From 8f15bb80b11a159292d7ab1e622af2fcb145cf79 Mon Sep 17 00:00:00 2001 From: Majid Manzarpour Date: Wed, 12 Aug 2026 00:20:56 -0400 Subject: [PATCH 1/9] feat(core): data-driven ability registry, spellbook UI, and a headless gate The sandbox enumerated its six abilities in eight places. That does not scale to a hundred, so abilities become data and every consumer derives from it. - registry.js is the single declaration site. ELEMENTS/ELEMENT_META are now derived views over it, so AimController, HUD and App were untouched. - Ability classes load lazily; settings modules stay eager, because the editor, the presets and DEFAULT_SETTINGS all need the whole tree at boot. - Each ability owns src/config/abilities/.js: its settings block and its editor schema. The six that shipped were moved verbatim and machine-verified byte-identical against HEAD. - The editor is schema-driven and builds an ability's controls on first open. Eager construction at 100 abilities cost 597ms and 107k DOM nodes at boot; it is now 7.7ms and 1.4k, with search indexing the schemas rather than the controllers so unbuilt controls are still findable. - Eight rebindable loadout slots over a Tab-key spellbook, persisted to localStorage. Fifty abilities do not fit on a keyboard. - scripts/check.mjs is the gate: it statically cross-checks every settings reference against its block, simulates every cast headlessly, and runs a pause test that mutates sliders on a zero-length frame and fails if nothing observable changes. That invariant is the whole character of this project, so it is now enforced rather than trusted. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + index.html | 7 + package-lock.json | 24 +- package.json | 4 +- scripts/.check-local.mjs | 2342 ++++++++++++++ scripts/check-vfx-distortion-portal.mjs | 360 +++ scripts/check-vfx-ink.mjs | 629 ++++ scripts/check-vfx-lensflare-mirror.mjs | 439 +++ scripts/check-vfx-tube-shell.mjs | 313 ++ scripts/check.mjs | 3939 +++++++++++++++++++++++ src/abilities/Ability.js | 56 + src/abilities/AbilityManager.js | 176 +- src/abilities/registry.js | 1375 ++++++++ src/config/abilities/afterimage.js | 276 ++ src/config/abilities/anvilfall.js | 478 +++ src/config/abilities/arcanevolley.js | 311 ++ src/config/abilities/astralgate.js | 473 +++ src/config/abilities/aurora.js | 299 ++ src/config/abilities/avalanche.js | 395 +++ src/config/abilities/balllightning.js | 533 +++ src/config/abilities/beam.js | 495 +++ src/config/abilities/blackice.js | 360 +++ src/config/abilities/bloomburst.js | 457 +++ src/config/abilities/bonecage.js | 392 +++ src/config/abilities/boulder.js | 340 ++ src/config/abilities/brinelock.js | 526 +++ src/config/abilities/broodburst.js | 503 +++ src/config/abilities/bubblecage.js | 408 +++ src/config/abilities/carapace.js | 312 ++ src/config/abilities/chainarc.js | 435 +++ src/config/abilities/chronofracture.js | 435 +++ src/config/abilities/crimsontide.js | 577 ++++ src/config/abilities/cyclone.js | 643 ++++ src/config/abilities/dawnbreak.js | 213 ++ src/config/abilities/dragonbreath.js | 425 +++ src/config/abilities/echostep.js | 312 ++ src/config/abilities/eclipse.js | 275 ++ src/config/abilities/emberflock.js | 392 +++ src/config/abilities/entropy.js | 321 ++ src/config/abilities/featherfall.js | 269 ++ src/config/abilities/firewalk.js | 390 +++ src/config/abilities/firewhip.js | 328 ++ src/config/abilities/gearlock.js | 427 +++ src/config/abilities/geyser.js | 590 ++++ src/config/abilities/glacier.js | 571 ++++ src/config/abilities/glyphstorm.js | 338 ++ src/config/abilities/godspear.js | 356 ++ src/config/abilities/grovecall.js | 468 +++ src/config/abilities/hail.js | 452 +++ src/config/abilities/hemolance.js | 334 ++ src/config/abilities/hivecolumn.js | 340 ++ src/config/abilities/hourglass.js | 474 +++ src/config/abilities/ice.js | 308 ++ src/config/abilities/index.js | 382 +++ src/config/abilities/inkbloom.js | 366 +++ src/config/abilities/locusttide.js | 516 +++ src/config/abilities/magma.js | 591 ++++ src/config/abilities/meteor.js | 508 +++ src/config/abilities/mirage.js | 190 ++ src/config/abilities/mycelium.js | 335 ++ src/config/abilities/nightfall.js | 346 ++ src/config/abilities/obsidian.js | 447 +++ src/config/abilities/origami.js | 402 +++ src/config/abilities/petrify.js | 339 ++ src/config/abilities/photonlattice.js | 222 ++ src/config/abilities/pistondrive.js | 478 +++ src/config/abilities/plaguebloom.js | 357 ++ src/config/abilities/prismlance.js | 671 ++++ src/config/abilities/pyroclasm.js | 406 +++ src/config/abilities/quench.js | 497 +++ src/config/abilities/railcoil.js | 398 +++ src/config/abilities/refractcascade.js | 383 +++ src/config/abilities/resonance.js | 355 ++ src/config/abilities/rewind.js | 360 +++ src/config/abilities/rime.js | 455 +++ src/config/abilities/runeseal.js | 490 +++ src/config/abilities/sanguinepact.js | 502 +++ src/config/abilities/sawline.js | 592 ++++ src/config/abilities/scrollward.js | 348 ++ src/config/abilities/sealscript.js | 370 +++ src/config/abilities/shatterlance.js | 433 +++ src/config/abilities/sheetlightning.js | 315 ++ src/config/abilities/shrapnel.js | 413 +++ src/config/abilities/silence.js | 177 + src/config/abilities/singularity.js | 587 ++++ src/config/abilities/sinkhole.js | 311 ++ src/config/abilities/skyfracture.js | 501 +++ src/config/abilities/slipstream.js | 436 +++ src/config/abilities/snare.js | 460 +++ src/config/abilities/solarlens.js | 610 ++++ src/config/abilities/soulchain.js | 375 +++ src/config/abilities/spellbreak.js | 427 +++ src/config/abilities/splatterbrand.js | 427 +++ src/config/abilities/sporefall.js | 310 ++ src/config/abilities/starfall.js | 381 +++ src/config/abilities/stasisfield.js | 336 ++ src/config/abilities/stonespine.js | 367 +++ src/config/abilities/stormwall.js | 473 +++ src/config/abilities/sumistroke.js | 387 +++ src/config/abilities/sunspear.js | 469 +++ src/config/abilities/tectonic.js | 334 ++ src/config/abilities/thornwake.js | 469 +++ src/config/abilities/thunder.js | 328 ++ src/config/abilities/thunderclap.js | 317 ++ src/config/abilities/tiderush.js | 452 +++ src/config/abilities/torrent.js | 407 +++ src/config/abilities/umbralspears.js | 333 ++ src/config/abilities/undertow.js | 496 +++ src/config/abilities/unmake.js | 399 +++ src/config/abilities/vinelash.js | 572 ++++ src/config/abilities/voidrift.js | 364 +++ src/config/abilities/waspfunnel.js | 566 ++++ src/config/abilities/webline.js | 377 +++ src/config/abilities/wildfire.js | 379 +++ src/config/castShape.js | 26 + src/config/settings.js | 1665 +--------- src/core/App.js | 127 +- src/core/FrameUniforms.js | 69 +- src/core/Layers.js | 34 + src/input/InputManager.js | 133 +- src/ui/Editor.js | 2063 +++++------- src/ui/HUD.js | 357 +- src/ui/Loadout.js | 256 ++ src/ui/Spellbook.js | 803 +++++ src/ui/glyphs.js | 116 - src/ui/glyphs/aether.js | 159 + src/ui/glyphs/arcane.js | 204 ++ src/ui/glyphs/blood.js | 126 + src/ui/glyphs/chrono.js | 187 ++ src/ui/glyphs/flame.js | 200 ++ src/ui/glyphs/forge.js | 165 + src/ui/glyphs/frost.js | 163 + src/ui/glyphs/hive.js | 160 + src/ui/glyphs/index.js | 78 + src/ui/glyphs/ink.js | 166 + src/ui/glyphs/lumen.js | 170 + src/ui/glyphs/stone.js | 134 + src/ui/glyphs/storm.js | 170 + src/ui/glyphs/tide.js | 178 + src/ui/glyphs/verdant.js | 135 + src/ui/glyphs/void.js | 159 + src/ui/glyphs/wrap.js | 32 + src/ui/styles.css | 854 ++++- 143 files changed, 57232 insertions(+), 3049 deletions(-) create mode 100644 scripts/.check-local.mjs create mode 100644 scripts/check-vfx-distortion-portal.mjs create mode 100644 scripts/check-vfx-ink.mjs create mode 100644 scripts/check-vfx-lensflare-mirror.mjs create mode 100644 scripts/check-vfx-tube-shell.mjs create mode 100644 scripts/check.mjs create mode 100644 src/abilities/registry.js create mode 100644 src/config/abilities/afterimage.js create mode 100644 src/config/abilities/anvilfall.js create mode 100644 src/config/abilities/arcanevolley.js create mode 100644 src/config/abilities/astralgate.js create mode 100644 src/config/abilities/aurora.js create mode 100644 src/config/abilities/avalanche.js create mode 100644 src/config/abilities/balllightning.js create mode 100644 src/config/abilities/beam.js create mode 100644 src/config/abilities/blackice.js create mode 100644 src/config/abilities/bloomburst.js create mode 100644 src/config/abilities/bonecage.js create mode 100644 src/config/abilities/boulder.js create mode 100644 src/config/abilities/brinelock.js create mode 100644 src/config/abilities/broodburst.js create mode 100644 src/config/abilities/bubblecage.js create mode 100644 src/config/abilities/carapace.js create mode 100644 src/config/abilities/chainarc.js create mode 100644 src/config/abilities/chronofracture.js create mode 100644 src/config/abilities/crimsontide.js create mode 100644 src/config/abilities/cyclone.js create mode 100644 src/config/abilities/dawnbreak.js create mode 100644 src/config/abilities/dragonbreath.js create mode 100644 src/config/abilities/echostep.js create mode 100644 src/config/abilities/eclipse.js create mode 100644 src/config/abilities/emberflock.js create mode 100644 src/config/abilities/entropy.js create mode 100644 src/config/abilities/featherfall.js create mode 100644 src/config/abilities/firewalk.js create mode 100644 src/config/abilities/firewhip.js create mode 100644 src/config/abilities/gearlock.js create mode 100644 src/config/abilities/geyser.js create mode 100644 src/config/abilities/glacier.js create mode 100644 src/config/abilities/glyphstorm.js create mode 100644 src/config/abilities/godspear.js create mode 100644 src/config/abilities/grovecall.js create mode 100644 src/config/abilities/hail.js create mode 100644 src/config/abilities/hemolance.js create mode 100644 src/config/abilities/hivecolumn.js create mode 100644 src/config/abilities/hourglass.js create mode 100644 src/config/abilities/ice.js create mode 100644 src/config/abilities/index.js create mode 100644 src/config/abilities/inkbloom.js create mode 100644 src/config/abilities/locusttide.js create mode 100644 src/config/abilities/magma.js create mode 100644 src/config/abilities/meteor.js create mode 100644 src/config/abilities/mirage.js create mode 100644 src/config/abilities/mycelium.js create mode 100644 src/config/abilities/nightfall.js create mode 100644 src/config/abilities/obsidian.js create mode 100644 src/config/abilities/origami.js create mode 100644 src/config/abilities/petrify.js create mode 100644 src/config/abilities/photonlattice.js create mode 100644 src/config/abilities/pistondrive.js create mode 100644 src/config/abilities/plaguebloom.js create mode 100644 src/config/abilities/prismlance.js create mode 100644 src/config/abilities/pyroclasm.js create mode 100644 src/config/abilities/quench.js create mode 100644 src/config/abilities/railcoil.js create mode 100644 src/config/abilities/refractcascade.js create mode 100644 src/config/abilities/resonance.js create mode 100644 src/config/abilities/rewind.js create mode 100644 src/config/abilities/rime.js create mode 100644 src/config/abilities/runeseal.js create mode 100644 src/config/abilities/sanguinepact.js create mode 100644 src/config/abilities/sawline.js create mode 100644 src/config/abilities/scrollward.js create mode 100644 src/config/abilities/sealscript.js create mode 100644 src/config/abilities/shatterlance.js create mode 100644 src/config/abilities/sheetlightning.js create mode 100644 src/config/abilities/shrapnel.js create mode 100644 src/config/abilities/silence.js create mode 100644 src/config/abilities/singularity.js create mode 100644 src/config/abilities/sinkhole.js create mode 100644 src/config/abilities/skyfracture.js create mode 100644 src/config/abilities/slipstream.js create mode 100644 src/config/abilities/snare.js create mode 100644 src/config/abilities/solarlens.js create mode 100644 src/config/abilities/soulchain.js create mode 100644 src/config/abilities/spellbreak.js create mode 100644 src/config/abilities/splatterbrand.js create mode 100644 src/config/abilities/sporefall.js create mode 100644 src/config/abilities/starfall.js create mode 100644 src/config/abilities/stasisfield.js create mode 100644 src/config/abilities/stonespine.js create mode 100644 src/config/abilities/stormwall.js create mode 100644 src/config/abilities/sumistroke.js create mode 100644 src/config/abilities/sunspear.js create mode 100644 src/config/abilities/tectonic.js create mode 100644 src/config/abilities/thornwake.js create mode 100644 src/config/abilities/thunder.js create mode 100644 src/config/abilities/thunderclap.js create mode 100644 src/config/abilities/tiderush.js create mode 100644 src/config/abilities/torrent.js create mode 100644 src/config/abilities/umbralspears.js create mode 100644 src/config/abilities/undertow.js create mode 100644 src/config/abilities/unmake.js create mode 100644 src/config/abilities/vinelash.js create mode 100644 src/config/abilities/voidrift.js create mode 100644 src/config/abilities/waspfunnel.js create mode 100644 src/config/abilities/webline.js create mode 100644 src/config/abilities/wildfire.js create mode 100644 src/config/castShape.js create mode 100644 src/ui/Loadout.js create mode 100644 src/ui/Spellbook.js delete mode 100644 src/ui/glyphs.js create mode 100644 src/ui/glyphs/aether.js create mode 100644 src/ui/glyphs/arcane.js create mode 100644 src/ui/glyphs/blood.js create mode 100644 src/ui/glyphs/chrono.js create mode 100644 src/ui/glyphs/flame.js create mode 100644 src/ui/glyphs/forge.js create mode 100644 src/ui/glyphs/frost.js create mode 100644 src/ui/glyphs/hive.js create mode 100644 src/ui/glyphs/index.js create mode 100644 src/ui/glyphs/ink.js create mode 100644 src/ui/glyphs/lumen.js create mode 100644 src/ui/glyphs/stone.js create mode 100644 src/ui/glyphs/storm.js create mode 100644 src/ui/glyphs/tide.js create mode 100644 src/ui/glyphs/verdant.js create mode 100644 src/ui/glyphs/void.js create mode 100644 src/ui/glyphs/wrap.js 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/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/scripts/.check-local.mjs b/scripts/.check-local.mjs new file mode 100644 index 0000000..43b79ce --- /dev/null +++ b/scripts/.check-local.mjs @@ -0,0 +1,2342 @@ +#!/usr/bin/env node +/** + * check.mjs — the headless verification harness (docs/EXPANSION.md §7). + * + * Forty-four abilities are about to be written by people who cannot see the + * screen this project renders to. This file is the thing that stands in for + * looking at it. It runs in plain Node with no WebGL, because three.js builds + * geometry, materials and scene graphs entirely on the CPU and every VFX system + * in this repo defers GL to render time — so an ability can be constructed, + * cast, ticked through all four phases and torn down without a canvas ever + * existing. + * + * Five passes, in increasing order of how much they cost and how much they find: + * + * 1. **Structure.** Every registered id has a settings block, a schema, a + * sigil and at most one keyboard letter; every block carries the five + * fields the framework indexes blind (`range`, `minRange`, `speed`, + * `cooldown`, `castAnim`, plus `zoneRadius` for a far cast) and the ranges + * are not nonsense. + * + * 2. **The static settings cross-check.** The highest-value pass in the file. + * Every source file under `src/` is lexed, every property read off a + * settings block is collected — `settings..`, `const c = + * settings.` then `c.`, `this.config.`, and destructuring + * from any of those — and each one is looked up in the block it names. A + * key that does not exist reads as `undefined`, multiplies into `NaN`, + * and produces geometry that silently vanishes with no error anywhere. + * That is the single most common way one of these abilities breaks, it + * costs an afternoon to find by eye, and it costs eight milliseconds to + * find here. + * + * The reverse direction — a key in the block that nothing ever reads — is + * a **warning**, never a failure, because a shared VFX module reads its + * configuration through a live object reference that no amount of + * grepping can attribute to an id. + * + * 3. **Runtime simulation.** Each ability is constructed against real + * subsystems on a bare `Scene`, cast, and ticked 240 frames at 1/60 with + * the frame clock driven forward and every subsystem flushed exactly as + * `App` flushes it. Any throw, any `NaN` or `Infinity` in a particle + * attribute array or a mesh transform, any mesh added to the group during + * a cast, any ability that never reaches `DONE` — all fatal. It is then + * destroyed and cast a *second* time, because the pooling contract says + * `destroy()` leaves the instance reusable and nothing else tests that. + * + * 4. **The pause test.** Invariant I1 has one observable consequence: stop + * the clock, drag a slider, and the standing effect changes. So we stop + * the clock (`dt = 0`, which is exactly what `App` feeds the manager when + * paused), snapshot every uniform, every transform and every instance + * matrix the ability owns, scale its numeric settings by 1.37, tick one + * zero-length frame, and demand that something moved. Twice — once + * mid-travel and once mid-impact — because an ability that resolves its + * dimensions during travel and then bakes them at impact passes the first + * sample and fails the second, and that is the exact shape of the mistake. + * + * When the ability is *stable* at rest (a zero-length frame with no + * mutation changes nothing) the harness goes further and probes each + * slider on its own, which turns "this ability responds" into "37 of its + * 54 sliders are observable while paused, and here are the seventeen that + * are not". Run with `--sliders` to see the list. + * + * 5. **Report.** One row per ability, then a detail section for the rows that + * failed. Non-zero exit on any failure. + * + * Usage: + * npm run check every registered ability + * npm run check -- --only ice one id, which is how you will actually use it + * npm run check -- --quiet the table and the failures, nothing else + * npm run check -- --sliders list the sliders that do nothing while paused + */ + +/* ------------------------------------------------------------------ */ +/* §0 · Browser shims — installed before a single project module loads */ +/* ------------------------------------------------------------------ */ +/** + * Nothing in `src/` needs a DOM today, and the harness would import cleanly + * without any of this. It is here for the ability that eventually reaches for + * `window.devicePixelRatio` at module scope, or for the three.js code path that + * decides it is in a browser and asks for a canvas: a missing global there + * throws during *import*, which reports as "the whole harness is broken" + * rather than "one module misbehaved". + * + * The stubs are deliberately inert. They exist to be present, not to work — a + * shim that half-works lets a module take a browser path in Node and fail two + * hundred lines later, which is worse than failing at the first call. + */ +const canvasStub = () => { + const context2d = { + canvas: null, + fillStyle: '#000', + strokeStyle: '#000', + globalAlpha: 1, + fillRect() {}, + clearRect() {}, + drawImage() {}, + getImageData: (_x, _y, w = 1, h = 1) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h }), + putImageData() {}, + createLinearGradient: () => ({ addColorStop() {} }), + createRadialGradient: () => ({ addColorStop() {} }), + measureText: () => ({ width: 0 }), + fillText() {}, + beginPath() {}, + closePath() {}, + moveTo() {}, + lineTo() {}, + arc() {}, + fill() {}, + stroke() {}, + save() {}, + restore() {}, + translate() {}, + scale() {}, + rotate() {} + }; + const element = { + width: 1, + height: 1, + style: {}, + className: '', + dataset: {}, + children: [], + getContext: (kind) => (kind === '2d' ? context2d : null), + toDataURL: () => 'data:,', + appendChild(child) { + this.children.push(child); + return child; + }, + removeChild() {}, + remove() {}, + setAttribute() {}, + getAttribute: () => null, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => true, + getBoundingClientRect: () => ({ x: 0, y: 0, width: 1, height: 1, top: 0, left: 0, right: 1, bottom: 1 }), + querySelector: () => null, + querySelectorAll: () => [] + }; + context2d.canvas = element; + return element; +}; + +if (typeof globalThis.window === 'undefined') { + const storage = new Map(); + globalThis.window = globalThis; + globalThis.self = globalThis; + globalThis.devicePixelRatio = 1; + globalThis.innerWidth = 1280; + globalThis.innerHeight = 720; + globalThis.addEventListener = () => {}; + globalThis.removeEventListener = () => {}; + globalThis.dispatchEvent = () => true; + globalThis.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); + globalThis.requestAnimationFrame = (fn) => setTimeout(() => fn(0), 16); + globalThis.cancelAnimationFrame = (handle) => clearTimeout(handle); + globalThis.localStorage = { + getItem: (key) => (storage.has(key) ? storage.get(key) : null), + setItem: (key, value) => void storage.set(key, String(value)), + removeItem: (key) => void storage.delete(key), + clear: () => storage.clear(), + key: (index) => [...storage.keys()][index] ?? null, + get length() { + return storage.size; + } + }; +} + +if (typeof globalThis.document === 'undefined') { + const root = canvasStub(); + globalThis.document = { + documentElement: root, + body: root, + head: root, + createElement: () => canvasStub(), + createElementNS: () => canvasStub(), + createTextNode: (text) => ({ nodeValue: String(text) }), + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + createEvent: () => ({ initEvent() {} }) + }; +} + +if (typeof globalThis.performance === 'undefined') { + globalThis.performance = { now: () => Number(process.hrtime.bigint() / 1000n) / 1000 }; +} + +/* ------------------------------------------------------------------ */ +/* §1 · Imports */ +/* ------------------------------------------------------------------ */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { + Scene, + Group, + PerspectiveCamera, + Vector3, + ConeGeometry, + IcosahedronGeometry, + TetrahedronGeometry, + MeshBasicMaterial, + MeshStandardMaterial +} from 'three'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, '..'); +const SRC = path.join(ROOT, 'src'); + +/* + * `config/settings.js` is imported FIRST, and the order is load-bearing. + * + * Several settings modules now spread a prefixed defaults fragment from the + * tech library (`shellDefaults`, `tubeDefaults`, `volumeHullDefaults`), and + * those library modules import `config/settings.js` for their `settings.global` + * default argument. That closes a cycle: + * + * registry → config/abilities/index → → vfx/Shell → config/settings + * → config/abilities/index (already evaluating: TDZ) + * + * Entering the cycle at `config/settings.js` instead is fine, because the only + * thing the library wants from it is a live binding it reads at call time, and + * by the time anything calls, everything is initialised. Entering at the + * registry is not: `settings.js` reaches `...ABILITY_SETTINGS` while + * `abilities/index.js` is still half-way down its own import list, and Node + * throws `Cannot access 'ABILITY_SETTINGS' before initialization`. The browser + * happens to enter through `settings.js` (via `core/Renderer.js`); the harness + * has to do the same deliberately. + */ +const { settings, CAST_ANIMATIONS, CastShape } = await import('../src/config/settings.js'); +await import('../src/config/settings.js'); +const { ABILITIES, SCHOOLS } = await import('../src/abilities/registry.js'); +const { ABILITY_SETTINGS, ABILITY_SCHEMAS } = await import('../src/config/abilities/index.js'); +const { AbilityPhase } = await import('../src/abilities/Ability.js'); +const { frame } = await import('../src/core/FrameUniforms.js'); +const { ParticleEngine } = await import('../src/particles/ParticleEngine.js'); +const { LightPool } = await import('../src/effects/LightPool.js'); +const { DecalSystem } = await import('../src/effects/GroundDecals.js'); +const { FissureSystem } = await import('../src/effects/GroundFissures.js'); +const { BurstSystem } = await import('../src/effects/BurstSphere.js'); +const { ELEMENT_SIGILS } = await import('../src/ui/glyphs/index.js'); +const { patchOnBeforeCompile } = await import('../src/utils/shaderPatch.js'); + +/* ------------------------------------------------------------------ */ +/* §2 · Arguments and reporting plumbing */ +/* ------------------------------------------------------------------ */ + +const argv = process.argv.slice(2); +const flag = (name) => argv.includes(name); +const value = (name) => { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : undefined; +}; + +const ONLY = value('--only'); +const QUIET = flag('--quiet'); +const SHOW_SLIDERS = flag('--sliders'); + +const COLOUR = process.stdout.isTTY && !process.env.NO_COLOR; +const paint = (code, text) => (COLOUR ? `[${code}m${text}` : text); +const red = (t) => paint('31', t); +const green = (t) => paint('32', t); +const yellow = (t) => paint('33', t); +const dim = (t) => paint('2', t); +const bold = (t) => paint('1', t); + +/** Simulation constants. Named because every one of them is a judgement call. */ +const DT = 1 / 60; // seconds per simulated frame +const REQUIRED_FRAMES = 240; // frames every cast is ticked for, minimum +const MAX_FRAMES = 900; // frames after which a cast that has not finished has hung +const REUSE_FRAMES = 60; // frames of the second cast, which tests the pooling contract +const NAN_STRIDE = 8; // scan the (large) particle arrays every Nth frame +const SANITY_RADIUS = 5000; // metres; a mesh further out than this is a broken transform +const DRAW_CALL_BUDGET = 12; // I7 — meshes in one ability's group +const MUTATION = 1.37; // the factor a slider is scaled by in the pause test +const CHANGE_EPSILON = 1e-6; // relative tolerance below which "it moved" is float noise + +/** Keys the framework indexes blind on every block, whether or not it reads them. */ +const CAST_KEYS = ['range', 'minRange', 'speed', 'cooldown', 'castAnim']; +/** Keys excluded from the pause mutation: they are consumed at spawn, not per frame. */ +const PAUSE_EXCLUDED = new Set(['range', 'minRange', 'speed', 'cooldown']); + +/** One of these per registered ability; the table is these rows. */ +class Report { + constructor(id) { + this.id = id; + this.failures = []; + this.warnings = []; + this.notes = []; + this.marks = { struct: null, static: null, runtime: null, travel: null, impact: null }; + } + + fail(stage, message) { + this.failures.push({ stage, message }); + if (stage in this.marks) this.marks[stage] = false; + return false; + } + + warn(stage, message) { + this.warnings.push({ stage, message }); + } + + note(message) { + this.notes.push(message); + } + + /** Mark a stage passed unless something already failed in it. */ + pass(stage) { + if (this.marks[stage] !== false) this.marks[stage] = true; + } + + get ok() { + return this.failures.length === 0; + } +} + +/** Failures that belong to the roster as a whole rather than to one ability. */ +const globalFailures = []; +const globalWarnings = []; +const failGlobal = (message) => globalFailures.push(message); +const warnGlobal = (message) => globalWarnings.push(message); + +const log = (...args) => { + if (!QUIET) console.log(...args); +}; + +/* ------------------------------------------------------------------ */ +/* §3 · Structure */ +/* ------------------------------------------------------------------ */ + +const ALL_IDS = ABILITIES.map((a) => a.id); +const TARGET = ONLY ? ABILITIES.filter((a) => a.id === ONLY) : ABILITIES; + +if (ONLY && TARGET.length === 0) { + console.error(red(`check: no registered ability with id "${ONLY}".`)); + console.error(dim(` known ids: ${ALL_IDS.join(', ')}`)); + process.exit(2); +} + +const reports = new Map(TARGET.map((a) => [a.id, new Report(a.id)])); +const SCHOOL_IDS = new Set(SCHOOLS.map((s) => s.id)); + +/** Registry-wide invariants: unique ids, unique keys, known schools. */ +function checkRoster() { + const seenIds = new Set(); + const seenKeys = new Map(); + + for (const descriptor of ABILITIES) { + if (seenIds.has(descriptor.id)) failGlobal(`duplicate registry id "${descriptor.id}"`); + seenIds.add(descriptor.id); + + if (descriptor.key) { + const key = String(descriptor.key).toUpperCase(); + if (seenKeys.has(key)) { + failGlobal(`key "${key}" is bound to both "${seenKeys.get(key)}" and "${descriptor.id}"`); + } + seenKeys.set(key, descriptor.id); + } + } + + // A settings block with no registry entry is a block nothing can ever cast. + for (const id of Object.keys(ABILITY_SETTINGS)) { + if (!seenIds.has(id)) warnGlobal(`settings block "${id}" is not registered in ABILITIES`); + } + for (const id of Object.keys(ABILITY_SCHEMAS)) { + if (!seenIds.has(id)) warnGlobal(`editor schema "${id}" is not registered in ABILITIES`); + } +} + +/** + * Everything about one descriptor that can be judged without running anything. + * + * The `zoneRadius` rule is the one worth stating: a far cast whose block has no + * `zoneRadius` still *aims* — `zoneRadiusOf` falls back to 0 — so the circle + * indicator collapses to a point and the ability lands on a footprint of + * nothing. It is a silent, plausible-looking failure, which is why it is + * checked here rather than trusted to review. + */ +function checkStructure(descriptor, report) { + const { id } = descriptor; + const block = settings[id]; + + if (!block || typeof block !== 'object') { + return report.fail('struct', `no settings block: settings.${id} is ${block}`); + } + if (ABILITY_SETTINGS[id] !== block) { + report.fail('struct', `settings.${id} is not the same object as ABILITY_SETTINGS.${id} — the editor will edit a copy`); + } + if (descriptor.settings !== block) { + report.fail('struct', `registry descriptor for "${id}" does not carry the live block`); + } + + if (!SCHOOL_IDS.has(descriptor.school)) { + report.fail('struct', `unknown school "${descriptor.school}"`); + } + if (!/^#[0-9a-f]{6}$/i.test(descriptor.accent ?? '')) { + report.fail('struct', `accent must be #rrggbb, got ${JSON.stringify(descriptor.accent)}`); + } + if (typeof descriptor.load !== 'function') { + report.fail('struct', 'descriptor.load is not a function'); + } + if (!descriptor.label || !descriptor.blurb) { + report.warn('struct', 'descriptor is missing a label or a blurb'); + } + + for (const key of CAST_KEYS) { + if (!(key in block)) report.fail('struct', `settings.${id}.${key} is missing`); + } + + const shape = descriptor.cast; + if (shape !== CastShape.LINE && shape !== CastShape.ZONE) { + report.fail('struct', `cast must be CastShape.LINE or CastShape.ZONE, got ${JSON.stringify(shape)}`); + } + if (shape === CastShape.ZONE && !('zoneRadius' in block)) { + report.fail('struct', `a ZONE cast needs settings.${id}.zoneRadius — without it the footprint is a point`); + } + if (shape === CastShape.LINE && 'zoneRadius' in block) { + report.warn('struct', 'a LINE cast carries zoneRadius, which nothing will draw'); + } + + if (!CAST_ANIMATIONS.includes(block.castAnim)) { + report.fail('struct', `castAnim ${JSON.stringify(block.castAnim)} is not one of ${CAST_ANIMATIONS.join(' / ')}`); + } + + const numeric = (key) => typeof block[key] === 'number' && Number.isFinite(block[key]); + for (const key of ['range', 'minRange', 'speed', 'cooldown']) { + if (key in block && !numeric(key)) report.fail('struct', `${key} must be a finite number, got ${block[key]}`); + } + if (numeric('range') && numeric('minRange') && block.minRange >= block.range) { + report.fail('struct', `minRange (${block.minRange} m) must be below range (${block.range} m)`); + } + if (numeric('range') && block.range <= 0) report.fail('struct', `range must be positive, got ${block.range} m`); + if (numeric('minRange') && block.minRange < 0) report.fail('struct', `minRange must not be negative, got ${block.minRange} m`); + if (numeric('speed') && block.speed <= 0) report.fail('struct', `speed must be positive, got ${block.speed} m/s`); + if (numeric('cooldown') && block.cooldown < 0) report.fail('struct', `cooldown must not be negative, got ${block.cooldown} s`); + if (shape === CastShape.ZONE && typeof block.zoneRadius === 'number' && block.zoneRadius <= 0) { + report.fail('struct', `zoneRadius must be positive, got ${block.zoneRadius} m`); + } + + // Colours are `#rrggbb` strings so lil-gui can bind them directly; anything + // else in a key named `color*` is a colour that will never get a picker. + for (const [key, entry] of Object.entries(block)) { + if (!/^colou?r/i.test(key)) continue; + if (typeof entry !== 'string' || !/^#[0-9a-f]{6}$/i.test(entry)) { + report.fail('struct', `${key} must be a "#rrggbb" string, got ${JSON.stringify(entry)}`); + } + } + + if (!ELEMENT_SIGILS[id]) { + report.fail('struct', `no sigil: ui/glyphs has no entry for "${id}"`); + } else if (!/]/i.test(ELEMENT_SIGILS[id])) { + report.fail('struct', 'sigil is not inline SVG markup'); + } + + checkSchema(id, block, report); + report.pass('struct'); + return report.ok; +} + +/** + * The editor schema names keys. A name that is not in the block builds a + * controller bound to `undefined`, which lil-gui renders as a dead row. + * + * A schema is allowed to be *incomplete* — unmentioned keys land in the + * trailing "More" folder by design — so the missing direction is a warning and + * only the wrong direction is a failure. + */ +function checkSchema(id, block, report) { + const schema = ABILITY_SCHEMAS[id]; + if (!schema || typeof schema !== 'object') { + return report.fail('struct', `no editor schema: ABILITY_SCHEMAS.${id} is missing`); + } + + const mentioned = new Set(); + for (const [folder, entries] of Object.entries(schema)) { + if (!Array.isArray(entries)) { + report.fail('struct', `schema folder "${folder}" is not an array`); + continue; + } + for (const entry of entries) { + const key = Array.isArray(entry) ? entry[0] : entry; + if (typeof key !== 'string') { + report.fail('struct', `schema folder "${folder}" holds an entry with no key: ${JSON.stringify(entry)}`); + continue; + } + if (key.endsWith('*')) { + // A gradient group: `colorMist*` stands for colorMistA/B/C/D. + const prefix = key.slice(0, -1); + for (const stop of ['A', 'B', 'C', 'D']) { + const full = prefix + stop; + mentioned.add(full); + if (!(full in block)) { + report.fail('struct', `schema gradient "${key}" in "${folder}" wants ${id}.${full}, which does not exist`); + } + } + continue; + } + mentioned.add(key); + if (!(key in block)) { + report.fail('struct', `schema key "${key}" in folder "${folder}" does not exist on settings.${id}`); + } + } + } + + const unfiled = Object.keys(block).filter((key) => !mentioned.has(key)); + if (unfiled.length) { + report.warn( + 'struct', + `${unfiled.length} key(s) not filed in the schema (they land in "More"): ${preview(unfiled)}` + ); + } + return true; +} + +/* ------------------------------------------------------------------ */ +/* §4 · The static settings cross-check */ +/* ------------------------------------------------------------------ */ + +/** + * Strip comments — and, by default, string literals — replacing them with + * spaces. + * + * Positions are preserved so a hit can still be reported with a line number. + * The first version of this pass ran the regexes over the raw source, and the + * very first thing it "found" was `settings.rime.plateSize` inside the doc + * comment of a module that did not exist yet — a wrong answer that reads as a + * right one, which is the worst kind for a tool people are meant to trust. + * + * `keepStrings` exists for exactly one caller: an ability declares which block + * `this.config` resolves to by writing `super('beam', context)`, and that id is + * a *string*. Reading it out of the fully stripped source finds `super( ,` and + * silently drops every aliased read in the file — which is how this harness + * first reported that Nova Beam touches eighty-two of its own settings when the + * real number is a hundred and sixty. + * + * Template literals resume code mode inside `${…}`, because a shader string is + * a template literal and the interpolations in it are real code. Regex + * literals are *not* handled: telling a regex from a division needs the parser + * we do not have, so a `/` is simply left alone. The failure mode of that + * choice is a missed read, never an invented one. + */ +function stripToCode(source, keepStrings = false) { + const out = new Array(source.length); + for (let i = 0; i < source.length; i++) out[i] = source[i]; + + const erase = (from, to) => { + for (let i = from; i < to && i < source.length; i++) { + if (source[i] !== '\n' && source[i] !== '\r') out[i] = ' '; + } + }; + /** Comments always go; string bodies stay when `keepStrings` is set. */ + const blank = keepStrings ? () => {} : erase; + + /** Template-literal nesting: each entry is the `${` depth of one template. */ + const templates = []; + let i = 0; + let braceDepth = 0; + + while (i < source.length) { + const c = source[i]; + const next = source[i + 1]; + + if (c === '/' && next === '/') { + let j = i; + while (j < source.length && source[j] !== '\n') j++; + erase(i, j); + i = j; + continue; + } + if (c === '/' && next === '*') { + let j = i + 2; + while (j < source.length && !(source[j] === '*' && source[j + 1] === '/')) j++; + erase(i, Math.min(j + 2, source.length)); + i = j + 2; + continue; + } + if (c === "'" || c === '"') { + let j = i + 1; + while (j < source.length && source[j] !== c) { + if (source[j] === '\\') j++; + if (source[j] === '\n') break; + j++; + } + blank(i, j + 1); + i = j + 1; + continue; + } + if (c === '`') { + // Walk the template, blanking its text but leaving `${…}` as code. + let j = i + 1; + blank(i, i + 1); + while (j < source.length) { + if (source[j] === '\\') { + blank(j, j + 2); + j += 2; + continue; + } + if (source[j] === '`') { + blank(j, j + 1); + j++; + break; + } + if (source[j] === '$' && source[j + 1] === '{') { + blank(j, j + 2); + // Hand control back to the outer loop for the interpolation body. + templates.push(braceDepth); + braceDepth++; + j += 2; + i = j; + break; + } + blank(j, j + 1); + j++; + } + if (j > i && (source[j - 1] === '`' || j >= source.length)) i = j; + else if (i !== j) i = j; + continue; + } + if (c === '{') braceDepth++; + if (c === '}') { + braceDepth--; + if (templates.length && braceDepth === templates[templates.length - 1]) { + // Closing a `${…}`: resume the template's text. + templates.pop(); + blank(i, i + 1); + let j = i + 1; + while (j < source.length) { + if (source[j] === '\\') { + blank(j, j + 2); + j += 2; + continue; + } + if (source[j] === '`') { + blank(j, j + 1); + j++; + break; + } + if (source[j] === '$' && source[j + 1] === '{') { + blank(j, j + 2); + templates.push(braceDepth); + braceDepth++; + j += 2; + break; + } + blank(j, j + 1); + j++; + } + i = j; + continue; + } + } + i++; + } + + return out.join(''); +} + +/** Every `.js` under `src/`, excluding the archive (it reads a dead settings tree). */ +function sourceFiles(dir, found = []) { + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry === 'archive' || entry === 'node_modules') continue; + sourceFiles(full, found); + } else if (entry.endsWith('.js')) { + found.push(full); + } + } + return found; +} + +const IDENT = '[A-Za-z_$][A-Za-z0-9_$]*'; + +/** + * Which local identifiers in a file are safe to treat as "an alias for a + * settings block". + * + * `const c = settings.beam` makes `c.` a settings read for the rest of + * that method — and in this codebase the same file will bind `c` to + * `this.config` in the next method, which is the same block by another name. + * What must never happen is treating `c.foo` as a settings read when some + * *third* binding put an unrelated object in `c`, because that invents a + * failure out of nothing. + * + * So the pass keeps two sets, and the difference between them is the whole + * trick: + * + * - **strict** — every binding of the name in the file is a settings binding + * *and* the name never appears in anything that parses as a parameter list. + * Reads through a strict alias are *required*: a key that is not in the + * block is a failure. + * - **loose** — the name is bound to a block somewhere, and is also a + * parameter somewhere. `IceAbility` threads its block through helpers as + * `_halfWidth(s, c)`, which is idiomatic here and would otherwise blind the + * pass to two thirds of that file. Reads through a loose alias are recorded + * as *optional*: they count toward "this key is used" and can never raise a + * failure, so the worst a wrong guess can do is make a warning quieter. + * + * Anything bound to a non-settings right-hand side anywhere in the file is out + * of both sets. That test is deliberately over-eager: its failure mode is + * dropping a real alias, which loses coverage, and never inventing a fake one, + * which loses trust. + */ +function collectAliases(code, configIds) { + const candidates = new Map(); // name -> Set(blockName) | null when polluted + + const bind = (name, block) => { + if (candidates.get(name) === null) return; + if (!candidates.has(name)) candidates.set(name, new Set()); + if (block === null) candidates.set(name, null); + else candidates.get(name).add(block); + }; + + // Declarations and plain assignments, settings-shaped or not. + const declaration = new RegExp( + `(?:\\b(?:const|let|var)\\s+(${IDENT})|(?:^|[;{}(),]\\s*)(${IDENT}))\\s*=\\s*(?!=)([^;\\n]*)`, + 'g' + ); + for (const match of code.matchAll(declaration)) { + const name = match[1] ?? match[2]; + const rhs = match[3].trim(); + const settingsBlock = rhs.match(new RegExp(`^settings\\s*\\.\\s*(${IDENT})\\s*;?$`)); + if (settingsBlock) { + bind(name, settingsBlock[1]); + continue; + } + if (/^this\s*\.\s*config\s*;?$/.test(rhs)) { + for (const id of configIds) bind(name, id); + continue; + } + bind(name, null); + } + + /** Names that appear in a parameter list: strict-disqualifying, loose-tolerated. */ + const parameters = new Set(); + const paramForms = [ + new RegExp(`\\bfunction\\s*${IDENT}?\\s*\\(([^)]*)\\)`, 'g'), + new RegExp(`(?:^|[;{}\\s])(?:async\\s+)?(?:get\\s+|set\\s+|static\\s+)*${IDENT}\\s*\\(([^)]*)\\)\\s*\\{`, 'g'), + /\(([^)]*)\)\s*=>/g, + new RegExp(`\\bcatch\\s*\\(\\s*(${IDENT})\\s*\\)`, 'g') + ]; + for (const pattern of paramForms) { + for (const match of code.matchAll(pattern)) { + for (const raw of match[1].split(',')) { + const name = raw.trim().replace(/^\.\.\./, '').split('=')[0].trim(); + if (new RegExp(`^${IDENT}$`).test(name)) parameters.add(name); + } + } + } + for (const match of code.matchAll(new RegExp(`(?:^|[^.\\w$])(${IDENT})\\s*=>`, 'g'))) { + parameters.add(match[1]); + } + + const strict = new Map(); // name -> [blockName], reads are required + const loose = new Map(); // name -> [blockName], reads are advisory only + for (const [name, blocks] of candidates) { + if (blocks === null || blocks.size === 0) continue; + (parameters.has(name) ? loose : strict).set(name, [...blocks]); + } + return { strict, loose }; +} + +/** Split a destructuring pattern body into the source keys it names. */ +function destructuredKeys(body) { + const keys = []; + let depth = 0; + let current = ''; + const flush = () => { + const piece = current.trim(); + current = ''; + if (!piece || piece.startsWith('...')) return; + const name = piece.split(':')[0].split('=')[0].trim(); + if (new RegExp(`^${IDENT}$`).test(name)) keys.push(name); + }; + for (const ch of body) { + if (ch === '{' || ch === '[' || ch === '(') depth++; + else if (ch === '}' || ch === ']' || ch === ')') depth--; + if (ch === ',' && depth === 0) flush(); + else current += ch; + } + flush(); + return keys; +} + +/** + * The whole static pass. + * + * Returns `{ reads, unknownBlocks }` where `reads` is `blockName -> key -> + * [{ file, line, optional }]`. `optional` marks a read written defensively — + * `this.config.zoneRadius ?? 1`, `c?.foo` — which counts as a read for the + * unused-key warning but does not make the key mandatory. The aim controller + * reads `zoneRadius` off whatever block is in the slot; a line cast is entitled + * not to have one. + */ +function scanSources() { + const reads = new Map(); + const unknownBlocks = []; + const blockNames = new Set(Object.keys(settings)); + + const record = (block, key, file, line, optional) => { + if (!reads.has(block)) reads.set(block, new Map()); + const table = reads.get(block); + if (!table.has(key)) table.set(key, []); + table.get(key).push({ file, line, optional }); + }; + + for (const file of sourceFiles(SRC)) { + const relative = path.relative(ROOT, file); + const raw = readFileSync(file, 'utf8'); + const code = stripToCode(raw); + const withStrings = stripToCode(raw, true); + + // Line numbers, computed once per file. + const lineStarts = [0]; + for (let i = 0; i < code.length; i++) if (code[i] === '\n') lineStarts.push(i + 1); + const lineAt = (index) => { + let low = 0; + let high = lineStarts.length - 1; + while (low < high) { + const mid = (low + high + 1) >> 1; + if (lineStarts[mid] <= index) low = mid; + else high = mid - 1; + } + return low + 1; + }; + + // `super('ice', context)` is how a class declares which block `this.config` + // resolves to. A file with no such call and a `this.config` read is either + // the base class or the aim controller: it reads whatever is in the slot, + // so its reads apply to *every* id. + const ownIds = [...withStrings.matchAll(new RegExp(`\\bsuper\\s*\\(\\s*['"\`](${IDENT})['"\`]\\s*[,)]`, 'g'))] + .map((m) => m[1]) + .filter((id) => blockNames.has(id)); + const superIds = [...new Set(ownIds)]; + const configIds = superIds.length ? superIds : ALL_IDS; + + const isOptional = (index, length) => { + const before = code.slice(Math.max(0, index - 2), index); + const after = code.slice(index + length, index + length + 12); + return before.endsWith('?.') || /^\s*\?\?/.test(after) || /^\s*\?\./.test(after); + }; + + /* --- settings.. --- */ + for (const match of code.matchAll(new RegExp(`\\bsettings\\s*\\??\\.\\s*(${IDENT})\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { + const [text, block, key] = match; + if (!blockNames.has(block)) { + unknownBlocks.push({ block, file: relative, line: lineAt(match.index) }); + continue; + } + record(block, key, relative, lineAt(match.index), isOptional(match.index, text.length)); + } + + /* --- settings[]. — the framework's blind index --- */ + for (const match of code.matchAll(new RegExp(`\\bsettings\\s*\\[[^\\]]+\\]\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { + // Always recorded as optional: `settings[element].zoneRadius` is read for + // whichever ability happens to be in the slot, and a line cast is + // entitled not to have one. + for (const id of ALL_IDS) record(id, match[1], relative, lineAt(match.index), true); + } + + /* --- this.config. --- */ + // A framework file's `this.config` is whichever ability is in the slot, so + // its reads land on every block — and they are *required* there, which is + // how `Ability.js` reading `cfg.lightColor` becomes a contract every one of + // the fifty blocks has to honour without anybody writing it down twice. The + // one genuinely optional read in that set, `this.config.zoneRadius ?? 1` in + // the aim controller, marks itself optional by being written defensively. + for (const match of code.matchAll(new RegExp(`\\bthis\\s*\\.\\s*config\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { + const optional = isOptional(match.index, match[0].length); + for (const id of configIds) record(id, match[1], relative, lineAt(match.index), optional); + } + + /* --- aliases: `const c = settings.beam` then `c.foo` --- */ + const { strict, loose } = collectAliases(code, configIds); + const aliases = new Map([...loose, ...strict]); + for (const [name, blocks] of aliases) { + const advisory = loose.has(name) && !strict.has(name); + const pattern = new RegExp(`(?:^|[^.\\w$])${name}\\s*\\??\\.\\s*(${IDENT})`, 'g'); + for (const match of code.matchAll(pattern)) { + const optional = advisory || isOptional(match.index, match[0].length); + for (const block of blocks) { + if (!blockNames.has(block)) continue; + record(block, match[1], relative, lineAt(match.index), optional); + } + } + } + + /* --- destructuring off a block, an alias, or this.config --- */ + const destructure = new RegExp( + `\\b(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*(this\\s*\\.\\s*config|settings\\s*\\.\\s*${IDENT}|${IDENT})`, + 'g' + ); + for (const match of code.matchAll(destructure)) { + const source = match[2].replace(/\s+/g, ''); + let blocks = null; + if (source === 'this.config') blocks = configIds; + else if (source.startsWith('settings.')) blocks = [source.slice('settings.'.length)]; + else if (aliases.has(source)) blocks = aliases.get(source); + if (!blocks) continue; + const line = lineAt(match.index); + for (const block of blocks) { + if (!blockNames.has(block)) { + if (source.startsWith('settings.')) unknownBlocks.push({ block, file: relative, line }); + continue; + } + const advisory = loose.has(source) && !strict.has(source); + for (const key of destructuredKeys(match[1])) { + record(block, key, relative, line, advisory); + } + } + } + } + + return { reads, unknownBlocks }; +} + +const STATIC = scanSources(); + +for (const { block, file, line } of STATIC.unknownBlocks) { + failGlobal(`${file}:${line} reads settings.${block}, which is not a settings block`); +} + +function checkStaticReads(descriptor, report) { + const { id } = descriptor; + const block = settings[id] ?? {}; + const table = STATIC.reads.get(id) ?? new Map(); + + /* --- reads with no key behind them: the NaN factory --- */ + const missing = []; + for (const [key, sites] of table) { + if (key in block) continue; + const required = sites.filter((site) => !site.optional); + if (required.length === 0) continue; // a guarded read of an absent key is fine + missing.push({ key, sites: required }); + } + missing.sort((a, b) => a.key.localeCompare(b.key)); + for (const { key, sites } of missing) { + const where = sites.slice(0, 3).map((s) => `${s.file}:${s.line}`).join(', '); + report.fail( + 'static', + `reads settings.${id}.${key}, which does not exist — undefined here becomes NaN geometry (${where}${sites.length > 3 ? ', …' : ''})` + ); + } + + /* --- keys nothing reads: a warning, and a soft one --- */ + const unread = Object.keys(block).filter((key) => !table.has(key)); + if (unread.length) { + report.warn( + 'static', + `${unread.length} settings key(s) with no visible read — check they are not dead, or that they are consumed through a shared VFX module: ${preview(unread)}` + ); + } + + // Counted against the block, not against the table: a loose alias can pick up + // `c.length` off something that is not this block at all, and a "reads 205 of + // its 161 keys" line is a number nobody can act on. + const realised = [...table.keys()].filter((key) => key in block).length; + report.note( + `${realised}/${Object.keys(block).length} settings key(s) read, across ${countSites(table)} site(s)` + ); + report.pass('static'); + return report.ok; +} + +const countSites = (table) => [...table.values()].reduce((total, sites) => total + sites.length, 0); +const preview = (list, limit = 8) => + list.slice(0, limit).join(', ') + (list.length > limit ? `, … (+${list.length - limit})` : ''); + +/* ------------------------------------------------------------------ */ +/* §5 · The mock context */ +/* ------------------------------------------------------------------ */ + +/** + * `shake` and `flash` are stubs because both of them reach outside the scene — + * one into the camera rig, the other into a DOM overlay — and neither has any + * bearing on whether an ability's geometry is sane. Everything else is the real + * subsystem, on a real `Scene`, because a mock `ParticleEngine` would not have + * caught a single one of the bugs this harness exists for. + */ +function makeContext() { + const scene = new Scene(); + const camera = new PerspectiveCamera(46, 16 / 9, 0.1, 400); + camera.position.set(0, 8, 12); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + + const noop = { add() {}, rumble() {}, trigger() {}, reset() {}, update() {} }; + + return { + scene, + camera, + /** + * `Environment` itself needs a live `Renderer`, so it is stood in for — but + * the two methods materials actually call are reproduced faithfully rather + * than stubbed to `noop`. `registerShadowCasterWithPatch` is what attaches + * an ability's `onBeforeCompile` hook, and a version of it that does nothing + * would quietly turn the harness into a test that ice and cinder *do not* + * patch their shaders — which is the opposite of the truth and the reason + * both of them threw on the first run of this file. + */ + environment: { + scene, + camera, + renderer: null, + envMap: null, + registerShadowCaster: (material) => material, + registerShadowCasterWithPatch: (material, patch) => patchOnBeforeCompile(material, patch), + setFocus() {}, + update() {} + }, + particles: new ParticleEngine(scene), + lights: new LightPool(scene), + decals: new DecalSystem(scene), + fissures: new FissureSystem(scene), + bursts: new BurstSystem(scene), + shake: noop, + flash: noop + }; +} + +/** + * Capture whatever three.js (or the ability) prints while a block of work runs. + * + * Not censorship — the opposite. A cast that triggers a three.js warning on + * every one of nine hundred frames buries the report under nine hundred + * identical lines, and the useful information ("this geometry is already + * non-indexed") is one line and a count. So they are collected, de-duplicated + * and attached to the ability that caused them. + */ +async function captureConsole(report, fn) { + const seen = new Map(); + const original = { warn: console.warn, error: console.error }; + const sink = (...args) => { + const message = args.map((a) => (a instanceof Error ? a.message : String(a))).join(' '); + seen.set(message, (seen.get(message) ?? 0) + 1); + }; + console.warn = sink; + console.error = sink; + try { + return await fn(); + } finally { + console.warn = original.warn; + console.error = original.error; + for (const [message, count] of seen) { + report.warn('runtime', `console: ${message}${count > 1 ? ` (×${count})` : ''}`); + } + } +} + +/** One simulated frame, in exactly the order `App.update` runs it. */ +function step(ctx, ability, dt) { + frame.uTime.value += dt; + frame.uDelta.value = dt; + ability.update(dt); + ctx.particles.flush(); + ctx.decals.update(dt); + ctx.fissures.update(dt); + ctx.bursts.update(dt); + ctx.lights.update(dt); +} + +/* ------------------------------------------------------------------ */ +/* §6 · Non-finite and sanity scanning */ +/* ------------------------------------------------------------------ */ + +/** + * Every uniform a material actually drives, from both places they hide. + * + * A `ShaderMaterial` keeps them on `material.uniforms`. A patched + * `MeshStandardMaterial` — Frost Lance's crystal, Cinder Fall's rock — cannot: + * its uniforms are handed to the shader inside `onBeforeCompile`, which never + * runs without a GL context, so the live boxes are parked on + * `material.userData.uniforms` by convention and `material.uniforms` does not + * exist at all. + * + * Missing that second location is not a small gap. It made the pause test + * blind to every value on a patched standard material, and the first run of + * this harness cheerfully reported that thirty of Frost Lance's shading + * sliders did nothing while paused. All thirty of them work. + */ +function* uniformsOf(material) { + if (!material) return; + if (material.uniforms) yield* Object.entries(material.uniforms); + if (material.userData?.uniforms) yield* Object.entries(material.userData.uniforms); +} + +function scanArray(array, label, out) { + for (let i = 0; i < array.length; i++) { + const v = array[i]; + if (!Number.isFinite(v)) { + out.push(`${label}[${i}] is ${Number.isNaN(v) ? 'NaN' : v}`); + return; // one report per array; a NaN never arrives alone + } + } +} + +function scanParticles(ctx, out) { + for (const system of ctx.particles.systems.values()) { + for (const [key, array] of Object.entries(system.data)) { + scanArray(array, `particles["${system.name}"].${key}`, out); + } + } +} + +function scanGroup(group, out) { + let index = 0; + group.traverse((object) => { + // The meshes in an ability group are usually unnamed, so the traversal + // index is the only handle a reader has on *which* of the three it was. + const name = `${object.name || object.type}#${index++}`; + for (const [label, vector] of [ + ['position', object.position], + ['scale', object.scale] + ]) { + if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { + out.push(`${name}.${label} is (${vector.x}, ${vector.y}, ${vector.z})`); + } + } + const q = object.quaternion; + if (!Number.isFinite(q.x) || !Number.isFinite(q.y) || !Number.isFinite(q.z) || !Number.isFinite(q.w)) { + out.push(`${name}.quaternion is non-finite`); + } + if (object.position.lengthSq() > SANITY_RADIUS * SANITY_RADIUS) { + out.push( + `${name}.position is ${object.position.length().toFixed(0)} m from the origin — beyond the ${SANITY_RADIUS} m sanity radius` + ); + } + + if (object.isInstancedMesh) { + scanArray(object.instanceMatrix.array, `${name}.instanceMatrix`, out); + if (object.instanceColor) scanArray(object.instanceColor.array, `${name}.instanceColor`, out); + } + if (object.geometry) { + for (const [attributeName, attribute] of Object.entries(object.geometry.attributes ?? {})) { + if (attribute.isInstancedBufferAttribute) { + scanArray(attribute.array, `${name}.${attributeName}`, out); + } + } + } + const materials = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of materials) { + for (const [uniformName, uniform] of uniformsOf(material)) { + const v = uniform?.value; + if (typeof v === 'number' && !Number.isFinite(v)) out.push(`${name}.${uniformName} is ${v}`); + else if (v?.isVector2 || v?.isVector3 || v?.isVector4 || v?.isQuaternion) { + if (!Number.isFinite(v.x) || !Number.isFinite(v.y) || !Number.isFinite(v.z ?? 0) || !Number.isFinite(v.w ?? 0)) { + out.push(`${name}.${uniformName} is non-finite`); + } + } else if (v?.isColor) { + if (!Number.isFinite(v.r) || !Number.isFinite(v.g) || !Number.isFinite(v.b)) { + out.push(`${name}.${uniformName} is a non-finite colour`); + } + } + } + } + }); +} + +/* ------------------------------------------------------------------ */ +/* §7 · Snapshots — what "something changed" means */ +/* ------------------------------------------------------------------ */ + +/** + * A snapshot is every number an ability owns that a viewer could see change: + * the transform of every object in its group, every numeric uniform on every + * material it draws with, every instance matrix and instanced attribute, and + * the uniforms of the particle systems it namespaces. + * + * Labels are built once per sample point and reused, because the pause test + * takes a few hundred snapshots per ability and building a few hundred + * thousand label strings to throw all of them away is the kind of thing that + * turns a two-second harness into a thirty-second one. + */ +/** djb2 over a uuid, so "the geometry object was replaced" is one number. */ +function hashString(text) { + let hash = 5381; + for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; + return hash; +} + +/** `[sum, index-weighted sum]` over a numeric array. */ +function checksum(array) { + let sum = 0; + let weighted = 0; + for (let i = 0; i < array.length; i++) { + const v = array[i]; + if (!Number.isFinite(v)) continue; + sum += v; + weighted += v * (i + 1); + } + return [sum, weighted]; +} + +class Snapshotter { + constructor(ability, ctx) { + this.ability = ability; + this.systems = [...ctx.particles.systems.values()].filter((s) => s.name.startsWith(`${ability.element}.`)); + this.labels = null; + } + + capture(withLabels = false) { + const values = []; + const labels = withLabels ? [] : null; + const push = (label, number) => { + values.push(number); + if (labels) labels.push(label); + }; + + const flatten = (label, v) => { + if (typeof v === 'number') push(label, v); + else if (typeof v === 'boolean') push(label, v ? 1 : 0); + else if (v == null) return; + else if (v.isColor) { + push(`${label}.r`, v.r); + push(`${label}.g`, v.g); + push(`${label}.b`, v.b); + } else if (v.isVector2 || v.isVector3 || v.isVector4 || v.isQuaternion) { + push(`${label}.x`, v.x); + push(`${label}.y`, v.y); + if (v.z !== undefined) push(`${label}.z`, v.z); + if (v.w !== undefined) push(`${label}.w`, v.w); + } else if (v.isEuler) { + push(`${label}.x`, v.x); + push(`${label}.y`, v.y); + push(`${label}.z`, v.z); + } else if (v.elements) { + for (let i = 0; i < v.elements.length; i++) push(`${label}[${i}]`, v.elements[i]); + } else if (ArrayBuffer.isView(v) || Array.isArray(v)) { + for (let i = 0; i < v.length; i++) { + const entry = v[i]; + if (typeof entry === 'number') { + push(`${label}[${i}]`, entry); + } else if (entry && (entry.isVector2 || entry.isVector3 || entry.isVector4 || entry.isColor)) { + // A uniform whose value is an **array of vectors**. This is not an + // exotic case: `vfx/FilamentPaths.js` keeps every role's entire + // geometry in six of them (`uFrom`, `uTo`, `uShape`, `uShape2`, + // `uStyle`, `uDraw`, indexed by role), and so does every ability + // built on it. Skipping them made this snapshot blind to the whole + // of such an ability's shape, and the pause test duly reported + // thirty perfectly live path sliders as dead — the same failure + // mode as the patched-`MeshStandardMaterial` gap, arrived at from + // the other direction. + flatten(`${label}[${i}]`, entry); + } + } + } + }; + + let index = 0; + this.ability.group.traverse((object) => { + const name = `${object.name || object.type}#${index++}`; + flatten(`${name}.position`, object.position); + flatten(`${name}.scale`, object.scale); + flatten(`${name}.quaternion`, object.quaternion); + push(`${name}.visible`, object.visible ? 1 : 0); + + if (object.isInstancedMesh) { + push(`${name}.count`, object.count); + flatten(`${name}.instanceMatrix`, object.instanceMatrix.array); + if (object.instanceColor) flatten(`${name}.instanceColor`, object.instanceColor.array); + } + if (object.geometry) { + const geometry = object.geometry; + push(`${name}.instanceCount`, geometry.instanceCount ?? -1); + push(`${name}.drawRange`, geometry.drawRange?.count ?? -1); + // A live rebuild swaps the whole object, so its identity is the cheapest + // and most direct evidence that `facets` or `roughness` did something. + push(`${name}.geometry.uuid`, hashString(geometry.uuid)); + for (const [attributeName, attribute] of Object.entries(geometry.attributes ?? {})) { + if (attribute.isInstancedBufferAttribute) { + flatten(`${name}.${attributeName}`, attribute.array); + } else { + // Per-vertex arrays are far too big to snapshot four hundred times, + // and nobody needs to know *which* vertex moved — only that the + // geometry was regenerated. Two checksums, one position-weighted so + // a permutation is not mistaken for a no-op. + const [sum, weighted] = checksum(attribute.array); + push(`${name}.${attributeName}.length`, attribute.array.length); + push(`${name}.${attributeName}.sum`, sum); + push(`${name}.${attributeName}.weighted`, weighted); + } + } + } + const materials = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of materials) { + if (!material) continue; + push(`${name}.opacity`, material.opacity); + push(`${name}.emissiveIntensity`, material.emissiveIntensity ?? 0); + flatten(`${name}.color`, material.color); + flatten(`${name}.emissive`, material.emissive); + for (const [uniformName, uniform] of uniformsOf(material)) { + flatten(`${name}.u.${uniformName}`, uniform?.value); + } + } + }); + + for (const system of this.systems) { + for (const [uniformName, uniform] of Object.entries(system.uniforms ?? {})) { + flatten(`${system.name}.${uniformName}`, uniform?.value); + } + } + + // The dynamic light is not in the group — it lives in the pool, in the + // scene — but it is unambiguously something the ability drives and the + // player sees, so `lightIntensity` and `lightRadius` are measured here + // rather than written off as unobservable. `light.intensity` itself is + // damped and therefore frozen on a zero-length frame; `target` is not. + const handle = this.ability.light; + if (handle) { + push('light.target', handle.target); + push('light.distance', handle.light.distance); + flatten('light.position', handle.light.position); + flatten('light.color', handle.light.color); + } + + if (labels) this.labels = labels; + return values; + } +} + +/** Indices at which two snapshots differ by more than float noise. */ +function diffIndices(a, b, limit = 6) { + if (a.length !== b.length) return ['']; + const changed = []; + for (let i = 0; i < a.length; i++) { + const scale = Math.max(1, Math.abs(a[i]), Math.abs(b[i])); + if (Math.abs(a[i] - b[i]) > CHANGE_EPSILON * scale) { + changed.push(i); + if (changed.length >= limit) break; + } + } + return changed; +} + +const didChange = (a, b) => diffIndices(a, b, 1).length > 0; + +/* ------------------------------------------------------------------ */ +/* §8 · Runtime simulation */ +/* ------------------------------------------------------------------ */ + +const _origin = new Vector3(0, 0, 0); +const _direction = new Vector3(0, 0, 1); + +/** A cast distance inside the ability's own declared reach. */ +function castDistance(block) { + const range = Number.isFinite(block.range) ? block.range : 10; + const minRange = Number.isFinite(block.minRange) ? block.minRange : 0; + return Math.max(minRange + 0.5, range * 0.8); +} + +async function runAbility(descriptor, report) { + const { id } = descriptor; + const block = settings[id]; + + let Type; + try { + Type = await descriptor.load(); + } catch (error) { + return report.fail('runtime', `load() rejected: ${describeError(error)}`); + } + if (typeof Type !== 'function') { + return report.fail('runtime', `load() resolved to ${typeof Type}, not a class — did you forget \`.then(m => m.XAbility)\`?`); + } + + const ctx = makeContext(); + frame.uTime.value = 0; + frame.uDelta.value = 0; + + let ability; + try { + ability = new Type(ctx); + ctx.scene.add(ability.group); + } catch (error) { + return report.fail('runtime', `constructor threw: ${describeError(error)}`); + } + + if (ability.element !== id) { + report.fail('runtime', `constructed with element "${ability.element}" but registered as "${id}"`); + } + + /* --- the namespacing rule: ".", or a shared system --- */ + for (const name of ctx.particles.systems.keys()) { + if (!name.startsWith(`${id}.`) && !name.startsWith('shared.')) { + report.warn('runtime', `particle system "${name}" is not namespaced "${id}." — it will collide with another ability`); + } + } + + const meshCount = countDrawables(ability.group); + if (meshCount > DRAW_CALL_BUDGET) { + report.warn('runtime', `${meshCount} drawable(s) in the group; the budget in §0/I7 is ${DRAW_CALL_BUDGET}`); + } + report.note(`${meshCount} drawable(s), ${ctx.particles.systems.size} particle system(s)`); + + const distance = castDistance(block); + const problems = []; + + /* ---------------- first cast: the full run ---------------- */ + try { + ability.spawn(_origin, _direction, distance); + } catch (error) { + return report.fail('runtime', `spawn() threw: ${describeError(error)}`); + } + + const childrenAtSpawn = countDrawables(ability.group); + const systemsAtSpawn = ctx.particles.systems.size; + + let frames = 0; + let finishedAt = -1; + const seenPhases = new Set([ability.phase]); + try { + while (frames < MAX_FRAMES) { + step(ctx, ability, DT); + frames++; + seenPhases.add(ability.phase); + if (frames % NAN_STRIDE === 0 || ability.isFinished) { + scanParticles(ctx, problems); + } + scanGroup(ability.group, problems); + if (problems.length) break; + if (ability.isFinished) { + finishedAt = frames; + if (frames >= REQUIRED_FRAMES) break; + } + if (finishedAt >= 0 && frames >= REQUIRED_FRAMES) break; + } + } catch (error) { + return report.fail('runtime', `threw on frame ${frames + 1} (phase ${ability.phase}): ${describeError(error)}`); + } + + for (const problem of problems.slice(0, 5)) { + report.fail('runtime', `non-finite value after ${frames} frame(s): ${problem}`); + } + + if (finishedAt < 0 && problems.length) { + // The loop was cut short by the NaN above; "it never finished" is that + // failure wearing a second hat, and reporting both sends people hunting for + // a timing bug that is not there. + report.note('the tick loop was stopped early by the non-finite value above'); + } else if (finishedAt < 0) { + report.fail( + 'runtime', + `never reached DONE in ${MAX_FRAMES} frames (${(MAX_FRAMES * DT).toFixed(1)} s) — stuck in ${ability.phase}; ` + + `check impactDuration (${safeNumber(ability.impactDuration)} s) and fadeDuration (${safeNumber(ability.fadeDuration)} s)` + ); + } else { + report.note(`cast ran ${finishedAt} frames (${(finishedAt * DT).toFixed(2)} s) to DONE`); + } + + for (const phase of [AbilityPhase.TRAVEL, AbilityPhase.IMPACT, AbilityPhase.FADE]) { + if (!seenPhases.has(phase)) report.warn('runtime', `never entered the ${phase.toUpperCase()} phase`); + } + + const childrenAtEnd = countDrawables(ability.group); + if (childrenAtEnd !== childrenAtSpawn) { + report.fail( + 'runtime', + `the group grew from ${childrenAtSpawn} to ${childrenAtEnd} drawable(s) during the cast — I3 says nothing is built during a cast` + ); + } + if (ctx.particles.systems.size !== systemsAtSpawn) { + report.fail( + 'runtime', + `${ctx.particles.systems.size - systemsAtSpawn} particle system(s) created mid-cast — build them in createParticles()` + ); + } + + /* ---------------- destroy, then cast again ---------------- */ + try { + ability.destroy(); + } catch (error) { + return report.fail('runtime', `destroy() threw: ${describeError(error)}`); + } + if (ability.phase !== AbilityPhase.IDLE) { + report.fail('runtime', `destroy() left the instance in phase ${ability.phase}, not IDLE — the pool will hand out a live cast`); + } + + try { + ability.spawn(_origin, _direction, distance); + for (let i = 0; i < REUSE_FRAMES; i++) step(ctx, ability, DT); + } catch (error) { + return report.fail('runtime', `the second cast threw — destroy() did not leave the instance reusable: ${describeError(error)}`); + } + const reuseProblems = []; + scanParticles(ctx, reuseProblems); + scanGroup(ability.group, reuseProblems); + for (const problem of reuseProblems.slice(0, 3)) { + report.fail('runtime', `non-finite value on the second cast: ${problem}`); + } + ability.destroy(); + + report.pass('runtime'); + + /* ---------------- the pause test, on a fresh cast ---------------- */ + runPauseTest(descriptor, report, ctx, ability); + + try { + ability.dispose?.(); + ctx.particles.dispose(); + ctx.decals.dispose(); + ctx.fissures.dispose(); + ctx.bursts.dispose(); + ctx.lights.dispose(); + } catch (error) { + report.warn('runtime', `teardown threw: ${describeError(error)}`); + } + + return report.ok; +} + +function countDrawables(group) { + let total = 0; + group.traverse((object) => { + if (object.isMesh || object.isPoints || object.isLine || object.isSprite) total++; + }); + return total; +} + +/* ------------------------------------------------------------------ */ +/* §9 · The pause test (invariant I1) */ +/* ------------------------------------------------------------------ */ + +/** + * Two sample points, chosen because they are the two beats where the mistake + * lives: + * + * - **mid-travel**, when the front is halfway out and everything is being + * resolved from settings because it has to be; + * - **mid-impact**, when the effect is standing still and it is very tempting + * to have cached the metres it stood up with. + * + * At each one we stop the clock and feed the ability zero-length frames, which + * is precisely what `App` does when you press **P**. If scaling every dimension + * on the block by 1.37 does not move a single number the ability owns, the + * effect on screen would not move either, and the ability is not done. + */ +function runPauseTest(descriptor, report, ctx, ability) { + const { id } = descriptor; + const block = settings[id]; + const distance = castDistance(block); + + const mutable = Object.keys(block).filter( + (key) => typeof block[key] === 'number' && Number.isFinite(block[key]) && block[key] !== 0 && !PAUSE_EXCLUDED.has(key) + ); + if (mutable.length === 0) { + report.fail('travel', 'no non-zero numeric settings to mutate — every dimension must be a slider (I5)'); + report.fail('impact', 'no non-zero numeric settings to mutate'); + return; + } + + for (const sample of ['travel', 'impact']) { + let outcome; + try { + outcome = probeSample(ctx, ability, block, mutable, sample, distance); + } catch (error) { + report.fail(sample, `threw while probing at ${sample}: ${describeError(error)}`); + continue; + } + + if (outcome.skipped) { + report.fail(sample, outcome.skipped); + continue; + } + + if (!outcome.restless) { + report.note( + `${sample}: ${outcome.responsive.length}/${mutable.length} slider(s) observable while paused` + + (outcome.dead.length && SHOW_SLIDERS ? `\n dead here: ${outcome.dead.join(', ')}` : '') + ); + } else { + report.warn( + sample, + 'the ability is not stable at rest — a zero-length frame with nothing changed already moves it, ' + + 'so per-slider attribution is unavailable (usually Math.random() called from the per-frame sync). ' + + `First to drift: ${outcome.driftLabels.join(', ')}` + ); + } + + if (outcome.changed) report.pass(sample); + else { + report.fail( + sample, + `nothing observable changed at ${sample} when all ${mutable.length} numeric slider(s) were scaled by ${MUTATION} on a zero-length frame — ` + + 'invariant I1: every metre, radian and second must be re-resolved from settings every frame, including a paused one' + ); + } + } +} + +/** + * Run one sample point. Returns + * `{ changed, restless, driftLabels[], responsive[], dead[], skipped? }`. + */ +function probeSample(ctx, ability, block, mutable, sample, distance) { + ability.destroy(); + frame.uTime.value = 0; + ability.spawn(_origin, _direction, distance); + + /* --- tick to the sample point --- */ + let frames = 0; + if (sample === 'travel') { + // Half-way down the line, still travelling. An ability that buys a wind-up + // by refusing to advance (Nova Beam) sits at u = 0 for a while first, so + // this waits on `u`, not on a frame count. + while (frames < MAX_FRAMES && !(ability.phase === AbilityPhase.TRAVEL && ability.u >= 0.5)) { + step(ctx, ability, DT); + frames++; + if (ability.phase !== AbilityPhase.TRAVEL) break; + } + if (ability.phase !== AbilityPhase.TRAVEL) { + return { skipped: `the cast left TRAVEL before reaching the half-way point (phase ${ability.phase} after ${frames} frames)` }; + } + } else { + while (frames < MAX_FRAMES && ability.phase === AbilityPhase.TRAVEL) { + step(ctx, ability, DT); + frames++; + } + const half = Math.max(1, Math.round((ability.impactDuration * 0.5) / DT)); + for (let i = 0; i < half && ability.phase === AbilityPhase.IMPACT; i++) { + step(ctx, ability, DT); + frames++; + } + if (ability.phase !== AbilityPhase.IMPACT && ability.phase !== AbilityPhase.FADE) { + return { skipped: `the cast was already ${ability.phase} at the mid-impact sample (after ${frames} frames)` }; + } + } + + const snapshotter = new Snapshotter(ability, ctx); + + /* --- let the clock-stopped state settle --- */ + // The first zero-length frame after a real one is not idle: an impact punch + // is still decaying out of `lightBoost`, an eased value is still a step from + // its target. That is settling, not a slider responding, and mistaking one + // for the other would report every ability in the project as "restless". + // Three frames is enough for everything in the repo; the check below proves + // it rather than assuming it. + for (let i = 0; i < 3; i++) ability.update(0); + + /* --- is it stable at rest? --- */ + const before = snapshotter.capture(true); + ability.update(0); + const baseline = snapshotter.capture(); + const drift = diffIndices(before, baseline, 4); + const restless = drift.length > 0; + const driftLabels = drift.map((i) => (typeof i === 'number' ? snapshotter.labels[i] : i)); + + /* --- the gate: scale every slider at once --- */ + const originals = mutable.map((key) => block[key]); + for (const key of mutable) block[key] *= MUTATION; + ability.update(0); + const mutated = snapshotter.capture(); + const changed = didChange(baseline, mutated); + mutable.forEach((key, i) => { + block[key] = originals[i]; + }); + ability.update(0); + + /* --- attribution, one slider at a time --- */ + const responsive = []; + const dead = []; + if (!restless) { + for (let i = 0; i < mutable.length; i++) { + const key = mutable[i]; + const rest = snapshotter.capture(); + block[key] *= MUTATION; + ability.update(0); + const probe = snapshotter.capture(); + block[key] = originals[i]; + ability.update(0); + if (didChange(rest, probe)) responsive.push(key); + else dead.push(key); + } + } + + ability.destroy(); + return { changed, restless, driftLabels, responsive, dead }; +} + +/* ------------------------------------------------------------------ */ +/* §10 · The vfx tech library smoke test */ +/* ------------------------------------------------------------------ */ +/** + * Construct every exported class in `src/vfx/`, drive it, and take it apart + * again. + * + * The ability stages above only see a module in `src/vfx/` if an ability + * imports it, and on the day this was written not one of them did: fourteen + * modules, some of them seventeen hundred lines, and the entire harness walked + * straight past them. The twenty-two agents about to build abilities on top of + * this library would each have discovered its breakages personally, in a + * browser, as a blank screen. + * + * So this stage does the least that is still worth doing. It is not a test of + * whether a `FUNNEL` looks like a vortex — nothing without a GL context can + * tell you that, and every module's author ran their own harness for it. It + * asks four questions that a blank screen answers too late: + * + * 1. Does it **import**? A reserved word in a shader (`packed`, `flat`) or a + * backtick inside a GLSL comment is a syntax error at module scope, and + * three of those have already been found in this library by hand. + * 2. Does it **construct** with the options its own doc comment advertises? + * 3. Does driving it for a few frames — including a zero-length one — leave a + * NaN in a uniform, an instance matrix or a transform? A NaN uniform does + * not render wrongly, it renders *nothing*, silently. + * 4. Does `dispose()` run without throwing? + * + * The coverage rule at the end is the part that keeps this honest: every + * exported class in every file under `src/vfx/` must appear in `VFX_CASES`. A + * fifteenth module lands with no recipe and this stage fails, naming it. That + * is deliberate — an untested module in a shared library is worse than no + * module, because someone will build on it. + */ + +const VFX_DIR = path.join(SRC, 'vfx'); + +/** Cases are built lazily so one module failing to import cannot stop the rest. */ +const vfx = {}; + +/** + * Load every module under `src/vfx/` once, keeping the failures rather than + * throwing on the first one — a broken module should cost its own line in the + * report, not the whole stage. + */ +async function loadVfxModules() { + const modules = new Map(); + const names = readdirSync(VFX_DIR) + .filter((name) => name.endsWith('.js')) + .sort(); + for (const name of names) { + try { + modules.set(name, await import(path.join(VFX_DIR, name))); + } catch (error) { + modules.set(name, { __error: error }); + } + } + return modules; +} + +const VFX_MODULES = await loadVfxModules(); + +/** Shorthand: a module's export, or `undefined` if the module did not load. */ +const vx = (file, name) => VFX_MODULES.get(file)?.[name]; + +/* --- the shared props every case borrows ---------------------------- */ + +const _from = new Vector3(0, 0.2, 0); +const _to = new Vector3(0, 0.2, 12); +const _side = new Vector3(1, 0, 0); +const _up = new Vector3(0, 1, 0); +const _dirZ = new Vector3(0, 0, 1); + +/** A body geometry factory. Modules that own their geometry demand a factory. */ +const shardFactory = () => new TetrahedronGeometry(0.22, 0); +const spikeFactory = () => new ConeGeometry(0.5, 1, 5, 1, true); + +/** + * A case is `{ file, label, build }`, where `build` returns the handle this + * stage drives. `node` is whatever Object3D the module hung its meshes on — + * `scanGroup` walks it, so a group is as good as a mesh. + */ +function vfxCases() { + const cases = []; + const add = (file, label, build) => cases.push({ file, label, build }); + + /* --- GrowthField ------------------------------------------------- */ + const { GrowthField, GrowthLayout, GrowthEmerge, growthParams, patchGrowthMaterial } = + VFX_MODULES.get('GrowthField.js') ?? {}; + for (const [layoutName, layout] of [ + ['LINE', GrowthLayout?.LINE], + ['ZONE', GrowthLayout?.ZONE] + ]) { + add('GrowthField.js', `GrowthField ${layoutName}`, () => { + const parent = new Group(); + const material = patchGrowthMaterial(new MeshStandardMaterial(), {}); + const field = new GrowthField(parent, { + geometry: spikeFactory, + material, + variants: 2, + capacity: 48 + }); + const p = growthParams(); + p.layout = layout; + p.emerge = GrowthEmerge?.PUSH ?? 0; + field.plant(32, 0.25); + field.triggerAll(0, 0.4); + return { + node: parent, + drawCalls: () => field.drawCalls, + tick: (now) => field.update(now, p), + dispose: () => { + field.dispose(); + material.dispose(); + } + }; + }); + } + + /* --- ShatterField ------------------------------------------------- */ + const { ShatterField, shatterParams } = VFX_MODULES.get('ShatterField.js') ?? {}; + add('ShatterField.js', 'ShatterField', () => { + const parent = new Group(); + const field = new ShatterField(parent, { geometry: shardFactory, variants: 2, capacity: 64 }); + const p = shatterParams(); + field.sync(p); + field.burst(0, 40, 1, 0.6); + return { + node: parent, + drawCalls: () => field.drawCalls, + tick: (now) => field.update(now, p), + dispose: () => field.dispose() + }; + }); + + /* --- FilamentPaths: every path mode through one strip -------------- */ + const { FilamentPaths, PathMode, filamentLook } = VFX_MODULES.get('FilamentPaths.js') ?? {}; + add('FilamentPaths.js', 'FilamentPaths (9 path modes)', () => { + const parent = new Group(); + const paths = new FilamentPaths(parent, { samples: 32, capacity: 16 }); + const look = filamentLook(); + paths.setNodeCount(6); + for (let i = 0; i < 6; i++) paths.setNode(i, i / 5, (i % 2 ? 0.2 : -0.2), 0.1); + // One role per frame cycles through all nine modes over the tick loop, so + // every parametric path gets its uniforms written at least once. + const modes = Object.values(PathMode ?? {}); + return { + node: parent, + drawCalls: () => paths.drawCalls, + tick: (now, index) => { + const role = paths.role(index % 4); + role.count = 5; + role.style(0.3, 0.1, 0.5, 0.2); + role.ends(0.05, 0.95, 0.1, 0.9); + role.draw(2, 0.1, 0, 1.5); + switch (modes[index % modes.length]) { + case PathMode.HELIX: role.helix(_from, _to, 0.6, 0.3, 3, 1, 0.2, 0.4, 1.2); break; + case PathMode.ORBIT: role.orbit(_to, _up, 1.4, 1, 1.2, 0.3, 0.2, 0.4, 0.2); break; + case PathMode.MEANDER: role.meander(_to, _up, 0.4, 2.2, 1.1, 0.5, 0.3, 0.4, 0.6); break; + case PathMode.RIM: role.rim(_to, _up, 2.4, 1.6, 0.8, 0.3, 0.2, 0.3, 0.1); break; + case PathMode.CHAIN: role.chain(_from, _to, 0.4, 0.3, 0.2, 0.3, 3, 0.4, 0.2, 0.6); break; + case PathMode.LINK: role.link(_from, _to, 0.3, 0.6, 0.2, 1.1, 0.4, 0.3); break; + case PathMode.SPIRAL_IN: role.spiralIn(_from, _to, 1.2, 0.1, 2.5, 1, 1.3, 0.3, 0.2); break; + case PathMode.CRACK: role.crack(_from, _to, 0.5, 0.7, 0.6, 0.4, 0.1, 0.2, 0.5); break; + default: role.line(_from, _to, 0.2, 0.1, 0.4, 1.2, 0.3, 1.1, 0.2); + } + paths.sync(look, 1, now); + }, + dispose: () => paths.dispose() + }; + }); + + /* --- ArcNetwork ---------------------------------------------------- */ + const { ArcNetwork, arcNetworkParams } = VFX_MODULES.get('ArcNetwork.js') ?? {}; + add('ArcNetwork.js', 'ArcNetwork', () => { + const parent = new Group(); + const net = new ArcNetwork(parent, { samples: 48, capacity: 12 }); + const p = arcNetworkParams(); + net.from.copy(_from); + net.to.copy(_to); + net.reset(3); + return { + node: parent, + drawCalls: () => net.drawCalls, + tick: (now, index, dt) => net.update(dt, p, 1), + dispose: () => net.dispose() + }; + }); + + /* --- GroundField: all ten modes ------------------------------------ */ + const { GroundField, GroundMode, GROUND_MODE_NAMES, groundFieldParams } = + VFX_MODULES.get('GroundField.js') ?? {}; + for (const [name, mode] of Object.entries(GroundMode ?? {})) { + add('GroundField.js', `GroundField ${name}`, () => { + const parent = new Group(); + const field = new GroundField(parent, { mode, marks: 8 }); + const p = groundFieldParams(); + field.mark(0.3, -0.2, 0, 1); + field.mark(-0.5, 0.4, 0.2, 0.6); + return { + node: parent, + drawCalls: () => field.drawCalls, + tick: () => field.update(p), + dispose: () => field.dispose() + }; + }); + } + + /* --- VolumeHull: every hull, every medium -------------------------- */ + const { VolumeHull, HullShape, Medium, HULL_NAMES, MEDIUM_NAMES, volumeHullDefaults } = + VFX_MODULES.get('VolumeHull.js') ?? {}; + const hullCombos = []; + for (const hull of Object.values(HullShape ?? {})) hullCombos.push([hull, Medium?.FLAME ?? 0]); + for (const medium of Object.values(Medium ?? {})) hullCombos.push([HullShape?.BOX ?? 0, medium]); + for (const [hull, medium] of hullCombos) { + const label = `VolumeHull ${HULL_NAMES?.[hull]}/${MEDIUM_NAMES?.[medium]}`; + add('VolumeHull.js', label, () => { + const parent = new Group(); + const prefix = 'vol'; + const hullObject = new VolumeHull({ hull, medium, prefix, maxSteps: 12 }); + parent.add(hullObject.mesh); + const c = volumeHullDefaults(prefix, medium); + hullObject.place(_to, _dirZ).setSize(2, 3, 2).setFade(1); + return { + node: parent, + drawCalls: () => 1, + tick: () => hullObject.sync(c, settings.global), + dispose: () => hullObject.dispose() + }; + }); + } + + /* --- Tube: every path ---------------------------------------------- */ + const { Tube, TubePath, TUBE_PATH_NAMES, tubeDefaults } = VFX_MODULES.get('Tube.js') ?? {}; + for (const path of Object.values(TubePath ?? {})) { + add('Tube.js', `Tube ${TUBE_PATH_NAMES?.[path]}`, () => { + const parent = new Group(); + const tube = new Tube({ path, prefix: 'tube', nodes: 32, sides: 10 }); + parent.add(tube.group); + const c = tubeDefaults('tube', path); + const state = { + origin: _from, target: _to, side: _side, + progress: 0.6, fade: 1, widthFade: 1, seed: 4, time: 0, grow: 1, snapAge: 0 + }; + return { + node: parent, + drawCalls: () => tube.drawCalls, + tick: (now) => { + state.time = now; + tube.sync(c, state, settings.global); + // The crack is recomputed by sync(); poll it as an ability would. + void tube.crack.fired; + void tube.radiusAt(0.5); + }, + dispose: () => tube.dispose() + }; + }); + } + + /* --- Shell: every mode --------------------------------------------- */ + const { Shell, ShellMode, SHELL_MODE_NAMES, shellDefaults } = VFX_MODULES.get('Shell.js') ?? {}; + for (const mode of Object.values(ShellMode ?? {})) { + add('Shell.js', `Shell ${SHELL_MODE_NAMES?.[mode]}`, () => { + const parent = new Group(); + const shell = new Shell({ mode, prefix: 'shell', nodes: 16, sides: 16, rings: 6, segments: 32 }); + parent.add(shell.group); + const c = shellDefaults('shell', mode); + const state = { origin: _to, axis: _up, side: _side, span: 6, t: 0.4, fade: 1, seed: 2 }; + return { + node: parent, + drawCalls: () => shell.drawCalls, + tick: (now, index) => { + state.t = Math.min(1, index / 8); + shell.sync(c, state, settings.global); + void shell.standingAt(0.5); + }, + dispose: () => shell.dispose() + }; + }); + } + + /* --- BurstSystem, re-exported by Shell.js -------------------------- */ + const { BurstSystem, BurstMode } = VFX_MODULES.get('Shell.js') ?? {}; + add('Shell.js', 'BurstSystem (re-export)', () => { + const scene = new Group(); + const bursts = new BurstSystem(scene); + for (const mode of Object.values(BurstMode ?? {})) bursts.spawn(mode, _to, { life: 0.4 }); + return { + node: scene, + drawCalls: () => 1, + tick: (now, index, dt) => bursts.update(dt), + dispose: () => bursts.dispose?.() + }; + }); + + /* --- Projectile: every flight mode --------------------------------- */ + const { Projectile, FlightMode, projectileParams } = VFX_MODULES.get('Projectile.js') ?? {}; + const flights = Object.values(FlightMode ?? {}); + add('Projectile.js', `Projectile (${flights.length} flight modes)`, () => { + const parent = new Group(); + const material = new MeshBasicMaterial(); + const body = new Projectile(parent, { + geometry: () => new IcosahedronGeometry(0.3, 0), + material, + capacity: 12, + trail: true, + trailNodes: 12 + }); + const p = projectileParams(); + body.setBasis(_from, _dirZ, _side, 12); + body.roll(7); + return { + node: parent, + drawCalls: () => body.drawCalls, + tick: (now, index) => { + p.flight = flights[index % flights.length]; + body.update(now, p); + // Arrivals must be safe to read straight after update(), every frame. + void body.arrivalCount; + }, + dispose: () => { + body.dispose(); + material.dispose(); + } + }; + }); + + /* --- Swarm: every silhouette --------------------------------------- */ + const { Swarm, Silhouette, LeadPath, swarmParams } = VFX_MODULES.get('Swarm.js') ?? {}; + for (const [name, silhouette] of Object.entries(Silhouette ?? {})) { + add('Swarm.js', `Swarm ${name}`, () => { + const parent = new Group(); + const swarm = new Swarm(parent, { capacity: 64, silhouette }); + const p = swarmParams(); + p.lead = LeadPath?.ORBIT ?? 0; + swarm.setBasis(_from, _dirZ, _side, 12); + swarm.roll(5); + return { + node: parent, + drawCalls: () => swarm.drawCalls, + tick: (now) => swarm.update(now, p), + dispose: () => swarm.dispose() + }; + }); + } + + /* --- Distortion: every emitter ------------------------------------- */ + const { DistortionField, DistortionMode, DistortionFacing } = VFX_MODULES.get('Distortion.js') ?? {}; + for (const [name, mode] of Object.entries(DistortionMode ?? {})) { + add('Distortion.js', `DistortionField ${name}`, () => { + const parent = new Group(); + const field = new DistortionField({ mode, facing: DistortionFacing?.BILLBOARD, edge: true }); + parent.add(field.object3D); + field.visible = true; + field.setAnchorXYZ(0, 1, 8); + field.setBasis(_dirZ, _up); + // An empty params object is the interesting call: it proves every read + // falls back rather than writing `undefined` into a uniform. + const p = { radius: 2.5, strength: 0.4, seed: 3 }; + return { + node: parent, + drawCalls: () => 1, + tick: (now, index) => field.update(index % 2 ? p : {}), + dispose: () => { + field.visible = false; // release the writer counter + field.dispose(); + } + }; + }); + } + + /* --- Portal --------------------------------------------------------- */ + const { Portal } = VFX_MODULES.get('Portal.js') ?? {}; + add('Portal.js', 'Portal', () => { + const parent = new Group(); + const portal = new Portal({}); + parent.add(portal.object3D); + portal.setPlacement(_to, _dirZ, _up); + const p = { open: 0.8, radiusX: 2, radiusY: 3, seed: 9 }; + return { + node: parent, + drawCalls: () => 1, + tick: (now, index) => portal.update(index % 2 ? p : {}), + dispose: () => portal.dispose() + }; + }); + + /* --- LiquidSurface: both modes -------------------------------------- */ + const { LiquidSurface, LiquidMode, liquidParams } = VFX_MODULES.get('LiquidSurface.js') ?? {}; + for (const [name, mode] of Object.entries(LiquidMode ?? {})) { + add('LiquidSurface.js', `LiquidSurface ${name}`, () => { + const parent = new Group(); + const surface = new LiquidSurface({ mode, segments: 24 }); + parent.add(surface.object3D); + surface.setPlacement(_to, _dirZ, _up); + const p = liquidParams(); + return { + node: parent, + drawCalls: () => surface.drawCalls, + tick: (now, index) => { + surface.update(now, p); + // rippleAtWorld() has to be called after update() — it needs this + // frame's half-extents to turn a metre into the fraction it stores. + if (index === 2) surface.rippleAtWorld(_to, 1, now); + void surface.lipHeight(p, 0); + }, + dispose: () => surface.dispose() + }; + }); + } + + /* --- Curtain: every mode, every layout ------------------------------ */ + const { Curtain, CurtainMode, CurtainLayout, curtainParams } = VFX_MODULES.get('Curtain.js') ?? {}; + const curtainCombos = []; + for (const mode of Object.values(CurtainMode ?? {})) curtainCombos.push([mode, CurtainLayout?.LINE ?? 0]); + for (const layout of Object.values(CurtainLayout ?? {})) curtainCombos.push([CurtainMode?.RAIN ?? 0, layout]); + for (const [mode, layout] of curtainCombos) { + add('Curtain.js', `Curtain mode:${mode} layout:${layout}`, () => { + const parent = new Group(); + const curtain = new Curtain({ mode, layout, capacity: 6, segmentsX: 8, segmentsY: 6, floor: true }); + parent.add(curtain.object3D); + curtain.setPlacement(_to, _side, _up); + curtain.roll(11); + const p = curtainParams(); + return { + node: parent, + drawCalls: () => curtain.drawCalls, + tick: (now) => curtain.update(now, p), + dispose: () => curtain.dispose() + }; + }); + } + + return cases; +} + +/** Frames each case is driven for before the zero-length one. */ +const VFX_FRAMES = 6; + +/** + * Drive one case and collect everything that went wrong with it. + * + * The zero-length frame at the end is not decoration: it is the same I1 probe + * the ability pause test runs, reduced to "does a `dt = 0` tick throw or + * produce a NaN". A module that divides by `dt` fails here and nowhere else. + */ +function runVfxCase(entry, failures) { + let handle; + try { + handle = entry.build(); + } catch (error) { + failures.push(`${entry.label}: construction threw — ${describeError(error)}`); + return; + } + + const problems = []; + try { + for (let i = 0; i < VFX_FRAMES; i++) { + frame.uTime.value += DT; + frame.uDelta.value = DT; + handle.tick(frame.uTime.value, i, DT); + } + // The zero-length frame — a paused slider drag. + frame.uDelta.value = 0; + handle.tick(frame.uTime.value, VFX_FRAMES, 0); + + if (handle.node) { + handle.node.updateMatrixWorld(true); + scanGroup(handle.node, problems); + } + + const calls = handle.drawCalls?.(); + if (Number.isFinite(calls) && calls > DRAW_CALL_BUDGET) { + problems.push(`draws ${calls} times on its own — the whole-ability budget is ${DRAW_CALL_BUDGET} (I4/I7)`); + } + } catch (error) { + problems.push(`threw while ticking — ${describeError(error)}`); + } + + try { + handle.dispose?.(); + } catch (error) { + problems.push(`dispose() threw — ${describeError(error)}`); + } + + for (const problem of problems) failures.push(`${entry.label}: ${problem}`); +} + +/** True for an ES class, which is what the coverage rule is looking for. */ +function isClass(value) { + return typeof value === 'function' && /^class\s/.test(Function.prototype.toString.call(value)); +} + +/** + * Run the whole stage. Reports through the roster-level channels, because a + * broken shared module is not any one ability's fault — it is everyone's. + */ +async function checkVfxLibrary() { + const failures = []; + + for (const [name, module] of VFX_MODULES) { + if (module.__error) failures.push(`${name}: failed to import — ${describeError(module.__error)}`); + } + + let cases = []; + try { + cases = vfxCases(); + } catch (error) { + failures.push(`building the case list threw — ${describeError(error)}`); + } + + for (const entry of cases) { + if (VFX_MODULES.get(entry.file)?.__error) continue; // already reported + runVfxCase(entry, failures); + } + + /* --- the coverage rule --- */ + const covered = new Set(cases.map((entry) => entry.file)); + for (const [name, module] of VFX_MODULES) { + if (module.__error) continue; + const classes = Object.entries(module).filter(([, value]) => isClass(value)); + if (!classes.length) continue; + if (!covered.has(name)) { + failures.push( + `${name} exports ${classes.map(([key]) => key).join(', ')} but has no case in VFX_CASES — ` + + `add one to scripts/check.mjs so the abilities built on it get an error here rather than a blank screen` + ); + } + } + + vfx.cases = cases.length; + vfx.modules = VFX_MODULES.size; + vfx.failures = failures; + for (const message of failures) failGlobal(`[vfx] ${message}`); + return failures.length === 0; +} + +/* ------------------------------------------------------------------ */ +/* §11 · Output */ +/* ------------------------------------------------------------------ */ + +function describeError(error) { + if (!(error instanceof Error)) return String(error); + const frameLine = (error.stack ?? '').split('\n').find((line) => line.includes('/src/')); + const where = frameLine ? dim(` (${frameLine.trim().replace(/^at\s+/, '')})`) : ''; + return `${error.message}${where}`; +} + +const safeNumber = (n) => (Number.isFinite(n) ? n.toFixed(2) : String(n)); + +/** `null` means the stage never ran — usually because an earlier one failed. */ +const markText = (state) => (state === null ? '—' : state ? 'ok' : 'FAIL'); +const colourMark = (state, text) => (state === null ? dim(text) : state ? green(text) : red(text)); + +/* ------------------------------------------------------------------ */ +/* Run */ +/* ------------------------------------------------------------------ */ + +checkRoster(); + +log(bold(`\ncheck · ${TARGET.length} of ${ABILITIES.length} abilities · node ${process.version}\n`)); + +for (const descriptor of TARGET) { + const report = reports.get(descriptor.id); + if (checkStructure(descriptor, report)) { + checkStaticReads(descriptor, report); + await captureConsole(report, () => runAbility(descriptor, report)); + } else { + // Without a settings block there is nothing coherent left to test. + checkStaticReads(descriptor, report); + } +} + +/* --- the shared library --- */ +// Skipped under `--only `, which exists to make one ability's loop fast; +// `--vfx` forces it back on when the library is what you are working on. +const RUN_VFX = !ONLY || flag('--vfx'); +if (RUN_VFX) await checkVfxLibrary(); + +/* --- the table --- */ +// Widths are measured on the *plain* text and the colour is wrapped around the +// finished cell: `padEnd` counts escape sequences as characters, so colouring +// first shifts every row that failed a few columns to the right. +const COLUMNS = [ + ['struct', 'struct'], + ['static', 'static'], + ['runtime', 'runtime'], + ['travel', 'pause:travel'], + ['impact', 'pause:impact'] +]; +const idWidth = Math.max(10, ...TARGET.map((a) => a.id.length + 2)); +const cellWidth = COLUMNS.map(([, title]) => title.length + 2); + +const row = (first, cells) => + first.padEnd(idWidth) + cells.map((text, i) => centre(text, cellWidth[i])).join(''); + +const centre = (text, size) => { + const pad = Math.max(0, size - text.length); + const left = Math.floor(pad / 2); + return ' '.repeat(left) + text + ' '.repeat(pad - left); +}; + +const headerLine = row('ability', COLUMNS.map(([, title]) => title)); +console.log(bold(headerLine)); +console.log(dim('─'.repeat(headerLine.length))); + +let failed = 0; +let skipped = false; +for (const descriptor of TARGET) { + const report = reports.get(descriptor.id); + if (!report.ok) failed++; + const cells = COLUMNS.map(([key], i) => { + if (report.marks[key] === null) skipped = true; + return colourMark(report.marks[key], centre(markText(report.marks[key]), cellWidth[i])); + }); + console.log(descriptor.id.padEnd(idWidth) + cells.join('')); +} +if (skipped) console.log(dim('— · not reached, because an earlier stage failed')); + +/* --- notes and warnings --- */ +if (!QUIET) { + for (const descriptor of TARGET) { + const report = reports.get(descriptor.id); + if (!report.notes.length && !report.warnings.length) continue; + console.log(`\n${bold(descriptor.id)}`); + for (const note of report.notes) console.log(dim(` ${note}`)); + for (const { stage, message } of report.warnings) console.log(yellow(` ! [${stage}] ${message}`)); + } + for (const warning of globalWarnings) console.log(yellow(`\n ! [roster] ${warning}`)); + + if (RUN_VFX) { + const state = vfx.failures?.length ? red(`${vfx.failures.length} failed`) : green('all pass'); + console.log(`\n${bold('vfx')}`); + console.log(dim(` ${vfx.cases} case(s) across ${vfx.modules} module(s) — ${state}`)); + } +} + +/* --- failures --- */ +if (failed || globalFailures.length) { + console.log(`\n${bold(red('FAILURES'))}`); + for (const message of globalFailures) console.log(`\n ${red('roster')}\n ${message}`); + for (const descriptor of TARGET) { + const report = reports.get(descriptor.id); + if (report.ok) continue; + console.log(`\n ${red(descriptor.id)}`); + for (const { stage, message } of report.failures) { + console.log(` ${bold(`[${stage}]`)} ${message}`); + } + } +} + +const total = TARGET.length; +const broken = failed + (globalFailures.length ? 1 : 0); +const warnings = TARGET.reduce((sum, a) => sum + reports.get(a.id).warnings.length, 0) + globalWarnings.length; +console.log(''); +if (warnings && QUIET) console.log(dim(`${warnings} warning(s) — drop --quiet to read them.`)); +if (broken) { + console.log(red(bold(`${failed} of ${total} abilities failed${globalFailures.length ? `, plus ${globalFailures.length} roster-level failure(s)` : ''}.`))); + process.exit(1); +} +console.log(green(bold(`all ${total} abilities pass.`))); diff --git a/scripts/check-vfx-distortion-portal.mjs b/scripts/check-vfx-distortion-portal.mjs new file mode 100644 index 0000000..0a539b8 --- /dev/null +++ b/scripts/check-vfx-distortion-portal.mjs @@ -0,0 +1,360 @@ +/** + * `scripts/check-vfx-distortion-portal.mjs` — the harness for + * `vfx/Distortion.js` and `vfx/Portal.js`. + * + * Separate from `scripts/check.mjs` for the same reason + * `check-vfx-tube-shell.mjs` is: that one walks the **registry**, and neither of + * these modules is reachable from an ability yet. Fold it in — or add a + * `"check:vfx"` script — the moment one is. + * + * No WebGL, so this cannot prove the GLSL compiles. Three things stand in: + * + * 1. **The module imports at all.** That sounds trivial and is not. Both files + * keep their shaders in tagged template literals, and a backtick inside a + * GLSL *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. This harness caught it twice + * while the modules were being written. + * 2. **Static shader sanity.** Every `uXxx` a stage references is declared in + * that stage, the two stages agree on their varyings, and the braces and the + * preprocessor balance. Between them those catch essentially every typo that + * survives writing the file, including the reserved-word collisions (`flat`) + * that only a real compiler would otherwise find. + * 3. **Behaviour.** The writer counter, the premultiplied blend setup, and + * **I1** — a value changed on a zero-length frame must move a uniform. + * + * Run: `node scripts/check-vfx-distortion-portal.mjs` + */ +import { Vector3, IcosahedronGeometry, CustomBlending, OneFactor, OneMinusSrcAlphaFactor } from 'three'; +import { DistortionField, DistortionMode, DistortionFacing } from '../src/vfx/Distortion.js'; +import { Portal } from '../src/vfx/Portal.js'; +import { LAYER, distortionWriters } from '../src/core/Layers.js'; +import { settings } from '../src/config/settings.js'; + +let failures = 0; +let checks = 0; + +function ok(condition, label, detail) { + checks++; + if (condition) return; + failures++; + console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`); +} + +function section(name) { + console.log(`\n${name}`); +} + +/* ---------------------------------------------------------------- */ +/* 1 · static shader sanity */ +/* ---------------------------------------------------------------- */ + +/** Identifiers three declares for us, which a stage may use without declaring. */ +const PROVIDED = new Set([ + 'uv', + 'uv1', + 'uv2', + 'position', + 'normal', + 'tangent', + 'projectionMatrix', + 'modelViewMatrix', + 'modelMatrix', + 'viewMatrix', + 'normalMatrix', + 'cameraPosition', + 'isOrthographic', + 'logDepthBufFC' +]); + +/** Strip comments so a name mentioned in prose is not read as a reference. */ +function stripComments(src) { + return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' '); +} + +function declared(src, prefix) { + const out = new Set(); + const re = new RegExp( + `\\b(?:uniform|varying|attribute|in|out)\\s+(?:lowp\\s+|mediump\\s+|highp\\s+)?\\w+\\s+([^;]+);`, + 'g' + ); + let m; + while ((m = re.exec(src))) { + for (const name of m[1].split(',')) { + const clean = name.trim().replace(/\[.*$/, '').trim(); + if (clean.startsWith(prefix)) out.add(clean); + } + } + return out; +} + +function referenced(src, prefix) { + const out = new Set(); + const re = new RegExp(`\\b${prefix}[A-Z]\\w*`, 'g'); + let m; + while ((m = re.exec(src))) out.add(m[0]); + return out; +} + +function checkStage(label, src, prefix) { + const body = stripComments(src); + const decls = declared(body, prefix); + for (const name of referenced(body, prefix)) { + if (PROVIDED.has(name)) continue; + ok(decls.has(name), `${label}: ${name} referenced but not declared`); + } + return decls; +} + +function checkBalance(label, src) { + const body = stripComments(src); + let braces = 0; + let parens = 0; + let underflow = false; + for (const ch of body) { + if (ch === '{') braces++; + else if (ch === '}') braces--; + else if (ch === '(') parens++; + else if (ch === ')') parens--; + if (braces < 0) underflow = true; + } + ok(!underflow, `${label}: closing brace before an opening one`); + ok(braces === 0, `${label}: unbalanced braces`, `${braces}`); + ok(parens === 0, `${label}: unbalanced parentheses`, `${parens}`); + + let depth = 0; + let stray = false; + for (const line of body.split('\n')) { + const t = line.trim(); + if (/^#if|^#ifdef|^#ifndef/.test(t)) depth++; + else if (/^#endif/.test(t)) depth--; + if (depth < 0) stray = true; + } + ok(!stray, `${label}: #endif without a matching #if`); + ok(depth === 0, `${label}: unbalanced preprocessor`, `${depth}`); +} + +/** GLSL ES keywords and reserved words that must never be a local name. */ +const RESERVED = [ + 'flat', + 'smooth', + 'noperspective', + 'input', + 'output', + 'sample', + 'filter', + 'active', + 'asm', + 'union', + 'namespace', + 'using', + 'this', + 'packed', + 'cast' +]; + +function checkReserved(label, src) { + const body = stripComments(src); + for (const word of RESERVED) { + const re = new RegExp(`\\b(?:float|int|bool|vec2|vec3|vec4|mat2|mat3|mat4)\\s+${word}\\b`); + ok(!re.test(body), `${label}: "${word}" is reserved and cannot be a variable name`); + } +} + +function auditMaterial(label, material) { + const v = material.vertexShader; + const f = material.fragmentShader; + checkBalance(`${label} vertex`, v); + checkBalance(`${label} fragment`, f); + checkReserved(`${label} vertex`, v); + checkReserved(`${label} fragment`, f); + + const vu = checkStage(`${label} vertex`, v, 'u'); + const fu = checkStage(`${label} fragment`, f, 'u'); + for (const name of [...vu, ...fu]) { + if (PROVIDED.has(name)) continue; + ok(material.uniforms[name] !== undefined, `${label}: ${name} declared but has no uniform box`); + } + + // A varying written by the vertex stage must be declared identically in the + // fragment stage, or the link fails with a message that names neither file. + const vVary = declared(stripComments(v), 'v'); + const fVary = declared(stripComments(f), 'v'); + for (const name of fVary) { + ok(vVary.has(name), `${label}: fragment declares varying ${name} the vertex stage does not`); + } +} + +section('static shader sanity'); + +const audited = [ + ['HEAT', new DistortionField({ mode: DistortionMode.HEAT })], + ['LENS', new DistortionField({ mode: DistortionMode.LENS })], + ['SHOCK', new DistortionField({ mode: DistortionMode.SHOCK, facing: DistortionFacing.GROUND })], + ['BLADE', new DistortionField({ mode: DistortionMode.BLADE, edge: true })], + ['REFRACT', new DistortionField({ mode: DistortionMode.REFRACT, geometry: new IcosahedronGeometry(1, 1) })] +]; +for (const [label, field] of audited) { + auditMaterial(`Distortion:${label}`, field.material); + if (field.edgeMaterial) auditMaterial(`Distortion:${label}:edge`, field.edgeMaterial); +} + +const portal = new Portal(); +auditMaterial('Portal', portal.material); +console.log(` ${checks} static assertion(s)`); + +/* ---------------------------------------------------------------- */ +/* 2 · the writer counter */ +/* ---------------------------------------------------------------- */ + +section('the distortion writer counter'); + +const base = distortionWriters.count; +const probe = new DistortionField({ mode: DistortionMode.LENS, name: 'probe' }); +ok(probe.object3D.layers.mask === (1 << LAYER.DISTORTION), 'the emitter sits on LAYER.DISTORTION alone'); +ok(distortionWriters.count === base, 'constructing an emitter does not retain'); + +probe.visible = true; +ok(distortionWriters.count === base + 1, 'showing retains'); +probe.visible = true; +ok(distortionWriters.count === base + 1, 'showing twice retains once'); +probe.visible = false; +ok(distortionWriters.count === base, 'hiding releases'); +probe.visible = false; +ok(distortionWriters.count === base, 'hiding twice releases once'); +probe.visible = true; +probe.dispose(); +ok(distortionWriters.count === base, 'disposing a visible emitter releases'); + +/* ---------------------------------------------------------------- */ +/* 3 · I1 — a zero-length frame moves the uniforms */ +/* ---------------------------------------------------------------- */ + +section('I1 — every dimension re-resolves on a zero-length frame'); + +const p = {}; +const lens = new DistortionField({ mode: DistortionMode.LENS }); +p.width = 4; +p.height = 4; +p.radius = 2; +p.strength = 0.3; +p.core = 0.2; +p.invert = 0; +lens.update(p); +const before = { + size: lens.material.uniforms.uSize.value.x, + radius: lens.material.uniforms.uRadius.value, + strength: lens.material.uniforms.uStrength.value +}; +p.width = 9; +p.radius = 7.5; +p.strength = 0.9; +lens.update(p); // no dt, no render — exactly what a paused frame does +ok(lens.material.uniforms.uSize.value.x === 9, 'width reaches uSize with the clock stopped', `${before.size} → 9`); +ok(lens.material.uniforms.uRadius.value === 7.5, 'radius reaches uRadius with the clock stopped'); +ok(lens.material.uniforms.uStrength.value === 0.9, 'strength reaches uStrength with the clock stopped'); + +const savedPost = settings.post.distortion; +const savedGlobal = settings.global.distortion; +settings.post.distortion = 0.08; +settings.global.distortion = 2; +lens.update(p); +ok( + Math.abs(lens.material.uniforms.uPostScale.value - 0.16) < 1e-9, + 'uPostScale mirrors post.distortion × global.distortion', + String(lens.material.uniforms.uPostScale.value) +); +settings.post.distortion = savedPost; +settings.global.distortion = savedGlobal; + +// The fold guard is the one place the module reads settings, and it must not +// have folded the master gains into the strength as well. +lens.update(p); +ok(lens.material.uniforms.uStrength.value === 0.9, 'strength is never pre-multiplied by the global gains'); + +/* ---------------------------------------------------------------- */ +/* 4 · the blade shares its uniform boxes with its hairline */ +/* ---------------------------------------------------------------- */ + +section('the blade and its hairline are one plane'); + +const blade = new DistortionField({ mode: DistortionMode.BLADE, edge: true }); +const bu = blade.material.uniforms; +const eu = blade.edgeMaterial.uniforms; +for (const key of ['uAnchor', 'uSize', 'uPivot', 'uAxisX', 'uAxisY', 'uCut', 'uEdge', 'uOpacity']) { + ok(bu[key] === eu[key], `${key} is the same uniform box in both passes`); +} +blade.setAnchorXYZ(1, 2, 3); +ok(eu.uAnchor.value.x === 1 && eu.uAnchor.value.z === 3, 'moving the blade moves the hairline'); +blade.setBasis(new Vector3(0, 0, 1), new Vector3(0, 1, 0)); +ok(Math.abs(eu.uAxisX.value.z - 1) < 1e-6, 'setBasis writes the shared axis'); +ok(Math.abs(eu.uAxisY.value.y - 1) < 1e-6, 'setBasis re-orthogonalises up against along'); +ok(blade.edgeMesh.layers.mask === (1 << LAYER.VFX), 'the hairline is on LAYER.VFX, not the offset buffer'); + +/* ---------------------------------------------------------------- */ +/* 5 · Portal */ +/* ---------------------------------------------------------------- */ + +section('Portal'); + +const rift = new Portal({ name: 'rift' }); +const ru = rift.material.uniforms; + +ok(rift.material.blending === CustomBlending, 'the portal blends custom'); +ok(rift.material.blendSrc === OneFactor, 'premultiplied: src = ONE'); +ok(rift.material.blendDst === OneMinusSrcAlphaFactor, 'premultiplied: dst = ONE_MINUS_SRC_ALPHA'); +ok(rift.material.blendSrcAlpha === OneFactor, 'premultiplied: srcAlpha = ONE'); +ok(rift.material.blendDstAlpha === OneMinusSrcAlphaFactor, 'premultiplied: dstAlpha = ONE_MINUS_SRC_ALPHA'); +ok(rift.material.depthTest === true, 'depthTest is on — nearer opaque geometry hides the hole'); +ok(rift.material.depthWrite === false, 'depthWrite is off by default'); +ok(new Portal({ writeDepth: true }).material.depthWrite === true, 'writeDepth: true turns depthWrite on'); + +const po = { radiusX: 3, radiusY: 1, margin: 0.5, open: 0.25 }; +rift.update(po); +ok(ru.uRadii.value.x === 3 && ru.uRadii.value.y === 1, 'the radii reach uRadii'); +ok(ru.uSize.value.x === 9 && ru.uSize.value.y === 3, 'the quad holds the aperture plus the crack margin'); +ok(ru.uOpen.value === 0.25, 'open reaches uOpen'); +po.radiusX = 6; +po.open = 0.8; +rift.update(po); +ok(ru.uSize.value.x === 18, 'resizing on a zero-length frame re-sizes the quad'); +ok(ru.uOpen.value === 0.8, 'the tear progresses on a zero-length frame'); + +// I5: ten pickers, none derived from another, none sharing a Color instance. +const colourKeys = Object.keys(ru).filter((k) => k.startsWith('uColor')); +ok(colourKeys.length === 10, 'the portal exposes ten colours', String(colourKeys.length)); +const instances = new Set(colourKeys.map((k) => ru[k].value)); +ok(instances.size === 10, 'no two colours share a Color instance'); + +rift.update({ ...po, colorRim: '#ff0000', colorCore: '#00ff00' }); +ok(ru.uColorRim.value.r > 0.9 && ru.uColorRim.value.g < 0.01, 'colorRim reaches its uniform'); +ok(ru.uColorCore.value.g > 0.9 && ru.uColorCore.value.r < 0.01, 'colorCore is independent of colorRim'); + +/* ---------------------------------------------------------------- */ +/* 6 · no allocation in the frame path */ +/* ---------------------------------------------------------------- */ + +section('I3 — the frame path does not allocate'); + +const sizeBox = ru.uSize.value; +const anchorBox = ru.uAnchor.value; +const colourBox = ru.uColorRim.value; +rift.setPlacement(new Vector3(1, 2, 3), new Vector3(1, 0, 0), new Vector3(0, 1, 0)); +rift.update(po); +ok(ru.uSize.value === sizeBox, 'update() mutates uSize rather than replacing it'); +ok(ru.uAnchor.value === anchorBox, 'setPlacement() mutates uAnchor rather than replacing it'); +ok(ru.uColorRim.value === colourBox, 'update() mutates the Color rather than replacing it'); + +const lensSize = lens.material.uniforms.uSize.value; +lens.update(p); +ok(lens.material.uniforms.uSize.value === lensSize, 'the field mutates its uniform boxes too'); + +/* ---------------------------------------------------------------- */ + +console.log(''); +if (failures === 0) { + console.log(`Distortion + Portal: all ${checks} assertion(s) pass.`); +} else { + console.error(`Distortion + Portal: ${failures} of ${checks} assertion(s) FAILED.`); + process.exitCode = 1; +} diff --git a/scripts/check-vfx-ink.mjs b/scripts/check-vfx-ink.mjs new file mode 100644 index 0000000..2279fd3 --- /dev/null +++ b/scripts/check-vfx-ink.mjs @@ -0,0 +1,629 @@ +/** + * A bench for `vfx/BrushStroke.js` and `vfx/InkDiffusion.js` — the Ink school. + * + * `npm run check` drives both modules for a handful of frames and looks for + * NaN. That is the right job for a shared stage and it is not enough for these + * two, because almost everything interesting about them is either in a shader + * (which nothing here can run) or is a *distribution* rather than a value. + * What this file adds: + * + * 1. **Static shader sanity.** Balanced braces, no reserved word used as a + * variable, no backtick, no `cosh`, every `u*` a stage references declared + * in it and boxed on the material, every fragment varying declared by the + * vertex stage, and no `fwidth` in a vertex stage. These are the failures + * that survive writing the file and turn up as a blank screen. + * 2. **The anti-glow contract.** This is the school's entire premise and it is + * one careless line from being lost, so it is asserted rather than trusted: + * no `uGlobalGlow` anywhere, no additive blending, a luminance ceiling in + * both fragment stages, and a default ceiling strictly below the shipped + * `post.bloomThreshold`. + * 3. **The bristle model.** A dry tail is only a dry tail if the bristles are + * distinct and their ink loads are not. Layout, spread and dice are checked + * per tip, and so is the parking path — the one that must not shuffle a + * stroke into another stroke's slot. + * 4. **The dispersion relation.** The instability's whole claim is that finer + * modes are *released* as the front grows. The JS mirror of the amplitude + * schedule asserts that a small front has one live mode and a large one has + * all five, and that the front law's closed-form inverse really inverts it. + * 5. **The power law.** The satellites are the readable half of a splatter and + * a uniform draw would look like a stencil. The bounded-Pareto inverse CDF + * is sampled four thousand times and its shape is asserted. + * 6. **I1** — a value changed on a zero-length frame must move a uniform. + * + * Run: `node scripts/check-vfx-ink.mjs` + */ +import { Group, Vector3 } from 'three'; +import { BrushStroke, BrushTip, BRUSH_TIP_NAMES, brushStrokeParams } from '../src/vfx/BrushStroke.js'; +import { InkDiffusion, InkMode, INK_MODE_NAMES, inkDiffusionParams } from '../src/vfx/InkDiffusion.js'; +import { settings } from '../src/config/settings.js'; + +let failures = 0; +let checks = 0; + +function ok(condition, label, detail) { + checks++; + if (condition) return; + failures++; + console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`); +} + +function section(name) { + console.log(`\n${name}`); +} + +const near = (a, b, eps, label) => ok(Math.abs(a - b) <= eps, label, `${a} vs ${b}`); + +/* ---------------------------------------------------------------- */ +/* 1 · static shader sanity */ +/* ---------------------------------------------------------------- */ + +/** Identifiers three declares for us, which a stage may use without declaring. */ +const PROVIDED = new Set([ + 'uv', + 'position', + 'normal', + 'projectionMatrix', + 'modelViewMatrix', + 'modelMatrix', + 'viewMatrix', + 'normalMatrix', + 'cameraPosition' +]); + +const RESERVED = [ + 'flat', + 'smooth', + 'noperspective', + 'input', + 'output', + 'sample', + 'filter', + 'active', + 'asm', + 'union', + 'namespace', + 'using', + 'this', + 'packed', + 'cast', + 'goto', + 'switch', + 'default', + 'inline', + 'volatile', + 'public', + 'static', + 'extern', + 'external', + 'interface', + 'long', + 'short', + 'double', + 'half', + 'fixed', + 'unsigned', + 'sizeof' +]; + +const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' '); + +function declared(src, prefix) { + const out = new Set(); + const re = /\b(?:uniform|varying|attribute|in|out)\s+(?:lowp\s+|mediump\s+|highp\s+)?\w+\s+([^;]+);/g; + let m; + while ((m = re.exec(src))) { + for (const name of m[1].split(',')) { + const clean = name.trim().replace(/\[.*$/, '').trim(); + if (clean.startsWith(prefix)) out.add(clean); + } + } + return out; +} + +function referenced(src, prefix) { + const out = new Set(); + const re = new RegExp(`\\b${prefix}[A-Z]\\w*`, 'g'); + let m; + while ((m = re.exec(src))) out.add(m[0]); + return out; +} + +function checkBalance(label, src) { + const body = stripComments(src); + let braces = 0; + let parens = 0; + let underflow = false; + for (const ch of body) { + if (ch === '{') braces++; + else if (ch === '}') braces--; + else if (ch === '(') parens++; + else if (ch === ')') parens--; + if (braces < 0) underflow = true; + } + ok(!underflow, `${label}: closing brace before an opening one`); + ok(braces === 0, `${label}: unbalanced braces`, `${braces}`); + ok(parens === 0, `${label}: unbalanced parentheses`, `${parens}`); +} + +/** `#if` / `#else` / `#endif` must nest, or the whole program disappears. */ +function checkPreprocessor(label, src) { + let depth = 0; + let underflow = false; + for (const line of src.split('\n')) { + const directive = line.trim(); + if (/^#if(n?def)?\b/.test(directive)) depth++; + else if (/^#endif\b/.test(directive)) depth--; + if (depth < 0) underflow = true; + } + ok(!underflow, `${label}: #endif without a matching #if`); + ok(depth === 0, `${label}: ${depth} unterminated #if`); +} + +function auditMaterial(label, material) { + const v = material.vertexShader; + const f = material.fragmentShader; + for (const [stage, src] of [ + [`${label} vertex`, v], + [`${label} fragment`, f] + ]) { + checkBalance(stage, src); + checkPreprocessor(stage, src); + const body = stripComments(src); + for (const word of RESERVED) { + const re = new RegExp(`\\b(?:float|int|bool|vec2|vec3|vec4|mat2|mat3|mat4)\\s+${word}\\b`); + ok(!re.test(body), `${stage}: "${word}" is reserved and cannot be a variable name`); + } + for (const name of referenced(body, 'u')) { + if (PROVIDED.has(name)) continue; + ok(declared(body, 'u').has(name), `${stage}: ${name} referenced but not declared`); + } + for (const name of declared(body, 'u')) { + if (PROVIDED.has(name)) continue; + ok(material.uniforms[name] !== undefined, `${stage}: ${name} declared but has no uniform box`); + } + } + + ok(!v.includes('`') && !f.includes('`'), `${label}: a backtick in the shader source`); + ok(!/\bcosh\s*\(/.test(v + f), `${label}: cosh() does not exist in ESSL 1.00`); + // Derivatives are fragment-only: injecting commonGLSL into a vertex stage + // takes aastep's fwidth with it and fails the whole program to compile. + ok(!/\bfwidth\s*\(/.test(v), `${label}: vertex stage is free of fwidth`); + + const vVary = declared(stripComments(v), 'v'); + for (const name of declared(stripComments(f), 'v')) { + ok(vVary.has(name), `${label}: fragment declares varying ${name} the vertex stage does not`); + } +} + +section('static shader sanity'); + +const _up = new Vector3(0, 1, 0); +const _from = new Vector3(0, 0.15, 0); +const _to = new Vector3(0, 0.15, 11); +const _dirZ = new Vector3(0, 0, 1); +const _probe = new Vector3(); + +const benchParent = new Group(); +const brushes = Object.values(BrushTip).map( + (tip) => new BrushStroke(benchParent, { strokes: 3, bristles: 8, samples: 10, sides: 6, tip }) +); +const fields = Object.values(InkMode).map( + (mode) => new InkDiffusion(benchParent, { mode, sources: 4, satellites: 8 }) +); + +auditMaterial('BrushStroke', brushes[0].material); +for (const field of fields) { + auditMaterial(`InkDiffusion ${INK_MODE_NAMES[field.mode]}`, field.material); +} + +/** + * Drop every `#if INK_MODE == n ... #endif` region that this mode does not + * take, the way the GLSL preprocessor will. Crude — it only understands the one + * directive form this file uses — but it is enough to prove the guards are on + * the right blocks, which is the thing a string search cannot tell you. + */ +function activeSource(src, mode) { + const out = []; + let skipping = 0; + for (const line of src.split('\n')) { + const trimmed = line.trim(); + let m = /^#if\s+INK_MODE\s*(==|!=)\s*(\d+)/.exec(trimmed); + if (m) { + const takes = m[1] === '==' ? mode === Number(m[2]) : mode !== Number(m[2]); + skipping = takes ? 0 : 1; + continue; + } + if (/^#(else|endif)\b/.test(trimmed)) { + skipping = /^#else/.test(trimmed) ? 1 - skipping : 0; + continue; + } + if (!skipping) out.push(line); + } + return out.join('\n'); +} + +// The mode is a #define, so the satellite loop must genuinely not exist outside +// SPLATTER — an eight-iteration loop per fragment over the whole zone is not +// something a bloom should be paying for. +for (const field of fields) { + const live = activeSource(field.material.fragmentShader, field.mode); + const wantsSatellites = field.mode === InkMode.SPLATTER; + ok( + live.includes('uSatDice[i]') === wantsSatellites, + `${INK_MODE_NAMES[field.mode]}: the satellite loop is ${wantsSatellites ? 'in' : 'compiled out'}` + ); + ok( + live.includes('fingers(q') === (field.mode !== InkMode.WASH), + `${INK_MODE_NAMES[field.mode]}: the instability is ${field.mode === InkMode.WASH ? 'compiled out' : 'in'}` + ); + ok( + live.includes('uCrown *') === wantsSatellites, + `${INK_MODE_NAMES[field.mode]}: the crown is ${wantsSatellites ? 'in' : 'compiled out'}` + ); + checkBalance(`${INK_MODE_NAMES[field.mode]} after preprocessing`, live); +} + +// Uniform arrays may be subscripted only by a loop counter in ESSL 1.00, and +// the obvious `uSatDice[someUniform]` compiles on the desktop driver and fails +// on the one the player has. The declarations are the only other subscript. +for (const field of fields) { + const body = stripComments(field.material.fragmentShader).replace( + /\buniform\s+vec4\s+u\w+\[[^\]]+\];/g, + ' ' + ); + ok( + !/u(?:Sat|Source)Dice\[\s*(?!i\s*\])/.test(body), + `${INK_MODE_NAMES[field.mode]}: uniform arrays are indexed only by the loop counter` + ); +} + +/* ---------------------------------------------------------------- */ +/* 2 · the anti-glow contract */ +/* ---------------------------------------------------------------- */ + +section('the anti-glow contract'); + +for (const [label, material] of [ + ['BrushStroke', brushes[0].material], + ['InkDiffusion', fields[0].material] +]) { + ok(material.toneMapped === true, `${label}: declares itself tone mapped`); + ok(material.blending === 1, `${label}: blends normally, never additively`, `${material.blending}`); + ok( + !stripComments(material.fragmentShader).includes('uGlobalGlow'), + `${label}: the global glow slider cannot reach ink` + ); + ok(material.fragmentShader.includes('uCeiling'), `${label}: clamps its output luminance`); +} + +// The bloom high pass runs on linear colour before the tone map, so its +// threshold is a linear luminance and the ceiling has to sit under it. +const threshold = settings.post.bloomThreshold; +ok( + brushStrokeParams().ceiling < threshold, + 'BrushStroke ceiling is under post.bloomThreshold', + `${brushStrokeParams().ceiling} vs ${threshold}` +); +ok( + inkDiffusionParams().ceiling < threshold, + 'InkDiffusion ceiling is under post.bloomThreshold', + `${inkDiffusionParams().ceiling} vs ${threshold}` +); + +/* ---------------------------------------------------------------- */ +/* 3 · the bristle model */ +/* ---------------------------------------------------------------- */ + +section('the bristle model'); + +for (const brush of brushes) { + const label = BRUSH_TIP_NAMES[brush.tip]; + const attr = brush.geometry.attributes.aBrush.array; + const n = brush.bristles; + const across = []; + const through = []; + for (let b = 0; b < n; b++) { + across.push(attr[b * 4 + 0]); + through.push(attr[b * 4 + 1]); + } + + // Distinct slots. Two bristles in the same place is one bristle with twice + // the ink, and the streak it leaves never breaks. + const unique = new Set(across.map((x, i) => `${x.toFixed(4)}:${through[i].toFixed(4)}`)); + ok(unique.size === n, `${label}: every bristle has its own place in the ferrule`, `${unique.size}/${n}`); + ok( + Math.max(...across.map(Math.abs)) <= 1.0001, + `${label}: no bristle hangs outside the ferrule` + ); + + if (brush.tip === BrushTip.FLAT) { + let ordered = true; + for (let b = 1; b < n; b++) if (across[b] <= across[b - 1]) ordered = false; + ok(ordered, 'FLAT: one rank, in order across the width'); + ok(through.every((t) => t === 0), 'FLAT: nothing spread through the paper normal'); + } + if (brush.tip === BrushTip.ROUND) { + ok( + through.filter((t) => Math.abs(t) > 0.1).length >= n / 2, + 'ROUND: most bristles are off the mid-plane' + ); + } + if (brush.tip === BrushTip.SPLIT) { + const sorted = [...across].sort((a, b) => a - b); + const gaps = []; + for (let b = 1; b < n; b++) gaps.push(sorted[b] - sorted[b - 1]); + const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length; + ok( + gaps.filter((g) => g > mean * 1.5).length >= 2, + 'SPLIT: at least two tuft gaps wider than the mean spacing', + gaps.map((g) => g.toFixed(3)).join(' ') + ); + } + + // Loads must differ or the tail is a clean cut, which is the whole failure + // this module exists to avoid. + const dice = new Set(); + for (let i = 0; i < brush.capacity; i++) dice.add(attr[i * 4 + 2].toFixed(6)); + ok(dice.size >= brush.capacity - 1, `${label}: ink-load dice are per bristle`, `${dice.size}`); +} + +// Parking. Stroke 1 goes inactive and must vanish without moving strokes 0 or 2. +{ + const brush = brushes[0]; + const p = brushStrokeParams(); + p.progress = 1; + brush.setPaper(_up); + for (let s = 0; s < 3; s++) { + brush.stroke(s).line(_from, _to, 0.3 * s, 0).pressure(0.1, 1, 0.8, 0.05).ink(7).timing(0, 1); + } + brush.update(0, p); + const before = brush.geometry.attributes.aP1.array.slice(); + const seedBefore = brush.stroke(2).seed; + brush.stroke(1).active = false; + brush.update(0, p); + const slot = 1 * brush.bristles * 4; + ok( + brush.geometry.attributes.aStroke.array[slot] > 1, + 'a parked stroke has its draw window pushed past the clock' + ); + const after = brush.geometry.attributes.aP1.array; + let moved = false; + for (let i = 2 * brush.bristles * 3; i < after.length; i++) { + if (after[i] !== before[i]) moved = true; + } + ok(!moved, 'parking stroke 1 does not slide stroke 2 into another slot'); + ok(brush.stroke(2).seed === seedBefore, 'and its seed stays with its slot'); +} + +/* ---------------------------------------------------------------- */ +/* 4 · the CPU mirrors of the spine and the pressure curve */ +/* ---------------------------------------------------------------- */ + +section('the spine mirrors'); + +{ + const brush = brushes[0]; + const stroke = brush.stroke(0); + stroke.line(_from, _to, 0.8, 0.35).pressure(0.11, 1, 0.77, 0.04).timing(0.25, 0.5); + + brush.pointAt(0, 0, _probe); + near(_probe.distanceTo(_from), 0, 1e-6, 'pointAt(0) is the start of the stroke'); + brush.pointAt(0, 1, _probe); + near(_probe.distanceTo(_to), 0, 1e-6, 'pointAt(1) is the end of it'); + near(brush.pressureOf(0, 0), 0.11, 1e-6, 'the entry pressure is hit exactly'); + near(brush.pressureOf(0, 1), 0.04, 1e-6, 'so is the exit'); + ok( + brush.pressureOf(0, 0.5) > brush.pressureOf(0, 0), + 'the body carries more weight than the entry' + ); + + const p = brushStrokeParams(); + p.progress = 0.2; + brush.update(0, p); + near(brush.headOf(0), 0, 1e-9, 'a stroke whose window has not opened is unwritten'); + p.progress = 0.5; + brush.update(0, p); + near(brush.headOf(0), 0.5, 1e-6, 'halfway through its window is halfway through the stroke'); + p.progress = 0.9; + brush.update(0, p); + near(brush.headOf(0), 1, 1e-9, 'and past the end of it, it is finished'); + brush.tipPoint(0, _probe); + near(_probe.distanceTo(_to), 0, 1e-6, 'the brush ends up where the stroke does'); +} + +/* ---------------------------------------------------------------- */ +/* 5 · the dispersion relation */ +/* ---------------------------------------------------------------- */ + +section('the fingering instability'); + +/** + * The JS mirror of `fingers()`'s amplitude schedule. Not used at runtime — the + * shader owns the field — but the *admission order* is the physical claim the + * module makes and the thing that separates a growing bloom from an fbm ring + * being scaled up, so it is worth being able to fail on it. + * + * Returns the amplitude, in metres, of each octave at a given front radius. + * Octave 0 is the coarsest. + */ +function octaveAmplitudes(p, frontRadius) { + const out = []; + let L = Math.max(p.coarse, 0.05); + for (let i = 0; i < 5; i++) { + const onset = p.onset * L; + const e = Math.min((p.growth * Math.max(frontRadius - onset, 0)) / L, 8); + out.push(L * Math.min(Math.exp(e) - 1, p.growthMax)); + L *= 0.5; + } + return out; +} + +const liveCount = (p, r) => octaveAmplitudes(p, r).filter((a) => a > 1e-4).length; + +{ + const p = inkDiffusionParams(); + const finest = p.coarse / 16; + + ok(liveCount(p, p.onset * finest * 0.5) === 0, 'a nucleus smaller than every mode is a disc'); + ok(liveCount(p, p.onset * finest * 1.4) === 1, 'the finest crinkle arrives first'); + ok( + liveCount(p, p.onset * p.coarse * 0.9) === 4, + 'the coarsest lobe is still inadmissible just below its own onset' + ); + ok(liveCount(p, p.onset * p.coarse * 1.5) === 5, 'and a mature front has grown into all five'); + + let previous = -1; + let monotone = true; + for (let r = 0; r <= 12; r += 0.05) { + const live = liveCount(p, r); + if (live < previous) monotone = false; + previous = live; + } + ok(monotone, 'modes are only ever admitted, never withdrawn'); + + // The whole shape claim: a young front is crinkle, a mature one is lobes with + // the crinkle riding on them. So the coarse octave must go from contributing + // nothing to dominating, and the fine one must never dominate again. + const young = octaveAmplitudes(p, 0.4); + const old = octaveAmplitudes(p, 9); + ok(young[0] === 0 && young[4] > 0, 'a young bloom is all crinkle and no lobe'); + ok(old[0] > old[4] * 8, 'a mature bloom is dominated by its coarsest lobe', `${old[0] / old[4]}`); + ok(old[4] > 0, 'and it keeps the crinkle'); + + // Saturation: without it the first octave to mature runs away and the bloom + // is two lobes for ever. With it, a mature mode settles at its own wavelength. + near(old[0], p.coarse * p.growthMax, 1e-9, 'a mature mode saturates at growthMax wavelengths'); +} + +{ + const field = fields[InkMode.BLOOM]; + const p = inkDiffusionParams(); + field.setPlacement(_to, _dirZ); + field.update(2.25, p); + const r = field.frontRadius(0); + near(r, p.spread * Math.sqrt(2.25), 1e-9, 'Fickian spreading: r goes as the square root of t'); + // The closed-form inverse is what gives every fragment its arrival time, and + // therefore the whole wet-to-dry gradient. If it stops inverting, the gloss + // detaches from the front. + const arrival = Math.pow(r / p.spread, 1 / p.spreadPower); + near(arrival, 2.25, 1e-6, 'arrivalOf(frontAt(t)) is t'); +} + +/* ---------------------------------------------------------------- */ +/* 6 · the satellites */ +/* ---------------------------------------------------------------- */ + +section('splatter morphology'); + +{ + const field = fields[InkMode.SPLATTER]; + const p = inkDiffusionParams(); + p.satellites = 8; + field.setPlacement(_to, _dirZ); + field.roll(3); + field.update(3, p); + + const sizes = []; + for (let i = 0; i < field.satellites; i++) sizes.push(field.satelliteSize(i)); + ok( + sizes.every((s) => s >= p.satMin - 1e-6 && s <= p.satMax + 1e-6), + 'every droplet is inside the authored size range' + ); + + // The distribution, sampled properly. A bounded Pareto with exponent 2.3 puts + // most of its mass at the small end; a uniform draw would put half of it + // above the midpoint and the splatter would read as a stencil of dots. + const a = p.satAlpha; + const loA = Math.pow(p.satMin, -a); + const hiA = Math.pow(p.satMax, -a); + const draw = (u) => Math.pow(u * (hiA - loA) + loA, -1 / a); + near(draw(0), p.satMin, 1e-9, 'the draw bottoms out at satMin'); + near(draw(1), p.satMax, 1e-9, 'and tops out at satMax'); + let big = 0; + const N = 4096; + for (let i = 0; i < N; i++) if (draw((i + 0.5) / N) > p.satMin * 2) big++; + ok(big / N < 0.35, 'fewer than a third of droplets are twice the minimum', `${(big / N).toFixed(3)}`); + ok(draw(0.5) < (p.satMin + p.satMax) * 0.5, 'the median sits well below the midpoint'); + + // Small droplets fly furthest. If this correlation inverts, the far field + // becomes a row of large blobs and the throw stops reading as a throw. + let smallest = 0; + let largest = 0; + for (let i = 1; i < field.satellites; i++) { + if (field.satelliteSize(i) < field.satelliteSize(smallest)) smallest = i; + if (field.satelliteSize(i) > field.satelliteSize(largest)) largest = i; + } + ok( + field.satelliteReach(smallest) > field.satelliteReach(largest), + 'the smallest droplet outflies the largest' + ); + + // Placement, against a rotated cast — the mirror splatterbrand aims with. + const along = new Vector3(1, 0, 0); + field.setPlacement(_to, along); + field.update(3, p); + for (let i = 0; i < field.satellites; i++) { + field.satellitePoint(i, _probe); + const reach = field.satelliteReach(i); + near(_probe.x - _to.x, reach, 1e-5, `satellite ${i} lands down the travel vector`); + ok( + Math.abs(_probe.z - _to.z) <= p.throwSpread * reach + 1e-5, + `satellite ${i} stays inside the throw cone` + ); + near(_probe.y, p.height, 1e-9, `satellite ${i} lands on the floor`); + } + + // And the canvas has to reach past the furthest one, tail included, or the + // far field is clipped and the whole thing reads as a rectangle of ink. + const half = field.mesh.scale.x * 0.5; + ok( + half >= p.throwFar + p.satMax * (1 + p.satTail), + 'the quad covers the furthest droplet and its tail', + `${half.toFixed(2)} m` + ); +} + +/* ---------------------------------------------------------------- */ +/* 7 · I1 — a paused slider must move something */ +/* ---------------------------------------------------------------- */ + +section('I1 on a zero-length frame'); + +{ + const brush = brushes[0]; + const p = brushStrokeParams(); + brush.update(1, p); + const before = brush.geometry.attributes.aStroke.array[3]; + p.width = 0.9; + p.inkLoad = 2.5; + p.dryBand = 5.5; + brush.update(1, p); // same clock, new sliders + ok(brush.material.uniforms.uWidth.value === 0.9, 'a paused width drag reaches the uniform'); + ok(brush.material.uniforms.uDryBand.value === 5.5, 'so does the dry band'); + ok(brush.geometry.attributes.aStroke.array[3] !== before, 'and the ink load re-expands'); + + const field = fields[InkMode.BLOOM]; + const q = inkDiffusionParams(); + field.update(1.5, q); + const scaleBefore = field.mesh.scale.x; + q.coarse = 0.75; + q.radius = 11; + q.dryTime = 4; + field.update(1.5, q); + ok(field.material.uniforms.uCoarse.value === 0.75, 'a paused finger-wavelength drag lands'); + ok(field.material.uniforms.uDryTime.value === 4, 'so does the drying time'); + ok(field.mesh.scale.x !== scaleBefore, 'and the canvas re-sizes to the new radius'); +} + +/* ---------------------------------------------------------------- */ + +for (const brush of brushes) brush.dispose(); +for (const field of fields) field.dispose(); +ok(benchParent.children.length === 0, 'dispose() takes every node back off the parent'); + +console.log( + failures === 0 + ? `\n✓ ${checks} assertions, no failures\n` + : `\n✗ ${failures} of ${checks} assertions failed\n` +); +process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/check-vfx-lensflare-mirror.mjs b/scripts/check-vfx-lensflare-mirror.mjs new file mode 100644 index 0000000..f11b412 --- /dev/null +++ b/scripts/check-vfx-lensflare-mirror.mjs @@ -0,0 +1,439 @@ +/** + * A bench for `vfx/LensFlare.js` and `vfx/Mirror.js`. + * + * `npm run check` drives both modules for a handful of frames and looks for + * NaN, which is the right thing for a shared stage to do and is not enough for + * either of these two. What is specific here: + * + * 1. **Static shader sanity.** Balanced braces and preprocessor, no reserved + * word used as a variable, every `u*` referenced by a stage declared in it, + * every declared uniform actually boxed, every fragment varying declared by + * the vertex stage. Those are the failures that survive writing the file + * and turn up as a blank screen and a link error naming no file. + * 2. **`commonGLSL` is not injected into a vertex stage.** It carries `aastep`, + * which calls `fwidth`, and derivatives do not exist in a vertex shader — + * the whole program fails to compile, pointing at a helper you are not + * using. `LensFlare`'s vertex stage restates the two helpers it wants; this + * asserts nobody "tidies" that back into an injection. + * 3. **The mirror scheduler**, which is pure CPU and is the part most likely + * to be wrong: a frame token that survives nested `render()` calls, a hard + * per-frame budget, and a starvation weighting that must not leave one + * mirror showing a frozen reflection forever. + * 4. **I1** — a value changed on a zero-length frame must move a uniform. + * + * Run: `node scripts/check-vfx-lensflare-mirror.mjs` + */ +import { Scene, Group, PerspectiveCamera, Vector3 } from 'three'; +import { LensFlare, lensFlareParams, MAX_FLARE_GHOSTS } from '../src/vfx/LensFlare.js'; +import { Mirror, mirrorParams, mirrorBudget, setMirrorBudget } from '../src/vfx/Mirror.js'; +import { LAYER } from '../src/core/Layers.js'; +import { frame } from '../src/core/FrameUniforms.js'; + +let failures = 0; +let checks = 0; + +function ok(condition, label, detail) { + checks++; + if (condition) return; + failures++; + console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`); +} + +function section(name) { + console.log(`\n${name}`); +} + +/* ---------------------------------------------------------------- */ +/* 1 · static shader sanity */ +/* ---------------------------------------------------------------- */ + +/** Identifiers three declares for us, which a stage may use without declaring. */ +const PROVIDED = new Set([ + 'uv', + 'position', + 'normal', + 'projectionMatrix', + 'modelViewMatrix', + 'modelMatrix', + 'viewMatrix', + 'normalMatrix', + 'cameraPosition' +]); + +const RESERVED = [ + 'flat', + 'smooth', + 'noperspective', + 'input', + 'output', + 'sample', + 'filter', + 'active', + 'asm', + 'union', + 'namespace', + 'using', + 'this', + 'packed', + 'cast' +]; + +/** + * Built-in function names that read like perfectly ordinary variable names. + * + * `three` converts a `ShaderMaterial` (as opposed to a `RawShaderMaterial`) to + * `#version 300 es`, so the ES 3.00 built-in set applies — and `round` is in + * it. `float round = …` inside an iris SDF is the obvious name, compiles on + * some drivers, and is refused by others; the flare's aperture parameter is + * called `roundness` for exactly that reason and this list is what stops it + * quietly going back. + */ +const SHADOWS = [ + 'round', + 'sign', + 'step', + 'mix', + 'length', + 'distance', + 'reflect', + 'refract', + 'fract', + 'floor', + 'ceil', + 'trunc', + 'min', + 'max', + 'abs', + 'mod', + 'pow', + 'log', + 'exp', + 'sqrt', + 'cross', + 'dot', + 'texture', + 'degrees', + 'radians' +]; + +const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' '); + +function declared(src, prefix) { + const out = new Set(); + const re = /\b(?:uniform|varying|attribute|in|out)\s+(?:lowp\s+|mediump\s+|highp\s+)?\w+\s+([^;]+);/g; + let m; + while ((m = re.exec(src))) { + for (const name of m[1].split(',')) { + const clean = name.trim().replace(/\[.*$/, '').trim(); + if (clean.startsWith(prefix)) out.add(clean); + } + } + return out; +} + +function referenced(src, prefix) { + const out = new Set(); + const re = new RegExp(`\\b${prefix}[A-Z]\\w*`, 'g'); + let m; + while ((m = re.exec(src))) out.add(m[0]); + return out; +} + +function checkStage(label, src, prefix) { + const body = stripComments(src); + const decls = declared(body, prefix); + for (const name of referenced(body, prefix)) { + if (PROVIDED.has(name)) continue; + ok(decls.has(name), `${label}: ${name} referenced but not declared`); + } + return decls; +} + +function checkBalance(label, src) { + const body = stripComments(src); + let braces = 0; + let parens = 0; + let underflow = false; + for (const ch of body) { + if (ch === '{') braces++; + else if (ch === '}') braces--; + else if (ch === '(') parens++; + else if (ch === ')') parens--; + if (braces < 0) underflow = true; + } + ok(!underflow, `${label}: closing brace before an opening one`); + ok(braces === 0, `${label}: unbalanced braces`, `${braces}`); + ok(parens === 0, `${label}: unbalanced parentheses`, `${parens}`); +} + +function checkReserved(label, src) { + const body = stripComments(src); + for (const word of RESERVED) { + const re = new RegExp(`\\b(?:float|int|bool|vec2|vec3|vec4|mat2|mat3|mat4)\\s+${word}\\b`); + ok(!re.test(body), `${label}: "${word}" is reserved and cannot be a variable name`); + } + for (const word of SHADOWS) { + const re = new RegExp(`\\b(?:float|int|bool|vec2|vec3|vec4|mat2|mat3|mat4)\\s+${word}\\b`); + ok(!re.test(body), `${label}: "${word}" shadows a built-in function`); + } +} + +function auditMaterial(label, material) { + const v = material.vertexShader; + const f = material.fragmentShader; + checkBalance(`${label} vertex`, v); + checkBalance(`${label} fragment`, f); + checkReserved(`${label} vertex`, v); + checkReserved(`${label} fragment`, f); + ok(!v.includes('`') && !f.includes('`'), `${label}: a backtick in the shader source`); + ok(!/\bcosh\s*\(/.test(v + f), `${label}: cosh() does not exist in ESSL 1.00`); + + const vu = checkStage(`${label} vertex`, v, 'u'); + const fu = checkStage(`${label} fragment`, f, 'u'); + for (const name of [...vu, ...fu]) { + if (PROVIDED.has(name)) continue; + ok(material.uniforms[name] !== undefined, `${label}: ${name} declared but has no uniform box`); + } + + const vVary = declared(stripComments(v), 'v'); + const fVary = declared(stripComments(f), 'v'); + for (const name of fVary) { + ok(vVary.has(name), `${label}: fragment declares varying ${name} the vertex stage does not`); + } +} + +section('static shader sanity'); + +const flare = new LensFlare({ name: 'bench' }); +const mirror = new Mirror({ name: 'bench', resolution: 128 }); +auditMaterial('LensFlare', flare.material); +auditMaterial('Mirror', mirror.material); + +// Derivatives are fragment-only. Injecting `commonGLSL` into a vertex stage +// takes `aastep`'s fwidth with it and fails the whole program to compile. +ok(!/\bfwidth\s*\(/.test(flare.material.vertexShader), 'LensFlare vertex stage is free of fwidth'); +ok(!/\bfwidth\s*\(/.test(mirror.material.vertexShader), 'Mirror vertex stage is free of fwidth'); +ok( + flare.material.vertexShader.includes('#include '), + 'LensFlare vertex stage pulls in for the depth unpack' +); +console.log(` ${checks} static assertion(s)`); + +/* ---------------------------------------------------------------- */ +/* 2 · LensFlare behaviour */ +/* ---------------------------------------------------------------- */ + +section('LensFlare'); + +ok(flare.drawCalls === 1, 'the whole flare is one draw call'); +ok(flare.object3D.layers.mask === 1 << LAYER.VFX, 'the flare sits on LAYER.VFX alone'); +ok(flare.material.depthTest === false, 'the flare is not depth tested — it is on the lens'); +ok(flare.material.toneMapped === false, 'the flare is not tone mapped by the renderer'); +ok(flare.object3D.renderOrder >= 3000, 'the flare draws after the scene'); +ok(flare.object3D.frustumCulled === false, 'an NDC-built quad must not be frustum culled'); + +const fp = lensFlareParams(); +flare.setAnchorXYZ(0, 2, 12); + +for (let ghosts = 0; ghosts <= MAX_FLARE_GHOSTS + 2; ghosts++) { + fp.ghosts = ghosts; + flare.update(fp); + const expected = 3 + Math.min(ghosts, MAX_FLARE_GHOSTS); + ok( + flare.object3D.geometry.instanceCount === expected, + `ghosts=${ghosts} draws ${expected} instances`, + String(flare.object3D.geometry.instanceCount) + ); +} + +// README trap 7, inverted: with no depth buffer bound the kernel would decide +// the source is buried in a wall and the flare would never appear at all. +const restoreDepth = frame.uSceneDepth.value; +frame.uSceneDepth.value = null; +fp.occlusion = 1; +flare.update(fp); +ok(flare.material.uniforms.uOcclusion.value === 0, 'no depth buffer bound disables the occlusion test'); +frame.uSceneDepth.value = { isTexture: true }; +flare.update(fp); +ok(flare.material.uniforms.uOcclusion.value === 1, 'a bound depth buffer re-enables it'); +frame.uSceneDepth.value = restoreDepth; + +// I1 — a zero-length frame is still a frame. +fp.streakLength = 0.9; +fp.colorStreak = '#ff0000'; +flare.update(fp); +ok(flare.material.uniforms.uStreakLength.value === 0.9, 'a size dragged while paused reaches the uniform'); +ok(flare.material.uniforms.uColorStreak.value.r > 0.9, 'a colour dragged while paused reaches the uniform'); + +// Nothing dimensioned may be captured: every metre and every fraction has to +// come back out of the params object on the next update. +fp.streakLength = 0.1; +flare.update(fp); +ok(flare.material.uniforms.uStreakLength.value === 0.1, 'and it re-resolves the frame after'); + +const taps = flare.material.uniforms.uOccTaps; +fp.occTaps = 40; +flare.update(fp); +ok(taps.value <= 9, 'the occlusion kernel is clamped to the unrolled loop bound', String(taps.value)); + +/* ---------------------------------------------------------------- */ +/* 3 · Mirror — the scheduler */ +/* ---------------------------------------------------------------- */ + +section('Mirror'); + +ok(mirror.drawCalls === 1, 'the surface is one draw call'); +ok(mirror.object3D.frustumCulled === false, 'a uniform-built quad culls itself, in _wantsRender'); +ok(mirror.material.uniforms.uHasReflection.value === 0, 'nothing is reflected before the first pass'); + +const baseLive = mirrorBudget.live; +ok(baseLive === 0, 'constructing a mirror does not register it'); +mirror.visible = true; +ok(mirrorBudget.live === 1, 'showing a mirror registers it'); +mirror.visible = true; +ok(mirrorBudget.live === 1, 'showing it twice registers it once'); +mirror.visible = false; +ok(mirrorBudget.live === 0, 'hiding it releases'); + +/** The least renderer `_renderReflection()` will accept. */ +function stubRenderer() { + return { + info: { render: { frame: 0, calls: 9, triangles: 5300 } }, + xr: { enabled: false }, + shadowMap: { autoUpdate: true }, + autoClear: false, + state: { buffers: { depth: { setMask() {} } }, viewport() {} }, + getRenderTarget: () => null, + setRenderTarget() {}, + clear() {}, + render() { + // The real one increments here too, which is the whole reason the module + // subtracts its own nested renders back out of the frame token. + this.info.render.frame++; + } + }; +} + +const scene = new Scene(); +const group = new Group(); +scene.add(group); +const camera = new PerspectiveCamera(50, 16 / 9, 0.1, 400); +camera.position.set(0, 2.4, -7); +camera.lookAt(new Vector3(0, 1.2, 12)); +camera.updateMatrixWorld(true); +camera.matrixWorldInverse.copy(camera.matrixWorld).invert(); + +const gl = stubRenderer(); +const previousBudget = mirrorBudget.max; +setMirrorBudget(2); + +const panes = []; +for (let i = 0; i < 5; i++) { + const m = new Mirror({ resolution: 128, name: `pane${i}` }); + group.add(m.object3D); + m.visible = true; + panes.push(m); +} +ok(mirrorBudget.live === 5, 'five live mirrors'); + +const mp = mirrorParams(); +const at = new Vector3(); +const firstSeen = new Map(); +let worstFrame = 0; + +for (let f = 0; f < 12; f++) { + gl.info.render.frame++; // the outer frame's own render call + for (let i = 0; i < panes.length; i++) { + at.set(i * 2.2 - 4.4, 1.2, 12); + mp.width = 2 + i * 0.3; + mp.height = 2; + mp.roughness = f % 2 ? 0.8 : 0; + panes[i].setPlacement(at, new Vector3(0, 0, -1), new Vector3(1, 0, 0)); + panes[i].update(mp); + } + for (const pane of panes) pane.object3D.onBeforeRender(gl, scene, camera); + + worstFrame = Math.max(worstFrame, mirrorBudget.rendered); + for (const pane of panes) { + if (pane.material.uniforms.uHasReflection.value > 0.5 && !firstSeen.has(pane.mesh.name)) { + firstSeen.set(pane.mesh.name, f); + } + } +} + +ok(worstFrame <= 2, 'never more than the budget in one frame', `worst frame rendered ${worstFrame}`); +ok(worstFrame === 2, 'and the budget is actually spent'); +ok(firstSeen.size === panes.length, 'every mirror got a turn', `${firstSeen.size} of ${panes.length}`); +ok( + Math.max(...firstSeen.values()) <= 3, + 'starvation weighting gives everyone a first turn inside four frames', + `worst wait ${Math.max(...firstSeen.values())} frames` +); +ok(mirrorBudget.calls === 2 * 9, 'the reflection cost is measured off renderer.info', String(mirrorBudget.calls)); + +for (const pane of panes) { + const e = pane.material.uniforms.uReflectMatrix.value.elements; + ok(e.every(Number.isFinite), `${pane.mesh.name}: the oblique lookup matrix is finite`); +} + +// The oblique near plane has to *move* when the mirror does, or it is not +// clipped to the mirror at all. +const before = panes[0].material.uniforms.uReflectMatrix.value.elements.slice(); +at.set(0, 4.5, 6); +panes[0].setPlacement(at, new Vector3(0, 1, 0), new Vector3(0, 0, 1)); +panes[0].update(mp); +gl.info.render.frame++; +setMirrorBudget(5); +for (const pane of panes) pane.object3D.onBeforeRender(gl, scene, camera); +const after = panes[0].material.uniforms.uReflectMatrix.value.elements; +ok(before.some((v, i) => Math.abs(v - after[i]) > 1e-6), 'moving the plane rebuilds the reflection matrix'); + +// A recursion guard: a mirror asked to render from inside another mirror's +// reflection must decline rather than blow the stack. +let depth = 0; +const recursive = stubRenderer(); +recursive.render = function render() { + this.info.render.frame++; + if (depth++ > 4) return; + for (const pane of panes) pane.object3D.onBeforeRender(recursive, scene, camera); +}; +gl.info.render.frame++; +recursive.info.render.frame = gl.info.render.frame; +for (const pane of panes) pane.object3D.onBeforeRender(recursive, scene, camera); +ok(depth <= panes.length, 'a nested reflection is refused, not recursed', `depth ${depth}`); + +// The depth prepass draws WORLD with an override material; a mirror on that +// layer must not render its reflection from inside it. +const overridden = new Scene(); +overridden.overrideMaterial = mirror.material; +const beforePrepass = mirrorBudget.rendered; +gl.info.render.frame++; +for (const pane of panes) pane.object3D.onBeforeRender(gl, overridden, camera); +ok(mirrorBudget.rendered === beforePrepass, 'the depth prepass does not trigger a reflection'); + +// I1 and the live re-size. +mp.resolution = 256; +panes[0].update(mp); +ok(panes[0].resolution === 256, 'the resolution slider re-sizes the target'); +ok(panes[0].material.uniforms.uHasReflection.value === 0, 'a re-sized target is not trusted until it is redrawn'); +mp.roughness = 0.42; +panes[0].update(mp); +ok(panes[0].material.uniforms.uRoughness.value === 0.42, 'roughness dragged while paused reaches the uniform'); +mp.width = 7.5; +panes[0].update(mp); +ok(panes[0].material.uniforms.uSize.value.x === 7.5, 'a metre dragged while paused reaches the uniform'); + +for (const pane of panes) pane.dispose(); +ok(mirrorBudget.live === 0, 'disposing every mirror empties the register'); +setMirrorBudget(previousBudget); +flare.dispose(); +mirror.dispose(); + +/* ---------------------------------------------------------------- */ + +console.log( + failures === 0 + ? `\n✓ ${checks} assertions, no failures\n` + : `\n✗ ${failures} of ${checks} assertions failed\n` +); +process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/check-vfx-tube-shell.mjs b/scripts/check-vfx-tube-shell.mjs new file mode 100644 index 0000000..9e6e6a3 --- /dev/null +++ b/scripts/check-vfx-tube-shell.mjs @@ -0,0 +1,313 @@ +/** + * `scripts/check-vfx-tube-shell.mjs` — the harness for `vfx/Tube.js` and + * `vfx/Shell.js`. + * + * Separate from `scripts/check.mjs` only because that one walks the **registry** + * and these two modules are not yet reachable from an ability. Fold it in — or + * add `"check:vfx"` to package.json — the moment one is. + * + * No WebGL needed, for the same reason `check.mjs` does not need it: three + * builds geometry, materials and scene graphs on the CPU and both modules defer + * GL to render time. What that cannot check is whether the GLSL *compiles*, so + * the first section does the next best thing statically — every `uXxx` used in + * a shader must be declared in the same stage, the preprocessor and the braces + * must balance, and the two stages must agree on their varyings. Those three + * catch essentially every typo that survives writing the file. + * + * The interesting assertions are further down: + * + * - the whip's tip actually beats its own wave speed, and cracks exactly once; + * - the funnel's skirt and mouth are both wider than its throat; + * - the ring train's far end is a node to within floating point; + * - and **I1**: a slider dragged on a zero-length frame moves the standing + * shape, including re-arming a crack that had not fired. + */ +import { Vector3 } from 'three'; +import { Tube, TubePath, TubeLayer, tubeDefaults, tubeKeys, tubeSchema, createTubeMaterial } from '../src/vfx/Tube.js'; +import { Shell, ShellMode, shellDefaults, shellSchema, BurstMode, BurstSystem } from '../src/vfx/Shell.js'; +import { settings } from '../src/config/settings.js'; +import { frame } from '../src/core/FrameUniforms.js'; + +let failures = 0; +const ok = (name, cond, extra = '') => { + if (!cond) { + failures++; + console.error(` FAIL ${name} ${extra}`); + } else { + console.log(` ok ${name}`); + } +}; + +const finite = (v) => Number.isFinite(v); + +function scanUniforms(material, label) { + for (const [key, box] of Object.entries(material.uniforms)) { + const v = box.value; + if (typeof v === 'number' && !finite(v)) return `${label}.${key} = ${v}`; + if (v && v.isVector3 && !(finite(v.x) && finite(v.y) && finite(v.z))) return `${label}.${key} = ${v.toArray()}`; + if (v && v.isColor && !(finite(v.r) && finite(v.g) && finite(v.b))) return `${label}.${key} = colour NaN`; + } + return null; +} + +/* ---------------------------------------------------------------- */ +/* 1. GLSL identifier sanity — every uXxx used must be declared */ +/* ---------------------------------------------------------------- */ +console.log('\n[glsl] uniform declaration / usage cross-check'); +for (const [layer, path] of [[TubeLayer.CORE, TubePath.WHIP], [TubeLayer.SHEATH, TubePath.FUNNEL], [TubeLayer.HALO, TubePath.VINE], [TubeLayer.CORE, TubePath.ARC], [TubeLayer.CORE, TubePath.STRAIGHT]]) { + const m = createTubeMaterial(layer, path); + checkShader(`Tube(layer=${layer},path=${path})`, m); +} +for (const mode of Object.values(ShellMode)) { + const { createShellMaterial } = await import('../src/vfx/Shell.js'); + checkShader(`Shell(mode=${mode})`, createShellMaterial(mode)); +} + +function checkShader(label, material) { + for (const [stage, src] of [['vs', material.vertexShader], ['fs', material.fragmentShader]]) { + const declared = new Set([...src.matchAll(/uniform\s+\w+\s+(\w+)\s*;/g)].map((m) => m[1])); + // three injects these + for (const k of ['modelMatrix', 'viewMatrix', 'projectionMatrix', 'cameraPosition', 'normalMatrix', 'modelViewMatrix']) declared.add(k); + const used = new Set([...src.matchAll(/\bu[A-Z]\w*/g)].map((m) => m[0])); + const missing = [...used].filter((u) => !declared.has(u) && !u.startsWith('uv')); + ok(`${label} ${stage} uniforms declared`, missing.length === 0, missing.join(', ')); + + // balanced preprocessor + let depth = 0; + for (const line of src.split('\n')) { + const t = line.trim(); + if (t.startsWith('#if')) depth++; + else if (t.startsWith('#endif')) depth--; + if (depth < 0) break; + } + ok(`${label} ${stage} #if/#endif balanced`, depth === 0, `depth=${depth}`); + + // varyings written in vs must be declared, and vice versa + const brace = (src.match(/{/g) || []).length - (src.match(/}/g) || []).length; + ok(`${label} ${stage} braces balanced`, brace === 0, `delta=${brace}`); + } + const vsVary = new Set([...material.vertexShader.matchAll(/varying\s+\w+\s+(\w+)\s*;/g)].map((m) => m[1])); + const fsVary = new Set([...material.fragmentShader.matchAll(/varying\s+\w+\s+(\w+)\s*;/g)].map((m) => m[1])); + ok(`${label} varying sets match`, [...fsVary].every((v) => vsVary.has(v)) && [...vsVary].every((v) => fsVary.has(v))); +} + +/* ---------------------------------------------------------------- */ +/* 2. Tube — every path, 240 frames, no NaN */ +/* ---------------------------------------------------------------- */ +console.log('\n[tube] path modes'); +const origin = new Vector3(0, 1.2, 0); +const target = new Vector3(0, 0.6, 12); +const side = new Vector3(1, 0, 0); + +for (const [name, path] of Object.entries(TubePath)) { + const config = { ...tubeDefaults('tube', path) }; + const tube = new Tube({ path, nodes: 64, sides: 20 }); + const state = { origin, target, side, progress: 0, fade: 1, widthFade: 1, seed: 0.37, time: 0, grow: 0, snapAge: -1 }; + + let bad = null; + let sawCrack = 0; + let peakTip = 0; + for (let i = 0; i < 240; i++) { + const t = i / 60; + frame.uTime.value = t; + state.time = t; + state.progress = Math.min(1, t * 1.6); + state.grow = Math.min(1, t * 1.2); + state.snapAge = path === TubePath.VINE && t > 1.2 ? t - 1.2 : -1; + tube.sync(config, state); + if (tube.crack.fired) sawCrack++; + peakTip = Math.max(peakTip, tube.tipSpeed); + for (const key of ['core', 'sheath', 'halo']) { + bad = bad || scanUniforms(tube.materials[key], `${name}.${key}`); + } + for (const q of [0, 0.25, 0.5, 0.75, 1]) { + if (!finite(tube.radiusAt(q))) bad = bad || `${name}.radiusAt(${q}) NaN`; + const p = tube.pointAt(q, new Vector3()); + if (!finite(p.x + p.y + p.z)) bad = bad || `${name}.pointAt(${q}) NaN`; + if (p.length() > 500) bad = bad || `${name}.pointAt(${q}) escaped: ${p.toArray()}`; + const tg = tube.tangentAt(q, new Vector3()); + if (!finite(tg.x + tg.y + tg.z)) bad = bad || `${name}.tangentAt(${q}) NaN`; + } + } + ok(`${name}: 240 frames clean`, bad === null, bad || ''); + ok(`${name}: draw calls = 3`, tube.drawCalls === 3); + if (path === TubePath.WHIP) { + ok('WHIP: the crack fired', sawCrack > 0, `fired ${sawCrack}×`); + ok('WHIP: tip beats the wave', peakTip > tube.waveSpeed, `peak ${peakTip.toFixed(2)} vs wave ${tube.waveSpeed.toFixed(2)}`); + console.log(` peak tip speed ${peakTip.toFixed(2)} m/s, wave speed ${tube.waveSpeed.toFixed(2)} m/s, cracks ${sawCrack}`); + } + if (path === TubePath.VINE) { + ok('VINE: tip radius is zero', tube.radiusAt(1) < 1e-3, `${tube.radiusAt(1)}`); + ok('VINE: recoil moved the tip', peakTip > 0, `${peakTip}`); + } + if (path === TubePath.FUNNEL) { + const throat = tube.radiusAt(0.4); + ok('FUNNEL: skirt > throat', tube.skirtRadius > throat, `${tube.skirtRadius.toFixed(2)} vs ${throat.toFixed(2)}`); + ok('FUNNEL: mouth > throat', tube.mouthRadius > throat, `${tube.mouthRadius.toFixed(2)} vs ${throat.toFixed(2)}`); + console.log(` skirt ${tube.skirtRadius.toFixed(2)}m throat ${throat.toFixed(2)}m mouth ${tube.mouthRadius.toFixed(2)}m`); + } + tube.dispose(); +} + +/* ---------------------------------------------------------------- */ +/* 3. I1 — the paused-slider test, on a zero-length frame */ +/* ---------------------------------------------------------------- */ +console.log('\n[I1] paused-slider test (dt = 0)'); +{ + const config = { ...tubeDefaults('tube', TubePath.FUNNEL) }; + const tube = new Tube({ path: TubePath.FUNNEL }); + const state = { origin: new Vector3(), target: new Vector3(0, 8, 0), side, progress: 1, fade: 1, widthFade: 1, seed: 0.5, time: 1.5, grow: 1, snapAge: -1 }; + frame.uTime.value = 1.5; + tube.sync(config, state); + const before = tube.skirtRadius; + const beforeU = tube.materials.core.uniforms.uSkirtFlare.value; + + config.tubeSkirtFlare = 4.4; // the drag + tube.sync(config, state); // zero-length frame: same time, same state + ok('funnel skirt re-resolved', tube.skirtRadius !== before, `${before} → ${tube.skirtRadius}`); + ok('funnel uniform re-resolved', tube.materials.core.uniforms.uSkirtFlare.value !== beforeU); + + settings.global.opacity = 0.5; + tube.sync(config, state); + ok('global multiplier applies live', tube.materials.core.uniforms.uOpacity.value === config.tubeOpacity * 0.5); + settings.global.opacity = 1; + tube.dispose(); +} +{ + // The crack must be re-armable by a slider while paused. + const config = { ...tubeDefaults('tube', TubePath.WHIP) }; + config.tubeWaveAmp = 0.02; // far too small to crack + const tube = new Tube({ path: TubePath.WHIP }); + const state = { origin, target, side, progress: 1, fade: 1, widthFade: 1, seed: 0.1, time: 0.72, grow: 1, snapAge: -1 }; + frame.uTime.value = 0.72; + tube.sync(config, state); + const quiet = tube.crack.fired; + config.tubeWaveAmp = 0.45; // the drag, clock stopped + tube.sync(config, state); + ok('whip crack fires off a slider while paused', quiet === false && tube.crack.fired === true); + tube.sync(config, state); + ok('whip crack does not re-fire while held', tube.crack.fired === false); + tube.dispose(); +} + +/* ---------------------------------------------------------------- */ +/* 4. Contract guard */ +/* ---------------------------------------------------------------- */ +console.log('\n[contract] missing-key guard'); +{ + const tube = new Tube({}); + const warnings = []; + const realWarn = console.warn; + console.warn = (m) => warnings.push(m); + tube.sync({}, { origin, target, side }); + tube.sync({}, { origin, target, side }); + console.warn = realWarn; + ok('Tube warns once about an incomplete block', warnings.length === 1 && /missing 79 key/.test(warnings[0]), warnings[0] || ''); + ok('Tube still draws finite geometry from the defaults', scanUniforms(tube.materials.core, 'bare') === null); + tube.dispose(); +} + +/* ---------------------------------------------------------------- */ +/* 5. Shell — every mode */ +/* ---------------------------------------------------------------- */ +console.log('\n[shell] modes'); +for (const [name, mode] of Object.entries(ShellMode)) { + const config = { ...shellDefaults('shell', mode) }; + const shell = new Shell({ mode, rings: 16 }); + const state = { origin: new Vector3(0, 0, 0), axis: new Vector3(0, 1, 0), side, span: 14, t: 0, fade: 1, seed: 0.8 }; + + let bad = null; + for (let i = 0; i < 240; i++) { + frame.uTime.value = i / 60; + state.t = Math.min(1, i / 120); + shell.sync(config, state); + bad = bad || scanUniforms(shell.material, name); + if (!finite(shell.radius)) bad = bad || `${name}.radius NaN`; + } + ok(`${name}: 240 frames clean`, bad === null, bad || ''); + ok(`${name}: one draw call`, shell.drawCalls === 1); + if (mode === ShellMode.RING_TRAIN) { + ok('RING_TRAIN: instance count follows the slider', shell.geometry.instanceCount === 10); + const lambda = shell.resonantSpacing(9); + config.shellSpacing = lambda; + shell.sync(config, state); + // With 9 half-waves on the line the far end and every λ/2 back is a node. + const atNode = shell.standingAt(shell.span); + const atAnti = shell.standingAt(shell.span - shell.nodeSpacing * 0.5); + ok('RING_TRAIN: far end is a node', Math.abs(atNode) < 1e-6, `${atNode}`); + ok('RING_TRAIN: antinode is brighter than the node', atAnti > atNode); + ok('RING_TRAIN: node count sane', shell.nodeCount === 10, `${shell.nodeCount}`); + const p = shell.nodePosition(3, new Vector3()); + ok('RING_TRAIN: node position finite', finite(p.x + p.y + p.z)); + console.log(` resonant λ ${lambda.toFixed(3)} m, ${shell.nodeCount} nodes, spacing ${shell.nodeSpacing.toFixed(3)} m`); + } + if (mode === ShellMode.DOME) { + const r0 = shell.radius; + config.shellRadiusEnd = 12; + shell.sync(config, state); + ok('DOME: radius re-resolves on a paused frame', shell.radius !== r0, `${r0} → ${shell.radius}`); + } + shell.dispose(); +} + +/* ---------------------------------------------------------------- */ +/* 6. Degenerate frames */ +/* ---------------------------------------------------------------- */ +console.log('\n[degenerate] vertical axes and zero-length casts'); +{ + const config = { ...tubeDefaults('tube', TubePath.FUNNEL) }; + const tube = new Tube({ path: TubePath.FUNNEL }); + // Vertical axis with `side` parallel to it — the frame must not go NaN. + tube.sync(config, { + origin: new Vector3(), + target: new Vector3(0, 6, 0), + side: new Vector3(0, 1, 0), + progress: 1, fade: 1, widthFade: 1, seed: 0, time: 0, grow: 1, snapAge: -1 + }); + const p = tube.pointAt(0.5, new Vector3()); + ok('vertical funnel, parallel side ref', finite(p.x + p.y + p.z), p.toArray().join(',')); + // Zero-length cast. + tube.sync(config, { + origin: new Vector3(3, 0, 3), target: new Vector3(3, 0, 3), side, + progress: 1, fade: 1, widthFade: 1, seed: 0, time: 0, grow: 1, snapAge: -1 + }); + ok('zero-length cast', finite(tube.pointAt(1, new Vector3()).x) && finite(tube.tipSpeed)); + tube.dispose(); + + const shell = new Shell({ mode: ShellMode.CONE }); + shell.sync({ ...shellDefaults('shell', ShellMode.CONE) }, { + origin: new Vector3(), axis: new Vector3(0, 0, 0), side: new Vector3(0, 1, 0), span: 0, t: 0.5, fade: 1, seed: 0 + }); + ok('shell with a zero axis and zero span', scanUniforms(shell.material, 'cone') === null); + shell.dispose(); +} + +/* ---------------------------------------------------------------- */ +/* 7. Schemas + the BurstSphere re-export */ +/* ---------------------------------------------------------------- */ +console.log('\n[schema] editor coverage'); +{ + const config = tubeDefaults('tube'); + const schema = tubeSchema('tube', TubePath.WHIP); + const covered = new Set(); + for (const list of Object.values(schema)) for (const e of list) covered.add(Array.isArray(e) ? e[0] : e); + const uncovered = Object.keys(config).filter((k) => !covered.has(k)); + ok('tubeSchema references only real keys', [...covered].every((k) => k in config), [...covered].filter((k) => !(k in config)).join(', ')); + console.log(` ${Object.keys(config).length} tube keys, ${covered.size} in named folders, ${uncovered.length} fall through to "More"`); + + const sconfig = shellDefaults('shell'); + const sschema = shellSchema('shell', ShellMode.RING_TRAIN); + const scovered = new Set(); + for (const list of Object.values(sschema)) for (const e of list) scovered.add(Array.isArray(e) ? e[0] : e); + ok('shellSchema references only real keys', [...scovered].every((k) => k in sconfig), [...scovered].filter((k) => !(k in sconfig)).join(', ')); + console.log(` ${Object.keys(sconfig).length} shell keys, ${scovered.size} in named folders`); + + ok('two prefixes do not collide', Object.keys(tubeDefaults('lash', TubePath.WHIP)).every((k) => !(k in config))); + ok('per-path tuning applies', tubeDefaults('lash', TubePath.WHIP).lashRadius !== tubeDefaults('lash', TubePath.STRAIGHT).lashRadius); + ok('prefixed overrides win', tubeDefaults('lash', TubePath.WHIP, { lashRadius: 9 }).lashRadius === 9); + ok('BurstSphere still re-exports', typeof BurstSystem === 'function' && BurstMode.FIRE === 0); + ok('tubeKeys maps unprefixed → prefixed', tubeKeys('lash').radius === 'lashRadius'); +} + +console.log(failures === 0 ? '\nALL CHECKS PASSED\n' : `\n${failures} CHECK(S) FAILED\n`); +process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/check.mjs b/scripts/check.mjs new file mode 100644 index 0000000..b5dc6ae --- /dev/null +++ b/scripts/check.mjs @@ -0,0 +1,3939 @@ +#!/usr/bin/env node +/** + * check.mjs — the headless verification harness (docs/EXPANSION.md §7). + * + * Forty-four abilities are about to be written by people who cannot see the + * screen this project renders to. This file is the thing that stands in for + * looking at it. It runs in plain Node with no WebGL, because three.js builds + * geometry, materials and scene graphs entirely on the CPU and every VFX system + * in this repo defers GL to render time — so an ability can be constructed, + * cast, ticked through all four phases and torn down without a canvas ever + * existing. + * + * Five passes, in increasing order of how much they cost and how much they find: + * + * 1. **Structure.** Every registered id has a settings block, a schema, a + * sigil and at most one keyboard letter; every block carries the five + * fields the framework indexes blind (`range`, `minRange`, `speed`, + * `cooldown`, `castAnim`, plus `zoneRadius` for a far cast) and the ranges + * are not nonsense. + * + * 2. **The static settings cross-check.** The highest-value pass in the file. + * Every source file under `src/` is lexed, every property read off a + * settings block is collected — `settings..`, `const c = + * settings.` then `c.`, `this.config.`, and destructuring + * from any of those — and each one is looked up in the block it names. A + * key that does not exist reads as `undefined`, multiplies into `NaN`, + * and produces geometry that silently vanishes with no error anywhere. + * That is the single most common way one of these abilities breaks, it + * costs an afternoon to find by eye, and it costs eight milliseconds to + * find here. + * + * The reverse direction — a key in the block that nothing ever reads — is + * a **warning**, never a failure, because a shared VFX module reads its + * configuration through a live object reference that no amount of + * grepping can attribute to an id. + * + * 3. **Runtime simulation.** Each ability is constructed against real + * subsystems on a bare `Scene`, cast, and ticked 240 frames at 1/60 with + * the frame clock driven forward and every subsystem flushed exactly as + * `App` flushes it. Any throw, any `NaN` or `Infinity` in a particle + * attribute array or a mesh transform, any mesh added to the group during + * a cast, any ability that never reaches `DONE` — all fatal. It is then + * destroyed and cast a *second* time, because the pooling contract says + * `destroy()` leaves the instance reusable and nothing else tests that. + * + * 4. **The pause test.** Invariant I1 has one observable consequence: stop + * the clock, drag a slider, and the standing effect changes. So we stop + * the clock (`dt = 0`, which is exactly what `App` feeds the manager when + * paused), snapshot every uniform, every transform and every instance + * matrix the ability owns, scale its numeric settings by 1.37, tick one + * zero-length frame, and demand that something moved. Twice — once + * mid-travel and once mid-impact — because an ability that resolves its + * dimensions during travel and then bakes them at impact passes the first + * sample and fails the second, and that is the exact shape of the mistake. + * + * When the ability is *stable* at rest (a zero-length frame with no + * mutation changes nothing) the harness goes further and probes each + * slider on its own, which turns "this ability responds" into "37 of its + * 54 sliders are observable while paused, and here are the seventeen that + * are not". Run with `--sliders` to see the list. + * + * 5. **Report.** One row per ability, then a detail section for the rows that + * failed. Non-zero exit on any failure. + * + * Usage: + * npm run check every registered ability + * npm run check -- --only ice one id, which is how you will actually use it + * npm run check -- --quiet the table and the failures, nothing else + * npm run check -- --sliders list the sliders that do nothing while paused + */ + +/* ------------------------------------------------------------------ */ +/* §0 · Browser shims — installed before a single project module loads */ +/* ------------------------------------------------------------------ */ +/** + * Nothing in `src/` needs a DOM today, and the harness would import cleanly + * without any of this. It is here for the ability that eventually reaches for + * `window.devicePixelRatio` at module scope, or for the three.js code path that + * decides it is in a browser and asks for a canvas: a missing global there + * throws during *import*, which reports as "the whole harness is broken" + * rather than "one module misbehaved". + * + * The stubs are deliberately inert. They exist to be present, not to work — a + * shim that half-works lets a module take a browser path in Node and fail two + * hundred lines later, which is worse than failing at the first call. + */ +const canvasStub = () => { + const context2d = { + canvas: null, + fillStyle: '#000', + strokeStyle: '#000', + globalAlpha: 1, + fillRect() {}, + clearRect() {}, + drawImage() {}, + getImageData: (_x, _y, w = 1, h = 1) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h }), + putImageData() {}, + createLinearGradient: () => ({ addColorStop() {} }), + createRadialGradient: () => ({ addColorStop() {} }), + measureText: () => ({ width: 0 }), + fillText() {}, + beginPath() {}, + closePath() {}, + moveTo() {}, + lineTo() {}, + arc() {}, + fill() {}, + stroke() {}, + save() {}, + restore() {}, + translate() {}, + scale() {}, + rotate() {} + }; + const element = { + width: 1, + height: 1, + style: {}, + className: '', + dataset: {}, + children: [], + getContext: (kind) => (kind === '2d' ? context2d : null), + toDataURL: () => 'data:,', + appendChild(child) { + this.children.push(child); + return child; + }, + removeChild() {}, + remove() {}, + setAttribute() {}, + getAttribute: () => null, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => true, + getBoundingClientRect: () => ({ x: 0, y: 0, width: 1, height: 1, top: 0, left: 0, right: 1, bottom: 1 }), + querySelector: () => null, + querySelectorAll: () => [] + }; + context2d.canvas = element; + return element; +}; + +if (typeof globalThis.window === 'undefined') { + const storage = new Map(); + globalThis.window = globalThis; + globalThis.self = globalThis; + globalThis.devicePixelRatio = 1; + globalThis.innerWidth = 1280; + globalThis.innerHeight = 720; + globalThis.addEventListener = () => {}; + globalThis.removeEventListener = () => {}; + globalThis.dispatchEvent = () => true; + globalThis.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); + globalThis.requestAnimationFrame = (fn) => setTimeout(() => fn(0), 16); + globalThis.cancelAnimationFrame = (handle) => clearTimeout(handle); + globalThis.localStorage = { + getItem: (key) => (storage.has(key) ? storage.get(key) : null), + setItem: (key, value) => void storage.set(key, String(value)), + removeItem: (key) => void storage.delete(key), + clear: () => storage.clear(), + key: (index) => [...storage.keys()][index] ?? null, + get length() { + return storage.size; + } + }; +} + +if (typeof globalThis.document === 'undefined') { + const root = canvasStub(); + globalThis.document = { + documentElement: root, + body: root, + head: root, + createElement: () => canvasStub(), + createElementNS: () => canvasStub(), + createTextNode: (text) => ({ nodeValue: String(text) }), + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + createEvent: () => ({ initEvent() {} }) + }; +} + +if (typeof globalThis.performance === 'undefined') { + globalThis.performance = { now: () => Number(process.hrtime.bigint() / 1000n) / 1000 }; +} + +/* ------------------------------------------------------------------ */ +/* §1 · Imports */ +/* ------------------------------------------------------------------ */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { + Scene, + Group, + PerspectiveCamera, + Vector3, + Quaternion, + ConeGeometry, + IcosahedronGeometry, + TetrahedronGeometry, + BoxGeometry, + Mesh, + BufferAttribute, + Bone, + Skeleton, + SkinnedMesh, + MeshBasicMaterial, + MeshStandardMaterial +} from 'three'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, '..'); +const SRC = path.join(ROOT, 'src'); + +/* + * `config/settings.js` is imported FIRST, and the order is load-bearing. + * + * Several settings modules now spread a prefixed defaults fragment from the + * tech library (`shellDefaults`, `tubeDefaults`, `volumeHullDefaults`), and + * those library modules import `config/settings.js` for their `settings.global` + * default argument. That closes a cycle: + * + * registry → config/abilities/index → → vfx/Shell → config/settings + * → config/abilities/index (already evaluating: TDZ) + * + * Entering the cycle at `config/settings.js` instead is fine, because the only + * thing the library wants from it is a live binding it reads at call time, and + * by the time anything calls, everything is initialised. Entering at the + * registry is not: `settings.js` reaches `...ABILITY_SETTINGS` while + * `abilities/index.js` is still half-way down its own import list, and Node + * throws `Cannot access 'ABILITY_SETTINGS' before initialization`. The browser + * happens to enter through `settings.js` (via `core/Renderer.js`); the harness + * has to do the same deliberately. + */ +const { settings, CAST_ANIMATIONS, CastShape } = await import('../src/config/settings.js'); +const { ABILITIES, SCHOOLS } = await import('../src/abilities/registry.js'); +const { ABILITY_SETTINGS, ABILITY_SCHEMAS } = await import('../src/config/abilities/index.js'); +const { AbilityPhase } = await import('../src/abilities/Ability.js'); +const { frame } = await import('../src/core/FrameUniforms.js'); +const { ParticleEngine } = await import('../src/particles/ParticleEngine.js'); +const { LightPool } = await import('../src/effects/LightPool.js'); +const { DecalSystem } = await import('../src/effects/GroundDecals.js'); +const { FissureSystem } = await import('../src/effects/GroundFissures.js'); +const { BurstSystem } = await import('../src/effects/BurstSphere.js'); +const { ELEMENT_SIGILS } = await import('../src/ui/glyphs/index.js'); +const { patchOnBeforeCompile } = await import('../src/utils/shaderPatch.js'); + +/** + * The two **global** pools an ability can take a piece of the world from: the + * app-wide `sceneHooks` ledger and the app-wide `timeField`. They are read here + * so the runtime pass can assert that `destroy()` gave everything back. + * + * Imported softly. A syntax error in either file is the VFX stage's business to + * report, with a filename and a line; if it also took the ability stage down + * with it, fifty green abilities would read as fifty broken ones. + */ +let _sceneHooks = null; +let _timeField = null; +try { + ({ sceneHooks: _sceneHooks } = await import('../src/vfx/SceneHooks.js')); +} catch { + /* reported by the VFX stage */ +} +try { + ({ timeField: _timeField } = await import('../src/vfx/TimeControl.js')); +} catch { + /* reported by the VFX stage */ +} +const sceneHooksLive = () => _sceneHooks?.heldCount ?? 0; +const sceneHooksReleaseAll = () => _sceneHooks?.releaseAll(); +const timeRegionsLive = () => _timeField?.liveCount ?? 0; +const timeRegionsReset = () => _timeField?.reset(); + +/* ------------------------------------------------------------------ */ +/* §2 · Arguments and reporting plumbing */ +/* ------------------------------------------------------------------ */ + +const argv = process.argv.slice(2); +const flag = (name) => argv.includes(name); +const value = (name) => { + const index = argv.indexOf(name); + return index >= 0 ? argv[index + 1] : undefined; +}; + +const ONLY = value('--only'); +const QUIET = flag('--quiet'); +const SHOW_SLIDERS = flag('--sliders'); + +const COLOUR = process.stdout.isTTY && !process.env.NO_COLOR; +const paint = (code, text) => (COLOUR ? `[${code}m${text}` : text); +const red = (t) => paint('31', t); +const green = (t) => paint('32', t); +const yellow = (t) => paint('33', t); +const dim = (t) => paint('2', t); +const bold = (t) => paint('1', t); + +/** Simulation constants. Named because every one of them is a judgement call. */ +const DT = 1 / 60; // seconds per simulated frame +const REQUIRED_FRAMES = 240; // frames every cast is ticked for, minimum +const MAX_FRAMES = 900; // frames after which a cast that has not finished has hung +const REUSE_FRAMES = 60; // frames of the second cast, which tests the pooling contract +const NAN_STRIDE = 8; // scan the (large) particle arrays every Nth frame +const SANITY_RADIUS = 5000; // metres; a mesh further out than this is a broken transform +const DRAW_CALL_BUDGET = 12; // I7 — meshes in one ability's group +const MUTATION = 1.37; // the factor a slider is scaled by in the pause test +const CHANGE_EPSILON = 1e-6; // relative tolerance below which "it moved" is float noise + +/** Keys the framework indexes blind on every block, whether or not it reads them. */ +const CAST_KEYS = ['range', 'minRange', 'speed', 'cooldown', 'castAnim']; +/** Keys excluded from the pause mutation: they are consumed at spawn, not per frame. */ +const PAUSE_EXCLUDED = new Set(['range', 'minRange', 'speed', 'cooldown']); + +/** One of these per registered ability; the table is these rows. */ +class Report { + constructor(id) { + this.id = id; + this.failures = []; + this.warnings = []; + this.notes = []; + this.marks = { struct: null, static: null, runtime: null, travel: null, impact: null }; + } + + fail(stage, message) { + this.failures.push({ stage, message }); + if (stage in this.marks) this.marks[stage] = false; + return false; + } + + warn(stage, message) { + this.warnings.push({ stage, message }); + } + + note(message) { + this.notes.push(message); + } + + /** Mark a stage passed unless something already failed in it. */ + pass(stage) { + if (this.marks[stage] !== false) this.marks[stage] = true; + } + + get ok() { + return this.failures.length === 0; + } +} + +/** Failures that belong to the roster as a whole rather than to one ability. */ +const globalFailures = []; +const globalWarnings = []; +const failGlobal = (message) => globalFailures.push(message); +const warnGlobal = (message) => globalWarnings.push(message); + +const log = (...args) => { + if (!QUIET) console.log(...args); +}; + +/* ------------------------------------------------------------------ */ +/* §3 · Structure */ +/* ------------------------------------------------------------------ */ + +const ALL_IDS = ABILITIES.map((a) => a.id); +const TARGET = ONLY ? ABILITIES.filter((a) => a.id === ONLY) : ABILITIES; + +if (ONLY && TARGET.length === 0) { + console.error(red(`check: no registered ability with id "${ONLY}".`)); + console.error(dim(` known ids: ${ALL_IDS.join(', ')}`)); + process.exit(2); +} + +const reports = new Map(TARGET.map((a) => [a.id, new Report(a.id)])); +const SCHOOL_IDS = new Set(SCHOOLS.map((s) => s.id)); + +/** Registry-wide invariants: unique ids, unique keys, known schools. */ +function checkRoster() { + const seenIds = new Set(); + const seenKeys = new Map(); + + for (const descriptor of ABILITIES) { + if (seenIds.has(descriptor.id)) failGlobal(`duplicate registry id "${descriptor.id}"`); + seenIds.add(descriptor.id); + + if (descriptor.key) { + const key = String(descriptor.key).toUpperCase(); + if (seenKeys.has(key)) { + failGlobal(`key "${key}" is bound to both "${seenKeys.get(key)}" and "${descriptor.id}"`); + } + seenKeys.set(key, descriptor.id); + } + } + + // A settings block with no registry entry is a block nothing can ever cast. + for (const id of Object.keys(ABILITY_SETTINGS)) { + if (!seenIds.has(id)) warnGlobal(`settings block "${id}" is not registered in ABILITIES`); + } + for (const id of Object.keys(ABILITY_SCHEMAS)) { + if (!seenIds.has(id)) warnGlobal(`editor schema "${id}" is not registered in ABILITIES`); + } +} + +/* ------------------------------------------------------------------ */ +/* §3b · The schools */ +/* ------------------------------------------------------------------ */ +/** + * A school is four things that have to land together, and the reason this + * check exists is that only one of them fails loudly. + * + * An entry in `SCHOOLS` with no `ui/glyphs/.js`, or with a module that + * `ui/glyphs/index.js` never folds into `ELEMENT_SIGILS`, is *invisible*: the + * school builds a spellbook section and an editor folder perfectly happily, + * and every ability in it silently draws no sigil. The per-ability structure + * pass catches that eventually — one "no sigil" failure per ability — but only + * once somebody has written an ability, which is days after the school landed + * and in someone else's diff. Here it fails on the school itself, immediately, + * and names the two files to create. + * + * The fold is verified two ways on purpose. The source scan proves the wiring + * exists even when the module is an empty stub with nothing to prove it with — + * which is exactly the state a new school is in — and the identity comparison + * proves the marks actually arrive, which catches the other failure: two + * schools exporting a sigil under the same ability id, where the later spread + * silently wins and one school's mark disappears. + */ +const GLYPH_DIR = path.join(SRC, 'ui', 'glyphs'); + +/** Modules in `ui/glyphs/` that are not a school. */ +const GLYPH_INFRASTRUCTURE = new Set(['index.js', 'wrap.js']); + +/** + * The bar for "two accents nobody can tell apart", set by the tightest pair + * that already ships: frost `#7ecbe0` against aether `#8fe8d8` scores 0.081. + * A new school closer to an existing one than two schools the player has + * lived with for fifty abilities is a warning, not a failure — it is a + * judgement about a 7px dot, and the author may have a reason. + */ +const ACCENT_FLOOR = 0.075; + +/** + * Redmean colour distance, normalised to roughly 0..1. + * + * Not CIE ΔE: this runs 105 times at fifteen schools and the only question it + * has to answer is "are these two the same dot". Redmean is the cheapest + * approximation that is right about that, and unlike a plain RGB distance it + * knows that two dark blues are closer together than two bright yellows. + */ +function accentDistance(a, b) { + const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const [r1, g1, b1] = channels(a); + const [r2, g2, b2] = channels(b); + const dr = r1 - r2; + const dg = g1 - g2; + const db = b1 - b2; + const mean = (r1 + r2) / 2; + return Math.sqrt((2 + mean) * dr * dr + 4 * dg * dg + (3 - mean) * db * db) / 3.1623; +} + +/** + * The school ids that have a leading mark in the editor. + * + * Read out of the source rather than imported: `ui/Editor.js` pulls in lil-gui, + * which is a DOM library, and importing it here would trade a real check for a + * shim big enough to have its own bugs. The table is a frozen object literal of + * `id: 'glyph'` pairs and is not going to grow a computed key. + */ +const EDITOR_SCHOOL_MARKS = (() => { + try { + const source = readFileSync(path.join(SRC, 'ui', 'Editor.js'), 'utf8'); + const block = source.match(/const SCHOOL_MARK = Object\.freeze\(\{([\s\S]*?)\n\}\)/); + return new Set([...(block?.[1] ?? '').matchAll(/^\s*([a-z][a-z0-9]*)\s*:/gm)].map((m) => m[1])); + } catch { + return new Set(); + } +})(); + +/** Roster-level lines that are neither failures nor warnings. */ +const rosterNotes = []; + +async function checkSchools() { + const seen = new Set(); + let index = ''; + try { + index = readFileSync(path.join(GLYPH_DIR, 'index.js'), 'utf8'); + } catch (error) { + failGlobal(`ui/glyphs/index.js could not be read — ${describeError(error)}`); + } + + for (const school of SCHOOLS) { + const { id } = school; + + if (seen.has(id)) failGlobal(`duplicate school id "${id}" in SCHOOLS`); + seen.add(id); + + if (!/^[a-z][a-z0-9]*$/.test(id ?? '')) { + failGlobal(`school id ${JSON.stringify(id)} must be lower case with no punctuation — it is a file name`); + continue; + } + if (!school.label) failGlobal(`school "${id}" has no label`); + if (!/^#[0-9a-f]{6}$/i.test(school.accent ?? '')) { + failGlobal(`school "${id}" accent must be #rrggbb, got ${JSON.stringify(school.accent)}`); + } + + /* --- the glyph module --- */ + const file = `${id}.js`; + const full = path.join(GLYPH_DIR, file); + let exists = false; + try { + exists = statSync(full).isFile(); + } catch { + exists = false; + } + if (!exists) { + failGlobal( + `school "${id}" has no glyph module — create src/ui/glyphs/${file} from the stub shape the ` + + `others use (both append anchors included) and fold it into src/ui/glyphs/index.js` + ); + continue; + } + + const exported = `${id.toUpperCase()}_SIGILS`; + let map = null; + try { + const module = await import(full); + map = module[exported] ?? null; + if (!map || typeof map !== 'object') { + failGlobal(`src/ui/glyphs/${file} must export \`${exported}\`, an object keyed by ability id`); + } + } catch (error) { + failGlobal(`src/ui/glyphs/${file} failed to import — ${describeError(error)}`); + continue; + } + + if (!index.includes(`'./${file}'`)) { + failGlobal(`src/ui/glyphs/index.js never imports './${file}' — school "${id}" draws no sigils at all`); + } else if (!new RegExp(`\\.\\.\\.\\s*${exported}\\b`).test(index)) { + failGlobal(`src/ui/glyphs/index.js imports './${file}' but never spreads \`${exported}\` into ELEMENT_SIGILS`); + } + + for (const [ability, markup] of Object.entries(map ?? {})) { + if (ELEMENT_SIGILS[ability] !== markup) { + failGlobal( + `src/ui/glyphs/${file} exports a sigil for "${ability}" that is not the one in ELEMENT_SIGILS — ` + + `two schools are claiming the same ability id and the later spread wins` + ); + } + } + + /* --- the editor's leading mark --- */ + if (!EDITOR_SCHOOL_MARKS.has(id)) { + warnGlobal(`school "${id}" has no SCHOOL_MARK in ui/Editor.js — its folder falls back to "◇"`); + } + } + + /* --- a module in the directory that no school claims --- */ + for (const name of readdirSync(GLYPH_DIR)) { + if (!name.endsWith('.js') || GLYPH_INFRASTRUCTURE.has(name)) continue; + if (!seen.has(name.slice(0, -3))) { + warnGlobal(`src/ui/glyphs/${name} is not a school in SCHOOLS — its sigils are orphaned`); + } + } + + /* --- accents that are the same dot --- */ + for (let i = 0; i < SCHOOLS.length; i++) { + for (let j = i + 1; j < SCHOOLS.length; j++) { + const a = SCHOOLS[i]; + const b = SCHOOLS[j]; + if (!/^#[0-9a-f]{6}$/i.test(a.accent ?? '') || !/^#[0-9a-f]{6}$/i.test(b.accent ?? '')) continue; + const distance = accentDistance(a.accent, b.accent); + if (distance < ACCENT_FLOOR) { + warnGlobal( + `school accents ${a.id} ${a.accent} and ${b.id} ${b.accent} score ${distance.toFixed(3)} apart, ` + + `under the ${ACCENT_FLOOR} floor — at 7px on the rail they are one colour` + ); + } + } + } + + /* --- schools with nothing in them yet --- */ + const populated = new Set(ABILITIES.map((a) => a.school)); + const empty = SCHOOLS.filter((school) => !populated.has(school.id)).map((school) => school.id); + if (empty.length) { + rosterNotes.push( + `${SCHOOLS.length} school(s), ${empty.length} still empty: ${empty.join(', ')} — wired, but ` + + `absent from the spellbook and the editor until their first ability lands` + ); + } +} + +/** + * Everything about one descriptor that can be judged without running anything. + * + * The `zoneRadius` rule is the one worth stating: a far cast whose block has no + * `zoneRadius` still *aims* — `zoneRadiusOf` falls back to 0 — so the circle + * indicator collapses to a point and the ability lands on a footprint of + * nothing. It is a silent, plausible-looking failure, which is why it is + * checked here rather than trusted to review. + */ +function checkStructure(descriptor, report) { + const { id } = descriptor; + const block = settings[id]; + + if (!block || typeof block !== 'object') { + return report.fail('struct', `no settings block: settings.${id} is ${block}`); + } + if (ABILITY_SETTINGS[id] !== block) { + report.fail('struct', `settings.${id} is not the same object as ABILITY_SETTINGS.${id} — the editor will edit a copy`); + } + if (descriptor.settings !== block) { + report.fail('struct', `registry descriptor for "${id}" does not carry the live block`); + } + + if (!SCHOOL_IDS.has(descriptor.school)) { + report.fail('struct', `unknown school "${descriptor.school}"`); + } + if (!/^#[0-9a-f]{6}$/i.test(descriptor.accent ?? '')) { + report.fail('struct', `accent must be #rrggbb, got ${JSON.stringify(descriptor.accent)}`); + } + if (typeof descriptor.load !== 'function') { + report.fail('struct', 'descriptor.load is not a function'); + } + if (!descriptor.label || !descriptor.blurb) { + report.warn('struct', 'descriptor is missing a label or a blurb'); + } + + for (const key of CAST_KEYS) { + if (!(key in block)) report.fail('struct', `settings.${id}.${key} is missing`); + } + + const shape = descriptor.cast; + if (shape !== CastShape.LINE && shape !== CastShape.ZONE) { + report.fail('struct', `cast must be CastShape.LINE or CastShape.ZONE, got ${JSON.stringify(shape)}`); + } + if (shape === CastShape.ZONE && !('zoneRadius' in block)) { + report.fail('struct', `a ZONE cast needs settings.${id}.zoneRadius — without it the footprint is a point`); + } + if (shape === CastShape.LINE && 'zoneRadius' in block) { + report.warn('struct', 'a LINE cast carries zoneRadius, which nothing will draw'); + } + + if (!CAST_ANIMATIONS.includes(block.castAnim)) { + report.fail('struct', `castAnim ${JSON.stringify(block.castAnim)} is not one of ${CAST_ANIMATIONS.join(' / ')}`); + } + + const numeric = (key) => typeof block[key] === 'number' && Number.isFinite(block[key]); + for (const key of ['range', 'minRange', 'speed', 'cooldown']) { + if (key in block && !numeric(key)) report.fail('struct', `${key} must be a finite number, got ${block[key]}`); + } + if (numeric('range') && numeric('minRange') && block.minRange >= block.range) { + report.fail('struct', `minRange (${block.minRange} m) must be below range (${block.range} m)`); + } + if (numeric('range') && block.range <= 0) report.fail('struct', `range must be positive, got ${block.range} m`); + if (numeric('minRange') && block.minRange < 0) report.fail('struct', `minRange must not be negative, got ${block.minRange} m`); + if (numeric('speed') && block.speed <= 0) report.fail('struct', `speed must be positive, got ${block.speed} m/s`); + if (numeric('cooldown') && block.cooldown < 0) report.fail('struct', `cooldown must not be negative, got ${block.cooldown} s`); + if (shape === CastShape.ZONE && typeof block.zoneRadius === 'number' && block.zoneRadius <= 0) { + report.fail('struct', `zoneRadius must be positive, got ${block.zoneRadius} m`); + } + + // Colours are `#rrggbb` strings so lil-gui can bind them directly; anything + // else in a key named `color*` is a colour that will never get a picker. + for (const [key, entry] of Object.entries(block)) { + if (!/^colou?r/i.test(key)) continue; + if (typeof entry !== 'string' || !/^#[0-9a-f]{6}$/i.test(entry)) { + report.fail('struct', `${key} must be a "#rrggbb" string, got ${JSON.stringify(entry)}`); + } + } + + if (!ELEMENT_SIGILS[id]) { + report.fail('struct', `no sigil: ui/glyphs has no entry for "${id}"`); + } else if (!/]/i.test(ELEMENT_SIGILS[id])) { + report.fail('struct', 'sigil is not inline SVG markup'); + } + + checkSchema(id, block, report); + report.pass('struct'); + return report.ok; +} + +/** + * The editor schema names keys. A name that is not in the block builds a + * controller bound to `undefined`, which lil-gui renders as a dead row. + * + * A schema is allowed to be *incomplete* — unmentioned keys land in the + * trailing "More" folder by design — so the missing direction is a warning and + * only the wrong direction is a failure. + */ +function checkSchema(id, block, report) { + const schema = ABILITY_SCHEMAS[id]; + if (!schema || typeof schema !== 'object') { + return report.fail('struct', `no editor schema: ABILITY_SCHEMAS.${id} is missing`); + } + + const mentioned = new Set(); + for (const [folder, entries] of Object.entries(schema)) { + if (!Array.isArray(entries)) { + report.fail('struct', `schema folder "${folder}" is not an array`); + continue; + } + for (const entry of entries) { + const key = Array.isArray(entry) ? entry[0] : entry; + if (typeof key !== 'string') { + report.fail('struct', `schema folder "${folder}" holds an entry with no key: ${JSON.stringify(entry)}`); + continue; + } + if (key.endsWith('*')) { + // A gradient group: `colorMist*` stands for colorMistA/B/C/D. + const prefix = key.slice(0, -1); + for (const stop of ['A', 'B', 'C', 'D']) { + const full = prefix + stop; + mentioned.add(full); + if (!(full in block)) { + report.fail('struct', `schema gradient "${key}" in "${folder}" wants ${id}.${full}, which does not exist`); + } + } + continue; + } + mentioned.add(key); + if (!(key in block)) { + report.fail('struct', `schema key "${key}" in folder "${folder}" does not exist on settings.${id}`); + } + } + } + + const unfiled = Object.keys(block).filter((key) => !mentioned.has(key)); + if (unfiled.length) { + report.warn( + 'struct', + `${unfiled.length} key(s) not filed in the schema (they land in "More"): ${preview(unfiled)}` + ); + } + return true; +} + +/* ------------------------------------------------------------------ */ +/* §4 · The static settings cross-check */ +/* ------------------------------------------------------------------ */ + +/** + * Strip comments — and, by default, string literals — replacing them with + * spaces. + * + * Positions are preserved so a hit can still be reported with a line number. + * The first version of this pass ran the regexes over the raw source, and the + * very first thing it "found" was `settings.rime.plateSize` inside the doc + * comment of a module that did not exist yet — a wrong answer that reads as a + * right one, which is the worst kind for a tool people are meant to trust. + * + * `keepStrings` exists for exactly one caller: an ability declares which block + * `this.config` resolves to by writing `super('beam', context)`, and that id is + * a *string*. Reading it out of the fully stripped source finds `super( ,` and + * silently drops every aliased read in the file — which is how this harness + * first reported that Nova Beam touches eighty-two of its own settings when the + * real number is a hundred and sixty. + * + * Template literals resume code mode inside `${…}`, because a shader string is + * a template literal and the interpolations in it are real code. Regex + * literals are *not* handled: telling a regex from a division needs the parser + * we do not have, so a `/` is simply left alone. The failure mode of that + * choice is a missed read, never an invented one. + */ +function stripToCode(source, keepStrings = false) { + const out = new Array(source.length); + for (let i = 0; i < source.length; i++) out[i] = source[i]; + + const erase = (from, to) => { + for (let i = from; i < to && i < source.length; i++) { + if (source[i] !== '\n' && source[i] !== '\r') out[i] = ' '; + } + }; + /** Comments always go; string bodies stay when `keepStrings` is set. */ + const blank = keepStrings ? () => {} : erase; + + /** Template-literal nesting: each entry is the `${` depth of one template. */ + const templates = []; + let i = 0; + let braceDepth = 0; + + while (i < source.length) { + const c = source[i]; + const next = source[i + 1]; + + if (c === '/' && next === '/') { + let j = i; + while (j < source.length && source[j] !== '\n') j++; + erase(i, j); + i = j; + continue; + } + if (c === '/' && next === '*') { + let j = i + 2; + while (j < source.length && !(source[j] === '*' && source[j + 1] === '/')) j++; + erase(i, Math.min(j + 2, source.length)); + i = j + 2; + continue; + } + if (c === "'" || c === '"') { + let j = i + 1; + while (j < source.length && source[j] !== c) { + if (source[j] === '\\') j++; + if (source[j] === '\n') break; + j++; + } + blank(i, j + 1); + i = j + 1; + continue; + } + if (c === '`') { + // Walk the template, blanking its text but leaving `${…}` as code. + let j = i + 1; + blank(i, i + 1); + while (j < source.length) { + if (source[j] === '\\') { + blank(j, j + 2); + j += 2; + continue; + } + if (source[j] === '`') { + blank(j, j + 1); + j++; + break; + } + if (source[j] === '$' && source[j + 1] === '{') { + blank(j, j + 2); + // Hand control back to the outer loop for the interpolation body. + templates.push(braceDepth); + braceDepth++; + j += 2; + i = j; + break; + } + blank(j, j + 1); + j++; + } + if (j > i && (source[j - 1] === '`' || j >= source.length)) i = j; + else if (i !== j) i = j; + continue; + } + if (c === '{') braceDepth++; + if (c === '}') { + braceDepth--; + if (templates.length && braceDepth === templates[templates.length - 1]) { + // Closing a `${…}`: resume the template's text. + templates.pop(); + blank(i, i + 1); + let j = i + 1; + while (j < source.length) { + if (source[j] === '\\') { + blank(j, j + 2); + j += 2; + continue; + } + if (source[j] === '`') { + blank(j, j + 1); + j++; + break; + } + if (source[j] === '$' && source[j + 1] === '{') { + blank(j, j + 2); + templates.push(braceDepth); + braceDepth++; + j += 2; + break; + } + blank(j, j + 1); + j++; + } + i = j; + continue; + } + } + i++; + } + + return out.join(''); +} + +/** Every `.js` under `src/`, excluding the archive (it reads a dead settings tree). */ +function sourceFiles(dir, found = []) { + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry === 'archive' || entry === 'node_modules') continue; + sourceFiles(full, found); + } else if (entry.endsWith('.js')) { + found.push(full); + } + } + return found; +} + +const IDENT = '[A-Za-z_$][A-Za-z0-9_$]*'; + +/** + * Which local identifiers in a file are safe to treat as "an alias for a + * settings block". + * + * `const c = settings.beam` makes `c.` a settings read for the rest of + * that method — and in this codebase the same file will bind `c` to + * `this.config` in the next method, which is the same block by another name. + * What must never happen is treating `c.foo` as a settings read when some + * *third* binding put an unrelated object in `c`, because that invents a + * failure out of nothing. + * + * So the pass keeps two sets, and the difference between them is the whole + * trick: + * + * - **strict** — every binding of the name in the file is a settings binding + * *and* the name never appears in anything that parses as a parameter list. + * Reads through a strict alias are *required*: a key that is not in the + * block is a failure. + * - **loose** — the name is bound to a block somewhere, and is also a + * parameter somewhere. `IceAbility` threads its block through helpers as + * `_halfWidth(s, c)`, which is idiomatic here and would otherwise blind the + * pass to two thirds of that file. Reads through a loose alias are recorded + * as *optional*: they count toward "this key is used" and can never raise a + * failure, so the worst a wrong guess can do is make a warning quieter. + * + * Anything bound to a non-settings right-hand side anywhere in the file is out + * of both sets. That test is deliberately over-eager: its failure mode is + * dropping a real alias, which loses coverage, and never inventing a fake one, + * which loses trust. + */ +function collectAliases(code, configIds) { + const candidates = new Map(); // name -> Set(blockName) | null when polluted + + const bind = (name, block) => { + if (candidates.get(name) === null) return; + if (!candidates.has(name)) candidates.set(name, new Set()); + if (block === null) candidates.set(name, null); + else candidates.get(name).add(block); + }; + + // Declarations and plain assignments, settings-shaped or not. + const declaration = new RegExp( + `(?:\\b(?:const|let|var)\\s+(${IDENT})|(?:^|[;{}(),]\\s*)(${IDENT}))\\s*=\\s*(?!=)([^;\\n]*)`, + 'g' + ); + for (const match of code.matchAll(declaration)) { + const name = match[1] ?? match[2]; + const rhs = match[3].trim(); + const settingsBlock = rhs.match(new RegExp(`^settings\\s*\\.\\s*(${IDENT})\\s*;?$`)); + if (settingsBlock) { + bind(name, settingsBlock[1]); + continue; + } + if (/^this\s*\.\s*config\s*;?$/.test(rhs)) { + for (const id of configIds) bind(name, id); + continue; + } + bind(name, null); + } + + /** Names that appear in a parameter list: strict-disqualifying, loose-tolerated. */ + const parameters = new Set(); + const paramForms = [ + new RegExp(`\\bfunction\\s*${IDENT}?\\s*\\(([^)]*)\\)`, 'g'), + new RegExp(`(?:^|[;{}\\s])(?:async\\s+)?(?:get\\s+|set\\s+|static\\s+)*${IDENT}\\s*\\(([^)]*)\\)\\s*\\{`, 'g'), + /\(([^)]*)\)\s*=>/g, + new RegExp(`\\bcatch\\s*\\(\\s*(${IDENT})\\s*\\)`, 'g') + ]; + for (const pattern of paramForms) { + for (const match of code.matchAll(pattern)) { + for (const raw of match[1].split(',')) { + const name = raw.trim().replace(/^\.\.\./, '').split('=')[0].trim(); + if (new RegExp(`^${IDENT}$`).test(name)) parameters.add(name); + } + } + } + for (const match of code.matchAll(new RegExp(`(?:^|[^.\\w$])(${IDENT})\\s*=>`, 'g'))) { + parameters.add(match[1]); + } + + const strict = new Map(); // name -> [blockName], reads are required + const loose = new Map(); // name -> [blockName], reads are advisory only + for (const [name, blocks] of candidates) { + if (blocks === null || blocks.size === 0) continue; + (parameters.has(name) ? loose : strict).set(name, [...blocks]); + } + return { strict, loose }; +} + +/** Split a destructuring pattern body into the source keys it names. */ +function destructuredKeys(body) { + const keys = []; + let depth = 0; + let current = ''; + const flush = () => { + const piece = current.trim(); + current = ''; + if (!piece || piece.startsWith('...')) return; + const name = piece.split(':')[0].split('=')[0].trim(); + if (new RegExp(`^${IDENT}$`).test(name)) keys.push(name); + }; + for (const ch of body) { + if (ch === '{' || ch === '[' || ch === '(') depth++; + else if (ch === '}' || ch === ']' || ch === ')') depth--; + if (ch === ',' && depth === 0) flush(); + else current += ch; + } + flush(); + return keys; +} + +/** + * The whole static pass. + * + * Returns `{ reads, unknownBlocks }` where `reads` is `blockName -> key -> + * [{ file, line, optional }]`. `optional` marks a read written defensively — + * `this.config.zoneRadius ?? 1`, `c?.foo` — which counts as a read for the + * unused-key warning but does not make the key mandatory. The aim controller + * reads `zoneRadius` off whatever block is in the slot; a line cast is entitled + * not to have one. + */ +function scanSources() { + const reads = new Map(); + const unknownBlocks = []; + const blockNames = new Set(Object.keys(settings)); + + const record = (block, key, file, line, optional) => { + if (!reads.has(block)) reads.set(block, new Map()); + const table = reads.get(block); + if (!table.has(key)) table.set(key, []); + table.get(key).push({ file, line, optional }); + }; + + for (const file of sourceFiles(SRC)) { + const relative = path.relative(ROOT, file); + const raw = readFileSync(file, 'utf8'); + const code = stripToCode(raw); + const withStrings = stripToCode(raw, true); + + // Line numbers, computed once per file. + const lineStarts = [0]; + for (let i = 0; i < code.length; i++) if (code[i] === '\n') lineStarts.push(i + 1); + const lineAt = (index) => { + let low = 0; + let high = lineStarts.length - 1; + while (low < high) { + const mid = (low + high + 1) >> 1; + if (lineStarts[mid] <= index) low = mid; + else high = mid - 1; + } + return low + 1; + }; + + // `super('ice', context)` is how a class declares which block `this.config` + // resolves to. A file with no such call and a `this.config` read is either + // the base class or the aim controller: it reads whatever is in the slot, + // so its reads apply to *every* id. + const ownIds = [...withStrings.matchAll(new RegExp(`\\bsuper\\s*\\(\\s*['"\`](${IDENT})['"\`]\\s*[,)]`, 'g'))] + .map((m) => m[1]) + .filter((id) => blockNames.has(id)); + const superIds = [...new Set(ownIds)]; + const configIds = superIds.length ? superIds : ALL_IDS; + + const isOptional = (index, length) => { + const before = code.slice(Math.max(0, index - 2), index); + const after = code.slice(index + length, index + length + 12); + return before.endsWith('?.') || /^\s*\?\?/.test(after) || /^\s*\?\./.test(after); + }; + + /* --- settings.. --- */ + for (const match of code.matchAll(new RegExp(`\\bsettings\\s*\\??\\.\\s*(${IDENT})\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { + const [text, block, key] = match; + if (!blockNames.has(block)) { + unknownBlocks.push({ block, file: relative, line: lineAt(match.index) }); + continue; + } + record(block, key, relative, lineAt(match.index), isOptional(match.index, text.length)); + } + + /* --- settings[]. — the framework's blind index --- */ + for (const match of code.matchAll(new RegExp(`\\bsettings\\s*\\[[^\\]]+\\]\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { + // Always recorded as optional: `settings[element].zoneRadius` is read for + // whichever ability happens to be in the slot, and a line cast is + // entitled not to have one. + for (const id of ALL_IDS) record(id, match[1], relative, lineAt(match.index), true); + } + + /* --- this.config. --- */ + // A framework file's `this.config` is whichever ability is in the slot, so + // its reads land on every block — and they are *required* there, which is + // how `Ability.js` reading `cfg.lightColor` becomes a contract every one of + // the fifty blocks has to honour without anybody writing it down twice. The + // one genuinely optional read in that set, `this.config.zoneRadius ?? 1` in + // the aim controller, marks itself optional by being written defensively. + for (const match of code.matchAll(new RegExp(`\\bthis\\s*\\.\\s*config\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { + const optional = isOptional(match.index, match[0].length); + for (const id of configIds) record(id, match[1], relative, lineAt(match.index), optional); + } + + /* --- aliases: `const c = settings.beam` then `c.foo` --- */ + const { strict, loose } = collectAliases(code, configIds); + const aliases = new Map([...loose, ...strict]); + for (const [name, blocks] of aliases) { + const advisory = loose.has(name) && !strict.has(name); + const pattern = new RegExp(`(?:^|[^.\\w$])${name}\\s*\\??\\.\\s*(${IDENT})`, 'g'); + for (const match of code.matchAll(pattern)) { + const optional = advisory || isOptional(match.index, match[0].length); + for (const block of blocks) { + if (!blockNames.has(block)) continue; + record(block, match[1], relative, lineAt(match.index), optional); + } + } + } + + /* --- destructuring off a block, an alias, or this.config --- */ + const destructure = new RegExp( + `\\b(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*(this\\s*\\.\\s*config|settings\\s*\\.\\s*${IDENT}|${IDENT})`, + 'g' + ); + for (const match of code.matchAll(destructure)) { + const source = match[2].replace(/\s+/g, ''); + let blocks = null; + if (source === 'this.config') blocks = configIds; + else if (source.startsWith('settings.')) blocks = [source.slice('settings.'.length)]; + else if (aliases.has(source)) blocks = aliases.get(source); + if (!blocks) continue; + const line = lineAt(match.index); + for (const block of blocks) { + if (!blockNames.has(block)) { + if (source.startsWith('settings.')) unknownBlocks.push({ block, file: relative, line }); + continue; + } + const advisory = loose.has(source) && !strict.has(source); + for (const key of destructuredKeys(match[1])) { + record(block, key, relative, line, advisory); + } + } + } + } + + return { reads, unknownBlocks }; +} + +const STATIC = scanSources(); + +for (const { block, file, line } of STATIC.unknownBlocks) { + failGlobal(`${file}:${line} reads settings.${block}, which is not a settings block`); +} + +function checkStaticReads(descriptor, report) { + const { id } = descriptor; + const block = settings[id] ?? {}; + const table = STATIC.reads.get(id) ?? new Map(); + + /* --- reads with no key behind them: the NaN factory --- */ + const missing = []; + for (const [key, sites] of table) { + if (key in block) continue; + const required = sites.filter((site) => !site.optional); + if (required.length === 0) continue; // a guarded read of an absent key is fine + missing.push({ key, sites: required }); + } + missing.sort((a, b) => a.key.localeCompare(b.key)); + for (const { key, sites } of missing) { + const where = sites.slice(0, 3).map((s) => `${s.file}:${s.line}`).join(', '); + report.fail( + 'static', + `reads settings.${id}.${key}, which does not exist — undefined here becomes NaN geometry (${where}${sites.length > 3 ? ', …' : ''})` + ); + } + + /* --- keys nothing reads: a warning, and a soft one --- */ + const unread = Object.keys(block).filter((key) => !table.has(key)); + if (unread.length) { + report.warn( + 'static', + `${unread.length} settings key(s) with no visible read — check they are not dead, or that they are consumed through a shared VFX module: ${preview(unread)}` + ); + } + + // Counted against the block, not against the table: a loose alias can pick up + // `c.length` off something that is not this block at all, and a "reads 205 of + // its 161 keys" line is a number nobody can act on. + const realised = [...table.keys()].filter((key) => key in block).length; + report.note( + `${realised}/${Object.keys(block).length} settings key(s) read, across ${countSites(table)} site(s)` + ); + report.pass('static'); + return report.ok; +} + +const countSites = (table) => [...table.values()].reduce((total, sites) => total + sites.length, 0); +const preview = (list, limit = 8) => + list.slice(0, limit).join(', ') + (list.length > limit ? `, … (+${list.length - limit})` : ''); + +/* ------------------------------------------------------------------ */ +/* §5 · The mock context */ +/* ------------------------------------------------------------------ */ + +/** + * `shake` and `flash` are stubs because both of them reach outside the scene — + * one into the camera rig, the other into a DOM overlay — and neither has any + * bearing on whether an ability's geometry is sane. Everything else is the real + * subsystem, on a real `Scene`, because a mock `ParticleEngine` would not have + * caught a single one of the bugs this harness exists for. + */ +function makeContext() { + const scene = new Scene(); + const camera = new PerspectiveCamera(46, 16 / 9, 0.1, 400); + camera.position.set(0, 8, 12); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + + const noop = { add() {}, rumble() {}, trigger() {}, reset() {}, update() {} }; + + return { + scene, + camera, + /** + * `Environment` itself needs a live `Renderer`, so it is stood in for — but + * the two methods materials actually call are reproduced faithfully rather + * than stubbed to `noop`. `registerShadowCasterWithPatch` is what attaches + * an ability's `onBeforeCompile` hook, and a version of it that does nothing + * would quietly turn the harness into a test that ice and cinder *do not* + * patch their shaders — which is the opposite of the truth and the reason + * both of them threw on the first run of this file. + */ + environment: { + scene, + camera, + renderer: null, + envMap: null, + registerShadowCaster: (material) => material, + registerShadowCasterWithPatch: (material, patch) => patchOnBeforeCompile(material, patch), + setFocus() {}, + update() {} + }, + particles: new ParticleEngine(scene), + lights: new LightPool(scene), + decals: new DecalSystem(scene), + fissures: new FissureSystem(scene), + bursts: new BurstSystem(scene), + shake: noop, + flash: noop + }; +} + +/** + * Capture whatever three.js (or the ability) prints while a block of work runs. + * + * Not censorship — the opposite. A cast that triggers a three.js warning on + * every one of nine hundred frames buries the report under nine hundred + * identical lines, and the useful information ("this geometry is already + * non-indexed") is one line and a count. So they are collected, de-duplicated + * and attached to the ability that caused them. + */ +async function captureConsole(report, fn) { + const seen = new Map(); + const original = { warn: console.warn, error: console.error }; + const sink = (...args) => { + const message = args.map((a) => (a instanceof Error ? a.message : String(a))).join(' '); + seen.set(message, (seen.get(message) ?? 0) + 1); + }; + console.warn = sink; + console.error = sink; + try { + return await fn(); + } finally { + console.warn = original.warn; + console.error = original.error; + for (const [message, count] of seen) { + report.warn('runtime', `console: ${message}${count > 1 ? ` (×${count})` : ''}`); + } + } +} + +/** One simulated frame, in exactly the order `App.update` runs it. */ +function step(ctx, ability, dt) { + frame.uTime.value += dt; + frame.uDelta.value = dt; + ability.update(dt); + ctx.particles.flush(); + ctx.decals.update(dt); + ctx.fissures.update(dt); + ctx.bursts.update(dt); + ctx.lights.update(dt); +} + +/* ------------------------------------------------------------------ */ +/* §6 · Non-finite and sanity scanning */ +/* ------------------------------------------------------------------ */ + +/** + * Every uniform a material actually drives, from both places they hide. + * + * A `ShaderMaterial` keeps them on `material.uniforms`. A patched + * `MeshStandardMaterial` — Frost Lance's crystal, Cinder Fall's rock — cannot: + * its uniforms are handed to the shader inside `onBeforeCompile`, which never + * runs without a GL context, so the live boxes are parked on + * `material.userData.uniforms` by convention and `material.uniforms` does not + * exist at all. + * + * Missing that second location is not a small gap. It made the pause test + * blind to every value on a patched standard material, and the first run of + * this harness cheerfully reported that thirty of Frost Lance's shading + * sliders did nothing while paused. All thirty of them work. + */ +function* uniformsOf(material) { + if (!material) return; + if (material.uniforms) yield* Object.entries(material.uniforms); + if (material.userData?.uniforms) yield* Object.entries(material.userData.uniforms); +} + +function scanArray(array, label, out) { + for (let i = 0; i < array.length; i++) { + const v = array[i]; + if (!Number.isFinite(v)) { + out.push(`${label}[${i}] is ${Number.isNaN(v) ? 'NaN' : v}`); + return; // one report per array; a NaN never arrives alone + } + } +} + +function scanParticles(ctx, out) { + for (const system of ctx.particles.systems.values()) { + for (const [key, array] of Object.entries(system.data)) { + scanArray(array, `particles["${system.name}"].${key}`, out); + } + } +} + +function scanGroup(group, out) { + let index = 0; + group.traverse((object) => { + // The meshes in an ability group are usually unnamed, so the traversal + // index is the only handle a reader has on *which* of the three it was. + const name = `${object.name || object.type}#${index++}`; + for (const [label, vector] of [ + ['position', object.position], + ['scale', object.scale] + ]) { + if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { + out.push(`${name}.${label} is (${vector.x}, ${vector.y}, ${vector.z})`); + } + } + const q = object.quaternion; + if (!Number.isFinite(q.x) || !Number.isFinite(q.y) || !Number.isFinite(q.z) || !Number.isFinite(q.w)) { + out.push(`${name}.quaternion is non-finite`); + } + if (object.position.lengthSq() > SANITY_RADIUS * SANITY_RADIUS) { + out.push( + `${name}.position is ${object.position.length().toFixed(0)} m from the origin — beyond the ${SANITY_RADIUS} m sanity radius` + ); + } + + if (object.isInstancedMesh) { + scanArray(object.instanceMatrix.array, `${name}.instanceMatrix`, out); + if (object.instanceColor) scanArray(object.instanceColor.array, `${name}.instanceColor`, out); + } + if (object.geometry) { + for (const [attributeName, attribute] of Object.entries(object.geometry.attributes ?? {})) { + if (attribute.isInstancedBufferAttribute) { + scanArray(attribute.array, `${name}.${attributeName}`, out); + } + } + } + const materials = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of materials) { + for (const [uniformName, uniform] of uniformsOf(material)) { + const v = uniform?.value; + if (typeof v === 'number' && !Number.isFinite(v)) out.push(`${name}.${uniformName} is ${v}`); + else if (v?.isVector2 || v?.isVector3 || v?.isVector4 || v?.isQuaternion) { + if (!Number.isFinite(v.x) || !Number.isFinite(v.y) || !Number.isFinite(v.z ?? 0) || !Number.isFinite(v.w ?? 0)) { + out.push(`${name}.${uniformName} is non-finite`); + } + } else if (v?.isColor) { + if (!Number.isFinite(v.r) || !Number.isFinite(v.g) || !Number.isFinite(v.b)) { + out.push(`${name}.${uniformName} is a non-finite colour`); + } + } + } + } + }); +} + +/* ------------------------------------------------------------------ */ +/* §7 · Snapshots — what "something changed" means */ +/* ------------------------------------------------------------------ */ + +/** + * A snapshot is every number an ability owns that a viewer could see change: + * the transform of every object in its group, every numeric uniform on every + * material it draws with, every instance matrix and instanced attribute, and + * the uniforms of the particle systems it namespaces. + * + * Labels are built once per sample point and reused, because the pause test + * takes a few hundred snapshots per ability and building a few hundred + * thousand label strings to throw all of them away is the kind of thing that + * turns a two-second harness into a thirty-second one. + */ +/** djb2 over a uuid, so "the geometry object was replaced" is one number. */ +function hashString(text) { + let hash = 5381; + for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; + return hash; +} + +/** `[sum, index-weighted sum]` over a numeric array. */ +function checksum(array) { + let sum = 0; + let weighted = 0; + for (let i = 0; i < array.length; i++) { + const v = array[i]; + if (!Number.isFinite(v)) continue; + sum += v; + weighted += v * (i + 1); + } + return [sum, weighted]; +} + +class Snapshotter { + constructor(ability, ctx) { + this.ability = ability; + this.systems = [...ctx.particles.systems.values()].filter((s) => s.name.startsWith(`${ability.element}.`)); + this.labels = null; + } + + capture(withLabels = false) { + const values = []; + const labels = withLabels ? [] : null; + const push = (label, number) => { + values.push(number); + if (labels) labels.push(label); + }; + + const flatten = (label, v) => { + if (typeof v === 'number') push(label, v); + else if (typeof v === 'boolean') push(label, v ? 1 : 0); + else if (v == null) return; + else if (v.isColor) { + push(`${label}.r`, v.r); + push(`${label}.g`, v.g); + push(`${label}.b`, v.b); + } else if (v.isVector2 || v.isVector3 || v.isVector4 || v.isQuaternion) { + push(`${label}.x`, v.x); + push(`${label}.y`, v.y); + if (v.z !== undefined) push(`${label}.z`, v.z); + if (v.w !== undefined) push(`${label}.w`, v.w); + } else if (v.isEuler) { + push(`${label}.x`, v.x); + push(`${label}.y`, v.y); + push(`${label}.z`, v.z); + } else if (v.elements) { + for (let i = 0; i < v.elements.length; i++) push(`${label}[${i}]`, v.elements[i]); + } else if (ArrayBuffer.isView(v) || Array.isArray(v)) { + for (let i = 0; i < v.length; i++) { + const entry = v[i]; + if (typeof entry === 'number') { + push(`${label}[${i}]`, entry); + } else if (entry && (entry.isVector2 || entry.isVector3 || entry.isVector4 || entry.isColor)) { + // A uniform whose value is an **array of vectors**. This is not an + // exotic case: `vfx/FilamentPaths.js` keeps every role's entire + // geometry in six of them (`uFrom`, `uTo`, `uShape`, `uShape2`, + // `uStyle`, `uDraw`, indexed by role), and so does every ability + // built on it. Skipping them made this snapshot blind to the whole + // of such an ability's shape, and the pause test duly reported + // thirty perfectly live path sliders as dead — the same failure + // mode as the patched-`MeshStandardMaterial` gap, arrived at from + // the other direction. + flatten(`${label}[${i}]`, entry); + } + } + } + }; + + let index = 0; + this.ability.group.traverse((object) => { + const name = `${object.name || object.type}#${index++}`; + flatten(`${name}.position`, object.position); + flatten(`${name}.scale`, object.scale); + flatten(`${name}.quaternion`, object.quaternion); + push(`${name}.visible`, object.visible ? 1 : 0); + + if (object.isInstancedMesh) { + push(`${name}.count`, object.count); + flatten(`${name}.instanceMatrix`, object.instanceMatrix.array); + if (object.instanceColor) flatten(`${name}.instanceColor`, object.instanceColor.array); + } + if (object.geometry) { + const geometry = object.geometry; + push(`${name}.instanceCount`, geometry.instanceCount ?? -1); + push(`${name}.drawRange`, geometry.drawRange?.count ?? -1); + // A live rebuild swaps the whole object, so its identity is the cheapest + // and most direct evidence that `facets` or `roughness` did something. + push(`${name}.geometry.uuid`, hashString(geometry.uuid)); + for (const [attributeName, attribute] of Object.entries(geometry.attributes ?? {})) { + if (attribute.isInstancedBufferAttribute) { + flatten(`${name}.${attributeName}`, attribute.array); + } else { + // Per-vertex arrays are far too big to snapshot four hundred times, + // and nobody needs to know *which* vertex moved — only that the + // geometry was regenerated. Two checksums, one position-weighted so + // a permutation is not mistaken for a no-op. + const [sum, weighted] = checksum(attribute.array); + push(`${name}.${attributeName}.length`, attribute.array.length); + push(`${name}.${attributeName}.sum`, sum); + push(`${name}.${attributeName}.weighted`, weighted); + } + } + } + const materials = Array.isArray(object.material) ? object.material : [object.material]; + for (const material of materials) { + if (!material) continue; + push(`${name}.opacity`, material.opacity); + push(`${name}.emissiveIntensity`, material.emissiveIntensity ?? 0); + flatten(`${name}.color`, material.color); + flatten(`${name}.emissive`, material.emissive); + for (const [uniformName, uniform] of uniformsOf(material)) { + flatten(`${name}.u.${uniformName}`, uniform?.value); + } + } + }); + + // Materials the ability owns but has not hung on a mesh *yet*. + // + // The traversal above finds everything in the group, which is every + // material in almost every ability. The exception is an ability whose + // meshes are conditional on something the harness cannot supply: `mirage` + // clones the caster's skinned rig, and in a headless scene there is no + // caster, so its refraction material exists as a field and is attached to + // nothing. Walking the instance's own properties finds it anyway, and the + // ability's sliders are measured instead of being written off as dead — + // which is the whole job of this probe. One shallow pass over own + // properties, no recursion, so it cannot wander into the context object. + let field = 0; + for (const value of Object.values(this.ability)) { + const candidates = Array.isArray(value) ? value : [value]; + for (const candidate of candidates) { + if (!candidate?.isMaterial) continue; + const name = `field:${candidate.name || candidate.type}#${field++}`; + push(`${name}.opacity`, candidate.opacity); + for (const [uniformName, uniform] of uniformsOf(candidate)) { + flatten(`${name}.u.${uniformName}`, uniform?.value); + } + } + } + + for (const system of this.systems) { + for (const [uniformName, uniform] of Object.entries(system.uniforms ?? {})) { + flatten(`${system.name}.${uniformName}`, uniform?.value); + } + } + + // The dynamic light is not in the group — it lives in the pool, in the + // scene — but it is unambiguously something the ability drives and the + // player sees, so `lightIntensity` and `lightRadius` are measured here + // rather than written off as unobservable. `light.intensity` itself is + // damped and therefore frozen on a zero-length frame; `target` is not. + const handle = this.ability.light; + if (handle) { + push('light.target', handle.target); + push('light.distance', handle.light.distance); + flatten('light.position', handle.light.position); + flatten('light.color', handle.light.color); + } + + if (labels) this.labels = labels; + return values; + } +} + +/** Indices at which two snapshots differ by more than float noise. */ +function diffIndices(a, b, limit = 6) { + if (a.length !== b.length) return ['']; + const changed = []; + for (let i = 0; i < a.length; i++) { + const scale = Math.max(1, Math.abs(a[i]), Math.abs(b[i])); + if (Math.abs(a[i] - b[i]) > CHANGE_EPSILON * scale) { + changed.push(i); + if (changed.length >= limit) break; + } + } + return changed; +} + +const didChange = (a, b) => diffIndices(a, b, 1).length > 0; + +/* ------------------------------------------------------------------ */ +/* §8 · Runtime simulation */ +/* ------------------------------------------------------------------ */ + +const _origin = new Vector3(0, 0, 0); +const _direction = new Vector3(0, 0, 1); + +/** A cast distance inside the ability's own declared reach. */ +function castDistance(block) { + const range = Number.isFinite(block.range) ? block.range : 10; + const minRange = Number.isFinite(block.minRange) ? block.minRange : 0; + return Math.max(minRange + 0.5, range * 0.8); +} + +async function runAbility(descriptor, report) { + const { id } = descriptor; + const block = settings[id]; + + let Type; + try { + Type = await descriptor.load(); + } catch (error) { + return report.fail('runtime', `load() rejected: ${describeError(error)}`); + } + if (typeof Type !== 'function') { + return report.fail('runtime', `load() resolved to ${typeof Type}, not a class — did you forget \`.then(m => m.XAbility)\`?`); + } + + const ctx = makeContext(); + frame.uTime.value = 0; + frame.uDelta.value = 0; + + let ability; + try { + ability = new Type(ctx); + ctx.scene.add(ability.group); + } catch (error) { + return report.fail('runtime', `constructor threw: ${describeError(error)}`); + } + + if (ability.element !== id) { + report.fail('runtime', `constructed with element "${ability.element}" but registered as "${id}"`); + } + + /* --- the namespacing rule: ".", or a shared system --- */ + for (const name of ctx.particles.systems.keys()) { + if (!name.startsWith(`${id}.`) && !name.startsWith('shared.')) { + report.warn('runtime', `particle system "${name}" is not namespaced "${id}." — it will collide with another ability`); + } + } + + const meshCount = countDrawables(ability.group); + if (meshCount > DRAW_CALL_BUDGET) { + report.warn('runtime', `${meshCount} drawable(s) in the group; the budget in §0/I7 is ${DRAW_CALL_BUDGET}`); + } + report.note(`${meshCount} drawable(s), ${ctx.particles.systems.size} particle system(s)`); + + const distance = castDistance(block); + const problems = []; + + /* ---------------- first cast: the full run ---------------- */ + try { + ability.spawn(_origin, _direction, distance); + } catch (error) { + return report.fail('runtime', `spawn() threw: ${describeError(error)}`); + } + + const childrenAtSpawn = countDrawables(ability.group); + const systemsAtSpawn = ctx.particles.systems.size; + + let frames = 0; + let finishedAt = -1; + const seenPhases = new Set([ability.phase]); + try { + while (frames < MAX_FRAMES) { + step(ctx, ability, DT); + frames++; + seenPhases.add(ability.phase); + if (frames % NAN_STRIDE === 0 || ability.isFinished) { + scanParticles(ctx, problems); + } + scanGroup(ability.group, problems); + if (problems.length) break; + if (ability.isFinished) { + finishedAt = frames; + if (frames >= REQUIRED_FRAMES) break; + } + if (finishedAt >= 0 && frames >= REQUIRED_FRAMES) break; + } + } catch (error) { + return report.fail('runtime', `threw on frame ${frames + 1} (phase ${ability.phase}): ${describeError(error)}`); + } + + for (const problem of problems.slice(0, 5)) { + report.fail('runtime', `non-finite value after ${frames} frame(s): ${problem}`); + } + + if (finishedAt < 0 && problems.length) { + // The loop was cut short by the NaN above; "it never finished" is that + // failure wearing a second hat, and reporting both sends people hunting for + // a timing bug that is not there. + report.note('the tick loop was stopped early by the non-finite value above'); + } else if (finishedAt < 0) { + report.fail( + 'runtime', + `never reached DONE in ${MAX_FRAMES} frames (${(MAX_FRAMES * DT).toFixed(1)} s) — stuck in ${ability.phase}; ` + + `check impactDuration (${safeNumber(ability.impactDuration)} s) and fadeDuration (${safeNumber(ability.fadeDuration)} s)` + ); + } else { + report.note(`cast ran ${finishedAt} frames (${(finishedAt * DT).toFixed(2)} s) to DONE`); + } + + for (const phase of [AbilityPhase.TRAVEL, AbilityPhase.IMPACT, AbilityPhase.FADE]) { + if (!seenPhases.has(phase)) report.warn('runtime', `never entered the ${phase.toUpperCase()} phase`); + } + + const childrenAtEnd = countDrawables(ability.group); + if (childrenAtEnd !== childrenAtSpawn) { + report.fail( + 'runtime', + `the group grew from ${childrenAtSpawn} to ${childrenAtEnd} drawable(s) during the cast — I3 says nothing is built during a cast` + ); + } + if (ctx.particles.systems.size !== systemsAtSpawn) { + report.fail( + 'runtime', + `${ctx.particles.systems.size - systemsAtSpawn} particle system(s) created mid-cast — build them in createParticles()` + ); + } + + /* ---------------- destroy, then cast again ---------------- */ + try { + ability.destroy(); + } catch (error) { + return report.fail('runtime', `destroy() threw: ${describeError(error)}`); + } + if (ability.phase !== AbilityPhase.IDLE) { + report.fail('runtime', `destroy() left the instance in phase ${ability.phase}, not IDLE — the pool will hand out a live cast`); + } + /* The two global pools an ability can take a piece of the *world* from. A + * leaked light costs the next cast its light; a leaked scene hook 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. Neither + * failure is traceable back to the cast that caused it, so it is caught here. + * `Ability#borrow()` is the net — see `abilities/Ability.js`. */ + if (sceneHooksLive() !== 0) { + report.fail( + 'runtime', + `destroy() left ${sceneHooksLive()} scene hook(s) held — acquire through this.borrow(), or release in onDestroy()` + ); + sceneHooksReleaseAll(); + } + if (timeRegionsLive() !== 0) { + report.fail( + 'runtime', + `destroy() left ${timeRegionsLive()} time region(s) live — acquire through this.borrow(), or release in onDestroy()` + ); + timeRegionsReset(); + } + + try { + ability.spawn(_origin, _direction, distance); + for (let i = 0; i < REUSE_FRAMES; i++) step(ctx, ability, DT); + } catch (error) { + return report.fail('runtime', `the second cast threw — destroy() did not leave the instance reusable: ${describeError(error)}`); + } + const reuseProblems = []; + scanParticles(ctx, reuseProblems); + scanGroup(ability.group, reuseProblems); + for (const problem of reuseProblems.slice(0, 3)) { + report.fail('runtime', `non-finite value on the second cast: ${problem}`); + } + ability.destroy(); + + report.pass('runtime'); + + /* ---------------- the pause test, on a fresh cast ---------------- */ + runPauseTest(descriptor, report, ctx, ability); + + try { + ability.dispose?.(); + ctx.particles.dispose(); + ctx.decals.dispose(); + ctx.fissures.dispose(); + ctx.bursts.dispose(); + ctx.lights.dispose(); + } catch (error) { + report.warn('runtime', `teardown threw: ${describeError(error)}`); + } + + return report.ok; +} + +function countDrawables(group) { + let total = 0; + group.traverse((object) => { + if (object.isMesh || object.isPoints || object.isLine || object.isSprite) total++; + }); + return total; +} + +/* ------------------------------------------------------------------ */ +/* §9 · The pause test (invariant I1) */ +/* ------------------------------------------------------------------ */ + +/** + * Two sample points, chosen because they are the two beats where the mistake + * lives: + * + * - **mid-travel**, when the front is halfway out and everything is being + * resolved from settings because it has to be; + * - **mid-impact**, when the effect is standing still and it is very tempting + * to have cached the metres it stood up with. + * + * At each one we stop the clock and feed the ability zero-length frames, which + * is precisely what `App` does when you press **P**. If scaling every dimension + * on the block by 1.37 does not move a single number the ability owns, the + * effect on screen would not move either, and the ability is not done. + */ +function runPauseTest(descriptor, report, ctx, ability) { + const { id } = descriptor; + const block = settings[id]; + const distance = castDistance(block); + + const mutable = Object.keys(block).filter( + (key) => typeof block[key] === 'number' && Number.isFinite(block[key]) && block[key] !== 0 && !PAUSE_EXCLUDED.has(key) + ); + if (mutable.length === 0) { + report.fail('travel', 'no non-zero numeric settings to mutate — every dimension must be a slider (I5)'); + report.fail('impact', 'no non-zero numeric settings to mutate'); + return; + } + + for (const sample of ['travel', 'impact']) { + let outcome; + try { + outcome = probeSample(ctx, ability, block, mutable, sample, distance); + } catch (error) { + report.fail(sample, `threw while probing at ${sample}: ${describeError(error)}`); + continue; + } + + if (outcome.skipped) { + report.fail(sample, outcome.skipped); + continue; + } + + if (!outcome.restless) { + report.note( + `${sample}: ${outcome.responsive.length}/${mutable.length} slider(s) observable while paused` + + (outcome.dead.length && SHOW_SLIDERS ? `\n dead here: ${outcome.dead.join(', ')}` : '') + ); + } else { + report.warn( + sample, + 'the ability is not stable at rest — a zero-length frame with nothing changed already moves it, ' + + 'so per-slider attribution is unavailable (usually Math.random() called from the per-frame sync). ' + + `First to drift: ${outcome.driftLabels.join(', ')}` + ); + } + + if (outcome.changed) report.pass(sample); + else { + report.fail( + sample, + `nothing observable changed at ${sample} when all ${mutable.length} numeric slider(s) were scaled by ${MUTATION} on a zero-length frame — ` + + 'invariant I1: every metre, radian and second must be re-resolved from settings every frame, including a paused one' + ); + } + } +} + +/** + * Run one sample point. Returns + * `{ changed, restless, driftLabels[], responsive[], dead[], skipped? }`. + */ +function probeSample(ctx, ability, block, mutable, sample, distance) { + ability.destroy(); + frame.uTime.value = 0; + ability.spawn(_origin, _direction, distance); + + /* --- tick to the sample point --- */ + let frames = 0; + if (sample === 'travel') { + // Half-way down the line, still travelling. An ability that buys a wind-up + // by refusing to advance (Nova Beam) sits at u = 0 for a while first, so + // this waits on `u`, not on a frame count. + while (frames < MAX_FRAMES && !(ability.phase === AbilityPhase.TRAVEL && ability.u >= 0.5)) { + step(ctx, ability, DT); + frames++; + if (ability.phase !== AbilityPhase.TRAVEL) break; + } + if (ability.phase !== AbilityPhase.TRAVEL) { + return { skipped: `the cast left TRAVEL before reaching the half-way point (phase ${ability.phase} after ${frames} frames)` }; + } + } else { + while (frames < MAX_FRAMES && ability.phase === AbilityPhase.TRAVEL) { + step(ctx, ability, DT); + frames++; + } + const half = Math.max(1, Math.round((ability.impactDuration * 0.5) / DT)); + for (let i = 0; i < half && ability.phase === AbilityPhase.IMPACT; i++) { + step(ctx, ability, DT); + frames++; + } + if (ability.phase !== AbilityPhase.IMPACT && ability.phase !== AbilityPhase.FADE) { + return { skipped: `the cast was already ${ability.phase} at the mid-impact sample (after ${frames} frames)` }; + } + } + + const snapshotter = new Snapshotter(ability, ctx); + + /* --- let the clock-stopped state settle --- */ + // The first zero-length frame after a real one is not idle: an impact punch + // is still decaying out of `lightBoost`, an eased value is still a step from + // its target. That is settling, not a slider responding, and mistaking one + // for the other would report every ability in the project as "restless". + // Three frames is enough for everything in the repo; the check below proves + // it rather than assuming it. + for (let i = 0; i < 3; i++) ability.update(0); + + /* --- is it stable at rest? --- */ + const before = snapshotter.capture(true); + ability.update(0); + const baseline = snapshotter.capture(); + const drift = diffIndices(before, baseline, 4); + const restless = drift.length > 0; + const driftLabels = drift.map((i) => (typeof i === 'number' ? snapshotter.labels[i] : i)); + + /* --- the gate: scale every slider at once --- */ + const originals = mutable.map((key) => block[key]); + for (const key of mutable) block[key] *= MUTATION; + ability.update(0); + const mutated = snapshotter.capture(); + const changed = didChange(baseline, mutated); + mutable.forEach((key, i) => { + block[key] = originals[i]; + }); + ability.update(0); + + /* --- attribution, one slider at a time --- */ + const responsive = []; + const dead = []; + if (!restless) { + for (let i = 0; i < mutable.length; i++) { + const key = mutable[i]; + const rest = snapshotter.capture(); + block[key] *= MUTATION; + ability.update(0); + const probe = snapshotter.capture(); + block[key] = originals[i]; + ability.update(0); + if (didChange(rest, probe)) responsive.push(key); + else dead.push(key); + } + } + + ability.destroy(); + return { changed, restless, driftLabels, responsive, dead }; +} + +/* ------------------------------------------------------------------ */ +/* §10 · The vfx tech library smoke test */ +/* ------------------------------------------------------------------ */ +/** + * Construct every exported class in `src/vfx/`, drive it, and take it apart + * again. + * + * The ability stages above only see a module in `src/vfx/` if an ability + * imports it, and on the day this was written not one of them did: fourteen + * modules, some of them seventeen hundred lines, and the entire harness walked + * straight past them. The twenty-two agents about to build abilities on top of + * this library would each have discovered its breakages personally, in a + * browser, as a blank screen. + * + * So this stage does the least that is still worth doing. It is not a test of + * whether a `FUNNEL` looks like a vortex — nothing without a GL context can + * tell you that, and every module's author ran their own harness for it. It + * asks four questions that a blank screen answers too late: + * + * 1. Does it **import**? A reserved word in a shader (`packed`, `flat`) or a + * backtick inside a GLSL comment is a syntax error at module scope, and + * three of those have already been found in this library by hand. + * 2. Does it **construct** with the options its own doc comment advertises? + * 3. Does driving it for a few frames — including a zero-length one — leave a + * NaN in a uniform, an instance matrix or a transform? A NaN uniform does + * not render wrongly, it renders *nothing*, silently. + * 4. Does `dispose()` run without throwing? + * + * The coverage rule at the end is the part that keeps this honest: every + * exported class in every file under `src/vfx/` must appear in `VFX_CASES`. A + * fifteenth module lands with no recipe and this stage fails, naming it. That + * is deliberate — an untested module in a shared library is worse than no + * module, because someone will build on it. + */ + +const VFX_DIR = path.join(SRC, 'vfx'); + +/** Cases are built lazily so one module failing to import cannot stop the rest. */ +const vfx = {}; + +/* ------------------------------------------------------------------ */ +/* §5b · The GLSL reserved-word sweep */ +/* ------------------------------------------------------------------ */ + +/** + * Words that cannot be an identifier in the language this project actually + * compiles to, and the reason the list is exactly this. + * + * **Every material here is GLSL ES 3.00.** three's `WebGLProgram` prepends + * `#version 300 es` to anything that is not a `RawShaderMaterial` — built-in + * materials *and* every plain `ShaderMaterial` — and adds `#define varying in` + * and friends so that source written in the 1.00 dialect still compiles. That + * one line of three's source decides this whole list, and it means the README's + * old advice was half wrong in both directions: `packed` is illegal in ESSL + * 1.00 and perfectly legal in 3.00 (`VolumetricFireMaterial` has used it as a + * local for as long as the project has existed), while `patch` is legal in 1.00 + * and reserved in 3.00 — which is how a `float patch` sat in the floor's ageing + * patch and took the entire ground material out of the frame with a syntax + * error that never mentions the word "reserved". + * + * The list was not copied from a specification. Every candidate was compiled at + * `#version 300 es` on a real driver in three positions — as a local, as a + * parameter and as a function name — and only the words that failed **all + * three** are here. That is deliberately the narrow, false-positive-free class: + * shadowing a *built-in function* with a local (`float round = …`) is legal and + * common and is not flagged, though redefining one as a function is not. + * + * `varying` and `attribute` are on the list even though three `#define`s them + * away, because by the time one is a variable *name* the define has already + * turned the declaration into nonsense. + */ +const GLSL3_RESERVED = new Set( + ( + 'active asm atomic_uint attribute cast centroid class coherent common double dvec2 dvec3 ' + + 'dvec4 dmat2 dmat3 dmat4 enum extern external filter fixed flat fvec2 fvec3 fvec4 goto half ' + + 'hvec2 hvec3 hvec4 inline input interface invariant layout long namespace noinline ' + + 'noperspective output partition patch public readonly resource restrict sample short sizeof ' + + 'smooth static subroutine superp template this typedef union unsigned using varying volatile ' + + 'writeonly' + ).split(' ') +); + +/** Every type name a declaration can open with. */ +const GLSL_TYPE = + '(?:void|bool|int|uint|float|vec2|vec3|vec4|ivec2|ivec3|ivec4|bvec2|bvec3|bvec4|uvec2|uvec3|' + + 'uvec4|mat2|mat3|mat4|mat2x2|mat2x3|mat2x4|mat3x2|mat3x3|mat3x4|mat4x2|mat4x3|mat4x4|' + + 'sampler2D|samplerCube|sampler3D|sampler2DArray)'; + +/** + * Pull every `/* glsl *​/` template literal out of one JS source file. + * + * Tagging is the convention the whole project already follows for editor + * highlighting, so it costs nothing to rely on and it is exact: a shader that + * is not tagged is not scanned, and a JS string that merely mentions `float` is + * not mistaken for one. `${…}` interpolations are skipped by brace depth so + * that a nested template literal cannot end the block early. + */ +function glslBlocks(source) { + const blocks = []; + const marker = /\/\*\s*glsl\s*\*\/\s*`/g; + let hit; + while ((hit = marker.exec(source))) { + const start = marker.lastIndex; + let i = start; + let depth = 0; + for (; i < source.length; i++) { + const c = source[i]; + if (c === '\\') { + i++; + } else if (c === '$' && source[i + 1] === '{') { + depth++; + i++; + } else if (c === '}' && depth > 0) { + depth--; + } else if (c === '`' && depth === 0) { + break; + } + } + blocks.push({ start, text: source.slice(start, i) }); + marker.lastIndex = i + 1; + } + return blocks; +} + +/** + * Sweep every tagged GLSL block under `src/` for a reserved identifier. + * + * A source-text sweep rather than a walk over constructed materials, because + * the two failures this exists to catch both live in source that no constructed + * material in the harness carries: a `patchOnBeforeCompile` body, which needs a + * renderer before it is ever assembled, and a shader behind a `#define` that + * this run's options did not select. + * + * Comments are stripped first, so the word may appear in prose as often as it + * is useful to — and it is, because explaining *why* a variable is not called + * `patch` is the only thing that stops the next author calling it that. + */ +function sweepGlslReservedWords(failures) { + let blocks = 0; + const decl = new RegExp(`\\b${GLSL_TYPE}\\s+([A-Za-z_][A-Za-z0-9_]*)`, 'g'); + for (const file of sourceFiles(SRC)) { + const source = readFileSync(file, 'utf8'); + for (const block of glslBlocks(source)) { + blocks++; + const body = block.text.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' '); + const before = source.slice(0, block.start).split('\n').length - 1; + decl.lastIndex = 0; + let found; + while ((found = decl.exec(body))) { + if (!GLSL3_RESERVED.has(found[1])) continue; + const line = before + body.slice(0, found.index).split('\n').length; + failures.push( + `${path.relative(ROOT, file)}:${line} declares "${found[1]}", which is reserved in ` + + `GLSL ES 3.00 — three compiles every non-raw material at #version 300 es, so this ` + + `material does not compile at all and its whole mesh is missing from the frame` + ); + } + sweepUnguardedPow(file, block, body, before, failures); + } + } + return blocks; +} + +/** + * `pow()` whose base is a raw `sin()` or `cos()`. + * + * This one cost a day and blacked out three abilities, so it gets a rule. + * + * `pow(x, y)` is `exp2(y * log2(x))` on every driver, so a **negative base is + * NaN at every exponent** — including 1.0, which is why turning the exponent's + * slider to zero never masks it. And a trig base that is non-negative on paper + * is not non-negative in float32: `sin(PI * t)` at `t = 0` or `t = 1` comes back + * at about ±1e-7, and half the time the sign is wrong. + * + * One NaN fragment is not one bad pixel. It lands in the HDR buffer, the very + * next bloom blur convolves it across its whole kernel, and tone-mapping NaN + * gives black — so a single fragment on a ground quad turns the entire frame + * black. That is precisely what `GroundField`'s SCOUR mode did to Pyroclasm, + * Cyclone and Hourglass. + * + * The rule is narrow on purpose — only a *literal* `sin(`/`cos(` immediately + * inside `pow(` — so it has no false positives and needs no dataflow analysis. + * The fix is always the same: `pow(max(sin(...), 0.0), e)`. + */ +function sweepUnguardedPow(file, block, body, before, failures) { + const unguarded = /\bpow\s*\(\s*(sin|cos)\s*\(/g; + let hit; + while ((hit = unguarded.exec(body))) { + const line = before + body.slice(0, hit.index).split('\n').length; + failures.push( + `${path.relative(ROOT, file)}:${line} calls pow() on a raw ${hit[1]}() — float32 ` + + `${hit[1]}() returns ~-1e-7 at its zeroes, pow() of a negative base is NaN at every ` + + `exponent, and one NaN fragment is smeared over the whole frame by the bloom blur. ` + + `Wrap the base: pow(max(${hit[1]}(...), 0.0), e)` + ); + } +} + +/** + * Load every module under `src/vfx/` once, keeping the failures rather than + * throwing on the first one — a broken module should cost its own line in the + * report, not the whole stage. + */ +async function loadVfxModules() { + const modules = new Map(); + const names = readdirSync(VFX_DIR) + .filter((name) => name.endsWith('.js')) + .sort(); + for (const name of names) { + try { + modules.set(name, await import(path.join(VFX_DIR, name))); + } catch (error) { + modules.set(name, { __error: error }); + } + } + return modules; +} + +const VFX_MODULES = await loadVfxModules(); + +/** Shorthand: a module's export, or `undefined` if the module did not load. */ +const vx = (file, name) => VFX_MODULES.get(file)?.[name]; + +/* --- the shared props every case borrows ---------------------------- */ + +const _from = new Vector3(0, 0.2, 0); +const _to = new Vector3(0, 0.2, 12); +const _side = new Vector3(1, 0, 0); +const _up = new Vector3(0, 1, 0); +const _dirZ = new Vector3(0, 0, 1); + +/** A body geometry factory. Modules that own their geometry demand a factory. */ +const shardFactory = () => new TetrahedronGeometry(0.22, 0); +const spikeFactory = () => new ConeGeometry(0.5, 1, 5, 1, true); + +/** + * A case is `{ file, label, build }`, where `build` returns the handle this + * stage drives. `node` is whatever Object3D the module hung its meshes on — + * `scanGroup` walks it, so a group is as good as a mesh. + */ +function vfxCases() { + const cases = []; + const add = (file, label, build) => cases.push({ file, label, build }); + + /* --- GrowthField ------------------------------------------------- */ + const { GrowthField, GrowthLayout, GrowthEmerge, growthParams, patchGrowthMaterial } = + VFX_MODULES.get('GrowthField.js') ?? {}; + for (const [layoutName, layout] of [ + ['LINE', GrowthLayout?.LINE], + ['ZONE', GrowthLayout?.ZONE] + ]) { + add('GrowthField.js', `GrowthField ${layoutName}`, () => { + const parent = new Group(); + const material = patchGrowthMaterial(new MeshStandardMaterial(), {}); + const field = new GrowthField(parent, { + geometry: spikeFactory, + material, + variants: 2, + capacity: 48 + }); + const p = growthParams(); + p.layout = layout; + p.emerge = GrowthEmerge?.PUSH ?? 0; + field.plant(32, 0.25); + field.triggerAll(0, 0.4); + return { + node: parent, + drawCalls: () => field.drawCalls, + tick: (now) => field.update(now, p), + dispose: () => { + field.dispose(); + material.dispose(); + } + }; + }); + } + + /* --- ShatterField ------------------------------------------------- */ + const { ShatterField, shatterParams } = VFX_MODULES.get('ShatterField.js') ?? {}; + add('ShatterField.js', 'ShatterField', () => { + const parent = new Group(); + const field = new ShatterField(parent, { geometry: shardFactory, variants: 2, capacity: 64 }); + const p = shatterParams(); + field.sync(p); + field.burst(0, 40, 1, 0.6); + return { + node: parent, + drawCalls: () => field.drawCalls, + tick: (now) => field.update(now, p), + dispose: () => field.dispose() + }; + }); + + /* --- FilamentPaths: every path mode through one strip -------------- */ + const { FilamentPaths, PathMode, filamentLook } = VFX_MODULES.get('FilamentPaths.js') ?? {}; + add('FilamentPaths.js', 'FilamentPaths (9 path modes)', () => { + const parent = new Group(); + const paths = new FilamentPaths(parent, { samples: 32, capacity: 16 }); + const look = filamentLook(); + paths.setNodeCount(6); + for (let i = 0; i < 6; i++) paths.setNode(i, i / 5, (i % 2 ? 0.2 : -0.2), 0.1); + // One role per frame cycles through all nine modes over the tick loop, so + // every parametric path gets its uniforms written at least once. + const modes = Object.values(PathMode ?? {}); + return { + node: parent, + drawCalls: () => paths.drawCalls, + tick: (now, index) => { + const role = paths.role(index % 4); + role.count = 5; + role.style(0.3, 0.1, 0.5, 0.2); + role.ends(0.05, 0.95, 0.1, 0.9); + role.draw(2, 0.1, 0, 1.5); + switch (modes[index % modes.length]) { + case PathMode.HELIX: role.helix(_from, _to, 0.6, 0.3, 3, 1, 0.2, 0.4, 1.2); break; + case PathMode.ORBIT: role.orbit(_to, _up, 1.4, 1, 1.2, 0.3, 0.2, 0.4, 0.2); break; + case PathMode.MEANDER: role.meander(_to, _up, 0.4, 2.2, 1.1, 0.5, 0.3, 0.4, 0.6); break; + case PathMode.RIM: role.rim(_to, _up, 2.4, 1.6, 0.8, 0.3, 0.2, 0.3, 0.1); break; + case PathMode.CHAIN: role.chain(_from, _to, 0.4, 0.3, 0.2, 0.3, 3, 0.4, 0.2, 0.6); break; + case PathMode.LINK: role.link(_from, _to, 0.3, 0.6, 0.2, 1.1, 0.4, 0.3); break; + case PathMode.SPIRAL_IN: role.spiralIn(_from, _to, 1.2, 0.1, 2.5, 1, 1.3, 0.3, 0.2); break; + case PathMode.CRACK: role.crack(_from, _to, 0.5, 0.7, 0.6, 0.4, 0.1, 0.2, 0.5); break; + default: role.line(_from, _to, 0.2, 0.1, 0.4, 1.2, 0.3, 1.1, 0.2); + } + paths.sync(look, 1, now); + }, + dispose: () => paths.dispose() + }; + }); + + /* --- ArcNetwork ---------------------------------------------------- */ + const { ArcNetwork, arcNetworkParams } = VFX_MODULES.get('ArcNetwork.js') ?? {}; + add('ArcNetwork.js', 'ArcNetwork', () => { + const parent = new Group(); + const net = new ArcNetwork(parent, { samples: 48, capacity: 12 }); + const p = arcNetworkParams(); + net.from.copy(_from); + net.to.copy(_to); + net.reset(3); + return { + node: parent, + drawCalls: () => net.drawCalls, + tick: (now, index, dt) => net.update(dt, p, 1), + dispose: () => net.dispose() + }; + }); + + /* --- GroundField: all ten modes ------------------------------------ */ + const { GroundField, GroundMode, GROUND_MODE_NAMES, groundFieldParams } = + VFX_MODULES.get('GroundField.js') ?? {}; + for (const [name, mode] of Object.entries(GroundMode ?? {})) { + add('GroundField.js', `GroundField ${name}`, () => { + const parent = new Group(); + const field = new GroundField(parent, { mode, marks: 8 }); + const p = groundFieldParams(); + field.mark(0.3, -0.2, 0, 1); + field.mark(-0.5, 0.4, 0.2, 0.6); + return { + node: parent, + drawCalls: () => field.drawCalls, + tick: () => field.update(p), + dispose: () => field.dispose() + }; + }); + } + + /* --- VolumeHull: every hull, every medium -------------------------- */ + const { VolumeHull, HullShape, Medium, HULL_NAMES, MEDIUM_NAMES, volumeHullDefaults } = + VFX_MODULES.get('VolumeHull.js') ?? {}; + const hullCombos = []; + for (const hull of Object.values(HullShape ?? {})) hullCombos.push([hull, Medium?.FLAME ?? 0]); + for (const medium of Object.values(Medium ?? {})) hullCombos.push([HullShape?.BOX ?? 0, medium]); + for (const [hull, medium] of hullCombos) { + const label = `VolumeHull ${HULL_NAMES?.[hull]}/${MEDIUM_NAMES?.[medium]}`; + add('VolumeHull.js', label, () => { + const parent = new Group(); + const prefix = 'vol'; + const hullObject = new VolumeHull({ hull, medium, prefix, maxSteps: 12 }); + parent.add(hullObject.mesh); + const c = volumeHullDefaults(prefix, medium); + hullObject.place(_to, _dirZ).setSize(2, 3, 2).setFade(1); + return { + node: parent, + drawCalls: () => 1, + tick: () => hullObject.sync(c, settings.global), + dispose: () => hullObject.dispose() + }; + }); + } + + /* --- Tube: every path ---------------------------------------------- */ + const { Tube, TubePath, TUBE_PATH_NAMES, tubeDefaults } = VFX_MODULES.get('Tube.js') ?? {}; + for (const path of Object.values(TubePath ?? {})) { + add('Tube.js', `Tube ${TUBE_PATH_NAMES?.[path]}`, () => { + const parent = new Group(); + const tube = new Tube({ path, prefix: 'tube', nodes: 32, sides: 10 }); + parent.add(tube.group); + const c = tubeDefaults('tube', path); + const state = { + origin: _from, target: _to, side: _side, + progress: 0.6, fade: 1, widthFade: 1, seed: 4, time: 0, grow: 1, snapAge: 0 + }; + return { + node: parent, + drawCalls: () => tube.drawCalls, + tick: (now) => { + state.time = now; + tube.sync(c, state, settings.global); + // The crack is recomputed by sync(); poll it as an ability would. + void tube.crack.fired; + void tube.radiusAt(0.5); + }, + dispose: () => tube.dispose() + }; + }); + } + + /* --- Shell: every mode --------------------------------------------- */ + const { Shell, ShellMode, SHELL_MODE_NAMES, shellDefaults } = VFX_MODULES.get('Shell.js') ?? {}; + for (const mode of Object.values(ShellMode ?? {})) { + add('Shell.js', `Shell ${SHELL_MODE_NAMES?.[mode]}`, () => { + const parent = new Group(); + const shell = new Shell({ mode, prefix: 'shell', nodes: 16, sides: 16, rings: 6, segments: 32 }); + parent.add(shell.group); + const c = shellDefaults('shell', mode); + const state = { origin: _to, axis: _up, side: _side, span: 6, t: 0.4, fade: 1, seed: 2 }; + return { + node: parent, + drawCalls: () => shell.drawCalls, + tick: (now, index) => { + state.t = Math.min(1, index / 8); + shell.sync(c, state, settings.global); + void shell.standingAt(0.5); + }, + dispose: () => shell.dispose() + }; + }); + } + + /* --- BurstSystem, re-exported by Shell.js -------------------------- */ + const { BurstSystem, BurstMode } = VFX_MODULES.get('Shell.js') ?? {}; + add('Shell.js', 'BurstSystem (re-export)', () => { + const scene = new Group(); + const bursts = new BurstSystem(scene); + for (const mode of Object.values(BurstMode ?? {})) bursts.spawn(mode, _to, { life: 0.4 }); + return { + node: scene, + drawCalls: () => 1, + tick: (now, index, dt) => bursts.update(dt), + dispose: () => bursts.dispose?.() + }; + }); + + /* --- Projectile: every flight mode --------------------------------- */ + const { Projectile, FlightMode, projectileParams } = VFX_MODULES.get('Projectile.js') ?? {}; + const flights = Object.values(FlightMode ?? {}); + add('Projectile.js', `Projectile (${flights.length} flight modes)`, () => { + const parent = new Group(); + const material = new MeshBasicMaterial(); + const body = new Projectile(parent, { + geometry: () => new IcosahedronGeometry(0.3, 0), + material, + capacity: 12, + trail: true, + trailNodes: 12 + }); + const p = projectileParams(); + body.setBasis(_from, _dirZ, _side, 12); + body.roll(7); + return { + node: parent, + drawCalls: () => body.drawCalls, + tick: (now, index) => { + p.flight = flights[index % flights.length]; + body.update(now, p); + // Arrivals must be safe to read straight after update(), every frame. + void body.arrivalCount; + }, + dispose: () => { + body.dispose(); + material.dispose(); + } + }; + }); + + /* --- Swarm: every silhouette --------------------------------------- */ + const { Swarm, Silhouette, LeadPath, swarmParams } = VFX_MODULES.get('Swarm.js') ?? {}; + for (const [name, silhouette] of Object.entries(Silhouette ?? {})) { + add('Swarm.js', `Swarm ${name}`, () => { + const parent = new Group(); + const swarm = new Swarm(parent, { capacity: 64, silhouette }); + const p = swarmParams(); + p.lead = LeadPath?.ORBIT ?? 0; + swarm.setBasis(_from, _dirZ, _side, 12); + swarm.roll(5); + return { + node: parent, + drawCalls: () => swarm.drawCalls, + tick: (now) => swarm.update(now, p), + dispose: () => swarm.dispose() + }; + }); + } + + /* --- Distortion: every emitter ------------------------------------- */ + const { DistortionField, DistortionMode, DistortionFacing } = VFX_MODULES.get('Distortion.js') ?? {}; + for (const [name, mode] of Object.entries(DistortionMode ?? {})) { + add('Distortion.js', `DistortionField ${name}`, () => { + const parent = new Group(); + const field = new DistortionField({ mode, facing: DistortionFacing?.BILLBOARD, edge: true }); + parent.add(field.object3D); + field.visible = true; + field.setAnchorXYZ(0, 1, 8); + field.setBasis(_dirZ, _up); + // An empty params object is the interesting call: it proves every read + // falls back rather than writing `undefined` into a uniform. + const p = { radius: 2.5, strength: 0.4, seed: 3 }; + return { + node: parent, + drawCalls: () => 1, + tick: (now, index) => field.update(index % 2 ? p : {}), + dispose: () => { + field.visible = false; // release the writer counter + field.dispose(); + } + }; + }); + } + + /* --- Portal --------------------------------------------------------- */ + const { Portal } = VFX_MODULES.get('Portal.js') ?? {}; + add('Portal.js', 'Portal', () => { + const parent = new Group(); + const portal = new Portal({}); + parent.add(portal.object3D); + portal.setPlacement(_to, _dirZ, _up); + const p = { open: 0.8, radiusX: 2, radiusY: 3, seed: 9 }; + return { + node: parent, + drawCalls: () => 1, + tick: (now, index) => portal.update(index % 2 ? p : {}), + dispose: () => portal.dispose() + }; + }); + + /* --- LiquidSurface: both modes -------------------------------------- */ + const { LiquidSurface, LiquidMode, liquidParams } = VFX_MODULES.get('LiquidSurface.js') ?? {}; + for (const [name, mode] of Object.entries(LiquidMode ?? {})) { + add('LiquidSurface.js', `LiquidSurface ${name}`, () => { + const parent = new Group(); + const surface = new LiquidSurface({ mode, segments: 24 }); + parent.add(surface.object3D); + surface.setPlacement(_to, _dirZ, _up); + const p = liquidParams(); + return { + node: parent, + drawCalls: () => surface.drawCalls, + tick: (now, index) => { + surface.update(now, p); + // rippleAtWorld() has to be called after update() — it needs this + // frame's half-extents to turn a metre into the fraction it stores. + if (index === 2) surface.rippleAtWorld(_to, 1, now); + void surface.lipHeight(p, 0); + }, + dispose: () => surface.dispose() + }; + }); + } + + /* --- Curtain: every mode, every layout ------------------------------ */ + const { Curtain, CurtainMode, CurtainLayout, curtainParams } = VFX_MODULES.get('Curtain.js') ?? {}; + const curtainCombos = []; + for (const mode of Object.values(CurtainMode ?? {})) curtainCombos.push([mode, CurtainLayout?.LINE ?? 0]); + for (const layout of Object.values(CurtainLayout ?? {})) curtainCombos.push([CurtainMode?.RAIN ?? 0, layout]); + for (const [mode, layout] of curtainCombos) { + add('Curtain.js', `Curtain mode:${mode} layout:${layout}`, () => { + const parent = new Group(); + const curtain = new Curtain({ mode, layout, capacity: 6, segmentsX: 8, segmentsY: 6, floor: true }); + parent.add(curtain.object3D); + curtain.setPlacement(_to, _side, _up); + curtain.roll(11); + const p = curtainParams(); + return { + node: parent, + drawCalls: () => curtain.drawCalls, + tick: (now) => curtain.update(now, p), + dispose: () => curtain.dispose() + }; + }); + } + + /* --- LensFlare ------------------------------------------------------ */ + const { LensFlare, lensFlareParams, MAX_FLARE_GHOSTS } = VFX_MODULES.get('LensFlare.js') ?? {}; + add('LensFlare.js', 'LensFlare', () => { + const parent = new Group(); + const flare = new LensFlare({ ghosts: MAX_FLARE_GHOSTS }); + parent.add(flare.object3D); + flare.visible = true; + const p = lensFlareParams(); + return { + node: parent, + drawCalls: () => flare.drawCalls, + tick: (now, index) => { + flare.setAnchor(_to); + // Sweep the ghost count so the instanceCount arithmetic is exercised + // at both ends, including 0 — an off-by-one there draws a ghost with + // a hashed spacing and no tint, which is a stripe across the frame. + p.ghosts = index % (MAX_FLARE_GHOSTS + 1); + p.occTaps = 1 + (index % 9); + p.intensity = 0.5 + 0.5 * Math.sin(now); + flare.update(p); + if (flare.object3D.geometry.instanceCount > 3 + MAX_FLARE_GHOSTS) { + throw new Error(`instanceCount ${flare.object3D.geometry.instanceCount} exceeds the built instances`); + } + }, + dispose: () => flare.dispose() + }; + }); + + /* --- Mirror: the surface, and the scheduler that feeds it ----------- */ + /** + * The scheduler is the part of `Mirror` that a blank screen tells you about + * far too late, and it is pure CPU: a frame token, a budget, a starvation + * score and the oblique projection maths. All of that runs against a stub + * renderer here — no GL, no reflection, but every line of `_renderReflection` + * except the draw itself, which is where a NaN in the clip plane would come + * from. + */ + const { Mirror, mirrorParams, mirrorBudget, setMirrorBudget } = VFX_MODULES.get('Mirror.js') ?? {}; + add('Mirror.js', 'Mirror + scheduler', () => { + const parent = new Group(); + const scene = new Scene(); + scene.add(parent); + + const camera = new PerspectiveCamera(50, 16 / 9, 0.1, 400); + camera.position.set(0, 2.4, -7); + camera.lookAt(_to); + camera.updateMatrixWorld(true); + camera.matrixWorldInverse.copy(camera.matrixWorld).invert(); + + /** The least renderer `_renderReflection()` will accept. */ + const gl = { + info: { render: { frame: 0, calls: 7, triangles: 4200 } }, + xr: { enabled: false }, + shadowMap: { autoUpdate: true }, + autoClear: false, + state: { buffers: { depth: { setMask() {} } }, viewport() {} }, + getRenderTarget: () => null, + setRenderTarget() {}, + clear() {}, + render() { + this.info.render.frame++; + } + }; + + const previousBudget = mirrorBudget.max; + setMirrorBudget(2); + + const mirrors = []; + for (let i = 0; i < 4; i++) { + const mirror = new Mirror({ resolution: 128, name: `mirror${i}` }); + parent.add(mirror.object3D); + mirror.visible = true; + mirrors.push(mirror); + } + + const p = mirrorParams(); + const seen = new Set(); + const where = new Vector3(); // built once per case, not per frame + + return { + node: parent, + drawCalls: () => mirrors[0].drawCalls, + tick: (now, index) => { + gl.info.render.frame++; // the outer frame's own render call + for (let i = 0; i < mirrors.length; i++) { + const mirror = mirrors[i]; + where.set(i * 2.5 - 3.75, 1.2, 12); + mirror.setPlacement(where, _dirZ, _up); + p.width = 2 + i * 0.5; + p.height = 2; + p.roughness = (index % 4) / 3; + p.resolution = index === 3 ? 256 : 128; // exercise the live re-size + p.priority = 1 + i * 0.1; + mirror.update(p); + } + // Scene order, as the renderer would walk it. + for (const mirror of mirrors) mirror.object3D.onBeforeRender(gl, scene, camera); + + if (mirrorBudget.rendered > 2) { + throw new Error(`${mirrorBudget.rendered} reflections in one frame with a budget of 2`); + } + for (const mirror of mirrors) { + const m = mirror.material.uniforms.uReflectMatrix.value.elements; + for (let e = 0; e < 16; e++) { + if (!Number.isFinite(m[e])) throw new Error(`${mirror.mesh.name}: uReflectMatrix[${e}] is ${m[e]}`); + } + if (mirror.material.uniforms.uHasReflection.value > 0.5) seen.add(mirror.mesh.name); + } + // Four mirrors, two slots, starvation weighting: everyone must have had + // a turn well inside six frames, or the scheduler is starving someone. + if (index === 5 && seen.size < mirrors.length) { + throw new Error(`only ${seen.size} of ${mirrors.length} mirrors rendered in six frames`); + } + }, + dispose: () => { + for (const mirror of mirrors) mirror.dispose(); + setMirrorBudget(previousBudget); + } + }; + }); + + /* --- SceneHooks: the borrow/restore discipline ---------------------- */ + /* + * This one is not a renderer, so "does it produce a NaN" is the least + * interesting question about it. What can actually go wrong is the ledger: + * a hook still held by an ability that was destroyed, a double release taking + * away somebody else's borrow, a restore that does not restore. All three are + * testable without a GL context, so this case asserts them and throws — and a + * throw inside `tick` is reported by `runVfxCase` like any other failure. + * + * The mock world is the smallest thing the module will accept: a Group + * standing in for the DirectionalLight (it wants `.position`, `.color` and + * `.intensity`, and a Group plus a borrowed Color covers it), a bare + * MeshStandardMaterial for the floor, and a plain uniform block for the + * grade. `mockEnvironment()` reproduces what `Environment.update()` does to + * the sun every frame, because the module's central claim — that KEY_LIGHT + * and GRADE need no restore, since their owners re-author them from settings + * before the hook runs — is only true if something does that, and asserting + * the sun comes back is worthless without it. + */ + const { SceneHooks, Hook } = VFX_MODULES.get('SceneHooks.js') ?? {}; + add('SceneHooks.js', 'SceneHooks (six hooks, borrow/restore)', () => { + const parent = new Group(); + const hooks = new SceneHooks(); + + const sun = new Group(); + sun.color = new MeshBasicMaterial().color; // a Color without a new import + sun.intensity = 1; + const sunTarget = new Group(); + const environment = { sun, sunTarget, focus: new Vector3(1, 0, -2) }; + const groundMaterial = new MeshStandardMaterial(); + const grade = { + uSaturation: { value: 1 }, + uTemperature: { value: 0 }, + uLift: { value: 0 }, + uVignette: { value: 0 } + }; + parent.add(sun, sunTarget); + + hooks.install({ scene: parent, environment, ground: { material: groundMaterial }, grade }); + + // Exercise the floor patch. `onBeforeCompile` is never called without a + // renderer, so the harness calls it by hand against the real chunk tokens. + const probe = { + uniforms: {}, + vertexShader: '#include \nvoid main(){\n#include \n}', + fragmentShader: '#include \nvoid main(){\n#include \n}' + }; + groundMaterial.onBeforeCompile(probe, null); + if (!probe.fragmentShader.includes('sceneAgeField')) { + throw new Error('the floor ageing patch did not reach the fragment shader'); + } + if (!probe.vertexShader.includes('vSceneAgeWorld =')) { + throw new Error('the floor ageing patch did not reach the vertex shader'); + } + for (const key of ['uAgeField', 'uAgeMix', 'uAgeRustColor']) { + if (!probe.uniforms[key]) throw new Error(`the ageing patch did not add ${key}`); + } + + const owner = { id: 'harness' }; + const rivalOwner = { id: 'rival' }; + const baseDir = new Vector3(); + const baseSunPos = new Vector3(); + const held = {}; + + /** What `Environment.update()` does to the key light, every frame. */ + const mockEnvironment = () => { + const e = settings.environment; + const cosE = Math.cos(e.sunElevation); + baseDir + .set( + -Math.cos(e.sunAzimuth) * cosE, + -Math.sin(e.sunElevation), + -Math.sin(e.sunAzimuth) * cosE + ) + .normalize(); + baseSunPos.copy(environment.focus).addScaledVector(baseDir, -70); + sunTarget.position.copy(environment.focus); + sun.position.copy(baseSunPos); + sun.intensity = e.sunIntensity; + grade.uSaturation.value = settings.post.saturation; + grade.uVignette.value = settings.post.vignette; + }; + + const takeAll = () => { + for (const hook of Object.values(Hook)) held[hook] = hooks.acquire(hook, owner); + held[Hook.KEY_LIGHT].aim(1.1, 0.2).brightness(9).tint('#ffd0a0').blend(0.8); + held[Hook.GRADE].saturate(0.1).temper(-0.4).raise(-0.06).darken(0.9).blend(1); + held[Hook.AGE] + .at(2, 0, -3) + .field(7, 0.4, 1, 0.35) + .wear(0.7, 0.4, 0.3, 0.5, 0.2) + .scale(1.8) + .colours('#7a3b1c', '#8a8375', '#3d5a20'); + held[Hook.HOLE].at(0, 1.5, 6).size(2.4, 0.8); + held[Hook.GRAVITY].at(0, 1, 6).well(5, 0.3).scale(-1.4, 1); + held[Hook.DISRUPT].at(0, 1, 6).region(6, 0.3).power(0.9, 0.5, 0.4).shardSize(7); + }; + + const holeMesh = () => parent.children.find((child) => child.name === 'SceneHooks.Hole'); + + return { + node: parent, + drawCalls: () => 1, // the depth proxy, and only while the hole is held + tick: (now, index) => { + mockEnvironment(); + + if (index === 0) { + takeAll(); + } else if (index === 1) { + // Last acquirer wins, and the earlier holder stays live underneath. + const rival = hooks.acquire(Hook.KEY_LIGHT, rivalOwner); + rival.aim(2.4, 1.3).blend(1); + if (held[Hook.KEY_LIGHT].driving) throw new Error('the earlier holder is still driving'); + if (!rival.driving) throw new Error('the last acquirer is not driving'); + if (rival === held[Hook.KEY_LIGHT]) { + throw new Error('two owners were handed the same token'); + } + // Re-acquiring a hook you already hold renews it; it does not stack a + // second borrow on top of your own. + if (hooks.acquire(Hook.KEY_LIGHT, rivalOwner) !== rival) { + throw new Error('re-acquiring your own hook did not return your token'); + } + rival.release(); + rival.release(); // a double release must be a no-op + if (!held[Hook.KEY_LIGHT].driving) throw new Error('LIFO did not hand the hook back'); + + /* The recycled-token hazard, which the first design claimed to catch + * with a serial and did not: a released token must never be handed to + * a second owner, or the previous owner's late `onDestroy()` evicts + * whoever is holding the hook now. Assert both halves. */ + const relay = hooks.acquire(Hook.HOLE, rivalOwner).size(1); + const stale = held[Hook.HOLE]; + stale.release(); // the earlier holder, from underneath + stale.release(); // and again, late — must not touch `relay` + if (!relay.active || hooks.driver(Hook.HOLE) !== relay) { + throw new Error('a stale release evicted the current holder'); + } + relay.release(); + held[Hook.HOLE] = hooks.acquire(Hook.HOLE, owner).at(0, 1.5, 6).size(2.4, 0.8); + } else if (index === 3) { + // Everything has been written, so everything must have moved — but + // only after an apply(), since mockEnvironment() has just put the + // settings-authored values back on top, exactly as the app does. + hooks.apply(); + if (!holeMesh()?.visible) throw new Error('the hole proxy is not visible while held'); + if (Math.abs(grade.uSaturation.value - settings.post.saturation) < 1e-6) { + throw new Error('the grade hook did not reach the grade uniforms'); + } + if (sun.position.distanceTo(baseSunPos) < 1e-3) { + throw new Error('the key light hook did not move the sun'); + } + if (hooks.gravityAt(0, 1, 6) > 0) throw new Error('gravity was not inverted in the well'); + if (hooks.gravityAt(0, 1, 40) !== 1) throw new Error('gravity leaked outside the well'); + if (hooks.disruptAt(0, 1, 6) < 0.99) throw new Error('the disrupt region reads as empty'); + if (hooks.ageAt(6, -3) <= 0) throw new Error('the age field is empty inside its own annulus'); + } else if (index === 4) { + // Reclaim by owner — the one line an ability's onDestroy() wants — + // and then assert the world is *exactly* back. For the two + // settings-authored hooks that means mockEnvironment() alone put it + // back, which is the claim the whole restore story rests on. + hooks.reclaim(owner); + hooks.apply(); + if (hooks.heldCount !== 0) throw new Error('reclaim(owner) left a hook held'); + if (holeMesh()?.visible) throw new Error('the hole proxy survived the release'); + if (hooks.gravityAt(0, 1, 6) !== 1) throw new Error('gravity did not restore to 1'); + if (hooks.disruptAt(0, 1, 6) !== 0) throw new Error('disrupt did not restore to 0'); + if (hooks.ageAt(6, -3) !== 0) throw new Error('the age field did not restore to 0'); + mockEnvironment(); + hooks.apply(); + if (Math.abs(grade.uSaturation.value - settings.post.saturation) > 1e-9) { + throw new Error('the grade did not come back bit-for-bit'); + } + if (Math.abs(sun.intensity - settings.environment.sunIntensity) > 1e-9) { + throw new Error('the key light did not come back bit-for-bit'); + } + if (sun.position.distanceTo(baseSunPos) > 1e-9) { + throw new Error('the sun did not come back bit-for-bit'); + } + takeAll(); // and back on, so the zero-length frame has work to do + } + + hooks.apply(); + }, + dispose: () => { + hooks.reclaim(owner); + if (hooks.heldCount !== 0) throw new Error('a hook survived teardown'); + hooks.dispose(); + groundMaterial.dispose(); + } + }; + }); + + + /* --- Caustics: every shape, every source, and the binding hook --------- */ + const { Caustics, CausticSource, CausticShape, CAUSTIC_BOUND_KEYS, causticsParams } = + VFX_MODULES.get('Caustics.js') ?? {}; + for (const [shapeName, shape] of Object.entries(CausticShape ?? {})) { + add('Caustics.js', `Caustics SCROLL/${shapeName}`, () => { + const parent = new Group(); + const net = new Caustics(parent, { source: CausticSource.SCROLL, shape }); + const p = causticsParams(); + p.centre = _to; + p.lightAxis = new Vector3(0.2, -1, 0.1); + return { + node: parent, + drawCalls: () => net.drawCalls, + tick: (now, index) => { + p.front = (index % 8) / 8; + p.now = now; + if (index === 2) net.ripple(0.3, -0.2, 1, now); + net.update(p); + }, + dispose: () => net.dispose() + }; + }); + } + + add('Caustics.js', 'Caustics WAVE bound to a LiquidSurface', () => { + const parent = new Group(); + const water = new LiquidSurface({ mode: LiquidMode?.WAVE ?? 1, segments: 16 }); + parent.add(water.object3D); + water.setPlacement(_to, _dirZ, _up); + + const net = new Caustics(parent, { source: CausticSource.WAVE, shape: CausticShape.LANE }); + const taken = net.bindSource(water.uniforms); + if (taken < 10) throw new Error(`bindSource() only took ${taken} of ${CAUSTIC_BOUND_KEYS.length} boxes`); + for (const key of CAUSTIC_BOUND_KEYS) { + if (net.material.uniforms[key] !== water.uniforms[key]) { + throw new Error(`${key} is not shared by identity after bindSource()`); + } + } + + const wet = liquidParams(); + const p = causticsParams(); + p.centre = _to; + return { + node: parent, + drawCalls: () => net.drawCalls, + tick: (now, index) => { + wet.waveFront = (index % 6) / 6; + water.update(now, wet); + // Written by the surface; the net must not have taken a second author. + const before = water.uniforms.uCrestHeight.value; + p.crestHeight = before + 5; + net.update(p); + if (water.uniforms.uCrestHeight.value !== before) { + throw new Error('Caustics.update() overwrote a bound uniform'); + } + }, + dispose: () => { + net.dispose(); + water.dispose(); + } + }; + }); + + add('Caustics.js', 'Caustics CUSTOM source', () => { + const parent = new Group(); + const net = new Caustics(parent, { + source: CausticSource.CUSTOM, + shape: CausticShape.DISC, + custom: ` + uniform float uTestAmp; + float causticHeight(vec2 xz) { return uTestAmp * sin(xz.x) * cos(xz.y + uTime); } + float causticRidge(vec2 xz) { return 0.0; } + `, + uniforms: { uTestAmp: { value: 0.3 } } + }); + const p = causticsParams(); + p.centre = _to; + return { + node: parent, + drawCalls: () => net.drawCalls, + tick: () => net.update(p), + dispose: () => net.dispose() + }; + }); + + /* --- LightShaft: every layout, plus the CPU mirrors -------------------- */ + const { LightShaft, ShaftLayout, lightShaftParams } = VFX_MODULES.get('LightShaft.js') ?? {}; + for (const [layoutName, shaftLayout] of Object.entries(ShaftLayout ?? {})) { + add('LightShaft.js', `LightShaft ${layoutName}`, () => { + const parent = new Group(); + const shafts = new LightShaft(parent, { capacity: 5, layout: shaftLayout, maxSteps: 16 }); + shafts.setPlacement(_to, _dirZ, _up); + shafts.roll(23); + const p = lightShaftParams(); + p.layout = shaftLayout; + p.count = 4; + const probe = new Vector3(); + const out = new Vector3(); + return { + node: parent, + drawCalls: () => shafts.drawCalls, + tick: (now, index) => { + p.sweep = (index % 6) / 6; + shafts.update(p); + shafts.footPoint(1, p, out); + shafts.mouthPoint(1, p, probe); + // Halfway down its own axis, a shaft must light its own centre line. + probe.lerp(out, 0.5); + const lit = shafts.irradianceAt(probe, p); + if (!Number.isFinite(lit)) throw new Error(`irradianceAt() returned ${lit}`); + if (index === 3 && lit <= 0) { + throw new Error('irradianceAt() is dark on the shaft’s own centre line'); + } + }, + dispose: () => shafts.dispose() + }; + }); + } + + /* --- BrushStroke: every tip layout ---------------------------------- */ + const { BrushStroke, BrushTip, BRUSH_TIP_NAMES, brushStrokeParams } = + VFX_MODULES.get('BrushStroke.js') ?? {}; + for (const [name, tip] of Object.entries(BrushTip ?? {})) { + add('BrushStroke.js', `BrushStroke ${BRUSH_TIP_NAMES?.[tip] ?? name}`, () => { + const parent = new Group(); + const brush = new BrushStroke(parent, { strokes: 3, bristles: 6, samples: 12, sides: 6, tip }); + const p = brushStrokeParams(); + brush.setPaper(_up); + brush.roll(5); + const probe = new Vector3(); + return { + node: parent, + drawCalls: () => brush.drawCalls, + tick: (now, index) => { + // The head walks across the whole set, so every stroke gets a frame + // where it is unstarted, one where it is mid-draw and one where it is + // finished — the three cases the remap in the vertex shader has. + p.progress = (index % 8) / 7; + for (let s = 0; s < brush.strokeCount; s++) { + const stroke = brush.stroke(s); + stroke.line(_from, _to, s % 2 ? 0.6 : -0.4, 0.2); + stroke.pressure(0.12, 1, 0.8, 0.03); + stroke.ink(6 + s); + stroke.timing(s / 3, 1 / 3); + // Stroke 2 spends a frame parked, which is the path that must not + // shuffle the remaining strokes into other slots. + stroke.active = !(s === 2 && index === 4); + } + brush.update(now, p); + void brush.widthAt(0, 0.5); + brush.tipPoint(0, probe); + brush.tangentAt(0, 0.5, probe); + }, + dispose: () => brush.dispose() + }; + }); + } + + /* --- InkDiffusion: every mode ---------------------------------------- */ + const { InkDiffusion, InkMode, INK_MODE_NAMES, inkDiffusionParams } = + VFX_MODULES.get('InkDiffusion.js') ?? {}; + for (const [name, mode] of Object.entries(InkMode ?? {})) { + add('InkDiffusion.js', `InkDiffusion ${INK_MODE_NAMES?.[mode] ?? name}`, () => { + const parent = new Group(); + const ink = new InkDiffusion(parent, { mode, sources: 4, satellites: 8 }); + const p = inkDiffusionParams(); + p.sources = 3; + p.satellites = 8; + ink.setPlacement(_to, _dirZ); + ink.roll(7); + const probe = new Vector3(); + return { + node: parent, + drawCalls: () => ink.drawCalls, + tick: (now) => { + ink.update(now, p); + // The CPU mirrors are what splatterbrand aims its projectiles with, + // so a NaN in one of them is a projectile thrown at the origin. + if (!Number.isFinite(ink.frontRadius(1))) throw new Error('frontRadius() is not finite'); + ink.sourcePoint(2, probe); + ink.satellitePoint(3, probe); + if (!Number.isFinite(probe.x + probe.y + probe.z)) { + throw new Error('satellitePoint() is not finite'); + } + if (!Number.isFinite(ink.satelliteSize(3))) throw new Error('satelliteSize() is not finite'); + if (!Number.isFinite(ink.satelliteAge(3))) throw new Error('satelliteAge() is not finite'); + }, + dispose: () => ink.dispose() + }; + }); + } + + /* --- TimeControl: recorder, ghosts, the field, the reverse driver --- */ + const { + GhostRig, + RewindGate, + TimeRecorder, + TimeWarpClock, + ghostLook, + recorderParams, + reverseParams, + reverseRate, + reverseTime, + timeField, + timeRegionParams + } = VFX_MODULES.get('TimeControl.js') ?? {}; + + /** + * A minimal skinned rig, standing in for the character. + * + * The recorder and the ghost are the only things in this library that need a + * *skeleton* rather than a mesh, and the character is an FBX this harness has + * no way to load. Eight bones in a chain under one `SkinnedMesh` is enough to + * exercise everything that can go wrong: bone discovery, the parallel clone, + * the rebind, and the interpolation. + */ + const probeRig = (boneCount = 8) => { + const root = new Group(); + root.name = 'Character'; + const geometry = new BoxGeometry(0.3, 1.8, 0.3, 1, boneCount - 1, 1); + const verts = geometry.attributes.position.count; + geometry.setAttribute('skinIndex', new BufferAttribute(new Uint16Array(verts * 4), 4)); + geometry.setAttribute('skinWeight', new BufferAttribute(new Float32Array(verts * 4), 4)); + for (let i = 0; i < verts; i++) geometry.attributes.skinWeight.setX(i, 1); + + const bones = []; + let parent = null; + for (let i = 0; i < boneCount; i++) { + const bone = new Bone(); + bone.name = `probe${i}`; + bone.position.y = i === 0 ? 0 : 0.22; + if (parent) parent.add(bone); + parent = bone; + bones.push(bone); + } + const mesh = new SkinnedMesh(geometry, new MeshStandardMaterial()); + mesh.add(bones[0]); + root.add(mesh); + mesh.bind(new Skeleton(bones)); + root.updateMatrixWorld(true); + return { root, bones, geometry, mesh }; + }; + + add('TimeControl.js', 'TimeRecorder + GhostRig (skeletal replay)', () => { + const parent = new Group(); + const rig = probeRig(8); + parent.add(rig.root); + + const recorder = new TimeRecorder({ capacity: 24, bones: 16 }); + recorder.attach(rig.root); + const ghosts = [new GhostRig(parent, { source: rig.root }), new GhostRig(parent)]; + // The second ghost is sourced late on purpose: `setSource()` is the one + // allocating call in the class and abilities are told to make it from + // `createShaders()`, but the character is an async load and the fallback + // path — source it on the first cast — has to work too. + ghosts[1].setSource(rig.root); + + const rp = recorderParams(); + const look = ghostLook(); + const probePos = new Vector3(); + const probeQuat = new Quaternion(); + + return { + node: parent, + drawCalls: () => ghosts.reduce((total, ghost) => total + ghost.drawCalls, 0), + tick: (now, index) => { + for (let b = 0; b < rig.bones.length; b++) { + rig.bones[b].rotation.z = Math.sin(now * 3 + b) * 0.35; + } + rig.root.updateMatrixWorld(true); + rp.rate = 30 + index; + recorder.sample(now, rp); + recorder.trim(now, rp); + + for (let g = 0; g < ghosts.length; g++) { + const at = now - 0.08 * (g + 1); + const live = recorder.poseAt(at, ghosts[g]); + recorder.transformAt(at, probePos, probeQuat); + if (!Number.isFinite(probePos.x + probeQuat.w)) { + throw new Error('transformAt() wrote a non-finite root transform'); + } + look.fade = 0.7 / (g + 1); + look.seed = g * 3.7; + // Asking for an instant off the end of the track is normal — the + // oldest echo has no history for its first `delay` seconds — and it + // must place the ghost rather than NaN it. + ghosts[g].visible = live; + ghosts[g].place(probePos, g * 0.4).setScale(1 - g * 0.05).sync(look); + } + }, + dispose: () => { + for (const ghost of ghosts) ghost.dispose(); + recorder.detach(); + rig.geometry.dispose(); + rig.mesh.material.dispose(); + } + }; + }); + + add('TimeControl.js', 'TimeField (stasis, rewind, slow, and the pool ceiling)', () => { + const parent = new Group(); + const regions = []; + const params = []; + // Every rate the field is documented to support, plus the exhaustion path: + // the pool is MAX_TIME_REGIONS wide and `acquire()` past that returns null, + // on the `ctx.lights.acquire()` contract. A case that never sees the null + // is a case that never checks the guard is needed. + const rates = [0, -1, 0.25, 1, 0]; + for (let i = 0; i < rates.length; i++) { + const region = timeField.acquire(0); + regions.push(region); + const p = timeRegionParams(); + p.rate = rates[i]; + params.push(p); + region?.lock(0.25 * i); + } + if (regions[regions.length - 1] !== null) { + throw new Error('acquire() past the pool ceiling must return null'); + } + + return { + node: parent, + drawCalls: () => 0, + tick: (now, index) => { + for (let i = 0; i < regions.length; i++) { + const region = regions[i]; + if (!region) continue; // the guard every ability has to write + region.placeXYZ(i * 2, 1, 6); + params[i].radius = 3 + i + index * 0.1; + params[i].core = 0.2 * i; + params[i].strength = 1 - i * 0.15; + region.sync(params[i]); + if (!Number.isFinite(region.weightAt(_to))) throw new Error('weightAt() is not finite'); + } + if (!Number.isFinite(timeField.clockAt(now, _to))) { + throw new Error('clockAt() is not finite'); + } + if (!Number.isFinite(timeField.weightAt(_from))) { + throw new Error('weightAt() is not finite'); + } + }, + dispose: () => { + for (const region of regions) region?.release(); + // A leaked region is invisible and freezes a piece of the world for the + // rest of the session, so the case asserts the pool actually emptied. + timeField.reset(); + if (frame.uTimeRegionCount.value !== 0) { + throw new Error('the time field did not return to idle'); + } + } + }; + }); + + add('TimeControl.js', 'reverseTime / TimeWarpClock / RewindGate', () => { + const clock = new TimeWarpClock(); + const gate = new RewindGate(); + const p = reverseParams(); + return { + node: null, + drawCalls: () => 0, + tick: (now, index, dt) => { + p.turnAt = 0.1 + index * 0.02; + const bent = reverseTime(now, p); + const rate = reverseRate(now, p); + if (!Number.isFinite(bent) || !Number.isFinite(rate)) { + throw new Error('the closed form is not finite'); + } + clock.advance(dt, index % 3 === 2 ? -2 : 1, 0); + if (!Number.isFinite(clock.now) || clock.emitDt < 0) { + throw new Error('TimeWarpClock produced a bad step'); + } + gate.poll(bent, p.turnAt); + }, + dispose: () => { + clock.reset(); + gate.reset(); + } + }; + }); + + /* --- FoldMesh: every pattern, every layout --------------------------- */ + const { FoldMesh, FoldPattern, FoldLayout, foldMeshParams, fanCreases } = + VFX_MODULES.get('FoldMesh.js') ?? {}; + const foldCombos = []; + for (const [name, pattern] of Object.entries(FoldPattern ?? {})) { + foldCombos.push([`${name} LINE`, pattern, FoldLayout?.LINE ?? 0]); + } + for (const [name, layout] of Object.entries(FoldLayout ?? {})) { + foldCombos.push([`CRANE ${name}`, FoldPattern?.CRANE ?? 2, layout]); + } + for (const [label, pattern, layout] of foldCombos) { + add('FoldMesh.js', `FoldMesh ${label}`, () => { + const parent = new Group(); + const paper = new FoldMesh(parent, { + pattern, + layout, + capacity: 6, + segments: 6, + segmentsV: 8 + }); + paper.setBasis(_from, _dirZ, _side, 12); + paper.roll(5); + const p = foldMeshParams(); + p.count = 5; + const probe = new Vector3(); + return { + node: parent, + drawCalls: () => paper.drawCalls, + tick: (now, index) => { + // Sweep the fold across the frames: the whole module is one slider and + // the interesting arithmetic (the hinge radius, the spiral) divides by + // things that are zero at exactly one value of it. + p.progress = index / 6; + p.payout = index / 6; + p.curl = index === 3 ? 0 : 0.4; // the straight-run limit of sin(ka)/k + paper.update(now, p); + void paper.sheetPoint(index % 5, p, probe); + void paper.spoolPoint(index % 5, p, probe); + }, + dispose: () => paper.dispose() + }; + }); + } + add('FoldMesh.js', 'FoldMesh custom table', () => { + const parent = new Group(); + const paper = new FoldMesh(parent, { capacity: 2, segments: 5, segmentsV: 5 }); + paper.setPattern(fanCreases(12, 1)); + paper.setBasis(_from, _dirZ, _side, 8); + const p = foldMeshParams(); + p.count = 2; + p.hinge = 0.0005; // under a grid cell: the degenerate hinge + return { + node: parent, + drawCalls: () => paper.drawCalls, + tick: (now) => paper.update(now, p), + dispose: () => paper.dispose() + }; + }); + + /* --- Dissolve: the heap, and the patch ------------------------------- */ + const { + DissolveField, + DissolveMode, + DissolveSpace, + heapParams, + dissolveParams, + dissolveUniforms, + syncDissolve, + patchDissolveMaterial + } = VFX_MODULES.get('Dissolve.js') ?? {}; + + add('Dissolve.js', 'Dissolve GRANULAR', () => { + const parent = new Group(); + const heap = new DissolveField(parent, { along: 16, across: 10 }); + heap.setBasis(_from, _dirZ, _side, 14); + heap.roll(9); + const p = heapParams(); + const probe = new Vector3(); + return { + node: parent, + drawCalls: () => heap.drawCalls, + tick: (now, index) => { + // Repose at zero would make tan(0) a divide-by-nothing in the radius, + // and the clamp inside heapAt is the thing being tested here. + p.repose = index === 4 ? 0 : 0.58; + p.rate = index === 5 ? 0 : 5; + heap.update(now, p); + void heap.frontPoint(now, p, probe); + if (!Number.isFinite(heap.crestHeight(now, p))) throw new Error('crestHeight is not finite'); + }, + dispose: () => heap.dispose() + }; + }); + + for (const [name, mode] of [ + ['VOXEL', DissolveMode?.VOXEL ?? 0], + ['EROSION', DissolveMode?.EROSION ?? 2] + ]) { + add('Dissolve.js', `Dissolve patch ${name}`, () => { + const parent = new Group(); + const material = patchDissolveMaterial(new MeshStandardMaterial(), { mode }); + const mesh = new Mesh(new BoxGeometry(1, 1, 1), material); + parent.add(mesh); + const p = dissolveParams(); + return { + node: parent, + drawCalls: () => 1, + tick: (_now, index) => { + p.progress = index / 6; + p.span = index === 3 ? 0 : 0.22; // the zero-width let-go + syncDissolve(material, p); + }, + dispose: () => { + mesh.geometry.dispose(); + material.dispose(); + } + }; + }); + } + + add('Dissolve.js', 'Dissolve patch shared box (WORLD)', () => { + const parent = new Group(); + // The reason the box is a separate function: two materials, one event. + const box = dissolveUniforms({ space: DissolveSpace?.WORLD ?? 1, erode: 1 }); + const materials = [new MeshStandardMaterial(), new MeshStandardMaterial()]; + for (const m of materials) { + patchDissolveMaterial(m, { uniforms: box }); + const mesh = new Mesh(new BoxGeometry(0.5, 0.5, 0.5), m); + parent.add(mesh); + } + const p = dissolveParams(); + p.space = DissolveSpace?.WORLD ?? 1; + return { + node: parent, + drawCalls: () => materials.length, + tick: (_now, index) => { + p.progress = index / 6; + syncDissolve(box, p); + if (materials[1].userData.uniforms.uDisProgress !== box.uDisProgress) { + throw new Error('the shared box stopped being shared'); + } + }, + dispose: () => { + for (const m of materials) m.dispose(); + } + }; + }); + + /* --- HardSurface: six generators, one material, two solvers ---------- */ + const { + HardShape, + HARD_SHAPE_NAMES, + HardAxis, + hardShape, + hardSurfaceGeometry, + gearShape, + gearPitchFraction, + gearRootFraction, + ShapeCache, + GearTrain, + gearTrainParams, + GrindContact, + grindParams, + BrushMode, + hardSurfaceParams, + createHardSurfaceMaterial, + syncHardSurfaceMaterial, + blackbodyColor, + heatToKelvin + } = VFX_MODULES.get('HardSurface.js') ?? {}; + + /** + * `scanGroup` only reads *instanced* attributes, and every one of these is a + * plain BufferGeometry — so the geometry scan is done here by hand. It is + * worth the twelve lines: an involute flank is an `acos` of a ratio and a + * `tan` of the result, and the whole family of ways to get a NaN out of that + * shows up as a hole in the mesh and nowhere else. + */ + const scanHardGeometry = (label, geometry) => { + for (const name of ['position', 'normal', 'aEdge']) { + const attribute = geometry.getAttribute(name); + if (!attribute) throw new Error(`${label}: no ${name} attribute`); + const array = attribute.array; + for (let i = 0; i < array.length; i++) { + if (!Number.isFinite(array[i])) throw new Error(`${label}: ${name}[${i}] is ${array[i]}`); + } + } + // The unit-space contract GrowthField's factory relies on. + const p = geometry.getAttribute('position').array; + let minY = Infinity; + let maxY = -Infinity; + let maxR = 0; + for (let i = 0; i < p.length; i += 3) { + minY = Math.min(minY, p[i + 1]); + maxY = Math.max(maxY, p[i + 1]); + maxR = Math.max(maxR, Math.hypot(p[i], p[i + 2])); + } + if (maxR > 0.5001 || minY < -1e-4 || maxY > 1.0001) { + throw new Error( + `${label}: outside unit space — footprint r=${maxR.toFixed(3)} (max 0.5), y=${minY.toFixed(3)}..${maxY.toFixed(3)} (0..1)` + ); + } + }; + + /* Every generator, on every axis, plus the awkward settings of each: a + pointed tooth, a solid blank, a slotless blade, a boltless plate, an + unthreaded bolt and a hornless anvil are all reachable from the editor. */ + const hardVariants = [ + ['GEAR', HardShape?.GEAR, { teeth: 7, addendum: 1.4, backlash: 0, bore: 0, lightenHoles: 6 }], + ['GEAR pointed', HardShape?.GEAR, { teeth: 5, addendum: 2.2, rootFillet: 0, chamfer: 0 }], + ['PISTON', HardShape?.PISTON, { axis: HardAxis?.Z, rings: 4, faceRecess: 0 }], + ['SAWBLADE', HardShape?.SAWBLADE, { teeth: 9, rake: 40, clearance: 55, slots: 0 }], + ['SAWBLADE fine', HardShape?.SAWBLADE, { teeth: 48, rake: -12, gullet: 0.05 }], + ['PLATE', HardShape?.PLATE, { bolts: 6, counterSink: 0.08 }], + ['PLATE bare', HardShape?.PLATE, { bolts: 0, bevel: 0, corner: 0 }], + ['BOLT', HardShape?.BOLT, { threadTurns: 14, washer: 0 }], + ['BOLT smooth', HardShape?.BOLT, { threadTurns: 0, threadDepth: 0, tipTaper: 0 }], + ['ANVIL', HardShape?.ANVIL, { axis: HardAxis?.X }], + ['ANVIL unfilleted', HardShape?.ANVIL, { fillet: 0, horn: 0, corner: 0 }] + ]; + for (const [label, kind, overrides] of hardVariants) { + add('HardSurface.js', `HardSurface ${label}`, () => { + const parent = new Group(); + const material = createHardSurfaceMaterial({}); + const shape = hardShape(kind, overrides); + const geometry = hardSurfaceGeometry(kind, shape); + scanHardGeometry(`HardSurface ${label}`, geometry); + parent.add(new Mesh(geometry, material)); + const p = hardSurfaceParams(); + return { + node: parent, + drawCalls: () => 1, + tick: (now, index) => { + // A slider being dragged while the clock is stopped: every one of + // these has to land in a uniform on the zero-length frame too. + p.heat = (index % 7) / 6; + p.brush = index % 3; + p.grainScale = 60 + index * 11; + syncHardSurfaceMaterial(material, p); + void heatToKelvin(p.heat, p); + }, + dispose: () => { + geometry.dispose(); + material.dispose(); + } + }; + }); + } + + add('HardSurface.js', 'HardSurface ShapeCache', () => { + const cache = new ShapeCache({ capacity: 3 }); + const shape = gearShape(); + return { + node: null, + drawCalls: () => 0, + tick: (now, index) => { + // Every other frame moves a shape number; the frames in between must + // hand back the identical geometry rather than rebuild it. + shape.teeth = 10 + (index % 3); + const first = cache.get('gear', HardShape.GEAR, shape); + const rebuilt = cache.changed; + const second = cache.get('gear', HardShape.GEAR, shape); + if (second !== first) throw new Error('ShapeCache rebuilt an unchanged shape'); + if (cache.changed) throw new Error('ShapeCache reported a change with nothing changed'); + if (rebuilt) scanHardGeometry('ShapeCache gear', first); + if (!Number.isFinite(gearPitchFraction(shape) + gearRootFraction(shape))) { + throw new Error('gear pitch/root fraction is not finite'); + } + }, + dispose: () => cache.dispose() + }; + }); + + add('HardSurface.js', 'HardSurface GearTrain (meshing)', () => { + const train = new GearTrain({ capacity: 8 }); + const p = gearTrainParams(); + p.origin = _to; + p.direction = _dirZ; + p.side = _side; + p.teeth = [9, 14, 22]; + train.plant(6, 3.5); + const a = new Vector3(); + const b = new Vector3(); + return { + node: null, + drawCalls: () => 0, + tick: (now, index) => { + p.time = now; + // The ratios are dragged mid-cast, which is the whole point of them + // living in the params: the train must stay meshed through it. + p.teeth = index % 2 ? [9, 14, 22] : [11, 13, 30]; + p.module = 0.12 + index * 0.01; + train.solve(p); + for (let i = 0; i < train.count; i++) { + train.positionOf(i, p, a); + if (!Number.isFinite(a.x + a.y + a.z)) throw new Error(`gear ${i} position is not finite`); + if (!Number.isFinite(train.yawOf(i) + train.rateOf(i))) throw new Error(`gear ${i} spin is not finite`); + if (i === 0) continue; + // Standard centre distance: the sum of the PITCH radii, to the + // millimetre. If this drifts the teeth are interpenetrating. + train.positionOf(i - 1, p, b); + const want = train.pitchRadiusOf(i - 1) + train.pitchRadiusOf(i); + if (Math.abs(a.distanceTo(b) - want) > 1e-6) { + throw new Error(`gears ${i - 1}/${i} sit ${a.distanceTo(b).toFixed(4)} m apart, should be ${want.toFixed(4)} m`); + } + // And counter-rotating at exactly the inverse tooth ratio. + const ratio = -train.rateOf(i) / train.rateOf(i - 1); + if (Math.abs(ratio - train.teethOf(i - 1) / train.teethOf(i)) > 1e-9) { + throw new Error(`gears ${i - 1}/${i} turn at ${ratio}, not the tooth ratio`); + } + train.contactOf(i, p, b); + } + }, + dispose: () => train.clear() + }; + }); + + add('HardSurface.js', 'HardSurface GrindContact', () => { + const grind = new GrindContact(); + const p = grindParams(); + const velocity = new Vector3(); + const contact = new Vector3(0, 0.02, 8); + const centre = new Vector3(0, 1.1, 8); + const emit = {}; + const axis = new Vector3(1, 0, 0); + return { + node: null, + drawCalls: () => 0, + tick: (now, index) => { + // Frame 3 is the degenerate one: a stalled blade flat against the + // floor, where the tangent, the binormal and the speed all collapse. + const rate = index === 3 ? 0 : 90 - index * 6; + GrindContact.rimVelocity(velocity, index === 3 ? new Vector3() : axis, rate, contact, centre); + grind.solve(contact, index === 3 ? new Vector3() : _up, velocity, p); + if (!Number.isFinite(grind.speed) || grind.speed < p.speedFloor - 1e-6) { + throw new Error(`spark speed ${grind.speed} is below the floor`); + } + for (let j = 0; j < grind.jets; j++) { + grind.jet(j, emit); + const d = emit.direction; + if (!Number.isFinite(d.x + d.y + d.z)) throw new Error(`jet ${j} direction is not finite`); + if (Math.abs(d.length() - 1) > 1e-3) throw new Error(`jet ${j} direction is not unit`); + // The read the whole helper exists for: sparks leave along the + // tangent, so a jet must never point into the workpiece. + if (index !== 3 && d.dot(_up) < -1e-3) throw new Error(`jet ${j} is aimed into the floor`); + } + void blackbodyColor(900 + index * 180); + }, + dispose: () => {} + }; + }); + + /* --- Colony: the SDF swarm, every target shape ---------------------- */ + const { + ColonySwarm, + ColonyShape, + COLONY_SHAPE_NAMES, + colonySwarmParams, + WebGraph, + webGraphParams, + LatticeGrowth, + latticeGrowthParams, + PlateShell, + plateShellParams + } = VFX_MODULES.get('Colony.js') ?? {}; + + for (const [name, shape] of Object.entries(ColonyShape ?? {})) { + add('Colony.js', `ColonySwarm ${name}`, () => { + const parent = new Group(); + const swarm = new ColonySwarm(parent, { capacity: 64 }); + const p = colonySwarmParams(); + // Morph this shape into the next one, because the transition is the + // thing the module exists for and the blended field is the code path + // that only runs when the two shapes differ. + p.shapeA = shape; + p.shapeB = (shape + 1) % (COLONY_SHAPE_NAMES?.length ?? 6); + p.waveAmp = 0.4; + swarm.setBasis(_from, _dirZ, _side, 12); + swarm.roll(5); + const out = new Vector3(); + return { + node: parent, + drawCalls: () => swarm.drawCalls, + tick: (now, index) => { + p.condense = index / VFX_FRAMES; + p.shapeBlend = (index * 0.3) % 1; + p.cling = index === 4 ? 1 : 0; + swarm.update(now, p); + void swarm.shapeCentre(out); + }, + dispose: () => swarm.dispose() + }; + }); + } + + /* --- Colony: the web and its membrane -------------------------------- */ + add('Colony.js', 'WebGraph', () => { + const parent = new Group(); + const web = new WebGraph(parent, { maxRings: 6, maxSpokes: 14, filmSubdiv: 2 }); + const p = webGraphParams(); + web.setPlacement(_to, _dirZ, _up); + web.roll(11); + const out = new Vector3(); + return { + node: parent, + drawCalls: () => web.drawCalls, + tick: (now, index) => { + p.grow = index / VFX_FRAMES; + // The topology rebuild is the interesting path: it rewrites both + // meshes' index attributes mid-flight and must never leave the film + // pointing at a spoke the strands no longer have. + p.rings = 3 + (index % 4); + p.spokes = 8 + (index % 7); + web.update(now, p); + void web.nodePoint(index % 4, index, p, out); + }, + dispose: () => web.dispose() + }; + }); + + /* --- Colony: lattice growth, with the refusal doing work -------------- */ + add('Colony.js', 'LatticeGrowth', () => { + const parent = new Group(); + const comb = new LatticeGrowth(parent, { capacity: 160 }); + const p = latticeGrowthParams(); + comb.setPlacement(_to, _dirZ); + const out = new Vector3(); + return { + node: parent, + drawCalls: () => comb.drawCalls, + tick: (now, index) => { + // Sweep the two structural sliders, so the regrow path runs every + // frame rather than once. Drift 0 must give a clean lattice and a + // high refusal must not deadlock the frontier. + p.drift = index * 0.12; + p.refuse = 0.8 + index * 0.04; + comb.update(now, p); + if (comb.count < 1) throw new Error('the growth placed no cells at all'); + void comb.cellPoint(index, p, out); + }, + dispose: () => comb.dispose() + }; + }); + + /* --- Colony: the shell tessellation ---------------------------------- */ + add('Colony.js', 'PlateShell', () => { + const parent = new Group(); + const shell = new PlateShell(parent, { capacity: 64 }); + const p = plateShellParams(); + shell.setPlacement(_to, _dirZ); + const out = new Vector3(); + return { + node: parent, + drawCalls: () => shell.drawCalls, + tick: (now, index) => { + p.sites = 12 + index * 8; + p.jitter = index * 0.15; + shell.update(now, p); + // The claim the module is built on. Every plate must survive the + // clipper with a real polygon; a cell reduced below three vertices + // means a gap in the dome. + if (shell.count < 1) throw new Error('the tessellation produced no plates'); + void shell.plateCentre(index, p, out); + void shell.progress(now, p); + }, + dispose: () => shell.dispose() + }; + }); + + return cases; +} + +/** Frames each case is driven for before the zero-length one. */ +const VFX_FRAMES = 6; + +/** + * Drive one case and collect everything that went wrong with it. + * + * The zero-length frame at the end is not decoration: it is the same I1 probe + * the ability pause test runs, reduced to "does a `dt = 0` tick throw or + * produce a NaN". A module that divides by `dt` fails here and nowhere else. + */ +function runVfxCase(entry, failures) { + let handle; + try { + handle = entry.build(); + } catch (error) { + failures.push(`${entry.label}: construction threw — ${describeError(error)}`); + return; + } + + const problems = []; + try { + for (let i = 0; i < VFX_FRAMES; i++) { + frame.uTime.value += DT; + frame.uDelta.value = DT; + handle.tick(frame.uTime.value, i, DT); + } + // The zero-length frame — a paused slider drag. + frame.uDelta.value = 0; + handle.tick(frame.uTime.value, VFX_FRAMES, 0); + + if (handle.node) { + handle.node.updateMatrixWorld(true); + scanGroup(handle.node, problems); + } + + const calls = handle.drawCalls?.(); + if (Number.isFinite(calls) && calls > DRAW_CALL_BUDGET) { + problems.push(`draws ${calls} times on its own — the whole-ability budget is ${DRAW_CALL_BUDGET} (I4/I7)`); + } + } catch (error) { + problems.push(`threw while ticking — ${describeError(error)}`); + } + + try { + handle.dispose?.(); + } catch (error) { + problems.push(`dispose() threw — ${describeError(error)}`); + } + + for (const problem of problems) failures.push(`${entry.label}: ${problem}`); +} + +/** True for an ES class, which is what the coverage rule is looking for. */ +function isClass(value) { + return typeof value === 'function' && /^class\s/.test(Function.prototype.toString.call(value)); +} + +/** + * Run the whole stage. Reports through the roster-level channels, because a + * broken shared module is not any one ability's fault — it is everyone's. + */ +async function checkVfxLibrary() { + const failures = []; + + // Before anything is constructed: the one class of shader breakage that a + // headless harness can find with certainty, and that a browser finds by + // deleting a mesh from the frame without saying which. + vfx.glslBlocks = sweepGlslReservedWords(failures); + + for (const [name, module] of VFX_MODULES) { + if (module.__error) failures.push(`${name}: failed to import — ${describeError(module.__error)}`); + } + + let cases = []; + try { + cases = vfxCases(); + } catch (error) { + failures.push(`building the case list threw — ${describeError(error)}`); + } + + for (const entry of cases) { + if (VFX_MODULES.get(entry.file)?.__error) continue; // already reported + runVfxCase(entry, failures); + } + + /* --- the coverage rule --- */ + const covered = new Set(cases.map((entry) => entry.file)); + for (const [name, module] of VFX_MODULES) { + if (module.__error) continue; + const classes = Object.entries(module).filter(([, value]) => isClass(value)); + if (!classes.length) continue; + if (!covered.has(name)) { + failures.push( + `${name} exports ${classes.map(([key]) => key).join(', ')} but has no case in VFX_CASES — ` + + `add one to scripts/check.mjs so the abilities built on it get an error here rather than a blank screen` + ); + } + } + + vfx.cases = cases.length; + vfx.modules = VFX_MODULES.size; + vfx.failures = failures; + for (const message of failures) failGlobal(`[vfx] ${message}`); + return failures.length === 0; +} + +/* ------------------------------------------------------------------ */ +/* §11 · Output */ +/* ------------------------------------------------------------------ */ + +function describeError(error) { + if (!(error instanceof Error)) return String(error); + const frameLine = (error.stack ?? '').split('\n').find((line) => line.includes('/src/')); + const where = frameLine ? dim(` (${frameLine.trim().replace(/^at\s+/, '')})`) : ''; + return `${error.message}${where}`; +} + +const safeNumber = (n) => (Number.isFinite(n) ? n.toFixed(2) : String(n)); + +/** `null` means the stage never ran — usually because an earlier one failed. */ +const markText = (state) => (state === null ? '—' : state ? 'ok' : 'FAIL'); +const colourMark = (state, text) => (state === null ? dim(text) : state ? green(text) : red(text)); + +/* ------------------------------------------------------------------ */ +/* Run */ +/* ------------------------------------------------------------------ */ + +checkRoster(); +await checkSchools(); + +log( + bold( + `\ncheck · ${TARGET.length} of ${ABILITIES.length} abilities · ` + + `${SCHOOLS.length} schools · node ${process.version}\n` + ) +); + +for (const descriptor of TARGET) { + const report = reports.get(descriptor.id); + if (checkStructure(descriptor, report)) { + checkStaticReads(descriptor, report); + await captureConsole(report, () => runAbility(descriptor, report)); + } else { + // Without a settings block there is nothing coherent left to test. + checkStaticReads(descriptor, report); + } +} + +/* --- the shared library --- */ +// Skipped under `--only `, which exists to make one ability's loop fast; +// `--vfx` forces it back on when the library is what you are working on. +const RUN_VFX = !ONLY || flag('--vfx'); +if (RUN_VFX) await checkVfxLibrary(); + +/* --- the table --- */ +// Widths are measured on the *plain* text and the colour is wrapped around the +// finished cell: `padEnd` counts escape sequences as characters, so colouring +// first shifts every row that failed a few columns to the right. +const COLUMNS = [ + ['struct', 'struct'], + ['static', 'static'], + ['runtime', 'runtime'], + ['travel', 'pause:travel'], + ['impact', 'pause:impact'] +]; +const idWidth = Math.max(10, ...TARGET.map((a) => a.id.length + 2)); +const cellWidth = COLUMNS.map(([, title]) => title.length + 2); + +const row = (first, cells) => + first.padEnd(idWidth) + cells.map((text, i) => centre(text, cellWidth[i])).join(''); + +const centre = (text, size) => { + const pad = Math.max(0, size - text.length); + const left = Math.floor(pad / 2); + return ' '.repeat(left) + text + ' '.repeat(pad - left); +}; + +const headerLine = row('ability', COLUMNS.map(([, title]) => title)); +console.log(bold(headerLine)); +console.log(dim('─'.repeat(headerLine.length))); + +let failed = 0; +let skipped = false; +for (const descriptor of TARGET) { + const report = reports.get(descriptor.id); + if (!report.ok) failed++; + const cells = COLUMNS.map(([key], i) => { + if (report.marks[key] === null) skipped = true; + return colourMark(report.marks[key], centre(markText(report.marks[key]), cellWidth[i])); + }); + console.log(descriptor.id.padEnd(idWidth) + cells.join('')); +} +if (skipped) console.log(dim('— · not reached, because an earlier stage failed')); + +/* --- notes and warnings --- */ +if (!QUIET) { + for (const descriptor of TARGET) { + const report = reports.get(descriptor.id); + if (!report.notes.length && !report.warnings.length) continue; + console.log(`\n${bold(descriptor.id)}`); + for (const note of report.notes) console.log(dim(` ${note}`)); + for (const { stage, message } of report.warnings) console.log(yellow(` ! [${stage}] ${message}`)); + } + for (const note of rosterNotes) console.log(dim(`\n [roster] ${note}`)); + for (const warning of globalWarnings) console.log(yellow(`\n ! [roster] ${warning}`)); + + if (RUN_VFX) { + const state = vfx.failures?.length ? red(`${vfx.failures.length} failed`) : green('all pass'); + console.log(`\n${bold('vfx')}`); + console.log(dim(` ${vfx.cases} case(s) across ${vfx.modules} module(s) — ${state}`)); + console.log(dim(` ${vfx.glslBlocks} GLSL block(s) swept for GLSL ES 3.00 reserved words`)); + } +} + +/* --- failures --- */ +if (failed || globalFailures.length) { + console.log(`\n${bold(red('FAILURES'))}`); + for (const message of globalFailures) console.log(`\n ${red('roster')}\n ${message}`); + for (const descriptor of TARGET) { + const report = reports.get(descriptor.id); + if (report.ok) continue; + console.log(`\n ${red(descriptor.id)}`); + for (const { stage, message } of report.failures) { + console.log(` ${bold(`[${stage}]`)} ${message}`); + } + } +} + +const total = TARGET.length; +const broken = failed + (globalFailures.length ? 1 : 0); +const warnings = TARGET.reduce((sum, a) => sum + reports.get(a.id).warnings.length, 0) + globalWarnings.length; +console.log(''); +if (warnings && QUIET) console.log(dim(`${warnings} warning(s) — drop --quiet to read them.`)); +if (broken) { + console.log(red(bold(`${failed} of ${total} abilities failed${globalFailures.length ? `, plus ${globalFailures.length} roster-level failure(s)` : ''}.`))); + process.exit(1); +} +console.log(green(bold(`all ${total} abilities pass.`))); 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/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/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/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/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; + } } From b2c30516d05a8ea72f77b2ba34030f43dd823e9c Mon Sep 17 00:00:00 2001 From: Majid Manzarpour Date: Wed, 12 Aug 2026 00:21:12 -0400 Subject: [PATCH 2/9] feat(vfx): a 26-module shared tech library, and switch the refraction pass on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ability should be beats and configuration, not a new renderer. These generalise the techniques the original six invented — the bolt's ribbon strip, the beam's three-layer tube, the crystal field, the raymarched wake — and add the ones the new schools need. GrowthField, FilamentPaths, VolumeHull, GroundField, Tube, Shell, Projectile, Swarm, ArcNetwork, ShatterField, Distortion, Portal, LiquidSurface, Curtain, Caustics, LightShaft, LensFlare, Mirror, HardSurface, BrushStroke, InkDiffusion, FoldMesh, Dissolve, TimeControl, Colony, SceneHooks. Two are worth calling out: - Distortion. The README listed "the distortion pass runs with nothing writing to it" as a known rough edge, costing a half-res clear per frame for nothing. It now has five emitter types, and Layers.js counts its writers so the pass skips itself entirely when none are visible — turning it on would be a poor trade if it then ran unconditionally. - SceneHooks. The first thing here that reaches outside its own group: the key light, the grade, the ground material, gravity. Four casts can be live at once and any of them can be destroyed mid-effect, so it is built on a borrow/restore discipline with tokens rather than direct mutation, and the world is restored exactly. Caustics is not the usual difference-of-worley recipe. That gives blobs with soft shoulders because worley is smooth except at its cell walls, and no exponent fixes it; this draws the fold of the refraction map, so the singular set is a curve and the filaments come out thin without being asked. Co-Authored-By: Claude Opus 5 (1M context) --- src/archive/materials/OceanWaterMaterial.js | 7 +- src/assets/ProceduralGeometry.js | 14 +- src/particles/ParticleSystem.js | 112 +- src/postprocessing/DistortionShader.js | 57 +- src/postprocessing/PostProcessing.js | 92 +- src/shaders/lib/common.glsl.js | 99 +- src/shaders/lib/timewarp.glsl.js | 167 + src/utils/color.js | 26 + src/vfx/ArcNetwork.js | 313 + src/vfx/BrushStroke.js | 1250 ++++ src/vfx/Caustics.js | 1173 ++++ src/vfx/Colony.js | 3282 +++++++++++ src/vfx/Curtain.js | 1487 +++++ src/vfx/Dissolve.js | 1363 +++++ src/vfx/Distortion.js | 953 ++++ src/vfx/FilamentPaths.js | 1297 +++++ src/vfx/FoldMesh.js | 1675 ++++++ src/vfx/GroundField.js | 2173 +++++++ src/vfx/GrowthField.js | 974 ++++ src/vfx/HardSurface.js | 2945 ++++++++++ src/vfx/InkDiffusion.js | 1062 ++++ src/vfx/LensFlare.js | 995 ++++ src/vfx/LightShaft.js | 1029 ++++ src/vfx/LiquidSurface.js | 1348 +++++ src/vfx/Mirror.js | 938 +++ src/vfx/Portal.js | 682 +++ src/vfx/Projectile.js | 1187 ++++ src/vfx/README.md | 5689 +++++++++++++++++++ src/vfx/SceneHooks.js | 1558 +++++ src/vfx/ShatterField.js | 766 +++ src/vfx/Shell.js | 1061 ++++ src/vfx/Swarm.js | 963 ++++ src/vfx/TimeControl.js | 1460 +++++ src/vfx/Tube.js | 1649 ++++++ src/vfx/VolumeHull.js | 1753 ++++++ src/vfx/hookFields.js | 228 + src/vfx/prefixedBlock.js | 161 + src/vfx/quads.js | 117 + 38 files changed, 42044 insertions(+), 61 deletions(-) create mode 100644 src/shaders/lib/timewarp.glsl.js create mode 100644 src/vfx/ArcNetwork.js create mode 100644 src/vfx/BrushStroke.js create mode 100644 src/vfx/Caustics.js create mode 100644 src/vfx/Colony.js create mode 100644 src/vfx/Curtain.js create mode 100644 src/vfx/Dissolve.js create mode 100644 src/vfx/Distortion.js create mode 100644 src/vfx/FilamentPaths.js create mode 100644 src/vfx/FoldMesh.js create mode 100644 src/vfx/GroundField.js create mode 100644 src/vfx/GrowthField.js create mode 100644 src/vfx/HardSurface.js create mode 100644 src/vfx/InkDiffusion.js create mode 100644 src/vfx/LensFlare.js create mode 100644 src/vfx/LightShaft.js create mode 100644 src/vfx/LiquidSurface.js create mode 100644 src/vfx/Mirror.js create mode 100644 src/vfx/Portal.js create mode 100644 src/vfx/Projectile.js create mode 100644 src/vfx/README.md create mode 100644 src/vfx/SceneHooks.js create mode 100644 src/vfx/ShatterField.js create mode 100644 src/vfx/Shell.js create mode 100644 src/vfx/Swarm.js create mode 100644 src/vfx/TimeControl.js create mode 100644 src/vfx/Tube.js create mode 100644 src/vfx/VolumeHull.js create mode 100644 src/vfx/hookFields.js create mode 100644 src/vfx/prefixedBlock.js create mode 100644 src/vfx/quads.js 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/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/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/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; +} From 6da5eba5fe2233afec5b0ceb5f00f40ff65a36fe Mon Sep 17 00:00:00 2001 From: Majid Manzarpour Date: Wed, 12 Aug 2026 00:21:30 -0400 Subject: [PATCH 3/9] feat(abilities): 94 new abilities across fifteen schools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frost, Flame, Storm, Stone, Verdant, Void, Arcane, Blood, Aether, plus six new schools chosen because each forces a rendering technique the sandbox did not have: Tide (caustics), Forge (hard surface — involute gear teeth, brushed anisotropic specular, a real blackbody cooling locus), Lumen (light that behaves like light), Ink (the anti-glow school — matte, no bloom anywhere), Chrono (time as a driver), Hive (emergence). Every one has a named trick and none is a recolour of another slot. A few that show the range: - Dawnbreak borrows the scene's directional light and swings it, so every object in the world throws a real sweeping shadow. The ability draws almost nothing; the world doing the work is the effect. - Stasis Field draws nothing at all. It clamps other systems' clocks inside a radius, so a Storm Lance already in flight stops guttering. - Thunderclap's entire effect is the silence between the flash and the pressure front. - Silence renders a hole in the frame: not black, absent. - Spellbreak is the one ability aware of the others. - Mirage is the caster's own skinned rig, cloned and posed from a recorded track, drawn with nothing but refraction — you see it because the world bends into a human shape, and you lose it the moment it stops moving. All 100 pass the headless gate, including the paused-slider test. Co-Authored-By: Claude Opus 5 (1M context) --- src/abilities/IceAbility.js | 2 +- src/abilities/aether/AuroraAbility.js | 429 ++++++ src/abilities/aether/CycloneAbility.js | 785 ++++++++++ src/abilities/aether/FeatherfallAbility.js | 342 +++++ src/abilities/aether/MirageAbility.js | 337 +++++ src/abilities/aether/ResonanceAbility.js | 729 ++++++++++ src/abilities/aether/SkyfractureAbility.js | 747 ++++++++++ src/abilities/aether/SlipstreamAbility.js | 453 ++++++ src/abilities/arcane/ArcaneVolleyAbility.js | 668 +++++++++ src/abilities/arcane/AstralgateAbility.js | 901 ++++++++++++ src/abilities/arcane/ChronofractureAbility.js | 1267 +++++++++++++++++ src/abilities/arcane/GlyphstormAbility.js | 618 ++++++++ src/abilities/arcane/PrismLanceAbility.js | 894 ++++++++++++ src/abilities/arcane/RunesealAbility.js | 892 ++++++++++++ src/abilities/arcane/SpellbreakAbility.js | 1026 +++++++++++++ src/abilities/arcane/StarfallAbility.js | 933 ++++++++++++ src/abilities/blood/BonecageAbility.js | 681 +++++++++ src/abilities/blood/CrimsonTideAbility.js | 889 ++++++++++++ src/abilities/blood/HemolanceAbility.js | 1239 ++++++++++++++++ src/abilities/blood/PlaguebloomAbility.js | 787 ++++++++++ src/abilities/blood/SanguinePactAbility.js | 770 ++++++++++ src/abilities/chrono/AfterimageAbility.js | 510 +++++++ src/abilities/chrono/EchostepAbility.js | 632 ++++++++ src/abilities/chrono/EntropyAbility.js | 753 ++++++++++ src/abilities/chrono/HourglassAbility.js | 1014 +++++++++++++ src/abilities/chrono/RewindAbility.js | 832 +++++++++++ src/abilities/chrono/StasisfieldAbility.js | 827 +++++++++++ src/abilities/flame/DragonbreathAbility.js | 854 +++++++++++ src/abilities/flame/EmberflockAbility.js | 760 ++++++++++ src/abilities/flame/FirewalkAbility.js | 804 +++++++++++ src/abilities/flame/FirewhipAbility.js | 802 +++++++++++ src/abilities/flame/MagmaAbility.js | 856 +++++++++++ src/abilities/flame/PyroclasmAbility.js | 921 ++++++++++++ src/abilities/flame/SunspearAbility.js | 808 +++++++++++ src/abilities/flame/WildfireAbility.js | 1023 +++++++++++++ src/abilities/forge/AnvilfallAbility.js | 864 +++++++++++ src/abilities/forge/GearlockAbility.js | 1076 ++++++++++++++ src/abilities/forge/PistondriveAbility.js | 1102 ++++++++++++++ src/abilities/forge/QuenchAbility.js | 1066 ++++++++++++++ src/abilities/forge/SawlineAbility.js | 1202 ++++++++++++++++ src/abilities/forge/ShrapnelAbility.js | 1102 ++++++++++++++ src/abilities/frost/AvalancheAbility.js | 822 +++++++++++ src/abilities/frost/BlackIceAbility.js | 751 ++++++++++ src/abilities/frost/HailAbility.js | 842 +++++++++++ src/abilities/frost/RimeAbility.js | 969 +++++++++++++ src/abilities/frost/ShatterlanceAbility.js | 989 +++++++++++++ src/abilities/hive/BroodburstAbility.js | 1123 +++++++++++++++ src/abilities/hive/CarapaceAbility.js | 798 +++++++++++ src/abilities/hive/HivecolumnAbility.js | 948 ++++++++++++ src/abilities/hive/LocusttideAbility.js | 986 +++++++++++++ src/abilities/hive/WaspfunnelAbility.js | 966 +++++++++++++ src/abilities/hive/WeblineAbility.js | 889 ++++++++++++ src/abilities/ink/InkbloomAbility.js | 662 +++++++++ src/abilities/ink/OrigamiAbility.js | 720 ++++++++++ src/abilities/ink/ScrollwardAbility.js | 614 ++++++++ src/abilities/ink/SealscriptAbility.js | 868 +++++++++++ src/abilities/ink/SplatterbrandAbility.js | 825 +++++++++++ src/abilities/ink/SumistrokeAbility.js | 840 +++++++++++ src/abilities/lumen/DawnbreakAbility.js | 444 ++++++ src/abilities/lumen/EclipseAbility.js | 554 +++++++ src/abilities/lumen/GodspearAbility.js | 858 +++++++++++ src/abilities/lumen/PhotonLatticeAbility.js | 413 ++++++ src/abilities/lumen/RefractCascadeAbility.js | 823 +++++++++++ src/abilities/lumen/SolarLensAbility.js | 877 ++++++++++++ src/abilities/stone/BoulderAbility.js | 870 +++++++++++ src/abilities/stone/ObsidianAbility.js | 1145 +++++++++++++++ src/abilities/stone/PetrifyAbility.js | 1110 +++++++++++++++ src/abilities/stone/SinkholeAbility.js | 984 +++++++++++++ src/abilities/stone/StonespineAbility.js | 1200 ++++++++++++++++ src/abilities/stone/TectonicAbility.js | 1122 +++++++++++++++ src/abilities/storm/BallLightningAbility.js | 819 +++++++++++ src/abilities/storm/ChainArcAbility.js | 904 ++++++++++++ src/abilities/storm/RailcoilAbility.js | 898 ++++++++++++ src/abilities/storm/SheetLightningAbility.js | 763 ++++++++++ src/abilities/storm/StormwallAbility.js | 809 +++++++++++ src/abilities/storm/ThunderclapAbility.js | 655 +++++++++ src/abilities/tide/BrinelockAbility.js | 1207 ++++++++++++++++ src/abilities/tide/BubblecageAbility.js | 778 ++++++++++ src/abilities/tide/GeyserAbility.js | 1060 ++++++++++++++ src/abilities/tide/TiderushAbility.js | 866 +++++++++++ src/abilities/tide/TorrentAbility.js | 800 +++++++++++ src/abilities/tide/UndertowAbility.js | 1030 ++++++++++++++ src/abilities/verdant/BloomburstAbility.js | 951 +++++++++++++ src/abilities/verdant/GrovecallAbility.js | 1141 +++++++++++++++ src/abilities/verdant/MyceliumAbility.js | 618 ++++++++ src/abilities/verdant/SporefallAbility.js | 619 ++++++++ src/abilities/verdant/ThornwakeAbility.js | 1168 +++++++++++++++ src/abilities/verdant/VinelashAbility.js | 895 ++++++++++++ src/abilities/void/NightfallAbility.js | 531 +++++++ src/abilities/void/SilenceAbility.js | 558 ++++++++ src/abilities/void/SingularityAbility.js | 908 ++++++++++++ src/abilities/void/SoulchainAbility.js | 1061 ++++++++++++++ src/abilities/void/UmbralSpearsAbility.js | 893 ++++++++++++ src/abilities/void/UnmakeAbility.js | 1055 ++++++++++++++ src/abilities/void/VoidriftAbility.js | 613 ++++++++ src/materials/AfterimageMaterial.js | 548 +++++++ src/materials/AstralStoneMaterial.js | 354 +++++ src/materials/BeadOrbitMaterial.js | 424 ++++++ src/materials/BoneRibMaterial.js | 447 ++++++ src/materials/BrineIceMaterial.js | 234 +++ src/materials/BroodEggMaterial.js | 377 +++++ src/materials/CascadeBeamMaterial.js | 488 +++++++ src/materials/DaystarMaterial.js | 198 +++ src/materials/DeflectionSheetMaterial.js | 377 +++++ src/materials/EmberTrailMaterial.js | 362 +++++ src/materials/FeatherMaterial.js | 603 ++++++++ src/materials/FirewalkMaterial.js | 658 +++++++++ src/materials/GeyserDropMaterial.js | 205 +++ src/materials/GhostIronMaterial.js | 222 +++ src/materials/GlacialLanceMaterial.js | 450 ++++++ src/materials/HailMaterial.js | 217 +++ src/materials/IceMaterial.js | 33 +- src/materials/MagmaBlobMaterial.js | 196 +++ src/materials/MirageSkinMaterial.js | 212 +++ src/materials/MyceliumSeepMaterial.js | 559 ++++++++ src/materials/PetalMaterial.js | 459 ++++++ src/materials/PhotonBeamMaterial.js | 509 +++++++ src/materials/PrismMaterial.js | 613 ++++++++ src/materials/SheetIceMaterial.js | 364 +++++ src/materials/SingularityMaterial.js | 564 ++++++++ src/materials/SnareMaterial.js | 4 +- src/materials/SnowSlabMaterial.js | 222 +++ src/materials/SunspearMaterial.js | 214 +++ src/materials/TectonicMaterial.js | 364 +++++ src/materials/ThinFilmMaterial.js | 642 +++++++++ src/materials/UmbraMaterial.js | 184 +++ src/materials/VineBarkMaterial.js | 260 ++++ src/materials/VolleyMaterial.js | 583 ++++++++ src/materials/WildfireMaterial.js | 539 +++++++ 129 files changed, 92490 insertions(+), 4 deletions(-) create mode 100644 src/abilities/aether/AuroraAbility.js create mode 100644 src/abilities/aether/CycloneAbility.js create mode 100644 src/abilities/aether/FeatherfallAbility.js create mode 100644 src/abilities/aether/MirageAbility.js create mode 100644 src/abilities/aether/ResonanceAbility.js create mode 100644 src/abilities/aether/SkyfractureAbility.js create mode 100644 src/abilities/aether/SlipstreamAbility.js create mode 100644 src/abilities/arcane/ArcaneVolleyAbility.js create mode 100644 src/abilities/arcane/AstralgateAbility.js create mode 100644 src/abilities/arcane/ChronofractureAbility.js create mode 100644 src/abilities/arcane/GlyphstormAbility.js create mode 100644 src/abilities/arcane/PrismLanceAbility.js create mode 100644 src/abilities/arcane/RunesealAbility.js create mode 100644 src/abilities/arcane/SpellbreakAbility.js create mode 100644 src/abilities/arcane/StarfallAbility.js create mode 100644 src/abilities/blood/BonecageAbility.js create mode 100644 src/abilities/blood/CrimsonTideAbility.js create mode 100644 src/abilities/blood/HemolanceAbility.js create mode 100644 src/abilities/blood/PlaguebloomAbility.js create mode 100644 src/abilities/blood/SanguinePactAbility.js create mode 100644 src/abilities/chrono/AfterimageAbility.js create mode 100644 src/abilities/chrono/EchostepAbility.js create mode 100644 src/abilities/chrono/EntropyAbility.js create mode 100644 src/abilities/chrono/HourglassAbility.js create mode 100644 src/abilities/chrono/RewindAbility.js create mode 100644 src/abilities/chrono/StasisfieldAbility.js create mode 100644 src/abilities/flame/DragonbreathAbility.js create mode 100644 src/abilities/flame/EmberflockAbility.js create mode 100644 src/abilities/flame/FirewalkAbility.js create mode 100644 src/abilities/flame/FirewhipAbility.js create mode 100644 src/abilities/flame/MagmaAbility.js create mode 100644 src/abilities/flame/PyroclasmAbility.js create mode 100644 src/abilities/flame/SunspearAbility.js create mode 100644 src/abilities/flame/WildfireAbility.js create mode 100644 src/abilities/forge/AnvilfallAbility.js create mode 100644 src/abilities/forge/GearlockAbility.js create mode 100644 src/abilities/forge/PistondriveAbility.js create mode 100644 src/abilities/forge/QuenchAbility.js create mode 100644 src/abilities/forge/SawlineAbility.js create mode 100644 src/abilities/forge/ShrapnelAbility.js create mode 100644 src/abilities/frost/AvalancheAbility.js create mode 100644 src/abilities/frost/BlackIceAbility.js create mode 100644 src/abilities/frost/HailAbility.js create mode 100644 src/abilities/frost/RimeAbility.js create mode 100644 src/abilities/frost/ShatterlanceAbility.js create mode 100644 src/abilities/hive/BroodburstAbility.js create mode 100644 src/abilities/hive/CarapaceAbility.js create mode 100644 src/abilities/hive/HivecolumnAbility.js create mode 100644 src/abilities/hive/LocusttideAbility.js create mode 100644 src/abilities/hive/WaspfunnelAbility.js create mode 100644 src/abilities/hive/WeblineAbility.js create mode 100644 src/abilities/ink/InkbloomAbility.js create mode 100644 src/abilities/ink/OrigamiAbility.js create mode 100644 src/abilities/ink/ScrollwardAbility.js create mode 100644 src/abilities/ink/SealscriptAbility.js create mode 100644 src/abilities/ink/SplatterbrandAbility.js create mode 100644 src/abilities/ink/SumistrokeAbility.js create mode 100644 src/abilities/lumen/DawnbreakAbility.js create mode 100644 src/abilities/lumen/EclipseAbility.js create mode 100644 src/abilities/lumen/GodspearAbility.js create mode 100644 src/abilities/lumen/PhotonLatticeAbility.js create mode 100644 src/abilities/lumen/RefractCascadeAbility.js create mode 100644 src/abilities/lumen/SolarLensAbility.js create mode 100644 src/abilities/stone/BoulderAbility.js create mode 100644 src/abilities/stone/ObsidianAbility.js create mode 100644 src/abilities/stone/PetrifyAbility.js create mode 100644 src/abilities/stone/SinkholeAbility.js create mode 100644 src/abilities/stone/StonespineAbility.js create mode 100644 src/abilities/stone/TectonicAbility.js create mode 100644 src/abilities/storm/BallLightningAbility.js create mode 100644 src/abilities/storm/ChainArcAbility.js create mode 100644 src/abilities/storm/RailcoilAbility.js create mode 100644 src/abilities/storm/SheetLightningAbility.js create mode 100644 src/abilities/storm/StormwallAbility.js create mode 100644 src/abilities/storm/ThunderclapAbility.js create mode 100644 src/abilities/tide/BrinelockAbility.js create mode 100644 src/abilities/tide/BubblecageAbility.js create mode 100644 src/abilities/tide/GeyserAbility.js create mode 100644 src/abilities/tide/TiderushAbility.js create mode 100644 src/abilities/tide/TorrentAbility.js create mode 100644 src/abilities/tide/UndertowAbility.js create mode 100644 src/abilities/verdant/BloomburstAbility.js create mode 100644 src/abilities/verdant/GrovecallAbility.js create mode 100644 src/abilities/verdant/MyceliumAbility.js create mode 100644 src/abilities/verdant/SporefallAbility.js create mode 100644 src/abilities/verdant/ThornwakeAbility.js create mode 100644 src/abilities/verdant/VinelashAbility.js create mode 100644 src/abilities/void/NightfallAbility.js create mode 100644 src/abilities/void/SilenceAbility.js create mode 100644 src/abilities/void/SingularityAbility.js create mode 100644 src/abilities/void/SoulchainAbility.js create mode 100644 src/abilities/void/UmbralSpearsAbility.js create mode 100644 src/abilities/void/UnmakeAbility.js create mode 100644 src/abilities/void/VoidriftAbility.js create mode 100644 src/materials/AfterimageMaterial.js create mode 100644 src/materials/AstralStoneMaterial.js create mode 100644 src/materials/BeadOrbitMaterial.js create mode 100644 src/materials/BoneRibMaterial.js create mode 100644 src/materials/BrineIceMaterial.js create mode 100644 src/materials/BroodEggMaterial.js create mode 100644 src/materials/CascadeBeamMaterial.js create mode 100644 src/materials/DaystarMaterial.js create mode 100644 src/materials/DeflectionSheetMaterial.js create mode 100644 src/materials/EmberTrailMaterial.js create mode 100644 src/materials/FeatherMaterial.js create mode 100644 src/materials/FirewalkMaterial.js create mode 100644 src/materials/GeyserDropMaterial.js create mode 100644 src/materials/GhostIronMaterial.js create mode 100644 src/materials/GlacialLanceMaterial.js create mode 100644 src/materials/HailMaterial.js create mode 100644 src/materials/MagmaBlobMaterial.js create mode 100644 src/materials/MirageSkinMaterial.js create mode 100644 src/materials/MyceliumSeepMaterial.js create mode 100644 src/materials/PetalMaterial.js create mode 100644 src/materials/PhotonBeamMaterial.js create mode 100644 src/materials/PrismMaterial.js create mode 100644 src/materials/SheetIceMaterial.js create mode 100644 src/materials/SingularityMaterial.js create mode 100644 src/materials/SnowSlabMaterial.js create mode 100644 src/materials/SunspearMaterial.js create mode 100644 src/materials/TectonicMaterial.js create mode 100644 src/materials/ThinFilmMaterial.js create mode 100644 src/materials/UmbraMaterial.js create mode 100644 src/materials/VineBarkMaterial.js create mode 100644 src/materials/VolleyMaterial.js create mode 100644 src/materials/WildfireMaterial.js 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/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/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; +} From 7cc700dd8fcf215fc375d89f0783056835c5eee9 Mon Sep 17 00:00:00 2001 From: Majid Manzarpour Date: Wed, 12 Aug 2026 00:21:54 -0400 Subject: [PATCH 4/9] fix: three rendering faults that turned the whole frame black MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three presented identically — some abilities black out the screen, but only sometimes — and had nothing in common. 1. Program-cache collisions. patchOnBeforeCompile wraps every injection in an identically-worded closure, and three keys its program cache on onBeforeCompile.toString() by default. The injected code lives in the closed-over function, which never appears in that string, so ~60 patched materials shared one cache key and every one after the first silently rendered with another material's compiled program — its uniforms absent, the ones present holding someone else's values. Which ability broke depended on cast order. Fixed by deriving customProgramCacheKey from the injection's own source, composed down the chain; materials that genuinely share an injection still share a program. 2. pow() of a negative base. GroundField's SCOUR phase includes uTime, which grows without bound, so cos() was being asked for the cosine of ~10^4 radians; float32 loses its mantissa to argument reduction and returns fractionally outside [-1,1], and pow() of the resulting tiny negative is NaN at every exponent — which is why zeroing the sharpness slider never masked it. Folded the phase with fract() (exact: the cosine has period 1 there) and clamped the base. 3. The same class in six more places, pow(sin(x), e), where float32 sin() returns about -1e-7 at its zeroes. Two of them were in SnareMaterial and predate this expansion. One NaN fragment is not one bad pixel: the next bloom blur spreads it over its whole kernel and tone-mapping NaN gives black, so eighty-odd bad fragments on one spear turned the entire frame black. check.mjs now fails on pow() over a raw trig base, negative-controlled by reintroducing the bug. Also: post.distortion shipped at 0.045, a trim from when nothing wrote to the refraction buffer. Six abilities author their strength "in screen widths at post.distortion = 1", so every refraction effect was running at 4.5% of what it asked for. Set to 1.0, and the editor slider widened past its own default. Co-Authored-By: Claude Opus 5 (1M context) --- src/config/globals.js | 69 ++++++++++++++++++++++++++++++++++++++++ src/utils/shaderPatch.js | 54 +++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/config/globals.js 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/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; } From d8a1be79a06192e6608efe31b5029d8e3a0bcc3c Mon Sep 17 00:00:00 2001 From: Majid Manzarpour Date: Wed, 12 Aug 2026 00:22:59 -0400 Subject: [PATCH 5/9] docs: rewrite the README for a hundred abilities, and add the design specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README described six abilities, 938 sliders, and a five-step "adding an ability" list whose every step named a file that no longer holds a roster. All four are now true again, including the slider count (~16,000) and the fact that registering an ability is one descriptor rather than four edits. docs/ROSTER.md and docs/ROSTER-II.md are the briefs the ninety-four were built from — each entry names the single technique that makes it read, because an ability that is another slot with a different palette should not exist. docs/EXPANSION.md is the engineering contract: the invariants, the registry, the schema format, and what "done" means. docs/VFX_API.md and VFX_API_II.md are the tech library's signatures, verified against source. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 92 +++++++--- docs/EXPANSION.md | 279 ++++++++++++++++++++++++++++ docs/ROSTER-II.md | 354 ++++++++++++++++++++++++++++++++++++ docs/ROSTER.md | 416 ++++++++++++++++++++++++++++++++++++++++++ docs/VFX_API.md | 445 +++++++++++++++++++++++++++++++++++++++++++++ docs/VFX_API_II.md | 172 ++++++++++++++++++ 6 files changed, 1732 insertions(+), 26 deletions(-) create mode 100644 docs/EXPANSION.md create mode 100644 docs/ROSTER-II.md create mode 100644 docs/ROSTER.md create mode 100644 docs/VFX_API.md create mode 100644 docs/VFX_API_II.md diff --git a/README.md b/README.md index a946052..206cc5e 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 `Editor.js` + edit any more, and no ability list in `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, @@ -571,7 +606,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 diff --git a/docs/EXPANSION.md b/docs/EXPANSION.md new file mode 100644 index 0000000..6291e18 --- /dev/null +++ b/docs/EXPANSION.md @@ -0,0 +1,279 @@ +# Expansion — engineering contracts + +How the sandbox grows from six abilities to fifty without any file becoming unmaintainable. + +Read this before touching anything. Then read `docs/ROSTER.md` for what the forty-four new +abilities actually are. + +--- + +## 0 · The invariants (non-negotiable) + +These are the rules the original six were built under. Every new ability obeys them. + +**I1 — No dimensions on the CPU.** A cast may capture *unitless dice rolls* (`0..1` fractions, +signs, seeds) and *timestamps* (the moment an event fired). It may not capture a metre, a radian, +a second, or a colour. Everything with a unit is resolved against `settings[id]` inside the update +loop, every frame, including a zero-length frame. The test: pause with **P** mid-cast, drag a +slider, and the standing effect must change. + +**I2 — Nothing is a texture.** No sprite sheets, no gradient ramps, no decal images. SDFs, noise, +parametric paths and procedural geometry only. The two exceptions already in the repo (the floor +material and the character's diffuse map) stay exceptions. + +**I3 — Nothing allocates during a cast.** `spawn()` must fully reset state; `destroy()` must leave +the instance reusable. Per-frame code uses module-scope scratch objects (`const _pos = new +Vector3()` at the top of the file, reused). No `new`, no array literals, no object literals, no +closures created inside `onTravel` / `onFade` / `update`. + +**I4 — Draw calls are counted.** An ability draws in single digits. Instancing, not iteration: if +you need forty of something, that is one `InstancedMesh` with per-instance attributes, or one +instanced strip whose vertex shader places every copy. + +**I5 — Every value is authored.** Every dimension is a slider, every colour is a picker, and no +colour is derived from another. A particle system gets a four-stop lifetime gradient +(`colorXA/B/C/D`). The only values that may be shared are ones where the sharing *is* the design +(`zoneRadius` driving five consumers at once). + +**I6 — Guard the light pool.** `ctx.lights.acquire()` returns `null` when the six dynamic lights +are spoken for. Every use of the handle is guarded. An ability that wants two lights must work +with one. + +**I7 — Budget.** A single cast targets ≤ 12 draw calls, ≤ 1500 live particles, ≤ 1 dynamic light +(2 only if the ability genuinely cannot read without it). The manager caps at 4 concurrent casts. + +--- + +## 1 · Registry — abilities are data + +`src/abilities/registry.js` is the single place an ability is declared. Nothing else in the +project enumerates abilities. + +```js +export const ABILITIES = [ + { + id: 'rime', // settings key, particle-system prefix, glyph key + label: 'Rimewalker', // shown in the HUD and the editor folder + school: 'frost', // groups the spellbook and the editor + accent: '#7ecbe0', // slot colour + cast: CastShape.LINE, // or CastShape.ZONE + blurb: 'Sheet ice glazes the floor and peels up behind the front.', + load: () => import('./frost/RimeAbility.js').then((m) => m.RimeAbility), + settings: () => import('../config/abilities/rime.js').then((m) => m.rime) + }, + ... +]; +``` + +- **`load` is lazy.** Fifty ability classes are not constructed at boot. `AbilityManager` builds a + pool for an id the first time that id is selected or cast, and warms its shaders then. +- **`settings` is not lazy.** Every settings block is registered at module load, because the + editor, the preset system and the aim controller all need the full tree up front. Settings + modules are pure data with no imports beyond constants — they must stay cheap. +- `ELEMENTS` and `ELEMENT_META` remain exported from `config/settings.js` as **derived views** over + this registry so existing code keeps working. + +**Schools** are declared once: + +```js +export const SCHOOLS = [ + { 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' } +]; +``` + +The six that shipped keep their ids (`ice`, `thunder`, `meteor`, `beam`, `snare`, `glacier`) and +join schools `frost`, `storm`, `flame`, `arcane`, `storm`, `frost` respectively. + +--- + +## 2 · Settings — one module per ability + +`config/settings.js` stays the source of truth for `global`, `aim`, `zone`, `environment`, `post`, +`camera`, `character` — and stops holding ability blocks. Each ability owns +`src/config/abilities/.js`: + +```js +/** Rimewalker — sheet ice. */ +export const rime = { + /* --- the cast --- */ + range: 18, minRange: 2, speed: 26, cooldown: 0.9, castAnim: 'cast3', + ... +}; + +/** Editor layout: which folders exist and what goes in them. */ +export const rimeSchema = { + 'The cast': ['range', 'minRange', 'speed', 'cooldown', 'castAnim'], + 'The plates': [['plateSize', 0.1, 3, 0.01, 'plate size'], ...], + ... +}; +``` + +`settings.js` imports every module and composes the tree, so `settings.rime` works exactly as +`settings.ice` does today and `applySettings` / `snapshotSettings` / presets need no change. + +**Schema entries** are either a bare key (the editor infers a sensible range from the default and +the key name) or a tuple `[key, min, max, step, label]`. Colours are detected by value (`#rrggbb`) +and get a colour picker. A key ending `A`/`B`/`C`/`D` on a common prefix is offered as a gradient +group. Keys not mentioned in the schema still appear, in a trailing "More" folder — so a schema +is never wrong, only incomplete. + +--- + +## 3 · Editor — schema-driven + +`ui/Editor.js` keeps its hand-written folders for `Global`, `Aim`, `Zone`, `Environment`, `Post`, +`Camera`, `Character` and drops the six per-ability `_buildX()` methods. In their place: + +- one top-level folder per **school**, containing one folder per ability, built from the schema; +- a **search box** that filters controllers by label across the whole tree; +- a **"jump to selected ability"** button, because scrolling past fifty folders is not a UI. + +`Editor.range()`, `Editor.gradient()` and `Editor.castAnimation()` stay as the building blocks the +generic builder calls. + +--- + +## 4 · HUD — the spellbook + +Fifty abilities do not fit on a bar and do not fit on a keyboard. + +- **Loadout bar** — eight slots bound to `Q W E R F V X C`… (the existing six keep their letters: + `Q` ice, `E` thunder, `R` meteor, `F` beam, `V` snare, `X` glacier). Digits `1..8` mirror them. +- **Spellbook** — `Tab` (or `B`) opens a full-screen grid: nine school columns, every ability as a + card with its sigil, name, cast shape and blurb. Click a card to **cast-select** it immediately; + drag it (or shift-click) onto a loadout slot to bind it. Type to filter. +- **Loadout persistence** — the binding is saved to `localStorage` alongside presets. +- The spellbook is DOM, like the rest of the HUD. No framework. + +`ui/glyphs.js` grows to fifty sigils, split into `ui/glyphs/.js` modules re-exported from +one index. Same contract: a 100×100 stroke-only SVG inheriting `currentColor`. + +--- + +## 5 · The tech library + +Fourteen shared modules under `src/vfx/`. They exist so an ability is *configuration plus beats* +rather than a new renderer. Each ships with a doc comment stating: what it draws, its draw-call +cost, what it reads from settings, and the one rule for using it well. + +Every one of them obeys **I1** — they take a live settings object and re-resolve from it each +frame; they never snapshot. + +| module | what it is | +| --- | --- | +| `vfx/GrowthField.js` | Instanced procedural geometry erupting along a line or across a zone, with per-instance birth flash, stagger, height curve, lean, clumping. Generalises `IceAbility`'s crystal field. Takes a geometry *factory* + a material, hashes its shape params and rebuilds when they change. | +| `vfx/FilamentPaths.js` | The instanced ribbon strip with pluggable parametric paths, generalising `LightningMaterial` + `SnareMaterial`. Built-in path modes: `LINE`, `HELIX`, `ORBIT`, `MEANDER`, `RIM`, `CHAIN`, `LINK`, `SPIRAL_IN`, `CRACK`. Roles are selected in the vertex shader by instance index against live counts. | +| `vfx/VolumeHull.js` | Raymarched volume inside a proxy hull, generalising `VolumetricFireMaterial`. Hulls: `BOX`, `CYLINDER`, `CONE`, `DOME`, `SPHERE`. Media presets: `FLAME`, `SMOKE`, `ASH`, `SPORE`, `SAND`, `MIST`, `GAS_BOIL`, `VOID`. Step count, jitter and density curve are sliders. | +| `vfx/GroundField.js` | A ground quad whose fragment shader works in **metres from the anchor**, with a mode enum. Generalises the snare's field and the aim/zone indicators. Modes: `PLATE`, `RUNE`, `POCK`, `RUT`, `WET`, `PUSTULE`, `FUNNEL`, `SCOUR`, `LATTICE`, `POOL`. Re-scales live; never captures its radius. | +| `vfx/Tube.js` | The parametric tube, generalising `BeamMaterial`. Path modes `STRAIGHT`, `WHIP`, `FUNNEL`, `VINE`, `ARC`. Three-layer core/sheath/halo weighting is kept — it is the reason the beam reads as solid. | +| `vfx/Shell.js` | Expanding shells, domes, cones and ring trains. Extends `BurstSphere` with new modes rather than replacing it. | +| `vfx/Projectile.js` | One or many travelling bodies with position/orientation from a parametric flight (`LINE`, `ARC`, `ROLL`, `FALL`, `HOMING`, `LISSAJOUS`), each optionally carrying a `RibbonTrail`. Staggered arrival times come from a spatial hash so a zone fills deterministically per seed. | +| `vfx/Distortion.js` | **Writes to the distortion buffer, which nothing currently uses.** Emitters: `HEAT`, `LENS` (radial 1/r² gravity lens), `SHOCK` (travelling ring), `BLADE` (thin plane), `REFRACT`. This closes a documented rough edge and is the single biggest new capability in the expansion — several abilities are mostly made of it. | +| `vfx/ShatterField.js` | Instanced fragments that inherit a velocity and tumble, with an optional screen-space sample so a shard can show the scene behind it. Used for breaking ice, glass, prisms and time. | +| `vfx/Swarm.js` | Instanced agents on a shader-evaluated flock: cohere to a moving lead, separate on a hash lattice, bank into turns. Silhouettes: `BIRD`, `LEAF`, `CARD`, `DROPLET`, `MOTE`. | +| `vfx/ArcNetwork.js` | Node graph + segment lighting for chained discharges. Nodes are unitless scatter fractions; every metre resolves per frame. | +| `vfx/Portal.js` | A disc/slit with a parallax interior, a fracture rim and a depth-correct edge. | +| `vfx/LiquidSurface.js` | A live heightfield plane with flow-mapped crust, used for lava, blood and water. Ripples are injected by impacts and decay analytically. | +| `vfx/Curtain.js` | Vertical sheets with a travelling vertex ripple and a height-dependent emission curve. Rain, aurora, light shafts. | + +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. The library is a floor, not a ceiling. + +--- + +## 6 · The ability template + +```js +import { Vector3 } from 'three'; +import { Ability } from '../Ability.js'; +import { settings } from '../../config/settings.js'; +import { getColor } from '../../utils/color.js'; +import { frame } from '../../core/FrameUniforms.js'; + +const _pos = new Vector3(); // module-scope scratch — I3 +const _emit = {}; + +export class RimeAbility extends Ability { + constructor(context) { super('rime', context); } + + createShaders() { /* build meshes + materials once */ } + createParticles() { /* ctx.particles.get('rime.mist', {...}) */ } + + get impactDuration() { return settings.rime.lifetime * settings.global.lifetime; } + get fadeDuration() { return settings.rime.fadeTime; } + get instanceCount() { return this._live; } + + onSpawn() { /* reset emitters, roll the seed, sync once */ } + onTravel(dt) { /* resolve everything from settings, emit, sync */ } + onImpact() { /* the one-shot */ } + onFade(dt, t) { /* t: 0..1 hold, 1..2 blow-out */ } + onDestroy() { /* release; leave reusable */ } + dispose() { /* geometry + material dispose; super.dispose() */ } +} +``` + +Particle system names are namespaced `"."` so systems are shared per ability across +pooled instances and never collide. + +--- + +## 7 · Verification — the headless harness + +`npm run check` runs `scripts/check.mjs` in Node. It does not need WebGL: three.js constructs +geometry, materials and scene graphs on the CPU, and every VFX system in this project defers GL to +render time. + +The harness: + +1. imports the registry and **every** settings module and ability class; +2. asserts each block has `range`, `minRange`, `speed`, `cooldown`, `castAnim`, plus `zoneRadius` + for `CastShape.ZONE`; +3. **statically cross-checks every `settings..` / `c.` reference in an ability file + against its settings block** — the single most common failure mode when writing these, and the + one that produces silent `NaN` geometry rather than an error; +4. constructs each ability against a mock context (real `ParticleEngine`, `DecalSystem`, + `BurstSystem`, `FissureSystem`, `LightPool` on a bare `Scene`; stub `shake` / `flash`); +5. spawns it, ticks 240 frames at 1/60 across all phases, and fails on any exception, any `NaN` in + a written attribute, any position beyond a sanity radius, and any allocation-shaped mistake it + can see; +6. re-runs the tick loop **with `dt = 0`** after mutating a random slider, and fails if nothing in + the ability's uniforms changed — a direct test of invariant **I1**; +7. checks the glyph map and the editor schema cover every registered id. + +`npm run check` must pass before any ability is considered done. `npm run build` must also pass. + +--- + +## 8 · Performance at fifty + +- Ability classes and their pools are **lazy** — nothing is constructed until an id is selected. +- `renderer.compileAsync()` at boot only sees what is in the scene; selecting an ability for the + first time warms it asynchronously behind the HUD's cooldown sweep. +- Particle systems are created on first use by `ParticleEngine.get()` and shared. Fifty abilities + × four systems each would be two hundred instanced meshes if they were all built at boot; they + are not, and abilities should reuse a shared system (`shared.smoke`, `shared.dust`, + `shared.sparks`) wherever the look does not need its own gradient. +- The 4-cast concurrency cap and the 6-light pool are unchanged. + +--- + +## 9 · Definition of done, per ability + +1. `src/abilities//Ability.js` — the class, with a doc comment that explains **the + trick** the way the existing six do. +2. `src/config/abilities/.js` — settings block + editor schema. +3. A sigil in `src/ui/glyphs/.js`. +4. A registry entry. +5. `npm run check` passes, including the paused-slider test. +6. The README's roster table has a row for it. + +An ability that renders but violates **I1** is not done. An ability that looks like another slot +with different colours is not done either — see the "THE TRICK" line in its roster entry. diff --git a/docs/ROSTER-II.md b/docs/ROSTER-II.md new file mode 100644 index 0000000..77c17d1 --- /dev/null +++ b/docs/ROSTER-II.md @@ -0,0 +1,354 @@ +# The Roster, part II — 50 more abilities, six new schools + +The sandbox has fifty. This document specifies fifty more, taking it to **one hundred abilities +across fifteen schools**. + +The rules from `docs/ROSTER.md` still hold, unchanged — no dimensions on the CPU, nothing is a +texture, one trick per ability, every value a slider. What changes is the *territory*. The first +ninety-odd tricks were built out of geometry, noise and parametric paths. These six new schools +were chosen because each one forces a rendering technique the sandbox has never had to do. + +| school | the territory it forces open | +| --- | --- | +| **Tide** | **Caustics.** A scene with a floor and no caustics is leaving the best free effect in graphics on the table. Water here lights the ground *through* itself. | +| **Forge** | **Hard surface.** Everything in the sandbox so far is crystal, rock or organic. Nothing is *machined* — no involute gear teeth, no brushed anisotropic specular, no blackbody cooling curve. | +| **Lumen** | **Light that behaves like light.** Volumetric shafts with lit dust inside them, occlusion-tested anamorphic flares, and — the big one — abilities that *move the scene's key light*, so every object in the world swings a real shadow. | +| **Ink** | **The anti-glow school.** Flat, matte, high-contrast black and vermilion, no bloom anywhere. Brush dynamics, wet diffusion with a real fingering instability, and paper that folds. In a sandbox this addicted to emission, restraint is the novelty. | +| **Chrono** | **Time as a driver.** Recording and replaying the caster, clamping other systems' clocks inside a radius, and running the same shaders with a negative time step. | +| **Hive** | **Emergence.** Swarms that resolve into silhouettes because their agents target positions sampled from a signed distance field, and structures that grow on a real lattice. | + +Fourteen more abilities extend the nine existing schools, chosen only where they bring a technique +the school does not already have. + +--- + +# X · TIDE +*Deep teal through aqua. Wet, heavy, and lit from below.* + +## `tiderush` · Tiderush + tide · LINE · LiquidSurface(WAVE) + Caustics + THE TRICK — caustics. Light refracted through the wave's body plays on the floor *ahead of the + wave*, so you read the water's thickness from the pattern on the ground rather than from the + surface. The caustic net has to be driven by the same heightfield that draws the wave, or it is + a decal and everyone can tell. + +## `undertow` · Undertow + tide · ZONE · LiquidSurface + Caustics + Swarm(debris) + THE TRICK — one flow field, three consumers. A real logarithmic spiral drives the surface, the + caustics and the debris together; drag the swirl and all three answer. Debris orbits inward and + is pulled under, not deleted — it goes *below* the surface and is seen through it. + +## `brinelock` · Brinelock + tide · LINE · LiquidSurface → GrowthField(splash) + THE TRICK — the state change. A splash crown is thrown up as real water and then *stops*, + mid-air, as glass-clear ice in exactly the silhouette the water had. The handover has to + preserve the shape frame-for-frame; if the ice is a different splash the whole idea collapses. + +## `geyser` · Geyser + tide · ZONE · Tube(FUNNEL, inverted) + Projectile(FALL) + THE TRICK — the column becomes the rain. The water that went up is the water that comes down: + droplets are seeded from the column's own surface at the moment it loses pressure, inherit its + velocity, and each one lands with its own small splash and ripple into the pool below. + +## `bubblecage` · Abyssal Cage + tide · ZONE · Shell + thin-film interference + THE TRICK — thin-film interference. Soap-film colour is a real optical effect: the hue comes + from the film's *thickness* against the view angle, not from a gradient. Implement the actual + interference term. Nothing else in the sandbox derives colour from physics, and it looks + unmistakably different from everything that fakes iridescence with a fresnel ramp. + +## `torrent` · Torrent + tide · LINE · Tube + deflection spray + THE TRICK — deflection. The jet hits the floor and the spray fans out *along the surface*, in + the plane of the impact, at the reflected angle — not as a radial puff. A cutting jet reads as + pressure only if the spray knows which way the surface is facing. + +--- + +# XI · FORGE +*Steel, scale and heat. Orange on grey.* + +## `anvilfall` · Anvilfall + forge · ZONE · HardSurface + Shell + THE TRICK — mass. A machined anvil — bevels, fillets, a flat working face, brushed anisotropic + specular — falls, and everything about the impact is about weight: the floor dishes under it, + the shock is low and slow rather than bright and fast, and it *stays there* after. + +## `sawline` · Sawline + forge · LINE · HardSurface(blade) + Projectile + THE TRICK — grinding sparks. Sparks leave at the blade's contact tangent, in the direction the + tooth was travelling, at a speed derived from the rim velocity. A saw that throws sparks + radially is a firework; the tangent is the whole read. + +## `pistondrive` · Piston Drive + forge · LINE · HardSurface(piston) + GroundField + THE TRICK — a cam curve. Pistons do not ease. They dwell, snap, and dwell again: the motion + profile is a real cam, exposed as an editable curve, and the sequence down the line is a phase + offset on it. Smooth easing here would make them read as rising rock. + +## `gearlock` · Gearlock + forge · ZONE · HardSurface(involute gear) + THE TRICK — the teeth actually mesh. Involute tooth profiles, and each gear's angular rate is + derived from its tooth count against its neighbour's, so the train genuinely interlocks and + stays interlocked when you drag the ratios. Teeth that pass through each other is the failure + everybody ships. + +## `quench` · Quench + forge · ZONE · HardSurface + VolumeHull(steam) + blackbody ramp + THE TRICK — the cooling curve is physical. White → yellow → orange → cherry → black along a real + blackbody locus rather than an authored gradient, so the metal cools the way metal cools. The + steam is the loud part; the colour ramp is why it is convincing. + +## `shrapnel` · Shrapnel Bloom + forge · ZONE · ShatterField + ricochet + THE TRICK — ricochet. Machined fragments hit the floor and *bounce*, reflecting about the + surface normal with a restitution slider and a tumble that survives the bounce. Fragments that + stop dead on contact is the tell that separates a shatter from a burst. + +--- + +# XII · LUMEN +*Warm white and gold. The school where light is the object.* + +## `godspear` · Godspear + lumen · LINE · LightShaft + Caustics + THE TRICK — real in-scattering. A volumetric shaft with dust genuinely lit inside it, sweeping + down the line, throwing a moving bright band on the floor where it lands. The dust in the shaft + must be the scene's own dust motes, brightened as they pass through it — not a second system. + +## `solarlens` · Solar Lens + lumen · ZONE · LensFlare + Caustics(focus) + THE TRICK — an occlusion-tested flare. A floating lens focuses light to a burning point that + walks across the floor; the anamorphic flare anchored to it is depth-tested against the scene, + so it dims when the character passes in front of it. A flare that ignores occlusion is a + sticker on the lens. + +## `refractcascade` · Refraction Cascade + lumen · LINE · Mirror + Tube + THE TRICK — the mirrors reflect the actual scene. A beam bounces between floating mirrors down + the line, and each mirror shows a real reflection of what is behind the camera. Fake it with an + environment map and it reads as chrome; do it properly and the mirrors read as glass. + +## `dawnbreak` · Dawnbreak + lumen · ZONE · SceneHooks(key light) + THE TRICK — the ability moves the sun. It takes hold of the scene's directional light and + swings it from the horizon to overhead and back, so *every object in the world* — the character, + the crystals of a Frost Lance still standing, the floor's own relief — throws a real shadow that + sweeps across the ground. No other ability changes the world; this one does, and it must put it + back exactly. + +## `eclipse` · Eclipse + lumen · ZONE · Shell + SceneHooks(grade) + THE TRICK — the light goes wrong before the disc appears. Colour drains toward the umbra, the + key light cools and dims, and only then does a black disc with a corona of filaments open. It is + the Thunderclap lesson applied to light: the anticipation carries it. + +## `photonlattice` · Photon Lattice + lumen · ZONE · Tube(instanced grid) + THE TRICK — the nodes are bright because they overlap. A three-dimensional grid of thin beams + with additive blending; the intersections are brighter for the only honest reason, which is that + two beams are being added there. Nothing is drawn at the nodes at all. + +--- + +# XIII · INK +*Black, bone-white and vermilion. Matte. No bloom anywhere in this school.* + +## `sumistroke` · Sumi Stroke + ink · LINE · BrushStroke + THE TRICK — dry brush. One enormous brushstroke down the line, with real brush dynamics: it + loads heavy, thins under pressure, and *runs out* — breaking into separated fibre streaks at the + end of the stroke. The ragged tail is the whole ability, and it must come from a bristle model, + not from a noise mask over an even stroke. + +## `inkbloom` · Ink Bloom + ink · ZONE · InkDiffusion + THE TRICK — a fingering instability. Ink spreading in water does not grow as a disc; it grows + as branching fingers, because the interface is unstable. Implement the instability (a + Saffman–Taylor-ish threshold on a diffusing field) and the pattern generates itself, differently + every cast, from one seed. + +## `origami` · Paper Storm + ink · LINE · FoldMesh + Swarm + THE TRICK — real folding. Cranes fly downrange and *unfold* into flat sheets: a creased mesh + whose fold angles are driven from one parameter, so the whole flock opens on one slider. Paper + must never stretch — the creases have to be isometric or it reads as rubber. + +## `sealscript` · Seal Script + ink · ZONE · BrushStroke(vertical) + THE TRICK — legible brush weight in three dimensions. A column of characters written + top-to-bottom in the air, each stroke with a real entry, body and exit, and each one drawn in + sequence. It has to still read as writing when you orbit it, which means the strokes need + thickness in the view direction — a flat billboard disappears edge-on and the column vanishes. + +## `splatterbrand` · Splatterbrand + ink · LINE · InkDiffusion + Projectile + THE TRICK — splatter morphology. A flung blob does not make a circle. It makes a directional + main mass, a crown of spikes on the leading edge, and satellite droplets thrown further along + the travel vector, sized by a power law. Get the satellites right and it is unmistakable. + +## `scrollward` · Scrollward + ink · ZONE · FoldMesh(unroll) + THE TRICK — cylindrical unrolling. Scrolls unroll into a standing wall of text; the paper's + curvature is real, tightest at the roll and flattening as it pays out, and the text on it is + correctly foreshortened by that curve as it comes off the spool. + +--- + +# XIV · CHRONO +*Pale amber and bone. Quiet, and slightly wrong.* + +## `echostep` · Echo Step + chrono · LINE · Recorder + THE TRICK — the caster's own recorded motion. Three ghosts of the character run the line, + replaying a transform track captured from the real caster, each delayed further and each fainter. + They are the character, not a proxy; that is what makes it unsettling. + +## `stasisfield` · Stasis Field + chrono · ZONE · TimeControl(clamp) + THE TRICK — it stops *other* effects. Particles inside the radius hold mid-air, another + ability's standing crystals stop growing, a bolt in flight stops guttering. Implemented by + clamping age inside a region rather than by drawing anything, which means the ability is almost + entirely invisible and reads entirely through what it does to everything else. + +## `rewind` · Rewind + chrono · LINE · TimeControl(reverse) + THE TRICK — a negative time step through the same shaders. Debris flies back up, cracks close, + dust gathers. Nothing bespoke is drawn: the ability runs the ordinary vocabulary backwards, and + the reason it works is that every effect in this project is a closed-form function of time. + +## `hourglass` · Hourglass + chrono · ZONE · VolumeHull(SAND) + SceneHooks(gravity) + THE TRICK — the inversion. Sand falls into a cone, the zone flips, and it falls *up* — the same + field, the same grains, one sign changed. The flip has to happen on a beat you can see coming. + +## `afterimage` · Afterimage + chrono · LINE · Recorder(snapshots) + THE TRICK — N frozen copies, all still live. The cast leaves snapshots of itself at intervals, + each held at the age it was taken, and each one still re-resolves from the sliders — so pausing + and dragging reshapes all six frozen moments at once. It is invariant I1 turned into an effect. + +## `entropy` · Entropy Wave + chrono · ZONE · SceneHooks(material aging) + THE TRICK — one parameter ages a material. Rust, dust, moss, pitting and bleaching all driven + from a single 0..1 that sweeps outward across the floor and then retreats. The floor is a real + PBR material, so this is a patch into its shader rather than a decal over it. + +--- + +# XV · HIVE +*Chitin amber and a sick green. Many small things behaving as one.* + +## `locusttide` · Locust Tide + hive · LINE · Colony(SDF-targeted swarm) + THE TRICK — the swarm forms shapes. Agents target positions sampled from a signed distance + field, so the cloud condenses into a fist, a wall and a spear as it travels, then disperses. + The silhouette is emergent — nothing is drawn but insects. + +## `webline` · Web Line + hive · LINE · Colony(graph + membrane) + THE TRICK — the membrane. A web is not just strands: it is strands plus the thin film between + them, which catches light at grazing angles and is invisible head-on. Strands sag under their + own tension against real anchor points, and the film is built from the graph's faces. + +## `hivecolumn` · Hive Column + hive · ZONE · Colony(hex lattice growth) + THE TRICK — growth on a real lattice. Hexagonal cells build outward from a seed, each one + snapping to the lattice its neighbours defined, so the structure is legibly *constructed* rather + than grown. Cells that would overlap are refused, which is what gives it its irregular edge. + +## `waspfunnel` · Wasp Funnel + hive · ZONE · Colony + Tube(FUNNEL) + THE TRICK — density waves. The funnel pulses — bands of higher agent density travel up it, the + way a real swarm surges. The wingbeat is per-agent and out of phase, so the mass shimmers + without anything being animated globally. + +## `carapace` · Carapace + hive · ZONE · Colony(shell tessellation) + THE TRICK — plates that interlock. A Voronoi tessellation on a hemisphere, each cell a chitin + plate that flies in and locks against its neighbours' edges. Because the cells come from one + tessellation, the seams are exact — the dome closes with no gaps and no overlaps. + +## `broodburst` · Broodburst + hive · ZONE · GrowthField + Colony + THE TRICK — swell, then burst, per egg. Each egg has its own timer: it inflates, goes + translucent as it stretches, and splits along a seam. The crawlers that come out follow the + floor rather than flying, which separates this from every other swarm in the sandbox. + +--- + +# Extensions to the nine existing schools + +## `avalanche` · Avalanche — *frost*, LINE + THE TRICK — granular flow. Snow piles, slumps and finds its angle of repose. The front is not a + wave; it is a heap that keeps collapsing forward over itself. + +## `blackice` · Black Ice — *frost*, ZONE + THE TRICK — a planar reflection. A mirror-smooth sheet that reflects the real scene, sharpening + as it freezes. The only true reflective surface in the sandbox. + +## `firewalk` · Firewalk — *flame*, LINE + THE TRICK — footprints. Actual footprint shapes ignite in sequence down the line, each throwing + a short pillar. A recognisable silhouette in a school made entirely of formless heat. + +## `wildfire` · Wildfire — *flame*, ZONE + THE TRICK — propagation as a cellular automaton. Fire spreads cell to cell across the floor, + jumps gaps, leaves unburnt islands and burns back on itself. Not a growing circle: a *front*. + +## `sheetlightning` · Sheet Lightning — *storm*, ZONE + THE TRICK — the shadows strobe. The flash is applied to the scene's key light, so every real + shadow in the world snaps with it. Cheap to do, and the first time it fires it is startling. + +## `obsidian` · Obsidian Bloom — *stone*, ZONE + THE TRICK — conchoidal fracture. Volcanic glass breaks in smooth curved shells, not flat facets. + Generate the geometry that way and it reads as glass at a glance, before the specular even + lands. + +## `mycelium` · Mycelial Web — *verdant*, LINE + THE TRICK — it is under the floor. A fungal network spreads *beneath* the surface and is visible + only as a glow coming up through the stone's own cracks and pores. + +## `unmake` · Unmake — *void*, LINE + THE TRICK — voxel dissolve. Matter comes apart into cubes that drift and wink out, the cube size + growing as the dissolve progresses so the loss accelerates visibly. + +## `silence` · Silence — *void*, ZONE + THE TRICK — a hole in the frame. Inside the zone, nothing renders at all — not black, *absent*. + The most aggressive thing in the sandbox, and it is achieved by drawing less than anything else. + +## `spellbreak` · Spellbreak — *arcane*, ZONE + THE TRICK — it reacts to other casts. The one ability aware of the rest: arcane glass shatters + and any other effect currently standing inside the zone is visibly disrupted — desaturated, + fragmented, and pushed. Cast it into an empty room and it is pretty; cast it into a Nova Beam + and it is an interaction. + +## `astralgate` · Astral Gate — *arcane*, ZONE + THE TRICK — geometry emerging *through* a plane. A ring gate that objects pour out of, correctly + clipped by the portal plane so nothing is ever seen on the wrong side of it. The clip is the + ability; without it, things pop into existence in front of a decal. + +## `bonecage` · Bone Cage — *blood*, ZONE + THE TRICK — a different material entirely. Ribs of dry bone close over the zone: warm + subsurface scatter, chalky micro-roughness, no wetness and no glow. Standing next to Crimson + Tide's viscous red, the contrast is the point. + +## `featherfall` · Featherfall — *aether*, ZONE + THE TRICK — flutter dynamics. Feathers do not fall; they stall, slip sideways, catch, and glide. + A real tumbling model with lift, so no two descend the same way. + +## `mirage` · Mirage — *aether*, LINE + THE TRICK — a silhouette made only of refraction. A duplicate of the caster runs the line, drawn + with nothing but distortion — you see it because the world bends into a human shape, and the + moment it stops moving you lose it. + +--- + +## Totals + +| | schools | abilities | +| --- | --- | --- | +| shipped | 1 (mixed) | 6 | +| roster I | 9 | 44 | +| roster II — new schools | +6 | 36 | +| roster II — extensions | — | 14 | +| **total** | **15** | **100** | diff --git a/docs/ROSTER.md b/docs/ROSTER.md new file mode 100644 index 0000000..e21e489 --- /dev/null +++ b/docs/ROSTER.md @@ -0,0 +1,416 @@ +# The Roster — 44 new abilities + +The sandbox shipped with six. This document specifies forty-four more, taking the same rules the +first six were built under and pushing them into techniques the original build never used. + +**The rules that carry over, in order of importance:** + +1. **Nothing is a texture.** Every mark on screen is a signed distance field, a noise field, a + parametric path evaluated in a vertex shader, or procedural geometry generated on the CPU. +2. **No dimensions on the CPU.** A cast captures *dice rolls and timestamps only* — unitless + fractions and the moment an event fired. Every metre, radian and second is resolved against + `settings[id]` inside the update loop, which runs on a zero-length frame. Dragging a slider + must reshape an effect that is already standing, with the clock stopped. +3. **One trick per ability.** Each entry below names the single technique that makes it read. If a + new ability is a recolour of an old one it should not exist. Every one of these forty-four + contributes a silhouette, a motion or a rendering trick that no other slot has. +4. **The beats.** `travel → impact → fade`, with an optional wind-up bought by refusing to let + `advance()` leave the caster (this is how Nova Beam gets its fourth beat). +5. **Every colour is a picker, every dimension a slider.** Nothing is derived from another value + unless the derivation *is* the point (the way `snare.zoneRadius` drives five consumers at once). + +--- + +## Reading an entry + +``` +## id · Name + school · cast shape · key tech · beats + THE TRICK — the one thing that makes it read. + + Palette: ... +``` + +`cast shape` is `LINE` (arrow indicator) or `ZONE` (far-cast circle, needs `zoneRadius`). +`key tech` names modules from `docs/EXPANSION.md` §"The tech library". + +--- + +# I · FROST — the school that already exists + +Frost Lance and Glacial Crown ship in the build. These three take the palette somewhere the +crystals cannot. + +## `rime` · Rimewalker + frost · LINE · GroundField + GrowthField(plate) · travel → impact → fade + THE TRICK — the ice is *thin*. Sheets, not spikes: curved plates a few centimetres thick that + peel up off the floor like paper curling off a hot pan, translucent enough to see the plate + behind them, catching the light on their lip and nowhere else. + A freezing front runs the line and the floor glazes over behind it in interlocking plates. As + each plate locks, its downwind edge lifts and curls; by the time the front reaches the target + the path is a corridor of standing shards of sheet ice. It holds, then the curls snap off and + the glaze sublimates from the caster's end forward. + Palette: near-white with a green-blue body tint, `#dff6ff` / `#7ecbe0` / `#123b4a`. + +## `hail` · Hailwrath + frost · ZONE · Projectile(swarm) + GroundField · impact-only, staggered + THE TRICK — there is no travelling front. The front is *vertical*: hailstones arrive out of the + sky on staggered timers keyed off a spatial hash of where they land, so the zone fills from the + boundary inward and no two casts fill it in the same order. + A column of freezing air over the circle, and then it comes down — irregular stones that + stretch as they fall, punch a white pock into the floor, throw chips and bounce once. The rate + ramps up, peaks, and tails off; the pocks stay and slowly rime over. + Palette: `#f2fbff` / `#9fd8ee` / `#2a5d75`. + +## `shatterlance` · Shatterlance + frost · LINE · GrowthField(single) + ShatterField · wind-up → travel → impact → fade + THE TRICK — the wind-up is the ability. A single enormous ice lance assembles in the air out of + converging shards, hangs there long enough for you to see it, then *goes* — and the impact is a + ShatterField: the lance's own geometry breaks into two hundred instanced fragments that inherit + the flight velocity and tumble. + Palette: `#ffffff` / `#8fe3ff` / `#0d2f52`. + +--- + +# II · FLAME + +## `pyroclasm` · Pyroclasm + flame · ZONE · VolumeHull(dome) + Distortion · impact → hold → fade + THE TRICK — a raymarched ash dome that *collapses before it blows*. The volume is sampled in + world space so the ash keeps its grain while the dome contracts, which is what sells the + implosion; then the density inverts and it blasts outward through its own footprint. + Palette: soot to ember, `#1a1210` / `#5c2a10` / `#ff7a2a` / `#ffd9a0`. + +## `dragonbreath` · Wyrm's Breath + flame · LINE · VolumeHull(cone) + GroundField · travel → sustain → fade + THE TRICK — a real cone volume. Not a widening billboard: a raymarched cone whose density + profile is thickest just off-axis and hollow down the middle, so orbiting it shows you the + tongue of flame passing *through* itself. The floor beneath scorches progressively as an SDF + that grows along the cone's ground intersection. + Palette: `#fff2c0` / `#ffb03a` / `#e0400f` / `#2a0a04`. + +## `firewhip` · Ashen Lash + flame · LINE · Tube(whip) + Shell · travel → crack → fade + THE TRICK — whip kinematics. The lash is a tube along a curve whose curvature travels from the + handle to the tip; when the loop reaches the end the tip briefly exceeds the wave speed and a + small shock ring pops off *at that point*, mid-air. The crack is a real event in the geometry, + not a scheduled effect. + Palette: `#ffe9b0` / `#ff8a2a` / `#8a1c05`. + +## `emberflock` · Emberflight + flame · LINE · Swarm + RibbonTrails · travel → impact → fade + THE TRICK — flocking. Twenty-odd ember birds each carry their own trail; they cohere toward a + shared lead point that runs the cast line, separate from each other, and bank into their turns + (roll derived from lateral acceleration). At the target they collapse into one point and go up. + Palette: `#ffd27a` / `#ff6a1f` / `#3d0d04`. + +## `magma` · Magma Fount + flame · ZONE · LiquidSurface + Projectile(blobs) · impact → sustain → cool + THE TRICK — a real molten pool. A live heightfield on the floor with flow-mapped crust: the + black skin cracks along the flow direction and the seams glow, and the crust re-forms where the + surface is slow. Blobs arc out of the middle and land back in it, punching ripples into the + same heightfield. + Palette: `#ffe08a` / `#ff5a12` / `#7a1a04` / `#120806`. + +## `sunspear` · Sunspear + flame · LINE · Projectile(arc) + Distortion + Shell · travel(arc) → impact → fade + THE TRICK — heat shimmer as a first-class effect. The javelin's wake writes into the + distortion buffer, so the floor and the character genuinely warp behind it. On landing it opens + a low sun-disc lying on the ground with corona filaments licking off its rim. + Palette: `#ffffff` / `#ffe07a` / `#ff9a1f`. + +--- + +# III · STORM + +## `chainarc` · Chain Arc + storm · LINE · ArcNetwork · travel(hops) → impact → fade + THE TRICK — graph pathing. The bolt does not travel; it *hops*. A handful of nodes are placed + down the line with lateral scatter, and the discharge lights one segment at a time with a small + burst at each node. Re-rolling the node scatter live re-routes a chain already in the air. + Palette: `#eaf6ff` / `#5fb0ff` / `#0b2f7a`. + +## `thunderclap` · Thunderclap + storm · ZONE · Shell(dome) + Distortion · impact → delayed boom → fade + THE TRICK — the delay. The flash lands, and then nothing for a beat, and *then* the pressure + front arrives as three concentric refraction rings that push through the distortion buffer and + shove the dust. The gap is the whole effect; without it this is just another shockwave. + Palette: `#ffffff` / `#cfe4ff` / `#3f6fd0`. + +## `balllightning` · Fulminant Orb + storm · LINE · Shell + FilamentPaths(orbit) · travel(slow) → impact → fade + THE TRICK — a caged orb. Filaments do not radiate outward, they *orbit* — great slow loops + around a near-invisible shell, occasionally earthing to the floor beneath in a short spike. + It travels slowly enough that you watch it come, which no other storm slot does. + Palette: `#ffffff` / `#9fd0ff` / `#2f3fd0`. + +## `stormwall` · Tempest Wall + storm · LINE · Curtain + FilamentPaths · raise → hold → fall + THE TRICK — geometry perpendicular to the cast. The line you aim is the wall's *normal*, not + its length: a rain curtain rises across your heading, with its own lightning inside it and rain + streaking down its face, and the floor under it goes wet and reflective. + Palette: `#c8dcea` / `#5f7f9a` / `#101c2a`, lightning `#ffffff`. + +## `railcoil` · Railcoil + storm · LINE · Tube + FilamentPaths(helix) · wind-up → instant → decay + THE TRICK — zero travel time. Coils collapse inward along the barrel during the wind-up, and + when they meet the shot is simply *already there* — full length in one frame. Everything after + is decay: an ionisation channel that cools from white through blue to nothing over a second and + a half, sagging and breaking into segments as it dies. + Palette: `#ffffff` / `#a8e0ff` / `#1a4fd0`. + +--- + +# IV · STONE + +## `stonespine` · Stone Spine + stone · LINE · GrowthField(slab) · travel → impact → fade + THE TRICK — plates, not spikes. Flat slabs of floor heave up and tilt like ice floes on a + river, each one hinged along an edge rather than punched straight up, with the dirt underside + visible on the lifted face and rubble sliding off the top. + Palette: `#6b6357` / `#3a352e` / `#141210`. + +## `sinkhole` · Sinkhole + stone · ZONE · GroundField + Projectile(debris) · impact → collapse → settle + THE TRICK — the ground goes *down*. An inverted fissure: the floor quad is displaced into a + funnel, the lip cracks and calves inward, and debris falls into a hole that reads as depth + because its walls are shaded from a fake normal and its floor is never drawn. + Palette: `#4a4239` / `#221e19` / `#0a0908`. + +## `tectonic` · Tectonic Slam + stone · ZONE · Fissure + Shell(ring) · impact → propagate → settle + THE TRICK — the fissures whip *outward on a clock*, five of them racing to the boundary at + different speeds with the dust wave riding just behind each tip, so the footprint is drawn by + motion rather than revealed all at once. + Palette: `#8a7f6b` / `#463f34` / ember `#ff7a2a`. + +## `boulder` · Rolling Ruin + stone · LINE · Projectile(rolling) + GroundField · travel(roll) → impact → fade + THE TRICK — it *rolls*. Real rolling: the boulder's rotation is derived from distance over + radius, so it never skates, and it gouges a rut behind it whose depth follows the contact + force. It shatters on arrival into its own asteroid geometry, sliced. + Palette: `#6e6455` / `#39332b` / dust `#a89880`. + +## `petrify` · Petrifying Gaze + stone · LINE · GrowthField(facet) + VolumeHull(sand) · travel → hold → crumble + THE TRICK — accretion then collapse. Grey facets *accrete out of the air* along the line — + they grow inward toward the axis rather than up out of the floor — hold as a solid column, and + then crumble: the facets shrink to nothing and are replaced, in place, by a raymarched fall of + sand. + Palette: `#9a948a` / `#565049` / `#221f1c`. + +--- + +# V · VERDANT + +## `thornwake` · Thornwake + verdant · LINE · GrowthField(thorn) + FilamentPaths(link) · travel → impact → fade + THE TRICK — the brambles *interlace*. Filaments are drawn between neighbouring thorn instances + — each vine picks two instances by index and threads a sagging curve between them — so the + field reads as one tangled mass rather than as scattered props. + Palette: `#4a6b2a` / `#243d14` / thorn tips `#c8b06a`. + +## `bloomburst` · Bloomburst + verdant · ZONE · GrowthField(petal) + VolumeHull(pollen) · grow → open → burst + THE TRICK — unfurling. Each flower is a ring of petals whose bend parameter runs from fully + closed to fully open, driven per instance off a staggered clock, so the field opens in a wave + across the circle. Then every petal releases at once into a pollen volume. + Palette: `#f2d0e8` / `#a84f8a` / pollen `#ffe89a`. + +## `sporefall` · Sporefall + verdant · ZONE · VolumeHull(slab, low) · seep → hold → disperse + THE TRICK — a volume that *hugs the floor*. The density field is heavily flattened in Y and + advected outward, so it pours across the ground and pools in the middle instead of billowing. + Bioluminescent motes drift up out of it and die at head height. + Palette: `#7ad0a0` / `#2a6b4a` / glow `#c8ff9a`. + +## `vinelash` · Verdant Lash + verdant · LINE · Tube(grow) + Swarm(leaves) · travel(grow) → snap → wither + THE TRICK — it grows rather than flies. The tube's length is the front, its radius tapers to + nothing at the tip, and leaves unfurl along it as it passes. At full extension it *snaps back* + — the whole curve recoils on a spring and the leaves are stripped off. + Palette: `#6ba83a` / `#2a4a18` / `#c8d86a`. + +## `grovecall` · Grovecall + verdant · ZONE · GrowthField(tree) + Curtain(light shaft) · grow → hold → fade + THE TRICK — scale contrast. Six trunks with canopies grow to three metres in half a second on a + wave around the ring, and light shafts fall through the canopy onto the floor as real geometry + — tapered translucent volumes, not a post effect. + Palette: bark `#4a3a28`, leaf `#5f8a2a`, shaft `#e8f0c0`. + +--- + +# VI · VOID + +## `voidrift` · Void Rift + void · LINE · Portal + Distortion · tear → hold → close + THE TRICK — a hole. A slit in space along the line: pure black interior with a parallax + starfield behind it that moves against the camera at the wrong rate, a white-hot fracture rim, + and a distortion ring around the edge that bends the floor into it. + Palette: interior `#000000`, rim `#ffffff` / `#b07aff`. + +## `singularity` · Singularity + void · ZONE · Distortion(lens) + Swarm · form → pull → collapse + THE TRICK — screen-space lensing. The distortion pass, which has been sitting unused, finally + earns its keep: a radial UV displacement whose magnitude goes as 1/r² inside a falloff, so the + floor grid, the character and every particle behind it bend around the well. Everything nearby + spirals in on real angular-momentum paths, and then it inverts. + Palette: `#0a0612` / `#6a3fd0` / event horizon `#ffffff`. + +## `umbralspears` · Umbral Spears + void · LINE · GrowthField(spear) · travel → impact → fade + THE TRICK — anti-glow. In a scene tuned for bloom, these are the only objects that are + *darker* than the floor: near-black spears with a thin violet rim and a soft shadow pooled at + their base, rendered with a rim-only shading model so their silhouette is all you get. + Palette: `#050308` / rim `#8a5fd0`. + +## `nightfall` · Nightfall + void · ZONE · VolumeHull(dome) + GroundField · close → hold → open + THE TRICK — subtractive light. The dome multiplies rather than adds: it genuinely darkens what + is behind it, the floor texture goes to near-black under it, and the only things visible inside + are a slow starfield and the caster's own dynamic light struggling against it. + Palette: `#000000` / `#0a0a18` / stars `#c0d0ff`. + +## `soulchain` · Soul Tether + void · LINE · FilamentPaths(chain) + GrowthField(link) · throw → hold → snap + THE TRICK — discrete links. Not a smooth filament: real chain links, instanced tori threaded + along a sagging catenary, each one rotated 90° from its neighbour and free to swing. The chain + goes taut with a snap and the links jangle. + Palette: ghost-iron `#7a8a9a` / glow `#9affe0`. + +--- + +# VII · ARCANE + +## `runeseal` · Runic Seal + arcane · ZONE · GroundField(glyph SDF) · inscribe → ignite → discharge + THE TRICK — procedural glyphs. Three nested rings of runes drawn as real signed-distance + letterforms in metres — strokes, terminals, counters — that *draw themselves* stroke by stroke + as the seal inscribes, counter-rotate at different rates, and then ignite from the inside out. + This is the showpiece ground shader; it should stand up to being paused and stared at. + Palette: `#ffd27a` / `#ff8a2a` / `#2a1a0a`. + +## `prismlance` · Prism Lance + arcane · LINE · Tube + ShatterField(prism) · charge → fire → split + THE TRICK — dispersion. The beam strikes a floating prism at mid-span and leaves it as a fan of + six coloured child beams that continue to the target at slightly different angles, converging + again at the end. The prism itself is a real refracting solid with an env-mapped surface. + Palette: white in, spectrum out. + +## `chronofracture` · Chronofracture + arcane · ZONE · ShatterField(pane) + Distortion · freeze → hold → shatter + THE TRICK — panes that hold a still image. Sheets of frozen time hang in the air over the zone + sampling the scene behind them through the distortion/refraction path, desaturated and offset, + so each pane shows a *slightly older, colder* version of what is behind it. Then they break. + Palette: `#d0e8ff` / `#6a8ab0` / fracture `#ffffff`. + +## `starfall` · Starfall + arcane · ZONE · Projectile(swarm) + Shell · call → rain → fade + THE TRICK — staggered arrivals with a shared vanishing point. Every star comes from the same + point high above and behind the caster, so their trails converge in the sky and diverge on the + ground — the parallax is what makes the sky feel like it is above you. + Palette: `#ffffff` / `#c0d8ff` / `#3a5fd0`. + +## `arcanevolley` · Arcane Volley + arcane · LINE · Projectile(homing) + RibbonTrails · charge → volley → converge + THE TRICK — parametric homing. Seven bolts leave the hand on Lissajous paths whose amplitude + decays to zero at the target, so they weave apart, cross each other, and all arrive at the same + point at the same instant without any of it being simulated. + Palette: `#e0c0ff` / `#8a5fd0` / `#2a1a4a`. + +## `glyphstorm` · Glyphstorm + arcane · LINE · Swarm(card) + GroundField · gather → storm → disperse + THE TRICK — a blizzard of legible marks. Several hundred instanced quads each rendering one + procedural glyph, camera-facing but edge-on when they turn, so the storm flickers between a + wall of symbols and a scatter of bright lines. + Palette: `#ffe8c0` / `#c08a3a` / `#2a1a0a`. + +--- + +# VIII · BLOOD + +## `crimsontide` · Crimson Tide + blood · LINE · LiquidSurface + Swarm(droplet) · surge → break → drain + THE TRICK — a real wave. A heightfield wave travels the line with a curling crest whose front + face is thin enough to be translucent, breaking into droplets at the lip. It drains back into + the floor leaving wet, reflective stone. + Palette: `#8a0f18` / `#3a050a` / crest `#ff4a4a`. + +## `hemolance` · Hemorrhage + blood · LINE · Projectile(volley) + Shell · volley → impact → pool + THE TRICK — needles. Extremely thin, extremely fast lances that arrive in a ripple rather than + together, each one leaving a hairline mist trail that persists for a moment after the needle + has gone — the trail outlives the projectile, which is what makes the volley read. + Palette: `#c01a28` / `#5a0a10`. + +## `sanguinepact` · Sanguine Pact + blood · ZONE · GroundField + VolumeHull(mist) + FilamentPaths(orbit) · draw → hold → seal + THE TRICK — orbiting droplets. A pool on the floor sends up a slow mist column, and around it + beads of blood climb in real orbits — instanced spheres on inclined ellipses with proper + perspective — that flatten into the ring as the pact seals. + Palette: `#7a0a14` / `#c81a28` / mist `#4a1018`. + +## `plaguebloom` · Plague Bloom + blood · ZONE · VolumeHull(boil) + GroundField(pustule) · seep → boil → burst + THE TRICK — boiling. The gas volume's density is driven by a cellular field whose cells + *inflate and pop* on individual timers, so the cloud has visible internal events rather than + just drifting noise. The floor grows matching pustules that burst in sync. + Palette: `#9aa83a` / `#4a5a18` / `#c8d86a`. + +--- + +# IX · AETHER + +## `cyclone` · Cyclone + aether · ZONE · Tube(funnel) + FilamentPaths(spiral) + Swarm · form → hold → dissipate + THE TRICK — the funnel profile. Radius follows a real vortex profile (tight throat, flared + skirt at the floor, flared mouth at the top) and everything else — the debris ribbons, the dust + skirt, the ground scour — is placed against that one function, so dragging the profile moves + all of it together. + Palette: `#c0c8d0` / `#6a7480` / `#20262c`. + +## `slipstream` · Slipstream + aether · LINE · Distortion(blade) + Tube(thin) · travel → cut → fade + THE TRICK — almost nothing is drawn. A vacuum blade is a plane of pure refraction with a + hairline bright edge; you see it because the world behind it slides, not because it has colour. + The most restrained slot in the sandbox, and the proof that the distortion pass works. + Palette: edge `#ffffff`, everything else is the scene bent. + +## `resonance` · Resonant Chord + aether · LINE · Shell(ring train) + Distortion · strike → ring → decay + THE TRICK — standing waves. Rings travel out along the line at fixed spacing and *reflect* off + the far end, so the outbound and returning trains interfere and you can see the nodes: places + on the line where the air is still and places where it is violently compressed. + Palette: `#d0f0ff` / `#5fa0c0` / `#102030`. + +## `aurora` · Aurora Veil + aether · ZONE · Curtain · rise → ripple → fade + THE TRICK — curtains. Vertical sheets with a vertex-shader ripple travelling along their length + and an emission that falls off with height on a different curve than the alpha, which is the + thing that makes real aurora read as *light in air* rather than as a hanging ribbon. + Palette: `#5fffc0` / `#3a9aff` / `#c05fff`. + +## `skyfracture` · Sky Fracture + aether · LINE · FilamentPaths(crack) + GroundField + Shell · flash → delay → fall + THE TRICK — the reflection. A white fracture opens high above the cast line, and its *shadow + and reflection* appear on the floor before anything else happens — you read the shape on the + ground first, then the sky splits, then the pressure arrives. Reverse-ordered cause and effect. + Palette: `#ffffff` / `#c0d0ff` / `#1a2a4a`. + +--- + +## Slot summary + +| # | school | line | zone | +| --- | --- | --- | --- | +| 3 | frost | rime, shatterlance | hail | +| 6 | flame | dragonbreath, firewhip, emberflock, sunspear | pyroclasm, magma | +| 5 | storm | chainarc, balllightning, stormwall, railcoil | thunderclap | +| 5 | stone | stonespine, boulder, petrify | sinkhole, tectonic | +| 5 | verdant | thornwake, vinelash | bloomburst, sporefall, grovecall | +| 5 | void | voidrift, umbralspears, soulchain | singularity, nightfall | +| 6 | arcane | prismlance, arcanevolley, glyphstorm | runeseal, chronofracture, starfall | +| 4 | blood | crimsontide, hemolance | sanguinepact, plaguebloom | +| 5 | aether | slipstream, resonance, skyfracture | cyclone, aurora | + +**26 line casts, 18 far casts.** Plus the six that shipped: **50 abilities.** diff --git a/docs/VFX_API.md b/docs/VFX_API.md new file mode 100644 index 0000000..48e50f5 --- /dev/null +++ b/docs/VFX_API.md @@ -0,0 +1,445 @@ +# The VFX tech library — API reference + +Condensed from `src/vfx/README.md` (which is long, and worth reading for the *why*). Every +signature below was read back off the source by the integration pass. + +**Twenty-six modules under `src/vfx/`** — twenty-four renderers and toolkits plus `quads.js` and +`prefixedBlock.js`, which are shared plumbing. They exist so an ability is **configuration plus +beats** rather than a new renderer. An ability may still write a bespoke material when its trick +genuinely needs one — the library is a floor, not a ceiling. + +Every signature below was **read back off the source**, not taken from a report. The same text is in `src/vfx/README.md` under **API reference**. + +## Conventions — get these wrong and nothing renders + +**Attach.** *Parent-first* `new X(parent, opts)` — module adds its own meshes: `GrowthField`, `ShatterField`, `GroundField`, `FilamentPaths`, `ArcNetwork`, `Projectile`, `Swarm`, `Caustics`, `LightShaft`, `BrushStroke`, `InkDiffusion`, `FoldMesh`, `DissolveField`, `GhostRig`, `ColonySwarm`, `WebGraph`, `LatticeGrowth`, `PlateShell`. *Options-only* `new X(opts)` — you add its node: `Tube`(`.group`), `Shell`(`.group`), `VolumeHull`(`.mesh`), `DistortionField`/`Portal`/`LiquidSurface`/`Curtain`/`LensFlare`/`Mirror`(`.object3D`). *Neither*: `HardSurface` (a toolkit), `Dissolve`'s two patch modes (they compose into your material), `SceneHooks` (a singleton ledger), `timeField` (a singleton pool). + +**Settings.** *Canonical* (`p.key ?? default`, key list from `xxxParams()`): all except — *Prefixed* (`c[keys.x]`, spread `xxxDefaults(prefix,…)`): `Tube`, `Shell`, `VolumeHull`. `SceneHooks` is neither: it is driven by method calls on a borrowed token, because a hook is held across frames by one owner and a params bag has nowhere to put *who is asking*. + +**Clock.** `now` = the ability's `age` in seconds. Exceptions: `ArcNetwork.update(**dt**,…)`; `Swarm`, `Curtain`, `ColonySwarm`, `WebGraph`, `BrushStroke` and `FoldMesh` take `_now` and **ignore it**; `Caustics`, `LightShaft`, `LensFlare` and `Mirror` take **`update(p)`** with no clock at all; `Tube`/`Shell` take time on `state.time`; `VolumeHull.sync(c,g)` reads `frame.uTime` itself. + +**Borrowed globals.** Anything from `sceneHooks.acquire()` or `timeField.acquire()` is a piece of the *world*, not of your group. Take it through **`this.borrow(...)`** and `Ability#destroy()` gives it back however the cast ends. `npm run check` fails an ability that leaks one. + +```js +/* GrowthField.js ─ variants draw calls (3) ─ parent ─ canonical */ +GrowthLayout={LINE:0,ZONE:1}; GrowthEmerge={PUSH:0,SCALE:1}; growthParams()->object +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 at y=0, + // tip y=1. Throws if not a function. material is REQUIRED and yours; dispose() won't touch it. +field.count · drawCalls · meshes · records · material · factory · isFullyTriggered +field.onBreach = (index,position,radius,height)=>{} // assign ONCE at construction (I3) +field.plant(count, clusterShare=0)->number // the only dice roll +field.clear() · syncGeometry(shape)->boolean +field.triggerUpTo(now,limit,stagger,frontBias=1,includeCluster=false) +field.triggerRadial(now,limit,stagger,invert=false,includeCluster=true) +field.triggerAll(now,stagger) · triggerIndex(now,index,delay=0) +field.update(now,p,retract=0) +field.positionOf(index,p,out) · tipOf(index,p,out) · heightOf(index,p) · radiusOf(index,p) +field.emergenceOf(index,now,p) · dispose() +patchGrowthMaterial(material,{environment,uniforms,common,vertex,fragment})->material + // varyings: vGrowLocal, vGrowWorld, vGrowSeed, vGrowBirth · attributes: aSeed, aBirth + // NOTE: there is NO .object3D — it owns `variants` meshes and adds them to parent itself. + +/* ShatterField.js ─ variants draw calls (2) ─ parent ─ canonical */ +ShatterLayout (=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 +s.count · drawCalls · uniforms · material +s.burst(now,count,along=1,lateral=0)->number · clear() · sync(look) · setSceneTexture(tex|null) +s.update(now,p)->live count · positionOf(index,now,p,out) · dispose() + +/* GroundField.js ─ 1 draw call ─ parent ─ canonical */ +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 +f.object3D · drawCalls · mode · marks · markCount +f.setVisible(v) · setAdditive(v) · clearMarks() · update(p) · dispose() +f.mark(x,z,time,strength=1)->Vector4 // x,z are FRACTIONS of the radius, NEVER metres + +/* FilamentPaths.js ─ 2 draw calls ─ parent ─ canonical */ +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 +new FilamentPaths(parent,{samples=72,capacity=48,renderOrder=11,layer=LAYER.VFX}) +p.object3D · drawCalls · liveCount · visible(get/set) · nodeCount +p.role(i)->Role // i in 0..3 +p.setNodeCount(n) · setNode(i,along,lateral,lift) · nodePoint(roleIndex,i,out) +p.sync(look,fade=1,seed=0) // OVERWRITES uCount — set counts EVERY frame +p.clear() · dispose() +role.count=n · retire() · style(kink,width,dim,groundDamp) +role.ends(fadeStart,fadeEnd,taperStart,taperEnd) +role.draw(progress,tipLength,floorY,tipGlow) // progress default 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 ─ 0 extra draw calls when sharing a strip ─ parent ─ canonical */ +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} → chain+spikes+rim total TWO draw calls, not six +n.from · n.to (Vector3, caller writes each frame) · n.onNode=(index,position,count)=>{} +n.reset(seed) · reseed(seed) · update(dt,p,fade=1) // dt, NOT now +n.nodePoint(i,out) · clear() · dispose() +n.paths · object3D · drawCalls · nodeCount · segments · progress · arrived · firedCount · cursor + +/* Projectile.js ─ 2 draw calls (1 without trail) ─ parent ─ canonical */ +FlightMode={LINE,ARC,ROLL,FALL,HOMING,LISSAJOUS,VOLLEY} // string enum +Stagger={AUTO,NONE,RIPPLE,HASH}; 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 +b.count · drawCalls · trailUniforms +b.arrivals:Int32Array · b.arrivalCount // crossed tau=1 THIS frame — read straight after update() +b.contact:Vector3 · b.contactLoad // ROLL -> GroundField(RUT) +b.roll(seed=Math.random()*100) · reset() · setBasis(origin,direction,side,length) +b.setTrailColors(a,b,c,d) · syncGeometry() · update(now,params) +b.landPoint(i,out) · pointAt(i,tau,out) · headingAt(i,tau,out) · slotPosition(slot,out) · dispose() + +/* Swarm.js ─ 1 draw call ─ parent ─ canonical */ +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}) +s.count · drawCalls · uniforms +s.roll(seed=Math.random()*100) · reset() · setBasis(origin,direction,side,length) +s.setColors(a,b,c,d) // '#rrggbb' or THREE.Color, memoised +s.update(_now,params) // FIRST ARGUMENT IGNORED +s.leadPoint(out) · dispose() + +/* Tube.js ─ 3 draw calls ─ .group ─ PREFIXED */ +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) · tubeSchema(prefix,path) · createTubeMaterial(layer,path) +new Tube({path=TubePath.STRAIGHT,prefix='tube',nodes=96,sides=26,renderOrder=11}) +t.group · materials{core,sheath,halo} · meshes · keys · geometry · drawCalls · visible(get/set) +t.sync(c, state, g=settings.global) + // state={origin,target,side,progress,fade,widthFade,seed,time,grow,snapAge} +t.radiusAt(tau)->metres // THE profile fn — dust skirts/scour use this, never their own maths +t.pointAt(t,out) · tangentAt(t,out) · span · skirtRadius · mouthRadius +t.tipPoint:Vector3 · tipSpeed:m/s · waveSpeed:m/s +t.crack={fired,point,speed,at} // recomputed by sync(); poll IMMEDIATELY after +t.dispose() + +/* Shell.js ─ 1 draw call ─ .group ─ PREFIXED */ +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) · shellSchema(prefix,mode) · createShellMaterial(mode) +export { BurstMode, BurstSystem } from '../effects/BurstSphere.js' // re-exports +new Shell({mode=ShellMode.DOME,prefix='shell',nodes=48,sides=48,rings=24,segments=96, + renderOrder=14}) +s.group · mesh · material · keys · geometry · drawCalls · visible(get/set) · instanceCount +s.sync(c, state, g=settings.global) // state={origin,axis,side,span,t,fade,seed} +s.radius · span · nodeSpacing · nodeCount +s.standingAt(u) · nodePosition(i,out) · resonantSpacing(n) · dispose() + +/* VolumeHull.js ─ 1 draw call ─ .mesh ─ PREFIXED */ +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} +HULL_NAMES · MEDIUM_NAMES · VOLUME_HULL_KEYS · VOLUME_SAMPLE_BUDGET=20e6 +volumeHullDefaults(prefix,medium=Medium.SMOKE,overrides={}) · volumeHullSchema(prefix,{label,only}) +disposeVolumeHullGeometry() // app teardown; unit hulls are shared +new VolumeHull({hull,medium,prefix='volume',maxSteps=48,shadow,additive=false, + renderOrder=12,seed}) +h.mesh · material · steps · shadowTaps · hull · medium · prefix +h.place(position,direction=null)->this // yaw only; hull local +Z is the heading +h.setSize(x,y=x,z=x)->this // HALF-EXTENTS in metres, EVERY frame +h.setFade(k)->this // 0 hides the mesh +h.sync(c,g)->this · cost(coveredPixels) · dispose() + // scale with setSize(), NEVER mesh.scale, or the march's t stops meaning metres + // reads settings.global.volumeQuality (defaults to 1 if absent) + +/* Distortion.js ─ 1 draw call per emitter ─ .object3D ─ canonical */ +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}) +d.object3D · visible(get/set) // retains/releases the writer counter +d.setAnchor(v3) · setAnchorXYZ(x,y,z) · setBasis(along,up) · update(p) · dispose() + // magnitudes are SCREEN FRACTIONS, not metres. NEVER multiply global.distortion or + // post.distortion into strength — the pass applies both, once. + // Toggle field.visible; hiding the parent group leaks the writer for the session. + +/* Portal.js ─ 1 draw call ─ .object3D ─ canonical */ +new Portal({billboard=false,writeDepth=false,renderOrder=6,name='Portal'}) +p.object3D · visible(get/set) · setPlacement(anchor,along,up) · update(p) · dispose() + +/* LiquidSurface.js ─ 1 draw call ─ .object3D ─ canonical */ +LiquidMode={POOL:0,WAVE:1}; liquidParams()->object +new LiquidSurface({segments=96,mode=LiquidMode.POOL,depthWrite=true,doubleSide=true, + renderOrder=3,name='LiquidSurface'}) +s.object3D · uniforms · drawCalls · visible(get/set) · 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() on that frame +s.clearRipples() · reset() · update(now,p) +s.lipPosition(p,out,across=0) · lipHeight(p,across=0) · dispose() + // 8 ripple slots, oldest evicted. Fill-heavy: one per screen. + +/* Curtain.js ─ 1 draw call (2 with floor) ─ .object3D ─ canonical */ +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) · uniforms · drawCalls · instanceCount +c.visible(get/set) · mode(get/set) · layout(get/set) +c.setPlacement(anchor,along,up) · roll(seed=Math.random()*100) · reset() +c.update(_now,p) // FIRST ARGUMENT IGNORED +c.sheetPoint(index,p,out,across=0,height=0) · dispose() + // alphaCurve must differ from emissionCurve (2.4 vs 0.7) or it is a hanging ribbon + // stormwall passes the cast's SIDE vector as `along`; shafts pass -frame.uLightDir as `up` + +/* 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 + +/* 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 ─ 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 ─ 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 ─ 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 ─ 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 ─ 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 ─ 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 ─ 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 ─ 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() + +/* quads.js ─ no renderer ─ the two unit quads every quad-backed module draws on */ +acquireGroundQuad()->PlaneGeometry // 1x1 in XZ, normal +Y. Refcounted; releaseGroundQuad() in dispose() +releaseGroundQuad() · groundQuadRefs() +uprightQuad()->PlaneGeometry // 1x1 in XY, normal +Z. NOT refcounted, never disposed +disposeQuads() // teardown only + // neither carries a metre — scale the mesh, never the buffer + +/* prefixedBlock.js ─ NEW, no renderer ─ only needed for a new two-instance module */ +num(value,fallback) · str(value,fallback) · prefixed(prefix,name) +buildKeys(fieldNames,prefix) · buildDefaults(fieldNames,fields,tuning,prefix,overrides={}) +auditBlock(label,keys,fieldNames,block,remedy)->string[] +``` + +## What the ability agents must know + +1. **`npm run check` is the only gate that compiles `src/vfx/`.** A green `npm run build` says nothing about these modules — until an ability imports one, it is not in the bundle graph and a syntax error in it builds perfectly. +2. **Add a case to `VFX_CASES` in `scripts/check.mjs` if you add a class to `src/vfx/`** — the coverage rule fails the check otherwise. +3. **No backticks in GLSL comments.** This has now bitten four agents. +4. **Reserved words are the GLSL ES 3.00 list, not the 1.00 one.** three prepends `#version 300 es` to every non-`RawShaderMaterial`, so: `packed` is **fine** (illegal in 1.00, legal in 3.00) and `patch` is **fatal** (the reverse) — a `float patch` took the whole ground material out of the frame this pass. The ones you will actually reach for: `patch`, `sample`, `filter`, `input`, `output`, `flat`, `smooth`, `layout`, `common`, `active`, `this`, `interface`. Shadowing a built-in *function* with a local is fine (`float round = …`); defining a function with a built-in's name is not. **`npm run check` now sweeps every `/* glsl */` block for this.** Uniform arrays are still indexable only by a loop counter. +5. **`commonGLSL` does not compile in a vertex shader** — it carries `aastep`, which calls `fwidth`. Inject **`commonVertexGLSL`** from `shaders/lib/common.glsl.js` instead: the same chunk minus `aastep` and `softFade`, separate include guard, safe to spread alongside `commonGLSL` in the other stage. +6. **Patched `MeshStandardMaterial`s must park uniforms on `material.userData.uniforms`** (I8) or the pause test reports your working sliders as dead. +7. **Give borrowed globals back.** `sceneHooks.acquire()` and `timeField.acquire()` take a piece of the world. Wrap them in `this.borrow(...)` and the base class returns them on every path a cast can end by — including the player pressing **C**, a fifth cast pushing yours off the concurrency cap, and teardown. `timeField.acquire()` **returns `null`** when all four slots are taken, on exactly the `ctx.lights.acquire()` contract: guard it, and read acceptably with none. +8. Budget guide (I7 ≤ 12): beam `Tube` = 3 · tornado `Tube(FUNNEL)+GroundField(SCOUR)+Swarm` = 5 · chained bolt `FilamentPaths+ArcNetwork` sharing a strip = **2** · flooded lane `LiquidSurface+Caustics` bound to it `+GroundField(WET)` = 3 · hive dome `PlateShell+ColonySwarm+WebGraph` = 4. Three costs are not draw calls: a **`Mirror`** is one extra nested `renderer.render()` of `LAYER.WORLD` (capped at two a frame by `mirrorBudget`), a **`Caustics(SCROLL)`** is fill-bound at ~100 hashes a pixel, and a **`GhostRig`** is ~70 `Object3D`s of skeleton per ghost. +9. **Two shared quads, not five.** `vfx/quads.js` owns them. A new flat effect calls `acquireGroundQuad()`/`releaseGroundQuad()`; a new upright one calls `uprightQuad()`. +10. Still open, and not owned by anyone: `settings.global.volumeQuality` is read by `VolumeHull` but has no slider; `frame.uSceneColor` does not exist yet (adding it gives every `ShatterField` refraction for free — but must not point at the composer's write target, which is a feedback loop); bloom still bleeds across a `Hook.HOLE` rim; and the three modules that read `frame.uTime` in-shader (`Swarm`, `Curtain`, `VolumeHull`) are not reachable by a time region until one of them injects `timeWarpGLSL`. diff --git a/docs/VFX_API_II.md b/docs/VFX_API_II.md new file mode 100644 index 0000000..43cca73 --- /dev/null +++ b/docs/VFX_API_II.md @@ -0,0 +1,172 @@ +# The VFX tech library, part II — the twelve modules added for schools X–XV + +Read alongside `docs/VFX_API.md` (the original fourteen). Signatures below were read back off the +source by the integration pass, not taken from the authoring agents' reports. + +FINAL AUTHORITATIVE API SUMMARY — the ten new modules + +Read back off the source and mechanically verified. Full text in `docs/VFX_API.md` and `src/vfx/README.md` § API reference. + +### `src/vfx/SceneHooks.js` — 0 draw calls (2 while `HOLE` is held) · singleton · tokens +```js +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(h) · driver(h) · heldCount · reclaim(owner) · releaseAll() · observe(material) +sceneHooks.gravityAt(x,y,z) · disruptAt(x,y,z) · ageAt(x,z) // CPU mirrors of the GLSL +token.blend(0..1) · hold() · release() · token.driving/active +KEY_LIGHT t.aim(az,el)·tint(c)·brightness(i) GRADE t.saturate·temper·raise·darken +AGE t.at|atPoint · field(radius,edge,amount,inner=0) · wear(rust,dust,moss,pit,bleach) · scale(m) · colours(r,d,m) +HOLE t.at|atPoint · size(radius,squash=1) GRAVITY t.at|atPoint · well(r,edge=.25) · scale(inside,outside=1) +DISRUPT t.at|atPoint · region(r,edge=.35) · power(drain,fracture,dim) · shardSize(px) +// opt in: sharedUniforms({ ...disruptUniforms() }) + ${disruptGLSL}; +// VS: vDisrupt = disruptAt(worldPos); FS: disruptShade(colour, alpha, vDisrupt, gl_FragCoord.xy); +``` +**The one rule:** `this.borrow(sceneHooks.acquire(hook, this))`. One token per (hook, owner) — re-acquiring renews. Two owners resolve LIFO. Never call `install`/`apply`/`uninstall`; they are `App`'s. + +### `src/vfx/TimeControl.js` — 0 (the field) / 1 per ghost · pool + parent · canonical +```js +import { timeField, TimeRegion, MAX_TIME_REGIONS, timeRegionParams, TimeRecorder, recorderParams, + MAX_TRACK_SAMPLES, MAX_TRACK_BONES, GhostRig, createGhostMaterial, ghostLook, + applyGhostLook, findCaster, TimeWarpClock, RewindGate, reverseTime, reverseRate, + reverseParams } from '../../vfx/TimeControl.js'; +import { timeWarpGLSL } from '../../shaders/lib/timewarp.glsl.js'; +timeField.acquire(now?) -> TimeRegion | null // NULL when all four slots are taken +timeField.release(r) · reset() · liveCount · clockAt(clock,v3) · weightAt(v3) +region.lock(now?) · place(v3) · placeXYZ · sync(p) · weightAt(v3) · release() · isLive · hold +// p = { radius m, strength 0..1, core 0..1, rate } rate 0 stasis · -1 rewind · 0.25 slow · 1 identity +// shader: ${timeWarpGLSL} float t = warpedTime(uTime, vWorldPos) - uBirth; +// float held = timeRegionWeight(vWorldPos); +new TimeRecorder({ capacity=120, bones=MAX_TRACK_BONES }) + .attach(src)·detach()·clear()·sample(now,p)·trim(now,p)·transformAt(t,pos,quat)·poseAt(t,ghost) +new GhostRig(parent,{layer,renderOrder,material}) .setSource(src) /* ALLOCATES — I3 exception */ + .place(pos,heading=0)·setScale(s)·sync(look)·visible·drawCalls(1)·dispose() +new TimeWarpClock(start=0).advance(dt,rate,floor,ceiling) · emitDt · spanDt · reversing · stalled +``` +**The one rule:** `acquire()` can return `null` — guard it (I6), and `this.borrow()` it. A shader whose *position* depends on its clock cannot use `warpedTime` (feedback loop); probe each slot at the position the body had when that slot locked, as `ParticleSystem` does. + +### `src/vfx/Caustics.js` — 1 draw call · parent · canonical +```js +new Caustics(parent, { source=CausticSource.SCROLL, shape=CausticShape.DISC, custom='', + uniforms=null, additive=true, depthTest=true, layer, renderOrder=7, name }) +CausticSource={SCROLL:0,WAVE:1,CUSTOM:2} CausticShape={DISC:0,CONE:1,LANE:2} CAUSTIC_RIPPLE_SLOTS=8 +c.object3D · drawCalls(1) · boundCount · setVisible(v) +c.bindSource(liquid.uniforms, keys=CAUSTIC_BOUND_KEYS) · unbindSource() +c.ripple(u,v,strength=1,now=0) · clearRipples() // no-ops while uRipples is bound +c.reset() · update(p) /* NO CLOCK */ · setAdditive(b) · dispose() +``` +**The one rule:** `bindSource(liquid.uniforms)` and the wave and its light on the floor become one set of numbers — and `update()` then skips every bound key, because a number with two authors has none. `SCROLL` is fill-heavy (~100 hashes/px): one per screen. + +### `src/vfx/LightShaft.js` — 1 draw call · parent · canonical +```js +new LightShaft(parent, { capacity=6, layout=ShaftLayout.SINGLE, sides=14, maxSteps=48, + layer, renderOrder=10, name }) +ShaftLayout = { SINGLE:0, LINE:1, RING:2, SCATTER:3 } +s.object3D · drawCalls(1) · instanceCount · layout(get/set) · visible(get/set) +s.setPlacement(anchor, along, up) · roll(seed) · reset() · update(p) /* NO CLOCK */ +s.footPoint(i,p,out) · mouthPoint(i,p,out) · irradianceAt(point,p,out=null) -> 0..1 · dispose() +``` +**The one rule:** `irradianceAt()` is how the scene's own dust joins in — multiply it into your existing particles and set `mote: 0`. Second most expensive fragment in the library after `VolumeHull`; `steps` is the slider, `maxSteps` the compile-time cap. + +### `src/vfx/LensFlare.js` — 1 draw call · `.object3D` · canonical +```js +new LensFlare({ ghosts=8, renderOrder=3000, layer=LAYER.VFX, name }) // ghosts = CAPACITY +FlareRole={CORE:0,STREAK:1,RING:2,GHOST:3} MAX_FLARE_GHOSTS=8 lensFlareParams() +f.object3D · drawCalls(1) · capacity · visible(get/set) +f.setAnchor(v3) · setAnchorXYZ(x,y,z) · anchor(out?) · update(p) /* NO CLOCK */ · dispose() +``` +**The one rule:** with `frame.uSceneDepth` unbound, occlusion is forced to 0 and the flare never appears — the inverse of README trap 7, and correct in the app. + +### `src/vfx/Mirror.js` — 1 draw call **+ one nested `renderer.render()`** · `.object3D` · canonical +```js +new Mirror({ resolution=384, layer=LAYER.VFX, reflectLayer=LAYER.WORLD, renderOrder=4, + doubleSided=true, depthWrite=false, name }) +mirrorParams() · mirrorBudget={max:2,live,rendered,skipped,calls,triangles} · setMirrorBudget(n) +m.object3D · drawCalls(1) · resolution · visible(get/set) · priority · lastCalls · lastTriangles +m.setPlacement(anchor, normal, along) · update(p) /* NO CLOCK */ · dispose() +``` +**The one rule:** the reflection is a full extra traversal of `LAYER.WORLD`, driven from the mesh's own `onBeforeRender` — **no visible mirror, no pass**. Hard cap of two rendering mirrors a frame. `renderer.info` is contaminated on a mirror frame; read `mirrorBudget`/`lastCalls` instead. + +### `src/vfx/BrushStroke.js` — 1 draw call · `.object3D` · canonical +```js +new BrushStroke(parent, { strokes=6, bristles=14, samples=40, sides=6, tip=BrushTip.FLAT, + depthWrite=false, layer, renderOrder=7, name }) +BrushTip = { FLAT:0, ROUND:1, SPLIT:2 } +b.object3D·uniforms·drawCalls(1)·count·strokeCount·tip · setStrokeCount(n)·retip(t)·reset() +b.stroke(i) -> { curve(p0,p1,p2,p3) · line(from,to,bow=0,lift=0) · + pressure(entry,swell,hold,exit) · ink(load) · timing(start,span) · active · seed } +b.setPaper(normal)·setColors(a,b,c,d)·roll(seed)·update(_now, p) // FIRST ARG IGNORED +b.pointAt·tangentAt·headOf·tipPoint·pressureOf·widthAt·dispose() +``` +**The one rule:** `p.progress` is the only beat — `update()` ignores its clock (the `Swarm`/`Curtain` precedent). Every bristle spends its own ink load, so the stroke dries from the outside in. + +### `src/vfx/InkDiffusion.js` — 1 draw call · `.object3D` · canonical +```js +new InkDiffusion(parent, { mode=InkMode.BLOOM, sources=4, satellites=16, layer, renderOrder=6, name }) +InkMode = { BLOOM:0, SPLATTER:1, WASH:2 } // a #define, fixed at construction +k.object3D·uniforms·drawCalls(1)·age·setVisible(v)·setPlacement(anchor,along)·roll(seed)·reset() +k.update(now, p) // now = the ability's age +k.frontRadius(i=0)·sourcePoint(i,out)·satelliteSize(i)·satelliteReach(i)·satellitePoint(i,out)·satelliteAge(i) +``` +**The one rule:** the **anti-glow contract** — hard linear-luminance ceiling 0.62 against the 0.88 bloom threshold, `toneMapped: true`, no `uGlobalGlow`, no additive path. Ink is the one school that must not glow, and it is enforced rather than trusted. `satellitePoint(i)` matches the shader exactly, for projectiles. + +### `src/vfx/FoldMesh.js` — 1 draw call · parent · canonical +```js +new FoldMesh(parent, { pattern=FoldPattern.CRANE, layout=FoldLayout.LINE, capacity=32, + segments=20, segmentsV=segments, renderOrder=4, layer=LAYER.WORLD, name }) +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_PATTERNS +m.uniforms·count·drawCalls(1)·visible·layout(get/set)·setPattern(p) +m.setColors(paper,shade,transmit,ink,crease)·setBasis(origin,direction,side,length)·reset() +m.update(_now, p) // FIRST ARG IGNORED — p.fold is the beat +m.sheetPoint(index,p,out)·spoolPoint(index,p,out)·dispose() +``` +**The one rule:** nothing interpolates a position — each crease is a **rigid motion per material point**, so the paper never stretches. Crease tables are authored root-first and walked backwards; `UNROLL` places by arc length. + +### `src/vfx/Dissolve.js` — 0 (patch) / 1 (heap) · parent · canonical +```js +patchDissolveMaterial(material, { mode=DissolveMode.VOXEL, space=DissolveSpace.LOCAL, + uniforms=null, environment=null, vertex='', fragment='' }) +dissolveUniforms(overrides={}) · syncDissolve(target, p) /* every frame */ · dissolveParams() +dissolveSchema(label='Dissolve') · DissolveMode={VOXEL:0,GRANULAR:1,EROSION:2} · DissolveSpace={LOCAL:0,WORLD:1} +new DissolveField(parent, { along=72, across=40, renderOrder=3, layer=LAYER.WORLD, name }) +d.uniforms·drawCalls(1)·visible·setBasis(origin,direction,side,length)·setColors(fresh,settled,face,deep) +d.reset()·update(now, p)·frontPoint(now,p,out)·crestHeight(now,p)·dispose() +``` +**The one rule:** several meshes dissolving as one event **share the uniform box by identity** — `dissolveUniforms()` once, passed to every `patchDissolveMaterial`. Cloning it gives you two events that drift. `WORLD` space never on a projectile. + +### `src/vfx/Colony.js` — 1 / 2 / 1 / 1 · parent · canonical +```js +new ColonySwarm(parent, {…Swarm options}) // EXTENDS Swarm; splices its vertex shader +c.update(_now, p) // FIRST ARG IGNORED; calls super.update() first · c.shapeCentre(out) +ColonyShape = { BALL:0, WALL:1, SPEAR:2, FIST:3, RING:4, COLUMN:5 } +new WebGraph(parent,{maxRings=8,maxSpokes=16,samples=10,filmSubdiv=2,additive=false,renderOrder=11}) +w.drawCalls(2)·count·roll·reset·setPlacement(anchor,normal,up)·update(_now,p) /* IGNORED */ +w.nodePoint(ring,spoke,p,out) // ring -1 is the hub; omits the per-node jitter, deliberately +new LatticeGrowth(parent,{capacity=192,sides=6,wall=.24,recess=.62,renderOrder=2,…}) +g.drawCalls(1)·count·reset·setPlacement(anchor,forward)·update(now,p)·cellPoint(i,p,out,height=1) +new PlateShell(parent,{capacity=64,renderOrder=2,castShadow=true}) +s.drawCalls(1)·count·tessellate(sites,seed,jitter)·update(now,p)·progress(now,p)·plateCentre(i,p,out) +``` +**The one rule:** blend the **fields**, not the points — `mix(fieldA, fieldB, k)` is a real shape at every `k`; a point crossfade's midpoint is a smear. `LatticeGrowth`/`PlateShell` take the ability's `age`; the other two ignore their clock. + +### `src/vfx/HardSurface.js` — 0 draw calls (a toolkit) · no attach · canonical +```js +gearShape/pistonShape/sawbladeShape/plateShape/boltShape/anvilShape(overrides) · hardShape(kind,o) +createGeometry(shape) · hardSurfaceGeometry(kind,shape) // unit space, seated on y=0 +gearPitchFraction(shape) · gearRootFraction(shape) +new ShapeCache({capacity=8}).get(slot,kind,shape) · changed · size · dispose() // per ability, NEVER shared +new GearTrain({capacity=12}) .plant(count,seed)·clear()·solve(p) /* every frame, dt=0 too */ + .teethOf·pitchRadiusOf·tipRadiusOf·scaleOf(i,pitchFraction=0)·angleOf·rateOf·yawOf·positionOf·contactOf +createHardSurfaceMaterial({environment=null,flatShading=false}) · syncHardSurfaceMaterial(m,p) +hardSurfaceParams() · heatToKelvin(heat,p) · blackbodyColor(K,out?) +new GrindContact().solve(contact,normal,rimVel,p).jet(index,emit) · GrindContact.rimVelocity(out,axis,rate,point,centre) +``` +**The one rule:** a **tooth count is a shape, not a transform** — gears with different `teeth` cannot share an `InstancedMesh`. A gear is ~8.7 ms to build: never speculatively, always through a `ShapeCache`. Uniforms are parked on `material.userData.uniforms` (I8). + +### Plus, new and shared +`src/vfx/quads.js` — `acquireGroundQuad()` / `releaseGroundQuad()` (XZ, refcounted) and `uprightQuad()` (XY, module-lifetime). Neither carries a metre: scale the mesh. +`shaders/lib/common.glsl.js` — **`commonVertexGLSL`** for vertex stages; `commonGLSL` stays fragment-only. +`utils/color.js` — **`putColor(target, value, fallback)`** for string-or-`Color`-or-missing params. +`abilities/Ability.js` — **`this.borrow(handle)`**; `destroy()` gives every borrowed global back. \ No newline at end of file From 1eba77f0346708c14c229fe77c3014f1d864620a Mon Sep 17 00:00:00 2001 From: Majid Manzarpour Date: Wed, 12 Aug 2026 00:28:38 -0400 Subject: [PATCH 6/9] docs: correct the licence section and the last of the stale README claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The repo is MIT, © mohamedachrefelouafi, and the README did not say so — it said "code is provided as-is", which is both wrong and uncredited. It now names the licence, links LICENSE, and says plainly whose work this is built on. The heading loses its British spelling because it is now naming the MIT License, which is a proper noun. - The editor section still listed the old flat folder list. It is fifteen school folders now, built on first open, with a search box that indexes the schemas so it can find controls in folders that have never been opened. - The performance notes did not mention that abilities load lazily, which changes when shaders compile: compileAsync at boot only sees what is in the scene at boot, so selection warms an ability rather than the cast. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 51 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 206cc5e..bebcab2 100644 --- a/README.md +++ b/README.md @@ -414,8 +414,8 @@ first, then the air breaks down over it. ### Adding another ability 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 `Editor.js` - edit any more, and no ability list in `settings.js`. + 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 @@ -494,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…). @@ -506,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. @@ -573,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 @@ -623,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. From 2b7d6e397ba59a8c1dd67ccb9faaaf4c5be01154 Mon Sep 17 00:00:00 2001 From: Majid Manzarpour Date: Fri, 14 Aug 2026 00:28:39 -0400 Subject: [PATCH 7/9] Cleanup root --- icecast.jpg | Bin 203151 -> 0 bytes superbeam.jpg | Bin 45915 -> 0 bytes thundercast.jpg | Bin 146639 -> 0 bytes 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 icecast.jpg delete mode 100644 superbeam.jpg delete mode 100644 thundercast.jpg diff --git a/icecast.jpg b/icecast.jpg deleted file mode 100644 index d77d39399206affeb8feb0ee5d25fa72ca15ac8f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 203151 zcmce7XH-*Nx9$#Iiiig3ML-}RAQFlq5Y&X;LQ_ORP$2Z8RO!~xJ5oalO##KwM4B2v z6oF8bfYL#F@9l2X_x;}Q+;ysywxOavLQQ=H%EZ6~{U84wGyp7g6g||JDIo#?1q+0d z1#<8K-~#{%H8|VD3IF+nP=Ir!p`|-Q&j3~^KL$`hC@CqZC=X{1*7gVA15_;3$4^PC z(ws23MJwP2Jr^94MJIUaRU<39b5Tgf%01)={Yf@<4$jlUBBC%cxU8JK!uboAuOLxs z>KdAcM#d(lSFf2_-@apGYlpG-xO>mj%iG5{^ufbNkDoln#>T}bBqk+4OUcg3#pmT0 z6c&}eCX`oHR#m@w_x=O1sk!B2E2*oyr}yi(KJxI$=-BwgMmn3;a^DP*I5-HMf)Y$e^0QG|0l`*7VKYgkpV_Z2)KBZEPx8IOUh1X)N`%L zM#Xr97qk>oC%nHKVzDzssw%mR)L9pg+S*9k--OrSsy+ZReF7Ikk?+Q!MhC#(Wwme4 zfs;SL7M-ru)T}Chm)03Fe>XLkR7L9TW!^ggl)C~4D-Hm9mDlt2lf8jE>x098dh6S; zSea4T+i_XMbNY8407;noh5o>YYzzrM3lD%_)m;v&tF8Ng-Q8|K00R1VMXSt1u$eo` zGyDFw2SB~kJ~Ytwdtm#E17KsW{$1F^7SZQ?eP1nB=Hw6i&E0Q*14?l1PruOyh6lhu zsweGSSoqzK?P15I$gBHbY7Xlk01JJ;rd)CW{F-wh*mdiwan$Tg{nlFM{$A!{&abvM zzvo~sX`1~a(B}IA@YhVDX8V8FtlTtkt-gtUwU_Dz>f-eQ@JAVc^?ay}abL0Kb*I6< zWP}C2mQtA{fvWm7Y`1ad?5Fmv)nCKr?zjCl!#}Dzls9l?=9j#9&O5_Xm|rT*9(FHh zwcR$A1C#o)(!j|t zfjg^*3t+skyEPE_=cQQZpO>Tid-9tGdkKHKnL1p6RzhHA@wNR=Z$Uk3Mg4AlV{&)f z{IAwRkG6y&1l~{;MHGsT$NTabpt)qDZMX`!ec{2(qiOU(7_ZtF^R#FTNtMUAXxeUSTgbt(c7B(t7Oc)eq#X~hHuBFJMfpJ>CJmP_L6MEJ5-q~ zr2Yed!ukLhDfqWi5#Mah;MPjjp@P&7+aC&90jkdBrN#lEBvHR8tgw!bsfxE_h|9HLvPaQn~Bu)7? z<_h*(>OsN857)_DQfvK)$|SyWpQ`x)AR7F$U}Vw9b^xr5|C&!|?jZA)$FJ=Gj#B@R zp8q)taxW$jl==U8l(-$So&EdsDhvG}G{}DwIsh^f2RBdf9RRFq|5nmJ0Pg&?Bo>ED z`&;MQ|5+UQKiW-+`d1T#dD1^uhHhw(b8OrTjmQlC}ST+Rdg6asLJt|0m%1 zW%!49^Lt?o|L0Bg|Jz8TN?K6ctv*B=-0lC{PFCG}_6JqLo&5Q4#DS>%hebKuE{72R z%U}$02j#UCLjq?~4PPGL`zKQVyZA=P;W-nZlL~}SeL@}&uLOUR?|`&n>@$zvzm}D& zSFmYe_VSwH=ZF%*R?gA4mJzMz&QFWJ7D*9Db*BE)jQ-uGTymyaVmX_}-DXbE+pgdF zgR6x-&Wd?@|MymWeMy-sQ{d@k@3oHHKeEI0MLKOuXP;HWoJ;*bY+qE8Z{YZs(Wq@3 zaA*D1!;W<<#>ukknr-E!&kq9j zm=XSa(-WJIci&zIkJ8lxfO8`7wKK>eNG^fj`M~q+9LP5gk7JN;Q0A=&fEE!JxU-)* zs`iX+clRG{BoEvCA^=gdpX#?(z_Xuk5qC88bJ%`>1jtUd10#5ETdXX(8~}k`yAIFx zKi7bLod@4^{^Bhle>gn41K$Ok|GZOIQOT$D*=O}~Rc4+zpFuAZiJ%|KtybvrcF;m~ z&KP7HG=UwHzh?y!2#|<@hu=b%KASj*OuBZ@RsPcI;fX8>TF7BtR8aNxVh4=xPG14m z^3G%2WI~ZepVx0i{;IbEp4`8f2u|{zfx}6|@TVQTRRJoQ^>CdR*BWxm-&K~Sf7mx} zJpgQOTJI}~G!txhVJfd(X!&ku@@9Y(jGkhA&eX!M$1aOTRPQFSt}Y>l1oFIpv8=*h zJn1*3M3@&NLDo^LvOKA>x2*zFmOrO?&R=gp{eXw|s@>n`_o^L3LIEh8SN-qPD1z@_ zkC0#F=@1S6=w<)&A)z_c3rM?uzx^k*vbh=9p8K1FCcGQB7i(X?19Bd{-^AtL+Jfx1 z;5n^s&CQB`aY2w6lYcSYKUDDE*WDeRKW%>z(ti=-KWy)pVt-RmTW~Qz8vIWRI(W#1 z|K_THGtl&ZuyMWFozh=S_SajG!vB+F|6$F)g=J&-ue3Spf7I4Q<#oxc1AzCB8}Mu3 z_0;{nzXtv$`P-A|aCBDNt@(5S+~<^JG64d_y(0)dAM2!={o z{wP8GS#Dl2V@E8Y6H1CY<4f;3eW@7=1S6)fo}Jpv?V6r;kV}*0uqV6(td7b(K;|QF zceYYp{I)1SWY32Xg~1tNsU9%Jvcu=% zDD5_K>^8Eoz>Rpvd;G#wU*l;HUBnd6+0YFX;2sU~?%$O7YZcct{jpQX?zqdq_km zIUT9a^iA}e(v%hpIZkA!+>(p77S#%wK_7P`<%q|%0rE@m6TzzN@CT%qOdbn5lf4Pi zysH^k*Wgzvh2uHp=-siF^3WmJ$XTSqJCrDE%OdPWb(_|>pT2byCR-Ellfx>0il;D_ z#n_UQ#(u8M$1&1Xb)zWAgY*K~NV%>5Q1E-bEzh|wqov;XBv+xQlay7XyW(>${h^I1 z*S(pqym|CpFqX?W;u_SylMv;*JF_SsCcIca%jVeyO%!fvF7{m@mXaUe*i0XuQ`D)s zt3Hc6MiH7snU>+ZzHRErd9f(IKk><8;PGpXRP760tZmR4EV<0hO;8|JLOCnaihxLnX5|KV%U$?nccTioqy_&l#cFAW-1Hm|_eS5u48-0uO}!3==PM zCLooA1i9(;tLOCa>_h-%3a!C3^I+ulDbaJzo(+X3)X{5sC+UfRrJM%vKEAUqw742t(9R0|WzBTJwUwC`7KSAUlW$3*ry`O+tK5F(5fWnms?K z2dYZ=f?)_LGG3p;3F&YQQFvFwTynro_TLG=BAZ*fTYc z9iAfSI2MG5Aao}ZfDV&Vb$LX8gB(4~GgDJ8Qxia(!}K$k3xvMO#nCt_cW1eMg-(kC zWR;d2yY=0I><|LyrxcyXWIPYd4Pkfr<&NRhW_Ab)Jp_UWMKBTyaKaF6lGK1)kc=)- z6i4$APm}=Rh~SieFT1FLpBsHRK_;@logj1?HKk3{iG;p@{J=`1U_k~bQ5;>eJ4PCv zKoe()qkF;ub!3I9sv6^61^Oc6i=N2};6#eG#;L~)86MD43}LHzvZ}-YSw{ME4yB8p zDM=fPjUwMYDwUrRF|2;vYql+^XX8{oc`i&84NOTLRps{mI&J$P@KzvXGiG6v|-vT1wbzPPFt4pXVMsu7badVsX&ET%tl|&hsBD^@eyDWWdpB_8u zsX$S5`3OzLDtG_%S#{d&3a0JeXtQVS!?wmFDi3@QfXBNsw?>#Q5sO#*C+=20OTN`g zE!BKezI4)Ds3KLD1W|hRqwd*K{fCeHqW2XS2OnE?i7@A#d1s;;1M{R}ZSnUF){Pdcx+d;lM|7 z7mkMAzFoB>j5?(wZw(5v(RQCaHD%s*b^bv$_JuT}nN|8mPyX=2(Hq`j z29KIL83wc)S&VMK5&~%JbgVi%KgUFM8>*M|k6641IrPJQ>7M&CMR2sm6lqHaE>TSx z+(JvKFlFum+9FPO?7AB{4!RPf@iCj2h4Vvq6%bEFWQX>KgD0Z`1Vs(VJ0pduHJV`3 z2u3o@bI0%%)_g~w@)C)cFiqD6ZbffgCjv61)32Kybb=erg1vW&14q-TRS}yNWRPZ{ zjs!#uRoQWjoNgfOwFt*y*76TIKr%djT899{Ke!E=Cd(|mtz5oX4GUOHLBHpAwZpAx zf;b4B6}e5A{7?i_KMK|ziU;Jd%x0oMHdYiumshr=m+Kr4v=N*@v*$F0liqd{-uE8b z`l7U!`u74zxvB915PfHG&n=BJ}T1tCX-=sFLoLx?9S0N-gTcG zYJ7~*Rh#Ou$gx#^^<@WCYva7aQ>|B{6wgu2cwgJbT3)5=#0t4GC&-vVyY>N)*2(^T z+7^Ak=;!!aJ?*qI@7*x{ir8NFFyW8T5aS@RfCtAg@ES*D#aQu~!2U>b6EmCtBeKHV z8Sm^0tG6-Z@33tB`1?7|``W?vVM-48OP|LgOl9URzDd3FWFzl-dAXkc5G@pZ64_#& zMH{+lO1pE*`@*NjeU+*>-KUn}H3^Z@1pbdxQL2XfM3v)XopnM#x1ARf_ir5?SYwLW zok`j&4mf>%v#Z&(t6#z4PAu}0(m6Mio?MSyvPS0l&94^S_mKrkKk#0%-@8N&g-WhQ z1NzEAkOSariSbwd-o!$;3C-qG+4MB*SX}i~<242shzF@QbZEeokGXt~t!d-U>9+lt z{F_4Iml8B?(B0G792#SO_qMFTGE&CEN&Z7A&E*}5t9D1coVPQdoryJzddv3-$*4AI zN@xDA7FBFL+Wv%B_Xf%p^578 zoL7As7|&D9qdI~V>FVFb71QaQpazESAxX;J@rK9#vN{8;Pz?k<4F;x46fpzn+TdLU zg79Rf>~PMrc2(5u*&*K9L(m5{iP&g%lxRK+XlX&m?%~$(O)zHRAWmNrI_5XIphNOc zr1?ODSP(Rw2D2N5n0TOx3&R!*o2_}%8T1x}Oh!)3jILA_i6sSaIt}uH_q|xO^dDv- zco&~52nWcpexzIwJy;JH-a(0Z;i1C9y&Fe6kVwHtArELKvWGI9e<%a~gn zg5)v`*3FXgob^^bdITG*`~IEa{^_#7uVB^ySL{r^{z8wAaxYctH~(6s2`LfoM0M&m z+4UnEBl>moA#)m$cGUt-IZi5eId<7Y{6hwM409+ZHin4Cd@<5UMj?@nA-o@P6Y~vE zuR0R;@WVHM(Qp1@H~mE?NnzMARsTXy&OrDO!CX?>l+Vm_peqPZWaDX~Ud@MK_0Q*1 zXAiq2=kRv#Rp&pzyP1bGqDL|-r&P_+RV6Y|t8}b>HQZcM9vr$*8YNa@+_H|nk)4T^ zM!p+G;K{H?%r~%oDv}+#tIq~jgiCN?cI^5!N2=QL!i|DF(7{B-hpqjXMmNK`ja-4S z$g%5scrIqVR+PQ;tX)-!G;{;Hm*1PfXTmsG1H$4jW#t>%LpVA%Z(y*Yi76Fc){j2%lL2#&Fb)-f`bP4G6M;)p{ZpWaKoIrFJpv#@W3&c60t*ZHyyOU)lnYiq# zHDa7{Fom9bqW#E*N7#V(GpMc^WWz0voKG8t6kQcP0yiHhTT~Ygw+^8lCo%|~QfTrG z_-)72dIg0OJ zm<$kJ(%vrr5ZflGzVPvkM+&jZN3iv!n3|)>{W7-6VtH{EP6v06e!sXG{i1o<3x#Lg z716M7gI+OLUuW5gAzz2MdfZTXSpJmYuvsE;Tl`!l(^Q5^V&H|z-37u~YBy{*NkZ7C zIWc@z4`2=uo0`96yp#W31qh(-+wUOT)E0Ty=vP7uG2`Hno;GG zM%M0P{_wN}v@VSUe!~`evUgx~Rf>K3CTox58jsv?vDAyP6RKja2Hj(PUy5e-1*>{X zY~EztSpH1xpVrl}){z`FPH)%#s`zY>6QdZB@Xi@i3z zKRpBZHvJgTPA`?< zN1pqiU z=+nSHpo=EJ9p8y|QjP~dC2WGZWHFt(?&LN_&<>!kFwkDTrwm=IdC78JuTY{@^Jhz; z$%S7gN4 zBsnQM34;cRVb7Z@)H!^5vZStFNI#KE@HKFgdxDMZPA2LFs~h?13$^YbxsW=b-!2*y ztV0&_qU3=kXAh}W#Nw5}hhaB@;o-E*Z_)OqqDNBHK;Sut4S>~v=BY{7CP}6{99)pq z)QF=C43f`;9RPx+-bH4moc9Lz+ylqJYAe$)SB%(WSC9k0jQn)y5NG~&h;L9dPdm^y z=3FrU?I8qx#w zJZT}S7dD$waCc)xIrx$mG*8eLYY_CI`np*luFPdeWCwq<(}%g~f&qmy^6Yp@cop6N z(I6p7HUqtBQl~Ci6ha`>AOWkO6Hb7K?k)_0qaw27$)ey3X@r?8dp>m=1t+!SpnbF` z6sLbqDW4R7s?bgIWXL^%Bkgb}vm_@;GQ|f~#FQQ-MH-aeTwjrqC`qjdrN&u%8jN?X zcUA;nwHZLq!RH80I55fx(%*nC1S9Ag?C5aWL2B&!^@w~A0U0M@a=fIU!OLxZs_Pgv zR^j(kT4g~3E%-t?(!+J!WuRqBt0MR6=7QGvPRBWkdd*8Yvhqm_sCHj!cc$!kMx%bE zpUZncSz8JU-@cl*TW&7INnXa@C>M?5U7f4zhN@OX)U7EG+UpY_7B011_DzGnayuPV z>^ZE;|J&1gE>YJIJ}By|FCJqRQ7#p)Bq|*V2HDheT*|e^qs%UX129C-#gMTEO zW)M1>EIUK>K*_ADc?a#~c8owMN0>*k&!J#rXpb4)u?jF)C#|MTz@0F9CSP^5+_QJ^ z^Bs{aSpzlA4uT`gSAmMi7f*}fN-jfz@47c5MwL|5e0u37qs7&~Lpira*2DM0o%T64 zJ-duL@G0vQsm|=d%O6))Wx-(bdzX`X!~X6+)?wN<@{C8_S|;lHmNMt^v%FJ>p($6^ zBP3sW&?bm0(^KxPHQzUNuF&DtnI$LgF}FvUwT)mPpPZ(fi*F-ucPaN!KmAF4msjZR z_2(bQ%&D!^ji=N?ONxtkRc}drdf1Uj3fNVCD&GH z@r z@U9sVerSt#NO@6NAs09;=zjEv^ybn{125@>scJJhnY`MWc=Jq)FWI6Rh3W2Y>L+;1 z+l9Y;I_5aGCR@W6Dr?&z@|8O6u||kdh*pW*neZdV+tIoEtkI?!x(=Q92A_5g?n-su z7rf)xyGZ_Yx~==+%KFo9(IuvQ?~)DLON-%J&XZ{@aUJ{ca2XQ5&+vNx#WLV|ij#S6 zYpTpQ5-ZUWOdz#1>^|@?nwQXYMKP&Kv_X4`OhLRsJz;?{@vDt(GR|a!F8q%l%^b(<}Asu4^v|Yf#nygfN%~W*pkz+UV5z5>{2H zeg9rTOCyo;1QaA<>~N5BM~8Q{Fh27`yc{BRqw3^2UO-(SBy21Yr1NMdrR*+FZ3W^}VhUw+o7R!)MNpW&ZEN;0v-n|SQ{R&kUmZaA6*qA;vYoyP9i@0HQ^ z7wy+VQl79|#W&KX?A3(U>7KO~Gw%&=c7`p#l0!H=1U-(k-071o>3PBP#Lx-ewHNXN+N4U!BD$7^=B@STapsydq;CpD z-=x`%#gf4hiwJnF+*Goe*14w@e90v~93}PZ_)S{OVi?izIwrsxQ+D>OPg+?PN2Ahy z`9MjOvSGv?^Rtqxue<4H^CPM4f0#WP7i7Jub#hiFQ_n)P^~;95j@jqOb1Op;lX(8< zmWDKcCX}_9tGDv%^W~S@oqp6YGb)t(6b>p}_wu_4uQ^%KZD;E5xkVlTPfARi_i5@D zb?fit{y?E8jxcHiqx_>6O-&cAM}O2Wnyve#)2sJPgy(Ew*)2yahs0^kmOj*pF0pj4 z3D_iJ!pkPJgeGO3KR2~|Bz?UId+zO)ql0=k(l#D($M_ljIM;k-$5$1&Q;ot%UC!pp z5~a>&v()2x_;t)!%2)Nu*J-Rfq^4XsVEUZz*LL!wrxLHrTI;S$841$hKj6icbgw(> zK@R{ap67S&?#&-NT9*Lp;C|FHHEMQEp3J zyteyQZjnDybj?+Sc-Y%ipg2Y}2OAh5 z?+Z0#$%s$e)~7_|Dq)&Kbp#TE6moZA4Za;HUjs`ntiBAYOY=fU0|si9hLKjMiBUI& zPm4lo{0qd1D$3mtY=Q*))lISfAcUF-E;r-3s8yV}>{u9z<(;%Y#b|%C;h9Zaqslt($0$h^O0rM4Rz#S+ad6?Y5!ep=jI8J=|Y2cJT^2$tPg6t`v zgGy0j4%LO`g?Z!XIMm^f@?6ymmJo~}fJTFmjH8*$JKxKQlaOLfh#8s20>Jikd#I?=n$%)getHaMLhitFxQ1_ z_}ws<(vviD$fI#wSXXVVwCxoYr_`Y!cbAFue&uzlu3X+uxmfKxZt~zWyPo_p= zkP6%eZV}m;pj*N+$I%AHCUPlqC56e-x?nboqaqz7^7+L8w0b=k&yWIu!=#6zm;iZ~ zT1i;{VGdCsT0)%-UL#6z(EuCJZm&a_r*uO}lQU>Ug~Ni#KpVSon$pjzBcKm4YZC#! z^aOjT2$9yNNyyCyW7ba4S(}6;Fkx7bLy-iv1hp0Cq)3y5$qR~%E>N<+R5gdbOy_e< zk**`{<7V+S9`Vs{Qrb?9jn6x#g(z3} z>}L$sGQ}hYKToZuGfr1hz|pg9QNG(JKJdL7?|ry<>;4@NUz2&%jS5xiCoL+{%GJ%h z38O0XYpD);EaZy^fQNU>XodI!Gg)P1J~VHazl-24IaF0!MTlrc42LkiwWaOuP3+;Q zSdePi{nYy+piNP7;FvFcQ#7k^4Uet-2KsE2;?t(iYo-@eADz4Xw2Oal7~AUh^|JlF z6K1c|PgE_KI!(NbtS;*6-*P{ugqc2@oh4&EHTZ3kSjWED9x3={N&G#}8(YjXFVWNB z{aE^jy&Tz0=FPYfp1r}xy{B$pt5yvPu)fLXa<>&t5UJxJy>Mr%8TJ%>@3$8~2?-L)*?sfl8rX*TMS6cO>9IOArH9B4A(UoX+DsC(?suxxKMg+@w;?-<>E z_Cm_~Ma}voLy&K`4lF>$(0C=qTdDASZ1mYSlv=SCs;!3&&aVJXkhyGdjzxC}&{xBY zavN4GT}yCRinknNbel01C_nQc+SgWLB*ih8YO%pDRQK}!CPVdn^f{1YLdFUXRDK&gQVnu+i|5QFU)^qMed?XS|Ar0V4jbq|1!cMjC5fF?yV zV4%Yk3cQoifiFvI9GcrSvr+*hJ{8lgPsst9(O#Z5nbOJC&0)OTq;1sQi`F^!q=ICX zOGe7y0py{N(qEIqJrFFNdg53*9Nl`CX4g+5aMoXxsDRZ3w80BIk2AV~ccPR$7#&Iw z$JOw18K&@5B-QklJxsZ{gm5FmJZv%VCFn^{sSh#g!&^L;jAdoP3xiGiBWal3~g^B!n!fCh6BkdqT1r*^}KLIJTv!Qu*kjJ!mcIEgXS6}@* za|ovIk=?x$nQwj3TcIzDD3rqe&|5a?6O%=x$4U>+a=Z*xMPFFOOZH9aHVwA{YL%pr zDHV#Ai1AZ)xZmq8GGTuF+UjQJzL{-GTM@-^qXU5HJW7p{l^@|}w7yRI zm>zwSvXE87UdvPb#g%ZZf!Vfa^4C}GX@X7FjjJ!3p2n|^5Hvrmh-9${ad!uQ8+E~J zKm{~dd?{Qt=B_Vpx(_@JB(c#|vn$z?P7w^d)@UBbpH@Ergv0nqp+?wUQFb^1~I-s$!8cRu>@3^w{} zT`F73OO<+Jq>+DwHYKV>pf+l~x<{GYetrA?KI7G#Buk9RsfW|F%K~*;*rn{9aMJs)F+}@#_k_x$`#n`;~0)4@}+F+#$Wl3|vq&(tv&~>V9H?tpSDqrt^e^yK% z=HKl6Bi*5TQ+Bae@2Rp-If?(7#1GE~TaG5b6q4A4dx`;V9*_QAw)l>bCV@`5fQ>@)wp(vra27JYu&n>DsY_BT(( zUU!o06<{4jpBv-mUL7qRQgXQ3sF?iaezD-#(=%q$D->dBubjRh{dtz^*ByHTxTi+1 zM!elSY2zYbI*fhPprBmqFxejud9rVm^2#++Izte;s!x~CI+5Ti&S3B2$Gf$oP#H`} z_$ph^#^6`^y3E=-Z{U=;hwBHi5ZYU@dL`nSi+b-FO0mk)6(quw-F7+SFyBasNaFUGgimn+_w+qYH>-fbbik}Qgh43)o4O3 zA)rHrCTmk|#3Kh;KrDpYJwMYKP8G*pTF1;$%6@lmn7tIanK|l^$>AL`zdxZTJMMX_ z&1W@#;`=q@@>RL4fa_%x1n{y)NyBQv#|86z%Z}7H9o7t5HP8of=v4@KwE?_hg0G@S zVF+^(I$~<}R2(_V@4~rPvy&Ur46nV`rATtpvb^GJXzxz=jzdFIkz5!(GqE7&MztiZ z6nRcl$U{4dkoUE+xy_mgg8 zhGW;oqC3f4P>WWZD$7{Ka~uEEfEwGdAU1PPKw`*oEtG9Um7L^GmebR0n+t02z< zr2~kgt)k;J+5p;OH4y`}Wrtb;?uyWDV8$4VE~RdLirTFS4>IUnzD?{o&$`#ix>QE{(tcRdOqtdZ69-~ULm_q7In zCQA4aFoPPLFQ;{#KBr^{2>EreGn>i#S04cZ3O7K(qIl^rCQ6QzlMVCWf#Ug8>#5fTb>veZ6Pcc>}sPN^Ob&LU#B_?G33(V+F)lUaQ%`Wo%u;9(y z@WbO^`vlB=`trS89hXWwYPXMVclWujzBn^4c<)nYa@@^E{~g$7Ec3%&URfEUhqY^J zhQf8JV;}j>HkbQ&CgD{2V>vABPrg67(5^WXp`#7K=S24glPa&URW~%cQu^erDZcO8z^AeL1xjw~TJxdU zH|HK+Z*Ass)ztopqEjkd97qmhoyMH(n~38vOVkn$Rum8C;#e7L3)8WWJ0nhsipm559AG zkdj1{2-GJzwZ}=|!#D6jEyA(`v?H$Kz|=;HJwc-Oigo~6=>XX7S+|{K8&)v5f4$T^ zJ|?dcOT4=J`N2G2Ec2H)?d13Co{N)@b{TkI5AW!eeHOAwc5bm$qw;0U>@C;)$X>DL z&)jb6z8u@f+w;?cexF}sapQxD8L`IZy4O*bNY=|=G<|iU-Pi5go}`;*I*v)w zM}{#*1+nMUk5*5gquUiLYBDWXylSdrr{f}$s5;Z;2j<_T$YCEWm;-pGZytYnER3P? zQ`%OHG;q?eAGiB_^0U>8%lyD(F^1OqJYLAR^aFDgc>myOo@8dx_pSAp0@*l)1|Di3 zA=3QmC__UI_3x3C#$JfFD|pujPOpv#k&Y`smqX#3aS~Ur8(x1PG*2ZZP;#cMh_W)H zkLJZxudpp3IpPcXQ9G);7kmH4^ZPPMO^F?sg)`|qz2#X>8JWKZ)c8FH%N?`H9ZxkR zm@|Yi=4mM}TGcq(dn;Y;jyl0EonL+N>;z3Vrub8+Nk_qZC`k-sWQ2NIUEDMa&2l3p zrd)+As7tC-^F36Z;L~)C9tno?AG~iiwz;}%lFp?x^Bpu!c z#VZx`BLrl6!@Jf=P7}f3oJEQ10G?l8pgQd0M&6dStS!)`8H_kK2PW9BMf*-pX?#5Z zN)b%>W@uONpo-?jc54KUC_kD5m?27!(Pp(N2IXQSL-g?Q6iJGCJa?D75^R*p5b>xS zUaS>Njt-sD_Tftgv>9$7a6Xp zF!`e_aSU4kY^>%x8ornGZ#(;5fH@}N@rDk{eDe6I@O`Twk!5SX>2Em!@k0y8wt@oP z^id3}=eLc5*}ZVITaC@%iR`qs0w2srv2n8z-E!dZEh<90W%%LhPK3te%a0-8PXM~# z?F!ziy}$`jMes4!G#8&rlyiwMbIs&s7^xG2mQ& zoTeGVNB=$e+kyG0q_w1X%2Mvn!X0WP>?G#Y(kJlnaY3sPixo2pN_Ar;4 z`CGR1Ug78}VABr+;;&UDW&+tqdY>jF$PCD+~$wkK^Wbc-{P>^zKMGFG1tJ< zz>MAxHR{Xyl*i8v&Kd5O^IV3l!=KR>im&C04(%3a76S^zpZ&EF?64jcL2X;`zHg$> z1~mB7-H{;S6R@FJWkQy5EoY9!Szk^$+cZ|?ZeWSy3(9atm+rGdw0yY$%4cub#adG! z;8V;Rj#ezDM{fv2Oz_*k8;sa#z*4E}QP~c3037ZLH~nGzHn58?F9c`WOGnBe=x$s2 z1sFXt;VIlaKb3Mb)7w&?>gFva6s%uk4jqx7WjL!NIdJc$QiOOgJ1x)D#Y&YAw7q#u ze1&`u6}|Gf20C4@Q1rDIZgUzO(XFZHjNi!#fMz3kmcYAaSFP}pWS=|EBPqI~RacG8 zQ`{iFwu^Z*v^l|>=-46GXS_w2c&bO!Di<)P{4yIz@VX+k-qdRIB|--k*6XfHx;iD~ z+rBJa`>idbn2^A5DKPXCa-y4Z6+d*3FFB_*;H}C|K9p3P5t%cirO!8^7D+pUh~|B) zVs_W9Dc}H5G0?TUMH!63$twp1-0gK&)MqtXYVJ0S>e1m0W{DHY${pJ%q~qzV%Z?WV zIU3%TBNmv5)X|O+&$MXD-#KG{?y^W0E!>>`reTH;@uJ{*+qJC|n~mF8)OkT#!MzkO z7W$l5TUB7vMyT}u9;R_?`u#La!*l^jyj7H62ixQgNj7y68%%(=Z0-!=*%+8l`AcgM z&^D?)!Q1HL-#yFEa^ph#m+;QM!TKD`8+yBRR&k$|lGbt?US{b?wTjhkC@rn!3(qe1 zZ5y}>5B`+VIm29jirb6tvbwU1nWd6j2JE)y#&rAS@b zel_Yr9bAukv#!}hjOW&^m^82U)l!+OA(cG#4GB3ny_>U&VV@EPzPL?9L=}KHnxi7# zh%|-fn`1gI%ERci9(q!p^jW~fSLBTB5}-M|dJkO}ci<{3m1n%W@cSV($umv%mZ1Z`&( zKL$%vbfk^DbjcEo@vXEeP?FLalYiQe=2Vg2yjp7y z3UpxWP(J=+Qw`mP-X__RWn)qq9|VUkgj3#xfec93`PBV+tA?X}CG=lBqsx*M-AHd- zn8~#RPdV#3c5-Sx-+6VcOzK?r=+54Zc>ZbFdo{;P@S3f%!9@y(hfB&)w--6QLO};) zC1u-T*lRYSaC7wCOyQZ2j^RgCMV3sXb}cql{D*i<9K>79VAevEeL+fU@#mB9I=e-ovbiMug~ zt)uh~dv`(G>`_+UCkGdS@zb*ocBp2dX^}$WuEA?kRK%KW$LSR~G3Eg}CgSH2^5Bya z56zCZ?^=mo&R-lZn*^?k#y3$dj%$^kPP)k4-6KJxA(_AYRZPp8-&nz3L91olMSPv5a5pWSTq_6+CJl8)=&ICA5-#+8W&Te_*@4&I#^s5>3-zD{Y8DT3I| za(#MT|H_@j>mt-4qC&Cxv;bw7Y~~KvCi1Q6QqB8<){9~1BWA|jp2WXw-=&lA-x?X@ zsmmM{b?ofHwQdy#6C#JUdqfAVmGAMix1_eQ%=M}Y-O=Pe=4S#=e38#y`aICSG<0rp zhsaiM6~?L59~NdpDfr1H)!Dy{`u?B~C%&izaIr9J)qh1eJusO_nbN=bC2B9f@H&EK z!ix^z5Q^)XF?#z_{CoXv6)KSk<+DE%%bIu@eCoa`@`flhtz;HL0y*`g-d>1(^*!KQ zJ?-2+ueZqXcLM#KQeNh|R*3s0MLu|>pEE{!bNlPZquU=E3rO)Wmdt`!H3lG?tcL%LD;^XRevMM zj3iP7$4u4xi*p;WBOKHMGEN8STy8G*I~0>wE0xD;ZUtR?2|{ki9Mwlcu2hT~PAwFq zh7<(Vi)g=iIO3yJ&p53M7&o|6#&L>iU5UL8{{Z1vg=O&{iR>(5kIZRg04N-skzc90 z-`IW}_%Fl$8$=F(Byz=`9Z&keez|J-5B3lEaCMIxjb`D0(e$|d{n+5Q@Q>H@uYB>o zxc)Hl{?&ZL6rwo7_7&R*DLZsW1xk)Fb6d82EBi+LD}RPx5Or-bFwbNrX)R@8_u?li zeP7mRV_%~Cw*xPz@-mt3_<8!Kv zuI$VgEPGQr)yY$~>VabgzV&(S9jTF0h{5EFmujIoD?l5%ay_XI=wLHMe_gE<+eFC(=_xc>lmibBK= zI@F00kTKe$G|CfV@Eh`=yEpKU#8jUz_sOIqo+!8?ER)xdr5geD z{OW9b(qMY$=}aszARVb4SHI{gKr(*qKg;8&6ai4E{_Q5;YP8C}xjxj^Q`mlV0RV^} zYIs!xoKmz*Mp%LLG=Wqc5=o#0B}E}%dJoQ@<*#9ijzSiyK;BvKaL(wa#fMMw)p zCh_Y*IQRNc0#Y6Y2O^qfRqAkkC=(6jcctBt$2A$BpOp#vie1k9U?{2(r2Ar+(`@yp z?F-b?dY(C=;6!EJ(NE)4wN2mI*MIfN{{WG#K!t~;Q`MDMO}hoT@?ZHH69di&q$3p} z8K$t}wM7h~iYTxh&w)r9RG^WQG2s6Ix+|tD8R`gcQCzoz<5@JBLncq0aM|R1qPGH1 z0m{Feo{iS9mgJX1vS&X!`ubFnfEZ$hjxbdH zDeGy4COHq$xfC1Nm~DO={h>m!>aUUPXCO!0Df$#R5e< zIv@BI7Xom*2Mv+wNJ^Y#GxexS@;t4>@urh-naB)3S}qnQF(Kr9huVR;e~38rBB2)n zkI$7oI#kH9vgKQvVPF%g{pg7I6r$xj93Og2xH#G$+cdsT{vI&21&4X1`);N=i15Q} zdghebkKuAYw7+MSK_DZKdIH2mGX2SO>_teDIA5AQ{`45w^3h}5Q{%!O9|zFVVPU>% zoP4amT5_?^%E0| z51LdP`h`DEDH;^+N*}ENMe}as{{S8tr}=WQ=%4T&Dp;XVzw3j05lG0{<7)~m1bbL@ z+&+gi7hGYc{{Y8n#1-VL52s2C0y};*0U3rT=;gkL6y>%Gcn$~GH02rOh5RYoa(FI3 z-Jl5CBkP~4(ku<0fc*|CV23~JkNgPJkuuo%U+GkddJKx)CN92}X^&S21+NCX*QRs0)5mU%P$OG^+EI=NO`BZ^SypNcDX*{;ZM(leI zl_L)?{hC6%dJ1W94hzw_PeKplR0E5J9Fh4{Z1A3)eT^i9jyAX9QS#gcVtojq>H~`@ zJQ67gI}QjxT0p+TKU#5Lml(&=h>48J2RRiia-Uj-qCV!DBhO0EiyDwN@IGu;z5dL) z7lw7O7}{HSjwPPeT}hL zYeVTT+Oyy$oAAnXzS78R3$wlvyN!f#UngoyvDgmP>ApPp$KnqiT3Op_m+x~8qIq#6 zH$?|E=C_B>Vtq$i(ygAxba|HMWKkQ5QZR5>?FY7MTg@`#OuUi_<0&j;1D}5NA_E!s z^{HBUE)VY;%x_mc>lAdc{v5jdKBc~O-Nr_0U3UF-O-QEDt5l|jOIcC#yq>tElE!P> zt?=-B;;6~!Tei`>>qk^@tiLvWYcwkdE7NJgts6-1?A7qBKpc|2zt8ii{IfKVApm#! z)g%dxw?_?u-@SJ}47!fm_BN1=q0S9)kgKzzgM!Ak?ctKb;09sA<*TBWhdpJ?tbU$+ z2l&_F+b@TZ+s||4+`^;xeRmQ%3i9vTq8&%ZmKK`)cJPflW-&%l!G1kJ=DZ`|h31W@ z-`wfC#-iF~#l$--?ii4XH{M;}?46_&{?|iUo+t6Xs|ED>c+@1G2$=k(vZ}z5kzG+q zOPjgDCnp6LvOL?y-Wviv}}$HZUz8uqmnV#6%$G` zH>ECkAD|zz4}<(M;ZFG7yRb$JwQf7vcG`evzk@8Vvg@dEX) zby>XInH;kk?>}Xo8~a1zm8I)|(Ae8gbC7y4O^N>ik2tD}=8)gJ2Xpi|C8hneme!Di zbs^=r;8({VwqL{h$UJj%sTIs`3@8cpSk-@}eJSAWc4#kA$IMy4&3yCwY5YK*_s3V- zF&kpFSb)Iin9+~(t!Eb*Sm%V4;dFdws-s6U1D(UIXTDzIU_|I-@ z2ljBY{{Vy^_Fxy1Czo@rT+4C^7$rdwNBXN(-vpMpx{I9dU`hOIs+27kBh9ToX~rv| z(f-)h5I@3A4V?qbsKI0-kUn4Wug(2#EK0*@!ixPE{j~0y-{JO!Y(M9SBz|AvU!Ge0 zf9Vo^1#va*-1HyCW159pNkU^8HTJ*kS>crM--G;L<2e9Xe5KOuSpNWh@X!30^RJAe zgtW_#QHuQ(@Lz_{fc^#eKS5>LcY7E1mCzm9WAf+RF5k+vr5e_KmPz66?^u_@n47a9{_09*4GyI6ZspEM!=L97#`KTxttX^JebrU{B+mK8n=mVuGFNmJcs}QyJUe{)pwQAg(`29=XGGOa@$4+r=?!- zR+S#Dtyoi0MQ;>7gxf9gwl6ri>X^F{@0yaNhWst>4AZdYWSy6fqdhPap_)x;;)R}An}%+ZDpxyGRWuNydGssMWL;7|A_K!8yYE*8YiNX1R#~`PqQxvLGAVfnKlhKfyD2Xn7{vxaV zF|s-?c=1}Z^8tJ|@>JaG`noou-fR5hL6l@&;00I1|J_K2>_Pm#G(j;g7Kl=6C z7_~{w^*AQ^s>Um1cn9sVsHUmn`O+&IbVgRE-$}?(O=TX6>Cw6_Y4X**|oj ztw^Ny8-BG3ecWy)m-m?Ae=|sG#JrRDsiY3z=AkPqbzro44tlviN(vst1MNVVdj3?= z7T&!5Y2PU91p^V7uw=7O_l!mpkVaokcfPudlO2q;2gwQpIWf2$AXw1xukT+ z;zRVMag|B|)dEvn9he_#nW4bRCBC$nW#B01z9|771~|!}2lT~Dw1wvjP2PjPO+iBp zd%BVIrukUxO*qKk!x%pFq1^B=C?r82uoUKrHuAuIDZ4`+e@b{n9RVlan9&9!Q@|}s zcOHlr>r%3xWu`V4t7q}58zW?LK4;IcrtJR!WSW!A9f3cEAu={`gdgotNYS_-x&WrU zfltiDel(2J z(v(}aa~U9Y#%f*iDIQ`EsOdlz#leNP+#@feCrXr09E+7Ds?>3$i}!&(l?=LEQc0cc zt?CCf0S}f~<##W*=~66`D8|Jj`_zwVJ-EuSojrOHOALtTmjV9(y3_!fGUR1kcNnBL zV}lbIkF{DTt;t`+QWSB_XLdU`6byj*@Q;{ztK6>)C|RMGf^gk~@M_$09*&+{{{T8k zBUw0fU&|-+phQb+xMXqeIH_X~5|NW2_aik4w`kAqW@P^WWsc!Nl4!Y+K_A|XaZm%y zsp+%SGUk|INqLV7-`+}9ohBcDKE%_63P>Ok?kE67o+$qS)(L;!9MYxM4kg^4%pqbj$13Z4`anE zfpg5~k&mIKN#(N*z-P5rB2^j4YDBnJJ7mfe>NqsOowt7jZTu<0?f~UOk7{64$H>S2 z0&_~fW`0%;?A%m25cG_FYBw6N{F z>M0sUWB#ZQa4KP^$Ufw``kc~zn&l5gkb6;aq7>x(jLLlj0zX=t$+*W%cs-O2wJ~+Vt09PR%!lkeps+j0d=Ymw!sSiKvbNB&Dzi0;=7D4KGrpn{^ z;Z^qlngq#8GV_Sr{sqkek5eAe>N%(tmyMER{QK0NWT-lp2Azd*{$c2{g&*$Ha@|}> z>}nR7NXwuj>qxBuKPf0_1<6_@Zm3t>8d0@=>y!9XA0TCA&*4y+r3aM`Ki;6C!(6cF zibwlKm~NSmFk{_Xp%Ubbrr%nX3b)*sY4%zK$F0#EZt};W8Kr4i#|*N20g6qs6P$Uz zy{R1|Bj$r2LqRBwnl>1C@$_m{mIVMSI6j%pIW1#9GJJ#SoK%ZzAmvDt@TOTAxMDZ~ zhoKZCuBZMz&){lCwx7IN9>$rs%Ig~`_cU0RJ!X*O??vy{r)z7IY;FlYMsem9<21-F z8*urvj>J}##;0*{VrEvOL8H0XF{ty-IS&8NCutZel5>R>3tB4nZ_bW59G|5%l}Y)F zFV>nN+))~E1e;Hxr^gbp;kQ(lsT#Tx$WuIqJ*rE#w`-||x)T!g+G{`&BHPZ>Q!6r# zjrr6NvNM0GkbP?8FqY&g#cLf*-sEpIfQD5KjyW}5-J~9b`c~o6W>`bCWmne~lXDWG z!N@+st>{J3Sx~9upT?e?u&C7hWJRDkx;a(^RTN-Lqq&eJ|{x6;ha7)Z$`cma47 zy*Gw0p@|ydWLVsWEh3&pSnwqFmcAsjd(uU)lo=56F`D~h;!nhnhI*&Mi+gJbY?}FF z*c6})4m#$D#StmHpB>#QI@&F)k%t3oG9F09KU31~r@PZFV3WdC0G}!VSq+E^NfypHL*Vlis_l6eR!u}@L<&5*>Sw$i5ozgx(6;IN>N7L7QfFAYw z1@IfglK6k%hK#c&*)OG<^WOx7T>cUMwbh4Lk;RH>Q)i}l5)&?|Zy90!w!TpPwFys* zy7Pip4GhSiDd1v%;2B#3hBVl1Y_@N0ym( z2ipj1jc$5P=+CqMAi*DnJO$y;45<)7LOWX@aiP)&XR0ODJ*s`#=vp&#h6w~-Q#(Z}+y@~@hIV{Z?^X|DK} z#jJO(jbcTz4K-<^FK;rmCr*K7`Q(kcAQS6wJsIe8v^X>-j*cRauL#I`5? z5t~E>Rt+$3UqaG5eh?)< z_Lz|iI2({E;B7eLoY%%)HvN{mcC38$oVScBC$!E+;xO($BbjC;=b(v0D^sd zJ0nolJY96E*7gvXqAow>qYj|^0DteQ_>aT(!s_xU0-u?H{cH6b!1^SA2>$?QjVn)8 z@^u^dyys?ORYvXv*f9plL>Ox zdW&K;jJU?~eW@DPCpbTiN^(Hif%O!COWl_q)srgZV%Hq*EBI0}BK4N1#~(L8@H$3_ zAG;HMahgL#RwJ?9_)xo#Y*dVgJp!LYN+TWHAC4#((HnOl{VB?-4=b8JMgA?v>zZB6 zN6M%1;(~`@5yV6B6bSM;8-A3}-yh=&KD2pYa?H*6)IA0%L{sGBn!Yy zpQdR6X6uFeidO<2k1JuHSMp--RyJjNlK;no};`04??6fFof%oHh`9b)jvQ zdX3)gQVC0NSSP1?Q5od#$n`u>1ZFXS6d%Hch5O-0y*tj?_g~CDi$|F}og)Z6N#=k9 zZcp)=icqm|%mMVyY2HXt+){{HvF5%{q8v~JlFJ-p1%`VL^$C^8`6I@AwM?q02vkw;)c$mvfd2q!&;$k| z!!iC&p|*3peF?2KeW{XgBU9F`mfj33UZ>alIpFFKRtzw$Lpa6H`y#p-?c=(P zERp1OA1~edR#M!OOrUSPGtd#h{OQINan!5!HBUD-Spiz+9B^BJLilIjPX3oT~;OT2jM25IucI)}V9+w}JzDiY!rKt>T$>Fa#dQnv3jH zTQTw_1qb}Hl;`jUn70NK`|ZA2{w3+3=Te7BF@^z8OqvM@u4B&Gfwz;OR_K2ku*_I( z-N(Q5sN{ktOh^L`oby!L2evu2knR5fo^oq{n!5h8j)?;2kxIExFfAfl9fNa zLZ1Do9g61dqQI6dpY3u*N(6I^hYg-^qOAO~4lv(Zfwb5qV&oXycSDcTfrzmP#u42Q zrg*2UPn@wXJ&iML&~S{iFQW_^a_SMN3o5)V&;mI8=mKEzx5`0J_q)?2v4M{W6|?u( zij#Peakp^CT;x+$-A5`+XVZ2?Rls7b(KaJ+Y2Rxo*m!pOmE=;fkwzI+hpN#N zE=UBa^kGN|1hHHU21Y)Zr1FU2Sg)|5?2=p=5b@o3G{a`=%rh}xNlrx|B~&q=m?`%s znsU5Oq&Oodze8x+9OM0m*qvl^=bI=qmNo&)tm#XLMSb;f_%sM7F`2@(BrMG_U95s?1??v9lq zVg6nY70UYo#V%!2{qgK-x>}Y009PZgsAG{%cbXsIS014AKoc2Nh9p7<`$nN?A!C@% z54A$q>JA;YeOEN}k~bMgE$g$20FyGrIQfVBOGs}e92VW?M zx@l+wR_QZ>Q4gbLq%6mkkbVO+h2mj{WFE}4&$B|@#VaZQ0H$XGuE0t5iBNRN_82tN zB1w}Xoco;AEp2cQ-!czF#U>0~$!^L1*IG1Uu>H2+@Jb(JP7<%6I{An6^$iV_L{{T%XFhqVwm(kRZLNiDv+rva(AKm`|>(qcLT!{ex0C$Qu zobctotNm$?z$c51@H!Ly>QS}EW5SPe4I$hZgB$bDCvY^40y)VDG4G63SgQ{qv4-dH zrkG=6%x-^`Dx;|RE;j!Fbp~wX1HAn?;Kn2h#_7x0)S%U9%KAFW? zzP_6FZ!;(dqKpcF?o|1qW+V8sngDX!O3a~GaVG$Esjuwh(_AIYXs$*;TvcH*q~X^n z>)p7f-QG=iD@!c9-1N%=K%fYk_B@;`k9<;#fglBQ_?%M=gE;e0j=eKNG;DBC$URh2 zuoFhINh+vCC5{GZ*Y=NNbkR<#tUE6?0E#W(;p4|32EeHuZYE}sNT+WHJep||*;S*A zvD)NQf-($--Sw#Dj&jFyq5lAOmJ3qu7i*GzDy%DyGUI}(DH25l{QC!TwCA@GIY!E` z?9?iSKQ7JszfeT9!H8yVM#*w^e>410BUk@<^?N83Vm0_20-~N9A0J9alXU z;Y3hSq(V z|lP!b*;hlGr~mZZk|M(;e#}0De_&YoVH_ zZCF}d$vwu%{#W%idR;oIC*HH+wUT%9A?25@DId*u#DnJ@eTHa@(59DCXfBdV zkd6-LJU69#uk6XD3$GaJw*c-IRx&U?jOAk=`Vp@MX#BYYXOYOSuD@dM4_jWQt#f*O zt9zNDw`qDD46*0yxAGOyMcLh)*oA0Wbu|9~XbT}P!W~h;W%BQB4+GtS{{Yad;=7m% zG0EyqYxKYNr0_PErFDEnJ zSy16wnSdW&E78J3-nrxBWo2Va!q*`+>rQwQuk){+e`${oU3iz{Mymyy?wzhS00wzD z0=@4-e=Ab8lh#%Kb>W{IJ|T~X-yXG1LG6XvhVV+UHvzGO{LO7$e(^Fe^1Um0o?Y>$ z_ITEQ80i{i_NvhZyiNNm8P@va!f89~g@$ik*#9jA0{{R;FjdbK_mA`m+ z8}bqi!N&1R$ba*jzO`=_a|81Sd;-=pfk4|T5*YZ}7o!qr+k zID;u^rUx9ZasbC#`|CuFFJQj1Ba5qNPt8cL&998|+<1S*x{a-~n9!5CS3G-GE%UaH zl-yEhifYzE-%^qkJJDE;z;&smadTw^BykYH;-tBqh%CwTZpo;vBP@9YXPU>B$4&G- zkM>shYySWX2jZTOX7O*bj&C~W1AvG(Hh&;L8vA?Wr-)MPR9h=@qC0s0>h#^y@&F3+ zuh?_JE8$-PYd$ZzTupdd&e)#FxM>)EgZ=|a;>-5bHJiJJIox|!ei9thyV)LQGOsN@ zRl659(0l&a~_+Jt#&UG+W}*BHq@TOi`O=?KeZHw@O+t#D*HZ@u2J z9&o*mYtO4Iv(u)uA|wuxoP8=cJO2QL53dxjaO8Zbe-lg(D|KZa*seFJ)Q1R{9XURR zk%VY6+)yG{<=1!7u}=Nk50*2Z_IWi7Xo$<7^?Q%uO(u5e7avnnd5p*Ix{vn96s3vd zcs{^XltYQXiJT8Xj8ni)jJZGUb51HTl0P=6+H{P8zX#9^&;z!hJRlz6VvT_GAW|6% zjDdsx)icUzI>>;3vq3|QW4jSipDsR*GecWOfaRHw)|r@zc zuolFdzkq%xG}arAcNO+prX`X7>fm~wDN0J;dBXnyyG4MOW<@z>rVEk$0H3E6iEN?g4{~ZmF#()p`r@OO`Y3qb z4tr1nn`sXv$R2{6v2Cc5?9KNh1O29KVx8rg2k)kAe|3MxfEG#8A%=0EOjIu;xcMXe zTBlf{LHo7GyK_j=EN2^sGyzqSvHRC-e|1I$5yFUmL9qK(jF~u9;~iM>QJB>I<|o>K z5%}uaZ*xrmo(~44NWdQ|?H;T;(*78K9Cqtebvab&mtc$^ zCI0~Q8W!R_`M}Qu0-I7W;-e!Ku+ULO6s$9{@MXinrOTMu!{;BJc>wJlM{4S#k(?n@ zklcg2ApH$<-UL-TJi)mharr^V@Q$^*EtcGuw~yUdV2)__2>}FWa<)OJif`(hhB*ZGs;{RQnWVH~80usG6Zm$i6Er@=iU~M5JXmPHPUTqSepC#WOHm-$RakcoRgRl2vhIRi zfOLt5<^KTdROxyU;^DlroB~vpKSNiNMIBt9_CUcOg#)>n1^(9&-78zo{opc(@vET; z!3k^`7XVcl3{VygcD zY72jo?jsQ=8^6`*6C>HKMlCPBB)b(bQ=8t~Bd!7w7_Q<$o`sCuG z5WHVB1h4xnep*_RiEbl?ar43h`l-RHj~&&xUn!%U4mPmE0qknW+8usdg$duxUPUOA zc-)dJq2uN4_|mXgjvJ(r{JB`f{{ZyRWAz+W&7clnc=Z)o;t%pjOM}{={>?G`*3QZP z!*VIuq+St5Ps^2Vy=fX(kGi`4wMnkzv>3R6ul+PmMNJjdbFTGe+m5-W1nYGR92diS zFvUv5N%HLuhu4~@@+5qJJszO<9$umOou72`!kHV~5-N7ze z5~YV#JQ4ZQ3zkz-iH;X)xA#cJB#4(81|Y}PX`~52460O|W}ddI0R7vrKixSr!sTX; zc({d~G5j)gr1P#~$D06Pe~Aui>5-#cm`sEHj=!xmT%!!A+TZQ<6kG^kk#2GXyH8E0 z6)CYkj{fV$DU-wYf&0XbkN1e@^r+#2ct6$MI{yH(+K?6!&Sm8oxb$u@L!&AEqh?TU7yg-^l{8bsKgti5-`sP9Kn=pMz>!b;BveMpNdK${ph1o88t31d zX4Q{7CL`M!6aiGjaEGq{0B7QyvpRpQi*LGeDseM)DAIxGGg2(^u>he={uMR?B$_$K zJjl*Jg!TNXR^4}SSx=&lD$ChLI)cJB9^HRBXtHR()ZFYJ-8|#-tGEh@B7^*6(9-#V ze)J`M$0Cr)a}fE|Or!gq=u5ay-Xq!>Tm;L{q;-Fe*6A5=LNA%;0d z;EKTa9Fa-om`)YdM{Lj{69&(j7|-m-6n(76_uwh@=BD#sxmN?O-n7=5CI=zM`@qt< zBbHG=3*`cRdeG2JgdaJ8_Z(C{SYeS6KKUHeWmy*+U6bl^DAj>$be+JL+#FMPD`Xgm z{{VlHRdH|zNl6Ew;L-Mofy9DH&%SUd*Qm+<%L^Q;#~JlQgZfii+C}9jbL@U^*R4jP zDMn*aCOd;qL}=G?<}>I{X%6BRm3o6Gsa7D5pzlrlPBFe0LH^q3^QAX(gY&YGJ-MN^ z6;a4G;(Ha~;9LL7ex;G^^&J#6{yj?Q$sy zx#x}pE69CN<20b#x!loVzv-aQ>s5nl9e;M~>bx2^hCjbDFS+DU1+xva9Ihj8P}Ljm zOzpT2>{hJl)>fK=%2m*m8P0G|;Z)3EISCsm2bLreKnxcx86}f={{Ul?N%p9ZA~sTg zzPJXgz_Q5y04PnqpmnXNbm$|~<+!;fJ-(PCsR4kD?UIKBva4`E3U>R2Wd)Z#@Cd0- z*&WGJ9Q|?psf9{&7WN*5W|#!6BafK7djdGB$!3zXVkT@1mII&0rd#{9YzU$tdJbxP zePZs_q{4{9j1!E|B9hvCnM6=eIP3iX0R42}vY_qxV4v2c4;04@aHdY{)AXqXL3v%w z5BIsCE>&xdfNny5oYjjvg6_&(A&2g`x}#s*k(@=DH0oJMR?U!>8LkliW;pq-&B% z>?=RTei+nzF{sO7sM@`?+$>f|`!QPkqv5uWnpeZ^3fU84 zi#X)k-9csCf1P&W*S&Lt6(`D~i(e5$x|hH&AGD0l(Ln^DmdOB39&uj)#~GYV=2BS> zc@_F2;=3_7!e59M>fh48IX|?GoR=x(7YuQeS=V0qSYLptCCi(pX4>PC z!qhNGR&@xOB-9hLJo}Us2b@)#Yi5zvhd4Pk&182UMSjSdEcc%m>}~=!%S(HUryy_$ ziGm;d>6h~r_0{wkxO3EW74x_3Luk_Kek;ENDIKIXfxX^M{{Z_)es%PPz~3_{?AfnZ z46Kg_9QClaU%9-Edm}0RYvpg-D(Nl0FzTwDvGSap{{VU;nEf-%$UPZ7Ff!oPK|Gv|{elUznoi0Alg?f(E1+5Z5-E#S|F z{uy>FEvBBAR|eb0u! zYs;p&_@AvYf0WMQe-1yTazC>Nhhx@0D#Lv`8DY|3d)7GMH{4JBd4c}`fY(R+Q#rZ# zgQ__1Jck|6%xkaxnzSRL_ybneWshTOcN-%la?!=d<+-2o6>yF0O=_I6k=bfjD%z2J zmI`sWcdw#;54nfIx`p0)Wrw~JzIU4201x9QcfKp>9|gcP&xNxUI1L%WJ@ds@HQos> z=QdiJo0B~APq#B&I~K}rI0Spv^o$k27z(^q8yG}!inSr*HKSHY$xTXaoBkWS@^vd$ z&l|R%p|6BLYcCC4Xx|hy&|@MdnTi~7gUI~|ueS7w2z6k2EI*xm`TKKe>mIXjc8T*| z+s8KVd=)?X4RAk?)b!%-O?floDWYeW3<#}n3g|OyI^CtDYGip_A_W}saZmdUw-a%} z2Nm>(?04{~^{rjq6jj=4v89*?8Uze}^1XtVt z0104CH^K`%q$(m8W<7xC`B&yQ#_teIu6VywxS5NVo!hUg6JAyQ`7__Oslq3ZY8h#c zdCg``@{^38rC+|CHx}AJthc^Ucnp4mxM#WF9SGUn_S^pTH+MfXsrD4+ndkedpZCp1 z(UXnJzeN?3MzN%m-4mR8Sfnn5DL_gt#_R&8oD$NBO}_cwiH zrWBdZ42k~$0Tn9DW*ChN6VpBFIpdlmi8iOO>rr8*A+kMB&Gj@KKryrrr!>Ub)RrUb zO7}5DIB*RG2b}o%Mn1IBz5e#o^s4Wu64+EN?@}P1amSW}->n0=FP5Zor2aIO2$9g=A|Qd0f9b%b50Px zI2rnyLn^E|<;zL+IH^OVLxNX6f~GJN_kjc2kzReMqKlXFoR3 zD#X&m82Oo3{q8EK+61kL<1hZu;7|jM2!Dx>(9-!T216CccBU8^zVmrQ{f@MsV~Zm$ z(jWHC00uVe&+g4^nbz;}LwL0DDknY<#P{`e&K|i|mpU`^g>sfx!H! z;su0ofDffOdB>{k6fgsqXZ&~{g#bp=sOmoqQif;75CD6%O1Q`h%b$J?M>WiG{I1|X zp*)|Z00!9z%Q-?$}v|NKUo^k9Or{&VD-~oW4c>WFr0b(yM@LJrbgq!B_Tp2xClU{pa(%^h zHn$PoMzbWUQMlfNzo8Y2WR|5^=Ut2ks2|<>8inM0X3Lo;ho_hZS%2Og$GuFk5J@B; zJu%U~l_D2kxLb}gIAQ)YkwFTHfMrm@xd%LceZG|_O@}^METfQq_x}K1sJuotF9~E~ zdJnkYQP|YcUM}#GsER@V04&(``Wh?*(=2yUx=S2pH|o21C;5Z)ssam(2L02u%tQWJ zKi^U8-_X{OxQir@l#U4_pURW%Yl5TY$52NGorxf_)M2_Ugmz^-BMdP5kLoJh&d#7L zN~5R@bNbeO-LyA{d09N==*rk-{{RtH{@rn4e}5BPz`P5D9FO;x`qY7{U7cIZC|4YU zGx}9kS5Q2+8%fHc&OZ*~pA5-yY^$+k64x3h3SU z4u8ghk$Lmm58j1z{_>80Dw->(t^pylk8Wv3^y+{P;Bksavxej6X2hSu=rBb9KPtoS zWF9&1Rj1RTk%0;1IOxNJ`BbSK*2Dt;0BIhb8z0K7MKl);vtZmg@?Y-{e0Kck13&#T z2EbLhQOP5Q#R+{Jjf0dq`?3NL{{UXK(hwAopC))Y=xMS*mnpJ%k%#xF0+NE~BazS* z6z{XIJCJVv@i;U~rI=fNt-t^e^>ZD==mm?0QmRVM=+*0NwT7k@{0k zkqoDM#;o9V1Oh37(8cpbD?|Rs=zj`uo_O1Iie-=c(eOv%NfI<#PijAPIAD4ty#Bpu zvT8C!!7gDjdOTz0t8h&f&O#HP;$BDiQ`l`lsTe8K2Ac#y4DmSLlQA7Q=7|Jv$wI@| ztu|W=c^t7;hFoyycCh?MT7j+Z;9SRWE&%M{D}G~xQ+-M&NQx8XV?A)p2&V~UPGpM% z)khUcbx4;2XOb7r2yv7r`c=h~%m9SSo`jwc(33z8S4TfAyHBS>PiP~QeCTBV0BDM7 zK-@HHNbQWD&XokQXUb(?RAtQoMg5%|ZTq6|pYDk}BL4t+ zxitaPqdKDEhGCq-@N&c@@|2HC3KA04w>R^ZrFNFCQ#Hea8nsN_QxG zSwPZdOsL+mu>es&xQILAbuTxCl&uf!&m33Pgza zY?v~@V#i0uMLIjNE<(%?s0Rb`G~YB$!pXejrg_KHr9%3AY{i<`pMOfL36eU&yK^^A zPRb*5D8xXp{{U!ID+9oD6BPDgkxn-kF&~;0VcnxY(xgR{k>eR_a&g^oML68~!{sWS zh;Ef$Ek-axyrwdLh1X~Esig9zTswwS?Vsg941GQcPU6bG@f;e4YY~tmx{;KB3!I9S zS;C_TT|eMFQ;{x53K5WYasi+S7CB-Z+leC|#HN*R5e@>hU+*nkStJYtp^bmp85Ln* zhGERv9-InS0*bQEGNoJSX;u&q60*0UIsH1-i8PB&stTUDQ%qX|r)+X_)GMgx@y#$8 zrIou$^c>Xj%o~p=sP#1*iFF(N@rE6WwomJdd{QC#WR%9=-DSl9B$ndrl?fOf$UT1^ zDj}yB@f1@@5)Pmoin!@+>NfzycMXcsjteO4k>il8gmR!0Obm6mngRl`m3{dBRWz3y z6B0o(hU!4vKhNPnn&LNLBMzA3oPJ`DK3Tr_5aZW1NC?fuEyA>CJ=|c_yIWym!B?>Z zr}U(FR$-FJ6yIKv@3(d<=eH{d#D%xMsv(yBX+> zky9(B$oZLb=vR?fV6s^w^3!%tbgd&BlHG_T)9$>oeEg{CkTLnyRf}#KR1y{FQR|{u z1Qvz|QdErO^{m8>KP{bMBo6Caa$9l~xs9z4UbUA;n#yEYm}hH9zt*y@bZ3giZiLA2 zNW=oCBD18pVIf~FfIU3U)hR|WDl_N((f59oiV+s+6r^__yT=RKns=7V$^i!?4k|CP zY#dD@H~USRRkV^NGKE=(e_8;)3P*6IjJWOAuH5~K-ohy3EhNl&Z%V^Q%IC`o?N%Yo z(Gm|Lk|$$p!}`_Mi>SqQYd@1LSY&h6Nc5}TC-Ih{;{8@z?NaF6T*<>o$CJljb6m)c zlLPgp6)YI;8D7S^6Vk>lYjax5P+dCYI}-Thahj!ns*OhGNeJ3l`c-J$mcyz14PA*N z0k_AJK*r&Uc6|c&A-A;o2rR?fJXc5I-wZyW&ynT;I2h?!Hg?SREsvsTKMqHrqIgAjD{PC?1^!e2wtmLeXzLbK#inB`S?_XX|3tTLq0&v+m`q!z2Tb@oTX|-afi>?|k0C=u79L=KLuRrz4kN8*T zJQj_2E(Qs1ezp2X;u|kNMZJR=S*+!Y9ga|s>t8K?$)5#3;c9$Pv^Q4g`yRoC-Qhh7 zf;K1OzzltBEKGTQ^6-nAH^RPL!xO?Bupa_fDjIV0gM4&4gH~IST4G!V9LXlBiPn7=*pxy&YGBr)1MSB zkJXyJdJAyw?m_yR(C}Bo-8;j627^rS#E(6lppncnH}6w>ZaMYqS9GmE&4yUraC?Ks z52bcdoKk4>adhQ2nU`g6iL2*7jr!%UgFk6KKUlbmIbmBnNnIUtoJIx@IFT>W=2q*@Paj(PCtC2Wx56KQmf8id zRG-Tpd#_iJ^6l@>74tRkiLY)Vmri#r3`)TP?VNP|YfK7ua$5#H#mz;XFT#kth+Onw zu2`R?eI@%l$Y${MTSGar~SJ;QKMTpIWP0NLq$uL<8U7y?c| z@0!CH`T3-EVDA~mEclo9f|4tr8(wBMmR6O@s`V$*z2`{q>iA>9`Yw&IA+)wdkZ10@ z4bDF@Yk;=!CC86`G{8l>OJQku_M4Z-3W#?c{dgapd$Q>(tj#ktf-utj%-r=gyd;s! zSvmWwx#sS>uh$%?Dl#kRDQ>^B{uSCF7-{gJAH@FvO7q_pcoM_Io@<7e)fWUv~RqS3K ziDZ}x&HbZ1IsLWpYV-u$_Hsu$@z8GAe@3r9dQK)rYs1un8BV!ToxyiGwokLe#rj-3M4mj_=8Y{d3NV)0f!9! zzlD8Au4-vvYUPLkDLmITd2>sWCw>|hjH*cGv9)+!NgxZ!Q2ZyM5FC&TGdt z9U^@{#0_UWeq6CW2N9A)KGq zzfeDD)Vd!Cr-w~Pk``s$RCGWOU&IRhz42|dkX%c=ZS}=+!Co?#bH1jMOR?wIJ3Pac z1p3t(v!9d$T9=6tV#F4#=>#lK@Q_p> z;{iPG-}OJzp;@{40iD0u=Rb`&o7d?IL+(yn@}k8ePbrt3gC3);H|?>bVDfzjIjH{5 zSpNOp+uP;*#Y!Dw17T1)9C1@(fZe=<3l7hBI5g94({S?~9;&=lhT<|gD1F<5O!I?% z)oBl~;*b$Flu?pWCCL6G$^B`kVvM$OdSun8;$|etg!UYo9@Ze)&Reg|%>asmLD5Ej zl;YA6fwc#wIW;le)L<{D98^->ECc6}Ip}lA`U(mo34`-4>Xl_g>VtvU^H4S9vJ6FT zNIhAGDkg?Wzt&7uf9ITI1M{XPNj2m#^(9YWdYWLrk=GE$NdEMM1s{i6bPz`dHm*Lr znwgnM!5R9BLLl2U!2R{YvY+6Mahjeut>FPgDi60m&Y^ix8wc_nnrL^B{_QrY{v+0a zEIf;ov=BSgUVLb9eq&4!FgR%o`&9970E7w$xS%dYx<~t^P*QndoVH{>wKR-We90u)}3Xvg=M;-F1ZX+Bs~G$(^2jjR4A z^rGNTJm`vY$^h=%RW`n9K1fHJeTEc%HDN5$Po8-^^Vv=UkDwW;z>EN^8u92j{HY>L z=Czhw8RXhOyBQ<@0M|{ufnd|Fj7_ynEszE?!L2Qb&C2ADL(k__^=Cw|yBQ<*PmKKy zE}(EMIHsOOKA3ve*C>pRXsIwLsspj`OR8xSjf9^)h+*616{RhdqEm5bJgbiD(I4>j zsy+i;=`xVvaXH|Q4+6Sb0DgpB#i=-M`g;tw|q*FxWo1s?*yG zq!}#abUDEvM(g-f5Xozdt6hcU_I3K>tpi2hv*EJr21j$j{HnCNL?o7iSuN)4@*WS> zdJ*)gZS@qH2}ti24x%Btagjbf0T5ZB_)3q>MKs(;Vxnl zN43!Z02pkFk*3B$$S3>bsP^@&`LxI#jIhe~la5J`jQ;?G}TvLqh^p zP)1Z``;p1~M^RC{X(IA4y$C-r{Qm&@)mYk?^6|`YIxBq3`uxN2r2An10G8-Oe|%(* z`|GdfDOdp6M+}Bor+AyuP86TyX|Jf<+YjDECLQEKgZ}`0^!$xjR#re!LCMiVaOBU)>N}x|R8d=g6tV@`21?4x53`{{X6@xU!Dretn_GLZ9a~ zPD@+salSc>$~vrv1P`u1og@m~OVYy9kC-bC**X11H)OGZJpI`I5^9us)GEOz4FmrG zx5A&H=xWHghC#YDEPjMi9lH{X@fQ*UpZ11DN|y^djj9jSXCL8EZ~4nInWK{E1W-h=zR8Y5^6 z5YO{^GhqJ!pQdVnc$VtW63n+TkM51E2hyJwuVnbk800-xL$v}wq{mi{p37S#dm3Qowr$#b&rsLBh6TV zDeAk2pzA;kfo<+S;oQU2{{Tvq*sG731LxcP{{SkHHh#9(xWEfKIFg; z?#gn1p`Z%?0NC-P<~`9L_E>@We~mkB&AA|$`IOY^`u`WUzueL zJA!jnU>G$kQ~+ECwsZaQfPWgcw^qatDb?~j9RC2H%Ak%25u0k@w^E}S{IQ?bm~_}S zov6?^s-2nqIQ~@{0c817wmhGF;E%`G^r*_pr0w}g?)?7%E-8W=$f92?#XdiU_&>|~ z3V75}8CK<52OV-Uzn?$MRa^vCmuWAC&l}R8n2!YG>UpUWTSooj%eWE2R_p$Lw1QYl z<7kuUr;p2uEJGS?p?;xVPw=?O{b`bE$}kaHF8=^80-vzw_i9&;3F4H!+Nj}JdhX_s z4ErVOkX4dI^eRC=jXX=`oLnQ@``|D?jai1}afg|qKivzA{xu-`JCa$q`!oLlPV`xo zp%TplAloS0{?kw)w{i&z{*1Yy?ChDdySpGE5`=nSk~eHepA-TweLJ3!<+aZoZ_rUH$b=O}l7DnKJ?_T%P+GPiA+ zALBrR;v>Tc9*x1NeCZ+qW6X!KVUN~^n&Th5gaPZjbyyM!0xszxE7+gCij3UA$7qv2 z*`|jwFZ%Bvx9$edAh`G9lt|K%gz|Cfb5XtXL~$$)9)F7)g%QrPHxrMg zGzHkwBAAh~#Pg9}o$yz|`e%y#Sz)HnD2gd03g)>evD zITA*C8tSEPr!7?6Y>%=30BG;o@4^273%oUFf2Ume(z0daEDr7~GbP3ql1rX{F|t1O`N(5+28zFTe(m%#oLK}1V5&f!`XQSV(3hjdvrtLu9xSR|JY z5CiL3Rw;3J8I(f72VB;!jj1)Q!p{r&cQCf)IK~L+PBy-#Q@dIoyYX}OW7a+s_)ARI zZ?0mK?Wwt9UaB$Pyg{raQV$tDtI_;r@sGs*KCrj4)$gRZh7}0Ak3}2-Tph%Z2>FNv z^xa)8MQmYBOYINS1b=9Q?J?yY|;--st;vaxo2BtAR&XELn z6FDC-hGr^r>xO8*@1tKU-0B3#$b|YFEq&GEeHu$Yf}SF>WiBAlV~nqC$K|*F$(s0x zSu@;7c9zQ!4l8==Qw%e*X_%{O)6a|(o@;x<)58X#Y?q#U*{rH&NM8<6ADe0HahkS~ z3mY>&(G;Nn0LwwtQ@q=0Q_fJk&s~+wlT7WpXz%|3XB!A^elO_F8#|bM_YD1TB9j^S z}e0}2pjVdIWRpaUfa@T&=YMMH5>)A!sk>wv3JV@HMs%(*qDjc^y^^>M- zTR7DK3M(4qwxOtAT(((>JpMI%OPP>t3U>9cK}V82sPmNhrsX5m{vT^YPS!1494J39 z9l`5e_loTQ0JAluxPeqfbOYzMd)JTYT7IEq*4KY!m|I8z4hBax?WhNcyeAnWHx{@f z*A?c}_a>xz(0@(cM28UFwd ze-dk;Yf*iwLkbg?Y_awOn%lj&<&d$9;Jq)L^tm1><12`c@{1G#8d+N~(OdFPv9JIXW;L zl{(ceeT=KE@2OYKYMVmgHC2jkYbiEiFYS5#&bhiu1)F+}L=Te3pzT*d-KR*y zjJHG9w3}12IOxVyoS`B98oTl?qhH}T&3Xrjv$j z%l<3)<)T-!fx+VpLFrvzj=WnPj28{&0fB*o-oA0yH90jaxgJI=WH~tc(T1eozQYw; z{$0W7c7F}DNAV7`9Ap+&VUz4Sn)J_$o+pPzlSR?sbyxdDdysX;NBGnq2lROSJ>i>; zYUG5>Wl@i(t$5tJ-D?TK`ZS@PJMde9eYxP#%<#mk*#Soy*)}Am2f0v5G1Q98Fe&8ay=$Y?$!mhDySS{$;%jm9L{ld}IK^Qjc)sNl zks32SH@fOYP%A7q?T4A+L5hd$pSD)>P=;1OO!m9=gS29k;OFHN`CeL?lV_f z)OAo$f4$bLNjQbJ`QJBTw{=B}Oh`1JG?KCIKHaJpw({ME?YKP;TAJHezOX<=Ane5?XL zQ;Z++{c6&jsZlQnou|L~{A)IfOs4H}4jqCoaHA*lrAV#V?kT;!UnlkHRvQto&Tu_3 znrj&xD2+D{_W1t*AL&w1sVWA*!uoP5M!AopHr5~BT+;*vm^0*o)F9*WJ%3tNh7>#` zY6$Jr(i#*>=Y(C}{RibqA{WP+ilF}hbW@QPPY%j`3xAChTz#JznLph)&-l|QV%l4; z&5WMR)bNT&$ss@I^s3_a=5-<~Lnr%B@~`X9=}eN^S&$jn$NtJEAD>!H0VMYeZa{fc z=z5>??NFP!osX4ky+6FiX#D!0%A|@5XbAGxY3@iQ{%18V3$_E6?~MNdPyYZ_NE8fn z%y&y6jeolugOZOEImK{RRc7rq~%JEr|$m%I;)^wgo|jY_jx|KrkJD$s6N@M zM^g?;!yJ;os2?xqK}(B|^{?cQNQXb29RQYjp3#QzxIGhg58yhV%Az-yGM|#&<3HY* z5Pt#IrGnjI`H_d1e~tcT{LM^SKsaS1zD^JLngFHb3ETZ5c{g<`GBfxO>sH|r+o2H^ zMgIU~XY)NMVi$%*^~X>Acl>Gwxr`D7K4JD5$Mh5wSo1N+0himQHe09{hES)}9)AJW ztA%OCclk@)1E1IYDOLrH7mc=$?-QK2919C7sPN%nPqOpM3W^riFL zaQW4M^;`i?lJMZ=LudGj$)I9+0~uBr_ogwy90GC1396G{z>R`SOZ~(fzpoV-c`i;G zLv0`TSC&7IYO4V))#5>bQ{OoFD z86~zTIP1qBjYzIhj?*#+cJthHGLPxnth$}wbqtvP9;5N;N=7*`s2w=zOmi9mxZzJg zPx4--goSb@gK;MrC#mQ^`t-u09vQY1zzx*?9csZ=mjii>da%nM!}w@RJ&Zz@E!+|b zKUSe3BB_p7$jOO+HvFotp#hIgy4i_8bopoG^gZiTD=M9+qz(vE$Kp*@)g@KZuGGi4 z7+(NpSt5{;MY%QcOB4V$%;tMB)4*;tlRA#MwH!JOqO>;s}538=tTtB(~@5= z=JLpH%npAsK$cA8s6+XKS$2B1`-5*g3wyEmk%!9u>T5w{jf)SN@ILDwbMKyTv5%GFwU*_=rhA(;OF%S7nk?+*0%bqV5;VxMihFyNkJ zlju)+7}6(68Ai~8BmL9fAHv5!krk2suW?~2 zi}=w=I2REO(LdMF){JlG4gO-HA8=8=ht`uA+2eWSSI*WSb(C#!_|iP{+l)@d$96(D z5BGBRiB2yCZY>R336GZQ4(#%kvM$m=_?L387+`l?PT_mHfJcO00ze5bno7 zaDOAu^Q`%zF2&>Cl8$#L2k{*ZM-A=FOiYM&lg!9IQT1NcB4)~ObC!=I(4K$#{{XF3 zy3#z^4DdO&o4JRB_>PD2sbRP+7Vj}gJqq%N@$70R1=}DoAarN`MyU*~_WQd5i=`7q z{{TJUUZ3wLU&^do-rHTNXr&S+eaHv-iVGdHunQN{NeS^KfIXlE{{X6~ww7Yg*_-V6{tvvT{sMoPtCrV7 zX~IsX9l zel?^mlV{FOdz|op8m&H)V{oB-wT=3Rh~xDge;O_XU_~Pa&QI4gtrRj0d3*l=;6La2 z)Mmo=(0=KK>URJHU_X(j!FU=|5&hHW{amL9@E_q+1g73ti1JU%{n=Y^{)hS0$#E^a zVra@Q_mma~@ErbPr-~*R3~(E^KbSnzwxTIqvwMS;AAspl0}lh zs?Q0<-M5gvq)f7(%ctThNN!@g$&Nzap-9K+R({VRBnQW-QQPWjg!(Uw$dHUfVk*KQTl&cs@B(5${Hq1#UH~k!;kn7{*bQzTQJu~&~Nuo$i^G7Djm>wn{#@{lJ#8sPnh%PxKao1epX{{UK% zuY@i8_zE!U=r^eRM^D61oxqdNXx!{?FnSfmDZe8d;AK9pLC5o_E!;DMlXfl#z3o#$_+(MHzBV`A9<0G@(@ zh`MdMy6ty}fz=q1_=8eICB?WY8IEvAQo9NKsz0{*So2}pJCHN~0N1JF)oq*Q-mB~m zPw7Q~jjzC6vqdBfJ3rk&l|61e_5Iw4K>igws!1*jDl%1uc>2>!4{C975tARrt-JK9 zu?(4R1V;+VFS3jpc+}591o`RC0H|cRvsO^EBFK7%`AGZ?S&B@Y9naqbni3GmOq0JJ zTIahQ;+AbHH*CmNx$7F@l%!HipC~9DTkg?u7F+i-vfy{=R*<=Y^r+(7i;h?!I$ zEz~ezz@(@$$k#_IpPxx zJ|k@!YmFM+gUrD=Dc?2rr^Bel#q2Q6(qkeqZ}x?J!9VRqs`y93_nr$&XoADO-QG@H z0PX&jtyis%dRsP*PxhMeKY~0G8 zN}Q_HmF=Nbqec+5wKTME9O`y#qGICkebPq~=de33-(sK6UX<6laA9@7QCf{|P0^H`JotI9kJpxW!&u}ilIt;zg9 z{Z;e^lVKKxq9iCliZF108uM{=7b>UKp1um3RV7oRJg4Hd=57`N_fTyW!P-iYY)+fF zC)l2Artys8{{T>)Ms2`0Ylxa}HtYoC1B&gYnx>+8b7|G)JxfT?bjZ9_rNw74ozX;+ zbC4Je*BRlYc{RK1iAX!9X3w!=YpU=Hn>!6#RdJ8ATb+de0Cj1db?jr#r2WvL~9CKYYCaF_t3lB}z zsTRtAAAC(0f;<_g=yt3ll0^#G?4zl#F8!Re@u>VqOUBD3q%u#RdZ-V<6|y~J;XLhl`rLBz zB$sv%q0WD$es_FyvW$4URaQ~CWpVnN{R;SBC)xEkkN$cg41Gwih5rC*Ej#-=$9l@B z+6Byu_fM^HL*r=ge*!*suuEjs(PsG~E;k;y73yCdb?*Yl;ZF(aUKh89OQCr)N~k#mj(sZ&UD(;Q zmCb(&d@$C0beEsm)^cAZrM#C@9?2RhW^bkia@SgPcBPRNDO1sSuhE~``@!~K4gUaV zty^8bjegm#Le?+`ToR9!`Js1_ASI82 zH5-eD{mrwopW=K4{Et7CPZSYEe{`U~a^olU=A_#E{GTtWIHWR~TS+#2>532TLN@;Z zI-c6j-XPu8oLq?nH=c>sSF?4Qe}BirGCXDr$O0G5Lz{3(ok-L3bo{CSh(iju4I@8d$3M@SLX#3g zv0^<(^`}XHZyFqxkB*Y?Yd`I#;NsOw5AT-Rzn@>yfG(++=Y?!_$6Bo}p$vJ;7je;J zUEj<4(^}f%M9tO1?f&Fk55>BgmLicRBZ)!(0AygF$Grj|^X9o3^48FQdR|!m1N>=4 zge<|PEj)veT;va;^s4|2<-SbzVafa{tYcBv9>8n4sRT2YB)^sqb{n7k1u>3oh))JzV>7s?9| zJqJ(9hVrJ)R%=p{_D0+uLtlI1g)`v zE=wMWerEpw>r=Q~aE(Cuq5*%rcpsHTJn*IfBsl1&Is6uYE|?J5NY8BUbN(NNEJ*OF z0s;x{8zlWtT0lmU%`)UjVL$B*xfLS8<(!5L=c!M;ex{maRBfdSKccT6oiCWIK*2cd zyeRsb08ziH##n!_arvI~{{S?E0h5nG$LCAt1pVX4HZO2MQTWw4H8#GLg^Gz>apyS1 zYyLFu1(v>oHU0Ad2P1b1D>G5QmiJG*HqnC!iSsnP1Hs`%S&vOuV=dlpWCxZ!M!!K$ z)X^JEyKgKX+`rCGP7gssL$T+7PM-8pMQSmJtrVnlMHeLPJP#~U>2fTmXfvIy{{R6M zxpMXm9P)tPAcX?L0I40p{y6OzSAA0vR&5Wm$V~4ikiEpUnZo4#>@ae zJV)=L_Z2${vqbGc32mhH9f|g+W{MVGhD9A>lfTo`@Tmo?V=PH{x^?JjP(=?c11RW0 zfJfG<&jGk` zG7Xml?%YOk{{Vds^sEJ+GG<`hWBcI$06JM=lmPE4C_Nbm2kTX>=b9-3-U#3)g^_>N zzxYCrXYA`5mh&`xUAv7U)YD zC_q-}##O$Co#n9rY$)H|W6$7f7qNK1duy0kx`FdX-|!yP8|JWK9n2!ww+cpCTk*#f zn8_Xz7;+2xH{Sg}#;MJr!*xHIz2dkX8Y~G1*BlzuPJTxrbI|(!H7Am-ga_sy!u@{( zMZ{t;y|5%mziohU&l%ey{{XzaX02H27T2gvvnv6^1qUPQy+vse2;s`_AHx3td+;?* z_R7ZcMUW$lh5NGGsaXfqj=zma6RzL}6tL{dGAZE9%kwYE*q&Ga0IgZVeM-odXbtVU z{{R}U$w$;@nzduB#i^1l*-};El>PPkr2=KCtp&&iP@s+#vCrVbppx41(Bw#GU+&6& zU_F>{YISGZmY4?hkM5tq(DAVu`JH;>KQHA>L`kVzD*-7nP5sCD8};DSqW%KJAuPwT zj(@<@WP%wY9%N`xpH0AieJa1%MY@A)Hs#OyxiZQ49Ar`pm)7Wo+9N#oVUN<8a{#`N zWRXVb4@Vn-{CbM7{i2|QaT~zh0h8{ZqgBYroQ^{O0K9Xbp{q{C8M0|t5H4*l%E#I_ z`Pb+VQTo#(yYhZpwRm5&hvq^*-XGLfxwl>UB$7L42lMYxv~ejRhFAN!$NvDXi93L> zM--6>70{vR!y8ZIQy9XIGEZ&@{Jm=`TN`zk%z@h0Z|_In>N*;QukWm%GO4+b{(3+# z--_q?RN0`qHn4WGI-DM={r>Ko$#(l=VkGYyaKrFC z)yU(xkx4+f?s2$}>qvSMp}0v3$Qn>TiatpCjRAGjZanH-g=l=lK6-|~) zcKyY|FdxA9&(m-p&Vj82bxBo`zPTTmpjR0#lbp4%HgX=V6ObF~y$x4*wHqJ{Vs!ri zrfanRM}zs+v~wA-*cd(caZ7^}7A}C1#?i?ioieUax4gEW2{f*c)60}?KZkGDv?GZL z#7d!Y>64GfpqXK~8!i+|zK4&}s+}<*{{W9k&9Lj`oVxxX^rv!71}chB5?8p4aZ#Ie zW!)T19F84H6;JzSTNNpB8qJUSX#wZ*JpM+ehr|$FH!`J^LajGlp;2mF5uPqI%sk1(WTzSYGVfpKO-9^@+O zd+x}lHPS8%1zKKR>No8ls@Xg@>PGR?${)8wG*nKWbxI8Ebue6BBW6 zHva&AFPk4;^&-n}baVEjBYGjmD_6*n4B<-j495ffDy-UcSBDa!790mq0UwWAcK}NW z84HNoPxi<;{7pypcvZ&aRqvc*`B5gE_J{;GE>C5c?jM=test)rrMExn7V1xTk1hP^ z2y;QWZiulyglGBG){{vwhsYQkn9N}<8V(r|P1sU_6Hf+C7S-l2G) zE-n4%+V2)eaRjH0q;1dVO}h&q?!%!Rf&D22lTQ<$EeJi!XY>_HJ4pj>Y1%CR01Ruz zQ6%zP28(9I+IsXj{VKXz5ZR7rh-0X2!}2vU+m&)gWdpk$8o3;+9A$`DliXt!NDP&p z=^0c+X9VML`AtaoY=igEm=8eD&+0g%=xh*FeCF7 z?!FKGokp+l%TV!8_PwkvZo9mh8|H6eU0kOVX*-`g$F0evLTxsxq@yk7b>P()FJ0~n z83|L))m}e3)7EX{7ZOJ08Pt);&Uvh3Xvse0-55XIY!Aw@lx)s~?prGxNYV)K$B(^T zN5dZj-gx)MdS->Ew#lz&2+xo8af6q0>;_5tS1oFi2IbX;amdAeh5I9T7CjrndasQy z?Sxl1?Amso&A}0m_3|&*3Yqscttj(I#-x;Pd!4UTN!2(!VTcxZBI0PuiPp3bL zuTk*chhmT=-TTHG50Mp=61f=2^y^+}qcYoCeYwc<=F1@WJ^q-jt8GqM9R!V)?hkRA z^<@_&q2e=7X(`0-@AXT<*i65Hbpx{bnq9%Ev^ zN_({1h*FFh+FT41Yu776QG;ok|S;!yGylT>E&-XAuwS{Bc&wJ{;IQP#*m6HM1Mn3?i12(YbCu)ZT9Ri#|HwqYne#7cp+aY$;Y*O68O2> z!1tE>qD7Bs*|V{)Fw{kssUeotmJ6-Him~Com7N(ov$Cp-NaW`GexImD?1b%YtPOjQ z!cP_(E5u1iXk35~p|2&@rny2Ibn=R@Aw&)kG0-BO4(H6xa-sB ztfScJqg&a~oc!!?TGifdW<4C&GozuN<;czxtzPjZ*3>EvGAX_khs=i{WXT|2JuA@C zD(96O&Zin{rp| zcI%y|_?QA}mZ9QnsjZedre;7ff@_|IDw6ls%-SFYIK_3^UYysm z!2ut;&zRZlE6w!Vqb!0KfL+46vjji|k?k$RyX%J{GY8*%T?tmlt#R*d z6R`nG{{XvPma^9K>Mqv}<`(&z70Js9V;YjCVtBqU&hGmou(r~4WBRCr^bvJLGiw8;XOv)Yw4tUrQ;_eKk=>cE0rjAFu}fQN~dy+w=r8r#idw*9A&!w zD>Lm=Nz4lJ6Z}jzsQ$HOG`o1)0Wk+u8O`@&Q;oAfbdp6 zGx(ab9i(wFVHVeJe8O{|#O9qe=$0yr2ItQ#4k>5VU>32XFOuV_ZuOh7q9V9>oPfnn zf4njL{{W>|YgJQ)0dxJ+gHj8aX5t_i-~=qq&53$>^NP`tT}oZ1(6DHc+FY?Erq1h3)_wq1;FDgOAdX z5l3xj5e7)v=Klbmk}l$Z9+f|p44qX^v8bBh1Bns09-03D3W`h1asl&#N;vYEV4wI6 z09;EXEOs+5ej6ZuJt>kzV#!HVfyp_*{OXG=mnBS1!6k89Dri zK|l=i=aW868^?E6!v1HI_*6zIy16Kw)i?+7>T2;b@wkKj_a>{!a3eWjOK0o)QLyYs z8$}xJTp;P3U{jni8CKmMGrKq+ms(`kip&9w5__=c@D)~j>sSa^aU`wMB;~XHDQZQF zEM&%1<$YLSegdJJQZ~Qq-4u3Sg#I1s6t=yVLh9dRM&loTL;VFRX{~7Gk;}?YEh6q? z`ShkSvftfZukLLGQFoeQpUG*7VQTIktw+E;LbDyhrr$Q~E%ES~o>wsQ#zF=SbRZ)!`p)m*oEdpLkB+`{?(pXbqYyF~=IH z=#h>wK#FnbqUF5X=|0f?qD+XC{{ULO9h69$6p#l&*i%~Ji-m3c{{RAx2jfFsBCuQx z`cqcw2w${5M3MU<9I^Nb4Uj^g3t!5_q`D<-uTpi}sZ7f$2So%UGXA{cqlw^U)%-lbT}kkOOK z!9Y4tV%Q9k@`I|Aj%q=ffm28zPm8@goGYGMBilZruZeBlM>$62WI@#ZYGm@V?wcJ? z4;UHERPv6L&n=v7b7bfDmce;XQofWDUr;uHM+cDAE;bFWa1Y&KRjsbpSiaE+^DPem zvvLhu%XvH$k|KWa=WQz&a8!}_EK%2^)>fmVB$J7e&8DC}X7$hMT5N_f^UTx4 zdnn{ne$H?U4>ACF!O0ZKGOV?Yw?f-e+hu>)j#5$Un$uT`NspGhCiMe`q`lKEZmmpH z#OUNhDzm%0G8^9tJ&+tw5Lm0B4jf(d)w9#r~}O5kQe}0!HndhxdmJDH2wG zTMdu8>(qTsBg-Sm6v9DoyC(r#^{EwPjrKaMM2-(*Q(@Sxd7(vmMEjj)1B;jr9DRL7 zK^^Vhl1KJ{^I$zMU|b*g5Umx0Q7&3fExl8t3X$z1cG)0g4Z&UCD|=M{seNQoCKIFY z`?4l5)YEz#<%1|a3}CQ7UrNom@bcNn^6Ac+eaBsdZiUCwwrQ4{*n(jN@(nt2aG{6p zCI0|){{Tt~HQwe}l}bX;tA7_zf5MRYuB17RG;RnZdbi`))Sh<7AR{B50`yk;QpE!? zP@!UT)nAoAPfAQoqxK1{hwg4Xr9blQM&xgy?NUc~ZwV0Gqgze?06e5@qkS5+8p+4Y z=6PiGhi*aqYOI!UH}2$7mg8iE?LSUxL{A*{>IcY#AK-pS`Wl#OF=NX>TL9-6%hO1s^QEE;Ms`5;|7_eCv{sl#Z zUQMgYkQ5GCTPN`p&o(IV%P3%YK36}Oth1+IX-9tHslqw(`$406*VN10!G#{dYoYXRH)9! za$t|h{#9etfv#t^47VrcC;e;#a{fKVNg0Ackp5skzc>E?s-)cP=Ob$#tfLK3HkOP_ ztH`bW=+4}IM|y@bQi#8GKnK_SX{zEk#z;8mc^{an>K@OR2H|=cAsBJ>Q&wQPf>Vg) zQdg2k1bszPXjUS)vV%}x-$Gk&-Euew*p6zy*srYyV31rW{{WVAvCci5e^FY0Z;6!R zK=11aBz_~MC!F#R-CMKd^#|qrMM#Y0)znI`-FcSsj+@u{IO88$&>mI5%N^_6<`mg% z$EvF35Bvo5prZ7qDE{LG$8De=&Z7HbMhYq3 zPb3_3`g2*S6xL)5s0+vc06pU+)A3%SwC&}!nmofQI30YU)9N~k1jsfEjF?1`H+4nF zKSRYeMeHQ0MRfHC=loZ{;A;Gr3mSnI4tni7hyMUto~WfauH%8pUPu1`T7eny!et~2 zk{h}e+DG7OSnLtl?sCL>=lO9`JWYBzRVX?$VB_e1@Ti5I$N^!w41=6F$ovmc`p^Zq ztz%4JCOts^03K;2u(6zNxe>YTw0)Q|{>k=c#}aSD z5Buk>R!e}@I3ytV&74Y;fUT5zq>iX{HaHnoj~1yFgfC)x|YwTA`Fw| za=?Et)`Y;_zJ}S|L^14-9aD@NOKaw_iQ;(#p!5y=HIWU>Z?nyj!#4pO>_3ZXycTcXXhTLp^6oRy6sE@U>mSIv!pjDA=(TkQ92jWG!*9Y{M(G1JeriZ+;{t7ND+&1ppnMu@Qto~@I( zR9Mpb6fLe|kw*2A3E&4)$@IwLy$9i6jqLmrtVMjbFyGFHA}f)(N$tgYABFxM4ST_I zT*G5*Wz@7qSR_b*+cYyaNWlBdBT!HKM!ZJa)lt-(M<#n7Yp2;dc4s1}s$F(H$5rtW z`03+|W!5e%v`A#YX}rC$#Gn)RM?I?#;w-UvLh@}VLh%%u#+8L0dLfNnh#3KW0SB6& z;eWzk5`0CS{{XR}d$v{B9lHw4_?PhWTktlgCY!8VJU2sz0P&1wx-Fr{&8g+LMq7t= zNekrX3^_Few2=(S8Bva+x1U6t%Q75!YH&)fG1KwyR0f`=eF@{2oTHDgcV z{{V$9JbB`MBSq6QJ>C7n+n@??2{?0wm? zO?i*(Gw|7Tt6gj24yOX8y^1xpmgT^Cm<{Yci-Di^tJd!H;S`cyNhqFq4&uyvp0(44 zR+ZVuif-BzZ*D_>F6u#e+T%b^jqle z0}}~gB#YE~)IT0IJp)0r9xc3K06Y2;jNZCV16CG2v5;SJ3|e zvM<56)-`*N7F-85dRmC>)by6-Pn3V(Dp>wiH@2+SO6th_x5BKtPKAA`NH(MVu@X7j z1~LA91$^)0ZBk83R=Ac%&Q4n%o|W&uI(E}MHx8d3N0k;B`nEZ*n*2uQA2LZY0m%B- zZU#4#n>@_IPANeg#McqKTp>9Kx%$>^uD3LI4+JOf^T(w+GS0WIK@u6nKHgLGPa6*BYjwI~{)3RbzB?nr5$StJ$rk>j_sQ z40^AOJD1{%!|wr?M6PNpXpZoLwaVrnp4JDn$-AJZZw~US2BDH-vmuD%d>kF-T z!%(}FWIW7Jlh6(T{Hhy=^RLi1+&vf9nyY1GvD*pd$dsI%_04MNmfvgCqw^#A6mAB+ zNGQUZK5ny>YW8G0#rN41>CmpwNZv=Sbp9Q-Z9BrkRvTxy?d_aa8`+IVT(`7E23v^% zE%?`as#>qw8Tvu1)3+B7kF4L+o*r(GKC&BLt{{V0JrCQ-hwzkZ&6Vm|kU2&45Nz`UCQm=@O zGTN z>Jy)8^3Q-?AKh^aGJqpZ*vESJ{{XNczc)kz;R26p<;KnP@~($QUq_lUo9a0uhfJ~B zFo=S_NByomA*%S>P_@)_t5&%X+j(*eG;#6lEYh&{(#F|I;Rm8AfMDSb(BMdp?wQ|j<$sMHD zaa)!zz+x~xtLg9C7s58WU&Sp}TX@nnmQOQoF|@L)91p3lo36J-D{fqj@mfuDB*#u# z*sI~+4r*Q{*Q|8-P(8)W>k=eAcQFb%@7}*bym4<2!_NbFcfeY7jNjSttWLn>yyus1 zz>;z8ULX4ud_Ov$jIX?5eG2~oXJ{~&j5_ew3j*8vuTGo4_GjxCA0Ju5~bA43a{A{yE)$H@Es~pNYJv=#_if&wvn~V0zq{c1M1x?b4=0U)TLOOLn{{LrRqL_Q}sI`q~1wt>6K5q z!2bXx6>nl9^*PfG<-?yj+7$lnzbEl;{q&(zhKT@1XI3O~L70M@Jb zIux*J_WFiV?dISTe(&Mdutw%$q-DU!e4dKgsZFw>Bt8$MeExM5;@JjdP`$S?z(0Vf zOm98}3hph7Cu*+4*JMX0@if-!v@9%H`^5p;e-^1w zqzFWk0|dllauJ#=byc<{)~m za>lD(Y8KjJeA9?sx<`%9{@Cq63U4Q0-T6Qd?#6Q8jZwMR?d*w-<8_}rLlE5U^)zEr z)FL}m`!?0O2yi=5`7xVeHD_Q(um@UelGf2Bq) zXSqf;@a>De(~NHa0CXCX*&()4;TRM5=a-Uy&$R$DD+QQtmlmjcMG4El`^KWwtx_#6 z=pgbCM8rmi{hsx4E~9gZ)fmfsOfhY~{FSJ#S%;rd84nPg5% z+#i(mKPuJDV?%l5I;NV-74rS~)O@l5)E|1qiMul5pEEKnj0oyF0(;boBXVRyJi*mi z^!CMPX&12S@tI?CmkIL83E+=jD^+dbKfQ}nU7hQWp*?bmB`*^GU2Rs_aE z%?h&5(c|fnP4jR`MiDx2+3oCoXg6@mx$|4kUaC2zsO(gd#z?Ln?@vJ-r~P%a{m1pd z^{MPG#qzYUpDeEuz}yu*_@|_%;kU;VEa*8Wj8qe8^IhB@q}*6bCB}^zSdTXrQje9rN{+^^={nc;fMIH@9IM2Z(*5Q4#c82i zha22Qa6apDH&$k?qzytxNW6a8JxXB)kV@A%Ln@e-Vso1XCjnn>Vj4rAQPe)LRu zC+S%hx~;{f+FEMGZ0^^H?i75odsfTD8UFx(J0pRSu=V_C0V9b~&c%!#@t)tEO}ag$ zS82}=5gZ@FtB)9JY={q4TZ!wXB0Bf88}= z-tgY;NU)B)HN4+=RKoT(;qY<)04-OgL3oi~D!7fj_4}6*@Wa!eN=#*&cwN+mU>EqK zJ%0*ux0Ei`V$1&kJmupRoVvZ{nn&9ZZ9FaNkCgj39qT|%HNq*HK^l>inh~7RxLA@1 zv+{_;`m3HrHtHK|m>M}exnevRrNJxy?FQZ=RL<`qZdOp({P9vXq5vI`W3%na6+mIN zy~dK8blDpE4y|i0OCbLMWYwKNTubKiVvU69_pPV-Tzx%jb(t0=-a$^%p~0-}PfWDG zT+ABj!OC1m!=d_$0G2z8nX?+QC>>*bk_WXht(sxAZVb5UtTFjkTTQ9y3L54%)GXKT z+^EE{k8xU7x{_*k!dM+HBk8?9`X56|!mvYTtC-jsFi-B}W7E`9$!!eF_|=+*py8V1t94I(-c%pvz1ok+z+ykKuFkRI3BH z3MAY*l>Q;~HHw!vFyTdN2@7Y|P85KljCHl2 z@2K<*-mF^d&|F9%bZ9P5KW3lhKZ8;eF0HSbfX-xL`~=~slT5U_8yYX1bGZDjK8L+3 ziDi@cLv<0!$;YTQFC^@hxE&CW<>^dCc;mUUa&jh*w%`^){DnPrgpt_~5)L-Vb};_{ z6ZENBRE>b{$5AgQ@HE%5is}-sgB?Q1zsUBaI};1Nzz~fg9Se*wq26haI0SXi4OE`e z;?REXRSS>xa~$#P)X241<8d6>jkCahRQ(!&Me!TE^C3}=-zg*d)oYC&%3{Q+Sx@}6 zjO}m!y-iO~-^)4Z+#kxAbO~Vp0FNt%i9hGwVdg~NM(tImzL!ipbr=n>Cu+L4 znF;pDd@~I$OW{&^Ug&of5Y{wKwfznQ4CS^VxxKdxF61;lExdT%G^lP1Jri> zsfEd=xQgF$vw%+^F$47PRo75W_}J}{_fcxB+Bx#oEoFEa4nu+%A7T$Ur8k#WyOMbP z#2sGWoPHF-<<0V3DPb52ItfT7n`@`F#Mm?W1x7+e!2FFtXLD_HC&<9RbZ!Xxj@4l< z=kh#L?d6Al(1P8++5u1DntM-hz@IFpdpFDa zccDYNux5-evT%Po0^^*4!tHiF$-$=?mLfM0qq2^<`c+iZ(ev|_J-%(g{syLwIj#6u z*8{643z76c<5ZAbl2=(mhlsGpIp}{1jteg?Q6w&43;Ze2)avn0hiEG6)kw$lqt1>N z3ht^w=ub6vA?kD<9@VdH97w9{s&YucZ^FGk$M%WWZS>QpE}?S_D!X<&70BscGTQTj z<=e#V#tDy%{c};R<;i7PBN4^?N=eE7pPh6nmO155n{%?%JZN6IkTNe8yx2&(xbM&RAQ{g)yF?FKA+bznHI&HQQLUZx!m6GDo%T?TpSt8%);|Z||em+=$gRma3H9ot^&Z5zhYDRonT26@T)SBl%k_B$Ujt^`co z5)WZMg(vlA#iihl7CWbl-6Pp z-l}>IYpR57k;RIsd)TFDL~aWou4;M7ng0NGIQFO{2_e8Zt5$8~A@5#-(DHe$p%{{A z1{FxfTGo6&XYnV&*Pa{JLkJ}W;Fug6d4TyGdiBOC9mZJ+HK}bOjTH0)jFFmEBBc{r zH92K;don7KR>O0mwwNl4qplUEAW1t(Fo^g zj;C+{<(cxypL`V`g?ZnO{t{SxTd>nS=Sy8m)?^V~5zm*q=0-k*ay<{$zJrZzbVVe{ zx!AF;-U7y|0)hEgja4oWO%%6on3v`QL30-Tt)u-BJm=(9Rl~2k$v0nM@S+QvLdW2U| zX|a-Ha?)BT%K|%~>PPafm&U$4lJ{PPH1p+0Z}I^jLNma}rFI?{@ZO!`n3nd=A*X8g zqZR zAiDT`*6R3`_#pHmyrWW4nEwEQ zXZcrDQg@NXR#vo6p<^$jcwfWzNef6IwpnD`$=t&~*1EgBOHW@2xSD8*SjgBI5=gH+ z*St$}XRF<-FiD| zGU6x_GK0IfC;C#XpE8Tsnlp;Kwyfu`^rUHN7U85cSO>tf)y0o@&6n7iW-Niw!7|K;=bJc1Xcyc|@ zHSrdYIk;%X2oJas?rYV&1);~`KL@?pPcD10xwrKS2LtiRu6x6}bNG`{w}F(H%Lm`= zJ6BinH&R;-IW1w}xCo$n3h1p;RXJ$A4tg|WOPdq9!}x;jtU&@$*-SvtFfu; z$cxA``!^yM_+j~|l(z{O5F*ye~uLggP*rLe-y7(q+qSl0(nZyyrlZ&ACf}FjMtAcfAw{97M} zYt2sZvF}UWk@R1Lyek*MpMbhvndR)iy-&8@7W>UB^k4W0+xb^R`#E@P?K=FLjHpS7 z7s(&Q-~PJF_{Xd^x#DOv^lZf&5OCN>2^=N_)8m!?4Ituo;NRyeFT=pLMAHwqo*S zWIm%O^X*i;?>vvQp^ zBlY~MHo93(To8Y}!=J*aO|06%kt>*G9S{x9Y89U5{O*ta5fA*eo92*@Zo;GuIqq%Y zguJsVulsl;{uM%dchpfH$^;5{^QZX+J%FiU(=F}TNhC&TPyD=0cQ@fynMTpKAZPEn z&*ex8F?d$pls&}KO*dWS;RmoNbeN8cq`%c5R zcqo5`f2~I~%u3!;11p}1ZaRHWN_$zXzxRI&ur{*G% z7Gs`7!jcEF^)#Z^LTdUPg9TtcikB>c_v6;ZW0^%f&ffkc1BcRv7oigmV;2$W9QySWEHXmgN0 zf}{%2M|*g?tvA?Pp0iEMu=W*d(@v93CS_4Phpoixmi!G~c}#>Z@3bto26#? zx z*^R`n+`j-+GeXZN$^^&NM+^B;bu1A^f^ZZ!3lw~_eiam2Z`uHPNU?-)utM2+2m4r{ z2xNC@3{f$jW#HrPC)SAUT1i#sm+dnzNKg8;_p6cV{{Uo7v7agt(HwHr*Gc0-LaN8z zyPB&FAep2NyU2GP6$bX%>0z|h$MFTSEY2NqzWa442#sMNXA|z8`rP zY5FYjYY=(KGcDSDvcl)?{uQ9mPb&$cEAw5K^%xHT_O zM$8tZFl1$pME(N5&ZcCJW%Cupa*s|3terbphg-H2M6DIvcv+%j_i^pqRr7J@K6Ztb zdjX%;n$T!^m6r>+`Ki!r^1^?J9DzWYHON>1j^b#fC!t=R+2)W=!4@`PRQ=Xf;C+9_ zo;;Wi&n!s`b>I>`{**}OAXQu#8(phXQvA#-SLrnMI^Kiy~h$bPktDqnbR6%I}2 zmQT&JZg@v;s1?%3A}B8b5~uM3eqm2dlOZ(ItXoacbR4<27ku zjyT5O-^cyd>OUI8xY1|R<&yVEac?3209j|Bl2602t2#EcnuL-eX0yJJ{bjR^5`DdC zF{5K>jAa6t`=J=cFx;-=M;o&gqmU4F{Ad{h z$^wxDd)bB>CL9s(RAYwn)sNU0%UtEHCFKvU2V+{7H_D|qg^oaf))Rn#`t@FW)n~&H zAELZxar{7~U>&Dwzi5@C&yh0!0Iao;a=+vFRl=xODdsbOaFRIo^c9VJXC>$!WD;dh z$#R2h568U{Twm$&p}j?x+;Ep5mMi}7EmkQ9NZX+vv>8#r!Dyr(!gyBq z6+$RY%wd{eIyU89M@q0QBiWZC;W7M2Bzxof&;quj7;=vy!u>krG4wT)eWfd08;e0D zjq(0Cml40zbo^^!EaOED8hOz$=NQQRf29i(0gy=@(esh`8UW|pUcIqknuH?SRyY?) z_Z#x9n^Nx*xs_2HktBSB@0xztrGcY$S)%^{o=h|Pe_F~-5^EQEbZMPatJqu*-8cKB z)RJpGgfgA;F*!f$GsAs-DPGz;as#Teo>o*G{xwHS*W!|9h2t81;NaTN(Y@64HM0~g zB!?Fd3xCfn01Y9c1&mCgq+FpqIvf#2&5ScA&b!KTv&4URp4@*by7nj-ZeJ!t$Yap^ z)S@FE(IHsa^SwqxP=;a|?&1V88{2XJ04>pW%D++H>sBr08iYs73p$RwepgSZts^W- z-ejX;`@U+7XX<-?HJg3*6Dv4=~}4|mVtt!pgc#65l_0di&VGS za}vye@f^dI{{VFMq)mvJ+{+=1Y=nD^{rx^9r%a2x2ytuVQ*6e$)#h%d99;MAq!+2w?d0qVHQpYW!v za~2V*EP4J1IR=qz&zXYl3x9Wa$bOW7ul5mZ^T9cRoA?~>iN6liF0$5uEzBk*Jskmf z&-E32S_B7rn27!`a;NGlP*8#L?M|DM8&A|w1Qz#yXiqjZ+Nb!#Wlz)&l_JL@6C$51 zcWA~`{&iYS9ybpxC+w#`>n+H^KIzl+{A#?~rN*55W(z7G`DqEm{wRN@Xah*V$~U{n z%l`mO+mFOlk+?v7xYcqn%^yF32C|{n*6E3YHjsaKe23^ksYI8v58e4NFL0+X_=>9m zE9g*MJ56rJ)=~U+&9|TSm!Yb2++OJ_{&vf_$3A7T5y|+W>-bfVva?B$HUb3O{hh;hR?D1Sh6Nqb>wd>7A+ zS=a8C`CU)36-G#I>@Xsn-L8MsUXDQaaX=Q2+9p3W;sfalgg^aya#=vkS=r{3`_eJb z_*5}#_G>4Vbd6;<@W(hCPtnG6T4^zr0|a*rFs#4?KSneGS?#Ud^B6+7!yxk8^8=c4 zi-;sXSy@!`vO%%W(T88eRDwBGl%26C?#Foi>B2RQNqJebZ&>*fZCFucW*NecYu&yZ{>wfj z_>1A`^?M|RV2(ZDJAi*G+BDp?IpvFpkW=wuGH-5^C6m<*axv@8TX3R1%()ISA8tYXMOBfW zMImC!cEVtObi(C`rcJGX59$D-7fA#4j)L?f3n1^Hc2-<%tia6%m^AMxk=KlZ+ zYuJZi%=?sfJpTao=p@sMXd?_jD#hWgyk`ZFpX6yIOEqmR{Mj6`sRxp$u0DsYHs4Hzq!C8X8W$%cr{2)mfc=zZ((t4WF?6 z{{W9>zJL9#JUIG?gS2g0CD`_J7-S%4DIos<6(5~?^GZ~#c@*W#S+nJYmOF|sVhaZ=$q{_*27G-(JV$RwCG~BPT5uC zx8i=a(s*-M8aRLlJD~)RsOeaCkQcSSmv;TESPuTx4W-LT8_6aJsf)Cctl;Mh zcR70tWtw4}<2BHKVZ4$4=x2(i+BMaymqDGpoOG$6)#ik)x{v>b6NwX4+0u6EAB=Hs2qjE_kM0f+*x%q5l zyek?Vp^tu*(@PAd`J{1G#z{w(D4zSo)9P0Y-+euEUZ3!T!kTsc&SuZ~*+P(##OpTH0uKB}&9Za=QNj8v*kl&YmIDQRSxW zP7O`Zo}#)pJX7FJJH0dec+gyJUYO=hN5}=YR$fTuSdhCy(ttEz~WxR?yT8Zuqhwm}T zuT%JWter1aw`KX1ovrQ|(gYURT<6z|aA85vHbS96>34UCuC>nsVo=Kun6inD3pnc&r6w$;_` zAf3Md0Auv4KM{1vd_CeBC5_5Nz&Ip-BV5*{7;B=cSA%5nD|hbJs) z%F^81_^I&j9}He}PYx08TRQC@lQ>dY^QA(-+lL)>T3;{)}k_^-t`*F)^nTu9L% zA0swKYVX3plipmk=MlR{5hv3*HLPPQbYC{-9Y-3q(vHVNsNIHVY~-57cpgb(!R6Zq zyPt@5I(C()$8TT(iK7In^Utkvwie!X!Db|EIT@@ewAIdG-Ns1=( z8_;d%n)QDQcwH_v8=Y?TceTTpg&X)rdj43&a9;~!MW0c-oZ(h89(~y3`B$Ld_=zI3 z3j+jDyI0s(4k1c%_fFh1r%Ebw-0G&kwuMgV0xE;kir_p)=^1r%NodfN{8%{#A6asye;Mt(Z=~yH7-o?Ni24Lt>tO(P5^_mXfn3js{72_sw4|1ZHWdf(sH^@2v6sbq&AqHlGVVAXhkEr@7}KWa z&kfg=da9b-_s@meSN495CZjOgTwww1D}wli;xv6q;ep@f{IQP1)Yq(dmgXM~_-sQM z!o?Ux9=v1oubaGaer;oCX2Fq)^{*=l7h0pKBizg;u+-s692dlE-?q0E2Nm@v?C0UOwbLcMm2zid`TT3s z#K}>n&o2uXTCzv3Ujq%i5HA4r6@`5mNiqi*=C*HdBbw#abC~$7iDd(Mmj!cQJx=l0 zx%IW|^j!}n_^oU&&AM^94_fE^I|~axF@E@UMB=*-h|6e@Npl>#q#wX5Bf|EEOIrmC zoXfa@^{;Z3{gs*W^-@*2yPZG9&xY226Q%Ixn|Ui+32mG#$}&L!5I-8p_+9X>-{6mh zHG?Xl)NW-aM+YOzP(lp)a!=!4;csUGTU$>mD@_D=M?b@nUQO|*#6rhNdvqYYoxOAG zUM&{qK1H$gmExgIP40PLhwWd*9ygj{nE{t7eXHO6cXGZL_*<*X{;Z|3P<_u8=idT6 zE9PAr$4u=TvUnAX`(^x1kHfwfk3qMTEcXL+st>mnrD=OgiYuX(7Cz3CsQRCt-adG4 z^=qkRouwtR19TO^+(xCgt{s2b;1BVxkHj}dd1Nh)r;}WLUeZ4%(fikUgf zaKfNq)@N~Uykr;e^c7j-c07V{-M&#%-?;tDBf2mjD@oARNnU-ql0}|1?B5_Cjb_jy zLA}W+OJH>9qx@-$aXxnC8y~`-DE|QK)p-0!-do27tKKmDBVWh8REZZ*ZTFWYcOUrn zg-R&=X;`FNpI(A8$f)zqIP&0J{$`dtrqkcd9Z5y6T{5cyu6a*;xnzJ32_~U!YjG6nfLNJ5sd)1fH z=8h6ZL?CC&MWVvFnh~!t_KAZhXd*ZtiSJUpaj4i4V^RDHc|VOdYjjdKk1QU67n4kD zSz>dtXO2977bH~_NbrC_8=^nl9Fs!FG-n7xFZ+E>LM5MZ*b+gGWN=i{CF{hj&kINg zCQhoO){qtemy#kM?>Aa++L8~RRS|m4CqY@$_?9W8@~i`1-1-h9UwqS(N;l3W)hM-1V(lP7w%^)hhrOYXZ*|+|Ag8&&p=Dvebx=f4yprUud%~%%zvnU_D{_}H+ zfa;Nne4AtT%cImU+)r|70t0f;uTd0TOUQBj>9JhPcyVbMje0fAy~nVtGT8aDq?ghg z>3WF&0D%v%t1??g=Q9qxg+q+c1!y$;bawJkyr=Kn6O&F6geS=?@^^OR3Q28P@?J}H z?77J_rYnNDR#c1Ww3+}6?YInmqF;UhqG?$GX@{8okvmfXk~P4$Dx<8R=QPE?i5#j9 zd%FS%A6lZ2^D4{C?of_IW4%|szJkglHPo@B2Zc`TeJYLTh!w7W$*0CIF3J1E{E6;q zEwmN4xcg1>i`%B?zrepiLSR=G_kJK?!GE(f(fj~@@=^8lHE!!oiqA>8TSRCfSlc7} z{2sM^;zY!=IaAS5PkO7WJnyDm2bW}iQxJox`Fqf}1JCKFtvjVO^{t5#iYTBic3uOy z@^rZs#>mX=nGa98I5oK~%2>&tGxtn+QOQq#rD1p)7-rJuSr>a5Ps&eGv{yj@SR`m# zAVx4gUf!9kUW`m>onvC~gDh>#iU%3>6ua(ab1VzAcjp_$0z+=(6Z@igCyl}3gXok1NBtxp(@<1oqcOg`&+ z`-;ZWwN%#88C=7A7ZM;CVe9o3sv(Zy5g0PezjvR^k3mQYH^xFmaM^F*aDMmy0A88) zkcVT&WmD0B$KYy75*3WBxK=$1_xJXw8H{_9S7N{4_Vl1G69!n!PZyg5@Klh0KU&Rr z?sNp)f#uU1_*CwDaC?tY{HoWSmUsZ}jk^<)Dn-1)lMyn;da8rR`?Lcjv@s-0CA`Ms z+4%r-`4iLWQ*DYB65IatKP68;jbY1aabnGPr$$T!=38exA9Wp#Ua@nk?wZ4EW8xy`HHS|^tf`yR}bA!AIhYP$|*V^ zNf(ZP`u#-!Om8BY_{z?`0*s7SUax4H>mg{K8`S>*RdR<1^X@&3Z9@t~o=l6e*Oe*9 z^4jcRpC#ccOCb@Z~OqP1K*vCY?##PV$%4>2Mi%E0$fFhBa=hG~*c zmkgj3B*X_Xu<1<~kuWU_D=FcSuMO>0zRrUR=*cMO4oT1S{AyT`PTc0zyD%6h{{XE( zT%gvn#c<800+JqNw2*v<+;?B7$g5g>;_gO?_S)VKOcHaSa6A2fO49OHHCX1_0sFEJ zdHVGqN{apqY2#S$g1$LP8ul%%8XFm1uV zAMvb))n?Ne#c;A;*(V8jk)7-H_4gsAq%tsA@LYccxj%rZKF{SbDxw!B?hqIn zOKah%+XsnO<_^~(v5(592`N*F1E3u3<%L)ghY&U$o(4S#JU9OUTB}`K#Ir2uO`NBJ zEJSTTRUK;LSw|qH?O zV@2p-S&y3}re!>jEC5H>6!x`_P)J|3DE|O~IIQ38sO>hUq!!X|;g$#A6a8_>{AzeL z2b>8O2_19t%OBkRYDQdyBNCegn?L25;g|61Q_X7y>?SE=j^Z{P#Xs~se_E}Z`@%CE z5Gd&&V$43HjDB@2+H|g^Ug_>s5BX(;Vfl6Z>X9Zi_A}_wvgwx~LOC`Ur*i)Qz+1I4 z%f(jcc3ndDa}$9apEPcN*?xw%?exn%fC!f79RB&T!{|;=@};}Cg61=BVp$_61{v2Q z9><(gU{)7*GJs>UXjQs2t~MG`XK@MhB$QjR{wNb5)^s`*&YigE_dY;8n=7xHGxTH9 zmLCx$S1WI*ESgm0;}W*$pXk(pzdfS6E6mc#k;H}gKY{-M3Yj+Bpj5YwQ1RwO06&PN zNn(ut?U)n%aE-{{i2nc@EIP5iKoDGMkzHn1LS1@E7E#C7{3(&i zb!u=?Z{h=@VI@KMb*m-`Uzr$?KZ+5xhv!UNbSz0zao=;3xPEm@%mUk3x4M+Xl6lgO z3@o^ne*sg8jHhZw!yQ&~0Uy?*k_&s3RA|ybItCbZ`Wmb)uHt>LO4FkM0L!)4TzfVt zfpFW*P8vju(lWWv(9KDqXivC{ZMF_rMu{&!tpY@9dj&TW@1uycvAC2jV)_gxA&fLm&}J z{{TGhM%Mfry*mRXlF6rhNi2@uk{o|5RG-=Q<}i%{mK+W+e>!-*+`M2Me;LU9=@#kb z+NvC}I2%XH`OuiIY3a!&uE3I&3(_f$N9SKgd@B8{^>2qB9J^C`}$RtxMYO0OwN5%fJe|*RXUui$295l9fyuQT_=h)cfYx1V>^~D z{3G(M?}7gS3om{zcfzPk!HS4R%I@T14=|Nn8SMV&Hn&>S4AlMS=i>QCq@@XivHA^W`U!8NS8zK zyh#q9qeiwSMEROY6Fy_NzWu}S72!`3unPN`4?r6O@fG*~0Ku;S_)EwB6VvrS8F+dN z+w0B$0BX3CDLOfo_c0^5AlJm-7Q8;b4)Om0h;(H}jvFh%#zkyfLcjFAKZ)# zez`R3tvWfp?@B@J`u*9zpC5KR$+~@~4$oF{FI(U*@WjHQWICv58_I z#|Izy>8rMP(M(1hEA;u!)1RU1RETWTw$MEIqHCA)1dZ~6fH?hY=x^CO;WeL%z9(r@ zykOdC@Alc$jIP-Ja1MP)U(UQ6PZCLTf<-OA19FUTJ--_J=k`JHpZp>|0oVLXI_;M5 zt(+{wwl_XMAS<$zdz^Kqk~@zTG{0?@^!%!F8zZkZ=f4yDIsX6&7sIVu-)V8>d5RQg zl{<&>uTZdenP=E@j+~4d^eRn3D6`9_7gh5^ zq4_%&o|8;x;^~?Nr-Kn^D)VE_Rq~8P3N3@I62k+UeRfT6UK$%&_@3 zaEwHUgDZQQ?W)pil;Wp(M(u22OQ^1y50<%CkbKMs9+i_u>)^u$(eZ2g=LqL>zWvsm&$L7oo4B-ZaEVDLjsI z=~>#goVNEaVmU+7oozpsFyAH@0ljJ&bpTi-gXQ(57`|(wXC)|FMU5Lad65`{oEn4T zHnPiMVP>TM^DbLHovF5WGF?jXK@&^g2r_Fk;t5x`uwlt6>bUEXkMXODy+v~uE?P3P zIhc*Sa}d}kJ?gd1!wZ)P0>|z+s<5dND@+d1d)G~%=|jZY<*uCq{L+xTb{XXU0=-tf zo#ysOiFa1eTfGkB;oSF0;Om`IV<=eM#bk_f3z350f3sb8!=DDpcc?AZ%3&hRgfQyf zl~Y%_wA1dbv`aM~WYdsBpnntfWK=-$kOd!F~&Z9wd8i%1Do|tvq_GPb;_jQv%$a z@m+VqM2gc#pIp98$!(OkIPZ?4x*+|9B$nqrRPi;RHi+{`yfr42_+NjnYj=9BpdL;HrFiJeyZYChL7-nvYdjX# z(n~1Z(gYbQpx{?#AC?N&IBQd=>N(9EPN{n(<SyoK@TGt#?%2*U7bF_7CDNm31uvrM$?>z;YO1^{+{{wiecq#5fVC0=nwU-ANpjVBFlAI{yF;MPaCTiYT=k zozRuwlZ+p&OXJNl%R{{a!e1~-H{(48aE5jca8Bd-is*EW7gO<1%WV_R(5?#}!noSx zcWmsFb6l%)e@t(%cvi+WlgXCyc9fI+N;nm7Q`Vq@*au<9Pjgj#Tc=;zEMhBWds`SI zj0|(TXlDIQdGXZ7wW&vG8y(NvJSeW(5RFC5c-X3wrBPEz?{sZOJMT7efTK7k+PDvh zK0a5Kt!>!6QMT42qa4+r8EfD0jp<8kXaTvEc9K7YcQxaemokq|vu6QU z`B&0^4kMf4_kndQi^!TAsiZ2Z{ct!R*10nYHx5Yd&qFJUjYU>_BwzTI;!m~e#=e`!~KJ!q2nrZUHn({4T4GzZLA>#+OeeSa>2=-Q>to1QoNcxk{MeW>6+}m z4@$bVy{k7SH)SK}Yk(#VnO;E>b?wLe`V_Pz@Ic_$zM)u)~#=3(mY%7Qn-;H?~XmUnXS@bxmI@63cJ?p|&w%!WxTi#s$=^}{DBOgF3^N;p}_=i7^ zJYT3@HNhV_+Jl^6o-6FH+VA2@_&dQ+=+?(G+^*>nPbEh{ey5uJ((yj7*VD0S410+u zXypDil`GlReNC%;aitxR&g(Za!p>SzlhIFF=jWDJK+;+;AKz|&O4XHPEyDuD^*psl zdjYqJ9USHRvvXYY)as6!74#^ey1J1}_Uua zl(P&~F$8kY_n*vf?(hfJvSzy%)+Bg@VT&>T1so5?s$APNO}7NUTrXPD({%f*#+n#r zTq5o%j^A32Smd?ZuMq>djP7IjR9fTav0BRhTRHcJ*ra0wa!odURyggYYxom!Y=?8t zw-ruvb8#tHl-neQbX6#S^C>E5-iq?w~@bq3-wCiCAIrd&!u8ue@<41<=7WQTS82;2jzw_ z{OW8;2ljlE+X=2_7Xv&*hiVV6tyoNXO1MQ)(ir+w(-eC@v&#ZQ8Sb1hssm4GioLV6 zE1pZ7=7cL2t`OWR`Gd-hPe3}>wZ;0TnH+ZV8+6;XnYkviuI{|35JUcvKXTtF6`ek* zA~^GH-c#tjfmLv^Xrrv8#8rz7`OYe-67MBY$&bAG`9)Q{@imk&0?+3;I9MYb=g|69 zZz}g!i~TZVr&+fG+6MWUd!J8gUWKAb^{YQMp^N=C>>eSHASv}Hr6AJg)JQtD!AOtH zwpQe*Kkqks(9__9Nm7q((e~kb>N0nsx&`SM??MyQ&w(Fqe_D+JNORHvGw(biKuOY|t?^)NLBuja| z&1V~5>JP#0k$q1=Koef-klMy(o>2rtqUV8D@N8Dcdzlk_*I)t0Ga;T zsz|q2hmjKUsq`J{<6A~@&c-F|pq@Ry`qe|RQY|9V&fm(mg;aNux}W~NNU#I}A$AYn zcQqtlWQ+2-SchHX{NkLlGcMSXLias?FY%ydW`&@emszGGp&bQQ)iNw@yy%I{FSV7h zqmN3z=Englk+aHp_54YydZHrf_e|~`j8DNm4i7;>+ztksdeg|kIi?=9u_94L6am(F zG9xaPCdXLVOiu3o7P?5)ri=H|w(xmR?w?xWygN0LExUdPtuujD#3@1vysXu>-43D$|#6|w@w@G zmSS=}F`AttT{w-3iwks6xZC~|6LXr?wApT5bsaD=T`!=(kPe{Knr6E$u?Z2!1Wx*DZagKaq9@Busp=k4E>aZx~b(Lze{Qkc@-wDqyBn5~f7G0A2z1B8!1S+g2CF zfQ?+@`6bWJ7S%P0^`qa$d~9^SNxqP31SaxeE|7pkL5Z3=^e#EV|rwl z?3$trT}C0n-Gz{Uc~5VpNk)Ri(x?{_5Dy8DlrjGR8kofmvf@N%gz>q7$Ufux)G4UP zBN(`wJO@#b{{T-)f7&i>$2R-p>AGSvJu|`LngUqkXwG9*XtDQIeqs4_rj+|Ke&v*B z_%X>I!kTTbhzze9M0$l>8r$xaMPaXTLbbKssjg6(iRD%)HK_#vE8pSTd~@* zk3v0a&Yj}uZ-jCAQd#OfJd?;_>JM>R(P`rFow2Q{kCK1_N7n=T)H<$%ZL7wR&xo#v zG0zuJN2WQUYDp_RvdxL(*zNaA5;5+h`gW;{O(O{AxC)&-$d`lo))ZRB_Jc67*HF)C z(ktLUE#2#&j`}EJ$ICPg(UgL5?kO+_E(}Wjq#X0~?M;>fn0aI~4%oo{Sf#bOkL;^0ldaS*(`xkn zIQklCy|z!8CXL_tq9ixyIR>SM!hnG{5+skhw_nWv09vr2CyQh*i3<*3a0Y)0qL)iN z-?+Ay%{>9&emUwt3U$ZYq?rSxR_C(LlRO?r$LXcUXssu%XcKQoba&?fcS|UN@-7$e+D-zx{{WwAkS1$;!w)w=#hj3<&*7Z^04kO(J|8D|4|1UL zk~RqZI3Jj+2%D6?@3Oi67U=%~hfl_tBS7tma}?w5>^aZnKnj*H$^NNV1Z2Wg57Qs5 zS6f?%W!-Bjwf_L8b~~egU^`XBx0Q|_-Lu+E?H`XdEVdURGW%qJ^eWjS@i@=cg}s2y z8qrAv6UO>2IM+P~Las4W~Xw7Mp zr${l?t!83D_6M))ilS^>lK42>QQRJn76xhXdGe@}%8zD#{&M5+su118^7&GuSX_68 zX8}*qx*x)>`6?s;mVvW@&mXV#p_EvSQfSEgQMm(+fNUf3ALrJgp4#5v7@9SB{{ZKl z%bfi>)23M98>ZVDkNIl3$KzEWP=m}NBsZA<0G{C%#A6}-+&e)*nwEm_*JcleL8b(DxCQjKRj>w z;;cii&85s2Q?@#D9zM|*%>({JkH(F_(uJ<>GRbK2L^?dDVf=v zE#z%#5AlzlCj3Tm`cjFbxWu4w{4W~s()jIn90BF&foLM z;Xf6na!a$y1VTn+PxjFB_=8bLawHAqp}5H9t2r=4EzCR#8ok4kImny4p8bjG11E1&m)z}F0@PBwZ zU>uQFrnMIVOmWD{IOJm#(qvz`&J7M*S;N}OV`I;ia5tymPDrU+Q=Zfhwnr_zV~;jJ zo-_Ux)ae>Lmv<_Pm_Cj3R=4QP|G4Xd0~ z8j3g%6pGQFr4Hfv`c(e_Ygfyz#>aJRn$4fXPkVXiKt+~$X16=Z`i=!ry3=mpkhHnf zVb55bYp?tSb)-K-c3oXOq=@-{BVZ6dbM?i2q5Bl-0_(u~tSck#w$(h}n;*vt;Qs*R zKhnM=(xrvSSd{_JS~nY9eg~TS1NJBLVzBXh75NpdS#UAVCe3$You_ky6t7QH>h8 z1$|wA;(N=D9wTr2-?2&NZ5?*!=280B6Y+mYwej!5Zxh6^2brT;A@7${<_v$|IQ(nV zsSDH8@#xF)5YmYlPYJ_44`;!mhB!RGvM0eqyuk zEQ7gqQmy^y$WPDKyhj@!M4iV#fs=MnzX?1+;{6L))-0?|&9;>Yn&wAu^=X`E-h~^9&c`)0E2qTUipwWVFHu?M&F+4A zAVpKq$THu79V*+#$vmO35BFb>(zY~hOHQ=4+j^lltCB59tyhDG6Ir8<_v8)_OH?p5KI~k zHqA~Vv9q_g82Y0DyVe|0UfhmkD_=tPHzrReKcZ8g5^o*E2gzbC+{PR4Ngrp7h}$MTg|HJ zxAzvwJ>8s!NF_WN$pjo9eASe)k5{^uTY_aLb2n}QsIQR2Beu{K6N=dIe6~8ak*Y%s z%5@+B2u=>p`21>S?77m=xLQ)=lQA?s8gCP8scC37C}0K;2^c+V*RS-QAH??3=&K-0 zn_L5qImcS9;r$xl!5$X5)n3;_itYR`A{1$HO0-sIM;Y--p}8y3|&r6@?F*Gj>!Rf5N>wH6ti<9yTVNqgRmX zyb7z~y$bJFmLe`LS1~KEMdJtYtZx$deKr384qaYEq-q*^`N0>X#y<9bgZWmEiY}U6 zK&);>vVf@&=oECVPX|i37vUX2NmRz;Jyh^TWp@UqZlejONgfpJWwuF5j2@%7HQ)Rt zSah3BQ^ZnrwTQtBddl60;aIxIh9uB@NvUY8rb4pi4nFX2x<}Mi?SEN^!yX*cbao2} z-e#3Y1(}WkKZScWC+(>yuBXk@i=kRmmqWAg9f$Vch;558l3AR~AU%gte?eOx82nOD zt)idZDbCP()V~I_?=MZi)=qa#nBpzib>sA}Jou-oG@8VjTx|yge@gKhRWUrh5%mz4 zvBF)eNu17$_MKbAAyk~o+XDIy_3GNnh-~!#01oM@SrBb1omr1kYlHZ6W>!gW8n^KJQ?EF{j5Rq`kFNekhMi{zfc!m{P;Qw(W9~;v=I?c* zpy-!cgt+^C-^^D$k3}BCtx2rw2UygR9!y$6^D+MOR|n$@_q@Ee+aWgRw_(Qq|zKYw{A&NI#v&ew70dm4oNYw$ZFg0oQo(? zjoWcvol7;L;?i&0^2qJJCisI>(e!;fTR9k8-dhxrN2+oBs}tbe&a-jii+8q<&4{Kh z3F(Y;n(e$tp~>P818I@OyXHw6Fb8ebv!m%&-wgaBVvlLmWJ1pP=Q!zJU$l=ag536H z2`Y+Ge(FSD6Fd>2Y8qU+$cZ7nhyWx{;^(D#2B1Vr*ib<|yHWoD368C<%l(}^2YmpcOE!qt&fgh5YtN01-udf@A8ljKZQkG)|#gg(6$~f zy#7Hq#f>*l@wLU2#V5147<}>*J#kzg#jgTQ;pL0O{v5gwWjm4bE_MPs8uDv%E#=9c zNspRaJRWQ4?}l=JYJ5G{?XG`(scP&I`2d7Cu801~B;xnnaB=INf0?dZ zzk&qCO$+MM5R)s&D<)1FOalB-wRK8EwX3M!K7Q1L21_H-7J7HMW+ zs;m4)xZP^POIwzW@wpB-uGacz8r6#{0MP>>&+zez=gbFK*W-lc%(7!YjdWhuITF(6 zzkCPKmrU^Cx^>$mpt(4%j(OhqP?B?!gy4ND_M2!lPYT<^8h~~M$Kh3?LlwR;gI_r0 zr&d!wtChlxDg4Mv<%w>fR}e|Z!zGnU5QwZiNAq*KDojh1YIT@;gT zT;i+C$kS(*c*a7q;ZMr7(|jkHZg1gn#yb90&v=RtH5(x0(s(mi{=l5D<6vx$!o51a z^*bZYpt&k0_BvmT9w>c7Hc+<;e6fz_xvu;6eDIp-njG4MihRjV7bB>z4UA9XZxcfj zo!jIJG3smUEd?RbE%f~!A@d|#o7;-=u}|4kRM%79!LMso)RxERukAtN`~LtDc;8fc z*_9)9L{IL4z!mcbv*s*^Y59FC?H}6{;rY`1Reyf~*%i|X0LQ4w{cGaS5InZ>;i6v2^aLJMLgyZ7sY28~fSjv+rJMSZtSe-{Nu7rJCk8kc_Fx##@Y5Jix1sZy%QwD+1sQk;$x?qAd7zE@rt;K2Nb*u>gfRax>0PYWv&akHQavvivSL z*Y~lk&n+YAtbfndN5Ru~!kG^Z?a)?4gijcHDmiTvWY*6=c)r-J8(YW2z=L#y<}AF^)fz zc>sS5N;*^Sqq$3X;Y-F?0Lr6`oco%pl3iP_<%CK*xa(P#ZAqm_x_G*0@*!sGt;s(1 zUfa%ugAdCec5#wwqH0L$Ba}&!a(A96`&y*IUQaj^l|(&0qpdJ3EQP0GHd z6o9oq*=4(sd5Z1iG34m>N^^{U@yGP7D-8)_P-z<_#+ao`xYG7vC=+<_H+SVe#^3%!7H>u4#?664KhBkRSOS=JT+^aNr zNeTqUO7}lXYEI=wVK&f@QP!mtij8M9PnNkeci2~h_|x6)S7DwdRP3>}Pb{&;a$txR zkN`<;Mh{P|M%h-tCTWK}_WpzENsNqN2L}k&(Ra>68lD-UlOohCfqwfle@c$#{wpmeIR`kDZX z2#2q=Mk8_xqy3&P%5joEr9>~<89&lsXOR8YkLDkjYJ1$;%$u$!QZvFR;2%TlKnSwS z9%F(bj`9P+{{R|jy-Q{)SA5Ou+MZ`=P;BBQ;1%SN?kS2xF%T+%2h8WN`gf|Z3T`5a zm8EYro2JviKjTiiy-QeHdzpk#0m?Dw=zG>4p{>JZIfl&*_4DzudE5B*9@JR)c1x)i z?^2u_tmR=JaX$|9SOzRtek^dd@UEYy{{YWcAH0*;dJ5UF(JgGDFxnxF*YC{P!9M<{ z^QXADai5eT4sxgtPsdNG^8qCC2=sejN#X{=iYOh9yTG%mY4b$GC8apX;QXVCutD&?q2{We?3tME zRXH#@J-w>GgJUx3GXWuwGaiGd%C)#vZWXOA{KwA>3E_e0M-_GKuo4-b z`XYi7fOakSPwPn-aQ;of+Qe`%!&I`w&dG5L1134g@ejtFe7lHMnn4lA8;%MeeA6by zbd7D8M(pnqImtNMed<=VTW9kkQ1PBzXQ#bE735Oyf=L!qyfBaE-S5RdCwq3-V3DJ4 z5#Qwl*#7|a=mR5O(GB5EPfT+i@_gG-a2$83G>v8*ZrH^lwZ5Sj-;Wr<^&YjZVY-Qo zk%-bVeq>R&k9x`1JSg``{h_Da6~AAb3G#V^xESg9(16r|gqc~$Tz%}f4eluk!6a1=00&*paTuqz;22aq6n6bJZL4nd_ z8fmGDmE2Lf3JTs!Mh9#+xo9=+1Cg z=y}Neszrg>yql&Q&ms1b8}TQKm6>lL3{=G;by7B!{QA^?YXKT5!C-jbfF`M?merfYsjqJ`Oc3QLb(N( z@C4IM*&p%aZ7sr{af~nEPBHmYzLq7F^o?2#UOzgh3mV6 z{{Yt&%U}3zYm1NVJv_&z->)SViSs>(;1AB1O4on1?#-U7=2+hkxCRkP?}9l$N`y3Y zvt2^5g}5RnJ@bHl!KeYSi+)3q2{699|*T2%Cx>zsm8at>O?kwlbRUiZPIKlUzOwpQOHNxEG zpZI0RPs6n~S9J=fGOzsg$3gE{3ofaq!G#Tbrvt!0JdrP{$KM}~X<0l|+Zk+cyyPd8 z18Mgd>zZ~Epp&FK(A55G#TL^io`iH5B#*+7(acLd{)>~UM0HI+KPBP=y?&#L z+_KbRxwZ2wjC0zYWfWkKsQ&;Ouw;S+G6z*$a*{Ry{Ogz0tSx7_`$mg2*0=0=iV#Xa z*F0jYL7jEI!$!gFSO7RN=a0}-=2)a`tF^oJ^v}0FYmbM-w-%*swP+*L?s^N8lN%qv ze>&CJ_W=dX^E@Y!_J5RqJxxu*{f!&Bt-~po5HSA$mW1$s!m2*A68`L`+CubbRKN26 zdBr|GI?^RFB7LcSWIKrbO-U4Hb=_=Hc9IGeAxGdb{uNgy4RwW;KVCo;5Ce|LlVVuoBW^V4H?kPHL;Xeg&; z_B*HAkuu#cSZ_fm@Ff2LD%cO@fPp^B{lL!F{*^-JD5hN8M3S%Y$iI1i9@SCrZf!tF zpZih~{{SqqT2vm#zvEH{vnJ;TRSW3p)AXm#!-aKO6C5z${{W3qwA7BEr7egKKKwT) z4nF}>eVB$GWX%Te;XoBn;&Yl>3gwh^36iTa1NTn_KNC^4>fB~1SM6Jd3{IPY{*?G_ zt)r1x$F;}ZnEcuNPfD6cE`NMH!P-STkN8z5po};?H!ZBvOJvQYxdn`5?v4G5_NuR} z+*!&$wUO-<@Fle$x<6jxx_IRz0C6IyBN7llK~PGOxO`w2`_Z`Cr}L_Otz8Lo6r%Dj zA`-$Db?PUK=lp8fc6D9YnSFnVepnTUccP`G!(8dINu^EPftX+Z0(kstq46ZvZ{_L{ z2VwsJFBZ9E{6ME-T#lAqyNHfYFrVR*`~@RR94ZGW4-Fd(FW@@XWIChjFc~e8W{l$! z76bGg)BeqKBDa}ze6##8AWZviAd^Lfa`Ij*3<<@*VnNz5_|wF2&$*%nq$B-pxa;-p zQP}BFe5{GM@HWVIAMme1@NSI_#md6b7{g$3fImvw2#GrtejfN5>s=*gh!ETcX8DDC zcY=Nh#|6BQ-0qc6JdTyt{5bexfJGwU_uyBv+4y{EVN|Hb26J6%DB+Z!L&m%(@QTC3 z_qR6>Y>cej7F>`kJH`J12&2`6)7#~UISY<4*B_truXMfB@Xl31h>mK$iwsKJ3Wr|# ztt^bL&xO2U;5*F%?U|!!y892vR~03e(8Fsm&pUoxSKMC~dSL{{cX)Qb(;{8FIR*HMO zL`8mz0?V9!V!lqd(t=zngh{Y+3mlPOe}2cecDjec{c}%^2ijsOJ1OoLApZb5-ioui z7>9MrbXrhrYspbZ1mxqUJJYYM7RKGgSn?c?lW!e=!!>?*RvX!-OdxIm6=P3D)S#Nx z+wX!@ktcq<&M>)r?4lCweLdIY11d3L`W&p7mBk-?2tu+(W zqgS2S=c0_sBXXffR(9vDeFytCXgZINY_;DH!>CQBTeYghCSUb#&fFsm^gB&>*1e`C zlNg3HH%{aN%tC?J@vp7FW&Z#S&EcPhFyA~IFZBu9>9&vgW*8yl4O~Yy)JH(v@Gt=f_2P#XorbC_ zCR*?wnSH2hT5G_g?1u6rcFAVJal!Pj(O(YTKD8UHfQBTTlhm^kf2Dk#`!;w>QSnB* z4w%?&6Hb_FGA6(`PmQQcbv?)(IIpwg)5e&Q7-HIq)rYNmR9b`|bH~IfRH=2bVofIR zM7%_}QmR#5an3>LYpl>bTW@RT#e7)X7|MWp7VX7z*Ozl@LN}G!$||Ti=mllpF&h*N zl0X#}Q{1H8H+8X@;>})YZJsM^f(gr{bY?J;OM+kIrRN!yMxW`P5o* zW!0`Y%qXBbu*h#}^zVmyKAmHCd#zY)wt{z{KPD(z_A>pYhR#TCo z@e+5tSajqY)>FDg_OCgg!wyr*Uy%JPxM>$wx{~Cu1e`C@nROE9AZ3kk)02dBJu0wO zrFMDacwTrg-fNaq#wqrGBh@q;fR7_IV2pbVm^7 z?$1-to(U(<2WEGg9<6EPuMf{;zeTSU28MlWv|g5AOnK>ST|J{G>4 z!#AQ>$pH@`x*GH?Yf^_$g4e%1D(#ABU(NIx!rQC@Mc{5QJzgX7j+!V4>B0!Hit_WZ?lUJ3CT(luWuX%E?4 z5*Pidsjm2}X>UEQp8FNpv&q+aCb5I73qc!Jg&LmxkZR=Hs&U=!dB=znO)APOP{-{I zH%rfRT*kJ}G@S6{P76!hbH%ABrzD8%m-|mm z7I$BNs=4h<&~4vE)Qk=Y1-hQ~1)b1agd4#se>&OJWBXl^l@2Z=!3Vh&Qo7J7+HvRV zk9zQliXROLZ|(qPWh$xYI#vGwhMMBvcpmTV4hV`e82eW( z;>n}C@SN=ff}{{R_pS<1y;iN%^l>x2C^Y&Wb>VAN{{V#0;GeU!$P}MKz#7umw22_s zKeOXPTuR8Jzuv6R4ULyDx{}Z4s5tc84SVIMgVKK;UTSj23^1z67$b&a1#O9+v#4Q( zrs~qMTw1S$yeS5oZgF($x>MgcubqA@$2O!Mc9Z6biD>(gn))YNh32|>+#SqG`U?5O z;;iO9X|`l5u~NRht9%-?oQ_;vo6($yg|TsY5^dp|3{8C@@Rl`g8(+4`{q?Kk>9mUQ zKMHBG!l@yT3=ZGNHSb>ru0in?&8Z)|Xf~hgOA^%X`x{`=g}J((Z3On7AB^pfd!|Ts z9qLU};ufi|>9AT{Or{7y2*6^!FXCr`EI+dDuQcnJlp?Y6i5cYAo%|;7^}e&KNgdQe zcr76_vXjAV@tSn)Dr#(s&PuPdiP3m=0cGKPY-Byp?_3f80DRSZJ3;oTW^4ruoSN)f z#!XuKJ9~8jr8r%q01nkx;wFP{;WUOA=G^lx7-yX0n${KKq@ucJNRyNj(yoEA9)Yq+i4)D;?v?tVMPc5ef z{%*e2wR(!3DL$;j4NcXp7%h?BYO;eocH{0wGg+F2*OddgAYfFFDzvWqvdl^5vAjce z7Ri^UYsRGx=eu5~WwFNU+Kl?0?~t#uGI6@8>Uv<)Ezo(J-1E&#XpyVUaW@g6`E$oL znXPL70BUAD^umu?^xwO&<-?W7-rT~uvbol7yqk1s8)-XKb^w$516--#}6WKygS&<|x5HpjR_!L3W%<(ZfQBSY8@O;QpLLZDA~ z;<#k&dJ!o7mS_PAW0F7bIKilvLmqb{k~{iU?a|2C+vc+N&ja$P<&=jz@CRUrHH&7^ zMA}}X95R;ka>QURYAdX2we9R0%4& zAyO0`K2y)*Pe&+OBRONqcKg4Fdd;%YWjMQ;3 zjhim=IsOrxRJ=IMq$*>DUiCLBz>%cnl3HmyFm4CdrPvCQGvw!lQ=E^&t4SOp0?t0m z3iG|r3H(K4wzVbY!GlGKt}(+;DEUXw_oOpo`C+qwywWACKz|qBHG;Pdt}%y9wpil_ z=DMHdKUEz~Pj97cx)SPP^$EJ|Q}VCjTM|hL``ch#f2+m^`TS`Gj9>Uh63*f|e%US6 z$otYr0UwoU$u-K5WM!R4@FChq;muu&?PX1o#uhK{kxcT}%*-mXIQ~AMepFm`Dh)`X z{qLIuXN>L6e;(ClIpUpmKtT0l%{hvU8Nr%P!FqlGe_DcjRlO(8K^%YG=zkuRTvjx0 z($K6T+kCfMaUpY`%hIV_!!$rVtZ~prypR6B)}V&jqxo$nk`c~uct4o_wNYQf86-e#*Dk8-^i8H{}Z{&=hFaShY1WgR(1I4ku((t(w1^xVZ!kOTM6<565d zC2i6XET7zQ!2CI>0?i_r?PhN*buup>f#B9Qp|8QG$ZgF|E`IGm zZG8OsQ#?ui=Olyl2Ru*&gb10VNLAavarn|n1To@AKxFTa=qZEk7NAE6ie0@`hB59) zs`KiwX>w-b7Hg;bv8T>l`wZ|X0$FVYPK6j%Qh2}|rJH0VeucQ?*2`UqfE!Ro00oByhhRMgEw=)M09nScK?jteV{)8< z{{Yvjx|P@2^y`?(%QQ|2CnxTj*OF-Yj@dUk^gRc?P}Lzwbj!B+T$tM|MhH7kY8K#m z?KQclQ%_phk~%3*S||&hXM+65tR7sSBuqmu`~+7^a?tEnNrK1$C{vT4PW8?BA}FM= zkThaO6E_Xh8%Mo$Ffg%go>B-H;RqcY(y?G?TnKf`IW3yrS0oG;7%{hQO+%z!UfbLa zCi$aVv|Xz2&zPRoy*~7nXO28KMjbZ_rL5^OYc`7{Cl^tj(8|2vj@hZ)IkhE<+2Uaw zw@>zV`=vfr9^EQ&q{9IdEIx7RBxL7z{{UXIw4Hr*NF=wpjbppE`DRDs{sce$Ri$sJ z8+kl~nO(l?rcOt>9Ar^@l*#0`hUysJEvSQ-+zy_{G?2R7BgW(`qk@cc$J4z*E6Hss zkt1&@&SXqu2h?@^X>RSNy9+8v19eAHyN^z2CdA8&Ndh@-hlrf<_!@MCSviz|6!OXa zML`5Y;unfg<%bd{Ok>avF;ZEw+c=CMQ=CTZF`rOz-jM8J_?yFvsY@QEr0@RGLAdM~ zNf*DRL!|1T@Q}uB-w@ry_k*9CKA=}v43=&rRm3cE$((cQ2PUw5SK)~-q+M4^lghtQ zxa}NBJ^kxMt{WnIYgdtsR}zmi*>EHuUs|v%(#-8_ygk{nO8$MTGePkjI{mSRWe{pG z=gf_`BOO=|t!b{O91?j`N{b*LE3xH=uemiHK`WMD7WkgmVK~7>=;QFDGOnQ*k#|PQ zcs;@Q1N>^5-Wzr;1Ts$nKYT6-KE&dsD|pZ6LL-BYti*=;j8xcGEyhD;D~v1u0CjqO zMsq~-i$G$z*)6+`pto$fIt<_-Bfq*4o|)Hcb*dt;mtVV*dbK z^fV!(4W*OA_iSv5xzowZ+OAKP>PHFPKV4^;F%l^--jfAm2NU^vZ?b! z)UG)|e*uwMms(@#_aE9CF;!;fTU_B}Us3N=JDLd<)}nyPa+cCB{IiZfnST;_rzW3n ztkK;&$tSBmpyVb@?1+Y@hghY}_>v}d?E_p5Dkv&H0HAjs$#@%0&{xDf~v z%z}ygk;d)3uh-Y}so9#@3)_$bedu>^eZl6T7r$mutln&014c8q*n!10BAn)1XaJ9{ z$p$n2=^3U&8p?0UWRRr5{{VNB?7-tcTCJ>T*80`2*{>q<=U!u!H!6Lq>P02VS=M21 zDB~!1PlM8zOMCfFb`^-rnhiaRR9GrU1_k^Td9Z)sxheWxL<}3d9{{UXPD=Y6g z1h$~v+MY)^1MxL`?2%jBy}iUsEx8zA%g_7^AI`aJof`YX?)Q2-8MOI6X)O;xJy$he zgAem&KP|$)KyImrsl?0)Ac4F zF+X+j*spO+iW{55z8KMEtin{0?y<`tF~>nko#u{p^f zg;x>ZXy{F|olj02`Am072=+b4)`6N$Hl-qw6cLDzPcgT!ABPnswv7HuzuTX2`^cDx z4{Q!iKXLVt!u=O2i`6tXhHq+AR+=^_Jx{{R9#Di*(rBvtbb()uo3{+v)39e1a+ z_WoVWOLcC;m24A_pssdJ3iHGHxzS}=?K-`c!N4Ex=aF3;GE8GF0vOQsNnZm$L6Ry5 zv^SFmdA`j!=ptSZ;%SqqnWuP)+fbKhHWy>>K{DVUrg%SE)49Kga8^=gAH=zA{W1M& zo}=Nr9YkXCCV9_Gr6+ot&6V|E455leI=z4&c@88&>T%S5G^{gjYrBSwi6K@)+^}4I zK%_{gXijC_3I5>Q`jb#-x`no<0Gi5Gb?V4)3j2E2j`;bKCfP4^QMtb#{{U4mxl%hA zRPTu8{U*zPoMNB6ETiS}rS?1@%9OX-92SOdL2mb)CMS|mX z3uV4#H!2cQ_hyKMBk`yW)#jXbO^rg-4rERxU#aMS3XTm)q$JtJEa~6n`A5*2kzVQB zY534G78;t}p!*|{{cDlxtVy0yUFac-k<_h5PaMOqcj2GrVC9z~?l$^fZ z%xAccB-#k_5EK6ZeKG!3Ev4jliY2slyN3ie)4hk{X+ZO0+8jrWk1l?h_7o_j7iB-} zxSoIBIlVZ}*%+}O(xkVwC`!kL&mL5O07s=g?aD}GV9~JwNgp6Tk9q@50-fo-LC2XN z1b!x+gv#sTC?4#~YFkdZ>|y@^R(}Ilg^l&Is$awiJPWISe>JPtSNj|Wm|S`$cMozD%4C5G>IKj2Hqudjb$U2xm@u5CJ31`Ft9 zho1QvAF9{N@1jW^(Uu}@&~O3hYuJBhxNa=GVWvH_(nWC*^BQ2-9r&kCEw+NBle1^o z_jVs<)-G+OQZ_kFzLg!0p4OMJl_UWF0Ht(#&9Qw_3#R1U+rulL?=eA>`R1E(W&?&8 z9F8lwRnX;hb-Cw16}%%Bt*6W8F7lEV;8)3C5WEf+=**FTQ2eA0MSaoY`!}A}c#w63 zI6l6=%D#2+hlA(4m@=apI1SoO@@Md6FaM@IhwB2l21A z^ymkNtrtwSM*B6AEHQ#Qs*G2JSa>RJKg2q0@&Sl$;*m-0Lj%XMO+j6pm7^-HBx87b%F6Z`8NhfMK6oS>g9FqMdXAh|fq2W{zMj4yCrN}2 zK36%mZdxOs;Xal0W}V^Kw1_UF6B!r-oEqoXMusg)+Gb+xWf@b*BhsNbw(f7sB?OU) z@Xx>y_&Y{;;k8>!tCue>-f{rKjQ)R}cN$fd#O0%Q%xrj5T@IyX4VVx{Fwy`|wrbX! zqIpV=TOjlmjH)A8)4MJBZ^E{7Tq4fE_u5D4OXCj++Y9LAwuMz=Qy~g5&p<0vN{TeP z-Ma;AR%c7A#72*H^MjMxx%t|a(bTA^M$GX^EZ*Es8A`jpN&c04L-7OYR#%p1E$4s% z&jT6%02=DNS)q$-w;TX+af;y{J(qwEaD6MO&zWj*IC9TJb4}J+h(M%mt%z=z9q!#v zO3bsfU5h5%G3$!I9fifygozo6J%wP-h_;Pu9X@S(e=6SqFWzQ63a8>N7V|-xHEETj zagbE=Q45Vj!j}wF%#L;(fzXP7n|H0r=Uq=EgbW3IR%+?!Z@hIeWFKj-)eu)LLDm2?%mZoQxio)!%3l!v1U^`BQwsf4l8fmrvLS7&WV_q0br(NWj#5 zGAyl))JJ1VO7F){rFpiuYbCwWP2uy%uV&RZKZfqt8=4sg-1g0RD85K@B4T(R$ahc(o(6vx*1+1Z{;A(z(0;032x^ z2(cDAX-Az$&g;o$@6BNNCsoyaZQ|HkK<3ikPc~EhAa>8aO+`Ab)Ru__7fyw#M_Dt_ zym2J9x&#)HkffrgJR5hdB9E zWY^JAifJR{Yc`n*>dR?yxb-|>*G8J+&t(M2Kryz)9-_R``sCfsxj|J2zZJ1*EUTpX zLvRfF4P6x|r>TV;OHH2J@Y?Zbk6qECIr~Mkc|+>EfThwjd#SuQ_O}>0EDdCQJdRsU zLrK3yJGPHB`wl;qbox!q(BA4+@_f+6E2uw-uN#|^Z6oO+O->2S;jL^T(Dlm*_k6E) zwX2`k(!*kw+M>ntm(k*B82&@*ytbLx2Zhde_nSx}0L!%MpxpCcK-( z*Ft-ni9E6UutJgVT2gMAm3nclZEa(e(JfKqiZdR`f~y~|t$J6&oi(jINX+>ml!Kp2 z^W7m`H31>c9EJJ-YuLUYLb6}1pM^x*QI*Sr=A07fd3VLD3(pVuhVI>Dc7e|L$zzSD zoPHI5;Ps%l@b;s8REL&Tj1$KLt$H`cT?X3v3rmRti9jeg%YrHJ-08NzolZ=~bB`?;?00$` zuwHnk+wF0gqLyu&6OvaL2EKvTe5(y23upqygaD|#afUW0!09cv%Fvs$zrbTCoYNbS%L15FTN>giDXon)QfXp$UTIak+HuA~40Yz_I z-58)Go&|EU#&rpt9#^USDMrgssNIrBH+qs@-CNo1Cvg~6?rXic_-A7WgdMI>i?Rc= zj;ejBdBW-}^hPi{!qi z!T0G3#^J(}M-_~=JCM1Q{{Xw1)A2h&CG?juKHEo;wvs{PKGl&UGhjdnR{sE&tU7)b z=1nwuaCab_wX%}(1Wv_<82u`m-pMBsLh6L`EZ@@=ac$8g21yOX{hmRPKj1Ya&dJMs z$Ix&q26V?lyh(57`GvmII21tP9XXai5Rr1GQN!OvQW=Ey3kyyx$cimXhLFx~`=It8csg^$mem(jTW zX)xHQb!iNzGOw8~c=GywIH9!ZN`(7Ciacdw;IHe=X~K&#Zh(BB#V2pmIjp@$TC}mb z7S`wO_r0S*xDToAS+q1sXEpwwBmyY>kK%Fi@$3y{U3i@qQY;~RnaRfIQ?U=%rCWU} z_gfqFX^KD&MXV=}$31IO%GxVPJeVR_Ap_H#R1}$oYi4flqqx>*H=O6}u$-sq$^5H+ z?n`^OD`^R_gn!E=P%uBGG4%UF#zcrTz1SRM@gK^oO{TM`KWk_7iN;6x&@$0U7Ti6) zz=YtjIVPY2I8W~fn<@Sg&;I~gn$h2^Av~YmFJCBteLHzw6n?O#s?=nA7W|P zW#FY^`C>09>Q%BoK0hH;r_}AOOMR`$kaUR#a(i*wmK)ji6bWGu+3n8_GxW#%HDcPr z*3`(6sk*suzchm^ex{@fW(&*WB0MiA5&Fjvw~1s;71_r z0^=V}l%r5+l%!Vb#dzKhK>YDhOK}~(*(7q(w*WZ6`T^2_ApXyhv*j1JPT4(2)2Hi9 zNN(WDTA1d+=OX}qAe`2MH`-$&P$Evd7oqngWK&F%E2y_1M~)l-K>WJU1vkF?420fW z$Ziip2J!e{@%d9OZ*AH(o@sQB(aVMWh>N>HhvU8n{3%| zC1&mp8}O?wIxrE!f<+y8`BZ&JdX0d$K_a=8iR+B9KA02*$bsD_?}J5-e7(6JiSJU2 zl$rJ``Agi9g5ONkZ1PIJWy=_2UUs>_{{ZWKD=OmheR=$wRS?+pYnEIydoF2QE?K(O zN~FW7F8rJfCU2ezJG^0oYPn>9J|?h-}MmJ#cfo>sBIySV@*g zUodf(;{(!*m2uCuLRxeL58*4q`_*@bDgIky_I4k7MdLogrgylRfSNp8_S??ytrJ{B zGptZ9*Bo#-U(S$RtkKJ5gomZ@tg({A0@U=cA9gU{XX?^L%7o_IP^9<=Id>sq2lkwq;O!0G%Tp)|QB zjsF0AlN=|g+AF1v{@)MH8_5$6RQ$h&Ya`+NpwlH>e)G;ek=tmlj(AqeK{Ao#$bNLr zHn*)}_XnXJxw&uLI{?YREzg)eX`<1sTXRUy5kD}9?c49gS(?{)4IG2cURAn(I-Us) z)I}W^dCIyL$LCT9Kdz*=2!fxQJbcl&r)sok zO9=!LNQ)WCjIjXy4Rd;gZKP@^RMXZ&ZsQjbqU~1y0BfH0LOUBvj5m`oB49Fo8G8{; zX@0{YWjkPFoOeQf2c=_0aXzmEQ(KtUGB-xd#mGN}T7oHV)W>mh$&MN%!h!VaaZScCXgRMTzkq*&bx$Dfgj+()-HYHK-S zQ7yOvLBU1j5%^UJF|cZvF(;DN>{#%PDG9iGkG;#W!nU9~Ig%oNXoD8ody(3*ygA}Xyh{PJxP`y8 zlz#RSdipRYr_&r(v~y1d$d2V>`LnV~GR3{S43AomGP)DZXp*FJK<9QoSu@wzkMXE8 z9LmWc*nf!~@O_9U`cm9N_Omky8KHh&JcZxx03X(#*4DGC%LmB$%E|@~dxM^7=q5zB zCLpOVn><;_WK*Jv+5`~Wl*4x?ywx6sio3ypuS^kdmodkNB5(1ll4-1)})LK zo_)SoJ%%vD`PE%ZPq5b`B!-IWE>=;U=zDY16q+cv9_WaUcOFsutOh~$IH{UBEP*hS z2gX2*7cHMsa48Oz z73RS_)6F2~WUqogfOe???{oGi2|e6Qr}%>mZRGy|mSmIq4wPzu(?t7Y2NHysee9eO>_DZKc;f;aoFF;F z3~kS`J?a*eUMc%Laz`qR7i>mT@ak$6f>{&GxnUD@^A&O%{se!*i-9C8_CTEZ&}XR| zoqnY7X|rBJF(tIh?0DFxApQfPsYQUcU=a4meMh9z4L`u_Q$PA z7JuA_WNQo5AM1$OpJrd;6hW!o!El!Pvn{A*!d*tWNe{CeZ9j!<$t-sytFMmc#9P+y5gxHyN40PKi*D0y*gW+){qZs}{c%QwTT>%gvD5En^Xz1MT}MAFTLMELelThW*L58|B8OJ9 z5xX1RKsR1B)uA8q zz=+rL>T1=dn`sPz;0VqA*7=X-dR82s7n@6%T2>(-*_OD?UajYJ8x{0^a7hZ3t$pJWKPZo-^_KXpoJ6WnRmmF^^p&k z@g|!jw$ZWj%G@ttnRfi>urfIO!kI|?_|H&-kUq5dAX0zPiC5<+c|VHw{#6K$Nu}Ka zi)?ihvFH8(DqkW(SI(An1aQ$V#r*oyu?0)}D_=EcX(ZDw&-}Jm3LokRQTS8|s9i*k zZ*AUaSMJ^0CeVJl>GiGXtf93WLX!YKXI0%N;!a26Drl_sp^_YVOg`kyTRzyUO6WaI zs2XdCH^j*BvFjM~Nc?(LyE_Y63YId@ z^GgQA)NPgkKJC@AlBeo>epN~&$)iHkOShUm$gcyA$%O~}YtXzBTG~iekITm@D~_~@ zgkgdt(H=yEsb04l-G2YT%MH>0eVUSp^V z4|?&R1^AnIL<$Bl4%3?TZ9iFPV^>4L9Sw9Ng`?8$qq8=O09@mo3dy#=j?@^y!sqW- zKAP#0J*0q0?O9j4%OMM#b5t~U*S3GV6UK6+XCkszaXS=ahN@aOij0w@G7O>Ou4;Nz zfD4;LxE_9jf!xN|bb)fLvS(=-1lNst(?>RwwiFe~!*#Ds)b(lHv`pNtLCLJWH$a2L zZ6q?eF4+K@3Ay48W9G=+7?ED3@IS-C1=Ozq{q$i%0rk)HtvUV@uAg_dLy?^1dRL?T zI`FhQZJwlKLAEm%NbU(DwWaRJ%A>q&du8zRbR7ogxwcsTyK*X>#DV3A`|WR~Phlat z@FW3#V`XwwpG7@Ct!GLIWFw&W z2QiWg5;!38E7xHn_eC+B6B~!;wkpoAeWt4D*7}5NDuiPuMdTp+x(a69v3@*?C#cOzjK#u<8OQvbkM`swxY-Ae4g34J=Fe8v0l0|ez zO4d0lLQ!d={i}IM%;P)yoYkKUrLEVAVvcyhL{?y@)}p_bFR(0e+iabB&JSP0s_4FC zI_0dRIbc-hwPxQ_btaNb+SIO^ZFFTnh+unHN{T>EKnglm2BD_QXR9!mnH7T*?OiR3 zrOlU^4qblrb2fCrSfb}^F;YQpxUF3x>t*CF{{S%@b6L~I75F)*EiZ+=6Vn+s}F+5AlW~{Zt-N2D=%BTY!sa&|BZ|}dhu9D&63RN%# zF(Qs8^gV??3vcq0GxVzO3(0koaHE==n%P*jHMf`n1FcCFkCvFn1cQo8dxUgg4odc^ z7g0jn2;}soac*k7z}u>xaaA8^1Ow8k-CVp8vk~%-LsTqvCc3yNq?3cjYIQU%-GTj` zc^`R{aqbOy?zuh1l1{Mx>5f#kIUd#APkyn?gth=9rE~r_wri`K*M|(ILPk6HKhC;n zyFE@i`s{fwi)VSSYv$S$`=E0)?s!q(@vnE&HLWMXnleFX5V;&N`_i9Qt~STRPo&uf zfmJs?)1TsEGhJ=R!})G}E^l3j+QJvx>?^jVSkBR&h~TA4aJ-e&E}^w=0dUh z16a~u@8q7n$*il(w1Ylj0mgHRz_{0w&Ir|F*UhCSqiHPbAPl(9 zPpQEDNc`)9)@PIz$@XJo~b*qg^ar=uz$S0sAcB>vB*UC&noXIPeYV!A~q3&HOcW0UWQL&BgC0LY_ zq!4M|1G|y5-7X_=L)tW!QABJVk6A%x}J-yrT8igfs~GwfvQ?b6_ka>OA(sCt7~ni#G>qvHyjG%yhGxr zI((-f5^$haZ8r2esq#fTlYOQ*B|*R=n)UwxhYpOsZUG3nHS=ZNUC_@mW=eEjHPu8kegWqYK2IAi42ww9&wYhW`L6744k=0EK!bj45FpQG=F9 z)j0GO7leE`EUm8Edt^rQoWmg>Fy!-5ystIdB8;EA^4A5_l8V^$tth^CyE1%NaVp!e zQZ@{e?OqGxy*^9LO+27D8&9#w#eE^;9Tq!H39en6AOKVf^6Sq9UR?RQ%7uG5in8tM zq!HA973tvX#ticDv?@wkp0%b$9+Tl~ThN%fdq|=_q=f_dRz;P&M+WBP}9>UhS~x1O!*{sUiqun z#I#JR2|RJ@T~CCxMbfOq(y#hN+~@nF+MMUh0}51m>Zj085ls(=v<8YC2+?rE*1l!& zp0L`+sV2+?QI0;f?q3;oPwf8y4Lm^NN!tU~zIoFF-8l0G)@SGGn&`mOz2?Zw>nOEN z9_ym`N-q+86l)n&7LiQKTeAb4{y>k$ydzY7g$y>IzsdSrJ)&u%N`)QTZ7+NN8K z<8+)K$kY7fMa-O&(Ru^*qMjyH^lL%X!c(uS(p}Dn;y=UdKL~h^cr5Nev^2?&n`v~C zWJww4oOC0K;HFE6&QfHMe|6WN&bKZvrg%{aVFy6D$OF^bvdaGR_hT>Kxhl<0S4V9q zI%}3%kqt^k7?uWwKfV`mtCp{Ca6feF?cC@2=9u=vLAq>b&}O8tu(WBjA_0;3*D=t` zaMQ5r2x0c#RDHIal+=2e0 z*$bTd5m~phTx%2gA(u_J9SJyh74BcMhmE{5@T12t-)dG-UPK8D*BESqFiG|5YYSZH za?tS2Q^Qx+Q~8&F@hyPy_H_9ek9uW>k~HKiV-PWqy(5je{Cb-ApV})}@UMvcWp#Do zm_$|#NsU4RK<&`ih)?G4Do(@)f%i$MlCe>DL@#h`?z~% zr+u?7RU4mY0BH21{t=mgj^ggXxBg zktD6aBpjdcq`*mHx%)4eZQ3dB>5-djF?UKLGt!TQkZG3I z+@!KcG2jS}59D~oMEZ67WS5>fg8u*mjfWra4l00=Wp>PV%1W=^ehPu~^`wy^iH<>* zMmz@PiTWOuQd`N{lNQp(i;!eNybq>1HJ_>Ys>eo;%Z!_=$KWcQZa?i(-(azJ>fYYi zJol0%qAp0}u75870EJ`!0BUM}CT6zMPRlG|?p$Dj>}hPY3ypeFcdp8)i}NL*ZK%Im z-;H8egm$}9CFskHei;6ATa+HAcA2HY;h;9cE-ssvm>tZ2K1FHAbqtOk>A)kMgz!H; z^gOF>FiT4#at7eU{{V$WBALc+0DZk9&I11cjVG?+v182pNG1gW6aN4$Iu`nMs?sV% ze7K@A1N<2WpL5olVe^OjFfcDhnNHuzhF7scwovZh!6q2D;wTvNW+Dbj&(6Ki-5%ds ziJV>xGB=buZW%4~CYmIeo!2nu9T5)TKMGNHZx}@xF{XJh$K($b?kfJUkz#x5TBb94&pxViKg=k%%d9YQ@Qi+fnj)$4SGb|?6ljw%}+6{3`1 z>XM6#z;Hi_WA+#{hG)IC_ORabd9#Zhq5!cubDc*E; zXadK)pWx_8^{R~5QAi+2(nj=+xL?52UUjl{X+uXK{{WVf3=gMqKo%Cb!!Qg&WLo#2x4>Wm4otwsA^6gn#_le^|))ouqw<|L* z-9_{toi)HH&c6k1$~hWkgJSpusZ@HpbSxNj~m zUc56LJXQ~Yt|N;`nMei1%uanauQj)GA-9;!v9O7-BwS#78p2Od8ys`n zNFiy`BtLYbJqBIBd%3}3xn!C z=vNx8L}LDj8aDeY3@X5e1dbp3%B2%aZh-b>LemS zM^$ryx6>WzkzBEn(onI=qvrW|BiAORlOM%?9ye~9o|t@{LNA;5T_hh`T?@p+Uy1GS z7)N;+#F3N9Be)fB`$juScBJN5SmcQE`%kYGkKzvx%c)5n{+$%iT}PJl9fEm-9fw+J z-Pl%VN|Q+hp(Z2-&KU3*d-_vmh2dqmj|Ff`D9BbGy!WnqLD%Hh8SQRn7MDrGM6vwN zeR5C#0IIdp&MSFYxC_@aBX_@8Uavz&K>;$bPh3EK-;ILKMt$qDBuQCEjED zZsM5wN?vW1?hK527k|?O0<_~YTd;&$gDExZ~-2p-lU?W znthg`X!ds&%7Yo>18MBadYaCT=HtU`Ej1=LI(fi``~us36{~Z7t>i?kz~k<)A2xoy zsu^yenjf*>x;n7S25hW@wgBjA$$A7w?9!Pc22>*^B7aZ?UGoxX7HqU^r-D9QA50qL zT50?w2kJJzdUPQrI ztc%`8+En^rQ2c?CT-rveW4+DnPrzx8K>C4P&G&~dbV;93(BvlHtd`AytUuWm(c8@? z5oeR98<9R!^)&wgp478K=v4KH{N#Iz6Ye83LGcZ)yU_icQID)deCzAiwQRlC<`G;F zS3ktxDgOZL*E8ZB7Ry!!{wc+U?0kv$1b3EOM&+ukVw<`q04c z7D+Vx6x}2_$v`o;{srmnM6-#RW{Hq79K1tu{ByvpXy(+4mW^$e4mo%8okzAh)Q3u2 z#q(sknnq)T$c{7h0;CBi)h$tWUC5}}`=zqS=mu$q{&fEUk6Sy7m=6L*0zJQ6@=i|SZNOiTp zw@BkS33U{EVsA|G@|xR@SyA)n8Dn9Q{7v{C^j+g)LvspDl7AG0kbmA<#eFj4M@FAd zkWFgGCe}yV0RI4Ck0a?!Xx%4xXNC4LKg51h?YFf{9H#FeD=UsRi~;oqv8;8AJy58& zGDA4wp$VBkAMvd*G|Yk-ZDY3>&ij_n;sEbT!W5*4`&|RCc2z9jjUxVrw{pem5F2?*y~#QCy4Cz9HocuC+PO9HWm8!tweWU-dj4wp3CzSSo*JrEp=Hu z$q}tDhbwZ2YiIiXD@!X9-Hqjv3CKxP%biaQgP;5BztXPkc#~|=2_rmjko?2ywZ#7b zZ|b@p+kILyb7-dm?iIoI$gZPJ)!{M6ELSniuYwQ)?}P9CDx({cX50;>ljN`q1*=hS2>AFwD zg|fJUJMCs+y5@BN731!q_ODi+9y%(H*F4JDnMF4Rdmo*$*|(M&>NOFs@krPs+zPBG z%lTGu%Y39@f%2#0p0)IUj(=u7N5eA9EseU}M1v>I=0*Ja*OOmq62T(}g`$=-hEg$# zikNBB)uJh4<6UUZYfI8Dno{sOSY!TJrz1a&Zd=;J9O^GtU$kfaWQ~++vuO_Z3iyor zsI6fDR1wM+;xp5C_*Mr?eT|I*`|MH%z+@b5P5`e)(mY7F2&%3aamRY`)VG=mK(v#u zSYM~$Yp(F6@(C17pJ$YD&qn-e(ThE7UIYl-W~06ya&O}`j$9WzM=XuTE6$@t`d0RyI2Zs{nY9@ zZ_i)Gr`G%?hf+HF{{X6r(a}4N{{VDy>dcG=Fi>@(B7?kBBT5 zNTR*9$B{V6EBp=IA8O-#$snBkgr`H1T~3X0G&ZGo6C8t>&q~eNYK@_+Y|6I!e6q~2 zoaKld*1*s|v#*0q_eKacZ%>6<@*=B_c+YB0Y|TB?o@P`5Qsio&i(386H`i-29;z~b z3WrO%7E-Hy=$nR7#bSf8a9HEsrb&oULCEx|8NGK37u_4>UyruQ zPMgHH;@Z{W5`OTHyXb3_&^0^DJBcJ?$;cV>HD+6%?VE~N_in2YD#q2t%Zx-Lm|CDcrp^0$=jxa9S&BUHVb z*HRHk3EFzsX=eUGVmjj$Y6R2Wgts=Sb=P+8qP>VU2dUvzgR2{@&Pv}69l9#)>PKp?;fr7GUlH2cAO#t+ z_#JUxCXVtxidTYBJvzoE(7YLM6rUv5fXevv>t27U>Rav_OpKF|4{GYXeXXj`G9v(y z0R(vT?_Oyn(p zx34{`CjS6aF_t05;&N*L0Ecx)hHpD}Y#P!&>}-ss@6Vb>#;JWJSH-R z=DN8E0B3+YQ*3Qg!qp&A!j4a=sITSO5pFmctQ?%AcT=XJ8=eXKK9FZjppS~h$<)O+ z(H_a+@LR%HG4vm1Wgqv+uQvE;qCurz>KetyQr5^4TRax@uUOIS8XXc?LXyTJpnKqo z^1q0;14hzqJTqoi9! zUrX@@vwbLQAoh0O^U=u!k7Xa$weOuAE6+5ycVFF*@mQwvXy5m{;)k*k{{XX8-)uJ! z6@UTq-o1_-KRC@wjHS|RGKkJ6?M8C=aG$o+^oKY*xN z<=?^b;q)oTsZ##mQHq`(~4WGr*H-A62O`4yO4Y2Q`y1*Es&Ny zLa-`6rlAu^9JKF}MgRu@eMM&`S|F108Jo+tm@a_n>e0M@L{ z32dxww7EA&nQq&Lkn+d#=~CQTLp$%e!XEr!AH-0y$83D7e8}W*agctdoFfdO<$U9u zt7S*w-j&KKwWRk7KF-e^OTcIOhvD9x9P-&6hA{H=CN4{TL91w9><=V_jBsT8tbT%$ zNn^2Kjq}KQZu!{#NTYF({jf|weC9s>Ny=mEp0uLmN&>{&RQ~|WMjTX)ZT6`WN!Tds z<*CY#u6os3^y4!hvJ5VM`1t_(3U&f;@!R9)FCp*uiT1~(MRO!^L}tBlZwm9YHqrRj zPN%Cu;rH_V<-EIo7BZi_&(v0b_ATzUI$Y~T*4ytTyniqQv2RLgFdZ(rf8s`KT_rq= zH8^|6`FQp};aV1c8q>6xO0fG>>^^pr=Pm9%D%=+q_TWWpwG$Wx2*UjbdYm*cN9F^U zZl#VLsCsCHW>SMk1}13T<6iN`*$edsnoZ~%V@RcsrHDnf_)k8upEV!TOQ> zs!uVQJh-C_{{Y@!xW9&Kbswph9I)CC-$N{3yOVhQ#a@M>yj5b+GB@3g2s?hg>Bcc^ zjPkH%JzcUt3{^R7W4$|?KPd-HFU)@tMu1zH)f;3xcODFYZ3EZ?O)$r1Jmzz|rd@tw zeK@T19QIOT)@eZ;tVD-J`sbQ?)Fjgs-CoBW$Oqn5*|*pONDJ$1^M3keF-6ob1P^nH z#=E_jSDoy%LhRolJ#Bv=jk^r(WVlg^kdjt67Je?VveTQzcJk%&XckFfX1r7I^xn*k*6}TWgl^Ho0qsf zO;2=^&lba+kC`$;?P_I|&mcR%6QRQpx%LM>^ayMQ*5z`Mhul75%a;9n(%UzX#s^{n z7)L$V(}C8TDhmfLxe>^4;GD2NxT&5snliFU<*NL_$p{DM)|k+@AG$t#3U`hluwm&? zU+LDKBGV+dmNT`!;|^I*Z=t8dtU&AKsu}JS^06UrZ0FLFdG9Tl%N?MC*}n0e`V;(H zMM8E98NMCWX0o_#BU1kWkGPH_a@$0!+P39pv1uoaVh3!>gNz=>n#|R_F?Fol`NriE z`T@EF8U;{(z3LASURz(ILw6V&lm}$7%1e{(Itpl{>}%cMSUO-zWRxAsV+Gk;*N#65 zf;~?1>-R#>Y=B{!W3&FNznpv(^xcxc~|e`I`{T8PDdV@dqf($e)`xSuhG2hy^t^Bn~`Pg zdj1t<9y^GNNSGMRiH(4LJ!#l3XHBKXz|M|jJsBPFtUiLJh{1OcmmEG)uijYHWl!Tk z31v1ngKU282I8ijDyw|akrb``a<|IE(*l^7=-n-`9O|yDepy>AaC(9|X0k4cT`wd>-T7%5D zZL%*>8vOAeMyUnZIga5M%~8RTv%Ba$YZFk@ZZ5S1)O5HdwV!TeGZ2X?`>$;K)r}(h zO?oLU=D0Sq#@KsU;=`x7C$$5)cwNbC2H!GBPa#x=^8Sa@+Nw*a+fK;wFnN&trUWj- z+N`FN4ZAO$7oN<1S?(CO;eo|oHf<7_E!k0 zh?l&UOMZ-y0UkZOk}BYiE7T#dq#Sh115?8rSr8;(W9Y8Or`CWd!J^rcCV)p9^$m^x z0M4bI1BDS!n9+v+>PWF5)-(hkCgsCwJL~YjENT;e}Rr!Pp`k$ zfH)%tso;5)Nt000bAuosG=Je;R*!RUsKzerW{yQ}8C#b8M{2t*#CGu*?*9ON0pL4u zK8;*%t)o7XdNm&m%w(AJEwTd2SHC?7rC>Xevg{b65?=#;Tf zudQ<$CyJx-G{0te($4KTZ#{`(d+i_6x1JQV#CNw7O&)L>GqFD(@@TLQm&mi-D48RX zxxs9M_zv|^^`A1O!6qQYrea1HaZU}$}WA#0a zH8eP)Z!v_IKyrT3CUH#);w}6GCfVM3$5pqFKbh%GxVgW%Kt9hEpgkp-x0Cc9wRv_* zhsgzYakeqH6vEOge*KdN_|GI(i5W7qS`4>gP4&dOgg^|&TWXKMb*t9?7=q4Yd7{p? zS0E4EuOYp?s>Q5L8Wsx9nCqNYhM#UH!z_xfKZQmOZ3k%*?PIU_Yw;5E!rHVCc8_m( zrAoUD=bqL2P4Hj#nbG`9qepM7UB?y5ApPNC$j5s8#e+|pX!CIhaoR`f1!`%!rnBLN zGHH?-?bKzXelR_adumkcK}M#QJeH?Ur#w2Jsa`zsKg0XI9^&@%SF}y4TXHsB@zVmn z1o)xj4Hr|^?tihYnQhMkX^t8B>x%RF{v}J|6xnktM>6r{Fu^9XZYQ4hR1Xnf`e^Oe zldJ6Mx@uQOOAi@U_RU%O8*zU1_&dPwS0jPY?p7H7=;t+^rRaAvvNFi>CU8RZ73sbn z@RBqkjC`Jz=UD7+#}^f(llT$m^9GN)$;tc;YQuQ%#!h`JMjr|lbwIm+3hH!^0~H~ebNEwidz|H* zFLA=5E%dJc0KlFFU1CEd#fH<6J65Kb;dO~n74O#-={_3LlTWidR1Er2aimkU@bB1) zMuUozQ1HpNh+I2kJvpv}0YUYxS*Fu1ygI^SvR+JtsvdFA6z*M)3fvc6q<(d~;hhO} zDD@j=AvZ_ngN~RroRQ*9&GPe{3edFFO_YV=eXK^)j^OpBc26oI(m~R1MkC)6e5^6| z#(AmkL|niN`2Bzb&pKWN9yY65Do(HXKEw&Lh@!qOO zY6}C<`&QrUjfNfVq5Q<~|KkEJ@^6Pyvn7cgMwwNWInT&%#MAZDb7-EE(8_4O4=E!~b+ zin9=WFk{H#nhlLVw+D=-eX&_r#i7Yw^;ze5X2}E*>)xHHUWSNnQYw^gCo2@8z9!%# zhl5YMwvK(^09UnP>QjZ83~kP9q_f?p>9+Tz2FNXks_vsW@Ob(!5{MEr#|?uRw<#=^tIb3mo$z( zU-m%eyKexhtXSu9(jzj}*ty0x>Ri9D-w&9;z)6w9QQ@F%uJcUpyIUG>j zmo|phl`*r^rFjVRZY2<)?iW0ohU#L9UuYpmIIfFU_<5)_7P7`RK?-M{J5-M6y;9R; z{>ITym%)FKf4y5C9Gd3Z(cb1Vpz<<*TJh>i@^(Gy5VSQLc@#PZmas#B6?r|_`qu-0 zsyj)LyMrDG=~{j%@dI4lNgwcz51^}l9q{CLT2$AQso1DYW7mplD5TjEYIEg}Lx#IU zB*!Nev!sO)ZQaSJ^=}SbeS!@}-d{G^xMj!OKJ`MuBr|}$M+cg_T*3*^N&A; zde_7KJ?}N~pSXv0c`tyhR}U-;gJzZ~Rg5 zE5oN-Y5R)YT^~A8=nqBw1xxV9LHiyrW?hgXnF}}LUNtznaOQiTO;&!=uP;P=+52ty z2UhWijv~0ztq!9NmAp{-7Wiop47X0bO?*YJXp`xe62uZqV!RmV2ZP_(r~vJMh6(jL&ws%e~&*5VEBjOsl0D%XtuhQxL9>4(Lh(p+LEaCB!GQ$UWDk% z6w^HFSeVMJqW(w0lKJq@nPSNGPHO#*oqekoTS#QMXxDj)B|Cuk{Kw~A&Ci2%7+K@C z)5^N!nIGAt6<8i}4ud3S$nKap!> z!M^EvN6Lrm>r|%DA}+TYjl*9|dDsugzlD0w#D5mg;;m}#`^dAgp5;_XmhlMU$Q?qC z0reuhrsiv{KJ84~C-XD3#yPB-HB7m^X19tsVvaRE)N@T}gi3bhpQp^Z{b`n$f=h^2 zW?0Z3QHIiMYU0^sfW4_J892!qTFaEH_b@`b0%jx+MCd-Fy*Szni1$PlSG0rVZ@^Zx zT5v?&G2DOHsOGY1({Umc?Vr3wONn|I(m^e=2Mn@GI*C^-e-YA`&ys(dc%C@>h}&@d zOSe*l`y=|)_IfI4*8!rqc_(r6G{eo^`2PS6 zD_pck6^-Vuj4xzuKFg}xuPop1AL&;TB)35gj3}r$+?)^oy%WK4ozli0Jmd>^>GcMo zi&Nhrg&6}LhC#C*{d$&$nCDon_e(GhDL-|_58;Yih=t}n?$IL?SUDFA=DwTidv?W>JZ^vIgYbRGG@>iJf0}tAy*`dSvOgh= z)`6BdU1EU}cRXxSwTHPhlUfza%;d1>t}=^JVyQ$7Un-9##=~fgA^l!7SF~hnZ56~X9%=m`uNzs6gNWA;T=&`ohGuVc{A&vD#8+Mu|3HZR@cM#8iS?gu)(B5x;Q@aL-ifaKT6ZTvP_p(1JApE zy>bX{Jp!7znu1+O12~Ev%0WhB@0vm+mEPy~aGjC#X+Z7h0pg*TPqBpyFjz6xMasE8 znH6e#v{XpdcRArB&Ojcyrp00jGrM_@)H3oH=|CB$PrA8G{i%XyA{mr81J#ce3tU}C zFP9W6F$3?w{3!Pa`BnCju1Ssowk-V99yfar@ufR-_?R&OXES*H zP%TSxkgBQ$X8sh6{{TvfrAt(MtEqutFkJDs*?-2V-RDPvu4KAbBz)^Q<^GI6m2+fIm5!{Dx3y$NC)neA} zNnleS1+t++;Dg*$u(?fbX1ax0uU}+?J0ehlho?N$R`#~mQ>-r-llzY$EKj#JG^E&0 z1Arowo%)c5j>L zYZJtJM50yItQpf#h-LV|l|BCe3fi9T8DeBbl6}}JV_pZR%xbz_+%&=|VvZS^=&R)0#EH*RcS4p z;_fB+0rwcK>o2lumMb-~qsHsF5Hi61PkO6cQIbt^lR8al46iU40T%(+(3AY>$IZV@ z+hdG34dyoH&wPwyH9T_zn63t910~7(~DE1<*Nv1^`E5YSQBKB0yf8HmpSb)bhvCAQfPV$Y+Kf|G$fjPC(YGGpoA-jl;#EjpxT&rpap;23!1 z;6K0CuMI@0WQk^)+?*>ynA&|Zdsarj;OpDjKHUr{WpNPP6ib$q--=hbwmL}ehMTrF zA)szoVrOr$^a7ixU%|99$f+RZUEd^oU{@=lYI1ARn@joSTeSrl80=ObPg>uG#WdMB zBvGt+;gk~Sex{j8Ty?&r_XlcO%_|Y}C>s|Zp!!o0Z`L;^NbO-}CI})V z7;kWCSS_4}NTXeZea3Cr_X3)z+M4-^BN7gBpD17R=|B!}ElRXwd~5fC$zkp~{#9F4 z@Z>teCFQ(^-u(t*xnGw*?4N46Yv$0IAu96{sx(n!BFPi(koo7kR} zNA+cXtpr+A=v%fl&MzjfmseTk?}t!}&HoW~Pm1#!H% z`Up+I3&B~D0mh(vtdGimJqv$%C zur%w7=kpEX!t2qkQ|2DUkN8vJmP=AR*nuG7WRUgtVe3;$jvLK^2q%KDj3a$N#(*lV zlnXY?#QpyO@Xd1GX8!=Er_!F*^4l-^F?k_BdNmt%gV${W;VuhCZPqM(<&f@gsQ&=# zRJ+Zi10-_XCuhzv^`w{>8s~>>b+9H$7SB{})HVG-T{J-v=)w!fnZgI{{XE(D+VREMMNW&eox^__jb36BY!pAatY%U zAu~NSYpn&=9Y~3-+vOLrC?xwZ{6=jOn-ML&~`Ox6QRD6CE*Lah8*otgG=TzKkr0ngMi)J>MJtGTej8} zA}~s~dFBKgPvCkHTb4F=(cDC`Mz(Ru)tql{#B{AvGi?|2{U+jFvcb8Lxgm}Pc3vLQ zV!B)v{ovyYxj&sd!}DDC?dUy%RyxA&7jqfR4y3M66{g8J1oaxLaYHd@uK; zIX{(VYF-pDNM1*GL(aj%{#EKWrFI1@7<#u_tE%a-O&L44j}FLH&( zffk>)2s(EZ#M9xNg28_(>M!h`?UVtYF^p7po*j_Npr8Vk!<6U0u!CHwSd^}M;<`9= zu!uff3g3f95yp1_2==IMVc3KUmReRSLvI=|1az(YJA{tkZy2slV<(z0NEL1ii45aw9%0>$s(bBb^{!6#*UFKGa6!TLHPuYQqcSI* z-4Ly_(xXwX^;>mo9(0TO5A1Tf@E|hI_GfaxolZaL@4?)zlO15o#+O zLUwc=eL)qYk~!k8jNJk=H-{xcaP!DAeE{kD)#IScYaC57Fjg5-d)5B{4r$4436^AC zqwdF^rndDxPAT*_p}`Lef=)YN@mM+8=xZ8E+MKL;zdMG6#Bk6C;IH zB%S~jsp3{&4(S&5+d;UMce{Ug+Kzt3Dt6S%mKasTNfz^*0yzS@UjSc4YjtsIsAE>c z4cuqexwnlX(KP$xDG4%_au4wX&1J{pSTyZI8R4DhaBx(djApc#zY7_s?CNtPuK1@O zO{fT16k+dPJ*+??byh+{{nAGUy~ABp@g{+1b_bS!jGy84uQKsf;_7$FZElKSjy;F~ z^c{Z_TH&g4$CT&AQs$`T+{V-3f@Fzd8FuT(O4FU=vW>#>!G3Id)@Fb{!*3*$f2<=4 zy~Ra$F-T;|!EWZg6?yB>^W4=XZm5E7QQGgyAgRVd&uX1+gUnkQR6=Tor3{8!c~MxX zh!X0gfuB=XFpyXRN6#V8;YT99k(?_xt0J(8CcLqwkEe$AC_pZ-XzWAXov1{ci5unJ(uPX5^+v-m; zrz_WWWhT6_X)!7|-oOuf(9+}kb-N=r)6|n#LW-fPM_j8q>dl#+8HLwYg+TrxlV3*o zKTiJGy3Cxx5CMtp#d#lw^myMAE}NiDuFgY}Vhy>-^c@9R@jr<^ z!C~aW;Uiz&tYgq|UEPkHnkJ0uFk}(skFOQrULw>U=TvCe;X!7{<3$ziXKsSTNnxp` zw`Z&TJd!PEMYy+`HIf2;;i|qc(=@#&$bc`F@-_zE;-UCAA!}=pcpwfdL&dh{_g6x3 zh=|%To@+V#l^QgI_LLO5ni@jdLu|1^q!0kf>-g5XKE*#u&e5zw4b9Z|tM{xSgtmCW zsd}?WE=b*+j=y~~T`-8`^!BfxJ|(Bw6s(!6mz5?)sdKHSvvvS2A1_vx_2gdi3=o&5BJe#$RVqY=Tbyety zJ%Hx8u@hD6SssQG62s7Pms6j&j$a>m>UmCH6nqxP0dtHG!n%))o+DofcscaTHraDB z6(xGApI0k2=P@VjaL2)dC-xMi6nRnG@Iy8i&4_3Yy%6-^}10|5!Disuci z{3!6wzYCSL7RWMVFi25;hpl;bwfi|)X_|!hQbLp3kC;ScB(Oa>9qZY2`&~-n@!*ow zS-Y-52XZ&kyVvjx)|x$~&C8K-aQhY$$RlYWeMthm4sM6ja=wWC`|+2+O;W+rQi9%h zvUX+wC^+`(UPCT`tEfB@!q)PsIWC7a`ZulV@oF~$7q(LzU5P8t;l+6`#1Dng>a(rH z`c$w*jK<6I@A|O*RZ2G>Ge^nG-~p>5ZjmL68N(qvexwd6#)I(6`r}iI!p=hLBYdGG z-h`h)Uga;rSuE`uNUquDloK7g@;;q<*9#4nq2P^a8bdvm%x#d;MVuV_Q0bYvhh3}w z$G$Jnd@}mo#fXv+o?$E04_sHz8st&hT*(CO<+79V$-zI3eMjPN+IPfh;fVC>jaW?_ zvH4*lkdj7mf}q#UJ}lEG@g$FRbutK;=gY=3^)-~CV&NxqkDphH3EmAl2DrQEaq}X3 zlS6oQUl*E`ziXGMLGq&e0pIIWe`8zQ?oB_;wU_w3w#px^Lm|GgZPOUF1YVmPV-Lc) znmZAF6i7El3}B29;a9F;kg{N57KB6@{U-2~q zxVDVI6$aiFTa_O5EVO1vqohALERrb5c%LiyDd|y3_NiZbM;jpO>U_6h^sACtgv|1O z_5T38jkQP6QXn_KEK2Gz%SqJz4QFAdReQ^Oc=!zqpZRAh@=vbentW5@12y|9Td2tS z!Hj)tKUUW4yfU%|X&QGQEI15U`_?_Dhi^PxA-cXM%FLcE;Q2}XDr{Lj?}+aFMRIJk z2mwrC?w92Ndse24;d?z9KGSjy&HEpkX}(|&`#%2v0LrDbw>mlt3pZIJ9U*nvN7cL4 znCJT>rRVcwao-z|4{u6~4Hi6!?ho%HXyf!o<^HF)(wtr;e<~>VKVL0AQa--4$nSr& z#vqXdWA98382bgLwAPkug83bcpM0OKO^dl+>qm;|%(r_P$M8y-RsR4!wU)YW`3Td@ zmeGUBg|{c*4Q>XK_!A_L96!S!T>VeFd92H!M4_ zG0J})^#f{gkUrXzS|RBi$Z-DvRvF^7B0|#nlD_vH5e2YEy)+g^>IKg~&T=uOf zEp0S=WsW3W$D>AfZ$9)LMtfM=-8AwxbZb`*tN2n{>Q;>J`^1udrDR_%{rEL^QPB@p zz2ZcS%^;RwtiyngF-^RgCvPAE#C3Q1kv|$)Zktfb#Ks7R1j)%h%ShrI2#3k##(Eg~ zpTPAsnIuC@wuUx%9ie~Xkslk6(C{h^w=*#F2rzc@fk%_^9Ac~{E||RGe82f;85qa8 zJ?YmMEg2EV^Uc{;=1@INHRb~*ze`jRyiy~u!Ybj{@FS%%)#IJPl0}w5_f;E(`UBRa zwt-^Y?tIByWVkrM`gW+~h7C!G5e=h;nbd+khAC_ao?s?_CK854`_Htl!S(KHj;Vcb z;mac|tbTvGxyg{!8s4LC;cG<^&Q?YrC9xZWA9}*lw0qAIUJX-HR+0?-kb{DLgSYjn zwe=}zL1$%m;|s;Pye1n2Y=mE5Vcxq8MPn3EMH+?Zk+xU${{T6peL^T>@=`x7mxPK) z@w9s8rm{$*48^y~!{&AQ!+TQF)+3v#SL~}M=amv>VblOX`g>I$u%*PMEg7xH@kowG z-~p{U%T7#CV5s4w95M9*qTdy`BqJiH3a&Gs#-`R*4>GH*#*-TrWO&!1bt>8VjOM6n zHU&n1Nhf5HvZ>ZJ&g^@UP-^!{9h~aL3p7kP1QEfeo})OE z*Ze7`tuH}MHM$*wicQp05!ZMcK^=?vn|!96sdoMYwQEns`Um#zo99BCZf(IJmB!`n zD=)!vv*?!)2-){ce(G*Nb$k75a?VTX30a^>=8x|*tVtu+0Jf!RInu^lm@G-YBDmwIOn{zPw3X&(4 z580U+m<+~2+6bap+Tqej%B1PYC^`4$lNPrNn>n0$!grQV{#f$VtxInd0x; zxS#N;t)*F^^EU414vzeW^dhR8kFi}BR^0@r_kj}|4I;KITg>*3w(h6MWBZcLfuBSD zDqD=oqs#eTGn8o4C+YO4?k8Dec-d2F95bAp_8@wSg39)1eJIzKLX_9E=jjmUN97l00?gn|L z+(`P(q>;y9^1~p;`+^h?!hkf~rO%fnOp5r-as$6V{<@VeV%SpQwo0FU9E4w}_O2en z;_t)Pv1u1KjcsoH$0`h;_j6rC=oaz?lm@w+a>nKN0zENEBe3?>?QJd_?9OHn{<0f& z^9c6_mT9jwXoACJ5+^xHuB7TcfaF!{BX9P0WK$ab*M=q97J$*%%47(!+CN?Vw0{2p zN_V+_Mr<|{w99XMdnjyW64`bukF8x@7S7E@#8E`k4oI8lE>XMb23q;5h#P8hM^8t3_4AsqCxEQLr4ZiaZ|LlK%i`SSv+;8QfW$0$%?B zoiByFTNbAnveW>I@;(CN9&gv)x0>ZN(5XJug}7Ipx_`1ob3Q51MztWn)7D47kCM?x zv0EMU-22qM>_*2*vO@xsTViI_3vP+>M-n0)G(qHD(nzmbh4iXgG5z$sW}s z9ha5Il4Myif)HUy_n=wZ>lSm`-bWda7-;&iJ%~N2f+w_9-qKG8L9>mA)9F_v(@?^) zq>B@PFvxyk_4O5Ca*J_);N}+hLQCBwoJBmGv|sOgS3?Rcf;)>#ly-0mn1)q{y;^0C zSQ-nc_wE=zX~H=6&2h6&4}-NOeKPOuwi34KtYSRH?g;JeOL9)iCiB_LfU+pdB*!Q9 z>qh5Yo_bZ%ve|Ks3^dgG^jJj^7L%E(SC1I9=(Me|cU8xa)DB9*hbBmOb$Y8O*&Cz)#q z*nhv&f4fa+QZV#C8NsYLu+<}Ibpgke-zvAiwREwU)A9*cw?D+Zy8VB~u=Q^aS$L*4 zl*m@`e(dPHf;%=ptzubg_udk_8h*FTY<^3T$;WDc7NKjM)4>$r%?r1fISx((fAy(6 z?=%=?As_pyG7V!#d-mhzY0E9*vB@LljQ%xfo2&I`eB`tPtZ@&UA5%gyCXxwk*p(`= z4yINa{3@&6o74N4p5jG3G7JIr%})-NAb&GsTp$?pWjP|HXrgoulCc2bd3-O>im)@L zYw?wlVYLi#zA?Fvw>7t}6WT~4 zIm<`YPyYa3w&A~x*-}WvPmHq2d-~NRV%ei=or4$8W7?APHsht!QM(E(|_umnx+C@mn4rQTBnb8B~tLipfhjC6P?3)3`lbrFs{Iw6s)G zssJOWrBV|+j|-L&DDu`kSAV13?NwUjw0$(8Q_eoM)L2H*lq01vBd)UvEQ^(`XNeVl zRmb62nsvv{s#hkuD8}R&7~-=wMK?GDCyJqd6fIx9P%=jq&gyq;pp5jS1X2ZKKtbB5 z!Exp{TDlwM6`Dk8s_el?l~1?Gg8}7j6{IrBCFlYzED$<=~Z;gGj%G5`Byz_z0| z`z|w8Z7xiAv4LzG$@!NU{cCqm)6AN5mp)+K8T*I0_N={Y#L??FB3NKcXi#BP4!>F~ zuY{@qJQ_tk4cH^|TmxMdrk`bRY3547-yH>WcI4nNJk^z#1Cz~WS!Ps1?8-2wpr{!e zjB`#|!*>+;SkBQi}Cf5o%V^s28jIr)jMj{4RI)Bag9Ju8!xGD(1V6ckP) z&owMW#KD2V^{pK)-W%I8taiErbL&~EcQ{k@sszaHtj)wyOwYMccdm8Q_ly^XHP=sU zc6nqUo2fNIT_QAPZ1bL#X_E9a?XA4E-nk4qRGN;BZFQ%cuQPSSJ{l$K^{$>5u#VKE zkP$9E^7O3Of-TGJQHD7DDf3vCl(#wfEu~1Jga+E9och;8quH!*&aD>rae&9QPjhIN z_a7_PTI(u7q3fx!CID{c01c^vDw90BZV+e%VR!2H?HX^lDv zCNIme>qg}oz8!|hK;~G~7|6pO^~?CVCDd=EnZ{I)lzJ^*w30(A!lx<@=Bk(1f?Z5R z#3(4)&q0BaRZcf|B66~`Ios6xUx%!%3*l|2c^z~7Bm@36R^Ls6#Fv60_l^nY2Q_O& z5!_npS9b)6gkcn8tCPU|s;qjImWg=MY77K8+yb}WuGmAHM0vGps9T#vba%R)%sv%( zrXn}Ga${4$!1S&w!?DkKu3Ey$gl1JPf8NF_kBNM17mTm1#iPBt+x)8%aug4GlFwI? z(^0Wt#fX_-Xdmqg-`L6#ZJ4Z7YE5=GekkZR-U>}U?d54Ey0{4FPq%90wL75|v+o&* zfxDiAHPHNg)V%)y8t67a;UUbVd)FP}lOeHXMJ1d70Pbtj!&CjtIgbk&R`u#A>~}}v zc9Rc-Q%JY8WV(?8J7Xgi$!a$~e~^)bfT!42WtM|=eg~0k(n$*La-i@lukcm=oerOV z*S3Cr+`qelTbyT){=Gw|3Qfuyml~3sR9|ZzN#eUly0*8BJ0G;nUQ`{?2g*MZbNE*) ze>ThIhztP0eNB3wh%|4od9zBm5dQ4|9Wnm^*CxE%PSGy)jb+jp9#;oDKhM4~kvh7TwC)`gdYbxj6K+slh) z3|9(jmWihwLUw6$j&uELrqz+GEacK4fy6>FFylE z!0U?j?}lCqZxLN2Wn0LNLrDFA^!nE|YLTZ7W^YQAs!x(9Tkw;^#{T#1lBe!r=O>{) zm1pCQx<-=LxgWecFh9FqmuIEgcz(=VS;w(S!S_D3;eQw|PNU*jY!K%dEsoXTQkS={ z&(R-6S`xzH`8u;(_I0y#xxJo2)kZ7Y;nP&=Qe8>&9AQVGt|Q?mhkvqkrgj7u>7PpU zVs@|L&2wWX%~`Z}9b>&+a z(DK!?WC4qe2 z$m(jZi0)3Mt$m#dWmW?My;0gNLcjC4ef>4 zxg`1lUQ?u6zmGh6_Y!k$Y}=K4ab0)C9dy{~4{Z}-X|SpXTA$&^hnK^i9}`XTPcZWx zo2VGgc~Fz52QIAlYDd~uRMyLLXT*BcJ{izvynHW|pSx4tx&dD?_`2yIMvqjzRyR6@ z%#te}>Z2db=kTt>;)jgn@n?u5(=3>qSf5}Dcy~Q2ABg@OYne2C77`XHVpz!|p~g?< zNv_-_Hx{qEJj}+oJYO%uwE3QW;rpYjJa+al5Utg~>t5}lX%a>7?@^B7N*%$n++YlI zP<%b`1*Vk@Neo#a4bFRUTRtkflKN=;JEj76HfMgL+*8HXjHICvU?|El_LU5e2WUf5 zxDz~KEp5h3+0OiStS=sXNRH>m8eWlUAoAAX7;HQZr#Y^h;zqe|@bkf`VPC;tFipYbYYO?FroG+BW9a%x0nBy$bDRmgp_4P_F2H%_$t?f!4ly*R)MU>(^1YkpvAOjLUkA z;nUX`>5ld9HhMMAxjgb&Tf5t_$>5QnrCYm>cq02Ip=7ec%2i|v43YjA-*;+24Bh(jKAcyI>pl_I;As+VJ(LWxt~kfJ{{ZXP+M4c- z6{VfZL>4D3T0gU<*#Z9mXda@x(^|LGQa>*L08xTLI6G2kwvp+E2LhXqF$zvB`1?_c z?#4z9YEn${p?0YEs!a&Hk(yAAARR{-uYU25!>jA~*`u{DBOHfU-m-o@E6ufUgSv&i z&@ntsm zC;i&j)--voWfsf1=3X60#>41+>N^Vw0U^VwPT=(XD;E=~JuYF*9N0ED~JH<$=09{{XBb1p45ATFLP~uci2U5+!3SjsE~H z&&hwUrDoAeR~a0!L=5w~EJ`!E7;XOm_0}Gv;_JO)=-Fr?rtS8J9S3|GuVdkjOU11% z+dy=!I$lv@=RZOHYi7pA??ZrE+j%!&9JB#;NJptP78xFo;QMPF-rqajUiI@BbrM{d--GhXyeRLF@ih-aK@G%{5x;aOe}IgZ9_FZO9wCcOlv>zA zX4DvAV}$&R?t4~R-Q4O^$!($;A+#AuCqFVLs9g1}8yyyH0t2!L?m^|}jj<#C29}R- zy`Gw}+D49_YHdSOk#4U@{xR~zKiLMhe$v-Fyshv=IB5qzm1cdWOX8^^5vb?OxEo3O zpHFI*OWCY~ZjF>5^sj;LuBRY35pIEb^5kA$ z+@9Z^D#JD0i!_@ioA^`?7uO#2^L=rjv&GD(?<@V$?oCL_H>TU$Muc9C~G^^sez_zJJ~$Gj#Ob~5J;GYk{#C<2`N z`WZ|+8W~3nXCEjB+;LhKt#5A*AwFVtX%Oc>QQoCgf;(`o78`Z!%eWK$O=ZoaTVBp2 z@_d-d$S{(O-sYOoi(=~$sW}OSflpF4vw!ugJH(zVgFuJLi!XG;5yW{>^{l;j#LuC> z*)6Vq-!b`oxfEqPbsx&9=$Z_c)2;TgYdp;V06H)mly}Pyz22zQ z)cJ%R-nlf`Tx<~}M7CLr^2|*UQYq$7 zVNSS}Ad3Q3+Zg+M#D)LJA{Hv7kbgOBmOK?W<=Q7Hr zvGQ$PAHdgKZ&A>!YnH}AjdNW6bt&#cb7>B#VDU<&p^zkww%Q-*x_TnOwEiROnO&8;_F*&ZUFF>dzknpIF1-~IX=|)(2E^S z)QLI?S3A^iMklBL0IsIAzxz^oo3=3Ff-W|nO5yw|tWP{*!F-c6VE*!ao;we%bSF-k zCt0StnrNATkz!qoO4c7!M(XY@K6aS2e>V~WcQ^aP9qKWtt>dyo1lMr5;egnu@ii@_ zm8==t6iQimZ@k$0(!h^ue$M-)M*jd%Nx%pDrl6vXX?m9n1H3ki&dEOeeKS_>G$oxk zyG=a6e6H`ce=l*xS(?pmVKEYsF&R@B$p`CBvX!m}mgrh&j@I)V0DcunOrKAZ+BiRR z0J}g$zZ7t=`B+4Qa6Xx)O2)cOL-+Oh1U^pJQC!gW^B!OJQ#$ayFu$D@42kJ!?bDn$@@F8(7%k)*?MlN zZM2NBXZ=$~xZUhcJM<9Sd8eI7Hl=4Au$}RrLxEAvV<3^8XJ*g($YA+KJ#$xOv=a|8 za+4|fCUcB?AFVdpXyn`^aw7&Plyeq@asaQpKe?0&iEY0;OnJ6)aO zJR|Y~`x=sPbm+*P}H5_I3R&A5E5=lOb9C*i*srOXd) zsYos7gl*lom&YHSYYX@Jp2E(1m^zo6Q%a$(C>~#f_FG4<#gZ^=uKL_ z3u3Eq(#2@Na%6Gy4?-&%I4?DeU?$X}fe-LoCJ*9jYds3q#$VsteWNGky0~K4_B8AQ z{{X^E4x#(IXeYgt3_NkJ5Pm+D93xkO*v7Ew&>l(_^IbFCR;;$etaEH-`>P7zeGjb` zlbK~`imVe4zrU_My{U@jL!esU!@lO?<56bhGq%;w(xkQtVJF$$A&D4m-x2^AdKy{d zYiYy5E6o`H09gu;)K$m~cX0f%!yIS1IUxJ{&?660@ZH9faN4$jn@Wt&YbV^4dV%Zj zS2XQ**TmKVcNk?(9@Hnz8+ZQz_11#O_MA<=;@>|cnZO6o)<(7AwY$0g(bHy24N5VP z(efttB8xT#gdwdPGlJ3Z7I{HGKx)OvSN-}*M5DYw1!3q%Ls_~fiEZ@+YaK>ECsja9 zkxXzH4(pmHy^7LhSGWcubM|Nk_fMujtt2l)LfngqaXe`QxXd=c~mpVpi$rhAVkP`{dBcmfb(NA@SL>s4eiE^ciM z2(nEa80f41wNLxKX|UbgFCS!*c=NPRH^{$;>-tso(;={w$!P?3at^$5{{T}{3%GAL zB#iGiFk>sg`g)2Iuw112WRe}m^dTdKwN5`1)ctC_npK6=FKIoX!weRg%dqzZ)#X>y z(S$M&E2u%$KA+)CcT2d*Sp4Jt?we_a&MN0k)VvQdmrarN4K0uFA4s;_mhvfErDVW)~{J= z`qQcpC2V6SnWg3N^sORiEl+E;yB86uM+=YRCbg~Q5|5d@=I!P3E6zMWY$Pi0aQAX| z&kAeOG|PlbWshRp9CS|q0F7@Qj!9@q=iWhpg>BQna%sgQyl`$Er66!Y$22vNb`GVR z)Duz&2qS9Zv_=elx1D4=JS)(F^AD|Gj~6PhY~_b=4QY5|!&ln<+FMz~)6T<$QP#Sx zJK;vB1?ZM|B(i_v1a((Yx>o`wdIrM2M&m5I-4^c@P@?2L$1fsXakcyGeC_GlJJ zu-*8sj$aMIAwwGU?gdG9D(Tm1$N&|)XFtlI=BV1}{zG6^v=&1u4;1cFM)r_A$5yVC zIP$41WL1uS&q-rA zROfN7;>W>3H1j;t#lX(e+}AxbEwBs^BcT;qUlK_l%4B2PtxH4L)tb&FbtpJBe$LPk z0UdkR6}F&eK-tY|X}XE~KnElP!0kv0@4P)E%e_Y*O6Mrz}I#<8k_ z-D<41D|-x*N-$P|TIZh&X(QoPvFa+c_RQ=EEPopG!{K%Q$6J*@Gn@siE3XYWnIkK) zkIjr$sz^C(&SDF8X!di8+S0W19@0VN;;uudtZY{Uc5%|S^vx>bD}lZoMh^VY7BlZA zHW6i)9qT?lX=Ow6bLm<>C%7{#qiDkLD~Qvscisq(bF|c08PwZqCQOl>R@RcgYnc~3 zw-wCWMddSr$P7hlSl+d?q1T2}MTK*w)HK=bKvq8?@183p#vQq<*E;-~j42xdkU2f- zp!KPG7MUu@Ez=8-PaIXP9>CtqAQ;LJ+}3=DBzRf$$&}stB`-K1n>=KTnP00VpMKu`&Eq*SeZw zWJSy|<8yi%^SCt`=euLKDjVrtU8T!;MGiu?H!gbBp&fpgKbd87I{>6(ar)KC1~pV|qfqNSt}K=SZF(s_`>l>bhou)S&I&NJ!`t~{{X^$Bf>Tt zEn-)hhtD*F=VSHl^shF*@h^yU-9hEDy9eds6VMLTqv0PPYTE9kv1?OpwuCf}sBzP! zX;KyRae5iio*P)nd(5rzDkbpQRb_pRmjRT4v>dTNfUm7$ zM2V0t0)PUKKnF_mGYXu^MIPQ)Nv7t?o+#E+OR#C=d<4&5TvvhUZ7+-bU@jOJF5@}u zGhM&Njd(`{b|~Fbd+j~yAA$M_lH^^w@&;*AN|gsOSCp&bRU~@_jk4KV!)}8Nia7i! zbzAndw%l+D6=WbTT=04d&b_s5PE!*x5T_>{_^%;JH+FlDE)a2vOSn)Iq-6lvNg zjY|zF)=oy1>VKnv2TG|FpbmKEr@NH0AW@S2g-ZsUv8j>zfzbA^HZro*_nkczin@$~ z-q>^VILNLZYuK(XUMC|bHJS^dwCtZSudFyousMgD-QR=@m?$2LdWeEY38(^ zt~*y%2}V1fZYC0`JFSjl4-37nxxM4uq~%EdB7G~>^y_ufw4uA@eY=}A&v-7vTgcXX zqF6$B&rJJN{{RrR-|W3Oq95MN@+kV%)|;(00b%JZJRL{WTuV+;p!_y3zjtw)|}*gc6G($odM8 z;YWkl!#ZQ?S1Z2mae7z!oyS})`pDP(T_vk2{eo3~wSe1sAXhtutqpk_k z?M1DMthds9p?fIrnoke-cxVyaU0kq|<;iI!J%woLdUx5?uBIR9GW^a@Z1(&snfR~b zL85qOI*8qR|&2d_$xjJ2&8yL*DQxK|sGt}3x{3G~nHj?XeJK^@3Ws$l7E8eJ; zDo!WQQJ1!@a^J=lQQ|f#8mB!qcwd!L(bI)PqstR|~6!6({gws6ENE(0*_pGwjx zE!mvr-$T!DM1ByRg32Rj{{UAMGUbq;_6>7-%3kZ`i*9(KK4f!Skh6#P4V;f)E7Nt4 z3Xc=nF~3=0y-|d@AV{n|{VOX=_(yAN8cB1jNeEsENcZQ{7&)uuO&O^>pD=jF+IxHH zT6w0P@tYw;1q&YJ*HimFcuT_izmBa}#uxHh>4bqJZonBme^Fik0L5Pn*?3kpnrUA0 z8Aeb`mE3)^)AO$?u<$mm;)}nxt+|H4f<$jPO~bBMxu-<#grpxCzD+jlW-dk`W)1>Tv@ep zZ1j6CwOu%b4CZA#vGm1ZC9VGej99h9n_VSa<%FHDFVxjmuw#4n{eBCO$CzV4S}2FE zJJ&-Nv1fm>D`{f8IL78XO8o_5)9PxS%MWL1q+2W&*|#bN*wk@R9WHs~Stn@bU^DWJ z5&3tdjbpWyn9Azs?-@Dbn)gq3%$DRVV}m?BN4L4DdV*Hait2wYL@L5r7~rJ9Fkh*v z>7l}f-_Oz^e7aeTLmYXr+gzq0gmM+2(xrB*KT0 z0}PL;pa#axsEn2#PpnOX{{T=sRMN>6v^Xr7JR%jrC(u+EYikf$V~8uU!g&V;kJ6y~ zB(XQz9_A_J#(cTg?)?w-rmXc9%Q4v&kX%o`HU1cZ$RAqgd{5&QQgrPz6J0;c=OE=h zhpkZY2aIRF2E$K{qLc2+Bt*C1J*!W`9vZi>j9fcMad`ZY?SmKR-j%PfLES9`@YjSB zOI5tR8}FOu*&y5#-|Jga-Yv;7qLKl_$cLp!Zw&GnVwl4hNhEa7)DQ8Ytzor~bZR`X zKKdS4{AdECnti0pvS^X`&SV=&_sFdO0PLSKOxs*d6}aoT?^iya!mmW)_@A5oA3Y=( zGJo}|;Sfa`Vx|D2tY;(XKoq>on?wxJM>I+?x)ZU1^a88Le+<(GxL3Ce%ENGn8TBWj ztE+6+QY_Bvv3XSJ!lk*IGav7nzHS1^$Ybe4cF=Yry1R)YMg(qex#S#!^`u*7Q|C4$ zoxsV?0*^saf5JI$gCYIg$NpMdw$mTBI#nqfP_khhcMEvOg^J;RVehz6*1@@IYo9Jp zv&WQ?hf;R{zO@bAvPGp`nAk{sqq+0%*ftt-iprcwt_=TGff0<$njK}~M7>TB^^jBaS8G*A{guLwzT{gpaFXAIk=Bg~b! z=)7ao{A*GeuFQ{Zai}sXjH+9vGQOGitltF6KEX6QS9h4txc1;zMR5seQbm|~=ywEB z&O3wku6Fen#Tm4lXHv@pI}S{fF=Lw2(0dfGZ{1hLPj@{$apn?kc?YcWOn*bhnl< z%yEs(%kFA>TWe|5O*fl!zxbwn1Nc$0xDRb&&uSPZ^8sI#Vok*Raa5AxYYEmxxANea z{H<_!jgM1->*-blCYv~AD;y4RL__ZT)u|FG(`k!(F#bN?^vEK(v0JFhTwOGZ-C?#} zil(Epvx3Uw$}mQ>(#48rk7 zj`B=3Z84Z%L~`n!d5hk!=^D%$-Ga*-BrlD!L`F)VLsu;>k=4wMT2&@CRA0{?)y-?V zbp969ms!)I#jB7ei1Z%#s<$@1N;)`HS-6dZJ+mK_pSrDA?jRGyV`7oyZp3`B$4pf# zE3180*=B}AJW>I`_4GcKG&d1xEF-#-C?h#&*_-CT>ZjhaPUFy)%ri(~lr)ooO0=JL zMX4pXEW$|GM=X3ICnWlcp?j`HsU4O!_cxM)3~Ugpt9s<=8Uw5*;}Ddfy?x71c-+U=#K(>xNJr6&&svX;U12Bx3GFq8I)9&JK#%NbbV z{{RlvrKcNDCv-b4fZ(J{$o4p_pNLT8BDRp^4m$v2On_yinA#bz`)815H|%G81*_~XFqtM0$E zD;}nwGbCIIJ^rKhr*3ajQsvPa42=YyWGIlSIcWQza68qV$#oedwaPHT9Tz^n)yeoz z#lL8fY4_-{>M8Q8$@1^^=e=)S%=V$BgqaYZo)j1a`u%EkTC%n+!E$UFW1divbXf-_ zf7vxX#8&qpG-}>rawI3G_||A|w7{}2l^jQwX&7LS=xV6eBeS2&4JGtFKXRmm^aS+9 zGEI1|_eda|h~((1g9ZJ^txPN>R%@6e2vmIHakXFYJ?aG0@2|ifR62YlhF4>{PpIuu z!DSJ;SZ~^GLQ}+S%vA{bPy<(0w7XKRBTqL&YY^N=<;6(?-`T`oD?w{$-^FxyI)A`_ z`t_qGnwI0tf#b84MmEH_4m}M;Z*rIJ%400(GV3E`{{Z#Jy#uk0s`yeb5u;5d%ET$Kj5Cwa~iBY*@h{jxu*Ovh+Trd(=1D zeV>Tze5QoUpSdIbN1BrqkbuuGjcaVBVFw6iVK8xll;?W+x#26ty2qmOj!fh{`p@A9s6mvX*;caxx~{ z-ArfsdQ?!!=6tfrwoaa2IQPIEXq2^z86b^SI=V`Bf<1D3`&C>Bg~X7QM^M>6yO{8= z_;xg=FW(Z9ZD#6&qkC3e?}_y5h4R&;zmfj{<%|oG5A>$1YkNb*Zyuj-{{THDJET$m z?JI$~6yXt&z>pOmH=KJ`9mcRhWI}V5mJl zQT(e%PT8eKuoz;r1BGbU0R2rhp_zfG=yG_n5t~@F^^E+JO5eTwK&&^Ce+}vh7PlO? z)=7aIhdVyC=oboxK_VT}{{VZlRa=n`qK4Y>RTmt_M_+MTC){Fb=H7TCpHDEvt^83h zUt?Ofmncrhk9!`fM-{`(p%C+I7O(EJ(sF%*!{p$43Y$RDZDhV!?{_3D4=K;Q3!s|?dyq3cxc93YHF4ShA?KNZEPMyPhF99GVYapp%ltemW1$8RJL<+>Q(8N&{1PfoUL z#%M|EGm7Z7`vIs~?vEZ~@(AjEtC6(SUr)U)kKPV3T zM%q%Ap;!>v>xx#nNu3?Oi1&~@;!Fe?#%hh!0hFYJb_ZIk2D2r)7}bu}>&;CS$dWQx z)tQ<5{e1+<%oY;9_MdcB^n9iqn=f#ZoId=6V2=CrFDPeTe7 z9N}{5@YsgPOflvYo+*}*O||zf^1N_sTF=Ax*V;_?t8wJT2sl_7hVJuFSmOXM@p=K@G|v)hE2wz3)e(ud2gnGVakTUK;=0>Nt#sQde6x$o zfwMo|6_It}M6}dxrw^C&Rj>|grk$3o;l#OUk%!>DIpKXq`J;a+E!h|j?8hIOE^E1$&!w^KGfWZ*?N6DJIfERX+8Qreu+t zM`kQGw*EHasY`b!nnJRwoG?Op{OW%VcvkPk+QY<=v5rO_V{gs}dZ)`3&e;o^Z;~r= z<(;eQf_1hcSz<#Y^U!;mYuh!4hW^&e<0TA3$&ZTw~6sO7vY!rR2wOCtQ9VBMaX z{{SYs?G7k!d`GK1+hEfyq;{N7%5Je-0L zwRp$FsHD?8d19Sk%v@u#*J<&ZSJL(ARyG^1lx^d;dgZH4JVH;g=+LVrhH_in;QUjq zzPqoxvD~sZs=i zXz-1&x464FDhO1KPe6;)^U2L;jG9!`5w2fOPP3~cq-EYZVyR!Tw}dQTeb^|g%=>fn ztSPl0GUDb@%y1}0_j6uERnK~(QDd)TT}xGp*+138^Ee$dSGB2{-%cTZ^D?0PYAp)U z?c$PYj$U7xiLD8tTcD-DDy@(`>SXPp;SC(Rv6k^Je(&7iR$q$zLk5vyC}#pjQlslu zb=_Va4#lRGfH>pcyi;896nf?Rv#~plc7w%qP^9WR^f@s&g-SfB>RZt~bFJCxijki- z%$#j%^xY=mH97C4+9GC%D|W{mn&A8arLE?vx!3nm@^U(w?|eyTDq1bQf^2)aLP+b` zdYao2%B5tCFph3K&D|P0F^w#-AIkz|RlmLJ#<*G0{A1%)hXt){&I^7u)_9ijG#?J^ zkL4jer5}Y+@J5_8O%7Yx&L*507`I`J*F0|&=hW$_xumJ|TCpBA z>yJv#_)+lu6dH}%WeD7=!=S8Nz}`Rc_m-rzD*WK_!LM7qxEejJu9t29s!d6v2)|XPH)K>>`G?_bCbDaB{RUfsUyAKZ)N(n95 z&>agv@r+|_>9xCM$CmsqYnIkN8EXC=W|rDjyo;+x*8}O^vt+rJ801n4$fS~YXBa-U zsiyd%?rR5VuLR8=Fg($f{3@3+^fZ?wwd!R{a3ExoXiPIB3`qv${W@1fUG-ZJEXfVq zk&qnWsaIXOx&kR|VVplI`2e3@J!wofvdUOm6qS1?3_qn^+0MO8g@LT1WVth1t8ugf zN8wWc0BA}uts=<@81q^@k@c&07xyU{hA?D7*UwM8_|`_Ruh`p0iF0hCM&ooWM(<3D zt;9&Ot)jERF^&B`@-X$mrg`suZ6W(2UH4f`CLzyU5Kc&}5TgEK)0bOyA0$Y4#cJs= zM`;!;FC>e3#y6jo{ybHrXL}xd@h8I9H_nBc8LgFmNG=(-2KB)`tIl;V3u^HBZ5Vb0 zlB^eMf&R~G`ZDg)=IPQ4p>KMFfYKAY9r+c{_`ATGW7z)VRhlbf(%M(~BX9OtXFp1- zTSAjQe$(wW4Kh`h<{inni3=w?d-G4!bZcwtg>EuFFvHKB?i&TTYZ* ze`{#7NTTE*8_@|HvG0uIHIb`p(dl>km93?$a>E>~EgI{I9Y2INI0PR`hLbKW-sh2c zpTWAeoXvA?(L4-))m(9(_!o2au0HzW<_QcMg`D;&jFPRnSbMPTUZbhSd3(NHY^}kJ z?v5CV+6SgRy=#iqFCo;gBo=Iez{im1k!SyRnNM?YwXYj#y^|ZB>ZmpTen0 z45DE<-5MYHc5%nzDQ`3gR#MYIe$mMjLfc6E1y>R3ZJT>*n6Kl<{I?h**f(=rZ0}Zj z7oO-q5t1PCFyUe!=M;#NNXsm&uH&CAkIJ9pSzm4b#bq~(J(GVFjmmxoszWBDY zjpc35^hqzPwxW{_6qi7LnLR-n5Z?7cgN#yDe zpsSHtSa@#R%C=UwPgWT$ezbxMo5GS@MY18tSpNW-tdUo+=YOPQaLQwq!ONa3#Ynnx*gnN#%5AuUVM$0W_>+@w;?{2Nc)rd{0m zBtV07t~ibP1KgiV$D2~Wv@aFKi4H#W_N}=Tdg8BHYI<;zV!NdCh8?l&f2Ag` zseVJ&(JfL*?paxs7HJgV0qyDiYm3%AMRnpi*G zP-7k=c!wrM8(IE_t*kGpWJS8(0oeZlAJf{KDs0HwyAXZkIK@W2K#EA((haa&G!e!* zNrv_MR(i`N+lgeh&ARr1vAf?DG8ymi@;OL>`67^g?{iizB>OlIDug5WlaHkX6zUec za`T2>f;ooP2irf=o%RDMS)~mEj#4%Gm3_@riqWq&%W@WG&&eFW!%nzWvL0k|&1)w` z5uM8C(+9NxTAJoK(6ow^Zuv&e3m&v?<0k;Wc!FgBmp6zh9m;(0`l{JE2kpURTf)r@9h z7D*2#NSGa?+uE@8eNIhE=;>Nyf?qKH`cu(8sb1P38>wBth%Zi`VYwkwi<#3UTOEaI z+~~FzS|qWgQMig1Do7!+2l&;!Gf9s_wOFI`5r^NEMnOI5may-CX?3xK!4z(M!NF$1 z;+$@skgav8!~V3%Ddh5MHaRuWG$j;KK+cQ9NMqBQp3-I0b%uY70Hx{J4x8HL&jB^cWxfq+M- z`d6R$k4&4xTFh6Ls!|f$;?LKV`d4$NYcOj%VooM+pR8%Y&!r7&8>^ZT$i)?bRZ=pf za%!k=vRpedJKM;4W55mTQNc4=P2>f{QXkz(C-9!)v?aD%)DcZ1vJuEp!KuEZ5#(oy zqH!8V;{{i(L1xU7G!i39Nd4O%;-^H>S}Q|1NZC1AWNxFe_o`Rg)vUOR7nPsm_@FHl>of~2Ix5^J{&a}JMFC$@ZZ*O83{bNqq zZ>}mk%?3$Wm22smb&5nD*4i6%Z{lU#+4TC;ZtarR<>yk?#25>~6fNKF>3cFaw2&a2O8bAFWuL*LV`c z3P&;=q=fVIs3*2*Fv{y}SCw`9z|moIZ#$H6BhH_BN#p7>+MT&}DLcE9#d?>j;pBfg zFeJB5HlO~zLoKw{(i^L2yt04RM<+YStv=>AM;A8CuuT}jQT%J&Px#ZIy^i5GqDdon zLUFh2Ol)*lHuoqP$kze*D!c>!6(9VAvE`_a(T;LPOlSNmo#f+5l+GrO2w(kV2ti@) z1v(8+?P^V(jJJqJ1e1J`pU#0A(nSuT30VtVf-{Bb^#-6zzwFJabtE?7zt!A7@ef1q zSy9D#0a&hYQqs;t_icvahtM(Peifk>hhoa($v09l;#-C!{c>~qPzNjHKMh;>sC}DB{`F?~W5qW&vwvn?-7SsWe;|}c`^gWcPZ_UAiv8_fZ7yd-lLN|; z2i_is(2kYM_}jqiuf}h5*v-D95ZwO&!{+0j0q1w~q3fVp+mfb+kxnHMNqp_giB3Zw zVaGMM6Rgl92_lXI#?&1Tp~Z3D4)L$sfYUX1XfD&`Mn5TW?tN>jXe70Ep3WV_{p65f zIR5|M{{{ZV&jn9=lD7O-gtT!sB2iWJWSGJumVpeHBR{jO= z>~U2lX)Pp?ZDAr4_sa~F_Y}gz*HE4-k#8J9k-z2cPakI){lYgtjo-*ja;>xs&E$>@iTsqd|K+CaFBv(q}3e zipGBmuYaRlczWrtbZyhyGj5*8s3X;DcS_XlJVRz=hiq%oL}P|S@99I(Xt4&H6cK-+ z#o^I2@5vExa>EgeRI?ki)ffeiry?;%^lJC5DN8b=%DH$W!zu0~J4rz8?64!E9yL z>@GB@`U5I_jsE}w995-uMf5QsGsdANY{$__$4ZJ{2pg+}xziD&y3S5M>s6Rc7w8rX zc_$-+Ps*e5uS@t-duQPrsV2RGEAKQF@+4jAqubuPBfXA#k3%!SFzN>KJ553(6pO~; z(AU(S57O4!&L9x8aa=ZwrfK@U$-0KkZNnYP2Ws@)5_6}hjmB5Ey>zIabE&D;Y4*5- zB=zl@(YK8w$YI*4Xi|+rWQskZnBu)QKZX})Pd7!-rre%-R+hHpb6;bX(yUs4nj|a- z_iK6^cL}tDzglmG?*@mc?cw4F4V)a;qh07x_=egmn;67oj!DKUBy>gG;8p;Qoby)E zOE!DeyZr`zK0>d{4yL5iwD-EWNQWhlT9#;vg-@m_ALR!X)aw2nk50D?SQaCuYn_rM z+mX#eF2)V)!8tueWnAhC$mAZ`tzBXvE{;R{M&2is)|iTPw{8 ztYouSa!UzVaw`wR`jbZjd9m$1tFN>&Ucf*fcyI+FJx>>J4qRAk-y-4=a;FvQUL4e( z$i_FF+)WQ^@2sP(6Pi*Z&TE_mX)=+#luM-^%=O7T4|9~11-hQq#A z7!`*E5*Zq4bGeRrtoQ_u5WW+NxO~+jE=dTxChd)wOvMiD^?dOLrC26DJ5wkQCQlf@?XmrIUs>rC30me zBYRxeuP?NaW#T4~a0gy1nhiR_uzlU%K00E$%}&K7(sam%LxH`3KD=hI{6}PT%c)_O zZ!u2@*uev^ts;?wlUlY^g!1Qp;@no|ypiJ788G4v#Cw|M?e5mn@)!rq8HP=3-D+#A zU(G3OBOEF81B!;VHH@yVC9}e0RhfdQJet(GTPcij*cYBt!CY4nsA_QAOg4`&EMZoSf12HYj!zF^75_O&tL1;D55e-!Z5!trDFJ(STNf| z4ZPdk!8?y?*zqNnpQm3*1L{%3CVAec2Q_m-_-r*>*yXvIFDzXSB(`n=_paqALMgM( zbmuv8qYvSArffC2=WVeIITed^$sAQ(3q*n+EhAm!-yHYn{3~}=vtJG9Q)yP*eWKyF z`s`kV^Q?$`Ne-UYgrHWp1fO;_n}n*($r>t=qLg3E^ouzYP}E;fjRaQ0)zBt5&#%_G zgK_XLlH4;gnSNzpdsa@HjO@!Tj7)lh zIIktX)~_u}c^4AMgQ*O1YTb{GZmy#qT(YYn!4e)S-WG9MF|2(l?-RJ6!P-hVllwdt z=jB#7rrE89yT@v_K1j|Pwg$(iHJfSUIsBx^^B57h0ng!G?w=$PMjn4GT(WtI5jiA! z*DTcO=-Jf=N`DQNj%UOgG&)U?ySqzM5nvZ|JdxVA{4J&1*jvr0MQao#m*r@OQI568 zc<17cq!P(xC7HWMQiNmfw_+=E;Rd$;An@dGDP%C_Ev?h>ttASImo&CAr&g4!a>VoB z6IkDCdX?j)tkV!l1Pa;khlTF$ZEq6l)#2Z4>4qE}uOG-)vMe_7lFEpsk2o03I#U_I zTn-L@Dwm3GDk%@@OPR^Y@lS_3WsZxc+G-ksX4IoB;t%cuIL&ijHNO`+g~aH;dpi^l zyYE~0^2N`X#fXruGuN8rwY%nmNf}1*w2Yp@y=**g`QGgC@eL@bM6ckx6*&3QLTR?riI-bcV*88=(au_vy;nn_M4RgNo49htFN`z?d)ZbGAhR3CNti+$(^UU zB*f6f!z{Sy-qjnp1SwurX14cRQh1*(GFm+WxewFOsW1^_Va5^Ts_M#~V?^TZ03Nc+4 zv#S`aT*f&o>+M`KqWRQO>C?ooX5`Kj;*W@yR(nJ{wu68Vdh#6x_|H75es>Rycg|}^ z#QLQ7T8-R}t%d2&twr$j!nc~e@DW1n3uL}HAB}wtI6{RjPm`-srXsp%_pboyOBRh3 z-*!hNpL*+wO_?MTyz|<)sK%q9#T|vKs>cxJk2%k!U%$Qn%+iX7GF-?y=czuG^YvjW zljgbgw5mdsEp1G_P``@2Q3aHFHrD5Shqtv>_`TwE&~$aOfx^uw+kw;y-thgjejm{$ zzPdYRbuS|x-N3IHyOQJLUyl(6Og>;Z>=YX6r5eVCk;rO69@seVUg@S8)6LC~hx zwJb%yExEf7{=I1UUj9!Lc!F!TRxrD6LyiC)DzC)1jcqLSD>mC4ATxm7eFZbanw<7F zzh<{8p#K0EtvOubD!ukFok-K1Xs(-`sgL_lTy=ApAR`OgHMOW-i#u5)VgOOcwPxsc z-(<1#WILss=40zzhr~Y-m@OxXTL6p`n&6E;X*Cnll~+cSlii+I@o&WPYLZ=;+jc%k z$FFWGKZCv$hr)Ul*NHV<-zUz1j#%~rlfzyQy7AVj8NrK9iT6n>3=dwlq2pf|v+6Kt zdQG%vOSC>)dAxaz-2E%ul+`$U7;ev(jX!Hq)~T}mPFGR!JNT1OlJ@T3_pkDi8)FOG zuX=16WLJtxTzQN9N=_Ml%~|mdp`h7VO>=o@ZG5m9ri(3{_5gRM_-@lp*Yxz0PPw?V zc{v|#gs$=0v$dnK-_Ne*E}@H5mf0~Q%r?GI5-XSS3=-(`TT5?ec>-JYn$kh#bN%(s zPvu^#ci`*9w=Fbc;|K3Re_GCe56u%6+_@m`SvX_teQM(Ep_IA19F48?cTh#WqMl;8 zLl#tnPKw~e8@=ts(ULReJ_rNriq+L^A%vrtqK&}dt`@TZN!-Uc^8j3jAnhOHT`DP~ zo7!t+SC_=d;Uc$^X&H|g8Kg2ST3wLua$5UukeKYtBtG5S|K z;yoivw$iSQ7LlaQ$Ws{y>J59P@54*|VN&I#x!Az*I2?W=vQNR5g5}uXLe2*;NhUb; z8S7lLhc?Fa+$_{^!w;oZx$zmX{ma_R zXFhNdj^z5}RmZ!MGvum?03;iQ`>X9&Ez&sRD;$WxjJcF&KU%=D8%FanB%v?nVUZ+l z(0VASu5MOoKF@4+%6mvSU-zpz^3pkOac3LZ5Po}$azcFo=zgZIt+oE0Za&TAUqX3s zaI!b!{{ZTx{vq=AB#%d1Ze~L~gmWUCXX)+qs`odWQnR># zG}?Od9!Xbl&v3uvT2WlZ6{4(B#PM|wIs8xf)Jfl9wTdDXwL4^w4b`OwJN~s+d%3S= zFC;?mHpyQnOQ%;R96=| zl!%gCSqW{F)yMUPjCcdcRD51J;Jy1ZBhG z3u2pNx>VcJDMkqPpe&c}Cz2wtbwyq??rG9#(dtEJxq>-k$L`}J4fSFwocervcIhCz zp3sBx$0yDS^r^kZX-y*A#3V8!sQwu`)F}d8H=RD{O7k3JkSeHa`1a3vVz;UX%FuOB zd{m#?c54uCF`}2ChGZa`YztRX1%Sxg9dnE?-p{pHjT=5|yFo8Nq;3d3D#Qwsm6quO z$iw9^9EBg_Qe4cC$GId%2ZfD>d87qdnYO8K-k;$q_l+{$HZvBHS)5^(Mb15`*3s;h zrk!yq=psBVWq6as4+r`UsauPg_zD-56kggb#)^8y-rLWx>4}TS;fySBg0(blDCkga zMT+8CIgrcpv2No%>OC7m{>hDGVny5YB)*>2Zstku96$oeBXyIHlu^5~1d`m2eLx9( zfR`47{#r?FaCp@wpMll834ap9ic;y7Sd zkRv>jCjmePt?KN7U#Z2h!1tz7yB0o%lC>RziYTBC)4+0~=+g#5=j{aG9=|B9eN2NA zNg5L3zboKmsrCG7s$bb@(n)PC-twO^Btvmw_*3M#xV?*I zu9oLLB)K@i^~EJ+)E?!F`wMMi!drGNE3n*;{l*@Z!T5Vro5K3!meZ0Tk+!)!gd>1O zb+*cuvK^;bF zSZ@`PU(NYjv}2miy5DCzO$E8~q(8mhdXC1WzJSNxZVql>-t0d5xA6+MYc!Lhu#iBF z^JRI>F(()sq}~{Cv2qw1hky2Dn2s&B(jGD~wZFjA3leQQY`%0sn$(}(1Nd#$@dL=B%M*x+aTr>z4P+BCDD%VlY8 z-2Uy{;}6_>QQ}=99}#J$+DBNVUz#>S^D7RN>-)P2mEfM_N=W7_a7p$Ybo$gb7nj!( zi+C-P-as<97bhFn{oEQ!B30LIdELO@Sd$fx2 zNZA}?EJtz1b6B1z@T|IwEpjhI>T!&qiFT>SQO0Th4AxTa+`wXn*8bb&l^Alz*PPQ% z!_n+(*=iRWj9cwA{E&WUx^1Bk)351Hy|cOnZX~`X&k|c(4XVrQ+>`lJZy1@r*AX%k z!^XeHD@w(M+O&~uSdVt(gZOu)aVGC#8%rxyw-Z@gDMsK(<1Br1p7k8&O+A)SOtz%_ z@Tc&fQBuzuY2~Lb(?-Ylu}A#CtME%4u#X`^D-4H|o)3SyxaaXUS zTbq1&Le&S}ky8j+l@jywvw+%0`$35N17n}dz`@Aa&I2KdpgWd6+7WpQ$cY8L)1 zaf9z&4cCh;teo4;arW7hfVSgse?gC0!SNS{Z+uq9_6;!DJY#<84h)#-!03M(w)wXO zq14?h@JYVi64;UR2@eiE{S8K*BGRF^!#vlw4b~_WoS(wDUk=zTKqrxn$!smy4aR<04^S%VczW9R z_t)3+YG=G)?NRs&u($6dNxo%brv@T92i}oQ;ue+&Q6v5_)A6dYBkiqY(-@=>ELP|E zka9k?JBVZfTp5&%jiWiFw#@3xq)WT_LBRZ~18sR`%a5n-Ru% zNioUxG|6CwXWb!YE8n40Qn(YKl2VeR3}I>ndF^2ltU%jm8=IVa8U!u8^JFB5Ab!WQ z4{E~Jd^h%cgQ)2;6~Ft^N7W@BCX$wOxuSEu_pt%~91c)9G1WCetl%rSmP8NcCub#j_r9lHv<1Cg*TrMQoC6j(Z!Y;pMZuS)%$z8z`a zH}RaWe8%%k)3(ZY^pZo-J;izA_N`qIo(7bVBccfgkuAq#&WP^IS zQ-X7j#<;jXBk1~`fh=&|BFQ8!g&sf&VljY4c^88G83%>@U16{2Ht-;KO_SH?GfG1Po@;z@N0q5DPb z$1E-4!u8_-ebMb+(csM&#V>4yB$CG5f%8d_dU{t{*JI96mG5hvRq#k`F= zBas@fIsX7StBdi+T7pS*>7!Ie7*&w%1K66t@jZs6roNGJ9qE>InE9&RHkBi#Q@hil z)g5KI#Ldqw-;Yo0TPa&}DN&b0y3(~voke`hh+~RO^oZnsb=>Hh-NmF}ERGk`b}?RQ z;n@<+B8(hHpep3{9188NZsRI8k@!|J)as_WRgR_gLw3wumM7MqWqwRM8-EfY;4%wt# z*HbUWUKo8bikA{8u0b@$4UjQiwf)T3$1)Pf+|?6q6?p=z&c+Nna~`8Lk*n#%GXS_F zHQB_`tKi_8hW<5z<{O_aT@}}Ik=wmP6VT#xt0|>*Nt~!sFbKtZ9+cMBx^r)0CJmLr z?NoeAai?lp{F2!PNEH_$Mh5IvL@c2~=N*BoKv>izmr$S1nnLbAQJ%u2({#zFSzIYG z$JfwP;j=P~u)*uaZfO^7r)k%g*aS`7_Mi&TK#?FTf_-XYaJ398umPNbPMT1wj;581 zM6s*N!Bi4YOjF<(kjUrvQ4P=iDtkDkx3hbL`=a9rPf<}c5-yU_Il;I&B=x|e+Jhj` zd`d68M#5;6S;}?=BaUeIZ7i@w+_N5r$pPvUC3TL0UK0siA5NE!;~u zO`=246X{g-3rX(ud38p)T*P;n@yRu5n7V14g~gr3cOggm?l1>zn%mR7Ie%|snXTkk zT#clDwGV`2YwcZRlG` zEO;siKQ?;St)GVG()7!HQDqT6L!>}+kN&-S4Eo-Lm(6D*qR7W%2N?kLtS=hcO=@k7 zZ<`{RR$hb-f{VQq3ns3a&*`!qN^6OvhiT63zMR(2kfQ|f2LhteUNG`01Ls4JsH+z8 zG_%E($_D1EVp6`S(Xq5w({A4bIY6ZP=Cm|a8dP%5=jV_dW}vr=5jB$YBTQQZ)3r~o zMAn2UZ;&npB-FGVm6g%s-xASvi~EU5AOqO@*Q5LmlgrUw0@(<1E6@B&5>qP$$u-*i z4K8831qy(0E3&j#u{dz;#<@2()7jC*7ucMh01sQhuN*~6o-1!Z}7U8g^db7AXFoYXD3SgI83Mpaa-dCrNT zUPoj0=@cvK1y1GTE7biu3g}|fpHZ_A$uI9 zhhg_?KJ!kyxV32>9nqX%0l_A^;_R+))mNV>n`Np`cY6)3ygqq$*^$_HCZxSKdX~F) zrP=(yWr2#g{{WVfCnxjg@~)EK!wsQ$eedT(6!IJ-eG2hhKf|Ey3Q? zt&}&HPaHPzG}kic3cu&mn(IJyzvA#62-lI@WwJau4~vl0cCeIdV||T_B(mzX%KW! z4QA;Vx<`n7VI7gMh+4o_IG7AEAay6wyZ-!3rtC9^;6K`{Ia1E=nl-hY=-n_Qv^qc8p&O*85 z^**)e{tMOBTpzq7-CQVZd5MwR46bq%SEWg)*zzkvP3Vq~ z;0}=@-OH#s0BtyK!o4zPd#Pq-mpt%CPPNMTFG*b+LypBRRI-nljtd&QuHIbP*|JA$ z@)ZLGLFx6b8sEIC+C5qe+0jvEq?bt+Ka6vbdgiIesrj##wTyah;GcTtG}$lGE19mP z!#+f6y;N0ueNpAll^n7E}K!nm&-cyCYfovd(N+?!U7goUAeH)^f$??t@PHB+YBM%R`z%o!(=Ot4&J znxzkR_cE03)sIy2?d~8fJm^s845^-KzmVw>#;E2qz!ERDQSnd2Kln#rx3$v>m5l9Y zAQ0ZQv7>6X-Z{}OClL#Kt8^{7JL0)qq>_`^>V-JX*x+?v2i(kMo(qWXM5JauhP^Ao zS81Z@cJ}FJh!6lmum`1blPWZxXd4?GoO(GUd4sqM_u5QB4Z;8G?npa{h zU~FV`Dm(rpS9#+v5dD%!^y?=`VSoVnT!!|=RG%yr71vOvx#sK3rikG$bqk$aRC)ZV z5#*4_;!ZF-V~Tc@;vJV#+ru5ijS2aSCIR{y(Ek90f=robk}wC$x+KF7t!i6qmiHD1 zbn;@jarx3&)L%M zaJD}V+Bo;Y8M&VvNg*Kq0~JG1@J_2evqde0tt$0gab4qhw&N^P*$&avDb9Us2J_+; zt7{=bVYyOpBaL>l{v`8S^VEF|oM^AKdG+^%^@~W5E{`XcPb5d%(yv?ie&+uGPm(LY z4$li<6m%I2{{YsjI={rNPERsOW)bZmVaPf50~FgY9_kmWC;LK0AUiM|kba`IwO6f$ z`>y6u@Ls7SM$l>Ok*0R;IS1bq{{Z+#{6{b-G}z_?j42Dpu+3S%_>rjE-IZ(TIsOD+ z=jlmx@h-p zMbpbGwU6Um9G@IDh_2Nxj@Kk^$_!(%6}x-!*y;AmJbHuy!27J3=jlnRy@Zt>?CEbj z3$8lKONs&(#f9iK+^oJ zgS4Ma3eUFqovZ0K?A98OngGw)?tsI4hRtHtT}{`q=*~mO{tcW%Yi|yNHkE!4?{00> z{`B`7MtH9?@n?aew1OBXxVo}w4l<)@lh=b@@p18gS-iHE;`$p;J7~fL<$jHt^R0X0 z^_9KDUTT&BLJWm(m@({egNn*dZ{k)ric8@>e(`65;PCCkLk+_M<9F{DZU#8!xI58t zJdB?)woW|J_n+7gO7u^S{{RyQiR6lFZ2@PCZdv1(<{sfc`t{_i;@v!qNhG#&kNIIB z+x+Xyt7R<@PMfkX$SqdfAdtWplN)iL&a&Zo=96hsmcX1i;iMl=N}6VuQjKStAvM2M zi4F(UlT5m0NW%$2fsQ}AeFb7_UM{xK{_a2{UZ~wa8l@(oCF%t7q=pXVdcV|m6&lF* za^mtmfC08I?|Yw9?N&;|3}Mn-05}YIKAd%=^wEi4xs64e-&zrE~7JB+bQ9&cCWDQS9F~cT5% z%A`-oY`0KZG4sGu#N?sL2e79>3(Q2*VY|m054-jEs#eg4VLYq^x&7Qy^il8cS8i?u zaQ^)OLDA#&^rh%A*E8GL#$;e&Z1K0LK7y)!i|x$QT}KtsW%&`uC_l!XX=?M%E@X0B z{#nHR6aN6OQ(d%CgCv8>+truX(woz8F)nmyY*5Js#M)>-z&>ToLl^d>E$2;Z7NVei z=-UjV>037NNqF9Dk_HR@t|sSa*WRQ8z)2KyP;{wadi-ICd4 zTa)tn6An{7>VFOBiDHd;bsySp=R1sm1;kCNc)q!r$VgnX=f9M)%nw98wq zI!UFp6Ueg_-cK07;~$-Lw^Fv7Gl! z?vN4OQ|xr8breZ2?{02q1c!tjsHfA8)u{8UNDN5QDe7~@Q@D|=Zc=u2h8a%T7p@Pj zEyp3(noM@_E7;pZZt)*6bIELVA4)Cn7X4Ag&my?U1B@R^e332L$qy$y?fG+1FgkS2 zvuqGfbAicM{{ZXK-3c_AJn)zvJD4L>Xxnkze)#SWwOTVlaRQZIn-j!cm(qQopoh7&>=N!i4vwuqS{{RZ>E#a+ctgZ=)30Q^w+3!k%v;NM|F~p{}91J-Ztx7W;gtoS!A%fIW>e>i*VE(L9fK=Ybpw zSOfJO56+VR07AC8j5O^&p)tb;+;FG)Rn0Z=2}e}6c>Y!ac|M1aT4OUT#-b)$c8Uu_ zw3y|b<(PdvYV^8F>JhTuUCnfRD*UPsV}brPV(QIx3t(X`@}qB810h&@)AZS-y@l2# zSR_-2K-s~h19af1Uff3wtg(T-r~sAh)ivF$bHqoO?Q*9o$TNfMRj%SEYr?NB)L#t6xD{SW0@@_Z{Nb7cRsik)@j;;>Xr*V zuEe#IIdb_wPii*Ni9V*R7g9|S^5cyoVU=;+ew9p^blKRv?LDiUkfT0RALwfeZF0j* zm@nEI?MFyq=V|;Z*uU0wak9d}EN$c;>n=HBKi!~u9Tc*D>@Zm4gpa)BVAnk+zhS0a zybW}3B2koEf0&{E?-fjVbz+(*wOi|pxL@v~HWd%1I||;m)9tLI^5C+H*)!CyBzxHYS5T0wD_!Tokk?OVZZ7HRDB42sbbbO32wezq9YYX$75HG)7Z!&x|qou{{X6T zcpvPV(6`%r8*YVsm0#i*$?Qcdfecz)2Gt_8wMZk}O1?g9{c2lzis<>`-tJSo{#mm8p}_5mxgy7WHli}9b}*+ct6H(B!f!Tq%q2$D!iNq z#~3ENcCl-ZE$x>R594kFiUQUlXpBx}wm_pFGLwze=;Rl0i5gaZPGiP4{c0gSnz4K9 zfokOA%436{OjL3_&fa#HX!ue7x^IyfeLA1YosfpTv%uTnkL7Mz2bIeFz-U4WsG-`>e)YURDvx81OnY02+2Bm2JM>%evav z$hQ9V&nyS~y}ugN{>F~-(H`#Q?t%Ufy1$UAt)z}%8RZHy${xA&tp$PLK4rRqjrWl9 zzgnb2DOy=Jnu0WmQqYh$<17!S(zLZt583Lr%l4zbU^5e^RrJMZ+1sqKmM@hf1b<5J z{vP}>k6DsCtNVqr(t+y7X^@V~nxNd^JO|+a0226?{yiQGMxGTT{WO9QFG0BCy}nO? zx^9Ev&1OkpZxHx}tll9UAS%FFPTq6du6ozBcxOVf(ClrkEFx5afM8dn4_cQ{@OZtv zlK#f>eLGTR2yP+dI0KF7cxuj5=#66zT~C<2P4FL1wD@A zZ6}Ooo#Fogh94B$OHDa#5akJcM+0H-3&x7u z8%m9RU(Q{%(l@|A5ovlQ#q_a5BSM@0%#vk@@7lYaYxbGYtk@!49OrJ;&tG2EJ-@}s zwV24Zx{Nc7WHw32#y?u^r%F=X;;mAgOwWSeJ@DkZg{F|2PuOneI|Rq)+{5`-uXwk| z_Id)FOJFWVi&RFGa$?sw8j1PBaQ3n%+HJXYq0?%rEAT~DQQDKZoD z3=eA9(sjQh5y!~vYbys4_xIvZdn=BB)bS|T|1sZ5cAa7Jq%P}IP>@|5QtaZ7O!L!$#L zZEOZ0pL*D~(?Wrf&~QyxvzK#^de~^?iB*|MQ%_-7$(wpk6do$%T&dmP)~C6F8OI#d z&uR8{Qd1y+dGAi(*J2HT1I0%6vmD3~jAM}4;UEfzinV~lZ-9}V9t-^k=QkLS{$~P`b=^VB)MfO6!!O~ zk()WqKFjTT%0h~A%q!5fS2|Xjw${j6XI-NMk?UM`i7Z-oh?Y&+L!M9auSAOS8En4R z#B5xWNa;~GW_3|X#h!0-;XCC~aWr_3aNW4_1v#|Y{{XSID_e9EBgP7pIX!BB8Ti%< z+aVpJk{Pgl>ZgKr_^d_E%)c~hp?Ezqd99-va@^u_f~m@$g)a^17Z7Qe6KyLjt*~zP zt(k9!hip;>QZ6MMq};X5y@d=RUL2Vf)bL5HZxPzXaWt{G0PHw5bfl##F;|O( zIik-Tk(&PiS(TWRmfX#n>AXYYt0_D+r`f}B9(W5YsXY!WCTK0R>Ew*IXL3TfrE(r2 z@U8xna~7j1GTU7-U`Jv)SA3wVHFL^^X;Y1st)2IS=KE#!uwpa3f<3W{^h=!*-%Gd2 zZ{D+v3h@08R(mO-nl>SngPQl9H&BPfx+SH^U?c@geL$`X@o;uWtA?7KY|QBy?Jp*k z2Gt4yY;@zA^V$9?S$K!UvuPIjkzv|b@QDHj(N$imVOqT zd`+=g&e&$S<%#3aS56v=sR>Q=IPrLgMscLNo|~$j@~}K-wR+Eh?V!+nATN#vjT>q!uI&E+cmDv^1x-}k5?xB8B^kHmZ)uu6j;Cm6 z7(2aG!rJ;pYDr&-wANWm?c9ho>T z;PpS1PvGwj3niCVkb=wmm_Z#e_lW$>W%%c9!^V!q_h*jzJbyatlZ582vCTzFoi^n4 zWv>%lo1IBvR}m~@cJ?^PBOrcM%{SsS8Wx*1oze+F1@{g}$3t2F024He=q@zLVNyi4 zP?qoW1pxm5D&Vhl*`v0Y7>tui5l!5HD_TkwVanNsXF63bmCfyI;%xfTO)A_pup#N3 z0mo{0!)w2{YnHl#lFX(_na5xhfnIN6V{pziwu$0dlka1IN4z* zXe9FzXScboze%+>Axn%~j!cK91$lqOiDbXMx{@+6Bkfb$1Eq7qUe12* zBxDeKgTWQYYF-+RUglZBg%=DM^{-R-OB6rZ@)+1Q#uz@eo+fHBy6SXbp()SaJk!Kl zwXcW#NvT-GUNjRjMig)c*7qI3wD#;I+BF z)DjCg-e!>CDCA?gu8QNq&X-H4+FhjgURjaTra##qujNsA#p}32Gi#Y_HrH9PmL*Bz z3!nLDhn&@;YiD`QwfdJFd2)^`kGi&NBZ3bH=JqtnF1DZvCP?J*SIf<+VeM_Ck+*;0 zyNG4`L&CDL=<)Kkxdc}}8}K#Os*wcLZikg_gnh*)86R4Uz_CxM6`p&Kv)D#{VCU~2 zK~DIw<0viPO}4dREGarM*m?oluzb>*jg4$?((t z5<8ee?cZ$VkN6F7liF$ba<#yLi>dQf$4vFEzu`8fFLld=k<`c&jlAm^iP=A+j%dx5Il$uby7k%EL61Cxrwp6c?+i+`+K z;ITEmJ4q`X^;DB=*|)vBn^1~0PcfOgfsS)sf5Z#c(mXX4oGrFlK!|PXeQSc#ykIoT z>v_DTk>fu&Zne@`c!I~pJ}kGM<@Va!0Lc;V)s1M$%b6_;!%amv$?kO?4$((~JSrw= zfz&R3eW$j155Ru4!6$~UCe$aI^xQn}Czu#HDi6}VN^M~-y9sZ3A&Vi2KJ@0-L<|yA5sw*e)t5M)-Ek_&5PuT%AB|!@x|eq^Wp09E*#>tX zDt8soj)&Hn^C`!?V3_cD?S@5&ONJ_ zv+-TL*Te@0Kg16ojbFOINsB}i%t6YLpXE+T+;Eq>pk7`o#sS$PFasFmepOMgEt!@n z7BpfwAn*^RM=iCiakJXapJ-!=BPR}imBH(}o%>GlhK@bIDilH_QT45&l(#XHwyf@s zsVopm=oOtl>K4McxHZA*o+^g!>PET}K+p1SOaPyUf6lWf)^0SFM7!1{x`2PnEtuQ& z;<+Cb&tmwPhJv+vtdveSpye-P1BY6C(q<$HAT4^5Pt{uifcE=x&L0A{Ib~0ZewQny_ zQ-H`gQ}2q4bTwO=zAo{Io)p`rIlKF+ib{-nlUWvC8oHb<<>v6mpC`@E57xFJ(shj$ zT2Tk~mzXy>&&}_TrDt4tpGh)91lp`xWz%tq*JgdaD>l+sMv;u0(6suu5do(IGM%A} znID!bdJXbgi-~Mih!`PGF`q$Lw(4f`#j~Wb4ztEX1MkIU-)b|>s6D;Ds3a?cv_^hk zsp&~gG-#sNL#dwP%3DcQrP(75yCiIEJ-M$n)yhHgTwTdDjg&jN2tB=iwYXunB*%Se z_LY+buMvv=7_Th&foF53Y9;hpnZ|HHZOR9$3gxXUBdUyU_bT|B*GjU5zRPm$dC2m< z?_XnFG};&Tfq69;MbIN@1EOQo{Bv4G?}_s+k!P5U8FAC_toxZJjmlfHn~~Na4is01 zIbG;`5L~NMB3%JA#owmh&2Dq@+sl_$KA1F<{{U*~@_G8Enr2LYt+x4sf53ffRx3-G zRLd&IEdAslwmpFDMAvhuRf<%3--U=|{*{xJwKUwxI>n9LB1o=Jm>cDd_+>trJk|JF zr?@g-Mhw6WfjS`d_ccpV@Uz{kO?zt`vbR|y%I&T@Sk@)c?*+Y$>rJ(GobppWf$vbA z)`qP%j-$n1GlN5j+uG0P+_1*NIA3yW1H--&msPwPma1fQknU66&tJ-^X&QQ2-HXj) z+EN(qMq#*pO?RcfE=rPmlSFUdw824{Seu}vnZ)(Z2NQ|CEh95I} zxIAq=So#X4komHSCqr*I=!!T&{ZHjcw$e4&xw?qN56c{XGLL*5)yVCZ?ol<|ZEpVnz`c|I z0IyR@(%M0|6`CHKx)beHdukhOR=B#mw1~?(!*vkg$rcs9gjH=m_6wVUXLD&N`;bZZ zN4|Pgh=NIl#On6wKh>e>&vRBUE#sEigQ`ma8@EU@aqUeBmgK!KL|Sr<8jrlEll1kf zme4)y;&Cn`AL`&=1D5bfB8nvG)B+;Gt9di)lJz z*1KDq=wX0KJR{2;8GlYGrET{q`3b3AEvosK5&Y+c*gDl~Q6~1CM+IF?DJPb~VN``(pa-q76s^591! z%l`l@+-GXk5QBYiCTVu4<`jOPU&gJ=aTS5%NkaT!3Hk(ULf7(a$< zk??$ePMFuCFyy z>M=oUFWO;aEQDfY1C||+)Km(IrQC#hT1#1u4nO6afA#A}GbDSAG26%8-}=yoIvLt% zl1R3qu+DwHl~(EqZRZyO+HK$K(N1&daq24?O^UY5v~-q>Ux zPL-H+GM90ttA91zO~%NWgjV#xuBOXRx3Gw%rHoKyVTl~jotV7QmiKzDyKUszY0%kO zy5r8eAeSHR*BgJN9|!6%Ti?iTw8;<7U}B07Y~s7woBKB4MLoeb(+`(#%dx!=d{nxI zpBA%V_EK4?4nFbucDG|q*im-SscCO$mWlQ&NPs8=sZFbt-P7zP~vN+xK_ov4-!Ep`To>ZznMqlF|yS-`e6c>;>7}|My zi94zK(O@64JE**bh>}ix#uzJ(gcDDK;$tzjx|9i2Wp=O%~ShZpgcY zhxn9rAf76n-lJ!HjTNd*sa%7Q@$?_?E|kLNjN%!Pq*0d7D(XJz^~t2Szi5e${$+)f zWHC4r4%j}mpKm6ueJa6oB59xu;a4o<*}4k19)o2pdGh4CpLXFitX~cMg&UQjgW^pd z4-uf3NrG3pR?Ew@gnW0&$3IG+!k#O*ySQB^RlSzkkZniC-N&XIy>-@?Q8! zwoJ$mC$8^eYIVM8kmPwpedGt4qiJ&UDF{5KA9;N~l_kZ1kO@Sj%l+?B>54TX z5<)+J-1arKrAlW73kcbIgUw~zMw(^_5cM=IltBBVJKT}R zI5qTd!uy+Rof0LL5==+8_kb1Y&lU6KxicS@J+dgyG5o93{ut__$HUr;my(4xS2ClP z=%@~Vk)=E7V(IG7bMWQFa9crc91W9A3`^f=V}bb$S7&Tc1q6-xz!meKfc`C5YW^M7 z^(hIBbnDiiEHK_fIm2VqlU~=Q>JjQTQC>lChSitM^Odp!_9WH{Ey(G*wU3ehF6nxw zjy!SV{Y9+ij@H6YvzHO=3m6-7lhl)(dsmid{uNm4-Yqs5t)XGHq;Hwe_!|3@<36Qr z{{RZRRF*i=R^oW$UI1zEK_}N0@*Vz(EHC!kKi$ZF{6O$)*K%B{DDm2~wJ7Oc81iqo z&RX$?GFayyg?sP7$+WE##L-;qc7AP=5XzfC2E6OS3kBE(6ZhwbTJ-HBMmqsajB`~M z&RVOP=z6cluN&xIGqm}c zqe|+)>;tWR9pV1~A81y76&|v_cb<& zr!A%6k`mk7?-9_~Yp7|KI+mugA~L5yn5_}vfo&4c8f=fI-sIM*?O6{?+^1t_E5J!m zYWngY;}|2Q0A*U~mltMo4iXj15X-#jTH|$Xw|4C_Lz)zP3yCh*VYL${MhGLDQ&E#0G+vbPc>5u%ozpYwq6>zwnoc{T#k7stx?k=({%g81b04V$OT46Aa|-hD2nb4YS!VhY_@GU{c}pm z$y^oV2G-}4-QAmn3mW93eAw?=IxG_~WCR2woYv&JWDg{h!Du6JaKLmmLq^m-(XB0+ zMFgA(@duIkEL9$jT>*A|7XTHW(m1JJw3C-Z)%-!B{l8e6?ExNQ ztLGeYwV?k1=$;XhHb2G&J$bIG%-g4igtC>`v157bjB#E&uj>*S_f08a56Gb43iIgR zwu_m=45}CXrmKu;!$e}LRGV%{^*;splG|9KFp|<1 zi+IV--hW!|uJjEVBWWhGfGHf7QC?p^hen#3Z1#=*muU=fv;P3JIp_1OUmJLe`sYrx z`#3&au#(57U%0O(R9xyVM`V2jF;Rs!sz&V|P2-;wMF)!~OE|6Jwu!bZVF%6cUB7^= zv@aLgl@~2#sWS5tNq|q%yj#Y)TRqcUvaz@T{K3KG%{Gj#c`c>P_S5Z1l zpJ|+981lO)_ciFwoSEcNog}^;QK@q!&aZ!GZ5Sq589cG*Nv}fH1S7|{9!V}xBD)Xo zsxE7W_(L$CU%J(<$T4a00_WcXy+`3iuh@JT*K&NN_db1xO7ZP!C)I9Zh9G2;R!{)z?Oyzr zqek$Ry_N#FgehNt;;t-Jl{mp}dKe`Aq^B*CJflt0W!1bvJ?+~60HOyj%Z^($R^v#5 zT}`48wYtkT)ML~G)A`qRWpfal$e!HcM5wEc%MtZwAJs?vPYUuGuZM! zO6N|k<9#IvEv>_D!l!Uq+u7|)hxs>!NBX(L-&IhHhB z7Ci-Pd?hkZ@dPXoauo06iuYfJ#?Qe147=Cwa3{Jr zh!OKJ9T)L6<>F)P)9%l22_;p=`z=d95ic7=*JSdfT+E_RDnpT;^~7A?*j|#iF}15_ z_&(`1ld5ai{w>s+>gfV8eiga^dscn)-`Vl}(n#nIFry>#tD{M%D~`Q&Ikz2+xqLL2 z(73g^hUd6KFyry6wwh(mjUh55k+y#JGn&i$M7MJXxBc6w2P$@P^sQYp#Wz}G1-qGM zf=`qdIRO6vO5H+9cR4wsEgpuhpW@SNc;b1OD=yKy*0zU>U1voMBvN_`#bz8-Q4-;w2a@T&=#iw_bg{yPI3M&Q<;W8n5Zv zgBJ^_ZJzB{i;>44N=`NsmCDyQ_ZMO|xN~c44pkWZ#ClaL%|*225kVASG0ORall3CB zwNJBsg{24WQ90Vr{7QWY?OfgS!uUmU!AK)$BoD^4hW0a<>&<9Rn`Q=a<^X*#P zY7u!&3)~?)kS{EN*CTPJY8pJp`#R!r&&_PPUJt3H;%J{T_m6LAw9QFPlUweQR0*HW zz{jx1HG!)59@f?3HsBj@*(;Dp{40jA@h{mft*x(Z<+o)!l1b2%9>sy_T`aoQzO^o7 zk?=ox8Gz<7j@ySr?NhKcRMhQPPrt?`k}aEt?62um9?BMFd00k`$qSVy^rq{+KGHlr zBgcKINXHvvwwL#tkEf{SxhS>C{7))eUn;>E9Li#pA_Ycol2Ynx~n zmS;IEJ&jj@r-$u_mu!r^1dKo=`{ud77$;w&Xq(vW z4oHYLLl1N7T$8SnvDFMamZz%R>+;x1=g7+m>c`B+eSofa#hxnCJV|Q9QPbg^CNUw5 z4nN1$TgLAK_R$OuHQ(!so7PUN^Vbr(tC#V*Vh53Pp1|#;2xf&@hJC zvQAqhpU$}nX0y3il5{qbuiR*ujCri^gRi#8EWo!C1qku$sHTYw2;p`54yu5G3r~l(xA778#OI+5oueN zl%Bss*n3p5n@d>E{n5C~Nh_Qa?oD~`io9WQu3SEwrWbgfwwVC=q(54pB9gIt#ojz; zchj_a8dYzVhM zSGOqQ2+&U?gYM@T9;|6T$fbUTTbqxvIv7e^%OJpm0EYK9mHmTdalNh)?Jpee^Pz3O zfCG$HjCN7lznQt#cIx2m{XeBT>UnJzGljTz`>F;DAEjro;*6W4s_6wD8>satTY(et zA^KvfTWauqxW1&a*<9p+%rJ}WD{k5pXFW7` zS3DAZE0nUcm&E#*y}Qg-ihp$x=uJszlK9I10L>)9rowX=ZbP4X>_Qk-RbxYM5ax-VAQ1qGeH{i6r2|| zYC|Ag7*<8-4K~HLhIE%#aShiNt8pGb)?4+*sTE$<_8a?y8eA-KCJLm#DRcBas%c(3 z`-5!^rU;jrPj97Jmd*x+A(GA~RU1J;j1TkgPA=N8CXq^=&l)!!0R3w?Ms+KJ6a)xY z@9sF==qk0vtH&qV?JniF-S^{jlmpZJnw~eGPJyC(G?pROOLNok>r;J8v82G;g0e%q zZvddr{{X76wQK1ng7PbAu%ae1XPgbEik@v{X4?&%E<{N3u=3Ppn}OI??wIn% z(ptf2OQ$I%$=j7a+>=y10$MF1XGl?>&m+xl+%F%kY3k8T9xIbC%)cpB92Grl2Wy3k zO=&gaguK)bmUuARCdEF-y-A|m!KO<+oOWTRUz#~(=PG`_lHxRF#5jfiV5&ux>*Y*Tq(nj+oD{{ULFEv1R#Np0gY zZd`_LDz>X@a_TM%fh=bsM>!qECvZiEQe%HSghm{o{#9E}o*Q{hyCckFAG;@n?mm?$R?qSfV4910>ZAJ>j*P ztz-=|0hl)&l07QbtdPkJZt^^)L-P9P(xcU*mRMDy+iuYv>hHngfLb!Ai!`^_^rnw& zaZ3{xjQ7u{(!BG+I{yItBbtj_T<%+Wn`u+d#?5z);te;-NhP^^iTa3Q8@_Ji0=XX; zX!G8yYH{h3S=$T-;0*d4cC8cA!clG9^k?69X7R8{qG5t%>yJ_Y0PC!XyjN#r9t+EZ zcRWqCe6EZ;9xIyggqHpxg`w2%X5VfCCAzi)FZHgzZ3^1XG?PxXjydBy)tim}ppNwx z)T=$pl4?4>yBUtxO%`xC$C-2d`0q(+qOHu&4!5XJb2Ll3G>nx-U&F0*R&p)fx=kdx zQaMcHY3>N9FV$p?UdamFLi^HOe;0iF8g9%G>w8N}c;tfG$s$%=rPy=z&s^18d#Nqv z7Pqq&k}>n zpO~rk_NLxhTg?t-ot{z35OcKq59v>EdTdki7Kd-*JvE_Ro3L`T4!QR=k>KAF-K4`o z)MXG{#g$1Ma^uq`x=SmGuER!%u}>ZZgp6n04@&1ed#2c4+l!bkfYu=(s>U){52s3A zp?4N_I*qIoBE+TVola1$ScCo)SY2Gq#yR6yBtiohJpFOd3g$c~;=M~=RkVf}V84WZ z@=4AK9?RU;mZPD4u=%$a@?71gA%5z6j1TKgJvAHhE^AtjnIfBbILOc8o}*ZRB>xT7CVRU7|?Rl(@0m_f+ma8k6zxA(xRs0& zLQ-V=+s_O;FaoIAUB=<#jvP-J`NH%b`R*#j_ETS>FDH{Ca*m%V2hg74ff_fkrOIM? zfn^y|rvvZ;rqiy=f1`OpW*mmb0}oMIHW%;&+R=u)UzLK5+4mKxB0bbUGYFY}>-SUd zLSkk|iu@t{wvBZXqFx}$z`$@nt!H>&#Ii>(_I>c#I-Rl0Cw|yI_0+?4B#a}Mi-0?F z8{7)Z@fUAy2E89KlXz!x8eX&Gt zCI^7MDr+q&CnVlV(#k)BpskyGIAcXyX#BTam8}vXG5#0P@Ad6s(m7PxE4rP7sTr*g z3BIZE3in#@)}E^tn{la2EzQF4!8iTf{Q&%{Q^FS}#$87CVo9>Jc~p8ce5!vlUWMST zCLe|04CJ~o1F799IRloFhDX18Z&O$&x;bwa{5XrnJ|VX7?}ks$7jv56++P$Kl_^TgdM90dTr?z1`2+ zS*{yvN>6-aILAth;*5HY*NT@*xkVD&{{RYfB!Ef!aa|PElW$WRva?I6iSZA|_r5>A zL<((eV916L-*?>CIi}k*t~}qJ`ev<3q)Tfi)?Qy5kEgYDJ|58{z$J3tv^{k>`i#*0 zE~e=&U=0+2@&Lx_^*s{bC=yuqFvVwR+E&&=R=WtSfj~g{fa0`9GFLj!0(gcW4qc?C z&O3<|?gl}TnwP}dj9w$wq`hdC7IV@*6n+)U*!hM{jyhK47T~B;_*J4TO=$#B-9sd( zW{F%gAUNHRdfwH1Ngkneo<+QNkf_?QsL02;toUro!g4?Q)FraUOJEl5TS9g+lrCsT zVv@$!5iiQWc=}eBl@xZ@0zz>h=tW|{(f~lnQ`B}ft!pa~ySn<-HkjiUw>G3nZzc02 zk(q}~)7A+CXQfGIa~@4d?SY(EH0;i}Y)NK}Sdw|G;yk5i+e$ZLv z!L5xK%ZwFe1&%p2n1nGOHCYn~lOXd<4ZT7^D-gUdLG&FfJ>u>UdWI{~^=S!_2f3ox z0{x_^3;;JL88x4OsI+=C*2*?3KH}rO59TbVo)vIu`mL&+6fjar$)_0I*veC~(1*ac zakQ6_%t3{tJBBN2NMp0`gGQe|{D6H$bKefbe{29i!zlzB*70tjW0FW=03tb4js{OP zYCipiDWy-DnV%15*{(7SFF5DDcD^Tg{?P~kd8^KQ*PQqVb9kUKsc6$A{cE|`<=C*w z;2GTVJqKzkD^<|C5>Aboy`#@1-LkU|2N}m&p7K!9yi3bB01B(2Tz|x_GH?#ka4RQT z@g=W@EhLtCl#`M&dYb2q9Oa@qs#H|zl7D7dXo%BDSIunv=dDYk-dpRoa;XAXk+2V5 zMR*^NJ}X{ow;~H-1TDrv>0XiWFH-wXku)Ub)r%aSYqqTyPMA?apyoKM%B&z42wm!ysgYXLqG@J}A1IPVq*kZ7Vj$ota0m0=plEy5*jm zrmAgbxS5G&Z_rlulA(C@IQgS2N>kY$iFa!pQ$69pe=Ez!wsT%yQ2zkJS8*UG0@_k} z$%sAs-*0;F8D)8OUtu$ zC*8M%x6Rn&is1EKKTz?ni9}ZJzGD(y*|<@f>@>TnW6|2vafzfGWR-f6=qi_oG^EkB zhmr|PyQvIYkO#GT4(Yiw%!H!UV;;MmHko%Wh2in0N0~zK@?#vO&2RW`O_q0#B~!WB006H-q?3-PlS($7k?DRZ@vY65 zhIE@afH7PK^9bw3am}h5=XLsg4RB%w5 zFwla9(6N;zDe|)2ripiaAZW-?(kRDa$gbC3g=5twgB&U3eFat1uWh4vC0;;fCmkD+ z&2D&(7@qy7LI`oYkEKO=jm;rYrj<6<=PiA3iw~Bg1odJ3s|LWO_lP04BYYO@WNd%3 z0Uxd_S5z_ETFrFFo=PvN=~X;2WbbV?$^pHZQIGpMB>uRoidML!q~wv@Tgf@oCce%P zoDh4O!_|C6i%qj&Ll$r`(=~4H^5)k{HmJ&ABcV9Qe9_{s0^8kqQ7`RoT2cbDfOt8_ z_}4OVi(JP`PI0t)Zh1Dg{{Y(7@<^kp!h4SO@BRnym4}9WJ!x|?k2T^~4l_Y z5!)faAL45>!JZml4Qg{e!PykC$sS*-mR>8jlI|Ihy1vHF0^=3Pc!OL^Y4$NBw?@Yq zr%vu3%>`K6TFxEGSDTUAvEZ?lp~0*X=Sf zh>4}bmtIL74RZHZ#%qR!NroRaPFQ0ASE(7=HfNHk``R5oho@XGhpe>Qd6wQu37$z9 zXL}Eo2jR*5X|tV2!(vvtVJVSH?@`}1e#R@SQTCPrqBz7)!2=lLuE(s}-bm5PfmrYr z$3K-qYR}9k2}ViwGrR|LbiOEp{%H5x#D8{;(>}HBGWd$x`NKWJvnTOqkEMB2TZkf% zTeeF-Bc|V_V#%&BoZCvK6i?$^_3vEOC@PXt>~>SdIx%$np0#~*Z7swyB#IdycnWxL zsH*a7uuU_>SZ?QcJlCCBYABMcMRp`p)SlI&r%J~1SFDUniDZztwR$g=>vS~tHpN0T#!)gaMYzLl{wNj{3~lF zudKxgAms7&CjwDLwntqWk*5bu^*XEFGRD#}(FHg; z8|aO{FY}zddVYT53j9T0t38Yp~=A_v#z9>AeCfTC5In)51O~-F}uE`w=HLIi{(6X zh|kR=1QEN}9ffljHpbIga|?^Z!+eCU-uxf_wMTb%6{WhEkii(`_7EAr3g#p6EV{O< zBvw002`~r;avxTzOSx)y(H)KF#fkJAnBmk{&9(=2!Wo!npwD{hd^hnfT~gFJvq+(A zvWZ3;K9%Pewu_Ph%WghtBrLYyExc{{Y)Mb~go=*iOk0T$=IN{v~+h!*>wLX?GvkRY9KCPoE>U)BS5(Q25Db<4ZTUHuAx) zNc&MP4g!xvBl^}}^j+FIIb$WGJGwM70R1Zh>ftx6O)4nbM4oBD=iah> zHrF>WD(l*`Q8x3kIWkurzJA8E^#m->PF)B#7i8D>zdJ3jm&+Nt)a$T#c^dc zqSaZ7=VHWxgLB;Gqtg6O1=ZBBTv*&jA|)h}RoV-CeJY-*HRhkJx=bXrRX7kz8wcEw zDvqb(TT8i}Zf24a%Yh5wMt-@gb63%m%GX1xg8I(p&4Id(GMuA#VV;#!RPhAXQA)Bj zGAIBjjadGC*O7R(=-Wa?lQPD<2J<;S*{)LG$Cq|+yl;5+v8XKGYiB==bGqs}Go}hV35>FV+h53oHyD#g!J zWV#(7d5eke!pc-+A`TRPx_i_&mwIqkk(ff`pyxUC#ZbQ0mf8jK99*Mtsh&yo%}{-} za*3}ud7yltgM|EgAFV@?N;;M<7Utzl*GuNYIY>d;-%>hNb8&7WX_j9r9aJ1J`qO2S zOGr$y${H|!To@IrLLoW3nz}*kyROh+=&|wqCVz&PPo}8Wk07KjQ)_qM;E}?Zk zrsmlYOKv~b%0@x<8RIodTN^Dh-R^B}WYXn8PYv>)s-6yN<<-r$iDG5**X;3f1DvV< z0PEMB>zd8)jPH^-5taaO6-RGVKy9eH&x&->~z&xA)J3{ z{{RxY{LAbV)sGKoQ0NgPkhqfNhD@02{uM5%BwC;lO9GjJ=4^7mLOIP%@1iTM%?(3U zluB)qF*U<|;to{@sUEaiDq9Bfq%z9f$Xk-<+n69kZdLlGeL)=K>r2qpYHMHKHJl(yo^+CrkyDSvgUvx_U?a@lZf=``M`7pz z?NI5;!}svrCF8Q;nYsJ{>?+0XqZ}wj)Iv7?9lYfB0E&m8_t;DJHdvPD<{pZ9C(^4} zTg81RobR$l{{U2se&^xdpJf9{D7v_OupcaP9Ekp&^)<`D9LpqrTwrqNx8q4ZyWcfpM^Z&{q6mwOO7xZV0oF?`NIo z&>u>@^0IxI-KmYkAW$NvB=pgCX4v1h$LG1V5?W)}K< zSRqN}>c!pV*NinIVPPXCoQEwTJT-Jp;d=$s#1h)%CO>^21Rq@Wp*0a*vS?8)E!GhN zn9yK(eZA_mN9~h@ww=6$`^CSA_Z68olX+`9!=~S~$fNgyenS5Mvyn)3#?w(D)GS)q zryJuLQ}3F0*5cJ2GcF?7(13%wbTyfCD3)PsZ7EiNz5Ub`1bVtos4Q-L`6S^+auj-+ zw{L8=u(C>w?%y$$Mt4-C_Y3KA-L-*%6$D}8AI7*RKEI7rzMWx_A-8R}QfCNw!0t|a zQ>MNakd}@@PH?DupG;FMWObFLyoFwN0JC+#{^;}+?iUE-TWI{JLo2r~=aSV{c1Tw- z$WhqiZhD`kXx_5L5S3&A0l1d*>?JWWMRf6}l)rfH(u z+;Atj9S^1{b<|euUbmJ@JLZ9LZ3Lq+vyZxa)OT9$m!}-hbk{Ry8zSWA(~7M(grvEK zSJd?dN@-ROF1H2I8Q?F~|CyuCKS*;`yT5=*|s z^T7A3r@}JLChcERdxMT)i0xmcZ(UiN>o$qyF_1C}@q#)Yd;V1(pK}$|KrDOiqdR2B zT>9tVtqRQ#UZ!2GiuiWzUe3i~wv=oxi%d zPmbXv5yK+=)EueqGHJdj@a&!<(_HLSxQG^vcR%d$>s$|mwZ-uEt@dlhBHBV!hB?^T z^rrO-U0Cki$EIBQi@H>kf;#O!wW<<%l)Jcv0UQI9{{Yse++yw$5)4GhN6qMa;8i;% zXj!eUTXRQ(nfX>l{GwREw6j8(96R^G-BqWCTWOl=74qX@i3d~l$4XC7Oy<00;mf-#fvxGz z7G)sHk@1nkvY&FY>Y(=9He zdqy|6%6VAw+sC1x4x7kpu%gl;B~z4s!`h^eQI6gxH}@%OBPV)eq5ieb+h6OxB4%h7 zJuc7^tXSp8{{UXEN8tNwWE0DB_qU94Os65eu}u=oXT$>Y5otD(-8OiXj4J*;>c`l% zEm@=&*KcjNq%jO`A6l_xc+y4X!4kp{{PFYv{VHpyqLde!W)hB!59yCeqEV51;Y)d; z0?Am;pED>q2l1|l!(Jpd$hLQq7>XUJ?bLR`6;97kEc;_kvjffs7wbl~rZw%m&l?82 zQIof$de*6;8arE!LKtEU&E`w|BpS1(Yg1p(8ll?cZO5*0#d&sx<5=xm>^D*82LX>! zU8aetEIO5q!NQ=GAC7;E{3}(lg|5|)SecH2;~hUl)TLOgE}(|ycgG|vIoa=%kH}ZC zctU%R8|tfe`g?tj+yP{_AL_u*`ocS(L0M2~8dr$?S*Ks!+ebCcynniojEo+0?_QbV z>!`HNK4@hz!}bzGe+VhmAH;tu!U*y^#XOZRO4}&TF?1wVB6=k2a|J4hf~Yy8Y~|&zu!uw+HE7m*Jaqwznm~Z*yL$;~#^% zZ-{OiQLv6XYvx?;3-UkIc0TpOSZOoaPHbg>#OiRtzol)}2`osdBgh~gwNFBAb5`E;#U9WGJ?eiy?J0mjHI`3GyJDO9^zT_4t%t`cj!btZ ztlnO~un44P%V zsMs(v1!;Yk$+C9?=WZ#FQkAXPdx044aa&PkCovK@HH)O_+iznhsA}7p3+k=3e5cZZ zl3cC}5O)$rY5M)rTiC@P1LesAoYv);I+L7{S)L|7a>y4MI17r_3hd^qq9^!m``vO< z6zmF5J*nP0VQq7GBO$WcAo`J0{6D?4(M;=eBF7|*c9!%}T&Ik6(W$-Es@qAF6$ALH z;G3#VR=t9W(|jq38SicgA0XhGn^4sbqjM~$pgUAmFNafon?%&kbCt&)wWp<6#c?|+ zA1aOH)`~GzB`Fz7a`te9H)koX{79F>>@AQEI6q4Be-r#dTgyoV%4KIKXePT)h&DQS zo=F}u&Df6h=67~B8q8{ue3N4c*{@EYvq?=O$C|GvC^=l_B)|J{jHoe{BRH>n{hvJK z)ow>E6Cm72O7Tk{2VBSIW%s`RPI`>j)SnH!Js*R7B9{}cCzNhu-v^~~Cu@mjk>fZYb=&$1SXb_Hvkbw0^6?-4013p>TdZa@^UCdJ?0vzkn_KeweD>3Q z=_TI8`*f$fp^Qf05_#lcXX)!%7pV3!A>1TVe(CHhxl@kzJnw5eT1?uPP-CzwWk(nS zuW8aVO(O-t^37nwsw1+mma08Pb^aB)w|lSNvZ53!3H7O{ts9zAZOJ3euPzd)k-Y~lAdUyvJ)Vu5+BhhVqMLgbYY#1B^o}#&%zYWC*Koqe~Fx~60 zmij1&2XT|GF;LE7zl-H6vh~9n<)u;6T&(J=Mm1&3w>bX*?C-OoF~(5_P6bud(} z=X^0d*2>sb9qBwVudSWri!%^0w87j1SeI8?tn>NpEOz$s@`$}p)~#vxP+VQL%Y_qT ztbH5nU8)w2rxI_T=U)0mTArep6W`7>{00%h&KuIbD&kl)-wD~;wmiv@I}G;6U*%qF zqWGT2$hd}73JhfIamMbvVz6$!Yb=ofj<~@KmdN^jD~eEcq`M=&GFW)Ie=)PI=~`0W z8A&2g+3UgLr_!~2u3?a?X9p*xdETX}T}vWP^ZANCZG(^nEr*KEaSgK&+&i~Q>~x!s zrwnAAv^)JrUb%`!UKj)Svs{j^tFgA7rYyyI2R!wtE_^{1^bS7ISRlxWI46#k#$R~2 z+cYvqw5H-2B*G>lZ}-V+^~*Kv4nH;0!DFNEhckQRyrGx8tNz`5tS+qKX?v*{Zz5|@nu|IM6z6i z^2r&&TIU;6YiJ`3s`70hvvNA|N-osyno(+1M>sgBQAJMd>8`#YU)jmBSXu4l8`e;H z#Z+Gz$A5DQT3xjA0fve}$Gv08tyqX+nC)y~7zW5+!m7;n8r+-goLVvlW_bSbH1t~; z^0^(Yrlt02%FaBQWCW}LedAUv?`OJ^L0=)Cx}VayJu2qv(jeD&g6&`dV=TYC{7q%w z_@eH|#0aPgKvQrGI5mT7t2!eFwmnYfRkgQ4Cp>2~>m5laiKUT*Y6oG~yt?z^Ma7&g zA+=dK$;)smRQRWDcVaxqmN_xN=qg=~j#hS;J|u<-oo>}`EsJowuT z8?yH6!6EjOhl5b$L1eOz|%ZWacih0&YN}TPW(Ek-^cqxg(SL( z)4RFrcN(O6a|vy)8HhZyVDaf#-XQVv+e;qSfGd%?m3cMHORIghHoLeR3!S9@0IO1a z;8rHC+I7e7+D-BSpDZ?5P^WFm_D;26LFeJe#)#!iLK#(#)@EAX^a zv{y^1#O;$KF$_Q8HNT+zak$ljM61O3wei2R0|k7%I^)%|T^5h4Xl5aIs>mMkNEP9P3dkIMN z9VcG7@$*GJ?bD{jCSU-b<#Q&26h+z zAZTKCjI;opE4ae9(9>)@c@?9jz4m;vFs$TY`_}`g>hhSDNhb^!?!~+0C)%+l)}+0V zM|OrQGt+R(AIG(GJVRYhk6@YWbNJ@dP?W`N?;Azck58w)M<- zdE6_?rMp;A$!j@|5I9`q5m_>Lf@}7YIMJYh@b@E-ewEKWOYU57$8WEA`&H6>q|q*g z%R6s-dB)}E{h`vdd_DV1YIfD_PMA} za*e7x2$${y$o%Vwe+@FC-D#SHw%0>HC}LvzY^=RU;x+b$C9}V`isJWg?#xZOTk5^4 zWm`*?8c7}Zh_%S{OCxn9@JPwXw`R`orEw73-+0>TZZ#<$&q`C6;|g%!T8G0rB5UMo zm$zwiK6l4%ysCHU+Pgun9Xd5x;2$>$ux?ZJUe$x1w>0ESV20Y?OSCat$d>l%Gsw!f z*XvZIzMkT@yPDE zRQEsqYRI|18jOtP~L`-Mt+xfX{xE7E5vDc5|L-+PxudW?^+M2HLyNzJYHBL4Te6uOqR7K3Hx z+%mFq6er4kfCjQ>@tv5mm8ODdZxCb2BWn}up5nOcdrd>ex|Zof7}uVD=pKXKmD|{D zSb2Rz#~P?aLj*akl6$6T?d9JB@MJ&5>;yBpy)>?+0UeU2c@Ci2`UJcC8j*rl$9UZH8H zpl91ApL7om56T$*IvQkF`n|HpEU35Z{40>Iqv&`huELEZ%Ps6@%mWHO@~HL|b~s8S zymEPJqaYuc0r=GIs~-9z7++b)?5@pWGJWV}%8-7Ctxp6q-lv&1UeQicFT1?@)K_tx zaz%T%fwWJ%fs-NiC#a?_k3FL9({IYh)WbK<2e9;|zN{UKaxe}8WdZ1_aZC+ydhc%$ zRz>^rgMde%^{mUyTIiOyxV15WaV^9ndV%TfS8OGY6$_@%8^#89lbn8iDDx7zd2OP% zj09tCv~ayEl-9LN&kTL6yI9^Mo+!v3K<8kaURjjRz)^8;^ng0OQ$D9bqZVezQj}2MK16)M*_nlfu zPTYMlNjzGsP0MbCf;y>hty+zoDDvH1efs`&iH&1PjG;um zwj+-yZ6n_u>O}D!jEEO(me&OB5xQ;Wnpb*-oy(e*>$h8Y!z8%e?mI81<5AznEwp=> z;9=#6AC9DawV`UVTEyE*POtMb1CyWdu5-p3y`7!1+r^Q2F$%-b9OpE3&}Sn>1Y^Ew z3B_x2gEUh&QB((S;Hx31cxL_y0(lZh*>~(ht!8*vSx*k$-q~HQ_%g;dPI=Av8kVW7hFv>M zOK1;CXX*J=7P=ubOKqy^Qk9K&EKz)@!@uiWx>lgNzMF4$vZ#&MB|yg0+*Eh=(I(5A zbZMoC?qwY1dynfw65O++xv5dQ99Jv$%5ou%rZxsu*E0h$xF-0{a|=bDma9$N{dW=8o)1B3X`Es0}- z7Yfqc?mdAy1XU~aa`D|-LRFy$<~(j*>xyQQVX~3!LYHO#09M^{pI>hExVG|01d>Q1 zi1KsPe@b0Senn^>&bNwoxg{KD2sVr#UvbjB^Wrvv91{&n(MYzF8^R71heMj`4zZ_c zdW+k}&3dwO7B8D?dU|v9sjn^dFA`cwZT4vFEm&g`jn01#)LfrKC%_&jmsGR+9nH{& z;@x@AR$p^km%4?sxcT8R2nFX|P!p^y{0O#cO$0 zAb1yRqkrf?4k`CGw&o)p(|N_lK4#}Vh{ZvvK_f7{SwcvQf{HK-_XO0i-&)NSPYh+* zhX|u2A57DFY$Q^$ySRv(H$o&t$dXUu9k4r^w=J_t5fjO{V;fjtepI)XS2w8}##R|( z`H+rIeF>*QcL9rY5$B=W!H(jm(vtEE6|62A!IsW7FBkl?cKJc}CbSwkW?iy4jdF5{ z-`h0>+CHTNM3I<>1to5|^#k6UU_|Kg-O2uw2@1>55&iF_S{1BTJ9L|kv94lvz{e#I z*R^TQZ#JQ9V3>&Fl}I4`#MHYYni7c@n2bK@8&n^!QfivcmU8`{QMwQ+sVe7<;CB2f zeFx}p%^bb})^PcoO1L=yoZx*uYrM3Xui_J1{MPa=cRAsK?~mzR&bzN^n%$Jzb&af= zl%=?M6oLBJJEy*-rCr0KUtLY6Te;5ixjgzGdaoh9PU7zKPw>>rF75-`&B%#Q%^Q6M zK`)B$^(JU^T``PAe6vmRkHW23_-{tjT<(nB&)!K6=08tLnh9pKm{~IUHtc_KB%I^h z*wm6>-1tuUqx)8?dh;VTWricP0qa}ZeVv7bLA1+IP=G{%bDzewR?6BH^CyryV;OVT z+ZC5J)cQLgA~Gyacy5uC)xMMgHO-yLk*?9gzCU>ia3llY9Ys%Ve{9gKu@fOL_L2JjvY3gmhDc6;@|k35?cJ83F$QT@s7~?kR}7a}Cwg`S6vul}Apc$Dyfgu3$*R zIX-AOWpk1ZMGUfDMv^3|BW|jle-lx?vcWH#2Qx2KQ=P-!i<4tcWoyOV8Z>X!U61LC z$G_C3LeMj`a|83^r{P)<+!<7uvk@npoUryHs`!E!{>CH+GlTb-;MS31kyJ4NB6|`coi9^43Cl$Gv*zjrALk8cU>TS|qHOx{)znNg4aLV+EOyZg?K#*Tdc$ z@fE~Vt-6kPfRYo+*WRB1ygW3I3jY9UOn%X-UW77C{G1QEqy3Rw)mJOf>7?(u>V6u~ zZ8SYH*22mlklP?#%sL!kpRHQ)P1JffhaTmegc2y_y=v{45Zfw_Pf|~MoHzsFVF4jdYZ{pl~HjayLDor%SqltHN3I6~J!?M@o)M0|* z+?gaeIqFYyTAmO1C#1y{z0Ry&{zL_pWNwGrvOGzl!=~!9+*uf)(=BFLQZ+dx@sr1( z80Xwqr%p<7XO&))gq6=$v+#}ezk@COK*Tk(KHQ$YaapETk(8?Z$Bb7>zZA6lpM_Ss zEKB?JU9XH|?`F8`ZA1VZah`^ys}QAge$w2XyN(Wf*Ii+$eUnR;-Y8=%`2FD-VUJ4l z&+S6ar+shO>Od#=jUz;Kx{ryjH7!2Y(s*WPgoh(W+-jHOJYl(gbaz0Jb}trk_6?WSVZQ`)(v+ z2pfnQ`qUPeJER1CYc_^-mN2LaF_Yf3zjjr(eZj?N>FQ*O>K6Ow9!em$j3v?aoVloSzEhI4)RGK zope_^iH;%%G{;Y~!{!MWYkqa%`VOd7 zBM=fwoSfI7Tk1mQ58l6%(;qh?wyB3uIiZN1OIhq@`08C}#G0VC((MRR4p^K3O>XO7 z4q?;0Ep4ero=&CZ0W3SR0mmk{Ws=*&dTQK2!dsa@0L_Ab0a?r9Xyk%VwXk`JBaWYy za?VnA*5%L%n!BPp71=?2SHJ6Wj{=J$zo8-LjYc!o~842i^MDnQFq$ z_9eNB))?<1Z<0W`AbQqJ8l3h}J}%Xql5kHoZk%eg>|^b!Qf}`?dkNQKhdbs*2RI!G zt~bQ`xsl@`3Wn?t2E6j?#Me^FJjn*k_04p8JipseJWNUXcgAzhN2O?}`#VVI_U@|W zA3mUG+5yG~T5PxJGQ_fho-loDm%7z9-ZC-T*&q+6T88(;SO|&%DbR}Q4e?e>LrF^*4TTG~{}aICV(s2iTS!KsuR zM)3HPH?`d(`(o-_)SqmPAIp=}_OCzGJSTm9cDIwjeCXmUC|-JVn(N>#BrLGb(}g)v zjzw*Fa#4QFCX-;s*BHj{QC!^ancC>awbi#g(?yBybjUVG`^$1fiVrw$d9PdWmx!NF zv9h;fN-8q|dz{x7<8KKc2}LHYYUXPxq#I&h2ya7GEId;7t8UY7RSWXQ!;_wANqbey zwiHvt$)~yPl6avnZZRA|03Upw^ySvAp&D5oklT)Y-|;82oF=QztmOBuMpb!fi3Lr?mUJI`cYTz%39PpxoWrM9=Z z7g7enJF*YBHJ$I&wFI|2`8-Pnsyj%*tx3+(+>fPmtK;Su>}}(3A$ap7T!;KC2KPtu ze7OzV&nav&54tKju2wYI9|s(%9MtpJ*V)}2)|ujHn9JwI8okKJ2LnFUHkELYPN@Pa zoyD?b#~;?X2rpuS6yAxrwsw}}ic72cR_W0h*#|0vsn4Y+QCQYAta`iaw-CqXOL-g- z=b|lgo-?-9{6!A?9Yx{{xmSzk`e)L)dtFlbrSdKmBp>t6LzXqr=(py|Qea^uZ}OXX z&Pg@S+G|s5G;?(MqSW!|l0602CBv&y8Fi*8h(twNkw`58<54#IdGIi56+gS(PH!kKNO z9T>-TWpMGJJGY|ak3;KJFFqngr`fUAAuu6g6sY_B){Gt~w7t?|Ye@qk&eF#qQD z`BZkEE7sEIXr6m(cK#jwxbzi+t;^8Y*tU-5_e)zDB^H+pG>UKow>6b_s@pM-?LlU^ zQS$(B7O-_M8C%%IC7KBlh&g@y@$KtgXMf_|OT`y2X{JK!5kEJWFyCDE6<=7>Viq0s zXP|4}67;<`U$a6M_G9wkF9}uj>&NjJStN+YsNX3kYZ2%x8$yEjV@3FYm+-Z<`mL;_?fH)%hrL|Ay}Z3ms)9Q}GPdpazZU+L zk#TdU-rI|N%THDq0pT}dz5Kp!isaD9O9Tx^%RpN!;HJMA>JJjjCvN=e);W?t0%O!~Fs zSZE-b4-#DTP&?wZbbk)6f?j!-?Y9r}G~)$<`g&FUJ4VxNZB{E#vF#ZSr{*WVY8Sk? zE@8JmWFY+0uihTG9qG5x14Ayf?Kc@pYz9=BHf zIuG}KD*Q27vD+93db@OBed-I#sVyJNL-wg(q-n!1x%R2MdX%GZkHq>`raZ`}DKO&+ z^0s{l{y@R^|;;KuytI4Mx;- zjs96c_C4ub6i&G=Z0-=dOk!{{N%=_n)=b9lRNP~2_N%JUCFSXR27uD^Dvc<*S* z%G%BHvwM@$x(1Ho-4w$9bV@Q2he7OUZ@4IstQLzN(5nd?ZDEFA$GvCW+}%XpMX?iX z;f$a1&+(+Vx@}@@h&;I0gDVfwPkOeRakPnJwO|x~1DxmTd)94~ky;eDR5474&0&^j z;J4$@(=Q#YRTw#!cL^JHC+kvQ-k3Sr0xfF0OvmB_Nsm-@tD!! zdnlt=*{fAFpFji%eILL`nD$5iA2>sh%?S(wJ{4WK-g>mmAj zQgY~owcN$J@l@=h9ZJ!y;by}~fU-6{{VQ(5TUlWZVQ@BZ0t2~f*h35ouBjwIZ5SM5 z+*dt+;p;1_u$pv_X?RZNXj=-S-Fi|PwKlut?zE2CC6~TAL>{KC*lAG8tZi-wnB2a4 zvHE&@cCK0m);u@8rM8bywBw>m@Y0{~tF0_d%98?| zU~U-4(x&$w#O)-j7(fzLUZik9tbIoDCX_{NN=|>)w@)lomrXE}?6z;0VU`WZLF=FC zO9ixWTlr5J40$b#=kcILs}R;JWc^dwr6{%Uw;z)%k+w(yw@H!^1|6r@o1#xtEh6`G6jt)k$^eGQO3g-gtRTv7}dQ zMq6?D*VHMk2k{l0Yx~%xw7EP0!vvH5*Fq~s@h$D`nposu!+?P2B=i-XZ3i+wp#sY! zgBZu%9+;(Y=xABTEFbA6RJoDIBpeVu0q!Z&-do_b>E*cRb1vV=)d8YLfHs?Q_KZO| zCvtuu^sLvpySRA%-EP-5aIp_HsQ&<1N2t$ATZq(x-dov4)QlPx;@&po{{XyCtywdm zY+S|*F9Dcz^yaHsU)o%UHq$D&<6$@>@Hnb^jm^lI+C)}Kh~>v6SG_MFUSW1-)Na1b zY{WpHG_9UKg03_{6b40$Z$P6WnP~(N#EWi9u;&MYPkhqf>J4QJNh++QjjN6rvFI^X zsHJVe+K4-!TI(Uv#*5+mq)7pGg^Z^e=Ld?Y=(QW)Ly9d;ElHe;O4h>) zDK}9}4)4G*BWY5W1)NUY4#R5mT_&Y!`nH*IZI#-?<G5lclc-kb=b$fkM!M25A=4~8j>s>~heJ-VC9nwp(DFJRdBirjvUP5iXg|LOK=aS0e zVj@AlAWqz&>-EKLTFACw8+iBUf=6Rm8iGd{j_5g-;s)F|jFw*Fv@P!1^#OqbpdWhn=B?c88g-ioxwu)J<7%(W zPtiw8%>K%|zrfV5O2T!!GZ&Jx(z; zou(Uc7b_*f#z%3qRfsg?)M1Ky`{%hv8S(+xfcF>`vP;Rdi9)ZU^N+@|W10;gP?YZ<1A{>m3CgXJ`boxXs2*FE4r6{XgmY-dt#;pk9u z!?CWG<~xhXmBuaQUzr?o+4}p`EftwfShSYWWxV-3)NXQ4(fL+%Qk^pGj1l6_X2yQ% zqx$_S-GtWj#$IFl?mJ;>v3=jo4OLU+-F(^1CO=V9}vz+Tbt z593+ak;h>;xU~K0JWCvFoR9Bk)|m#UYo`Nmsom{phsblo3Z&jOH;!%eeLe3o2n<&U zC`!XN)h4-#Wdy|mcHp=t2{0X!5rUqm>&b#7Vu9;@GP!E*NIs2!*N&f%{6n6_G z`j(?Db{mNu{Z-lmttz79_nY2=y5+SBHVv49V`rN0xj@k?nRl`?`vjnI!uELdDe z=Se#ZpPDv1LXp%_*5Gn}5%DWpyXd#^?7oSLM&qmFjApvmvu#2NBAXFM)MJ*&zE~e$B!r3{tydp_}-5ia} zI{J@#xjM^sn^rYgh~&z7KZR!B2!bLf%W%IsI2k*=fBMvxH0)J*&jr6dGz1eTH;g8S{*=15}br_@%m-dik6(4Je7vz-1lN_#^TPS+C!QPh3a z{{ZXO2lndelDv9&Ysk7`R{|#b`Wki-(atUr+TtJd)?b^NMJUnQS)#H}^LoRH@O@D)`jdDC*Si3e=+_|!8XVkQUXL%}`$t9Y|6Q=tHA z5}~(28z~t$z$3MOj{GfS4}-LH9PWlN06*-C{MXU-67r0Il4J%4q3K_wp9oalcvDJ{ z{lv9Rq_E=~>Wjv-0CW0LBSF;a&9CDm>b+ot}eZ z;*oC}#w5xy=nqe7(U-r36IU$ZXt&`$a0yxrHW{`364l`7#^F0W2 zvFx*WgDfz^1HEqee^yKBBMfuM8Lu7FuIIc@HgZ$}!UJ8`gf0reg?Ra^cS^{~)OS9f z&^$M!uZRB8bG6;msXaLDUURC+9hK>OZVEP4s}3I)UF;AUo5};GX&X@UPD#l%&CAQX zH|M(18nDYH?2cTS9Fj@znzw0i@Xmo4qXr#&VAd29t2P=&i1iy!T5!}FIW}aIo}|;| zDNP-gwRsKo%QRnfanq^ws@i3YlFkB*l6a~*Yx$#*T`z~{ODnaKQJB}BYo18nF6h;_ zjjV$}{6BH&NU?4Nz_+MW=z~DLQ?N=66SjQr)ODNa4ob7(wtjlO{9w#nu^I2?*K1z*13&SQa95wux~0Q>Zc1+=^9R>;%U6AZQfZt zxf}}WZ$1*-3r(`>8;&``ab577AuBd{vV`Xr;jld-lL~!_iop6 zuHx9*r#0x_E%2QBWbG1ME1=J8SAhIRHrfr)l1yD(NW^D$0v^K^?$f_2XMo&iCXwF5 ztyx>?ziC~@B_Qx{O>`a{@s62xR_g9hkb+9IyaA5JzHNiVcg)e+-Z6n$@`|4@$FCLF ztacYRMmsy1+VQg+%QF0j+Zp1V^;TnqTv_XS*TmaTGH*C$oEqYBR@g?&9(`z>bvH6$Z70CF5QjXJBDiUc!9l)m@YKE7o$u0ZA zGOLJlu$-^yP1N-}NbVuId!VvL_+?}IQ3&@cZM0gvo)(Q|-tn$-cOI3u;olKGqsMU@ zERjYyaM-}EAI5$nx71-s)j~(^bDyRw2T#{p-q2fb*$VU{`Bzselx)sbc*^M>tK%JR z-tK#A`*NzX513~GPkQF>_5T1qDE`ZYAH+|3=k2^*Z4R3)t;~LZp1{I450rz~+OV%R zz*q&DM0q$F6)I6m$49Yq)a|akQTAOgVUR4xsqSkw$He)(RWma&A8#k~Ijtj_9L-gH~5A}%A`Kh}=;HRM;?-Hn~~tU!|B119MU9Qz8* z5b74r)8%1UR5KZJ20M)R6<5ZV)A(g{zY%Fmp53PLr2gFT3U%P3bnn(g2pJ!Id zL-ZA5_FL=uIN%fMn)A(G+Fc)6xV8IIv4zS4 zN6Nr=&0`;NSl&yS+}Dr9lEGr$Q2p3mc;^+&OR9OSG(A$^OGSl|;6N}W8DQS|uP?gMwLJ<)mrarHt<;jle-GBQv>y|Fq!W2N zLopz1lkQYHl3hwv;`$zSoEtlpm{8;*Wd({+{OG%hqg36s=IVbB>hm1y% zXp!1Vu{#Z=LhjrTrBjPrpHjS#&IAeBam#w-`c%58YMHtHxG$Y!w+TMOk253XA6l{f zndQXSb6iU9yar-V9)lH+J4Udw!XdXz<*+afLvOE{977O$WL`YRZOGJnQk64eJILLG z#_Z*IRmt3cI^3}F8)>ksi*XIoo-%uS*Do=(vt*b}=FSFp?#2)K?O2zdC~JufWsLHJ z&z89S{VF9=#d|BBgFlE)rxbuZGacKT&LbfA;;}B_ogKBptY9leZRD<|qldB(MD>prKG>to> zq=&?|&`KiJAvW`pc1YZ>;aum7J|w}XNYH8SZZ1y*uPOMR#<;zE#n=8Uoh&S3kV4%- zN*?%{imEjiI}`Pnt_Y8n$6G5(NnRpp(d@bO1E0Q!G_jToVe;e7t`^t zKx-W)#@I_|AhweLxn4sbuX^Jxd}(Pce`LIdOH0-He3=NweT`a%#!}nNxP0fz&SUxt z&)PIvg6ecltz0|_1BDp*{{Z^+L6mC%3zM-_@oe2!+?t-sIV^KE;rnYb=4I-m=}D+vSjwJjK3Ag0 z{{SsB+uoNja+3X;TNtw&ZnnmDIpKbXwPjy@o5gV@w6@6{eHv-U%n$n>zgklT?8sW= zv)Yr9ZJZJAeSVdqmsa->svuk_!yz1j{XaTa<=l3=(B-7?R;!^*WYDh~c)-hBPt@bK zdK!w~;z5O8%T%y7*D3Ph{^>rZx>)18xK@hBV6g*|&&VFjP~2&D7qC60>!*%z+5D{ABYXFhHmA# z&O^jL>irE`&~*qjJyuuOttLy^56djsApZbLO7B(N=yCqjQ+;)A)To zg>;7S=`n4Z6^aBHSrBpb#YXzIv%%+C&z5t5v2qytW4G3s13%g$mry_${lE_B*Z%;o zM5TR*<+iM2C-&gna}~c_r^?Uu9+fz0tWYRHDj>jUh6o={{{Wp>o5+<~c-dlXf2>2> z-`29s_m|%y0&KtM;y;z2{n6M^n8wY1H8v31;ig}`Gkl^k=sitMVH?@L)FR{5o*(n6 zL^hWYED-G*I&I*OtyZ4yYj-yAs@=>-Dh3%n$^MkNkkT#PYL@DSC5*KF78c>Z15~20 zxel|)RplKbk-k+Q<5SzkZ6a=)_w%2Yy!c`V7ux4L3{&tToFQ5b5IuiFt}jD6T78PB;r>qJ`= za+Zz}amjvEeFykczg9a~!e13f1Z!uiTDG4eU~P5EAEOGix6ID4X|nHU`^?;`kF8CA zFup@)kdc*Y$%NiB574?B!Ip~{cL*0a1p9=zDDTX@p5o)rFhUl0 z1NV-25s&ey?W45_B5WZXvk|q6dXrLH*^4MvJ7Oh^saHQXdz|;EZ?A1*j0uqWy%8U) zA8G(w$89=2?I)V;z)#|1+ZB~@qgiU3<fBesR7Y_ZGo0@Gxb>h62GX^CEP0xJ;Jdb7Ocy7W_6=1V#F1(@F+r%p_Au_`5yXf6 z?!vbGp{h(xyO^wy{N2RI`U<~tkhQnj?x2n0#xb`mKo#SKS^)NwtHwGE?jKKTt$C<< zShQHkmAEJG1s=Jq+wTrXZ89Auc`a@q_z;sB`i|7dFSRcY`%m&qwTD54&RlD?s7dn>ZOjAeWxo)Z8BZRd66IQH_+9~Ye$jVd&OZigQz(Hdtg<; zYaf!W9J^V5=>By66g}RCJf0u~?7c&3{Y$d+{70=Vv~Q?LE|i8u;wZ`7NBdt|WX}4P z=a5K})-&_AIAd0uM7MPQAKZOZfRuB0{PbOPyro|m|)AOwBw|OP+efEx|W`yWk~MW@V-~y`jJMR_6ZluF3{Z;%!W=G&*MpIt{WEO-5fwNVl^EH zA${wU@ouyvZ?RbtGybrzrEYkmT>iqbFvQ|Zi8x&Faq4TC@V<&QTkkgHu!tS*#Pu}L zmW^c#iBs+rr9*CAHA z4L($o+x;R;%Ng3?`{U48J>XbT^cm!mQ5<66l6M8i%DmL=eQOq^4XBd%NH(7@``=o@ z>cgqCs%YQanq|a7JOZSyeqKkVa^42Lj>c=PGf|YH#0w}V9r+oqjw=mr=mRJGOCs%q zx&88MkMW+4JUgme-6V$UGIujSKQ?*IHK;eOj=*bH(iVcs)?2s11(bZBiyEzArd~-P zvAMYW5)fongy$gt01oQWu-~Xz$33iT98LxsJbf`xYZf!x!yM*PUNgJ{z&@Y~jr1ng zdX}xUjWm-MJezeE)EN|nS1&SH|N65|(V^F}&aG|Zk%8W7y;16uokFZI3 z4#}Hp5y)PCoYNi6CyEGoXOL`0DCyM{8o#yu(vCx6~s$jsA1 z*f`{$Ls~-K^uSKx6CQUs$*Hx5t`?DfqTV^4;hrf5P!E)SDQ_+9=eB6$LhMh-V;v7; zn$L#S^!uX}w;#E1UK0HScNL(M%BTccVe8RFG^{2wPg|SRb(OrC5czT9G7=L zzd79-9FRv~KD3?FxS8g&M|I&%b96-cus0r(E^F4bo7B~GxUWp<91<=_R_W|(it!Ay z_^~6N(@K$66(`Rl8;8=l{SJF%)M2u{mPD~~cvisO{uNDiV}!e%&Y|K>J4@7q+Be%J zJUnrdLH_`2KHVzRQh1-lu_+sL%SOozPs~THY1mllmT`#e?a_~1BJg-9h!Fm9-h1d;@0$CVA(Q>hK-P(zgWtD}PM96WbNGwpbZ{M^d{&x6-UD z!vwi3s~}##c%SgB%gOE`oLxf+2!2_E9!TuKdWt?IElZcyao$4daW3xQ0ku^7RSOHX zibHEAB$hFiF^q0sUwXV@j${(aF_cB6*<hBsZfJIBu$;c@P1CvC`PM~A#cX{p#Gv4I@xfa*CptGDTK zEQu^Qj(GV}4=g)_Ts81t2iroirgRd_Z%Ghfl`0MJ!hks?(I;` zIuZ$(?`a2f&qG?V0jWf?p(_y?8@}*A<3d&$k7uH<+SxUmhq{~$#w2V;!~Ne{)`UV2 zmum{NWMB+o0RFVe^qYIjb}~%nW%-g;A9U3_Taj~>monZ$eput-7=L>-z_lNlr4W}g z{{W#oe7;!WPoeg$Cb+YTA~z^(@~O!+Ufu-0iQ~QBf-qxF0s5M!zSlu%GBO_MPAAFa#iV`;e09hA*t}8Yu;z+jx)aM4e$tJm)CRK%Cj~EW)EnJ47r_H8Z zJGo;Ul2{&to}#yoq_HMvx}13c7S1vTJBs~9_&sJ}@J5QeasG!9EB^oi75ST@Xj+Ge zFJZOPEalX0M`;qE)MJlwZRj@-r+QTu z)-;TgN4@HfRt>4dgzN+BUQhAU#!c|&;rx*#qASfn{hHm>;ecVw52y92-ZuE=md8NU zyfFyZG|4Z1&2{C1Fw6$j$>_xMlj+x*!}zQ4uf+cV8ujZ7$|w>LYF&CcdH z`d09YO zGcaAf+o$D8ad$I6nP{G87&#mP>r%yU1(m>O&D5u>H{BI9x}~%%!H5L$wMHv#5zBII zGDwmXlHzrdj|U?FRNron*p!9iXLFQfYyc~oy_JY2)B_uJ7^{9Cu=9(`z#YBF>03fg z=!|B&o&J%m$!%mFP^HQ0d)G;%>x}9)%jAx~TJvabS+3PmXyYl-WjF-aLuYp!s~BUp zXu;mOz|D1AL!9&>;(xIa8gSoU^|58*6LP@r0c*+U)uXtc5Yd-m&KLY@^{%@l^E4|F zhi`Cm$JU|64K{lHzNHbAyE_D4eW^~l1os1D*PUBxww<+I7UMp6)* zf@>!eShLjhtwAG&PnnWydfQOkymYQNNJV@O)v&XOI0SKAGFqLn*yv)uVBqJ9*0;M3 zqb9hh^&RAM-n0_;Vgg$Nqm{Dmj;iKW@|cF^vNT;4yh*REvkapA%zXt?8nO|B;NrSZ z0eGuP(r)hB_UbqtzyQ5cyj9T;mQKv|4J$~s(QQOfMi2qX$0oV28F;Q62wg;q4i6cr zbq^hBA7yDF)FEO<2H6FC+wp_q6gHPq#RTG693G9D>%n1NS##9TJz6S4FGp;`$>s)WdEL5c7jvy#67% zi%Kzx%(0FY6>ux)XC-Sy`RW*VA9?On{?H2{YnwJp)fiWec8q!&?7S6w40h6evQr-4SP8+-!Q{2Gg?Ez|jdGP0;8Mn^+lZScFq5a>GHz55beI4nu) zUuNHYPVn!Feji+Uw~(yM4h#&#B;vTLRh2nMm0dC+N}W|cd0DNz&xAFvAIGOGntaNM zs4vQ9R=__)T$H+umvBhWaAk0E6!07ItsjgU0$p9)HIYK<<sk zV^*$p==7^ygrN*LLiuW~YjY;EGer^dtz7-xy#967*mye3#5UVT@~Ip0GoN~wB$lSB zUqp^l)5V&#)w|o+fSEWev!M4rm8+!by23KvUCj*O9%6`PMRsptqUn}wW=P{uxNIC8 z_86}})HNHZ?xY%Ir4_k*Z{Q>9E0%SU(Hd7eEknk7jigCy4ZM$9ok7HK6E#r^1 z-keDAvSbt=DG7c5J>!kRp@ZIeqv)5#i{>6Of{RY(J zKuOOhxjyy7Lq3yh9I9=E(2y{txd-c8s@BxTRGQHq>)?%UFAD3Ld%-=tuxIzDAcCjc z@vkWOx2m?A;{9IV%SjW@GD6T0N0!`k8{3Isibl$$ZMX3!`c;eD%Pk>D+|nZb?Wg7y zikFj&$7d?9sFV6taU(0c%KmKU_z1=-6;zgm%9Y05YSZ4`NNnQrqdbS5ve>F)R=Cn- z-t1jL%bwqbXKLD<7BiMsX|7wOOU_TdW?t#{ULck+dn@VCKO!!Hy|Y~MoV%Mw5oqsp zUl$#6{Ko~mGBT3VzuW`=0M|~`H8ZK(!*6#CS%tR~`1BQnqv=+fEu4@v{{UvUSk5rq z`t_=k>aR2hdoWC6&)f46QtLZtQlqY=%g+$SZE@vA2!yHm6!Kf?RPH{}dm|Y_!oXx~ z#!uAM2-4qc92w(p`Domq{v45-}L^he7yM0^O{a%vlu>54isTbp91Q?P@m! z&T;RxFAQF6Ye$~k^pK6)zK6AR`d^45)i_1}07xS%ZpR1fnq+t9S080%HnEYC9A`gD z&%N-%s|ob!2)#~LDGvwe)mKv2s3^6e)UJ(w!DYKEGG{(W<7oc1ruUj;C}WVPlB1L( z7(Yt$TR$J%Sj+ZJODQa80Oaxb_pSSAZM9VUK%eawC!NEh)VU9ov3{cI%h@rG4F3SD zaDH#6rBA1bQ$AXlB~kOKJfHEZcb5{{r}|T4fr(SU)Kqa=-0NFpMKRkB%gKLy_NeCW zT8)ipW=XXa7P)B;-^bh@=9zJI6}9^{&RMgMo0S9Fs9tK&_+BY4JmnFuSeW4c2fca5 zuj743U!Cl%k%Bqp)Mc1b<_=!xS>lfwSn1OQg^ZVqcrct}{qI`ln@zp(EZ=Uv^E{V0 zA1*LGg+6Y|H^zYl7cC1R3o*nX9x+Syo$ z?P601<)kN&eM#;3)k&njA)eE7$DBJb9U*ae!pdt=5-%h z-;fPev243D*6jW44$JHK&=IVm#3*G1WVrkO^+|Ui&d|I?XR6$=zhzt1I5zhK0e+sQ zwlA(_vO^*`5Du}q&JVYG!qpQ^)Z)d(u%6`c5kZF@=hCW2r|P;A!*C=T#iDcecKh*< zuXr*lOx z!zC!Zx}Sg8flD7(1ULK#&{SQOV@C5@F+e;jx)#huvaq;s`DKmE^*)rYI~Gmpw}&@K8WYje@8{DM9lQ|g zh-7@ES@}>l)*j^l07{O~fi~?x`F9P`x}fv`)n5^6+J1v==S=%<9Q>YNEa$d;s_$(G zTALHjG$@)`B}TDT+(Z1Q=m&b_Z#Ap`03P10hL}Ffr-lskIKg4~)jNGRQSpt$cRIhB z2Anw%MZi9pAJ)6uYikCx5!%~EB8YGl<8D9CdTv~r$T@ozbdL?e;mAta6=yuZn!KN( z?N#+_t6fMN39TcVX5(zUh{vw#nWVdNMY$~xd2{oT=(+3ls|gLU0t8;)GQSK@zxdK} zzNj|b$lTnSfU12 zZ}oPrH|{;i^s1|81>twrBUz<>5*Ow;{)fF)!_22+Y?|{Mm?l+-oy#0;_r2Iwy4}kX zt4$s|d9jWN`gX2oRPagC<&x_~y*6Zg{zQif_!_33Il5$u(^!S=QI9h!@Wc6F)Ldst zc1Eiyipv|nj^$5J{=H9Y44O)^?H3v3xWIGneQ9jJv##ydOBn(wA1cCl&$y`W^(MC3 zzEHTGh6c~(!?q~qLrIccYL={+IC&d@fMJ3DbzU7h=(xC)vZX|Q>BQ7y8l z%D3K3eSgNh&&A#`d;5uPG^a7Z$7{MD^@q?8Po+1-UNX4xJN=H(G*-C_g#I)B70~!s zz(3hh#dkZYyue_9e5yVD>K*rCq9}Mnz~@gzzSLmeSY=TE01x}Zx-CmY)4aC3wVkYG zPx)zXcxLn~S~q&z*dS||_8~oE zH7iEDvKV0S@<+Q0(YMsCbueYMnNi304#yu+)YhtATxq22{oRD_1$&1^<-f|8knRqBY0PbqcG1-_zO1KHe2*YRJ*0EaC zS@7y&9lD*G9PS@1tLydr>cR0&o2WsxgF|!E$YF=iYL}+QgqL1!nsTG<1ER#-9Q#x; zY>|YQi~gAB0VEtC_Ni?xZlRa$GB?^L$1%zLBhVU^?&G_4Sro>3`50&QrX~*ulnIH7 zfHG8!kFIMxTuG@u$rPoEpPEG(^G~4mq;;E8m7{^$J0v(_4-Q9VK9zsW`x>lG7nA_P z?Bzh9L)Oh%16weMZ$lZ#{Hndir8S&_Cy&jQh9lZK`;k%I%q^8#6Z`27BS`v_>P0r( zO=!jM6V16}mXSJv=sl~ie9XWJdBzlb%p(QTN_Qg&g4RpE*J>J4jH z)9m4EhhndfkPaBPrxnj=+C|@qZpNj2rbrRKNZ9fRsP(3`0zDhTpWD;Pcc{6wGIEI+ z0Q*+Sx7U0=7+W&xcB|DG{NPtX=3B@FZ3@OzU@Ht9dm7E1XSkclwgsThBz8SLfuZWa z$Q~lJ)viQWWvsya$soaY^v|_)HnHuDc~9m8(|1EvZY^x5i^#gR0x|M{!#|O%d(93_ z9Sr(zqVa%mw$670>qAqH{7VcV-WeIfLDXJ<#9&ZfX8;WVyxa^Jx)Gq%+w{0 z@>uQSQXGu41A?R9+|;_9^H~Vp2cB5r6}Ud;qql}7OtxA^JDYz5nm5&tWn?{VzQ}+^ zz@qR24o~Avp6)1F-TqLco!xU!2_OYkgDy8<`h9AJu}j;JGU*7lM#AUrlj}yUg0X7K z+C|Oh%4KegCp$;hnXO-0#c3qB&;hg%GwbYX&B#vKetCAaX}ll1>mW%rWcjRP_rd zw9_t>#t-fovnkGaJkt|Rx4oI9CG4&wIYST2uy*6UCan&qXQy2&w0CfroEZSx%ktx= z(uKI2)Zoizl7meqwYg!sib^Sg-FOd4u)fnImhcy3oTx@`mm5L*zlpB0@+jWO$8QOR zQ0`I&2NlkIIe3>yn7b%%v^D`;lkzW0>^I9SVOBkg$E=-yD(8ERkXYWfrEISxg?lgu z3!eF?HQPH~TSU0FMGZJo$$_3ny)qjmu+*fEY`k*~!gMxebdRj<0Em&{40X-ew9ClwfMCt$Ci>XW3$&h zfnDXHvYOI6xLh}wyM{S5p7#b-(=UF@Job@9N;Y0LlfS4Ha9gzCyp8e>Ku?rZ8l8pJ z%xiTD1#5G?9gZ0K@${<}LMydPhLAdukX+zUB(940mno-QMRN!wWF(t?JiTc(k)b*>&m!8JiwA*Wz z+GUrfeQ{JNXwJfD?c;&pQk)`S+j24j^&`GbK6q`TiM-N^2;hC^CX$zM zNDa*Hn2s?L5&TDhK9%OaI`F~LZ$8lhCJFFC#|Zs#UH+YMB#dobVbpF`oA{Tvo&2?Q&-#?$HrQfwWLYruXclJ?W=Og!7D2PAi2TIug><dJBVacy;e2l zNcXQK&DVuBz3Sh*f$-`M1c{&J-o0;4ylr1iisC6k#US0aIV0Gbdj-QxnFYHu1&-n) zwnXYl_56C(m5So+M%4dw?P?g{JWkx%TFJtu;r*ClAK^asCSY>6C z`U;!tVXaY>1S?~9ZSG`eiMffJh0mrdMk|pTGQ|e`a6<5+o2gh@-CMty?CyC-BMJ|- zRMPbWavI%~yzzXlqoWSSuD~+l39RN@t`-;KSYupq>zcalY5nR+>dtA3%@X-DA>5KTCq2d}T~5_d zTwhEenj?l;58Xb1RW+Ae)W|W$npLBJ%SjIDewd`V&}{UHQu&Ks%%mAb$IM9e0)bph zuMk{oieb~N8hF^@u4Nln+lr?ik#TV}cDmi{w*_(KMu%!2W9wZccZT*D{>v8V$QUj1 zfke7AT7)i>&c;u?n|RuNMLXP*zm=y8IW+aLhDh8H!Zq8I+n;LTL*DocRxsQ%jXHb~ z2OWJq4SEfg@1<&inuKfO`vdNJ`$nYX%)VTk0e>~B&(#*3x6KOy$>+r7HU z9MQaP0|(Aos+Gj|7PCz?tXtzG8AEi>_l0u49=>l0YR{$WYqk<`9HeJwJw|&P?;h{Z zOj^UZ^(DH0_3B&Hu7prVvqrL+P1fO^$AMMv8Yfu(%%t4Ha}i#jLyol{*(9|)rgJr$ zwpcI9BV+C9S}5?^s71jo(Nmx0Ohrp8sr5H+Hx}|Q5+V#R{{TMKZt~8`7zuLHd7pcK zFQ00ycWrTVBHTa)q=;~=KJyPlQ(3jVkQ;(d^gCT-IV#ivQrhxJr55FI2*eZ1z+vcp z#daSL{te&!TJcrBoogWxr#Ck&K1kEQ9;Uf_wHN8JBWB4NX5cB#GCvyo$M#tGT>c37 zeQz!5f5gjB#l);v461(UHCOCAT^+Z=Z-Jf~_*tY!29akPwleoD0hUvaPVS(4R)6gq z;_WNK{{Ra0w7n`m(E~#SO^nGQ#xeA*uZdq3G=GKu8oAW9Yq1Tq3)zO}KipB=lh{}1 zU&OB(_`CLq@jL2Th0pd*g9cLC$YcdhMo+oVC)Ty3-%@0(k3#Sl?Kj}h4}2|?!{6B# zS2~#nSBY_xHbx(gn8N=6-y*SWJ~;eC_=TtfZ{^b9gaz{AO{b4uD#wI=7ieA^`#jNu zCDzQerxT%9bV|p(Wc&W7IY4zLLl}9VT?^X?; z#0R#Wmij@KZKSqxJvVyOT$^M@j`f~me(N5eL0ta;i!AN+Fw+Mn>(NFxdEi)@H%7{h*6(AP&YF0@%t0o$c)_&W$n9>JrK+4R{uvLywRcyV zg{-h7f^}`(w|(z=-YdC}A-AVqM3*~!U}Eau2jbr z%rgd*oP4JP9)wgEHr9zO^4mGNx!J@ei3jnl`L!_?oMo2TQcU25=Lgpn(AmtQ&W}j( z1-;TOwcI{TsCjt0pTfF3ZBD|@7b`gc{{YJ(y?yJ(()HnY%Qu|EaD4uisSdHK#crlp z)q`>lGCw+oJ6QGG4;04^BMem=lh6w3G;a`zb}U<@1oiZ<7_s|A@W}U0BRKg42Yl6Oek;WwF^j0f4%@k}2A9X+ z%)^|lr zU+|~Ic`vUehT+!AbUh?&Z};}Ds^j7Sj!_}h6{B9NG0CkXO-FM%b4z2<{6(vG#ow2>Mn&*wR#Pta`4A;!y)E z9VI=GG>?z=c{My(eQ)M|N!HCidj3brVQ+3Sm%T6D==8=I-)H4l=8<2BlkzRtwz?{eas~*9 z9@VL*YNuMfS#GZq;{;|1hUOoaYF14$c3U}OMzdAM<{2mY=D8h9!5Wr{bl3VD%l1_~ zZ8(t!)3~ljUQOuEmnoQr^@JYDnjuNCmm zjIkhs_0~J5=_9ekpI<{;dX2<45+g>e_oJgH0RI5J?NjaDtz!M1o`+Gc>qEqIto~y{ z>63-+{xx3H#MX%)+14QQ0gc~vPvhRX`*vw&34Y9y0g>gLMfvxw8SE{Z)Nhew{{Y8v z?gewVb5yL0myKm9+i>n?bHg4&`e0U6*Yev=V4c=zTkcLekEo=F?H23ulQp>`60pkw zT9%e}vV!CX3^;St(YE1t(5ANSIoj8Q$s_`?edZrp>a>>s0E9|-+fN|d5Jx~SJd_WKjIA9{+lm`?`)>I zlHdooxs6WPn0Li_CDn#6XSSMfZs3)KjDrio>5j&?yg#c!YvNll*G?u9C@ivVT;zPD z_o`kz)|%hP8muzfN2Sc|_ty=RfN;IP8g5D|$cV?8-I)4;Yb$4xH7WtdcW&SKdQ$ji z?&9Z5)a*r<+1l|zjUXJfqm|F6;ZA$FptE(4l13()X8Yc`^!zI?P4SJjyzLFUt;NE9 z$)a3sIppTEQc`*r<7cUgzA~RfxqBUU)>|2qI~|vX$MvO`#PfKT-^sC0A_MoXB;me* z*58dZ+g*21hfunmFIGsvSXZhxIV^o^&b8}pL&FmNqfd37&5ljEA2=18l21b89%H7k zx3=*6tdc8A=d8@xAp45Bd#^=&On)WOJOwA^KaXrzGotITc$PhlAdS@g&@scGtzWs) z;e^R0xKuyI8*r!mE1pwro5q@2nwn*%$)vcLi@1>vAT5k$tfsGPJVdO@hz1Hb1RqRS zB?bFD!rs+3+&9YQ&J})#yv3tw4A#QBK+1GUbAFkXqHsQGtq(kYmJ*s)I2qI(&=+c zc#(o$G5j9K*1A!oz1_oHw9?w64hT4Q`u4>|B=BfSo7udGIV&pkKT+2-drP|yl+4lm zac!uFT}8}yy#SGL7uywQRPo#z2~lAt>&$%0oUcB;tBCOiu%0B;)imofBP)NcEAvV3 zQB9}aczXIPyQ?oYJ;1hdSMjH~;;e2ZmZRd^(Q|x67R}CEgzM0I*IQwsS!v3vY^F9| zw;B0~{{RYyP4O03--@XA{}2%Ke)(9&4U_srjJ`9M5dbpHUpy=ujje__bH+S9Se z+Q`VqraMyV5!*(E-NQHV6>PCS)YGtD5ApAR31=Z%dkiis22V=L+ zWAcxo=~1`eYJ6?A+UI5ysH=KrzST(wdiYZE+t%iWx9V z{iCZ)4=jJJ4XJ-;CfVcjSR4mI$@;BVp8j~{Xsk0J{v|x5e?$E$dkct}_ROd4N0xaw zKZk5lxQxsH01jLDm74141b@1oPs+*lBdMxBezD=8QdNCM#ZM9_!>IQ?>!82#t;m)( z^5pDS_`eFzeM0VbjNDtiaA%ZLjsE~&Po*Vx#*kU+aQKBxc5;VZxh(uL`vKQAYTr(u zP5I-@>(q%4&-v9&Urf{QlkJ{U>DLGe7ciZmeSNEsyz!Q?J*@VS$Zlhd;jbe@khEIw zLEPyzuNr}yD@aAf`~1F8=V#PaeJjBiTfek58AR84+#_YmueY^H;Tt_N-q6c#R`Pjy zdGqrEYhkrl(%Z`??OVD>)&BqkO7;b*Wwj`*#x9iGx(+e-Pp~xj?Ee6^Tg{g?iHxXT zukJ{!=&vEVxi4@`lH-V+e6jxkiTA6qT^nU*g^u3wx%qwh?Nf2swAZ(mQQ~jjTcO6} z7{}pNuQf>ZG9a{!OEUfG2Uv&K`c(^^cl$+zaHHEmrw=z!eL|k~G*H>Zzv%nz*z#Eh za(&Ny(R&4npwnlvk>yr5Q#kpgZ@vEj>iSczH4AiyGYl2rs}4!_6kO};VG5-2Z+82h zPbI#iu&82KFDF}zq9u@zkw@K&{{XL5sEUiFTxv22STr$ba=_f=`__csMal>-phZ$J z(YoY*2B$K$v*jP{5doYZz6aO6QJYhTSbr)KRxJF|F#Q0b>g)$Is(4geT!zqQn%-ga zNf2ztKA+)H>7FOJ@a*pnv1ZXb{{YK;{r>>R(z`o*SS?d+qZp50D2$B%06uE2qj6}q ztp=fP1ITb9E4VTL0B6#Z&}(A2)$A`V{?l)As!lRu>+O+JA-0<|6>GgV=j>Mi?ptzlgXm~q7~kkKExxyE$oM5CPIvw^uVyu>I;~Sw)8n*~>S+-c zHOVc*XVSd0S@9k3imrv7u#xP`VmJ?0KBlA8uQeYX>nf`r5Vj+Pe4`$u*P{4y!%5+l zEF)W+g1C}we9AoswLV|8!N$6R~Vc6wa)^EA?238!KHt&UYkqM9###koy9_}pAxua`05 z6YnYy@%5|e2cF(^Od@bU)VZNvg^ zF`h?1AdKLXPU76ReL2%$QiJUQ{_IEZ6Io+Qv(&RLprNO3HuNj#0rsm_u-q&Crs;&N zMh3z6YS~+Rlz8Jiuz3&nm+_^07GxbF{{TxH?5{r2266Uj`C;^G&XZUd(yg_~Ya}Q4 zjK}?Fusy!L>q*k)Ngg{{cU!ODb~(xP2B&DDarRlDO@MHZ$OG#@nIMU6;6rG^BgPd} zvpYZ=`(O719|~P zi8n1Y_?=qjSH;8gy*dwURC<=J7MX5tE^t*pjB&ftp^{5$Yd6#*xKxc-C6V)=Y*!-z zyz!;~0GR6MO@M+zkMo*KTMwnl?5rj6rmWXjAVX@0>!ElzjlE{Xf~pr z4%%(KwY*lsC{=bx_jd5rUgqxRIT8o~o#h-O<0w0v_WWwv=nHF--QLH4BH2fn~#xWhUWfXkK{D$}c0PEFQr-I7d#1Oe|Tud-q-!&>q(V>>wQySxt{EyGI zFC+`hWb;R96G?Kd3UnooK|X@3+3D$c{{U=L<>&oWeN_jpJ?YV#%}Pakm>DgjZE$&&qc{ufMQ+~e zFKXf_)I5$jWc2RFqJq{HfRDc9<(qaGpHoe0BPuy{tqIk>&M$3~r^~~L_i@D_cR_$N^DnJ)a_IVwmc^_tP0U?>&pF5BX|LkRA%(4O?V8?lHh{w` z`OrJ{G&K#^cP#~++fe+{Fc|~dt*ni1il#8A1`l!hRZAOaW3xuJxCehS5O-wNUl3~t zLa=vmB$>w8$MrNjwPa50$?=xFkf+$~PT3?MyeZK|H^I6Jh@-xdH=sNMFbS+*59t$Y znx5%c_1zd@w_!cH4pi_~v{r<5DTvJzv2IpF*-i+o>0Vp=l_9*#XpaQ& zUBCL(HOT-ata1i-9PZ$%(nDw=RdfyIuiXcrHJZNSEpLqD&5vn3Zf>XXtnGVkmX&ng zVN__@Ks@K={*|V05HIh50~z^;%0252_7kbuY8R#asN#vlNIBzaB;($cb$Sf*@=rs@ zv7;23{3$mY=&;42o0CN{tad&FxNAY3%*!;T6$1YN5DG^=`Nele=Js*{Rku#Xj$87r zO(($K6oXD^EcKH#D>s|wt;|eS@CRMLO4y4=(5GMB>cNzr8E#`g(!A%2gL^-?{Y(BK z)@=U(X|>@ zlIB0~rLyqHh3%BZ9<~W%xmB*BBp&$UtJ=a#OOX6K&7=PSFKGV&;MklmiZpwTb48U| zx4dAo1sK5<&3rx9Rs@qzpZ#=1yl6RZabC|Sh5RudT5We?9-*$`P)Frhmi_|xd2g$I zzCRVDQ*O@1)UCqv+Z$_W`^d@of10i8(fSoARtVZf!IRxnR4v zQy==s_Z*C#qZR6s_#?vllwhxk<%%~QZSNq%4_xd&&a;!?=YTEd6I^(PStLI&YmGt^ zkIidUO~{47Iiz%TX@B6@o^daXE_Fr_==yY%j*BkbFV?DD_+M1L-*Ks4kc>chVf(>* z^Iq>|;SU9f6kBVttUzEG?;r#DQFY-j4cVAyvDH4`GQJiMw8ZO-XFF66=S1ncbll(O zbYT2G%yAfsbe&pBN!tF>E&bh3Ft+f%nWNh*(Al{x0a+bRPro9T{qAq#Fpw53U;;8n zxUW!jSB#@_tT20VT2{XgJPAFdCT|lPS)@SE7u)T|#v_r+WMkgAXjgTwq0`4)3b;v8 zt4_DHjZ3XnnrrktKFz$y%3L<-VLN0p9Ouy1ib~QFvBXL2dMVJd5%C& z;cri&tys(!h&MW{$DVm(?Ow%q@SoxJ>|Ru!Dk?hRFYU9(`~r&~hhGjN`4hz`^C{ZH z`$pWhfA5~OHGPJU6TC@hVLiNNGa22z&m?yk^sN{~MiiV$<}o1dae?kD+JE6&_z!taN6ZpKd)v!sp3nW;gvA7QqYv=c{w`13}KTCz*qRNrv|{_|q+ z!_v7Q1nbgi*M`BTFxyOiRvnwLudU+nAH(p582nJIq!3hU7Wn@F&})U)J^=hIvYgyp zc*0vnmA5E5t*VUYwoOEwgK6@huIth2`YG%{XI5c!=K5s~j+)Sm_Z8%=W3-uS+2$>Zm3 z#;I&T`~nKww)lDA2xBqZ_>odBm`m$$zxoYUp<}?Hu)pyIxVgK$^6gylZ)$Kpgw^%Y z^y@eb5keML=PApB^{-(60EK1n^3GXH__U-_ADTbyD`0`WzDl?M01ETr-MIt)619*1 z;uhfl0O&OARgV*b;^tD&PP-2{q zGE$3ZO^+16jyppfkT{BX`A`qvAKg8FTD4)SUS6llJb-vRh%60zI(#N{EC&>f+;*%VGUM*Y

z!hk)h@{~{sU@`Ap9dgt6 zpONu@hx`Sp>+=Zgtt7a1=&`9|`3m+g2mZ+3EAar0)bOiknT9L)5#?THzJdEC z_)o#XXYn?#yssEW!jt(L>6gbJ3jP`&Cr#24`gIvr3am4aT-S(0W#Yda+%t%8FBQ&r z1tm}8(z-iO+26%;qRQGm!)N$*^Z8d~AxbLOOVslu>(u`Md8*y}9X`AJR%&->(CGFF zV;Y{AM72Y z*+P@+bG60Ja#ACO$FC!r<*$#y;eI)7;NYvN_ghM?b@+wng?^v?0J>PDImk-=7uA>ff|fmt?M+Yx}d19f{}s zE6qGj@uN|=FX!I~k$KoykN*H&ayl8FH~5m}zeI9W&3Jn)cco9-%ey|Nx$y>p;Xu&= z3_BM;CG9L*kQXu$z7JG6FLA4Q zn&iW86xS2JaFMBIKY-@D@ccVPd)S#=^RXOhkHq_!C@OLL{{TbY{vdwYJ|fm0Xf(36 zFgGE=8^wJ80OAjb`u_lmE?KX2DW`TP<}KR0j|u+Gel_u2%bh+;RBl7gyMLZ5-TV>z z3qq|7+SS)1hKe(q^?klqmn%iDk;NP?ZJN)Tag^`hW8)N#d$K@a&72eJYq{``z&{uG z+Bl@L)8uPuS8~kCc7NYM5nrLc75$$)AK_mu(#9z!L5#>Z56I@b8@+2o@YFtEnQd%} zKIve;&x-N!97?3zFtqN^rlG=9rypsIsGz%bJ~8k|>?wbCu*`4uO{}_;sbt*%%480i8LIZ*3+=9mms+}(_EE_)xdT5@il0;QKZCv>+7mU+jhH|N z-RKzpwdAE5vbMDu^v-yiRIeUd5MGxl#|Ml2Ng;VB8dbXzRa9kw`tW~ByKCSp3prNr zQ@d;Vqf8kmBR_bz^EKgKJ^j1PQ~^*Nb@b_Ve(@h-HIJx0>!y2v^7pk@?r>{;R9r>XN+Ix`eMIbz?`D?JR=}MW<2oKQqXleU8K@2wE$$N6>!|e{63Wc&=H58icks(i{Ydihw;mE5ZCp z;~V{EsVKsxjDmEu@yrMtxY6yQ^QoPE^4~JGhXiY zFj(i_vaU6N4ik@YSa*752Q)iy?2~tmm5~bI+wo7n*ng*AuBG&c9@mG?a{T zY3{j#L;lJBRUN&&j&eb(&uGHZV~zqb{S8E&;>=Z);=IfXLQ3F{qLz`mSINCku!=9H zs~ji*NiH+;Y+sdg>d%Z<-PeB?OqoR%&-z;a-{K- z)9}r7z90Cb;vWlXZ(*n1MIF1jk%PdFS-o;WJuz6yH#A4n+Qy)vFir5ZbvNFrzifIulP|Ekx8rSeJ(i}9VzOgH3=I}9V$g^ z*2v)`+dxd7d8EOq7ChCYfQ^>~5Nbu60|%{0kptF|;RIxLstt!^kTKqj8f0>9AR3Uk zW5p&=xhJI|fRmhaKqov<10K|_F+=S;NXNZP$8$_59dL6@3emO)D^*}7I1~X&7-KZ$ zsj!&Pqcov*`_qOgNzG7%9Mro8Di#N=0g3O528#fNq%5p3c^IWd1$pM3ux5dnT9=2` zNj}kP#u>oK*l-O;r)%=r&Gv0ZGZdYlA1SVkC5)jhL&FmP8e5C41`lL3;=6wGEyU+5bopa0>iYJ%dfBN-2dTqQz zh{5`bf041WfC1+Xr=sj5apTsWS#eDzSYY&{P#y?94L|OhtAdB+JbF}% zCgX!fq9)H6rVt6vGJWaWF^W|Oy-+uy7~R^RyLWuliU-n|$^pnVPRNCRqm_B)l~4@# zsKagQb50GBQ>e){1J;_#5OGciibKiGO@*k@IL|y&!{?!-^3;k9Whm6q4-HFzdeS6- zb?Z*xo+#7?`9gr(dsB*$jw!sJqL>pTp!CH8Lz;lYlb)2L_h*_3C&@I7ig_cN7DYYr zQVS>G-yI|9>T$80aNCO=zxGX8jCVuh3sujtO3i2u!z&KJt zr|sv0Y2i7^>p;;8+%VwrQloi;CYZnzNWU+7dy?58hLsf%T^(m>)JO zLit`XPQoy5G$}0{%VQ}+{l+|0aQKPsn8$q!#NC(-w{FImoQp4bTcN7vQvJ;$ z#KW&OB7k~OPcab%k5f%#$0My-2}34nh035{V-)b1r!e%Y_csyQfYZpZr}v!Wy#%&R z<@?lSS2W13Qe_N{xDW^;lbrRYmBn#gwi=0~>rQY<>6(b4B>U5_Htr{#P`NBgsSHGQ zsHDnMiquX_V**Dt6AT|(bIb@is+Sj#!^=1yjcDReK7zgvIO|O~1FkDRU2YWIB40A7 zT&k*PeYidmZgb*dBUFvhvvljtin+s`N_ONKe^O*ZkUTnyvX)`@o^ zvDX@#Kix*Eb4xR(SmW4K<#E!Ymj!!Os3oX3p-Mo_jo&xcqL2-^#}zw~RpkXmbVi(> zr!=a@JCMH>THySrntX+sxTbxQSy&CHn(U4iq_#&C@sLQ)mAUIxnkJErbg=k(Nxy*B z#fO5+fxG1=y?T`L=}UBZbu&muc5rK^JcMCSHGU5a%^(?!kF9!Lr-kFTz;#|LRy{J_ zATzZ6YuBZgMK5^qD`$GzDDzuC2A3SE&(gJRJQ}f$`!ar&-bS{uj#)vbiJ=YhoQm{g zfuy!Ps+pa6>~f3Y?YLr~1vtzYpixg;0#qsZPHFFcS0*m8OIq@c5t`0fpT{9 zS&cJ7PB_JP*LqNJNKk(Yz`oH#)}{sw?5gGGl2!O7dq<5ql%ur#wBb*`6teyxTzsn)QDMd@j+a2{x&#M-V^3-}zSWguiD$ z75IkbVzkmHc?kQZabCIL-`HQrejs4YY}P5)GdWN{op(YsXnWOK`_G=mV<(GTn0Ynm zu2S&rkHfDFM64u=;DOb${Nw3b*S{V#?HV7m=vriNCN_uJ+P$a3U+_+QSds~Kt!bkm z0?{B1xcYXlV(|CuvGC8rQRn+J?efRNvqisg_82s(XLwp`mOA<$I=E`RS$j&&yZ-<) zx8Qs?H;%p__|1$uZKcFAU67QTHgsYQJE{12qT@WfKQnqG?E zi#{aqpZpW{(#?Zwdc(;kbLHEWDt|C5*!(T~Ab2ZANYC~<+`9ATPS28kahzAQYW_6v zmxmCw<^8;88Tp?zTs}#FPwO?J z+Ua|<&~z__8Xl7vMYD$87pIn?Pv$wTd9Hjr;b_mFJ-iA!nMloiYvUiLsnbKc}!`{{Sxzq!s#Ckyv;NC>t`d znX{H`lg)LSKZ57JWlI|$F~7Tz59MBra*PHUb5Gv&K4!la=J@$IVcU)RMHqUAihNb$ zIo-7lLgF-HNeXZ&w$}D~T8XYFF6oRGIW_BgAHY8l>Jg8$i0vb8!Lmo?UWKE6&3X(7 zhg1!DAsmg^AFWkP+XaS?D%-L-=y1kym`|Q@YWuld`Ce^$?^2h_(yZ<-B_M5)A|!r- zv@~zo-^U&~nI20yEG^{c&YX-NrU@hRud|@iyeHtxUomX0Y*7Z#LMX@{n66h{{iXa3 z;n5;J;b{|r9BGor{QBa&@9nHrT`+WCsq}PsOD@65OEbl*u8E#+;J?_TOS4s*TDvzh zXFDJ6{$n4`y`RIs3w$5odsU7LD->Wp{E7+BuO_?~#-Fxqva{RxZ%mSD9DtHCV4rX~ zu4)K=F8oz-EyFgKY|FW2!CpV$AlEHSK0gzAV(RiP$GJ-%m1XbQ<+x{7UC*mEZ;Boh z@T77r#-nWuB5o>!x8a)deSh||)1{V6e-7B)YI6+jXi&0#KA(+K@TcsLX>bMKh_x$? zPU(V%QzIx$bpwzw_*buJe-At(;5&5|_BQbC2ltEyTk-Gs*OHb3gR;Fz9`8PPjfLdn zLZ9MzjgGbPQ{sfosb~F*WZhK|P{U*KO4O;U zar8VaMO>c+d17ih^YzgC7wz5>@DvBnxw5m`K5XN0^gg85lzc+{vivXMW}4ec2U5CD z6sO%M-;rMnc%R04*NG-+^*u^!oyR}Ca&h_BBXz7t2O#cq)RUaou|tO`LGxlI%+Efr z6yoYf*lmMeHA(sFw2&lQb&POHStpCuO{D~rp?x`1Pt2-?s}eUCih;F zQ^(84YN0M$)YVxO=B`y5R!40tH8rnMZuJRdQ}bf7Cb%OftzMZ94N#U6!0}wQB;Lob zNktiVbCaHH8sQ|AkPd63lE4woLTuwv(zquVBiN@^L1=RCr%LBM)aj?~QMioO#Gz9l zbgcQVf)kD_j!}#^J7Zc?(G|_U%`92bkjV9fu3u6*TVM~Lpx1v*?1U|rT+jdRxX`cntf)4E~gU?pC!N;2CdupY2|K1 zeJj&#d?g%s0RZjNwe5T{7(aLM#cHsvy-sQ7U&?u%uYZJ>aYFV zS)1}hSk}WR)0179y&P9HNUt<_4I>UQ>q}NN>rc1pHnzl-6U?!aJ;)jTYxBP&^tGzc z`fK)E{egU2`$Xw~@R45LuZ4UhNm#FCfXhAJGCy?~EAr%Eu_M?V9Cx~3>;a;9Q^Z=l zUlC{V4xgdM{#KoRr(D~tmq$DujT0Vg^n1iV@k#ha*FtpCEvzMq-q|Bxk~B~hC?9$Q^wD3i}> z%bvi2ZN+l(e!+ha5z3us#DI@mtAGA6Ut8a6`bw|}!M!tY^`R1if z4nDFwOn=)stFgZB4l`137^aA9f;~P6(65xGPj1zZf8p1HQ*);`lP+>pbZXx$M`}4f z)HEX(Oz{wDKFxT(OzLry(AMh6uDMbO$5C0jhJ|mbFU$_?aNWgYrN!@tq*R$r#j!aI zI*L{;YIQ-0=}JE;tz~}=>?r0~DwRDMkbM^Vq-{~u73)M zIac+jtfh`9Cgh-G(mIbyKuGIDWVc$K$8n}ofM=Xki!YcyQB5chUTHQEc&DisB>TG`qQ_1l6GclbjKXUZ$GS7CFr@qsP>fLnh?qfcK+& zH(G2&9D`EeW~8f;Oj0D?$7uC5L`VU%xleIL%qw!6ACX18BHkey6SR8OXe*N=D`SdU zh>m_*ZsyJi#UzT@oHo(T3_)Nl0uS zDnLM_DhSViT0n&ZJoOap5AM`JvLA0sRhTjKpcyerg6c@EVR@x+k)SNhdkT=BLz=07 zr>3QACA{3k>Qs+vb`bju=&xn}vnt@JH68!J@X3~PZ}aa+kK za~y3TL6<5sv}dXIp`oFqQ*v`mc?#z|QRait(O_b>K9wAdSEWCGO%7KStRb=7kT7_u z8erY(FDrFM$W&&F0V$9u>zW!&iit`Nrkv9%jybCVl15nYX{gMZ$)?G67t*RrcnCbw z0?S;Ck^L&WU4VG3+l#Uf6$4zzK*tqG4Jj@`Uw?YcyooNftqV}N z{{T3F)|h3@DUUtsIU~h82{FOR?^wPl&<}{T{{XU?j3XfDzXr8=UNg-nnYSlBJBkvx zH=$EZ(_2rpiUP(@EkF$2>fKhj)Z&2PxF1tl)A*7oxI|`eYSk+l^BV=i5DDg>n&Be_ zw}1z&U`wdqUi48Knt?5(Aep6RC#cP7%W*lH_Zs!QAd*Xg{oZRTZxKmxpC%>>dJ{|# zM56&lDXgl!0QyrkTOly|MCcPM8sYjvaPZL^wMTbeZiE!98Wn;BMK2gU?p!WoI zH3Lq1=DO2_R*1pEa^0K5P=+DGjMQgbW&5O$O2TQGzP_~3T90e4f>?N)O|H(z?~_rT zMp-(wXT}S0lU5^-o`$R1Nr}^hdKT7K$1HR8so|Bg)}e~so1Af1ps{D<4l36%%QqA~ zS%|3>vUsVX(k77L5T~!qM@s7aIq;jrUM65HG|Q`!-8`sSf&7JN=Ao?gIHfpkbCCp| z-<4m}d^3NqP9wFoG7@_974*k}zhQqE>u{{Pf=8ySV*)@M9QWi`(H{>#V_gHmHiBJO zRFYZp8SvmnILAYp@~dW;N`G$e%TuPF4;firlq|cw5%9*F`#x$HeplMAVTI&4@BkpsUut zGuFI7qCFEplF}oLNpT540rgheMk~pwo79rMDt2AohLrNx&1^;^ng0MR75@Mezv_N%yBRUEy;s3yL8t~$L}X;p8^DEiDk0);3hs9E=${R(!v z&XkVP==xZd`VEkU2l2&hTU+aQL~an-N7ODipVJk}_@Cpig8T%H8M=w)AM(y2&+@Mc z@t^H?tZFV(M1f$0kG#c;et8vCa_n9${_YlCpy$Xts==W}z}m zfyjIn82a?DKJh=tZv^-qC5}1cnpnvmTqRiKel_w2r}3}H8uCjO#;rM7dd7@G{)V~B zUk++Io3ytLF7_mrY@CzUz1%+yxV~&u^z4t4%sA%jmoKK1?)7KT-YxyJCzWM~MzB;k z`DK*k{#dUe@ejt|82GF=ovBY8Wb%-?0-(}<9LsXwbjj_rn(zEQ@JjajO|jcB0QD$9 zuX3InF2JkVFH__z<~iPR?%qyODa?E(GjUVBq?x5hvgsJ3Xt$khL zkAN)%U`#F3aQXbmLy`2&dJdQH`@=pKgMGk^$T>31K*#4^ej|uz(|nX;e?!>9aAsqQ zN_d&5F6{X`!~XyT=G0Ik*v#%aFdb{x{5ktSNhAnoVQ0V?Dp|inUYD(WPVi5H?e|Hl zS*|$!+=|EaHRT>J{j&613FNlYtmM03zbu0w6UVUrb>+NnXODllg^}+=BFOTE&5f+5 zF39yQAK=f2bk%P1@wCZtt?F%Q8z1>-calE_>+e`p%cb$-u@>^V>C(-q z$M<E*$cwn9d)?9&F#YS&gUaI>(CKikk7QeQ4Seh4kK1#^`olaLJ+f)m-S_>lrZDiQ@mO6 zd%^w%wtux>z^@-3N)Q|JIIqr`KWIN3-&rlB+RTOpEJ`*GcM?zI^BUssyjeZm&i5A& zGchEyu{_rB;mXx#d|g(^pA^RrPub=8Wf$ME_7{);0By!*me)YEDbsDlyo`6_AJ)En z@t4Nm6!^8vUuzR=8Nw;xeKKpw?|eX{^#|)%cbe&Ner(s$Ve;%A4O$S_%=t?Atj95z zHF}TJq1D}Ms6{1 z(Ym8@N#N0R%1Dky#~o_4k>al;WzQot_OkZPLyKn0qK@V~v9@|uNn>ri3g{Zx1nJVD z{?9i&1tPL^?sP*CmaOF@iz5Vzth!6x$+ZxlrFsR2gN*J81+(fatJ6FYg){w(fheLv< zV^*u*L(VLGAQ$*BD@G3w1#D~>`d5B5wLyVR{>%;2HMF@_h{iP??#?`DN{8-L$)kWi z;0C(i?A9GdX;V(Y$)R}rU9r@bXs=v08E~Kic=oRP(s63XO-ABw>pvEL zB|Y4}ez&RotVbF3`c^;uEZ-eBKij&WKIi-^0`J9ot;~`%m$IGANy(~5TfQg$KBWBX zl2en}ocrp1xA#phMrtVAa`RHC=e0q|J9Q!Cbf*j+)b2eq^s53eO4N#SJ!&VGG1b(5 zBCAQO#RDEm`U);IX<&MObl^gcDQkf3_i zIdvE!lPchz;d!WI(GpS`X&sMolh5l{qSLM5`>`oLxaNQ<4Rp+Q#RIYYznw@8^~(dW zL_Nh^Ht2dTDd_uf4@yqrDUC7ZY|U`&Jq{{#(_lgl(#QRxPxGrlj>d`>g49K~K)fjW zVw`gpG0=NbuI&3$itg)H7ZfmM&gypVItqZoMS=%bIH$h;Nk12r%%Ok?%y7?uc1boD7qW-n7(7Z6Y8JaA|gPnpGeVpM&dC zZ;#Z|s(~4CNt&X$` z<(yMkl6vzjqG|EBg$E{i0#%eO4jAD~Q1o?pGqzi#il-rUi$sxxh z6s$5paoUuCYBh4*6w`t>C~5Q^$Sv%x^y2>jXN*U^w>Ht#)S`7Jo>cb~(-8EkwZuFe zjN^)M85IiekD!#Cq3#R4mxJ0=g`nd6wnpI zVd_!=9chms6&IRC09fCSI#VT(1*&pRbIGV?y9IjC1e08a?^I>HV0bkY)8zH4a!N^} z%nK4-m+ER0F(CWYVN3f`g~vI~RgpIoA%3+eKmeqTsyC36NG@1jSBz$u(rnPj3h-wBc(eK*}WF21hIz_E)7<@*X`mQKJ(}+l$$`F+vbXP$o?JNR5p5# z*%S1F%8j*a!nE*oqxus)`$N|(1vOWYXq*=f;gu(}T~Z$6!Ak9t^>>25JYJo()1~$5UIzla|I3s~Gh!Jo}UYY8H?M&w8l$1P(H3 zhE{Qk@1=sA`W#hp^q{q5ra&o@PBBn&X{cvkjw3kLg|3gYd^$*W^)Wr%gW~ zW;88c`SAPp74dh9L^Iw(H0W4vV>ilco^z)|T9Qe5oXZgEzFJXoep;U@$70Gy%a5&V z>3$l$)MVRjZ6xRYg(km7d_DgF1o-gAmScTJ`bp4k-lSvo&3ey=z8ZWl_+@3~L2qVS zA&=hS-}L6Z`R6!gr6lJ3k<#!urvCtaYHPfmzXS7o!Jo6Ij67IS+vyUjI|UwK#eEgv zfA}Z%sd*i{>o@V*Mt*0GGlx<7iu!ir<28&U?4BIZXVhg*0>=>AKU!_9pAvjunK1@~ zZd)52L4ChMro8&urY=iFsjoMwbg+0SJ6=_#+9})bIll{j%ij(D9BiKYNUkmulJ^8J zKR|1ylj0YH{49~dqUmD#LBgfIxz2u}X_0&$((EMrSBbT`@3{N77fy+vWi3(aKNY?h z{5^{hJ)x2=M0V;6XYs{(3R1*9d8$7x5!~CstJ)M7p0Xt#Eb+gKqH(6_DWb#34|DS# zpX|1ON_DTn%Z*Ge$B8xhuMgg2Ra8;<<3Fu<_l&=7sUdk`&@}aqJYgPW!5@($^sgZC zKkYB$4--VmHmGAm{4l6b{R9g2=w(>x=?JQ6{Mq2*bDXX(nyjT>OTLl&k@l6v@5Ao_ zLFdbJWWGrI#e|MOEVX(60E<6tO%q68Cq%Vd`3K!{2v6~^iSK+>{J<#t+i_P20d+FOQUpE0s)w9)<*Y8UFPVvwN& z8&@^-{{V%*X6-9PwT-UHmS5rkoDbH!TZ@kfcrwtm^IKb@4B)WO<<`6k+(!?G3l`s@ z^%!0Y#8mh3^HkTHK78<>?D>5&#UQiV4_9N1SE=bAv;C}LMa1~zmI>4KtbZ4N)w%|W z8^NQ*Zsh~yYz?2!HROIG{i?iI;wW0u!ZB*^ftVL3r=~MpO=kGj%-1YWMN;96t@69o zn(ebbn}fsN6Yx!|HO0leL4Nbf>J4)`{{ZbH;J*x8x>?A#lO{-xBk5lzN2GY;;sxnh z4gJ6@9gNOZGx*n~_&@eO)wM8_Ycu(3*d-%#(z2^J#SQ%;?S&K*$+&dx9MCUz4)8)8Mj2&&E}3-a-*-ceO=(s*{i{x33s`c z#b%v=4A&AgfMd`xAXiy`<6j5(641ph&84JqkGPoQ@Tpmr7Kgcjq0IZ8Qps}+SHGNM zlviu(_a7|yclJK>|I%|uVe2?^^XR40>$1-n+q6_bAq`)fabi1#{U4e=YxDMS`9vB)Mg!{$OHLT z=a-27Ab5+#PVK1bw-cc|u=11q>ziVcH<<6dp1C1vmwwg!g>drf zARm_>g;SFG-#pjQP{U#H+Eao@&(@wJ6#1)3x9W6Fs9W1)oXE$%d(~-mwK>`Vu6WMG z^GlLzt<^?r47!^KSd{P^ib-`>WaYW6Q!yM0iQY%x41H@CStYru!l%%=aeB|t)d?>Awl@ltL;ORL?MpVCk`C&9MOA37tcDloTFcuV zKE}$$%wx4QtyfF%nDf<;dgF?{8H(To8wRANnT)8kh<2Y^PIK0;rkv9C;8ojg4}K3P zKJ}t$m^d_JEG*;!oSL}?p6oeV--Aa;$Ru-CS_Fvg1De@c7|Ako#F5lZXEm8yvH+(XESM_&2fTJw&Ooq=(OJrMx<_wrF8aMI;3C{dU0Ad z_Nu4lT259(^0sH#=xG||Q@5pRL8mH^SXH>-kxmCpbj4bVA6{!db2k!@v|h_Vv0^J# zAp)mJLH4YjO-qbAn8{=THL%Z0hDbcToZx!Yw{ujPLG6N`7LT&2QJxJt z>sYhXS2q$3-IaMYlHMe_)K1Ml!Pp*>NHD*hMeJ?L%@?*6_dE*8yw_~3n4o_) zLD6IRv-wqn;hX(bm(*`rSF~`P0r_IIth_sAVIRv9$luf9OJ}RsR4F{{YCrrhnJeU-}TQ z%6`%NoO(0%f8*ze8^%8kwY?c`MYNZP?7M=uJ*)G3ME?MSjeKrhV3Vuq@(9Bq zCr^z^a7PCf=+u;B@guUWn$+mz{{Vt%UOPw|;$DdvM<9c2=bu6Ib5W1@CVkQA@jph# z{di0N0L51UcmDtd5BS}1=N=@qi>UqGghTP^)~J8Oz&<(l@vD#fb&LN1iLO^t524Lu zdbIZuL-(CW<5gE$g@Fl~Kiw57X)uqymp=76+C+*1qL0F|(B(y6wp~xeQDAl?)BeSI zIQ{J5N1|25ARG~a+NH?PU&@OEC`Y45{A}`nyhU4z${2y$60Uu!Wg?BoJOCs?+rD${ zPQZx7dmAb->^fDXmvN2ckIqtD%LwJyL%Q&QE&#q&Au z`B9DCslyqkfGVuTawQvj)QQRKO2agOVDU{Z;I%A_VvvwK)C(&fDp<#v){P0X29ypl zQIVdVYJVgSXbJ@(kgKxwH8go~L5vYm647&xDef}JkKUX%^uWKbLMf}F!Ck|OZ&ts}UJl0^)sv94}y9_L1pt}GvPS=`ZAQ@`7aMo|9%mug(CiuN;fjd~mK6~A{V z;H!Gp(KxIfN5iRg`^ZU(U~(C14Lew$Ou0*KND`#+FJn}q$&*Q3?6jLXF7G3WS93_) zR~^oA{Oi=bec{VL0C*PgN$g<1G7;oU0Q%In5jltybk z9g;{;oCzlCgC2vauRZZ^iY`1$t=(ST$Yh#lF3!g|_8yq8OWATp=aj2DwQROWH**$8 zH3r?MnzbtiQ4bwd|^dA`;a5__}2A^r*P$neQwPLp?{3(n;G%IJlNU?*~ zrrnA}K;x$sBD-Ver|k*SgUG7Ake%)^Nj}vop<&HH`AU3q;YMmKGSI-Hp^jq58L2Z) zz}STC=9}h^K~DSQnolHp(CyF>D#{pOQjAr6nEOe%m^ z1egq|=qf=e9jZ2NDYULxIgp;DQ%mtt7R4_9I?w~R(>u^u4%B&z=|k6G5u73CnvrCU z_y?Ma*kgfFZ3uEWH5+aPy~45e9}wEvUPo;0wnrciIII41S3jzFRy|ixitU(&j~oM6 zWWealt}+ia&E#}6)J>_AOY+g7EMGCbsYIC{H9+#>pqk{Uqd^u~*RERUIg?Ntfrj0G@hQZOzCF!&M8L_K=;Xq6}NEXeGT%UU(<(8UtjCghmZY2pFcc z1j`6`r(&T=JAPFmmjIr^p5(a;j(VD5I6TxdUquMZKNU`0VKMu)W~a!+q>GOPa((3u z$5JXimiBpJg!&rHd9N-<3?o{D>@4rPc?)|3R*;)CC)X|G7*`D5fK_p++)t4lSO>YL zh%M0W00Z=<%X%X?UX^^LwlZs5tB2h46<_vgKEV_x=VJc=T4cJJ+m6*zO+b$MrqQ1< zspml&ji3T5wZ@^wROHq?_uF~T;Z^?Kzkq*Q-kk|e=w~|6j;)%qU2X?F6I3NtmmuJS zQ3+Yc88sr?$syXH{c&EU9J-}L$i!w;DQek5dlMX+t=yvy*`$M1ksD;FNgc;}*^5VD z<(2;ul~^7!{(>Ds;s|B%5+OSFcY4 zN(k|%nN_M(NR@gM_|$EHc%`1>6;eyEE1{@ENSP-(o07=hq*P5H2d!6Yq2HQo>xyKQ45dk=c_zX$vk z@h6L931g;QNsma${+0A+!(Z4N#9G9OHm=L3Lfu0UNc<~0m8fC$t1BGQuR70`j2xel z@x8u_B+_l#&F%(s^scYN9}6|F6J4YhI&9O(dMcsjze~Iu`y>25_;+TrYd%Hf>ON^E zeXIBi+?(P*!w-gV8*Mhu)ufF{Sl~dwk=u;_04nppwWBv|JvTqW8&3^5B=L1sFP-1q zkBj^Z`wV!y#1lgIkhP30%L$c%{#EqPfWKi+3iw_~r`B%8ZlSiUmr)-t^Tk?&;Qs)Od|;^lC-D=-2UfR_1OEUX$*(^Vjm14G%~M^LtXOJT zTF%&dbFVKo%M)d9ej5BJjcx6rv=?Zhc15(x-!v6pfykMX4In^Q3?OXV5%Wfd{$lF)#eiQgR z!q6GDyNK@O871ypKg^Sw@Xr)})t)Z#ATa2*No^Bn4HzZ+cl!?J(*y zhbQGIz;oBNdsMRARQ~{rB~Cq+kD0BU<@D!z6s2B$=X-s})VlBO8}ReM7RJv{WVn$= z5-VV&XWVBa@~nL*1EkD_Brr=p7FiY z$#7Ti2XT+(UEkYzWeD=qe{wjN3}-lr{`MO8Z1@a8GY|q6J%Gk)Saf^I$N~utdSMCv zmHJy_`#yLRO$y7U+Z~5Hm;+mu{u1zxlom+s?N?_^VAmwLqKeX;HqTNV7l~iRs~G+# z=KhWFkHmW1v4}L=fg2Dv6d(%vE8!>Xxu?%=(reb&a$GBL8a<=%A-S)o=kYIxG^N8m zv}ikp09If8E#D5VQr<}?VcHmre=74aIp!}B2O2c_e9uP>!&!C?s+KPjr{p`sUk!Xc z;aDPu*G)~y{{SsyLAadvHPobMZrD{LUcd~0D#NkyP2Q;ag2mP{H)Zk;KPuXY-O8zy z$GESMs_Mz!tgg?|@O1F8g|P8Sc`Fg#>GIu1Qc1Qc^dx^GE6(*#fc_rWr}M9k#mV%L zh6nQoy*^Nog7eI(pk$UjS0&AlrlBV^baC`%QST-^3H0gePO#$zsTjsQNZae zV@WRW^f+x(_F(YVsThvh!|ah@0@2|!`C$IFv*B;q@4`M1MVYPDq(jV-AL@CpEAhwe zy=A8>L!w#7n*Voz#OYHR!ub42yBhsRO z4?}ptyu2Uwc&$@7&f$iiS zSd6ddS}B;g=vlVXQ9#0s`qrEpX#{nv)+q(jhK5Mw82%HQ(~2FWWDl)3G+^S)`0aXd zc&oPd!Eup_wy}C*p0PI>6+G7?lx%k3d!I^>LMl10BeY%4!9eOwVcdA;+(;s|jqV@N z4k?MYBDrsgLmv-(QU3s7yRZEe)z+}c;<-PGN`K*A_@n;8cVGG`s~Z0R z!Jnf#@ZZLVs{GOLbzkuJJ|NRE{{TNxf9OJ}Rs9EzdrZe4=`o-Dcvt01;z#ar=zhC? z)QyjW{w-{7B2qy2=D!l)XM3ZDA)j{aSMAsBBM`XwN8;eppkrGk6INMV$ z3H7Bb?Mn7E5lL=;N|8!T2Th`%}dl*C-c;fIi?31PK*yqXlqfsPIFE%@$FBgAY!yp zA-!xuA?`9M>cLJAT3z@R6o8&U$)OV2X&pr=bu~>HhYeE&4brr-V!19ysHU*oDDsb` zIbz2+rOF({Mlv!%&$T@^NiJ!$wRKgp0p*L zSEW)|!{q#+jEaby(xBvI=jv&qB(AJ>-w}LtntqR@czeUI=IDA{N{=F`A8C~G@^gX# zKX==XE6=TU$aN<9SO?Aun$Wl)sR!oIuQi3J=uyl}_UvJoIpd7h(wma8&kAvayiJYS zKTmpak3EHRmKy8YN-eL{tDcYAx+Rqz6_LXR930jbXHAEyam^zj#{(1>CY4&A`;}`5 z07{i5T=k~y4c4PE0CFg`iMeZdYAP7APfEROZr2is&}5z{jXq`*sG_dQdv|x;?b4C$ z_hST%SEu-6;XK#g71jJhsYuqkZJbQ7u)t}j$H=2SvtD~}0y!OlttU&7T*ejEM`Tlv zDmgVckP*d3;1N*mWjq|#4|Aq2>r&##0LCfXs-R-0ik%yGprJr**n6GB7|lk=$DzmJ zPX`B@erRiyX^??~^HiXNQA%^hY7p$54O7@!BH&;ss)pc>In7KY$j&P}Sa%-XYP`8q z)}%a|Zqb9yFd8OdP7)enAXG0l);^T1LwyTD@+d9ESM$aPBcFcs8OJpFZVA|skSaHm zY0i1?Q5nyzK{V$yS&x{r_Kn#Yraa+4in6D<^3=9)D?Z~@2s-|>S%uB1Z}Fd)R&C~~ zi~w_3lWN77fl#FFAvxxsC^lY_@tuz~2zNg95MU29@pBYyg210aRHE4~Nfu%SfHi3l z)=|{9rV=J&P>buy#u)?y)S9IRnLPJK^D@Q>W&`o6l4};|NK?G~R#RFDSQ%z4ez>Vb zmiEX_6#(vPxok%_a(2`umA`g@M`CI+E~X?rqyy+`oL(lexSYcr$XIcJYH9TW8N!^O zY7%H2nP5Q@47+YV^zXDo7eJ%%ta-Hp+~%mysd}DkZPvtSnZG6W_UbX|Rp!15G7WR* zP~#xgc{Ku0-mPJWl$OUmOhjP!Hs;g@e)cMq+MxBUrnzYj253unnDJhnJi1fd@@wXh zrnE0T{^9=s*QnN4d~i)!ve4dK5-V=g!{;OAuX=`23u=7cCq8LeGX^V1l=2QMM%P4A z*=5ajR+<`x|c zsT?S+id=6N6HHncuZE>26Lyu4d;hho=Qj1qmf{*CwE1^{@Sp zA?#^aMsj!r)((`U^*WVmYpI6Yy(v7GZ18xlnk@!rBWwpJ9gS>QcpFf@C1JCYcE*2p zrMUbkR4<_t#?y~ef=hQN7{ywTPj_r>B>gMYG~a}J)}bP#dQH>25C`s957bw({2Tp~ zwNDdxj^5W?)2{4nq#0Y3Q0l*4YdW6Jf^wX+Gg!w}s8WkeB9TGnu3c=N_0NKb3oivEjW(TuqW{vfDu4dN|cbk>d^EF$^?no#8J}(XzJ$NS=uPx?vD``@rUQtHXGQVKgv7)5=&Q@P>%4%P< zcf-#Bp|{a2Qg)3_R^BbG{JE}0zCL*O;zTnyhV-B8`z0hS@{Bq5`Hl(vE7&3MkHY@| z4yVl>i)=iW#D|~Gqtw1E{4Vh2nv;2AsghD8gz|lWUbNvlDp#jMQ+{V9dRWQ9#=bV4 zDE5wr1>jGDx`&Hx*HZCTv3(`Xxi4(0RTl>w04vqJEAXqs9vQ^ent7UFKK$8c{cC`_ z{jYo#VC5~LnnVl1j41}WeOvb5vyAQ2^r=|qq~ik~x!qShHfM=#Mh+{s##nqu59O&; zokcmV-mSCfxh`*GiKDaA(%gZBXU5ha@Ds&zTE~L5{{RxGTdjUe2=U6=Z6p5x9$NXk zRsFO)L8zA&c6Qe44+U~gJq~l4=iBz3_=T;zM{6cQIRnWD^#ZPzM~;uY(q3$gR~2Pd zeaUmlc1quo^m6{rS`?BQbq!YH_!3${D-1F9J8@e2cfrpBXierVtO)(v%pHc3dphe zW8jYiLFZpeuF3N=T+CbR*EP&|i}sH2CxO+Y(=B6}%Aff6jaQ#ToL7{nEJUqEN`7xd zeFMi-!N>igbm@67d7g;>01_mULM$}tZWlZg^6~UIu4~6X8g*X==`v~;Ix?lYa#^k; z1b%EQ!2C=5UU;L#1lwquaDi60ha2H`H{ie}zKt`|S>5JCMb*P)as zT3D4BuQq(fGl{EJZk`5nlk$p7^*fzg_TAR*B$aIR_+wBAQy@7f@I5PwzWt>223q;B@&;#9%kG0shQ zRK?{uMaxPY()2kg<-A9jE^5^rBKo&u=N}2_{xFVBgOb>X-g5@OOhO$$719lG{kQ;A4Gfq0E|CrzZ3YTM>5~MY1k(f z<=U5v?r-KY%R5T!NhO;k{x!!NrjXzoVR9?mr%sg>%Lt^8oShlgla*;U-*bNFSTgD zSGl@_dRVMqC%L8&%-m+S;?Te%N?s=TaCO@ zc^DjuIf}9~1-=j6t0`^=TveBhW~&l?DW(=A*ojEYq;X9Uw4$zC*b~7NT!ySwSj%*) zuvt8q$gB4HetQbpw6Y4LE7F=~a=o-OEcAsWlDt;Eww|ki6mjiWBC}i!3<|Wc?E3mu zT*0QsMQn;N2iBHtKHlX@$ry3yMLDh(9J7!B@7k$d_?pVe328UKV1SdFIg&GRdl6iC zQDZ)Bq_SHOdMjjpxvK4~UTCC!u~uluSqI90T8wy;Rn*B#DJ0t=a zLJ|B}=DGMhTP3r3cec+FAIFYA#rs6R@p+{ay+H=04@;*i`E+*LQ9dFjP4^WKs-&or62IGJWzk?7j^#tqMElJO{>op%X~$- z{{V^u#L5pJ*>_+1DXS{-{nY&#z8W^I4^{b}3)Srh{zKYl{e4D1@!?cz_JRKZA?-8% zzM}sC(1m_nz9aVt?vK}>+Dx9u$fe}rBu z0Q|e7{WD*Ph5W5*;E(y}{{YO_PAB3=Wm1a(OMOi?khjc!wBfXlPCp9ZIZwEfKxyC; zQ8HlURIxTrD^rwqC<~8TeI`X%gj57lZ7OrxoTn6|k}x{ZG)Sx*XE>)|k=CFwkTce# zjr$(7tUC?|2d^~rV#C&urtU{tdM@4tCNaB9cBI%>+NTSFM&3B8T8X<2h>hHewdH}( zRWhXxden(_O8`YXYAchK#QlMU31b|wg;tpp78rWOR}G^M$!NQWd0n~zG5EgR>cr}gVgur~*qF2d=O zEUM#!NF*S1rU+F}0~IFmoE!?Zb2$!1-PbiJ=As90JW`1;C}Kmf4{Ac&PZWYcX&Jg7 zT444ZtW{$;&A@%_Csr0+A4!cG3qt(n?H8BAw=&1>zAT zV3Yh^>zyL^!_(&`Yh3j7uDcv#G}$6qMj3KD)uipHKO(J$rv{*a#@{|UL)2BokQS^B zOF@z+Hx?O=PVq@vi{qttOp` zxhjFc72bR?@MY(Uyh&kY0`V+^d+a1eJClyX{40yHgM8??+)o%ETKZuslp-6Dzbr^ zVVDAWsj~}?WF33dB0?$ta04A`yG#u}R>yR=EE$3CO)~+Wdevh2_NJ4?MVPs@ESWg0 zeP2>*8%vvo>kMrt{qheVrCr^T_o}7sqTRtHPs+%pSbBmEf1Of;VC5U0Pw@W$;&!3p ze-ue)G8VO9%!P6C;PD+vXGwNyx z5-9Kjjyu)2nN)v;M<;QqvFQZUw&|rH{{VGOSc={_pK&?*)dO>mbC0D*CFW04O+vxX zb7`yvI)S8GN#aKQhxt8+Dt>SDm^l0D z8tZm)e+to3pJO@Jirtu=9q{AWUK^X4x0C8?u#-@2xCe^JXL&y8#ZMKyvH^uZO7y7U zDo<8nw&Vc{MRrY7*OMh=-OHuWxB@67UwWPNm1*KZP*f zvkU@j(1NEGqCC06tw*am$gZG-05CraSv)};0|T1l-r_<&@fBQ`Zl^zuYwfMEiomDw zGp?J)A~A$gdsbD?iEb_*JIEfHtU(JFtB~54Jk)zx$k}12)96emp5@8hNj*hegGjlX z1!E^Qx!^qt+W!E=dTqVw*=sGtX)AgmVV~#vSL!c?KW5*Bl4tfw(>>_?u|;H zrU0%CW^wL$A_*WUq!kANktKDV$)jor4ZNn>E#G-VFF*;dkF1o|_D$ec=-13}XWVyxT+l zpFB@`!cPcNWf7(*0`_f6A^YyN3@`_sG9G{W4 z8PuD+Fi?WF^40y@nv3Iq18IRGu}f*#438~W`I^;F7SDEyZFGs^M&vuHmBu*eN%ZYj zZS<`>PlTT2ozqR{Uc-5W3?j@;KZfANdO+P;3*u3ktSZ8@E~ zes#p9n}Vyf=dM7_Z)hF_);vWpio(Ksx8sPAp1mvESbRMNengKNrY5ylXEy%!MR(#o zLimF<+>&(aak^Bl7lVV+J2{A=F48~Yr1x5P##yNzru-vxt%ynR8%d)I=0 zV=oSPctVuyAZ1#$9r#Xp5#|0Og&lNSo~Ki?3uM3b{Y~;%5XgI zqn5v#J$J>QwXcP=6=?LB-tE@_Y>e&ejtI{+yj0)G$qT1 zr0PEG^reQ*F>$^>5U4}4cKnZ(bgzS76TT|k88usrsNqgO(l4zBe+A7mN&Z_0Ki4;t$&Y0K(cpXte8jH3I5p?GhwUfgFB2=rWf`-L z_*1);A6o5CHpJ;pDx6OutoUy&cdt>`pRR|{S{K3p00ej_`PTLpir`?gDxc?9wI7IH z9Ps32Vss3=1w=R%@}19#wR`J*+MWE9IXNdJ56d;l_`BjgmXT}aETlFt?B6jb)`;Xa zt4jEajz$vWN;G51m*G@jL+Jki7=F>~rYoCrgKmED!N=oYIQ&cftTl_HEH>9K7d&lj zSAcli;=RtJG=djDF(WI-@~=0!)h;faw30Iu>TBP_<# z-Y59Gt?QDxxW1Kv{vGDH$@Tf>CobIjRzX&gvPNoUhc)P~SgS=?-Z*+t)vK~;Z)A}5 zAo_zy9h-(E{xtZldR4Tx&uVElhs_wPBDw}Vfm8WKyH&VtU4X(?g|?L>nE5E#9PW2! zR93_u1xg?+e(A1;9S%?KR)iW(z(04=qTsbTcyzfX{u;X$fbZ8e*V<`-Ajss^*mUlG z?kgEg>54}wZKC-DgeI@Yr{Q@O)Fp!+*!l`lrt{N^=H@!16m!X8mFZJ0i5%A1vf8{Y zIc>-srYku+olw!3t!58UGf|r_L0t#fcP{OtG|wehsKNBCtaRNR{{Yz-4xCa5g7p=+ z@h&*3vcR;Nq)f92raVI&3ck@|qIJ%5#w#F5Nh*FloS%mFY>l6}guknVEs-6?QE!bt1H)vk~A0!1SpWAUQb|9K&kG zNbFHZ+!Z=m0CEN@>~M|0DDBkLQ{CIysWVEcj>L@9<|56Hh9^&Yy$Mml!2D~T{{V!f zNLei5Y25z+7Tzg`Pu6ve8Kqlj&s9;$to@;C7ToBsHQOr${p9MW&>YrG-X*%dJ9O(- zlz-1d!qe?MA!%w=<;?HvCraFirMhfY3OzvPpEb(lNas_-+Lo@t@2<>9dZcExZ2Tvu z*d5Clm4|mwd94MP2RWr_`0GyOICd;X!ZiR40aAI#jGWaYKOJcENB63ev4o)6blh=_ z(hGo3HJoKWw9zqMrxi|G8S*my$B`-(bp(2Uo@)Z{K#ip@Wbbb;ZKsdQw1Wn2YCv}5 z2NDiSlzKue&mNddQ(%x^V$uUw(S{H%Z@>+Qnujf zH>x_flQhpXdJ4$4@eQTW1}OudLDr`GLpcRO0MMs#sw}?O0eXS?Qw;l$YNpcv0CKk+a0HT_? ztH0gJc{8I8OZTw#N5u7e!~E;|XZ?Le{{W#1qgV6}HT^UGzM}sC(1m_zw?AKpL-sTF zj6?qb3eUw0{5zMArhmr24`y6z*D6MFG=udu`-l5PNDsqr6A$*kk*~w+pZOT>{{ZW} zpUl@@Cv#C4%lj2?d) z07&NAFltcbo;j$@YEQ~2jmjOTtu4e_=N)QA01mYn8Ko6VLpJU?fW<_}cLE1WkaeeH zny0B3u+=+9CZ4C7V@1B0147?1%}|oLVv0!4<@crSL42cF5^0SNMn_thbm>Zc_@czN zK!f(sOOz%L*iywkCG2#OXaBP#cp_`!gt;j)^9YuLK79;y8!vea0uz1#8xpvDxEhS z%+*CT?&>6JT0jL)ELdKZdquGOL1x;0iL1BTXW8wy?LUQeMup>Zo#IlLDbHk5eAJUJ z*$Ahre7!4@QM<9(cJ&3w?OFP+j}81MAPF;_!>wrb1diQmGTXE2XtP4UW150n-2uW( zrdxkeT`iWO1>8#Ux{yaq^Gh5Hag{5dV_CEKPUB6T?(L-8-(fsfs+*D{+9;iVfvLl% zYSy~SRF3(j&ov`Quya-9ZiY)GN_*T)Oey2RE$EfCqJt?gN5$p7*4xnXhMHzGS ztJbky&bf68p4I44!23pdwej($u26K?GDxVeI^g?tI28K8zOU4=i(_B~XuYD-%+VX53_y?Hz!w_foTz2MU0XYUVEV8;i2v zF9)rA5X%P_fIvIHxN1#R2<}S*V4TL=fWg8%5(IrTmJwI7}SuY8gk1l zZ%aK>>T&YQ$_X7sHY>*slg(;f=@LXpDOE<7BT2#<KCXT}bF_#;F=K>Z%XoWy2kbl^Nnr6M> zSnNXkb@mYz*kJw_6 zZdqqjxAX?Nr#dvXsWgn^UUglmMa$6n&K)pkr{`MsIuy4LwkY01j)6%%EA7t+f5AUy zp5Y%`zY!o~l!W9SYv?Zl{>t76@Ir$Xg56wg!@S2kzYf*rSI;nbXK21}L)4*^W*EzR zysOOo>hOQT&lGEuL-s8q?Ms{_lETQO_QMg6*1o0qfBQH5M{O?N()V7UONKv~M9=00 z1-Ss^``6eI={io25(ur}w^+sjRSS=$X6nBXv>h2w+Am^Iyx|VxpP;Xwub)@Nd$cM3 zr>PDZ!(pF3I=|fPaefi_9cQ3hMg6INKBsc0CRrJ`6Z!g+`PZUY>2`Wzh^?Sh1N*7^ z*Ngb4_POxhj?zbA8{WE(8yFpZ>&mtN0NX2Gype{PWgH-keEqAAId)@=ca&{^kma3Z zSyj~+cjc@5&#`W0SU-6zs~U`TIOFrKXI%J?;cW^qZ#5fSgM*0y$JV|ixbgo0#6O67 zlErRrXM2_jn85&lLHSpxcqjH&*LAX&UM19Kfmme6=49vIx0X*s3c0aWSE4y%#Ms=z zT)B1{>GgXf&~?AsZ^JednDnaysoa(-79{8OAIhQAJ|O%;@!iUeJ4^n`v~UW%!?E@0 zgZbB8qx?4T55o%G)R{(|ZxIQaMCe;G2w_YR2M1c-8dhZ#Rg<23&O1bCOlS7Yqj zebn+w!vrbE_*c0@CQ|qD(^l-XN6q3mj_uRR={5c5?>Fdo9zOl2yiek^Fu`&CofiCv z?f5^5uNl=ZFZG*)Eb~b`hXZ`1=eh6hO??^QkJ!`1dd#aftm|P1bZ@_2+2N1bW5GTV zD>kPp-c27R=WU#w*%<75f?Zn^|O&QE04VVYmXp^Z6bt>RoH%ufyMlu&{;g7Ihp&Zx{?u z;4930NBd#i>MYj&8`5Q*w+kC(P_Yk!Mh6uh>Q zJF8eGJUQDQ2fre^zYPA!Gh9r!J}1^%c3cpO+4|={hv!~hdP?`lVlMk5*u!G0O)i$PNyB@r>6Ab>ja3ihmU@3>tl{^br;0I>Q$k!|!lkN_2_*XgN-`dB(-VnDi={`--2Ls3o6Y}8Gp;DUD z2Zn9;Wa21Sp1GzyCH%2}t5&!ni8xtGkH~Re zq;NT&DO#;X1=%CchA$_=KlW@hsQbyEq59v(Z->7PtQjruZ5*J=q$~(0(3J-tm3aq@ ze{Q==cAgC%PF7*}WtKuzk6QRQRPkiDj!Vc{y;$cpk^QMX?As7Cw*!Udx@lw>T5p=G za(-s@bG**9ZB43C?2;8Ft^yrvS@C7j7Z(%Gr1QOt z0r}QPgnl1i>Q@oW@DL9;YWF`4ctYDrfj6S7PY8ZLqCGM-XbsjqSo;0|gC|OJ6n(#jmc*^TrxRA)_1p3xStF7GY6SGR>=BuOx z`_fXjkv`W2mQ%v6YMRIaE>3x=79x98&{~p4IjtLeHaN=Cz4tvi)JtNVcBJEqyKkp6 zPY)1ni!HK{6%Jm%YZe^o`^X7>d^4RPxIegw1@k>t4hyHwX?v50^`0p zteL&6bB`9WsgNTg$}%y#_`f=_W#Jf9`Il0>@_)2*TkrC=F~_wvxgP%1o~0`ZZ)pXp zg^3jh`y&+ZGY$agJ!zTGYGbinob;%*hb3~uzCgz`nFV>OziSw%+Tfm)?ll{t%Qva0 z+T^cFgpz;#bkaenb1&JGn}%*PiaGVF>f?7xb2D}|YHGx~cP}Z(>qy`p^+->(c{Cf2 zI@0Gv`xJ{r?K~Ph^HfdNd(%w*^wiNjiJBj19<KS7=xVr^Z(≈*V&a*cPOad75e)wT%sS)$07bQDj}TtlzRSyY z1N-U*DxZrrNN+qDuLO`2_6hhu_072bYZ|pvguM;u&}yV#nm$jf`UlIe>4g6P&(vS~ z5ULe_!@wPXOb4ghqyGTVg?t+LkJ2H~{e}4S@&5qAzVS}LP?kCT>+v4!Z1Ek~J*Sj@ zroU}IKS{s9z9m*%VU7s;*W!)rCHz@ch4?$4I%uaK~JRv;O6p0jqX;}ku2Q=812a!_(0O?bpqp4g- zz~ZMyElhOJK~7EwGz5tUT6IQ_82u^xgilPK64KC4+l$)vh#Q?0x*c1e>6sbP`Dc^XW z^#O?Q%~~Po%NvKS9%08?h$$zfPaV5)$6Bsd$dr|U7CkBJ6EVrFP+I4x>sMp58T+-> z8d7Uxl690`tfyyeW_||(zKs2xJ{-rd_^(Zt<~4X=f3z@V1Ra17KN1CaosEe&1BLHj zTYk-3G_MNl+SHB}ZJ_dj>O*5c;a-Yf%8Pc@o(3kjJye$6PY(T|W?e_(HorO`3ioXu z;0MeXi#*m7ut-{5&p4^p7}Y?^R6x(IE$U2ab1fGqPfY1$9t~j zy%R?e#xcmnORDL`t=I$8n(8ziB(yv)Aa|ov(|ov{N)T~f%92sFk2T|)t9za|cVfvS zWOO*Hk^+N~ftu{KD*&#xkpol1~5*O?ee4UUojB7`5#An=#|I^6U$; zks&n}pQ6uv>``!o_>X$@S^O#>i56x49z{r+Tgd`J0)#|7oK~^G>vPU|p7uGIbjw{T zEyM?#-m-4X+`E?hr1TZm>iS~CFae1GW36IIYqXPq1!+2ID@7d?Yeru4ce%^z8UkG= zNZC}LH4NRy0bTxYkVxlwv{f1==les z6>G^MagUapFNfsP=ig_S1mv$_>0Rcf@XJ`y{0C*?T}UOgcdxb!asIH3a$7jZY-2gi zXF5tQz_EJkvF8^H$*Sibm2ybQkc{(H-ZH0}VS{a+U3_wa zz04RinXV7|L!5h6h0&pmj1qs|=DJ91GQV=Ahe|UZ5aPSzfjkQNyknvyo#BxTS!l9;mFuX&2j^Iq!JW4&gD!}RCm@P$H`O0OOEsE}neYDqutQks zTJE~Cx}?8nOa{Qn2b$acw|*sAYPx-|g>7Y7fNwr$QV1S{KDF~Lyw{o?(VI`6OL+>E zCN?0ES=x+~-EL_MJGWM1Nk5%@^T|`keQ~?_A4v`%sr{Pr_^l-F*~DJyu#D{)Ijo88 zN|S&_E3%r+hH^4%HsZkJ?$^Aj_AsT2Zr5iYb8g42W?aC)S5Bx zh|49wd+A5s-f|D&+PrG{&IX2@*M3K-Ln_U2n!=k)&qMQnO89rG>ZL`coRjY9wGDcQ zg#Q3#4;pxeR1K!Jm?w|4a7gvyzezOD0eDBjmdLPa_I9d+IVwASYKE=xhr%8j5;XdR z4p0m%Sly3m@nysnD?VH`CeLgAUWFCSj>M?DG?kq*~h54h9mjwR+z=^EC6#m(s9@CZ6PPCTgz&w%%e$~Ri?Q7!=c1MRr z(b{I*LhVA`dU6F}KkbZC)$%X6h~hYN1m5*u-hDx${66sChi%8%EFg?=jsWfNR<$n@ zXnq~H2J-F;ROl2CPtyXvR9o+j9}r$uj2$uu$ob1A-g{O3EA~+FR=*^3_>073DLH77 zobLQ`G5J@UECeF&jKqGd^(p5yXB4u`4VOmMk?FoI{i{45;iPr8o$u}kNaDf#`OoQI zAL75-_r$uhfudT;YaZjy+_QZ+uUFE4WA6#sF`D_LxP0!xbC1Ml71l@K?}pw1gUp)W zO@WXC(nu7ues$GCu3LwHx}@qqGa zN2O`9N=P_)9C4A`iuDzIb$|DKI*L3wVDgF@<8gAR{Iw<1e`3fMaWOO_|IKujv2gw*}#jg4{j@+v$*(!@g0J} z{h_EPLY!_YmC~@8k1gxhP+nRbj~|p%e3&dNsV$HQNSc8zak)QMFN6y zKBl~9#-FwZudK%{zKL!uken-m4Q%v+ALj}aDqkOk*zi}P*u^=Cyt9k(LKoP&+#{U2S_!<(H(?Ies zNTEt|TSoe1wa-ord~)NT!*(z8FE&Q}wR-QAE2nEUdoL9rXE&ZW9S>khYlj^hiG939~ zxE{FgUUzxo3%j<=vooJoZ~nb{D=Ea){{VWd?b&Q_M>NA>m9SV?r~J~@pL6)j_S({P zm}Q4Uu?->}s?uRV{Y`jJjK6E|7kGv&E~wT>3EC~bL(i!g&3x-5*Ea`fC70VZEM6YI zyTDaHje4{)j3o_W3%@gu6Pe=iAI7fI?6gMqzwt|2)@~kay+V759~+(9B=KC$&b>9m z5?Ge^t&2~GnuX(SD05vto$yOgxs4^3CfH6g0X6CJ`!#D=@+FAD)7DPx^2zUIk&n$p z8g`+oFk5SPkmni2eF38U6SK8_*AbT|`@*|>9|!3g4YG}q{G%W+=B3n+F8Y|f=8{&W z54`!)K=?tbYK0ne+4=?;!oNR zD5rF`B%t6BFe-WN8?%CSvkZT^1*ehhH#Ztqfn}2DyGLsBzZZOIw6$pPU&}pwHJ9ACcl#hO#DbQTB(OMgF#Bo|y_DsZaR_(NgF$dYMj6Mtz=`cspxq*GnXhD^Al)01i4<#kQWHfsT|nsm{@vX{E3n3fZ*N zwt!l%TUmj|D^@EpwMHs=kv2IwZZ$YHNTIj44Q|H_O?I}>6B^}keJNtFLfPBTt!P6K zLs-sThL9vAC>W~{+m;;Ek;VuWVl)6&4cwv;Yy~^XKBA|Hi)caVPx}BX zoMMM0X?9{QutCTK(~JQ0t#`2mebenxHK0M$wM&t_S&(gu5E+%fob9E9XYcU3S0ZkGHTI`D&rU))os^n9Mms@xT$8b zWk<^l;8j@$D?zXn)k!g$&7-9|m3QOs4{Aqc3y^b9l`^$iqT{V*&62pslOJ}NPII30 z@&fVJnocSt&XpC^X3^l7fOfgUyn3_Q29<-aqE@W3CxbY$Mpvnz2xg#Aa z20%NX&X+iQG|NjM&jy1iJ!+=vZ`y@FXo&VM<}d{1CA^DghmMnQWpg7x(zgnssj`Y(pUJ0Si8T%GlnNmc@y+(iW;a?!PKGHj*_50&?ocCTB*H$3X$t>)v818ph z=Nvz=AH@Av#&X`<_dG>bxz$wp<^KR;Z;Kz=6Re&U znIcID1j`vM&u^N$4J-Z$)A2`C)8e{l^!p~0B*8Vv-k2m{{#Dd#-?q2I>#a`WMfh#5 zOEXO)x1Hi?kYt{C6_|f*&jc?Y@T;$}{{Ukw{{Z1wxY9=}S2_ikYheC$Ivkpa!U@eR zXKCiKM>8hPIaHtj09`Z;)9HZY4>+NsDG;ha;^q*Eq*& zg@c2~0+oi^dKSZT>rUNJ!gmf1YR=K=M=3Yu(2pjReB4v6Ar*%tKos8~9CPypQktKG>H8@dC zbl~7sUgCBPgGzWfsdSr1yu1+wCbTc~N$npdCQy13D|JG0S4ijf(2TimW+4FjQkt<1 zsga1sBZFFS>E~b}diz$ep*eIjtB8bmG2;IKS<81Ns`&~2z1g&jYX}s>(NXX556!7|Lco>{r*`Hyj ze4t=gwD>bnp2OkA?wM>?%)Gb()pMP=@A(Sz+ifMChQ;=;q<#c=D&B1k^-H6=HFm-< z;E)F-dsJe5of>zNJc?7Pi>=8`o(HdJZzb!81U7#v!EX*cH<&Mk$6EG38ENs&uV2hC zH_q*a7$19zfeg;qMCDTB)1KP{WYqax2t4 zE%3ekL3&^8&GuC0NlD-jZ(5_`O&&cS31Wl}7>dFbAt~8zk4kc_8cEAU_VYZ`Rtr7L zfV=^LP5TMcw8pu01zdcCy>;3bfv@~ScN#+rFp+@YjtADP{8aG2{3DkzSr3_#A-e;P z{{Z1y`$)NRC#jqq;}qJ^@hwJQHdC6)Hd0Q<83MZd?GSDa(Z8@4D6zHxk+y<+8ndE!4*vj8xs93Gt-7^()bNtT zJERpcOANjFUdB>riw-1VzddtWmYNxDtg$R(*1ZP9LbsAI1WH@|s<|hI^Ma@`?OpWp z=*M<>HFN4~;Wz=M0CA1qTCon1<(>ep!F)2_Z``ZVcwF(7>s=~^IG#0Z^|OmzFqiwa zp=+cU>b2Lw;Y`T62lB3iOz=s&by(4fJ+WHaoE6(Xey(L&yR;Sjjv~`RECAcr9R_Q% z@VCOfN5nT?RjsirkM)Oz{Oj8M0q_*f9Cw%dpn&vFhP^9T@dlaT?KkXNOlZ?6`=caR zfr-r$m$JhyX&&YmCaYGT)^XD0k7v6+WASgn*!(4CjdgU5c-V0gpW*4B(AP0#;iYmv zcluYa>-ufSh%Z{^=@=}Vb_3D0gCpp!Zv5&ibEve`CI(SplHI;X02Wt1<4t~xaF7YeI>!!Lf_v}uE)wPTEQJj-*f1wq_h|TbLckfhB(E8lJCe1Nza#5ALwPbus z+B3{|d1nXIS3%+Lg&OyXnSQ~g+)SsVh?uYEiv0=j@9gd1F9*mjWVVRUA0k$eujV&k z{#CoE{7&#EfvpG^wpOvJ$CVB_{{X&wSA$cCoSm@LT#vS;!xE;svABrA9~^j3_A>E? zv*f+%L#M+1*lr+ufOB5y;cwWB!#Z-uCaHBT)aZA7jfFo^Te^?!Q>Mz&$DrvmYHp(; z;!?}`isohbrSV(F;Kif(d90MSH}I{u{nk0JGOjN)#C%OtUvb+@50PR0*eO?^WSd8` zXuk>k8{s&&TWQvI!8*GD1y6nP3&I{2jhFjI&H%g^;V3^YYsTaFFYzD6?i*e43qcED zv~dlLqtI}1_2##*32LAxr@XlUr z^D)(N3OB}Mxo^B;m-*b}V)#+gMDYyRs7JeFdLKgC5w)0b< zj|B1Wn*8w9z9s8=+aI;+cMMN$=la(@Hls9;mSX*So_|`Uk$d)9L(jdMj z4^?yTt$X&<@b;S{#i!Y{>9pXE2lB5p@el2-t>1=@&e1K?vE9h8lwp=zm%vh^I3JyL z{u}stuXvVtIrJkUy^BZv zp1vD=I*d)OUBPn5I^0M%AHW~!Sx+&*;lFo6Jksoy#SEV^#V&f@>|a?sFU0uk!`~15 zPvXmn*2hn|xJeEJg>9dSuXON_>`|_2kjFN&e;tN-F(?G%>MPhaPmcZz@S`=KhBU#b zT|~P>T3h>i;1kak#9aJB_@D7UIc@aKB1;56!bg@sJy^2 zR!3LbMltHhZznQXXN( z=?Mqdn(dBArSD>Dxp{1emT@W&_INr`rEc24y!sQypSDMeb?GEqIex(z2WW)t{{R~C z{d3}mzpqN~bA2R*x{yfzRe^6})3@%_vvxacq|yE#-|B2Oz(_v4SFcM0hr{a#q6NAj*>U^weo`$prniFIq#Ugh7SG%S7m9(~E(6Ta*OxK=kzqHP^eqK8^7V)3%=la$%#73L5kv_Km&A>4SSQT6at~jcvE`cL@rij^^VBgl$tsYO zzURM7hGxHwQr?HtTF>nn6oiXkoUcL;CcNLozZ&&@X_*n)G|yslTnC4*CDE@IbWr6% z05BN-RP%1vQ#KEvbMIH} z^ukzWc=~@@*S66K$H`LDd!CQ9ggw|R9O%;JKQu&)cB__%ckP-YryX%!U8aUc2Gd(G z_<3XiG6VG$pDme;>t9A-SZQe{(;40|o;j`SJsp*?vY-C|2mqA%NNeLh;$x6}5vHr4$ZZS?`V31gK={uT%MR?K>K*1=rV z^DC0lGA;Dx1Epx&>E|Pg)VH%EwOx+erUfo@K3kM6?Fc<9K`jUaj8w^LSDbNEge&)} zDLav2klTuz$v@pRs`%obnRCW#Ay`2c)ulqpdSAhAj-6$2Qn3uvqSHDytAjN+anC!W;}qeQG!YeXl3ia}^c zYO^%ZTMWqRqaDsG4*vkgHgFRhA?_T1&o#|1WLj4>KF_rC$JVP}UR&8jT3Jz4cg`+xnthN1MZk3kS zKEQSS>#w!b?W~k6P@w0oD#5|varCL?T?eB$`!56AL^s^-o=&HGaDOV-hfTM&7>Xr9 z=qplDh4-q?Fe-0zXuA~{oS#8ZP4gPC_#pPG>_F$WE9!J4nF(A}6AVD|o1$E_+ur znbl2bLm_8Al@T1(hfoDV%m*E7Bcqp?Y3We9t7jvnNLz}Dq{eGEB9v?{@&z&F+tZpu zAOo6>r5NI;nAKBkf1U4&Ys>FdUwJtPie}QjLIVI)!^J3ua7IPUAl{ zK-0A*;QeY{Ku~CV%a6Ki1IAM^@TRmg*2;1J0FF2K*4QdN>lemOr^0&I9W1B+0MHF( zIs2^I5ciWlUaMM1{Do|>!1Lq(0MLb1tJ)M|U2{!C(A-8p@!?-Ow?39TqxMhYoYPzU zBiE%nrRNHd&H)Fdekf{p2GhrvR}COgmrPHv?_a$hFYqgPgW=A(rGw@9rwJ+OFi-f` z=WO5bO79tXw^_TswDD$@1+?=P#Ie5M4SF>x^J;WYW8h24y!*+dQ=RTkex{;qlbn8L zzSZ%M{1V&7-VSsZUMX!9G;RAIq5siRMptX2_Nk%B2qd8c3s zT#76rBL+FgH1w048aE8}qD2k&Pd>DVbV?3+qcvR_mxEFak;Q8pbunb1(lnbzHF)bz zJ5O3HE~VWb`R#5z#p-L*^b4C^ZWV@X2gV3BrM`tO6hK|HqNON3pqA481S3umG#GKOA# zYv`|m7NXxp)gbQVFdn}xaO0}Vl`eRlS$bI9LZvRL{zuPNnsY=!+l(Jd`Xk}XEuX^a zcFE<9&RMW?obz5=tZ5Brs$9fVVx*YC6}x4sU5y1a)RSU)jO-MibK0`SL)%h~k3R*7 z`mJfn_GqItChsoGxSmhvSqrEC0H$!A>$TDJ!!o!$)bVH`E`VhjCbm_Sbh*PC5}Q)) zOQC3PEfXn#0P9-%Z-bjsy;W$>lz#JfuA4>D7wq8(-Ow89VYi1$v}Plq$u;x!@v=>* zy;=6SJfbxcl=enV)wQ>Uv=eP^xGXRM=Df@P72U^(uM0TEvaukZ_1bDONvqy7?mk#J z1GRP9eY#s&uVw?ibHcpv$;oQj=uZhtF{qZ&hQ?-r;VW+r*m-cqs&UE1eChF)=le^= zw!0Y~fpHS`~g{9zuOV$j6n&Le}KmGic>3t!$#D`c~O5$j&AR~nL=uTb!_`qU`G zIBFX$&pbXBxz#SCw~Hm*;41a^{VTrLZ7lv4_-0KW$N{TgLGuzU&yfG!W zhuT}_jc2@6#6kJN?td!gt~?>Gc;8o=MTTM|kd}8na%#kTYI=d`~6L3Rrnpk;W&3P>tJ8YW=^0i;!&3_>n$u+ovv>YCeDw9;)AVvl^d`@mL4qp!_) z?Y=@sVVd!4SFehfBGDgLg2G^O)$Js?r_kHhd{ELOLk851c;dJ%K*uYwVpyJ}^{FoP z0J$ACx6qd%l&a9wgt#g`{^35sDRlAwTJmdAOsqWb1_54R#^eNF>v%;;5bN5Sf z&Ilk?IWH6(G5sn{KTFj0sl+xGa?a#*3P7ufL{o?I{KLb}RRSFtP zNQyWdUN`tKZ_e6&CkyASptAHG&l#%UWx2YSWN|yTcnYNF@~>$B0E8dG9uSCsZtJN# zsm|ZKR+Hkd!+i-h>AG**r$O?|6aeS(AK_dw%{jEAP0R2-=Jf|gsPB5ui`dJ+g)FuX;EL^X}`F! zd;3Q`@|d(O3R{-xnIqeu%z^sWTzdD#uNFHrHc6!0$-?dmKpwnir@j54d@-RrL!@ZZ zk~+5Cs6QfWk=B1{op$eVu(w+g=v7}oop;g8>pMC)biCxxFBP58ivIv=$2oaHSse^t z0enfT+%&!=@ny;(C7EsK!jt%cSJU9H!+#AbTxz$UXqiq+&2=FM{sX(Mc#gU8L&f^s zQY^ZwfZbIF2lKBg*XGo3q-LJxSs&D~uVxueGJnLY%D=jNyts!fr==`2mz>?tt~GCs zzYBZ^x+^;vZYPaM4;%w_M{JxL;XFb6Zv`(Fm8Gm`4g)a3AJV>KlFC3b&4%^${VRNuk(VElt`V(?9=}?zBqW>xoL8d^1{Ri(roQ8bd6p`_@oLHZ*?Uy+ZmFr0 z_N(dcGBJm9iov?*0z_jC@C_LYF$ckm+y`30Yrj4Ex}YmF)2Rd($lywC@7= zRLbRZ8o?M`pZ9+%$-B}1E__akM%3=#!`49gPXTW$a*Q3tPu}a-+Ps>%hAMh#O?m46 zH=GkLN_%-mC;a$tU~3|aH&0XW5DY9F+JgnTHi4}`ok8nXPW zb#RNj>y5R?^?$58zG{!pNSz;CsfApz3!#-P2w+$-w=y^p3vzQGDaC>ke~x2 zsK719p~Y2Cgt{J_g8SkRi=KOx$=Z{-L?f>~tHFFz`%d`N#4EBbLQ51Yjkd>N>F7B9 zE6Q~(TKiFVd#U7|+?FioB$L;g^r+>P_f~@b>euf)>bZp(JIP9;{`=Ga00BPGm*a23 zuZIxH292k#qarWMJ_LcA(^x7|sPDSe5YE zHQ5}Vh54uNKJ3C-rYWvkZ9#U5vD$c(;|GZRQ9Hfm{LUMVfzAN-u0~s%i>D>zIX~X# z`OrZZa_SAsRIV1ky;RQJX!*QZXMC1|T8c@-)-zG=$u%;#D?xeoh}a4OBc zvq^xlF+PM>tLQ%irFKJl>NCj!uxrpX--p^Jg#hy{;+4V1=~NS0&a#fjB=H?0j|kFy z52)%)f;)B!!>JrsZ=w7g^FVvq7-GF1^Ig(3XiIL1anK6$pA`IbTS;CkHB-0otkqdR zg%~GVu8AF{o#0ImM~iWaHOKM0RC>3K^dAkzL(dokqNXe7zYzRd)^%dQ-AJf^g0WhgRFr+)546E#I5;&au4m?J>i+-|bv;R&%(;+` z*=*vuyRCBK;N;BY`rYTIcoAs7|V>Dne6 z?r~ieo`RfYSECw9A3akR-p4U#p>M`hIj-~J){CM=;#&ss6b&ws<`1+aYm!A;veA%Z zrxmFNnH)oblgBkq7fv@ZjM{w1Z}GO{z*?*pmi`dZq_&YH-7L}qq_1E;Y#Q?`Z7Ffa z3G}XobmJp*!1nzr{2D-Bc8|)GVxbiq+{UFxDPG1cwwOU5D6J@T)5ij~ZR`$ym7xxv zy)(@w(VKVN=WeuTxVI>y9`)p2E%-;R>lglQt;s99g~1rFq#&^hyzy36)evMYMebEP zv!gC(Zg~fSJ{ju17P9kJ@uavT?q>qK3oj8Hc?37{JT6b(Va0ZlMxdU<9jZIM8rx8a ztgwgALVAixq(tQ@>c}kQhUEi9>fnyXt*QmU12xQC_&ZS3r4#A1HwTEb!K<2Yh+f`7 z4yg&ZeB*XF{OPuy#(JCTt(*==(xyvBm#tm1HuI{qY_6nz<>Xd`cEAn~IjmVxTIys+ zrst9>+%}sBL0W4N$KC>*v7o3|G;u{#h@Wb)8g$P%r#x93m4FqSec}r}EE7D)r~A8x zYFlIEHPv{|FezP>A!Z;b^)<@3i|4*lnsuYh#PH{!{VF8z?yagbJ^kQBKl08q#U%TS zlT%v%0L0pTjzdZrv-~GDirysE^)t4{)5?qc@(=K>-9N%QZIcHvdGC+;Yt3tvR86P? zuV4lRCpEGj&c`1Qg>E$$Sv3o}4?l5Rir=x&Y_#PbR7wc%jN(i&`Is^+6S4k@oX`qU38WAgD!V^l1YWD$yi<`|`LdF0d*XB8%? z5Xjlc=AmTcnv}{^oQj5726#0$I^vlbRT<;0S7^ZLQnL?QuOs4>&ZwdZr`T{Rxtlck zm~Q5($}qfCHgrNdk7xXrC7Dh3%DHG|OVjCC6{zul_R0#xR#Z(52*1XQ+k zs1+G`>rpVk$Lmu_IjCQX$;$hk3L_gTI5flcq;@#YY9lDe;hN@?)arzE2@XY9ay==} zAjzm%w{)ze?6f)}t}<8JDx*2(thmonQLrAB%_hy%5eYe7XkV1}G_0o;6JL_`6;M66v)Yg3MmZCh$2UAV^NbECEx@6}+ zg(yIygG-r}$~mNFK9tR;IXuu`Z@np&moat+I29Qr3R2*oT5C9=F>=^y&5pH`;`iO* zU2ERTZ~Xw)s}L&-#u5YJjbk74 zh%~(a0MFE4`Vg-kKN3Bd?vLMj1&V1m>31}c+q0d|za8s{xRb*6MWc(v8iXi%`F9b4 z{OhB?ipx~fFQK`#ige!CSrB^qSD##bHt?m~V2=!G(mC~qpXXmh;i5X)x)V`v1$pJ} zwX4>CetD*d{{TAc^q+^G9oID5d;4u(K?TC^K+7I+UT1&s$HLl7>L&1hj|py#_FqvuXy!Y5Q_2HKW8;BBVYt3vEs9<}sj;vJ4Z*2E2c=SBVWK?K)i(E_0x1h( zsTq!N2DvL4eTs10B4Zg-S zXe?j57ik_*X`pXpe=h)@hQ5ICCX*JKp{$`ov!KbYK=^FIFd=fF@m`grL?hE-jPdde z*M*qkeXLYd(E7}W0_j$Bsk%IG#yTmR#5asr?>5h*ULp`~fwM2F=7KT6q)NzKBx2U^p!(?a8e$4b(Q&z#`b zpH{uwX`bdEFS=JntvUe|Am9Lc;-JkD{C( zvQ*KHZ>Ka06-Gfh=Cv+KYjV4`fGXs6{B){Xgc3+Lp(Eb3bLN%IRToB^vE_a%wexRP zP_vd^wTo}yo2%(J5K({H73)di_8QmYBJzK4Rn(~W zI6W8PtRa!5K^p3x%rMVdkXaF;y$ZKmY>BV=W5{IV~dqa&-ZXV>*9@-hKr+H zX;x4mEaP&h#{hM&8UDqDJK`lA=LWq8#CnCsxqS0UoD=e%yke+9;r5pVf=RD`2{(tM zX+2r+8K?VpDbAE_9g)o1>3(1etAX^b8+{G982u}(vhevskPU5H=zNY36}3!aj?Bu2 zROhUW4I{(&PX{LE-zaSN(Wv7TxXSGXkdV5!kR}(3->oE8jQH|QvpyOd9wLs0D zxcusChX@#xSl7CO#&|d${81S5lU}STYS}g2#@;x`p{$#$;DDXgm8@&YZd-hy5zZ^k zd`NtH3o|F_=L{(lSCqj;oR~Tyb@0ic-vZ;P4(cn9mBX>52sqv(tIkKL{kgD66{Go z)t0kYygQ|a3NG~~{{WtDSSdcgGWD(9H^;H*^XE;_qqdCpao6jH zr0~+I9#?i;F%ZMco)K5`)b(p`j{YN>HBAS@E2b$se|wS#zXeTAr}%fr9}=hY^AZ8rD#=c$FkG%J;E=8{s{2m2{rA1?X%}6$&h6J6`>D`z8d&d6YLsi+h$L?#xf7) z2lTHFO(J`@FEo!Uo~-94w5_xj3q#M`}7NINS&5D-P%4kBW8u<@Fb~TyU^6D9`6y^`D2{cf%<)(^&X@VUGn)eGO!= zSUN88jo+Cv%`;3|w5nP?mWCY7d3_FMxRKwvEOKi$?!>4ihWvkjpXXg=%pOXRI3wD# zCyCieCpoVAbBtA@ta$Zl&s)mu5+fNxI5p7R@XNhbiFp}_?`poDx~^kitSY`8?#z?O7eOw3WG&y zr3kUJG2kkWb6xj`egf(`lwN(@&}s0MS1mgLKt9BZ=kz}bc<05;WwA)~r~vuePm^l* z!yJQMOkWKw^dwpQQ{&e8aKbBlg6)rOnf$R{ZG2UvM0xasWtE%3L% z+io>G(c(*?jj`Gwc$kh3@4eL5MQib<`s_8gfczdT;VqFXLS|5XKmZZixClIT;2#Q- zbdL>O>o;MQmgeL%YFKl)-|U{ZgfO%~B3e zyq=$N>MVS4tCkfM7ig=$y`Gb*ctgh;+I_3V-YJhsv~WwKOaiK&peqnXbGH)wCGa*y z-PO;C^%zGn?k^MZ(6CnauN2jF?Mqb$`$tu|yJO1-lK}n$1N5vZuA+_30_Xe0WcBsy zUC_&Bm%l46(a{{QFs07rP7Y1=lfU7y?fQ@HM{{Ekw(z!tX{6eL_vIU#9f0YKV!YGE zUl}}E;^_(0wOQo=lBklf3CBTPZ1-XFv!~LFODoIfA7`|hWB&k5<1PG!cT>UPX=>xI zc1*Fjnvvy{th)zx}uhPC5Yr4J}qVg2~0M9{#T|JM$ zcV-@Z%-@bHr`p2W9Eq-{k+ss~;1ik_E32mq6fQ~Z7QTYhehS&Z!+H)qO?1|t4bt>+ z#1WizJXbuiF{0kvEb$YQG~}Gtg6O{u^lclH3bFM(n%I{2P10jAro>Q~S*2{AywvkKd`zfdr7cl*%vHa=B;yx)BH8GjHs>aZx1d<$z6S~hKK}__*SjAho6osM$$N? zCnj=Mx<1}E@mjFySOev*svQR%K{b9Y5b?pR{h}o1Z8YpR-Tf;{JuHlTrn(q(Vk;lT zULdycaz!Q5$l=b=BSPHzaZ=`(U9xy|(xatbhQp4PcSfEaTE;7w)sZAnLb$;ds+M33 zgIT!`Qz|Vxo|R+!IQ8c>sT2o2>d3Vu^sL=L)W?q5llV!g;3Ujxah?F7#V{MlG|%u06$HQ~>eE zwKC#(p#;k^pdHN@eFn5SC-807lpl+M-^3}`l8QVZS zt6booaWl|6>5;;72W0$WAd-F{(J&0|L!o_q0U_L{KxeR^#mv}?(MA&hJ=G5A&o z!~X!NzY@tc$Jt^(z_qJs;M*HPx(js&Y$XpOvR#IIUx~AG=Va9RQ^gni=vqH4Kfnp{(1v ze6}Z9F-hhO^{U=+9qJaiW#@xSQh7_pUV55^=3MluqUf`FQ@A~Fe;TIBQMl)mJu0+; zv-gckNFJY^M92K*mCEI0FDJD%SklM^K`RlQP+V!UW^+(UClwFcvPh_8oM){jYN4qs zv1R6|&+;CkmF_Y)sI>d(;wKk6MwGVynm&lR9G?jXysm)K1tO3Riu( z$7+DwDF(A=OkrUevTC%4Dd|$Qt5xN5S;?IdR%9&1RC~Q?UO~^bK_hPISh*cZ4w2VA zYP8aVa4C{2R7L%38A$1jZiRNpsKCui&N0R+CUNOp?ngq4TaY?nnuxhh)nY@z;-GvQ z#k(C5jmXTI;~$MM_oQ=y=}@$o&ozXlbAD1vhH@5|W+$aFn6T!tayCefoH3?k`_#Tz z&T&oRqjjNs5R=U-Fe^y?X^e0yEw?nbmle8F5;0BObI7LonWfKQ<+w1(rm(=FoO6Sm zR8HjaOPv;=MFu}6+S#OtN#Gkq{IIJ95tJj%Td)IIM#=UdGQ99pZWWX{{TZ3pI5v-;Wh0wj|#U@ zF+WEi;aqX}k=(aO@2ORhFLwn179Ra;m%J9?Ht#XIuihQ&e7ivgr6F9M%yyH|XQ{70 z*L8tw5N*9ierozw{nXCZ&U?gjNd!$Hx^^p`SmX>2YvvCU{?;E4^c{BdPr21M&u;r2 zqZwV;=La?DU$w8qnEVm@KI^)ju$48N$rjGqRN+uILHgzr?YQ4ORKdCFP&o_tY0*o%yzM$1OtHG1yi{4{owl!<=$j%~+Zm;DA0r&o z6kt==tu(Z_b353Gsf7B}v6X&E{4-Lr2J2OsS~tk)Qp#+wpaM9lHq=QVVk%w3)~#%0 z$tkgeG^6J0MUY>xN&^lLKD3cfx2dJZX$(Mg0+p;%eaS?*rRA!+xkth2R$^dB z_|>LMNCHxxa6M_<9+@=jbyko9anE|*5a-b6oViuaT{lS*s4Tpi*|qTUj4~xxv%QKb zRa+#1{35!ID&x+O1w*im?!|pB8w#3NN6BNcvZs3y*=bmi0EZRPX_hREHjHD^uIXA? zxFF`by)(n;am9OZp%@<>TOTPaSf50^A|y+Qgrn4o>#aO1YpX&ew#0JdmG4z-yfg?> zynQRC(e$aJnPfsn-iJMYwG)>vi1BXcElRNXF8a|`nml5=Zwy`BSP&R60Arfo(B}J1 zx+TH_J?nG(7714|0H?4bygK-+-V;aC;WGBxt4*4kY|&p^BXCYRt?O(oc>W%h%h+j> z**MNXJ%O%{-Ljw>`Ks1#=icDkj*V(ixv>)5nK6)QQp_pN+SU*ZcEu{d2SHh?uGTvK ztefVx}gimCWH7_Qy3>`&}Vyl{f;b>USv_JDd1>S0{a} z5pY#G?Oweq9RC0j@wl9Cu$GMLZngWH)<|t5`JecYL0dX~yVzKkml94_3`acG4+&|x zwp)pp1dOQmt!eJkNfB}ij!tWirMbBy(Dtz0&I-L}tJuxabe6Zcp7!OHna<_*70LM1 zRFhkq$%$EEjPvj5T~4>E`H?hH94ibGI^wynvIZQkPrY_wSG~_O$ILD4zO6@X-iTGsE{EiitU|d^hb+F2O4Va8SAI4eN<+rjS?_hX&?%M z^HsHhP9H2gS2L;j;!AaL1VFE?DATD|Vrt@OVU^j{&vJ;JeesYEKN{!t-xui`L^3_i z{3sLeeB6riPZRjhSJW0*&G@&7@wU_kU_HSgkIKBqSn!?Ch;9r0lIr!3-InWm=j)xo z*U(|IwCw6i$ID|lg3p$m4^HuK?IGd4EoP3^Nw01c6;+Be$^3C%ORfBT@ov5k_CAZ{ zrg)lCp^$o&$^KPx+u`P~Y?zAP&rOIEyUPko{cskm-*{5l6r20Grz7|o$x-wKn))gj z96S})r^;e8N|j%{d3TC#JXhj*h}Jw#kj76Dz^twZ;DCO$%*Eh+Di;lNE#{!(lFY}S z$8~ysrEg^e6t=UC4nFW)RXFSQ-kPdEtFR zc?-#RGnVX*%DDOiilcAfc9KlUh1)nKj%(huuMUY6ZbjRQ*N?!^G7_;2p7^gaSlV>n zr+qvYBC1@w9usBYqJ$_?2e(@2bpHScGLBW)`^bKI9Ia9puPO-L(9`01L> zk{}1na6L~0^R7?DpBcO{puCYA_~xE7hl(M&f%L%dUTds;a@KXoS#%8@glfQjt<{K8 z>OcpN!o4U{nml@xB{^(+Ozi}GG^nSh3C2xwo*?*%plAiw=JgD#{{SqbJDB}3-n>6k z*EM}m7S;9Dx7a>-Y`{CdzcOO8bk7eP_wzJO4&zao`15W6b^7ce^Z3_qv$o@o5}(>< zr0ZWAC)C41qG^vZukp$iPuG$wm7f0qUDdapTUmoig!M>?E2%91ecOESeKw3>eKB2LhxD8r}(+R1V|iBBD(K!qeuE z8<$*Q&c;LeoY%45_zx98B5i@|yPV_(SYLJCIeIZ6{5zJcSHBg>-#tJ4A|DY5k;ly@$h%V^(!( z6vBlbE9cq-`G}JG7Dx=#b zu$1L3+2}(C+)q>&*02MXL_yxE-gvuDvsPx1Ha!h_OqZ8e1&d0`5Adntt9_W3$!2e< zths#7x_BO6i0?HYiA9dvWIKIGu3J#?HTBtV-Vhtos->pxF;lLVTyiM7I^%_ebuy-! zX~7J{{xzO#S=^S~+4VKku9Jos2Nh*>+>g2`bDdU6TE`yRd1m{yBIxD+0EZRU4Iq>E zYQ%b82TB~nLz0g~$MDsa(UcFnT}o-g&{V@t_NC5-a*Lvm?q;MKGXDT_y0z2F_RTzL z{B)(vgxSchlY{qvN|(P?01LOF_03uuI=%r;K9$q`myf$Zv>^IY_LfCiGnzUOQINld zShv$ubxPm+F!iJC!1Us$+BR1rWS0ktG-S>DMVN`7d22s32KUZRXtNd zv(*_q`*fLCppgTAI@d%5RG~&F=0$R%gjNa>RaXFDdQ_qSi_)zK{{RX+vcQ8&04ifFb4$ApRBP)=BYJp{mKZ zPO|xb=d18Pl`FB?orJzz3%Sau$5Ya<@BB?;Xh@CB(*FQ0ST98%^;l^sYeG+R3)0w|PVb;mVxa>OtNN4w?gQAr`FZL@AdW|U*z zs$c2$+Jt2$iCiCEw1z@W9%Tu%T8++Xe+}K~k{R^rm?%8NPHAm?Xkd_A>QFtikK-eQ zT^X2xjz6tu>N;#XiWHdxFVIuDYACr4^f!?Pibat~;HwI0a(J#j>%%viOPO@LlBy5- zw&-crUNU%O5Nc6*51+k`4MQ!Xt0&acOM}L0v{%txAb8w5`h!cn4_bF(aQf+@M#_XZ$7l=lupRhp?Uh0LHkd^_q_Il09dbaC83vm^#1s8vg(z>Ck|R zM?we6DeKb|zSZbksrfiY`QY`bsW-$*%snmBgq`QI%e4qFCO!F+YDw@dK&NU*2} zJp0#~>0T(32LSxSyL}7BiYHav2=q1ecxv?OC1!jsVT8m-eNO)XOVUaZ0^+*eFHUtJ zkzA&W;@F=V*vQ9(MrvHG9WIq=kUn41yB#}ER*ZbVrDW(=ws!>- zvV&c%gm4uF2qL^(O-UxtV*^GG&P28vU>Lxy=xqG3ILW42qACnxwPFF39`*CJYBqh| z7K9XZ7S&6x7y?CFShj#MNgP2wwO}Z(HMY;7rB1_AJ}N;UwMc+somjau(~UHc#ZPK? z;Z6q>WYW84oT&-7ibgmT$z=!$Cm5`2-E(UcjDkVOO4c!QIcnF16?R;_zgWIfMtcm` zIjCx`=;2K^S9^(srzalt)2E4sGhU54Dmt^~F<6?F^nuH263r}!B$4e+(sWaKeG<4O zN#?p=?3P}&V@|O!U8D9D-0LLa5=8XSDdtoyu`B} zE2-2c48>zcApZarWZYYq7;c8XmXqXGJ_@UIlWgOj))Zry_mfhkgttHg9@HHME2WD` z4&aFLK9#d;qmoo@%>L&V``oZ_D~thK=F2k%8HT^~HJ) znc<%$2GDv}i;2dls~<;~&E=F(% zV_z*-6Dhq9(D3*uQi5$1qlI86jMJWVUV^2akxw;~sA^WW5j?jOBrzs4B#aeD)Si^1 zd2OjxC&}8x^W6NLb*zh-LFWMKu{uo(ed+)Z~kGhuGaL3qyYsa~H|}uQBmg z$FB(ZX~>&VTb3LZwA&#cQJmM$*OuNV@zv>VLsGrfHC0D!IqbkU6~ zLnf56D@5r4HW z-gLkoS_}dF_^!(D#}=?Q1gnb4z4(9)+fRTP2Ic&|6R@yyebRoP2=8m$kA<#KY&TkBXl6}OW# zSo%+8bJbn#8MC=_>+4cSb#8-(73OyO#M2RyaZqYn^TLN~w;lcJRvJ%v74_8hQ9UkA zX5BYs!u?Hiy0?ogEoE)j3LDthlIq?fyOBwd`Yo{b08zsn&$N%i_pW7YK6%399N&oXl58}u=l4$r-8s;JuzFTK^-u{)Tw79 zTT%F*s_F!!Ke`^Gvs+!%q~zR4N$;AHJvbB72iR5XeK25g(xOdAW7wsGQQuQ0Gj(urIIQszQ4QpIuib5C0@Z_>2H0CuSsFKUZ{mbRmJBA@nPSEXw$xM9saT8wm{db1gA zeT7N(Q(Eh5N7=opHjv%Xn6~vIzFsxKYVyLQg28&=ka6qCHH(_+ zTBjzJwJ-&-pxYeu{VP;kjiB@=JX2aQ4GLG(PAtqe=N&0N%tc>*2TEZ%?M~$_5kTR3 zkO!%zzCjt|*qj=}_>bZ@@OOpo^(f(Mi6URRqc!m7ihpT88S2`fnQHUdp!q~max?TI zqNP~DYI+!46---dpF{KpILw0}8bJy4uK@j*ykX++7Qs8~UusK8SZrybBYz&J74$h` zwW4a*GOI>(X6ehJ35a@A8ZnNQYppSu5AdvsNtL#d+Lc*B!Ob@JP=dubOCqnaJl8pW z<2!QZJ4mFuko5A$0;=trD7JNc1pH12UEK?1VfPSVkJh@I zdswYdBuWuSU@A~>LCz_rhNIlW(|jRgXM-evXq0tnK4V)5Qs3oN3P0K7{41RJjjrmR zAMnN1_K{>S@1w@pTR0;)Jv}Ry_)X)zf5v9@b-g5M#vFNUgNM)Jb5Qj((3I@F}r;F4v(G>-Jgo1V2*u2c;3OcQSH`qf;nR9Z6o)H2V5)S8UR zp2nh)z&w*h!t)qUfqG>ky?{OMe-OP0LQdYWvK06LnaZZL70LpUSv zezco3RLe#{MhVEOdXATGs7119(S|sq&K|WCmwl;Rr309~@a^u8DVt82nHQ{N=AUKb zVGr)L1TebwY*zDKoN-zDhMQ}t0<%J=Jx(dz8paOg$Th2b#mq7Z2emlPO6R8?>x#MX z&E}OMmrb3D=RRAU)V5wTXrx(xp$)~X1Dj64OtfbA9P-U2LO)L1)iHP>0mkY%2#qntW0E%Z< z{{XK!zw`r1>{{IU#*w;p$JUm%d=+CBr{g^W&U6w%aj09xBA?<$kyMY$xa942b;WZ# zA3%H_{h6#EOSjehSrc5`PNG=!(B%>}>I|ECVn;aOSF}O!qrtJpss8{7j)=-Ty^L;u zP-|M&;kR%Kk<*T!O4`#5QUmA8Pq6{{Y0-r0Dt{oqeg+cl1sSGPwq$*>;FS1t4_ zbordLhu({c=r(O}0ow#-L)!w8Wz;Uzg-|V+&euoPBpZ73U7m~L38Nqaaa<0RKJ9fHZ_0bu)ls~XJ`)*E z9L=7oqj-vUc*uMoT-T%MUNVvp5vDoxuNBgyz>Jf$S6OeZSXnOVZy{U`?lYgxw5v@? zY<$e8DssF}deMAj&J|ehQ(cvgu(t>qPac)vz7p|Xm8iO`H_)>VFkc-0HR^ga!IgHL zlhYml0Q%MQSe!CS=hR>^)fKss=ytbDAjv(mS5iUNx&1m{lzl5&7U}O_JyM@5vOfO+ z4PDABn9x@M3UE27V`6cdnQM+p$F~=BEhc!Sqoyhg+{o2rmOxT5^{#))MUj?9!ybCp z-oJJ&S9Wi}2hCoDC>Q_}>r|n#k{Jjw zaqF7Ug5AOCiX|o7RFto7QI8QlK&Hn5Ru!Z6emNPW7W;W3x!q2Och%kQLLy>^-mAP= zW06{^8QIdHc(P9vDWc^?@|lj9^`T?Wt!RsU^%V06L7Y~;(HMPQnQd$4ec?!!@xzhO ziqY@)MQ7hhw2{Utl$&}ORHUh=CTCyFMn*WUKU$n&y65X#dad%?f=K{Z1+DmkFj#Kp zy{s(;+IBuGHO4|QX|jf|Gf1UL3Oz+}+NAJCNGv38N^YmE&2sLea2f0?ItwW$kh5fa z*VoacptWb^SiD!YouZ14&v164?O$q|-$at)LAxY+S4XAj4Lf8hBfWK6cZWKBqN;ef z({{Nye7<$;+Bs_v4F<=^MRZnP9l-=CHPl(?n@AgLdfQ1z)Di;Y*1T%i-T60bB!l0K@wXr2j<%eJmbK0kh?&Xo8w16v)?soqGF^|@~kKR_5 z+4Skr!N$v9L!!N&D|IZ=A}H#_;8!8y4~V*Ej}(N@=1jd2RGj`e$Lm>_8Xl{z$W7hM zp;+h3BVzvmGfj)(&6FtKe2f1802@!{E4Gz7F@L;wJe)=^6H`hsx0^T*5d2~B4yf%0 zk*AAD+obahu>SzQcYiAL%}c;uF!6QapHjQHmnt`4?TP+{3X%AOUqZf-qv;9=i35X; z#~H3~TJg1(o4ahN2=^84(5kO$6z%GKel9bQy@;*%9#^M+&ep9NTg!!tE}}jA*dJm> ze_HLdpM`!H(P9Ef*664QbgWKz^v!Zw@5P~T#ba@_A6~V|>K_uWBnJ{4W7fTYt72*^ zws=n;hl8{eey6q1tLVBdxJaHARXEzbWAdyWQ{qjPs}+i5UO6JXBKyWtT;#l&2b_w< zn_M!IOout{Ygk}c+jlvqimy}Pjgjk`$Hhq{W-;{bgItC0jV~_AFFEVj*9$hJPZ>D& zs6VucJxzB;jW~QLA%~I0M>d2sK^>q6rCE*AX2Lkd8INA{0euK^cOCMex;>b>TtAI5UjB`Rbh}uJJ%oo019Wq3n-KO3ej8~ zDyzH+g8$apqqOv2Rb(taO`ohR)tQaHT;Dj8QM-4^$&F40J$jd~QqAv- z?H~m$^E>(=tj4<6HMasde#IB}n2i4bTIlSw8!bE!CIt*Xi1KR0m_;0M_)@y)Xp=dc ze+9&3^DizQ>HPz7Tb7z#t%710g8So|oIZUir*pe;;YFv z6q*9p2a{zguwk6lStid~jhmdQrkMFl%^=4;KczLZn){wpj2AcBZR1Cz9w61CF*p=>T^_G zww%;&amb?O`#Tr6ob{w>L8>C-G}fAnnyPn2mXHdr@_gSZrW3eSMC}%b(twLXliJXJS6)IOM6x%GkfDS2}h&if_F#?_E5_ze)r?ayy7X+R~P}FqWT}CEJ z;|vJu2S1%XqyY8(YAG&p-ls-%K;`cIJ$t1;nWkK`Mh_9QQd;=e?D7kJLI#0{@e##z zUTGwnr+ufvewdmiKZQ#=Ic~|})Zn~b(8#hcs5GuwuzKdW%ij)N>2evg`O7Fd^2w*! z_{r^db1+zMR9dm+v3P)bP0G~q2p zEP}IhHTHHuTZ&;P+M{`WD!GxpY8=gDPeR0!0D9CCNsfl1cFE+@G2QQ3xg%9fMv<6$ zRBolZ)TgCJT%IvmPDvW7Y;VlHsfD{#j7T*Ljx$)foeCo3o@jR;EkIqDr7`CmR5>PS zquiQ88mm7Pk2ZQ#3|WSAS+-Xwk~Y@LpTeRt7a27nk0Y8;pbS=Rqg04QgSnt=qFDZ3 z1wqK|Sjn0#2}1X%gTv5~5G8N8_Go8}Dl z%_HUeQ#_Eo=QWke9$cQB)O!mxJPova=8J`dDin@7QWYJzrD8KlK~}LFg1+?4`0K?? z)f0eo?Lfp=40x=M7XJXqwX6RC*PP$_0j&oZUbw7(6gmF@glkvN{Q1m3@xxHcNcrNr ze}&tJ#NP-M0i0_VSO=j#a(|6;71aDG$NVin2Omvp%l`mJrE<;LHg!W;BlMR>)Pz?t zo4pzpXD_}xP>1hD8>oPHU_d~k%(vUGDr94ChIS>(AZewz+k(30lARcPg!BGovtVrUm zM3*AMpkdOKCf{moa%HyEgdFqDXrgB10DDxa5C^3-PqjT<`c{#J%=xSipOsp(O{zHW zP!Lxfh7)Gi|80%b3lx$@I4;k%Od}ZQR@ZW`QuGLAA zBf$1G^i*A-e2ykJmoIaj_^0u_=#MU$rZEyAD((+MULP-vHC7qOBGV`lTtifdQ?|DdLOr1WMSb}1Q15xeX&~brz1RKyw~9miKNv&8~A?i;k?=8w2ZMG z&MVNZE)3k@kF9)WA`TO2pId^j3XqJKRB1=rre2w-5rD@vBywa|nhbvnj954o;STh}4CS7wlLo&l^+7Wl=jq;-gHJF$${o$432dYkQKD1;o~aG9?*b)21Nd{1Of7F@q@Yz*>;@{G_OcU_+8jwk}_*DPc}~U#DTXAn$lI0-1n(v((GB2Q!F;~ih@h2F~Bu?3mkw-@Op7t z5owB71#)YZ;uZ8gXl0V@WLZvbvbp3M)`G=>%V7N~wa12}R%z{+qk)~c`_%n2T13ou zK*rhbD>-5`I-!@O{p4!FYB=JxZ6Vl3IHp=`l|vy6r;(21+O#2$aTpji!t1lY>QLxw z>BUuYaawD9KmZU0VnRxhkEL1!`qp0B;&>&{)c(+XW3?c;E_oG66p@_Ns-;b0=9#Q; zg~a)XG}9QUV?^p~~k+u&|NEj-sqb5MjWi0B4$-HUl{46&FQvv#7L`EfsHs! zAH#Zu?VY<^U0cFlIFqXp*jJT&2>9!(d_~u<^u1F|Hoj7qNp_VP2OCK2e;StgUZagU zN-jxgk9kDSIOEoy5L9ta4hLaVd7jlTQ=f5x$T;m*p=|f6uOjosB1%;8NLDmY0-NU5 zWVkHfF~_x7R20#6xVhcU6qv^Xf^kfn1}Zb0^{4L?g*)brMw03*KxdOqVm8!He4&zY zS=XBNothIZ!Y{UH)x8CxGgcxHuGd;vA21j`}pFj4L@4fBr;u2 zZQ}mo^{b36xq5UuEl0$b8gM>j${2P&wa?x7ruS6BX_kd^{{Sr?rD$3BM$*))$V~13 z08KgOwo*9aK&XL7pr*!-J{=2H)FcgmaH^U6sl{#B_;$v~??}VR{paN7v>)ck?NF1j z)^0|zjfqIv&;e1rhdpXHnB%QR(gWU>=BW~w%|{^&Ii?o?XEhYFfI8E5G)V(s5_8g` zH${4!R81z}dFxRa!*#6snpu`w?;@aSi~Ce;p+^-Mm=32EcUB)Q$-4EX8ehF4YZRo;4^Q$-e9S$XSC zl3|Lc=A6>`bIsHR9jqT^6uxGgoN$69MJsNI*pH9W@31j12{ zw8JUSTCUQ8(xNhgPc*rW6gn3Sqdls;w+EVm<_q6HN{F`daY@M{)Ct6(j!i_<5m7Pi znlYN5bZN*mTroV;_lECKx`KTwH{3cJj$)$*S#|@VrkQh9eDU1kkW3ih8qLV*P$g&U zRB!Dq?@+ABt&!KgO2nM*H47Duy=w;}rZG8tjRyBjk*3pTd;`Q}(%JaQ;FLwF!{oUB zF2=euKh40!Qohq*zfH3se?mabV<&S|ix6JNc!Ee|4!G=Sl(t7&t7eNL`8oKMBm;5vF5SS?Cy@35srAM*x*!F_mEyL_@oZL)UN6Q!)Av#iBo7B z8K?^s6)AATH4fl?={YMFq9~gjP+SAnqmTeIRm&e*!OG^`wk*5noYYS!+IgrT-71uW2H5^Q$zaFvt(3{L98%pGH@yE+|)&>Tm<@>Lna1uO?LFA zGM3;DJ!uw@^Cuh})J*L81HB_5L&r3tPzOo^!YYz}YRd6qf#I!RkNI<${{Z8LwhV)} zHIL%9&xW;s`tzIr06;XD(es^mKLPEQ^W%@hs2pxdd`G6E4^VDn&-1Pty$kku{{WML z_+|e9V@8?(0HsmaHBW*^ML&rju=D&i)0}Z=gXvTy_*JLB{JRnKHR@hkdQ$mm>0b=v zsr4Sw*&anN!aHXBiz0uz)mZ!@8RA#`&-hoe`Dy7(u|LVGYFli*1(bGrARZ{nGQ$F+#0frTSf*mnz+v>sl{4`B2;=BX^69Rtvx;@ z$TASCT@>lv>T%VrWNGNOIbcpJV_xu_9whMf<%G-rwllPY-motGS*7@X6C~FPp+_n@ z*O_>;_Kv)_5~iHZ0*r+_LC@!2lc@?(3y0edk@Qgnppl*XBd~Pt-g@Et68z z<#-@ni3yODQS1q=rSXN7maI^#I0KV{PxY*&gQZfdl-2HW(yNG}OP89~KSL}(Y|SrI zxQ!x=AY+U$HRyUz##64t9JVq>QM<1-`GeuB-Alwbu|sJW-XLyOJRhj9yM7IPH`bp@ zvw$gQZKJO2>;F(;YlVv7 zC6RHC$Gt`2{{ROrl^km{o^+YX401DFd_iKyFh2_T+)Ze~a?{xT6EDF|ZB)c=2#XdK zc*mtnxP59iY@CW}usnA)<-P9DsEe}Yfhm(7^~-!j@qAtt(Ozm2|J)P!D1BGf0bG~~$_Pnh=GTRP8)?R4ZSj*`w!6%>Qbe23xhh?@1RLfJ1}ZNLhlalrKH zUX9={6G-}$Npj*=nen-Xa4}ujoVCJEP3U>JJl~#-X+<>8chYrPt|AR~7CrqdVdjn) z%Q51(dsn!7kKY62sLp#Dn^e`LynV6p5aW*Z<$YT3L)?x#*-~y=$kWukP^r0KQ!8Vj zu1iqW^$U{0M&2=s(~ixRgEtu%!8K`Owu~LbGgoybPLtZ~@G%uDVwb%uBZ8a5_czi9 zkupj3tJb~|i~wZ9oN@`Sp5pE}fD!G;_N-gk=9P|Lk81B#>Q75p`Pty`^j|yonJQUn z_Dhy+3M1nt54jDNYHD!reB-CrArVpV>zf2 zUI3x>1cN^?thrt6k=CaetF~yXDtD$_YDp5D0mXB7z9RDTj^34neXq$VI}0DDHMDT9 zPg9PzWeRckbvn!KeHJ5z&lSk(J|$43WM;X`?PhV1SdVUN3i4Z-*tl0(r+YouSH2j=%bhO+UGlc zYSS|iPc@wOKQ}#Vsgl?g;~5oBOH?DL%~($nbz|wU`D`iKq+-WrTYxHL)5jUB!UBhi z7B(il+LaG}7;AJROLavU?9Mtj0j+F|lV{~qEQJe1$N`gWrvQ7?qf$VFi(zTmyQAG?SJkUov803sP zk;o#sO-I9*T6EuO(@9WHN|Rdi-59Dmo!Sf$#(3>h#z8!q<9t8k6u6Mv-QpRPVD96Z z>XzYMep8-0iib3Y>GU*GKn4M+Baj}ZvyhY0twk9Iv2q0q7FcBQPmP}*l#y~OWyU&F zQWJqJM=UaG#7F@oRYpPxAY&CYGl1Jk!KzH=lne0@kyhh84{D;U2^`|Atg53FXlCJJ z+L4Md-7*7mVOBb>2~0+yu1N_pUV)WJ6N zHBI+3aqL<~Vrp!+sj6!}(al(j**EGgi|Erm75& z9uM%EG$qEy$#i_MKZvFC*?OAJ)9nqMYZ!9icQs<_?i+L_Srq{8O;WKg>tjJB+`W1C zq2&rbV}V?Dr{ilYG3CO>X*&fO=CY2nt!qRoMlIqegVT>%b{{J{%dJx0*#k=|$DW&x zD-!3&mgq`a#Md%17w?W~H>o_Dl&$QcvQQ#Gqu5pCm@vZ%ufC&la!5GE zP0cwAj{Py(m|u>yOYJN=(i?)uIRd4zs%Ws}j;5k^$*3i`+;NB1Cj{b$!Ctjqd}o8trAG6z3?6DOYM@yM6rN!8s=|HU4KT6K z-Zd?oa%fnQvTC_p)J;FVL~b$OqUN!H={F-{kWM?)4|2?YQBXRU9Fi%gV`UQDvy56!)le?rfl3X$Yv^PQKMtT{NwLcAt7sn@J_f zrOcVHAm^0kqXe-z<1`{Pcf`uPa#}n)pn4E z`Du;1Us`51ahl1@*+h`cd(yK4Gr_1N`qXYp^vy$&N+kICsYwU8s-oZ?0p^#^`cz6r ziZ&)J3G33RYBn~vpsbMsJw-@co((mydY&s8YH1ssz0QYsrpRQlm4Xh`+iw&YoI|K7 ziT?n-+PZ@4gU4FU)HK*{lM+U(J#o!q(Y=ch-@!Cs$11QshLzmoisP>QId!JX=UB|x zAHYu)J)erqL`8WYmcZk26_b^XRW)m!K3Rt(Q%yky>^CS3Y@`v_H9m4aqJ=w&#i0lt zb5Xl|yi=rH3XYp&LpQYbPW1wT2bQj;phcZrl|Ii@em+O0M*$f#Hh=Az>h6ncA9Zj26dO%rWDHA7~}3g39r z8ViC>DmQUbQ04-mcEI(Z$)1#jK@}GQCY^_D(y87l%%C2brr@aJfuuO5D8K{IQ+HtV zMsq zX)z<`iu51Z;Qs)|KzuU)0I{P?{{Ygs>(8sue`j%@+6Uo?_3=iT{{W?N*ELUpoiO;3 z`<5Ft?3!?l3SFYUF#0q_wvDwWXxM!yEc}{80eVx>0b@xTk~pUYARl`D1ta2{(?PDA z4(0r+Xxu+jO%9-8sh(L@3_mK?##b&X15X4l0W}OxTNuaXQu!peBD6?ORu?&^rg74o zuz-DPb&nmZRE%7Rl0D~|ak1xcI5lBZRI0pIi4f#L3i1Iw)sVa%F;Km^URSM644F`^ z!*>;|V{Oc2^d1-5o`RHP9`!0EbSRwM))XksNDvGhb4^t}YEd6Rc&*}$T(v8-S!{|_ zcUHk)#;(T}a6M?A6-b@Pz(7ep)wE(`DUmaXA!#HbRe2* zKN7wrCB$b>)8u`tknhu+*OR^5$i%phy()zXc(@$Ow>{)8=V4Ypd%lZeH3u& z2>kmMnpK*9%~<*W07m_VuWn%u`l_Q5(W4xGOE}lkDvc_Pm*IEp<>5aCM*)J!NhSefBX8&N z&3nW#+v(6MLWvpxKIp}99v7Y2H@5(Bn$x@0nVV{!1$?F{)vY^8wtema;qbK8NJU+p zN?d~3&oyaWHaq6IeKz&B?R@?ssQA<3hK1k@KeTE$v8!N}iC7kFdwx~tIun$+Vy@3} z80t`isX194%Sy3E%;W$DbDHu$h~FQyuL(e6u$3<_8z3q;!sDqXyaVC~?Mtb6p6Y9D zKm8KK?m)_NTzlrcraP%Mi?7}8k=ckK43nH^^)>XEJe6|$TtoREBbf0Vsy}6eT6g~d zBhb8A@i$)amE%cucI_GI5GQEJ>?qJYF{^l$V`!mi!sCkc{{V$Qv-H}{f?sNZqK)uX z8AxjSH^aUM(!4cogE+H#>d^^vYI%3Q@NK8jpbPgUGssD zt#SIVonX#9vJ@PSE75-nj$24)Ts&&o^%<^8b=)7kbYgH3ti8u(j}O*7O>npY*mLPw ztK)Wrgpr$y_W8Ukrp|tQSkFJ*rZ0tb*v}S%y_jR@sN0bF3b_U@a&1+zJbDino2JA_ zOA5aY{msJUKbebIHWu>cKRQBI5O8lOb8L>L`m`vPK5x&_8TrZ}O|x`b6c} zELlFl*Ey)@7fK5_AZM^9y;wL_wb}DkVOor?k+|M6&?EU}(xPBGw^La+e-!jc1ErRe z8o20oS1Eg>sbiLIdW_aB)sn-Ksgdnnk-}1XxA=);E85()`5LWgoF1H14G||j>hIZ4r9bSW8RoeqQ&ZQ4 zH62X<04VxW2u?Fs%orUhMZe3^xty-fjf&Bz2c=m>6u)P{q}o@5Q+;eQRZ)t1@!V4t zfEe%TPgKuAN$6JNiOA{CtqHnJf;!cNh!Il95X~-QD{6CE&x7FARh@GjxX5J~IjlP! zQuD%-t+n!*BK{c&;=K^2I(lNOUTM+aKw_Cgq4gA-xuug0D^R!8BM%#g2h*)-tLLXm z^UL22-)IwkqfL>FdC5;&+|c}61^(D(K3&K=N|BCe=tQH~>tk{%bYjM-q^pj#BF~Iq z^`SJ8lZ!@z@%O2&tDKBxs)jNSPAXWqJo8O6Hi=etQ;xN1CEt$5p;lk+=8%G~ka()S zjGS&=OpdiOHbqbuAbskZD}Z`*rlgEqTF|;=Y-2oA;*j?>Ovo{l#U6UbGRJ83CaJX1 z9E{M;<3{rXbj@Mi_@2hV5hUO@pyse{Jb80`{#}K$zxzdSDhl>zaW(S8_na;|zZRXxwm=ttXIIX=e!**JEUoixy)wCrkBHGwdJ3IpKDeWsuzNN`WuRGDpj3%TAH>|8 z){E`H04@N);;Q+Bzcij``crINmL@J&rU3M%j^GoF^H3QV)}dwxzdp3CQi-A`=aWcg zJ!>>iv~o%OXlt#*Xg`f0R-D&MIk;PP*!w$G7y+S1^-!xqs`!e%NF;V%ISM53A@l9#=_pIe3MNF1S zK|L~RBH_B#X)cC%KdnY*C%!8)bpi4=)4|0+*Mts5G5%wm;*qd;G#c{Udbxfs8$CIj1~;o((|qjhvj+ zxmeXf0k|WmqwPVD0OQuHNiaMPO;8ttw%AcfWK(ZdP68R z1Fsne0-|ZKIW-){oTY;(&UzYQmo-$}4_wj~ENXd;6iFnToOY;OmN{Ou@S#sM7Y4F& zG*KWS!TGvUi9q9ztt^=*+MsBGZnYf7iY1NvJt>UYIW+-tgXTG=`RY$v$C%TQoz6#E zjojjx(+^sTOOykxPceL!2ITNLsNISlX`+4S<^1WUEVfG%Qsr|+9R)M`14M35T1~6j zK&EoEu0B#yX{^{j^%_(TeI>hV?E} zayVh?4LHexklC(E=S95J&iffiPwtb&OJm|Q6NvAlXxx7mIH~r9@+5RmGZVlxV|eD7 z7333u(YSRz!KpV4oO4yoiE(`h-US`0n@S80l{2_C9_r1oQSKtilZ;}NxE<*qZYl-@ zoQ{;Q+`A7hNTfXUsEojNpw3&T6q_A~G>QSqs2qZL6cuJ9(uCXBH3ish<XnoC(?p3O)E_gfykuBK7uJX4_Y^S8d{AFbOh9Ik&-y69zYK~RdM<{{Z5d)%pJb zmr1|$149zr`PRJ$_ICdOlY;nR{{Uk~m;V5zao3+$p#IM1AG8m{9P$4E6-Jl;0Htx) zJx_t1Q23Giqu+`~V1p+EjPLtODA_a@y`-$qdCA=*eO(@*Ya^Uo#m5A18O>NX7R+)lA;0l9h%e zIW;tK@&Od1tvyf8T`DF#$&oS`SP&}2Z@F{d(w@o~4D(QIF*fY{wRW+bq*@-%6$U{Z9MsTAppbgi zDIrN48g`a1PBB}Wb*=Y?BsX@F`7AixJu4SV)za2PEI=N$e_YpATV{?X+zA~i(wvm^ z6-Y)@{o~Ly?;7}?^3(o5w!}vYT8Vs7;)t`h{qgMPyqeot)GVVQ88M8EgMcdizl^Se z{O&mGo@>#SN~!fcZ>!-Y_EGKr9{r)bRpIDX8EzhLERVmEz zwa<+N4kMCCn2ZKHc>QaN)_iq+;)|1etKK~KEOW~o4`4kySC$Q9Skxg2Yo*W6b&ad7 z@E5`V03P__(Dpb9YKy3#>E_U9=ug=+2{6S(|#I2nwF}If2*7w@(iqJ zJ%O*KE$+ggRlvqL731calah33ZQS%SY^oEiu{fPOBSH<4AXOVwb`@hes*y?@0!2&a z3iz3(+4_tpW$x@tN(O2%C_VE|Sw?!)rDIhzEk2vPQtqI;fjFdMxa&ymP_xiyDceBY zJ!&}^bCNlz=9lF-HJ3fn8Rohn2GQnK#3=MM2~FKaReOkIW4b^wSl3sKLHW8@J%4)b zyXX2>eLOAE;Md2hdZcw1y2Ydf=48j#x&2GVdUdWazIK1PT$ZJN+=9ZnyQ|=HlhVD4 z7zHia@;JQerQa$#-BaRSx{@NY82x6e9wP8Wz%f{t{p$JBYrUUZsM?&6oU7uB zm>z$$*P-2p%&JiMV4tc!tB+UkOrwJj*P2V4UjoY+GK+R!t$dv)h^`cn9OKxUtv`q- zWFvcX?_83~+E-N>?L#}8_^C(kJ@>`_Dew=5iCA>V?&M*RBl*9re9hyHLd#p$?ryB? zQpzW9l@~aryu4|cv$-eMvnG$sEPs`E;pi$WM{^pOhh6hoFGDNOm-^y48xjhTl;3MgM&)HN}W-yu2J#| ze8FkdIKz?5I?cn{>Qi?3#QsSzO<-NYOCLW!{ zIm>dm1Rq-F_1_4lqzv}pLvRK|uLm{Ks*~2F5t1`S$~^l|@fH67hp$DZryCfLNjmJX%nFe-ieReeWFgHg9DG-2zCyVA993`}lpou+_t(T`6`hjdzxG3jc0jQ&+5 zZ;Ir!4;WkO=oGh?C?7H&2(FoA06d+ASCfw)?@y&q+3!oGr5IoyxkEiSb$$uxpI zGlUDCV2 zG4Sl<_?r|6#wfg-W`+Bd<5_CJ<&o~?-z}ZH(EH8Q@!c=^zJ#6a)5Oc9%0Q-!0Rlh| z)^Tc~C~JFJb@XzY;;hlGT*#_I-ED9!bP6k`I-~8ZX0iN*Ct&qjEg>}*p$RO30Bjoz zLSO{d!^bI&7jyE8U3j{4CuMs*>IJs-5zKEd6CpID+^=4L15_dOY9`Y0oW!P$KOOWz zyg0YZHb41w+yY`i&z>Aa$cfFcZ@-M=iU_y{WYh(XqUDpfXWmT}Bn0`*yoHBi^`Sbt{zvtak9{o<3m zu~o9n>*(s=#E~dRE(#k9 ze%7hDPA){Tc>`+-d5)k*p*jjgvBfOmbyfPAZ028bW^vpW{*cC}&ZLY8SOL|S5|+f1 z*CG-ITHYct!p7da`D}=}0$&-LF+~M?4$(3&06vkRWQNesyzV1FTm!7BckA6}3ZqO6 z##aHkJd7}dSQK2R)#9Oveh#?P!o3ckfSl_G{2&A6YG&X4(F2(kkq1l(ueifr-xXS8 zh^%*Lw_8Nlv51RHf5?*MI9#Wp?m(9-Duo-u=puVyXQC zM^G6si&gEklua^1L3> z!;2c0i%jo653QCHy?2cZ?{veWgbulB6%)JR48yy^E0X0}l8Ww$&{uxezA4}ord9UB z#&g+AiHbq=t7}$@4?i6C*A#K*a)ks$8MxwAzTvv_W3O8y{pA6?d&q9{vFuLDO`*Vx zsmdw)1Jy$+WQiZBjK&%~w2x@55q~R&|6vu%(Cf= zM9w$aC%gL<{>k9+>5+0uu1Aqu3*q~}2OIpH9+J()cj00vNf?p|1r5`wj{w*uu-Rns zyy}x@q?;Z8E2i=ByvNjas;*uC|4OjfWoN|4ef$Up?JUO$Y5Q>d+y8KWVf1WcKVQ`n zd|%qCk*?lb*Qz6k{3jY9F>{(>+msr0b(C&8Nxm!0ae-e4hx|5&^EB;tZuQUzqE+qf74`r;@?ejd)CzQ}ICi1;BE9|AS}Np&0grMMj$v{k9hA_RXvD zPf|}C?pUXCQBk(A8xCwBt?hj`4rn$m6e*B^0 zoH+9Bk#cFJY2b72-+x&7CV=0iEW!I8{SxX^boX{IY0|&fTHykYk04q-lZ;DIbW6!a-F=X6i67-F3RaU0Edj0v_PgttZMz%&#>@K@m5f^D7xKbk#_R=V|CVDPw_Qy^e0@*V| zzaBr;R$4Oi3ywg2wX>aFs&!ibL;dzD2z5N z?*d_Veec;_@f(pz+t;3yc|YHvc5ad*WB^mLUH*C;0yl1GU=%O`gWiUSmQOohRVN)Q z=+BM?ofHxWmWLtF=0i;{mm}C_Y2M0=iJ`8|jt{=n3#=_(XajLg>qK`Ow^<}yHY-LD zjAtpyXD!n-s!lro^w)ht$Ae)BirQ}nCG8zcRbNt^p6(6okKr=qpqb-GNbnsJXO1}=ygTYC;8qqx71cc*l;R?E)L$6 zueFRjZ?z^vv7IXYue|&+pSS^shwr{mm^KKz0MkKnsXGeG%WIuVeho(=4}COj)bxlg z63yO^BY_M`a=dS|tdGY;i-ZG!cz|Qlb2j%YEt@Z|?z~oUv436T zYky|xTs61IKiHESslay?wL5gox@VBHz>vy;VtY#)CfrIx_k((KTRxANs^qyvydT5~p zr~P{zQh@_CQt??2#DFB!dk$%ct(qu)7n+zh!v~$|Qa%0}z`Su6U|6$s5U*C-zIeA{ z!-V-gY57$;BhFGkxh*}#H?I9*QULVtpqQhvzvxKbBq zhi(H&x)}B^f0H(4^WjJXjZiwu6fI%vn!98sK5p&Nb`>do?5A9pG|hs`&Tx&@9TXX3 zGEU-Gho8+B=zyMW-nWAs)!gO)kIKC+3EI!qYF`CEioM$<&BBOel0GWMjFGXI1{QwP zf=cvr#Df=Mw6E5F=?SUXY*7-uSeY0@%u^(_#Y`WAte1qcp%SanLTHC1)J(LzJs>kQ zK1E@Xo_?Cqa%H!{HhvmQG~JHB^t>wD!{S;4pY47Sn{ij`yfz!sRpk@^*CaL3q_s?? z*@biUP~M(l3u1s#r6NCn5ed)((Q=YON9}0<&*D}O(ysT8bS11ol=OelrBt22Ur(!r z?*R=@7*+N}ubI@S)`z7G2!1xHr^C{%eTy6RfTX{#i_-;8j9X~k$>MR*)#duPfL{aH z&swy!3iCZ_$^XQ$gd++-t%t}`niy05f$OJ<34p|*AlhcCu>Wv!ihvh)Ecwc2_P6EQ zK;Q0xx%{0{7C}M3$wn)*k$n|SWoCSR)Z%lS_NF(DfG;5VOSpPk6Zwdq>G@o~$eR`dmbB$q2=cRFk`wPY9D1V9Q9(0p z(Dipy4eoZIpF7z|WMiRq<)lIw$zTFxgaQqAHTY7nMLaRvf5v&fO133+?bGXhzd7gR z;m^j{S7|z_necESj6OZu5Kt>HxUFyh#5D8{g2t+BQ4!Y0bqgfBE74#;KYV~j4hkI&dY8fk}CV^CE>aN0LazlTor-~ZRIb%}9doRrADJN!mFoJZ2%#)^4D^rRr z4%OCKBK_T|bFuvQh9Kb>&l2H#t_;`Tbn)1tS(4Hw&%&sNbkwXQz6aa7UB35FX+fKM z`0)QUQf9owzue#6gz^LF@{VCTnULnIT!(6Wu}kn{E~CdVVL9P4(e_ZJeY@KfTICA<;@ZiUSQhAR{ zKei>Zpd6_@J}l*cjUS9AsvD1-S(didr3$!Bb9i2t3-r+59c|(X-J17{p>pi`vxoR- zz;cL-M6;$4nA@5!pALHjnlyG)eo@2hIHDfgAL z?&+Nbw3pDC>$~k{mHf1N+(DP*@035IP6~`R(HqtuKCxR!bC0%VuPHB;KiY@SHCoJ$ z>g2n);DbPANQ0K+VKnw|JO0P=>)0>3%wgWCT(FSQ@l}r$OTv$><5w+^#f&1aI}TL4 zcQvAqk>*vL?=MSx2>K7|J=yrmw1HTukqRys9^}W((FFR8C0*r- z?}MXJML{-fj=gMRtA>&&q-Uxm#+%WxUSMJ?aVFk(AG$#NbO}0`ovAn244kA+8}@o_ zlr0VW?(`{%meRYLgatoDhHO>3$Ww`fx7gI^0-sy{)s9wlh-m34THoVhV8rBNWiRPi zBOcqj+0sBW(qbC{+yRXMx|S=6>G-Tq)P)`2u_M9>n>!&akF|G94k$;* z)LRpFlts42DSI|zN!K*lKH0s<3SH}bH?yXxcBB4zp)o-+1f zy@GKb${qc6UDBd0{Ppwb=+H&Mvr8l8XF}wxUruQpuS*gMtIuSGVgp23{?E_#vw! zaDF={^h8r!=**lRt2Gy;9}NgY@`AI1ZmwaAvd+~@p8z;!3Sw!rPCI#Am!x4+*(hSs z%(v=iXr8ymlOHB(dxT!+bqR8^YwaL4XFDCA2acG$iiuwg&*7bJ*}qB6XTB81mxN9o zeWG>_`oHrKcF!Nzj(-DYqYK(>(C1Rc%WYC$?v+zlKOdpBT3x9XwAnmM`CJxVsGs8y zQ2yBW8${j+_IKd>8LaDv#mp_){--XR<9SM4F z)&d+O?u{d4^vW+tR7-zGy-ScdNAKMPov+espH8>rN&f4npQ&4;imfr(^$b)VvP}F| zq-k3gAtR{iR4QDJ_zOoguhf;UJU3mL)14LNHvR6GXxuhx4?QuX-2Dcu>K?-LpU+c@ z6_lX2dpjNfy^L>ciJKdgNiwQ}<}kXV zEa?0%54V3-@4bG-4{u>H#^iW7U(Z|zMJ&xB+tep?0b8RskHw`PTYRkFB&)+)Li@yN zppx&`-Q*p%E6NWBD(;R>vPvz<7(Fw;pM^^AZiK3rGRI@v7qoZuljfb** z3Xcey)WtWic!rxL^fajT*Mqa6(II&m$_Ba%rbUc~BL=HD#p_lLPKS!@-v+orCGRB5 z`zJM0r100sie!rh$Na(G2Pz1fyJp5FlCdI3hw#E=Lxu*`Ml<7gPFH`XlY{ZHvzsvH zw?D{f-m*XoC5fVuW4Q0tYk9mo=u2l*Op(KluI*y4k*GvRvPb}uCtZxTi8w9Kr zxa}@nlL4cSASP?t_8jRHv2^P(gMBK4udi;{YdvCLEWzA%xs)CPr{|@TE2(S8Vbkbs zfA-I75Z3Jrzp1;-E1M2wC7iR|e2{xcx5q_2s49nzzfU$c@? z*xc%V-GCa*gnT`QhGPkuzlE-JcT>&tDM$~Al2+(m#B5Aa;7ZsVYsK( zu!Q36yvsG6ss@|AQLw{4ENB&Xo^b>&UwzziJ1d)Nb%*b(#Mz01DJwlx&`w3hEX z61b*Z#ld`$-&;?Nz`_DD%K(#?jBxO=?h378^Nf?6y_@(yy`8@s+Y`Nhv%Ws}#SJc_ zvx(SA_f9 zUUaZBb5iY1MACOHZfV|ZZ8gQDm%&O<;+Sa(0~b|vX(TS%YvUUwgNQdQ+n;g>UeC7? z-(4DAEEGCESA3w`$<(ARX02mUUlEOq;XCuIV)Mt>Tc7#p$n`tIksXupwho7WMq8AD zM24YAYAtCeN)JJ4A((l80Q@1FMCnMo)022h={hDm z4h&%)WXt#!$u^d#Ya8rU>PzwHCk0Yo;o-SYZih_QS2w6|?I}!6#b0G~g;pl)x=4P! zNEC=OF=`bO1QtO1;vQ-42SUvh6sd`kig7h2>8;boj|$*FW77Ui)sx9aZ{KW|zc*42c$$psA8!~!re>1WwX-{M+PHi9 z_Kj7}swB7RyBEIBJF9sykpYHszDN3ejU)cOJS_H-ePFf}p0w(h0=1T?ng4LQ=Ea3$ zyh{Z9RS9*14zMhNM8`Yt&pXm()D+c1ZtIg8NT0p;LoBs7fXyy@pvWV?Ynt-YBE*<9 zEkX@3z&e-qyk!V*^C(1#VoF^uOxeB1QQr za=8<1l+EuuRF2VrGaFodA#{^lzf3&%4`;5=dSYL^L%gdswP#@Zy2{mL+Ir$0M^ACO z-#0;(M|ZPckA*_Bn+`6u@1<5EG_mj@qvZI+2%%iOJ2 zj+gNMBY$8$Cq5#LKj*%nDP-bB^gYA*xNaq;@HfNbOVQ=qpm#pzgKWWkm&CcD5yTnT znR!%6Q9y61z@XH|An3k7`=W64eSt6awbkADk!EXP45eE9VPI(_GCt9#Us_CG=7|Qr zrvJ(}DeDXf^4v9Cqu|7=>odjnNtHB#Pq<3~)jTm?sVK&y)5uLfLbm!{A0u6&cH+s6 zOa*SrvUX~!<7!q;7A<%VuARphM$x zrvD75D8o6V-}iBw70Os8l_)>ENc#h{NQAWUDj2=UiUu>$=L_!@nK4NTOjCS;)#ncG zrU3Z`4EFH}nqc{eVTA)4j_hPR>eP-G!IL2*>O`B>82b8nzU>eWZ|3L3DUy+XcMV(8 zeV;u|hdkN{xE!hUGvl5yVkNx?==n<@$F5XJ&udjSWzV;e_0(d;Z-Ca|P0=kVuygg&T%kuR~ot5`I8jik8R~z1&R^m`Da4^WR zfx0~ZyLR2lL#W{JGHt$O`MNqhzuRcILJ)2NV|9?Rfw;IVi1K0}j(PH5q~ z?KpwSIGnvqkiY>oGmdS?m{C=64Gu}T@vOgl@*qFkPJ4fKUmV6*E@s&3!*i3*T{lqC zKgHkwUIr7Gf~9X^3xp46fDy;pj4}q@tIFrc{Yu=bSys^@zSWQ(>6ceIlrmU+n*4e| zv@c~_WGlQIbW`KUlm1?4Q{ZKmw7ZH!=!quE=;eXQS7Et@z$J8l&oIta)stAa1I^*# zeu{&|%NKo-lq~Fi5Dlf5Cy>4vEIXBCrD?*iFB-ei$TTuD#YKYk3SCaLwk5s!OtmWd zVGocHd=i@;MzOi@=5C7YVsfRd5Ia}c{zj(}Yqx0SQ?}>DdY78nN26LKw#~|`v$QV{ z*+#wU77bo^TK)u%e|{>;fo!6 z?1dV>UpIBkEkx8W4_Q zy+EnRTm}Wrmpcth%{kA*$+KZbp5Th;e@*g`WP3S8f@3*_uZ?f=IpdPB0PUS_6kbl1 z(!6aXewkV4ykz;Cv`=##l#C_W`3nu^<1avgJzrs2{T1yS=iv9mWoPK27ISp@@NRs} zYP!%N9Fd7lb~SBl|JKw?_F=6nkD)6&<(@KS;3lNi7W6Dn_|Q*^uRT?9XN(<;5pxJ5 zO@623@y7DZjiz3jJC;d6s&R{fnYs)Zq8ZrY*My8h4PbSlR&1B?K z85{>3Y^^Tr<~v4JrT$L9kyNFq^iW(dh(=pRNB@}%%G+#v#;>QkEBzaS?#nToZIUw> zn=OdFKhc?-DD;4gGt?)tA#+K0Yn#YkEiLUZ|Goe7#xV4UNb1RV-B!Dyokv|&j?!d{ z2R{$_%{VLj&&2Twg=Dt z$T*{dRjNm$zTrLAGFJ}Jj|6K9J4AmJ4ARRR!O^CCOQ)<`*$l{l_HIwvQ)Vr8MAW{X zHl^N8-o{x+qg3(vv55w|c*7TQUYi9%&V%eppaTv5Jy2Oo)n~lkXr!e9hjbCs;h|z2 zzr>VtjDn27T&fR6G(tlX=7KQ@3-2!f%`fX7Lmx$>T_y3AEB(lLD4A|$6PQD!UGzS% zLBlSVKCS!6FaWe=DfGpE!a9>UpeOXbe6pLop>*}F-8?v|H6XF<&KL$li;-*W&*{ez z7(CGqm`V>m!}vziu2pPgd?MKtU)qbnF1jR^FCD$$>EUF%zbeT3vRGh<87fYB_mg7u zQKrXDp>?!X;<1_b$Qnc6qoWwvVjxO? zuct9eMp2F|k+4hk22_hYi+a<27fUHPiWI0Vw+#x5{oCR*=V;Y?@bShco-iy#Q&EnHVAZyL9(m zQu}53Z;aghlg@oWpNy6=OCiy#BK85Bs3?1o^)2|$LH0;J$4|*RRBeY%xh}r>zVU6> zz7OCpHjgUL=IewaQ5@NM!$*{u-Iy^zKkIy460iM|OA=<IAtK`>nnhnHU4a&xN6?N-xSHb8rI6$$=FE> ztI9;(m*Z-3&7ZnN*`xQ06xpmn^t4ffllGllixegYb7@mJ+-ymhX{3AZ<1a?|E`r&j zxw36^D3}vmk)LOXYHk=Q%M8I7mo7Z!d{o{YN#P`wpXc$W(}Xy>18PJ7)**!^@E@S9 zYD=PLc}}F(uHoE|17Snf8UiUts(C^g%~LK{zu#H&P%!=Qn@Q<>E#987FlvJ}uwlWZ zg|ns@zO`$j!JMelS{8NaM!L|;3O;T#Yvisi_7MH{6UrlIf4Ym+Dn$)0s%Lt)S|~NV zM3$?Vw;{|~e1=%(8kIYJsS_bEEj5d)JhWS4UoO&G)RB;qpolvS%vaHfSLZ`?T?4;t zw%tN{(B%!m7$|qJctV)I`1lJl)wzj98LYi`}w=BBv3OqcrT znXS_L=^O~N!%!5APix5j=07<9Y8&_`(%vzDr773bD{_Nnb7iNxKYeLYbH?f}3&t6u zW^XUix~;O7=*4A@57v+7vJ3j|@o1=;U?9{aQ1_a|WX+V-13>|)9#|*{{Z{(mNR8mw zc9@HxKfrsbmK*sjK7Pe-+PdT0Q{3u3234=|13mQ+)o~7hU1NRA(|e4V-oc2zE!GgW zwlW1?Lz&n|p0;0=eE`H02}XcH1RX#^Aiz31xrT?ijS;>n$+#ucX?jF|2O7d{@?xYs zXE`z$Y5&BzzSrTbZO<^O$bidrNa8zJP_*Kst|uaVCtSU_)dZwU&n!$o{QSb)FJ3RA z@;j-c+u>l-a9?MsSl>4tKaM@Do=R5iHK+-N?(GNi zseTSub3ywR7)&Z8pacVXD(YJ``FSj81iGqe|JPa%4h?-zmF!J7)M>XONfNNqwD70d z$?4}2y6;$%n$8mU)-iIXZ(93)zzTspc74!&($RuqqenIMo5QNaQy5gsi<{Vsdp~o9 z<48hBf?hN`1@f?L9%OyODAi{_%zK{I4NRPRn7=Z2n2*-%QFu z{kUliKR$6GW7GK`4L^Hii>t?==$-brD$j{NgZwLR6y7HU5})(D=Mipo2H zRFt0lnUfwXUHQf31X~xjGD@LYqF8`UP zl1EoxkgFE+mH=rBHr3?=rl0&&rcN=;Vsc%*Dz( z>%7S`jaPNGTk#)Kb8qMGSq-Cy5w0vxK$4P211EONg2Fxoz>?C#59TR;(WH(cU&(=q z(Xq;eG1(+#kb|DrbN~ z*bgaYm(8D=EC)~TdS4^Z8AeND>4~?+TRL{JT21ytU5Kvogj-j=IiK&X4%j#S$JTfA zwhV;=oKKOtj6X8}!}0OhEdDJ9cGz_aV6`sBlQK?270V}@64E<-XZdtuV7r~o-4IiP z;&$xU^oFWV> zJv}5~92SAGNE7Xo>%BxKbt)GrjOKb)E+0cJ>sBl*Zvhuwm+^PA#)Ss-#5pEmAV>&_ zLfX{3#!vXF7zI1t6+QJaG04Az$_!x@FrmcM@c>7a2nGrYeliGif0%VnSPeKwzU)S3ky!sopa z@43h7bC;**S{M*N_*WVir4HNwXNq{YG~aI>%9qI&ORfEjD$ zzI|FxOFkPO>5lftTL^0cn#X~UGEcUU7AvdJmAnfT!t3%rvux3d$5QFJ(TqC#^#w{>TfX0bd5o2sTKG%qQG4~6t_6_ARe^wBf# z9&zjxk_!^k;&?d_3uux)9okGgebmQ>7amMg%ElbSzB$9aS4FJxIrLEnM8 zoxmnOfK?{p>&}lFGs~qBt3Jp;Ic+|S_s6CMNae#j$(3y9?V>a+=vx&Ff4^!~Pv>1O z`IQ|}WqKQ~`_PsoAbKVfBkuT=x8NCtg>?t`T=jp3@XzD_`N56W=^UDa-I1l0Ke5<< zYs3G?v)5YxCWR=iHY;kcnki4&o2Ps$>yORxCvIhy{nP(q_b=jGq!jQrwfh%_F$}QE zqN>AcwgjCh99WTB3BBrrFLjpq*L>*M&4a&L?adJy5u&n_+C~SZ=C2*rmz#EB5~H)9 zh#O89PPz0Y71FTiP@}TA5O0wIL+=a3o+033^3+dOL!vW@|H5Y161{B^Y~)HA6YHCb zte5!TI+=V}w4*)*;V?6tR)&CKupDWG%F{(WEs5r8f zZfiEMB`5am7tL#D0FoWcQNjf#T{*oH5gzij#WOev2~ zTjtc;3QnvU^f$~Trl5xQ>EQ<%3c-Ls?!RMp4F7P zp}W^hVR_0@RqS$<^sD6e^m!p2iX-Zall z@yn5`V5_Td+Qv==bb1RzRN2rCWNf<~DzXZ9!?Rg(curZ>pzfNi?^s_6{|jBSr@D_^ zpRhc|spA1hF#nj8tI2MF>qb<&aX!YD^GWcVz!;@>q;-Hy=hFHINS9TCeur4j3)7W< zSENdkj_zZDEB22wwwMUkd?5Nhdol6e0fngiQj?XF*Y@W%7#?skji}1s{1^v=kW7YY z5w@oz;~bR966VtK2IRl^`(cDTcNL;0J9>Vw_tODfWw)P<8;`hL@c_1q;Dk64TE&2Tgr{VR6n_4Cs&osQVa2+H4_6 zn>JJCkZE2!6@9%IljR!({!F^5Gi6_T%4b>ahEC{*yovI&`hx#DULiIhX7cNrluX?H ztAb=xo#eN=8{L~98K85bD>0no3{QfheLkw(Q%3W1{!qx%v!3dVLs-n>M z=>x1zNr{a%=#*Oo1i|pDG*Hfo{o=-~E{)IQ7(Vz|ZM^i(a@Zq#c_H23qT^sMLI2`xf{~jW5~Dc6 znjfw@3DfbiZ&>w_?)muqE$xZPy#P69h37Qax_!G4TQ&R}xG zNKilW1NMM#6fpBk^#lFp#C$rO`J# zR|@%#v`d*USU}85dRX(QwiN5&uqN6VSf$E46_&jp_-*h$`% zwKVF|e~xYI1E^+`QSzt;69TpN&(?{VOfZKYil(|3Oc|1jA|I>t{%+RdWzyEZ&^Gy@ z)?exrZCb?Nzp9E+GjvsxpK55SpMjk)MieBb6csQW36JdXY;3WW4Pt+*e`MxWDsBWE z$OsjslOKh10;ig&yG?WtvYKPb>zkTEI=s8MZ=_7X!mvti4?pjc=9V6OXM@tTy8`T3 zsq&d3^GXlkhjD4U^o)_s6^9|ow|xkpwP)U*NGp5ZMn$M(@m=0Hvu|~x{JH1Xd@5n) zHw^l123!ArJ@0z%7iyN>v3`}@Icc-@z6S+LeoGRUjLb&%|=p_JJJ$UzQsy|o1dr|l%QVL$p`XTF%-%tgWFSKA0B3&32?+~ zlPLqcdvh#{g=Mb_K6Ws(WjnNR`9A%qt!9H1O5V)&gTPE}3x!6P=d#%to9OC82g{?! zVlHrzV`p_G-0vFG7+`HGnlh--AZuhTg*Dpj#DzYUTE2OGGuaBa3ZJ+!Se@LJC%33LK+pW^k+o{8>42WvdNDim6u zbgL5=#jTQO%P-&^gHNRnug*o_GVyY=J|nK>I53b~ht%Uxic>_f1dyd64j|Dlx=S<3 zeM;*mv;Dv=s#J8L(O|m*W5ir4s8Ht^iOfM-ic*+S&&dR8D#w(7ilACPg$vpy7I7g# zVn~n+r=yKaBj$|MO)x+IH+N5OQ2X=^Cj(cR;GiIv-*v8~CJ)+^q7sk13;V_7KPixy zEp|;sT1u-4Q$~$0Qj}{EN^;XE(IKOTS3C!bfTbfO{N0=RYpR=J?|Uz?b|EO> z=}a_jqG(|uPds&$1U=ZMth?-U>EO5tfg2vuOcWQD84k6@%!17T(v}(itx91k zSakajL`JQbv7zGhGQcw$=UCEUy4*f}nPJ?~3OsQF1w(1AwpDRM4N8 z4ll29ImaLdG4bbkoUa&@E$tYnt=CH26sW%H7#A-TJVV`7~w`0YoeSg zNZYjYncnB;_A~mIQ~709_9DW`z9lc;6r}vGTdgw>y!l9W=w>#sGdNdYDpi=*=cVz8 zfVIlvwsn{$090NVqyDC{kly<#9Ng^^@EdH>U%%Qz7hRxI9+X~s;JiGN&2e1FG&m<{ zBF=zi@GSM8FDj6~(bExK68PY_3GJN30w&%M+!38teOVdBCCT-Ybc{W>`^=ZlveHm7 zz!R72N$*{)=BIK4r?@Mtp!!F9c(m~!4%4lp9*AWc{4Et{i`w(dyk_qE%hT(DOgfHBZ>4x{v!@oB-&^f-|H28durE=Ys_>rRS1lD< zwg{}&ZFZS;PJ2SVTRKFiCcCffhsIxLwFlL-NCTZ}Q3wb#2Su+gx;7ZD#o2FitojNw zSE5g^=O6&i-E7TAL0Nv(J;1VCHWq*t?8Ld6(G?yQFhBhT8#;mLSieP{v<@=BuhTlj zhO=%}jUe-Cmn4Z_M^^8vxWYPqTg!N}66C`>#&6CouU>KvTAIy=wW~``O=r(iK;MTJVIF3qEcsa7b@*k0;;{}{n& z_fm00B7^`$sYO9_AIo0dyU5a!6tLa+Mn{!mFSx#ePYCJ9PrX@!lnw2wV!57JudJ^0 zAvsxe|w)S5pdi zPmD@20ZRVL3FW0FJ|dld=-9oI3W>&vi|DY6Yu(Feyf@z2}m1l?tM z&fhy4c$;q^|0r)-B%xxE*!a5=@(r%>7ixv~1?xv`UpqR`#zyUxy>-$i_SyPyeOGJY zrRb5w-msu1-lr~a6m*;GaI88F)U+zJ5oeUiKxfL>VrlD-1VbM;#j`#beUm4mRCd9j z?@a)ly`)ck{=+$*U!UH17wP57>+ixRlm4Ai(eL=>sTCRC{PITL@d0N}{H}gb-It_E zXT#Tt+D{2&ktmTtRtA{nY~3DtD7ebd7$2syMKOuj)^el~_uLOmfb(anv0y4II0Zzy z1`SxDt@Z;ebCN|o95^U`3G#9%#J1K1138~P(^?Wzp+hCP>`|9l#D)1o-pd?lza4v8 zEU-ofvr-2G-N^uLq3~>I1J9#OeZp&hb@_X&BR1Uuq?!wLW{2W@RC9dDZF$XkWcJX> zn_|ah>AGjLE^wkNoK<#ceE+^jA+l7>EXKu9Xdm|eHezb<`KfWFElqXC##yw-;=gIf z@`E7f$`T#{Y7fyvp18URUT4&1gz~ku3Adbw?lcmeG_y6F?oqClIrAO2=9~jt{`l(H zL3P*k%)EV~JYhZX5vUs9l~msHHD4i?gd^~9R6Z%8?VYbxzpP%@FBJEb^}IULc>XH( zm2BrV)sK^)<9N52YwFz=OYX)F%;Yjqt){av)q2d@bLly!8L>z4_bu?MDcdVja#`*Q zN>VW}@uf?=MI+x2(_xxu|P^fQ7Cq46sFO;H)4eaaka25~QS+qQKu;1;^8 z(#&$q@o9U7DD~ys24FFY|7aOm` zs14+e&7LKMVfM$8DoKNjz^MVs$gdYT&E*#=1sA36N(W}LuWbk94|KHImrw?vmBJZ| zGiiZJQ!%g?qwU~k-jfCh^$m)vg)uq9Zc@Wiybi6ju5%JPg~s&RB0Swe0$3G3E{W}r zUQLKH!vTG0oT3dT7rpY?_UBq$9Y*0i&b@%*Z5OdVio|9tEbTX&NAamRLNfur5URoU zO^b&e7N^cprEr*La3x{!;1Zth5@jD;%W_lhIqbe98!O5I%a-yUI_>95(qO{m3SC>kr-{(r z;&*7I5?J!k?2AggEy-Ghb3v8Pin5xUmrHS~5JS9WH5n_Q8xWBoIzZr71{Gc~e)`Ir zJyQmk!lf%WbE69)gmD1pi8zgaO*e}NoBIc=En^tBMQ1q8<g07f{-yH8~}{M<$*$@H-!LU zmD}~1Anz0@7~GB{M5ge<`|pO@)^iQ>(@^!=#CgD6R6#&HxUh@LpskQX?*)5ONS%rFs0s8!UhlQen5nc3~_t^;{}YaJ{VlNJ2dn3A5C!Ze+?&W(-H# zK*b9U+tkEo13*GA>#mai7Kxc0?tAbG(LZCr`BPVzuW0cp;44+HCe4SWFMMRIlw6|4 zf)SLm%{GjRe9BdF`f-^aSB006MX&3)Xnq$KSEey=3vrI)Vg<_ZaxQ8WqRCK^4_q5E zxTrt}8r=dn@#%b9$R)BVdd8W&9_y{Xa^FHe3th{z5x4>|Vkg&+ zXXSP9e2XP4>*3Lm%&;+)%?2RZbCZR#Rd&<+{0cesmjRfd&(RuoNZf93J9wI&S@R6F|7={1-s zQk$f+LHc2QVAgMS5wnk3f(eQ=lyGja&qikaRE8mS8h&oPh*#M`)`1bxf(5BKBt;8! zbO5gHX3zDcJ_yxg7afGp6}&?_3Xl6)S}3yjN)~O_wHp}Sx*81N9w%Z2u%;h-YYhQ< z0vj>rOqY9DY9Y+C_U(9D9_em7Q7ixeG5$+ba<*sre+K(#3R4qGLbT z@A{9u{s#W>;@3?e$2iIOLOCIw98gLE{ z0BwDwl3PJ^Ep{tZ_q#1WseRNWO@t(fp8Oys`duBudNDI7_E*59AX{U-U0_T4xJ;_Y z$E#Lc>2RNq53~I#csN^@av(tQ*Osc#>T=no>}nH?4Bw~Wjk61iE_Zc?_KHhsDVvYQ zY>Bls?9XhozdLuDg$iq&l5E-L$09%aISJ>H@&zEk=8-<$|KaJY!=mcGa0fvUC8WEN zR&vOJ0g>+R&H?F0a6lB4?ijioq`RaWVHgDI6p)hcd%oYj&;5f39%j!z`|Q2WK5MV_ zuJ_H$V!;YV)JGe}sl+b^Gux=WeFjYmusNygRmt_uaxec{;6B;Q7{kqLKiJGYOWD{2Gy30k^IEBFuUA3;$8ti`x<8s_H~X4Wt*D()|n z-&Z5Fvi#l5EMm*cVutQ^B5c?Hyh6!eqxUJ}0b(x4EG|1HdOx5zUEz1IV*M{~s@b@s zQ>&ory(^0%BD5BOQp)6C*cx%$Ecf|0EzT!j-pY!AH!TdPh+l%AsFmSqWQ1Mr9 zOV}YE`Sw__@9#(Ke(Pa6@D%@HA5wwpY z*@>upooy{Q>D+_qklTp@E0bIBy!&ujl)ISak(aL3)Tp&RL>xU(TLsZBNNo5#vfNzq z`i{3F>2MoHevX0a(-pi>_`2~V6Y2BHRF-zhNNZr{Ry6w%k<9(f$obUH8{wi0`KVLV zRm84j8z#~Jfltg!U;PadpCYI0B*AfpY*F1{S0Gw1bLPW^^_z#P^$WZxqjwO zjPZxtTP^4Ff0&^(^N*nH$A_njT&|ZZ=bQ;tzVR5BVqog9uNU~<%g;&^xEp!EZK#J9 zMnaxGy2r0MpQjT3^A>5Fxg9D}G%dIOM> zcV1!=OykJpMJQku$-A8@zrThgzEuF4|L`siAD;)c;PMk5E`(ozLDP)?>LgIZV--;~-L2h(NY`$P7IGkNL&P%t<6MN40wSTYw=$_TW^<`xTYAjGc$Ifj4daOr%$d&mpyD z#tq6v>s^IdkpDJfo;4gsE#t6>8ZNN<-DLMG^a1I#lE>kSe=IO5LP=)n){P!I61BK=(!*l}#GlN(w zVi*-5hFSfBU1QtZs;AOy0HG>3U63DQ&_MTG8Qmp*4wBV?+!ej=EPc7KF?LuC)LfJ> zwnU63VruOho7Sz(zklTz1<44B=hyIf=VScA3V`wi9~paxtq%hw9B1UV)VLd^Rkg2c z+05N>$%x*PdH-=!WPCjgW&?Q=*FW#YcpERzKc)3AkZnojDZt_acsUB_(d^HLwsUz& z-j@O)jTZpgMNc6NbR5XOErTwSw`Qq)8y*>p#Y*zR*!pC0O-eQ~0Qspm?c_*35r$8# z^Etx2dpij5SNQry@oVOB=&eI8chR4MVP&GHD3v&P|wCtXia!4jBrEvDE` z)!zuWlFo>Bz`RZYY5gEV6+Fjs2z@FU&n+&a8b%J$E+%JckY)_R<4Eq7*C#^7W<0Oz zE7sC12w$|zR(!*IW)-P{aKhHdY*PFZOEgq!;}vP05$H&yeL~ZL$f$Y!CtrYvyFN=* z0rGsj?L@qxWa}TmTrXH$;;2^plvj4?cX)@z+OwWhXZL@%2y>kTZKPN6zk{A={>4b0 zbUJLdSS9!0i#Ld+PfJdT93?MweQu}ybvD9Y*UxM}=zQ+#EWfw9jH0yXif!Syb8V1Gx z2uc%|4dj1--^@C{cu^iyLUw)J@f?4$P)frBhEy?$L;@Nu^WX z@c0pz;qYM|tSn^H@@pPoh3=ZLrwSNG;GhAOn?QvQ(mN>YqhS}$mw~k@RQL`gf}oK@ zeb&JEc_I(Zejz_XzKzID^Jst4u$VgNd8{zF-e8njclPhtPiN3eIg#o&Cvu^iP1y%1 zE1cqOo&ED_BLu&I%m>d3qYnNN+@!?F#s{ax2>U@Av(a=+&T$y|bdkh2XD+9&?-eHB`9`F&5XIKjWR)xX}p3a~q= zNOX%8=h!5}Wcr8rojW{QIgfl3LoVs#5k7W;_1UT6o@EbZy{N2Gk0(-2`LVA3izPlu zRrU58J?a}hu0#av?>G{6%KoYhDjeXF+Z6^;@Ux!RjQ)k;eW3q!9$kEYxyxY%bGY~_ z(cxsUks|Sl9;zM9dgR0`YkgN#Cqr%SJ2)u-Vhh`)`mNn0Lor%?N^(B=l^b6BK%8c5 zF@`3;i@1jVAh=#;t^--SV4ahkK?l?bej8koQayELaqW5)NUJ!@1$L+n{CNVAQ&{J1 zHpKR7lW@swQe%LG20S!ey8P`bs!BROL`h0G#v6&>m#-d6ca=;k-tJs{nEh*g0JYPl5vjp3w}e=5mW0kI9BW{-ta*Yhzu| zc4G4)o~*-D=P*>_cPyGei|J$x**-LT-SKsb-ziKvbXuOJxnphUIblz=(`5m^V9Nek zg;Mp)`T59Uj;7iSlyTPU_D%_tFZpQqcWw%tsgr?t9mUbFM!&0LiBybE63rWZs_8;( z)S@{4w1yT*10;9)jLS}vyr5w}U~s;=bI@+XpYJSm5>joP>Bc5}A{LimXlt^>gLMnhA?>*= z{FbJQy&lG5`00(`XU#O=I2?b#)SlfKkQN2N`p1tw=lPB z<8p&vx^Ey{#9Byj{zS_0$GU0o7B}w*^u`!YDF0E&1pr`tlDv`AL-V`pHREc$$oyR5 zf@)P*o+|t1PJ1;+CN;@5L?K2o5 z!V~Ok3ZO{K8t3hwQ3Ey1GpcTCObfAR70l;?TMQrTQ9T&6$V(>3tWToHq~XN&H*d<3 zpZ`RP3a3fvBMDTv4%zd5ng8?H*YLJaaT{ z3Byqfa%cbH9eZBVJUo^|88hbmVH)E|^Dp^ODa}`JQYP^-mFzdqhKkRK7ZF%B-zt4= zaaDZS2!`52O+~7_aSd;KiN01{LMYa5_52menv!2Gza3`8!tkLe?JTU6g|)VeMsl18 zN*yd$6J5=r!`DWu@fTl2iKpdZy;9gUykU)CY#;N1wk1-(^xaN6$Bd3)$-r;mwbfPc zKG~N)eR^5eBFM~)*TEr_sg2|^c}T434Vi#ba=U&X982Tifdx|lRby(3&H2u5QtI|o z03MvdRP>%h*|NO-ouGEyffCK&cxc{+e=-}jmNbAKxMMd4W%rlz&MhAEY%@+gtAwtvAvQg9y zjQ;844#}GdSdV1!lzbKbc<8^wilUjXoww$?rn&3o zMm&;EM}Igmm7g{M zv6nbDa1SFl#&^MjUrL1!BvXf+)|&vWK)0DE8Dk0uN^#*%Pnv1vz*JQniZ(15A8jYw z*?-7b^b`{yLgl~SHavTWjFi#4l^rX`gGFxLP}(qhCuvpc`2dn_KWOf1!1U@N!FqX^ zZ@*Iq4ALoTH^FGEWETIr5_>@a?h_aP#D5N>K)wlzv;V-riPJ)= zqtoZY_5SB~j-O##m%-T4bG*RY4@==nf8U;KZ7x6|>Yt(N-$qp0AmIT+#7q4HSnPibwq2`ArGxYkEFHoES@@2fn#_2n5+>HfODGDn$ zA1_A!ey%EyZF3p6QM%YhvQKVFsMw5J1n^2W>l&^myd>6~Vq^&uB42k{`BNcdLH^A~Ond}T|lAyC^D z%sYXr7bSPrCvs^UzABMqhPu&zP$!%t8ZQ?)I=30+KqUe1u|kJ_pK^Z=2=rL%qUm2} ziF&80YIrd{A`tv4c=Cu*M&~y<(-#h?_glWzlDyT~pNAqntiKDbu1d16WW8ekuNWQ} zd6l4S!erRisKAI2-ej-|N&SrxQumua=0}Sf2_WzKgTMUr5%lIi*jah*3+WwA55rD^ zpJ89y_3~iK+pju1KTWmLYD0om#jhtES(*Z{Jl_X+t&Ij#jaecq3!Y^f5ta@j=fqVQ zCt3w}!cWCxBs)ZV7fn>d>l)L&e%$|>`|EB&HU&UmP`(4i{{%_bT&UwazD0@7iRJw{ z-mNp6f6?~gh+DZ$zUnmqDBJjZ$RGRhQaQT)tg5d6Sm{;m06}9!F%4)jpnmgaZu?i- zZSj$>@Tsie-)QG~O&u+AXKF&pgWbDwt-o3DBXZdn9+5hZ@;(fNYX%tssgM}sv+}X{ zd>+|%#_?pZ_aDKl$a((VKlXo}KjDdKm-&{p6c6+Xv2+y@+!?$cb<1cl7}&ZjvtOlj zUu^WP)Z(*gP~Vf*qR$?V&Dm9vwG`)2W97gTsY)ICAzxeT3Fg?U>DO*bar@;T-mz^JLu6 zOVvkwd#c${^%0ZU4o?o})nffOYO3Ea7Rob$(<}cp-gG3!4hinepxR(aUC2m1xK$(@ zZ~tUJdE{Dy=XP|uiQwCId$z`>%kG8lI5c}XHEEOTJC^TCx#=?v&(uUcfC~R4k&o7!#PMO)#@Zi$z$N@fEgHiAI;x{3e$9xJVTZ0TU=&>N zJ@C&+$YP{kt;lq}{V)M@WX`|EIi@NHp8bRZ(y32lt~_FMqo{r*3N5RH<6nlpl*UcJ z3Xi8TCDK;NL3zZ3-7R<<7t}i$k0(v2N^ya%+Ek)91A359hf0Zl+ck>{K$(!<0`T zQyDc;C~P7zpf>>$#;|QFj_8;9HzEYNlhq0;>B!4WDZQb{^DPF*Yx5TMjSGIled+w` zJSq(ha`}~W+i?Cl?r?vSz&(*of^n*d{g%A?!FzdD?L2eGgf5IYG!vB+%i`QEX;I|R z)P+L(eh}{o&-j>(rbzI@WPg)0C)Gv$8Y+vjD0ftA^;4}Y z)OELA8WGLDFJ@O$QGl>5Wn3RZD_lA&%ld%+`(MGW!Z}^iYdLFE&1Xf;X#TT0Etk1F zL#x|SGS*5v*RmOJ_(W&9PF3(jP*M7SBOcrYp5Tjf)XIy)mhA@(!5kx-K>V<4R|luS zr--6vwv@!$0fcW69n_<$4$;#E{G8t2ROkF6ThHR$2eT9ya-mEsb`Fz+adT->Fu=VnG;{7;#B)Z z3>mZGhMolkLuIvu`CIFN22|pE0~iNuO7yIOGp2-9$gY|lhG&J4V0d%e)p{PEP2F_d z)}Sg!+jz5AKZ^^ovJJ()y8Qf+dW}fIFj$Og0MLw^hiIX{^hf?8FDckJK8^cH-WY}+ zr^1T(+M+dFxcqG2K*-Zm%3x1C`l*zf9eGJ)kgi^>Xnbd$0xDJYDPA|ivAN%RzH!5p zXEmNz9eeDJPUnG0b~PBi)PG`^jM$KjGjaz^G@+zc!R^g3$nMYBkrEts*0lC$d($@^4vR+U=qbm7v+PZWvq271Qgme)Q1ZF zv{Y`*MnCb`+kQ=Fi5;%1l-}l#4A=P_cS?;?uA$u!0cr7!&w+&{u@sHNW#XQp0cdP| ziijkNi}K9U0^0)EORkpvexMof8h=gFwK&iqLphsA;TjCzn@@6&$Ym zPp1d1cy0&hxl2+2TyGg{yNIu$9&_k`cHw7bzbC}3f(l0 zbebnB4OKdH0Q%fZ#hh;s@pnGPQ-gMiQ&0K0zoU-RI0k%g8l0vkSYEC4S&fml=DYTiLQHqNq=+lfhz0Ar7{-u0zW%4E9Bq)kr|HTGF{s-R)j=Pg?_*LMFMja zM&$a7p;~gj)+F0#sUaJ4*Le(Dxs_$Y`>S?}1ryR2#NQHBP+W+ISxSb_ z|2B2pUGst5(I=@1RSz(`?`8HC%a>}cM-X9$Kuo}j)X>4%y*TIT^rdR+cO&oC;8mhN z)`&4GrDA%X3t{0q+`x@8c+a~#Chkvn(uIas#A%GL9rIyx*i`c>E2eYknP1JR*{2$jbT-Y~D+pq2lzX_N3N{rlqeeW?MCCboKc z$fp8=PxzHmy7dKod&wHB@iTb`rUl6)lUl2Bb8=U4=WED6!TZr%xsxofFPAHZ(p z9pv`j33>S8W|nM82fkPPLrqv4lP7XcknTcXN4y`LmE=^QF6m10osalr<`>dZ7%bvU zYu`E44vNUkdA`t~Be?q|24r=w>k-XwDkGOC5&o&}{a57z zAN4*{58*F3GtOXx0B87%#rG_o)l=H%7JPT#C9{X~d%_7p1yjQ`iM z)U(_mGR}EGd#*0P=qipbOd#LUiJr{8bYNzvI<*i7l$JrRm2$o5ok92zutj6~i^zwY@}r5HWy(cAg5?SyNj zTPS5IM)4XU?2wvf$Z?Qu@U>5?SIa%U_AM;=CMDbjwXj5~dGNa!d`9-;5fuD=mrus8 zq--3zyr4!=eD~%!Dm$z|=U;HF)f>mTfl9~XygI1VPsc+ZKWm;7&C(OP46C|t8!T&b z@+lkB1RL@r$NA(JMM}8_GAV&&Snh7&WAK+|4kAiawi+2&7QvdmYw5pH_<^4Kv?ayi zmpb0{a$}Qd*!!{NC@Z5nPUm2ax53e$Dl7VMo)^S~XX^W-RQ3#WlKXqOf!qVC zlqnHS`;uTeBi;>_bSQ)w(P=NfCt}75P%RLCn1<974H<=RdQO6c7M`4elYNat#wD;e zodu5EKJd#OfU?`j10?_CU_5qL8t=850=ib9ht%#y`0&?f!ue(-aC2AS^j#==Z&a zgl+-9@@ZF?o{~_cd+)TSaLpJum+@=#!a0fNTMNycPj5EYzxDyU-BfK@U*J@zCq^LDq zxhGF~GtieK!>u;68bZ>34qFT;<0TG?Y^Tl-E*GU>c#%x9de{&2G6&AS;8pw}j8!Sa z6;!Q^G?TWj5|)1kK|0aX?3LnS)1M0$vkmu%fYW^h!yPR>-Z-#am%Iq)WS8Gn!2B8A zK)aXFWEftP@V>qRW7DcZdb3PoRircwAU&P68tRCdY+#_49Uc}&gg5#lFb?1fwlg(HH06n5Sbd<>hmyym!=5RYK;GP z;UYw+p^T0Pk$E(S7egy8o2MT2tTziOAnHf33b3D+ovF zy2BF z@v|2lpk8h0CcmtPG&dpJ!D0}xf~H9|DHGXb(ez1_FKBv%M|I)1+~u z(tNM;bz{R*OR-JyL_KlBJ;ViMda6%Nq$))5xU!)HZ>8OB@l?yTCMr{{PnCy~?S9`* z`8biZj8dIBMcmPgQNE_$iZ=0Kr)fH~*P^Dc21Ft`m{T+q!K%)$sRdhnJ`vf{&*fpo z(25*W6E0y^xf1Rv;(NbS%oseb^D6IXuuvR$-++al7>H{7^)tr=&w)(}m`_w!<#!t1 z{Z#{5Ykf*SQpFCD>8~+1T61ULTf<^3>StneMz(~~dkI7aatO&vC2b>Wy&;t50OxAt zPi4?1lHNZ9IbZVPX+gJRA6iMR7UX>I!%RVH!l1x(VVHova=Nw?UH^hMrP;TNg~J8H zfQWLRe}_54IPctFc0Z9?OG?@CXmZ18$1A*r8Y*j?xsV#RQj>~Swlw|nyO!-`5&_+> zm*IL3yu|ZA2f#BL$);Fp)rz6vEn~ZMXm`+JMy2YjPak%$@NEj;l~uK=(%1zq;UCvK z#${<$dZ!3|PZx8fdchgUuiR)X19K>#XWmMWH~1zz%FFe(XuV&mOsW<>d3|#;^OcpH zqMx^sVFNLIqIVZTIoOZ^I5?G-c$}>Lau4iLRuAplkDw`BK1~UaN_;%(s~Dwy0M_s3 zF1q7eG5ufgZt(7vKbtewZ;MCJ&xi-`u-)sgDwXUF#r)Pgy_ z|Kl|-YD8|yXYz)j*_;fE*-0z-dTvXnzU>s6jc%zfb5V?6UDRNo<@x$rY!TVFwcgu|u$q9mAmWgMY%ia z>YH>6=&{Qkx*ZFI+Eu*lynWE)5D)C&oD`MnI-qes|r((=9IHfX2Sg3}}3ZvGb7%j$FZh1cks? zY0N*~m&cq;##FZRT5*(Pwb@*&( z#-i(7nCy1<#RD}FAHz||d7bYsK`!))-L_e`YyapDed11T{Y8}JjpJykP%8)vApxB2FF1+Xn#S25Y}=bvD`6fJkqd!wd&pIU z0Mjt_?`PSWE>~Dde?DUB#!R#Xh)^szPPGfUFKEATBsQic+uAod$PP+iSe?1?AA*-w zD#c(ekA1bSc}tYU5Nq`heC8W4K&}>sFZKxfd>yG4#ywoWVO6v`k2WYl(grPfNGK z)RPJy$sR$-9V}p-yKc3nNzL^{?j2-OmfQD4+V%63p@}-~6cF7Fk-I*f)^X~tt+9s< z*0rdvZMXk7s^!Wl8pA|Iqg-%b@f|Z@#1~2WQSFi)s?eh)7iz!{{R_PHJW2rBIa zu7?RUD7K=mFvCLR!Z&Ehlv;1Wvv(S5Q@O+Rx2x12!C$8x2Lu;BI<>>gt7%fkPOVRU z{BmM%TX$A~=gU;kF;k~!Kw^aIQtrC(IxS~WwVd*>x6|q%%7U_fS zMmS1T5y!(-6-FPU5e{RvqSj+Q{*n~H&ECGqcq_%T1NE@qjh@Ff>B=njk3n@P3tDlU zS!v8TJjkuJ%k{n-I(;u|g7eP0bhzNirE9W;xE!{{}NO6oBDr&*! z7J79~XAB?aZf;0^1kDWHZ_)qu-!3%hY!GpRcH-}7Trc>P+#zx|;6OKpDR7|0M!?Z& zxDO(5SJz26D<7!UFZM(DTrX+;Z_J#xB9EZSq5DltOMHJ=M$98fN7ZIVVGnw;C;cYv zzh7`;GvI1J%_#v}iMt&c(Da?$RrUcgu}KDeyCKcM{#DxfG5JHxoqwHg{X<{phE4Dz zs2FIensGQh&VSJDhL~C^qyu(BEwu|>_|2r&d0XJO!2Wx=hb*}x{_%;2>ymi|@qHCK zd97%VM-X};AB=Sd7-p$&+x`D<$Jgt$d$&8F%&InBuZ++u1unZ=Sf6lk1y)0K0k=A9 z-ql6dJ_-vyiWx{}T0ObzcQ~9DS9^fUo5`|PKZI$XC|$q0q^zKN@V(#W3;;|tNrz8B zE$E|`33ebc@I3xZ{8BH71#FG7+7QhLIiM-)<;G>|Tjk3FKOX<}hGi8@iB0&*h)a5o z$b>5NpwQ+#=CG@I-U4y!b~|R5!c-}@ea{(y3%dNTuhip5Q0^mWC@H=Ae(sWT5Z|S~ z^EA_3?L=HAGb3 zqYU%!w=Mx{84?=LA7*$2v5WBEh879EOu%{#)n{7Y@O!EF$ceSll*X`uZuR80uWVZ3 z2Zm}_MGAmDEHuQ_AEud(j#JmEg#>Ys$)@D=d#(DKyYoPg4xYC2A3TEM987e6^4@X_ zxH;j+e|(QXFjlnVIab22WT`D${u>0@o#s!^|GJWx8T$ElNn_kQuhlE%$5A(soBeJ0 zF@X0WU?CdLq-w^W+)ux2qI-mc_6RzH3Hbv1V;W2Zi+z~3e$g#bPv7iEclN_?<r$~9REEa>G$LQ3qls*IHm1WmQ7NrD8rZHRqb1;0O_?iYwwjNJ_1 z_wm*HZ|n9mn1_&t?OZ{qq+gVJY?UiwJN&GVxd?c5hp;^B(L#g+ai zaBRCrP~~eTI&2}c`$~B(-@~}|7Orj5xgm|9t(rI!n~o9V0UBn_XB&uJFI=MT*7%1T z5_hqOE4t}AgLd4t%G|r5eCj6>(<>tpU&3F@cv60BT2)byYRZuK@bMN5*w7DpESI|| zIldRvAMII@dS*MmP+&sjv^>_H{AV7>^ruA8eu5Vz%-^yxXg?+`Ivsm>^#H4mJnqCd z_S|@A({Ni@QpMKUc;BU1OQELtH~Zn`ofYuaF64M8p48w2s(6gFODmQdep7OX>$UEJ znGlV2|4Uzz9g4-Rv-E#h~X}qE~R|pBkALu`@I9*TL25^~(FVW1BWDUYqWWXGobOulc0v|HL ze~`-oGa_wO(_2lgzT#8pJ%4Q1^dOr9%oRoS%KyHG_wMzS7P>&7V<&n~81TVQB#IWB zH3PosKt&QwE#dzh`@E_Xc8B%;g;-gT$YiF09xeBkbJW&-{_DRfbZc)dtg;Y?Y_bMH zcaGV!5EEjIK}>773`Mo%KL~ndi6!m3M^ICou2X>6@!qdPCAVzNIjozXu3Ie|yG~{M zhe}7$L^BQVcRPujYRvn6S`KITUyud=lvD@NK<6|!dc=c(Cm)%PXGD!PpRdzR)4`r) z#BlBZ;rSUOgy11yPuZ6qc<@SDV)a*b-7;QEU6%3>N}IH?22+WEfCbfbKIVhp6qmQs zL{p!t^qhg0%6%zrXajl0=}z88|F^3k(_c`t;Q3QJX6DB_c37?I01rC*o*?{*5Z$!D z-V17U(X9M)5M4>h3tY7PqHW0gBR}Apmbr|Y?CvmBrASd=votS&Tv;a8g1GmXgJ&l&y0Wa^z5#m z;^w1e#|v8Y%m4n1e@AZx*ewhDi{!yfLjFh490_#|*5>(5_*vlclS%kpYHmzW7{&b? zjzdz&VA3A!!F3X%msfWO-6R?UJPkx-b?@9B^sFf2OAI7l9glcBkN*3gewh=K&Sq?Ap75yN31$Jwf*S9JRQ2Y+S>#@xouGv1DMx&AOSGzM>IlVxr ztl$IhxD$bNZt1ab^yReeL}t75YPCmD#D_mXPqBbKwFyr?EFyDXS&hHTtwq~(ms*Xf zw7#l%1oiV*z~}xuh*hQZYwYsweg0$f&|hyJ@7DeZ@}yO9|Hzj5KPbf~Kf(X%mqIDC zA*jos4Wg@^-WuM8>XP5(bjl+QAx0mu%(KA9*YvUA7aoK6UWVJ{cOw2YsqhWyS=#gb ziP*)x^+`t{{jPvh(QhPSlsH&Dg6p-+)iS0yIySKTz3;f?-Qa1pP!qqOa(PnP)<_AB zdm;J8Zohx4J(Gs)r>oY<9f7z48Fx^W{A9J_U2SXY)cqu`<~Y;y*@F5ws~_M09AdFl z5BxkJcj}!3$61=n5QzL+`bCCSB79psHTyY@$)sjT5HAff7xM~vONn(g1$M}F1?(3adm0DeG%s2YW)N!TKf5LYQ zuZ!C?wXrHO-kk}-D3r_H)bq=CIN6&zysa(i%@yag(cs7K$=9Hw%}!Em9@?F3c$-&% zL+s29620|Do!Gu%OQn17v8(WUlLuaz#`p*dym$mb^r$MY@?Zt_PUM3xH~h~Xc9uOU z{HW0*|2cG2HnRTenAB@*4cEGDiC4Qc0tqS?4@p;Hy!*Q;Nl z+DmBdp8u!6D&|GJVP+)ouB2m9xn(xDW-^Ul*&ne3Q9I!qm2I#fx~@(7v0m38PO|Ym z`Qjg(|3zEKV1ut|T}8z2Yio?py7bRS3BD!?FOj|d?5LKM0#T@3azrGUEc4n7(-UQU z^qMS-a?zGYdqI8<(I&f@G3AN+xPFpt7^C6=`#}6_Bm@woKi<$#EK8hp*Un&T)|$Fd zdvWECdWI6-3YOec3O_r*QkfAr*Nxx))pYM^c2doIe11me?a1uA6d7lfr}FFUj`JWt zx?bD7BIcVfz{HC5u`BN{-~GeWl-&J6_@TkvvC%|g-erbvU+>OwSR)wB#sp628!(-$jdt6{T0t}2fwgQtt5ES!z)|Ug z2ye#tE4{qMn$^<{1hp=O3U@U^FXo{Gg)zFdiR)rR)Q_MVM#M24zHh|%lLc>v!MBci ze54|qw;|a(hJkjie7-u17hV}`L!17QTBv}z1epK9b_zZ3h;rb%v-VuR{X&Id2V#

a=NmUTA^D(J|H!Q6t+WE z?hu8HB5&-O_clrj733=!O7qVR|IO$$P2^6uv+bO+L*w^|B-! zgf23;uXY+%dbbaq*Z~AHB0lJr7F0x2jCk4Z2zj47r9_4-9vY|w;rCuTRahN7BfO|Oce=WNtbEsY@o-Vs41zhjC=Kqm z4qoI=h*D~&8sly={W(kQ2yi%jB8XK9q4w#2J$j)l-Nd`6Q2gJ!IrE(n@l+p@QkHD7 zCKgNaeH0*~_T=D5ULSzz*5pMu)-b2q4DX+n4cOw=*U&`?4__g*-mGPXq4Lw}Y*ZLM z-l#dq!c%~^wYDyt%K|tVI4%m8xcdmIbJ^y@g7yIjEV#pguZ;j9_s-rZ;WX#;xi5M_NTl9T0+Y@uR{!O-F# zrz*uKi5k_wA3#WnyTXvEmj#)lTk_%h-+j0ZN!2mQr|WYau+c?U)dvYj<#Pe?VKLg! z9JJu;FN(n*wE0?t^a7^820?{Ri_c7j3(yf^Ie*!ni)yt^n`^EYZRtnL_ihsCq!}_z zS8i^nE{0Qm|G|z%<9Aj}lz=K%qC#&Gz0vIFLB~ZEU9vN}`|jUpI)iZ%(0^>_KaF~t zAkZv4&Rbx2qgepiDzlapRcEP^w6IzTahBYJt;cNIchPhq#ve5~PjDUilyyHgOsG zD1F7D+`692a+;^EJbOaRq}ih_m`0kumO!z?*|;gL>dT*k8;kzxjK}ZcH@p6qMP9q( zuLYhQTtYLceD{^4iZJ;4=ziTGMSRmcB^n&*y-EtbsOd%`7z)Ihm(g%1i(u6NTedbs z2u;hAI0Y^tH^pJRzDi#4DEg@EO4XVw;q4^t*D62yl(5lR5}(JlYLR9RFepc>ygf= z7$ilb74s~i%8e6)Ddo(+<}+t2DhqKP(iH>Gr(&q%uh&<}_8ZUxVY-RtO0~WwvS>{f zd#BUj=*n|&;xouST+P_CKqp34v;i1bN;CXu^$|p=cAx~kz72VJ2tWNU?YXOVbT5Yd zUb7-m3QKrM!@s{Lo@_GkX$yI{H9Xz1$a*FYhynt*sX?nLBaK?wk)>|@dr!m2eeWEr z19dt$4?M~b;1pN0*Q3Gex*q4TKO)nrPpMp_yIhz-J@nyU-2^q=?wfx2Ui~By+9Xl` zd@vXH?GStv`CkLoLYwR>U?jJOtI|ZfQX&FhtlC900cRS@W~ zwZPLVpQsW8pq4tO4SsvYfev>fxON8as1w!f$1E>5u@=bZKc&yJ_{?U&ogMex;fz83 zQi#VhAlq9B*=saAJ;%DgXxOPWKOF#u3{8@2&O#f0j^qNq}}Mz zKL))#)_a!2yE${=k0*Akv#2YU)Lz%avIm5T=cH60rs{wrW-9ck#`rY_baT2{{}3-9 zIQ&6*;47B-j{C;&!7)0Z2cjMkEQwT+8%?3>?cmJni_E<9X5e;-*Gdhtul<1~c)lh- zX=ufOMgP#Xb9bxNT;Wr!Sx@)x6#GV=9WU2j?JiLI5d?qgDq(TfbBN{OedM1B=(!O3 zZVt@p(w2GLl`Km%?5x<^|Kj7-r9rMfBsCQ{PAkxV+O4Jf;(?@7v?!e#W|16FxZJL3 zS8r<8XX&V`=HSf+E=qTQUpBL0t3(pL9ah_f%~i5~PY{7g5POnq2)aG}_e z7(N5S-#1G@dOOi|s(<=f?}pMW+*I>ovb43iU9DP|MZ3F2#p@Ty6RPA6suzcs{Jd^V z-W>1R(iSG4fPVHEq$#u0Tl60M@|PcR>OgzW4zBtA&RGH|EihJ>BvL zq&+kMQ*NAmQd1obk^O+}?RG1(A5wj%nPPThU}sh=!FWQNv?flwuZue8RAEwXRJkNe zspHa+?W`D+B)J{i-gu3_WiO>DnjM@GO25gMSwIjHhcrLUey?+n_n<_hNV@dJk|eC8 z8VF4}RRgiUXPq3rU%Ng*-rzM*x3r!B8qjST*(t*tV-s>m*3WyX-OSj=D-~Qv8f)S= zgCsi2%LupYgm+$;(9Z2}Nx$jF_uqk-t+#!^6^R#_3kb6qEcPF3tey4Jy4|HEz5vsG zZ5P2U(5?DiAoBhYI55%A!J%zdlbe^3UC(#37_Xw%RiM4gWYc!DUJ=)jaz}m8;tmBX zD+q-4>Kh28K>tMUPuJgFV78`z-tjqsSHhc0*E0>|;{-TunN)*&CYT`>{Ct z*S8PYOY0{1KeFpH5qk$DEy%OsvjYhO;TN8hRBTu^HVQ9&UIp#BnN@7A+OeM8JC0QA zD*Vewf>D2t666Gc5V-enzgIdc*&=zQt&O6d1*$StdNi@x?Pd{ zHH4b2YJso){S9?eUiF?*VCC?KypD8Qrjdl-AU9LqbrTLp3Yt8O@*R?c8uFV0f9-|tB;BazV&Fs=V~3g1N%bf0JHK^!lwc?1c=1NJ{J8JRA0J<;r1+wz z(^Kc4;fz?&Q~wPAv(2674OV&JOiInRxD6nB-OWvY#krPQ9p!aCT~XouCcmDPXAfH( z>B8t2(Jt3Q#u@VIw$tL@9RDixnc3aD|5MeM$3y*n|4#`ikwRHVQMM4qz6=T>CM4NX z2t)R=g-MFCP4+^@K9qe)_AN%TuUWzvOG23}4aWH0(fj@We1CsHD}F#MpgDl8&u zY*4lD>EdTe+pgc`wXUd$v)$&To7kr5V|9-RxqHK#lg{`kojd{OxwkE> zKmFybH?|$7R8tHZW-SBCxqUVC_0Zl!vm98YYn~(MH*?f;9KjVEv6Fys^ zk9;J=>c;DkQ515;Ua>ooK|mJ^)^1+OtQaTNp*cM&%vGGbVVSm78Sm0i1F07Yh5kv^ za~~Zo!;KJEjQkd%4~JP4?u4Q%>Xl+MGCZ{itV7km-<5byp4R;w^BL>OeO%9TKvr8f zNo@J1ZPI7erv=AfsqrVnr02whdA6~3kB2>El?Gl0t2ln#!^EimDunygo37L6<*bM0 zrCT;=rh~VsdGmLCgqwGU^n_HFc-9mL`kVxoRq^Y*fg*-Tx)A>`FQAig?q)5Y#{&$Xkt-XGfxcXqYNecJUWK0Mt2#p- z+*uixS22-8ZcVdsG2UWt_Lh#!_`K7&FBZ9O;BcI5lH!=xG9L$5$uH||Gm=`@er*xn zog&13b>^m<`?Yo_&R4YguI;H=9A>@O2U%I2FkLv^s2)wbIk&Ul;a%3|az!Fp3alHc zOm(+R#oI#oRD>Br*f`w%jm`*u%<;OZV_^CMp>=dGBOC-QmSAAajSO1BHuz=c~quNiXQ&3X#w zjF;yxa%69LMpG%qF&lnF47QPz8<(7TzJ54Tt~CA)O+HVXCpf>qwr|ck;*8Ck&yZ2K z>+U?dC7o%R3GLC&SBU{W*ZHcgVx#M$9_F8~d3toA`WvdH^o~#>%e+C|PvdSIGg9cX zdTOh8N)`_nD-$kVf=6FUN9~u{h~g*n?0YjF(Uf5q+etrI-)(2z-)r)Jax4P&W_9wP z29GDcZRfi|t9gm*#rfRa^1DRMrh?}LL4~&KWugyZ1wN6yn>4NSsm1eZ?2^e^c}4_o z-iq~e{IDlR9_sS+%7IRAM{?LQ9YP|~r79yTAad*h;FxKLj0$7i-} zUTG4wlC5lIX*>~^w>oBPWS1;ak?LpGO26AQ?Sz1N0P9p6KSscGavxGQNhc`EvU_B4L+4;8?hnbu&~XGSTJEZtxEj${GXbf;&o#J#I;! zD$e!gpJ}Kh@bK$>v{0HpJ?8uL=g5cx!`&mAt;bZ>o8G(+YU+m9~xn6V(7H{)P zSd6^I-FGB=ot2XHmbHstaxRT~_fLTs!rf>94&-D4DW8T6lhi-h`a~5?kikLmy z8@VY?203fyNOgVM#!+7|Aqrq3T~od6Tej1=Y--K?-CQf_QNfR$(kDGGg?u+|7uCg2f*MLTaF;^3IZF(4rm^{B+M8=HO4?%CBt>vGcA zPA&OK)%)f&UU_S7ksGL|$*T3aLA}ZC)kKNeTOVv}XSv|V6;Ft_$W0P)Zs(PxmDm{D zWcLPtCs;0!`c-)Xd`Ok)%d)1N=+FSOSrz*)21LQ93xkZ-c5gayUXP9}&i|nA_+jzz z`3vsgGffB{M%)k4 zmNpiW;_$fk>G40U{P zBVmP!Hp2HL-3!gh@5>Ntk6$MH| z<&K{$ORHR+hSZ78B$Awo4!9}VwTda}sl7BAY9gsG(!B*+P9SVHW z&wTTXbKv&Su&HyTtX&mK-xSAMQQ9Lbf0FNm@v)a#VO_s}*(Xr^IBwiy=@qOhqJ0RL zUT>?9ar;z*Zuvz|l5PzW8fm=Wj70{WU$@XVYdAmsaxv;~oJ2yod*sivHVWJuqOZyN z@5q$)6;GDy?cox3dgq8Y=YmB9~}5Xj@eS7lwTROZ^klDx751oqO&4tOzI zdwniM^0(EjG-H0PvWY8_`nXuuj+>`+nm7d3?--roS)Oim)8N{wC%ueTby2=c+=pCV zHeVBYA0q~m=q()*DxGWuE*ay&Tc7^A4?#MbhujIw9n!yaVau%dd_ccc8NMHJLi*$?D&-7l03i73EAh^K6R~sMp zjLd>{LNN!iwJ1x^3WQsoo;?As&2%LkIeGu7g^oIl-t$M}U5F#UO%j5Pwx1NCuM_{g zgjpY2<~>n1yoheuDd@MHLS7Zs6eumR^Z0lwD#0>d6mr@SmthH;m+OJj2AC6 zwO5)ry-w9D8uP7gM3nns+vny(P0$1rWz&7g`Ki)>t^$68_mjlR`g1U;zQ+3Ih2Y=b zMQ^XC>_gOGuM-!0HY;b`y9rhhai0$vZc%k71KQd}nLY%t@oN*r7~&H=^3>FMd}BN; zhm(HJfA_|Jdw*pZdW{sO>2`cs?!&tN>87OEiR_rpg0rHopCPGsig6h8Wv0CKJ7?o* zj(wJFf^U3+F5dCTZ%;h^gT-&*_on!jikELQdakynI?#HMOz&m9aTPb0vFfo)d7`!t zNwf_oV_6KwG4=O;czI?<20dUm53oi0Sjz}-N1pQET|=%P4;H0;4ck!eg%5Yn zKT>@v_qn zi+07=y0z=n$s-&*~-t^=$fnKiR;ZSQUEuDElm8W7mY9V%8Nzf=B*YR|jYB9H2$KbVr61HamTC;)+;h zQF+0XlZQo@7U|Pq-4j{)Nt*T1;s(()NGas7&UbngrQR|`tKm(Dg(-cSB`tI@{CkB9 zosytg?s>E38HsP!M41X(7slMpYSjoV*9iswd%L&`)5{bOt!gaF5jmjMxO|2YgC{TF zyt_D+{3TY&)bdWEeCng>`N8q*H@6I5xqB)?5ruYE-*rUqGO4~PHW&_&N-E<>7iC?H zW&UQnyy8sf#yoxX6PKy&KID4)K4d<*d+O3)!nIlaT0#X@JOll42Zi~PJYL?PQI1Ot zKT44QvubXuSO0vw9K+jSjCy|CN{U!xZh%^SlmBh1&2y>xj*!L%&F}m~c#5~GE1ici zF82Hz_rXt6Y1rt*xTnnz9@+J$lJI`d=DK!$Lnp4j3_Wd7M1``Y-QJ-k}iqv9fmfnTl&-ZyY+V;X>QmUMjHH@>@taI1+d9w)}?No$$QJKfGfs{ifck7n-Mdq}MQA^KyCnL;e^^vd zlv8NZk(@uqI-=|@oYHN-2rh%rtBHB481M~{8GjW!tlTCr!NQ=m9AiY!TgjvGsoiUI z$++9gz~YQtaiOfnu{EdSar#1`k7s8FTsxc;V;85g#1AbSvfC@L;#s(pKX)$FWkri=ZFuzvORCQn|+ zJ3q(h$3sh(Jw7*Y6KjHn%Dm9)FHrAXdn+nMd-M;Y&`Mkj{a7HZCCpsq#zL&1e`m zu&&vM`xtQM`a`biHgVlDB?S5$`sC}}H_n`Q?A#mB6w#}iS3%CV-Fg%3syGr9GFiqI zxs@#~^uzC+#T<_(J2w2XmRFp<5^b2P{i<%M|2kq)*vDj2mK8$Fnv(ZT=Z9$2z1l*J z{t5@axtl|T!6P1ech7}eP4b>Vq>0qMT2GMA6`0q#m7T)kc0MI?$mS-_@cQFOZV8xD zBqn3|M#Veq)N4IGa1Cs0FTddO%~yRb?FPffhW&SkY|r0hM0iS@Luj!y;b(i{b^XNw za`K78WB4B3@QMC9W47;Cy(h#yLT77)ZOsmW`{^gxzBo6qo0W?d@xOxN+4vr@bZ~KR z=8HZXAZvT&C{)!OmzP&LEw4)0)KWWea}H7JaL-ImzB(_U*=Q^1NHXeaTm?PPtcph;X5pr4CL zEI1rt{>D;HH{8<1+a@gJ+Vzhs^c)K%0VWUJE)5f`1~l~U`dFfN-d{bnC|GEAEy1rH zQLp+0eR+Gj*r3tSS}mv-^T8pi0fNMQO)Q;op5kB{xR?}hY?~2x$tEV~{^!rlopW)K zv4}*cDR#4Lq|31^-s>)ds#6KiFGdA&opiBt`$P9@J~@JupSnQtwBRIN(3z)?37@#Z zBHJP7R*D3ruaUk%#@W+(ihu7d>~SxPB;$C*^5E5hSC<)Ej%KbK$bUKA=g%0hz4rzz z_Nm5!YQ)@q$T91cFTL9v7;0=cytfW>AuZ$nG{6)C%f2p+vp>(t(#&=Amw7i~HpVZz zFrJlYuUua3U^|q`=!t*9Jv^9u2i2PmDq5EsTQXgw*3cv>rR2gGx$2|t+U7(3G93?V zop+rhpjG2jNB^{6bd0_ci*;YjFZeJh!Af+7@9o0yw!T^?vS)Vu?4QUseo%(*Lp0h| zlOr*BeZ}6eUTen39{G9{wwOJPH++5swY!CC$#Uibi;d#oZ@T=txY_7t=*Xt?TXg~p ztc$71>j?zn@Lg0gl@MOOm*fjd+m@+Z<_E+_DOQ4@uG9Y2hETlNs7#;laU94=&1O>& zEF^2AK#5x)81tnvYsyN_#l!^PGGhM5hsGUoKsnsuO{9WVuCI810ZteIA~>F4&R7jn zbhNH;2&r^q3V1{mYlteg=w|DAPDnv9Gb}!%6IH3`p8)m9Hx5T0N=+1WWNnbxE0JlW zbxu;GZ&uM@W*{q+g)2Fq%^Glt{1cg%@iW(t-$6>rm2ZxlR)EG@;jPFoN}wQgtGf}} zI-dM+d02V82T>#0J36#GP-6L@T+ag*hv4sqMcet=xpZ|I=z&uM;$ji7&bJR-zup}O zE1E&wO1rY_fCyFR_wLo^^;c%5+x5BdxG->aTH|To^Kf2Y+qRf7v7f2q1vOq_m2Yxh zJ}Z4MD<>(y=s=Td2bEKFnSv8W+;+E+jbl}BSAnNB+9$lM|DY02HYU-4y2DU=G(C9l z!$twibnQ#f*{~*hBkY!)8uJZhR33USw(efVZYgHN4Ae`H0bjE<`<9kij+!pl?P#zq zBH@Wfiv;U76pQcdF9cdA$uv=xT~hb{$!rhpQ{unr2p?oFlt_8HE1I5IbiiILC`$3( zbIFBnbq&0|xD>Qn04NsOzp4^>p_f{UeQuJk88rY+9)IA!0>^8AEwTu9k=xsa;}P!X zP#-l?Pe~^4>_hPMt7Bk!JlOlptsrKS;KdZ$34tn>#wgdK^GFkqh4cEyUwn;bxPA&k zFMoC$x~m9ma1pGgpP)CW1sSdxm}ppbGdkuo+6eX2pHpKK@PPd+%}n%khx~L};k+=n z8qA=+EXdY}^uOfq&V+cQK#}-~cCxWc^oHN7jJ}|9=<}a~U#y40M*Q`E&XB6Ie4^-6 zz|1juq3Zb~2OG$)5_Y!Laqn7AUqbczsxwK!jBK4LaD2NQjbCIkJ@Dkt*3i)1@}-bwi)Q{$lIuy zn<#<-;_P>`MY9szUZ6HbW?cuq2%7{rjTTSr)vll(=1Tk9rFNH_~Whx8QguGRD$)JT2l?EHv#Nh1>W?)vL zl=tp-Zq4x@@2fXOeMi+?Yo<~*cFm#7+T@Gohv2`t27@q8XtMKs@uz_h&?`o&`P$W+uZazSyZS1XyX`(?Hh+38^*eNfXAr(72`9bVmUtu%?-4El zdm!SIo}$;Gpu4s_N@w0iBQYcnupNd?jo9-HwE)jW0cpu50dGL9`wiq?kd+N$$d-&7 zu?FPe8@zy=ESw@zP#mqS5{YtK0&*9F7}4IKh1+gp-G@|MAZPAGqF(PTQ%<9qqLa50 zRg+>sg|P|3m}nZh$z{eL=%XtTNwf8l1oq6lpXXEp)f|+*xLpD8!moKRYx-d^*KJ>) zo0p@EdYrwL<3Ad~-}+y`ezIn=yvn^?@GkIg$J*_J04&E%Z2pLjVw3? zz)PpR0j4|9^Ea(HL3=9_z>HbxP{oeoaB1A|bt-ODE;(V~U*n#}9Q1C$fn3o`UKEeN z?W+=7tGD1}K?)a@AgCZbAb7KJtwb3Z^$OiTPoe_cFzeycD{v8b1(uw7puHU$N^)f0 zKKqcWF4+-WyAXme-Z}fQ_aE(}sva|UCb@6y38RpRZOy$D`k`HZ3OhaMW#8dB2M$U$ zfv#alZj_K6YYLq<)w^)eC)vQLGH>)ZG$>sFV>=Ykzjx;>?0^fhknrn7G^*+VVzM(K z6_skVh^AD+S2;kXz4chBwd3w_3OjRHs_`0KYUT+#J_?Y zkK(4l`ucwm0>E%N;;0^BxD!Zw9al3UDV!3G|{ zCFK8dQTqQ(8HmBoYXxBvB#QJ(0(E(MOW=5=wV$ZDy9fS4%&7Cjch@s?VX^T zwRt;)6{BX*=bMR(VZCJB#y@5TIoh-3*6D*zN_Dmd?cTxPS#U%rxJdH5=@4OZhd?=v zVMg_#{_)@Tw?GM8m^z5HTmLw4R6qn(B&VQ?UFIJV)tfWK>98;|u7b*f>R8Wy{a*%G zx;e6zB}zuv#Id8=xHMk)IyK%qx z(ME}Kx)I3rzkapN#ZL~m1Rk~Zt7L>zG=H;~EO-ZCnf{-gl4LLZ;y&c{)@#)y4{Ct< zeGA}t5wXo$Uu$)2>v;8M68Yl){}Tme2YfT|zHk3{ALq7slJelz>IU=OT8ssnlk^bP z^{)u+R4}JoUpIqt6sqdZbNaZEsP?1}fnh7AUdy4x-mrLL(GoRwk_O>;g|#iOz1rMG zN+XE;v+w6p`{S1NCRAH*#{LL&2Ff-9D3tz%LV_%)3JMw1x2W;{Z_qG66mqCmst%*a z#1}zW~nYj zyU(juSaXtmuvjcPZe2q@aZBD4a~niOH$2$V#I)4-w~Jm2aK;lx ztpFEsII%eH57b-^YOYYkjU9FYZ)TxMmV>}v)Nq_6 ze*RQ@oZv|G-Fek_Zk&$PF_9`7MI;;DuF3;t0432>=>N z`l9g-fKm^_57mb_-zeY5ufgXIeA1FS-WP&(`MBtc?U4AL3g1C>DCo&lH=&5clO{lb zNME4(0w4-HT6AFd5sY~NVP3Te!F7;h{KYega@vK8gc}tJncty=Wv#$&ZhU8;Wq@-! zbUPaPkAvu|O08{!u6sZdmK}#+1w#}jcsfs$ms`sMk)EhM4XUl;%SlmLz0NiITKxvnA&QIJBU)KR*$##1A!_`8cXWkr_blV4tsqeSW{>G}Y(~ zI?Y0HvFe=BPuQL2&Oi{n8A5}D!4U@az*Gm|!SRxP;Pbief6xRjRtTa4PT_M-7dLhY z+pb8zM-Tf&K29Ct^Ux6$r$N;D5BlwZ*vI{t^<)JAwO`%+jQpmNjTO}BB#NL1jogsk z9UnPJ-P}i50H#f<3Yq%1wR*>!f+#$lGs^wlL=;8;A!BWTU`vs%1 zERU?$3a)`t0rxX8+fhgm{Ma#M6Bw!WD)7MnBn|)tBz*?Rso8neL{;&=UUED5J^2`# z>_i7H`%I0pX$3s8sJ*l?nSOgA0(g~^Z>#pN_aIiyXrQ7L004_`Z~fP*H5}e+F0N|4 zBssgvi0X1cK({tQRByyf^nv{7VA^2`5_p)Am)>gyM2>YkC*N*OPa5g~7pfWr$`y7f zi4B0<-MCoD0dzoQ1FK&FR*zsl5KNBf`VzB!d};CTyki?jgjK6nc+muJIFK(W3W3g0 zA4WNXB@LijOsS~c0YT@mG1++${^fTTc_Zt!Wmtb$Ta7GyPU0Tuhr$#Xdba-T#>gf3 z+66Fi@!W@~2rGi!s|(O`8>Zw7=E8!C^}u1O;sFYVvr#}R>;DYw`D+4X1&c7tfUtgS zo4L5}@X7%dFd8wqz63Uk0&(dte%;Y@ba0XD7;{rvE~pli-7)g=qklAllXM?if?W7N zNdxvFsRJIGxX!>+0cKgKe^qc)73)Wn+X_$zf_k+{uv!`j_FJzdI!LX`r=%&@AS1H6p{c4w>1^yEW{>5y&5}Kn`{O67ZO?KCA!1J2&*F*Ww+)F;MIV1pR8Tu z#3~VOE$PfzGZ_0@F;C`LN!<7TUp}J@PHq`U`gn1>t^!r-%@7q|OAIdDJ7adj`SHjL z(GDMlmXPM#e@^05+~MX#_zP9o&?75^(Nn^sY_A_u&@#61K3Y$cYrV!FH#drASG||L zgam@c7LE5I2KhUqodccx_eBbJyR=z!9>0F*-XKE4&YtjZDsbGob!y&pWiE1O?$4?v zO*!4UYW}AP|9Wm`2ke;TUbeo#&*|??A8a|=5hLaEzUc`BvCdc~zv9d+4OZJPv~~do z{HLv1RGrG~>!FKaa)L3aFhU)fw%$vwWm5NuuH&T)IXv-I*vQrVbAE?;!O0m-+K$^> zCx1Wm4#lzxwv4MBB@1I(rqPtA8!suv6dJWt-o37VJo1@u;5($CD3HGze`gCbB-cXC zHJ9}#1FN>37?P0=pKRR%Y&VX@Wto~ zV~|Q2Lx;g6UD1Vxrnz}$=Z3GmGE^7z#$o35uZGZ_49eqUbkB0W>1fhGHCb;6rl-%o z1!sy#fnQet{e_s-bU^03Ae`J z_(w-FQH{_6#KN%f?ib1u<>6}vO;)2rS~@tvRF7V~PHoKfIEoo+lx0r@gp@?g<}fOO zM#J^b9#V&YV#la;0FK)ZkA`lBP->ua8?*(=r8Fvy#T?M$dA*t}G)49P4r-2|>EWf9MKJM${yxXr5#4=E%-`at3A}8xFDTPu3MWm0v-35(=A%;LjBiC%#ig-wEzK^lvyAq~vde?LARJ>-&?fZrlh8O{bI5ke|XtJ_W z>43i@rNR}3Be748X&{mDv@PL6>4#HvLDSpnnykiJ?mABPL#ww_f? zD50P1Y?!&l_7KX{IG65gL23EWxFh4NK)y5rmP#=vFQqpS5fL(?q8Wbn4SZk+pfqF7&`HXhbT7Xu87Jk^b^kjheWAf+=rB8qjnT^Hv>&68q$HTz)>|u zFTIUb#23u<9 Date: Fri, 14 Aug 2026 00:32:05 -0400 Subject: [PATCH 8/9] Cleanup docs/scripts --- .claude/launch.json | 19 -- docs/EXPANSION.md | 279 --------------------------- docs/ROSTER-II.md | 354 ----------------------------------- docs/ROSTER.md | 416 ----------------------------------------- docs/VFX_API.md | 445 -------------------------------------------- docs/VFX_API_II.md | 172 ----------------- 6 files changed, 1685 deletions(-) delete mode 100644 .claude/launch.json delete mode 100644 docs/EXPANSION.md delete mode 100644 docs/ROSTER-II.md delete mode 100644 docs/ROSTER.md delete mode 100644 docs/VFX_API.md delete mode 100644 docs/VFX_API_II.md 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/docs/EXPANSION.md b/docs/EXPANSION.md deleted file mode 100644 index 6291e18..0000000 --- a/docs/EXPANSION.md +++ /dev/null @@ -1,279 +0,0 @@ -# Expansion — engineering contracts - -How the sandbox grows from six abilities to fifty without any file becoming unmaintainable. - -Read this before touching anything. Then read `docs/ROSTER.md` for what the forty-four new -abilities actually are. - ---- - -## 0 · The invariants (non-negotiable) - -These are the rules the original six were built under. Every new ability obeys them. - -**I1 — No dimensions on the CPU.** A cast may capture *unitless dice rolls* (`0..1` fractions, -signs, seeds) and *timestamps* (the moment an event fired). It may not capture a metre, a radian, -a second, or a colour. Everything with a unit is resolved against `settings[id]` inside the update -loop, every frame, including a zero-length frame. The test: pause with **P** mid-cast, drag a -slider, and the standing effect must change. - -**I2 — Nothing is a texture.** No sprite sheets, no gradient ramps, no decal images. SDFs, noise, -parametric paths and procedural geometry only. The two exceptions already in the repo (the floor -material and the character's diffuse map) stay exceptions. - -**I3 — Nothing allocates during a cast.** `spawn()` must fully reset state; `destroy()` must leave -the instance reusable. Per-frame code uses module-scope scratch objects (`const _pos = new -Vector3()` at the top of the file, reused). No `new`, no array literals, no object literals, no -closures created inside `onTravel` / `onFade` / `update`. - -**I4 — Draw calls are counted.** An ability draws in single digits. Instancing, not iteration: if -you need forty of something, that is one `InstancedMesh` with per-instance attributes, or one -instanced strip whose vertex shader places every copy. - -**I5 — Every value is authored.** Every dimension is a slider, every colour is a picker, and no -colour is derived from another. A particle system gets a four-stop lifetime gradient -(`colorXA/B/C/D`). The only values that may be shared are ones where the sharing *is* the design -(`zoneRadius` driving five consumers at once). - -**I6 — Guard the light pool.** `ctx.lights.acquire()` returns `null` when the six dynamic lights -are spoken for. Every use of the handle is guarded. An ability that wants two lights must work -with one. - -**I7 — Budget.** A single cast targets ≤ 12 draw calls, ≤ 1500 live particles, ≤ 1 dynamic light -(2 only if the ability genuinely cannot read without it). The manager caps at 4 concurrent casts. - ---- - -## 1 · Registry — abilities are data - -`src/abilities/registry.js` is the single place an ability is declared. Nothing else in the -project enumerates abilities. - -```js -export const ABILITIES = [ - { - id: 'rime', // settings key, particle-system prefix, glyph key - label: 'Rimewalker', // shown in the HUD and the editor folder - school: 'frost', // groups the spellbook and the editor - accent: '#7ecbe0', // slot colour - cast: CastShape.LINE, // or CastShape.ZONE - blurb: 'Sheet ice glazes the floor and peels up behind the front.', - load: () => import('./frost/RimeAbility.js').then((m) => m.RimeAbility), - settings: () => import('../config/abilities/rime.js').then((m) => m.rime) - }, - ... -]; -``` - -- **`load` is lazy.** Fifty ability classes are not constructed at boot. `AbilityManager` builds a - pool for an id the first time that id is selected or cast, and warms its shaders then. -- **`settings` is not lazy.** Every settings block is registered at module load, because the - editor, the preset system and the aim controller all need the full tree up front. Settings - modules are pure data with no imports beyond constants — they must stay cheap. -- `ELEMENTS` and `ELEMENT_META` remain exported from `config/settings.js` as **derived views** over - this registry so existing code keeps working. - -**Schools** are declared once: - -```js -export const SCHOOLS = [ - { 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' } -]; -``` - -The six that shipped keep their ids (`ice`, `thunder`, `meteor`, `beam`, `snare`, `glacier`) and -join schools `frost`, `storm`, `flame`, `arcane`, `storm`, `frost` respectively. - ---- - -## 2 · Settings — one module per ability - -`config/settings.js` stays the source of truth for `global`, `aim`, `zone`, `environment`, `post`, -`camera`, `character` — and stops holding ability blocks. Each ability owns -`src/config/abilities/.js`: - -```js -/** Rimewalker — sheet ice. */ -export const rime = { - /* --- the cast --- */ - range: 18, minRange: 2, speed: 26, cooldown: 0.9, castAnim: 'cast3', - ... -}; - -/** Editor layout: which folders exist and what goes in them. */ -export const rimeSchema = { - 'The cast': ['range', 'minRange', 'speed', 'cooldown', 'castAnim'], - 'The plates': [['plateSize', 0.1, 3, 0.01, 'plate size'], ...], - ... -}; -``` - -`settings.js` imports every module and composes the tree, so `settings.rime` works exactly as -`settings.ice` does today and `applySettings` / `snapshotSettings` / presets need no change. - -**Schema entries** are either a bare key (the editor infers a sensible range from the default and -the key name) or a tuple `[key, min, max, step, label]`. Colours are detected by value (`#rrggbb`) -and get a colour picker. A key ending `A`/`B`/`C`/`D` on a common prefix is offered as a gradient -group. Keys not mentioned in the schema still appear, in a trailing "More" folder — so a schema -is never wrong, only incomplete. - ---- - -## 3 · Editor — schema-driven - -`ui/Editor.js` keeps its hand-written folders for `Global`, `Aim`, `Zone`, `Environment`, `Post`, -`Camera`, `Character` and drops the six per-ability `_buildX()` methods. In their place: - -- one top-level folder per **school**, containing one folder per ability, built from the schema; -- a **search box** that filters controllers by label across the whole tree; -- a **"jump to selected ability"** button, because scrolling past fifty folders is not a UI. - -`Editor.range()`, `Editor.gradient()` and `Editor.castAnimation()` stay as the building blocks the -generic builder calls. - ---- - -## 4 · HUD — the spellbook - -Fifty abilities do not fit on a bar and do not fit on a keyboard. - -- **Loadout bar** — eight slots bound to `Q W E R F V X C`… (the existing six keep their letters: - `Q` ice, `E` thunder, `R` meteor, `F` beam, `V` snare, `X` glacier). Digits `1..8` mirror them. -- **Spellbook** — `Tab` (or `B`) opens a full-screen grid: nine school columns, every ability as a - card with its sigil, name, cast shape and blurb. Click a card to **cast-select** it immediately; - drag it (or shift-click) onto a loadout slot to bind it. Type to filter. -- **Loadout persistence** — the binding is saved to `localStorage` alongside presets. -- The spellbook is DOM, like the rest of the HUD. No framework. - -`ui/glyphs.js` grows to fifty sigils, split into `ui/glyphs/.js` modules re-exported from -one index. Same contract: a 100×100 stroke-only SVG inheriting `currentColor`. - ---- - -## 5 · The tech library - -Fourteen shared modules under `src/vfx/`. They exist so an ability is *configuration plus beats* -rather than a new renderer. Each ships with a doc comment stating: what it draws, its draw-call -cost, what it reads from settings, and the one rule for using it well. - -Every one of them obeys **I1** — they take a live settings object and re-resolve from it each -frame; they never snapshot. - -| module | what it is | -| --- | --- | -| `vfx/GrowthField.js` | Instanced procedural geometry erupting along a line or across a zone, with per-instance birth flash, stagger, height curve, lean, clumping. Generalises `IceAbility`'s crystal field. Takes a geometry *factory* + a material, hashes its shape params and rebuilds when they change. | -| `vfx/FilamentPaths.js` | The instanced ribbon strip with pluggable parametric paths, generalising `LightningMaterial` + `SnareMaterial`. Built-in path modes: `LINE`, `HELIX`, `ORBIT`, `MEANDER`, `RIM`, `CHAIN`, `LINK`, `SPIRAL_IN`, `CRACK`. Roles are selected in the vertex shader by instance index against live counts. | -| `vfx/VolumeHull.js` | Raymarched volume inside a proxy hull, generalising `VolumetricFireMaterial`. Hulls: `BOX`, `CYLINDER`, `CONE`, `DOME`, `SPHERE`. Media presets: `FLAME`, `SMOKE`, `ASH`, `SPORE`, `SAND`, `MIST`, `GAS_BOIL`, `VOID`. Step count, jitter and density curve are sliders. | -| `vfx/GroundField.js` | A ground quad whose fragment shader works in **metres from the anchor**, with a mode enum. Generalises the snare's field and the aim/zone indicators. Modes: `PLATE`, `RUNE`, `POCK`, `RUT`, `WET`, `PUSTULE`, `FUNNEL`, `SCOUR`, `LATTICE`, `POOL`. Re-scales live; never captures its radius. | -| `vfx/Tube.js` | The parametric tube, generalising `BeamMaterial`. Path modes `STRAIGHT`, `WHIP`, `FUNNEL`, `VINE`, `ARC`. Three-layer core/sheath/halo weighting is kept — it is the reason the beam reads as solid. | -| `vfx/Shell.js` | Expanding shells, domes, cones and ring trains. Extends `BurstSphere` with new modes rather than replacing it. | -| `vfx/Projectile.js` | One or many travelling bodies with position/orientation from a parametric flight (`LINE`, `ARC`, `ROLL`, `FALL`, `HOMING`, `LISSAJOUS`), each optionally carrying a `RibbonTrail`. Staggered arrival times come from a spatial hash so a zone fills deterministically per seed. | -| `vfx/Distortion.js` | **Writes to the distortion buffer, which nothing currently uses.** Emitters: `HEAT`, `LENS` (radial 1/r² gravity lens), `SHOCK` (travelling ring), `BLADE` (thin plane), `REFRACT`. This closes a documented rough edge and is the single biggest new capability in the expansion — several abilities are mostly made of it. | -| `vfx/ShatterField.js` | Instanced fragments that inherit a velocity and tumble, with an optional screen-space sample so a shard can show the scene behind it. Used for breaking ice, glass, prisms and time. | -| `vfx/Swarm.js` | Instanced agents on a shader-evaluated flock: cohere to a moving lead, separate on a hash lattice, bank into turns. Silhouettes: `BIRD`, `LEAF`, `CARD`, `DROPLET`, `MOTE`. | -| `vfx/ArcNetwork.js` | Node graph + segment lighting for chained discharges. Nodes are unitless scatter fractions; every metre resolves per frame. | -| `vfx/Portal.js` | A disc/slit with a parallax interior, a fracture rim and a depth-correct edge. | -| `vfx/LiquidSurface.js` | A live heightfield plane with flow-mapped crust, used for lava, blood and water. Ripples are injected by impacts and decay analytically. | -| `vfx/Curtain.js` | Vertical sheets with a travelling vertex ripple and a height-dependent emission curve. Rain, aurora, light shafts. | - -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. The library is a floor, not a ceiling. - ---- - -## 6 · The ability template - -```js -import { Vector3 } from 'three'; -import { Ability } from '../Ability.js'; -import { settings } from '../../config/settings.js'; -import { getColor } from '../../utils/color.js'; -import { frame } from '../../core/FrameUniforms.js'; - -const _pos = new Vector3(); // module-scope scratch — I3 -const _emit = {}; - -export class RimeAbility extends Ability { - constructor(context) { super('rime', context); } - - createShaders() { /* build meshes + materials once */ } - createParticles() { /* ctx.particles.get('rime.mist', {...}) */ } - - get impactDuration() { return settings.rime.lifetime * settings.global.lifetime; } - get fadeDuration() { return settings.rime.fadeTime; } - get instanceCount() { return this._live; } - - onSpawn() { /* reset emitters, roll the seed, sync once */ } - onTravel(dt) { /* resolve everything from settings, emit, sync */ } - onImpact() { /* the one-shot */ } - onFade(dt, t) { /* t: 0..1 hold, 1..2 blow-out */ } - onDestroy() { /* release; leave reusable */ } - dispose() { /* geometry + material dispose; super.dispose() */ } -} -``` - -Particle system names are namespaced `"."` so systems are shared per ability across -pooled instances and never collide. - ---- - -## 7 · Verification — the headless harness - -`npm run check` runs `scripts/check.mjs` in Node. It does not need WebGL: three.js constructs -geometry, materials and scene graphs on the CPU, and every VFX system in this project defers GL to -render time. - -The harness: - -1. imports the registry and **every** settings module and ability class; -2. asserts each block has `range`, `minRange`, `speed`, `cooldown`, `castAnim`, plus `zoneRadius` - for `CastShape.ZONE`; -3. **statically cross-checks every `settings..` / `c.` reference in an ability file - against its settings block** — the single most common failure mode when writing these, and the - one that produces silent `NaN` geometry rather than an error; -4. constructs each ability against a mock context (real `ParticleEngine`, `DecalSystem`, - `BurstSystem`, `FissureSystem`, `LightPool` on a bare `Scene`; stub `shake` / `flash`); -5. spawns it, ticks 240 frames at 1/60 across all phases, and fails on any exception, any `NaN` in - a written attribute, any position beyond a sanity radius, and any allocation-shaped mistake it - can see; -6. re-runs the tick loop **with `dt = 0`** after mutating a random slider, and fails if nothing in - the ability's uniforms changed — a direct test of invariant **I1**; -7. checks the glyph map and the editor schema cover every registered id. - -`npm run check` must pass before any ability is considered done. `npm run build` must also pass. - ---- - -## 8 · Performance at fifty - -- Ability classes and their pools are **lazy** — nothing is constructed until an id is selected. -- `renderer.compileAsync()` at boot only sees what is in the scene; selecting an ability for the - first time warms it asynchronously behind the HUD's cooldown sweep. -- Particle systems are created on first use by `ParticleEngine.get()` and shared. Fifty abilities - × four systems each would be two hundred instanced meshes if they were all built at boot; they - are not, and abilities should reuse a shared system (`shared.smoke`, `shared.dust`, - `shared.sparks`) wherever the look does not need its own gradient. -- The 4-cast concurrency cap and the 6-light pool are unchanged. - ---- - -## 9 · Definition of done, per ability - -1. `src/abilities//Ability.js` — the class, with a doc comment that explains **the - trick** the way the existing six do. -2. `src/config/abilities/.js` — settings block + editor schema. -3. A sigil in `src/ui/glyphs/.js`. -4. A registry entry. -5. `npm run check` passes, including the paused-slider test. -6. The README's roster table has a row for it. - -An ability that renders but violates **I1** is not done. An ability that looks like another slot -with different colours is not done either — see the "THE TRICK" line in its roster entry. diff --git a/docs/ROSTER-II.md b/docs/ROSTER-II.md deleted file mode 100644 index 77c17d1..0000000 --- a/docs/ROSTER-II.md +++ /dev/null @@ -1,354 +0,0 @@ -# The Roster, part II — 50 more abilities, six new schools - -The sandbox has fifty. This document specifies fifty more, taking it to **one hundred abilities -across fifteen schools**. - -The rules from `docs/ROSTER.md` still hold, unchanged — no dimensions on the CPU, nothing is a -texture, one trick per ability, every value a slider. What changes is the *territory*. The first -ninety-odd tricks were built out of geometry, noise and parametric paths. These six new schools -were chosen because each one forces a rendering technique the sandbox has never had to do. - -| school | the territory it forces open | -| --- | --- | -| **Tide** | **Caustics.** A scene with a floor and no caustics is leaving the best free effect in graphics on the table. Water here lights the ground *through* itself. | -| **Forge** | **Hard surface.** Everything in the sandbox so far is crystal, rock or organic. Nothing is *machined* — no involute gear teeth, no brushed anisotropic specular, no blackbody cooling curve. | -| **Lumen** | **Light that behaves like light.** Volumetric shafts with lit dust inside them, occlusion-tested anamorphic flares, and — the big one — abilities that *move the scene's key light*, so every object in the world swings a real shadow. | -| **Ink** | **The anti-glow school.** Flat, matte, high-contrast black and vermilion, no bloom anywhere. Brush dynamics, wet diffusion with a real fingering instability, and paper that folds. In a sandbox this addicted to emission, restraint is the novelty. | -| **Chrono** | **Time as a driver.** Recording and replaying the caster, clamping other systems' clocks inside a radius, and running the same shaders with a negative time step. | -| **Hive** | **Emergence.** Swarms that resolve into silhouettes because their agents target positions sampled from a signed distance field, and structures that grow on a real lattice. | - -Fourteen more abilities extend the nine existing schools, chosen only where they bring a technique -the school does not already have. - ---- - -# X · TIDE -*Deep teal through aqua. Wet, heavy, and lit from below.* - -## `tiderush` · Tiderush - tide · LINE · LiquidSurface(WAVE) + Caustics - THE TRICK — caustics. Light refracted through the wave's body plays on the floor *ahead of the - wave*, so you read the water's thickness from the pattern on the ground rather than from the - surface. The caustic net has to be driven by the same heightfield that draws the wave, or it is - a decal and everyone can tell. - -## `undertow` · Undertow - tide · ZONE · LiquidSurface + Caustics + Swarm(debris) - THE TRICK — one flow field, three consumers. A real logarithmic spiral drives the surface, the - caustics and the debris together; drag the swirl and all three answer. Debris orbits inward and - is pulled under, not deleted — it goes *below* the surface and is seen through it. - -## `brinelock` · Brinelock - tide · LINE · LiquidSurface → GrowthField(splash) - THE TRICK — the state change. A splash crown is thrown up as real water and then *stops*, - mid-air, as glass-clear ice in exactly the silhouette the water had. The handover has to - preserve the shape frame-for-frame; if the ice is a different splash the whole idea collapses. - -## `geyser` · Geyser - tide · ZONE · Tube(FUNNEL, inverted) + Projectile(FALL) - THE TRICK — the column becomes the rain. The water that went up is the water that comes down: - droplets are seeded from the column's own surface at the moment it loses pressure, inherit its - velocity, and each one lands with its own small splash and ripple into the pool below. - -## `bubblecage` · Abyssal Cage - tide · ZONE · Shell + thin-film interference - THE TRICK — thin-film interference. Soap-film colour is a real optical effect: the hue comes - from the film's *thickness* against the view angle, not from a gradient. Implement the actual - interference term. Nothing else in the sandbox derives colour from physics, and it looks - unmistakably different from everything that fakes iridescence with a fresnel ramp. - -## `torrent` · Torrent - tide · LINE · Tube + deflection spray - THE TRICK — deflection. The jet hits the floor and the spray fans out *along the surface*, in - the plane of the impact, at the reflected angle — not as a radial puff. A cutting jet reads as - pressure only if the spray knows which way the surface is facing. - ---- - -# XI · FORGE -*Steel, scale and heat. Orange on grey.* - -## `anvilfall` · Anvilfall - forge · ZONE · HardSurface + Shell - THE TRICK — mass. A machined anvil — bevels, fillets, a flat working face, brushed anisotropic - specular — falls, and everything about the impact is about weight: the floor dishes under it, - the shock is low and slow rather than bright and fast, and it *stays there* after. - -## `sawline` · Sawline - forge · LINE · HardSurface(blade) + Projectile - THE TRICK — grinding sparks. Sparks leave at the blade's contact tangent, in the direction the - tooth was travelling, at a speed derived from the rim velocity. A saw that throws sparks - radially is a firework; the tangent is the whole read. - -## `pistondrive` · Piston Drive - forge · LINE · HardSurface(piston) + GroundField - THE TRICK — a cam curve. Pistons do not ease. They dwell, snap, and dwell again: the motion - profile is a real cam, exposed as an editable curve, and the sequence down the line is a phase - offset on it. Smooth easing here would make them read as rising rock. - -## `gearlock` · Gearlock - forge · ZONE · HardSurface(involute gear) - THE TRICK — the teeth actually mesh. Involute tooth profiles, and each gear's angular rate is - derived from its tooth count against its neighbour's, so the train genuinely interlocks and - stays interlocked when you drag the ratios. Teeth that pass through each other is the failure - everybody ships. - -## `quench` · Quench - forge · ZONE · HardSurface + VolumeHull(steam) + blackbody ramp - THE TRICK — the cooling curve is physical. White → yellow → orange → cherry → black along a real - blackbody locus rather than an authored gradient, so the metal cools the way metal cools. The - steam is the loud part; the colour ramp is why it is convincing. - -## `shrapnel` · Shrapnel Bloom - forge · ZONE · ShatterField + ricochet - THE TRICK — ricochet. Machined fragments hit the floor and *bounce*, reflecting about the - surface normal with a restitution slider and a tumble that survives the bounce. Fragments that - stop dead on contact is the tell that separates a shatter from a burst. - ---- - -# XII · LUMEN -*Warm white and gold. The school where light is the object.* - -## `godspear` · Godspear - lumen · LINE · LightShaft + Caustics - THE TRICK — real in-scattering. A volumetric shaft with dust genuinely lit inside it, sweeping - down the line, throwing a moving bright band on the floor where it lands. The dust in the shaft - must be the scene's own dust motes, brightened as they pass through it — not a second system. - -## `solarlens` · Solar Lens - lumen · ZONE · LensFlare + Caustics(focus) - THE TRICK — an occlusion-tested flare. A floating lens focuses light to a burning point that - walks across the floor; the anamorphic flare anchored to it is depth-tested against the scene, - so it dims when the character passes in front of it. A flare that ignores occlusion is a - sticker on the lens. - -## `refractcascade` · Refraction Cascade - lumen · LINE · Mirror + Tube - THE TRICK — the mirrors reflect the actual scene. A beam bounces between floating mirrors down - the line, and each mirror shows a real reflection of what is behind the camera. Fake it with an - environment map and it reads as chrome; do it properly and the mirrors read as glass. - -## `dawnbreak` · Dawnbreak - lumen · ZONE · SceneHooks(key light) - THE TRICK — the ability moves the sun. It takes hold of the scene's directional light and - swings it from the horizon to overhead and back, so *every object in the world* — the character, - the crystals of a Frost Lance still standing, the floor's own relief — throws a real shadow that - sweeps across the ground. No other ability changes the world; this one does, and it must put it - back exactly. - -## `eclipse` · Eclipse - lumen · ZONE · Shell + SceneHooks(grade) - THE TRICK — the light goes wrong before the disc appears. Colour drains toward the umbra, the - key light cools and dims, and only then does a black disc with a corona of filaments open. It is - the Thunderclap lesson applied to light: the anticipation carries it. - -## `photonlattice` · Photon Lattice - lumen · ZONE · Tube(instanced grid) - THE TRICK — the nodes are bright because they overlap. A three-dimensional grid of thin beams - with additive blending; the intersections are brighter for the only honest reason, which is that - two beams are being added there. Nothing is drawn at the nodes at all. - ---- - -# XIII · INK -*Black, bone-white and vermilion. Matte. No bloom anywhere in this school.* - -## `sumistroke` · Sumi Stroke - ink · LINE · BrushStroke - THE TRICK — dry brush. One enormous brushstroke down the line, with real brush dynamics: it - loads heavy, thins under pressure, and *runs out* — breaking into separated fibre streaks at the - end of the stroke. The ragged tail is the whole ability, and it must come from a bristle model, - not from a noise mask over an even stroke. - -## `inkbloom` · Ink Bloom - ink · ZONE · InkDiffusion - THE TRICK — a fingering instability. Ink spreading in water does not grow as a disc; it grows - as branching fingers, because the interface is unstable. Implement the instability (a - Saffman–Taylor-ish threshold on a diffusing field) and the pattern generates itself, differently - every cast, from one seed. - -## `origami` · Paper Storm - ink · LINE · FoldMesh + Swarm - THE TRICK — real folding. Cranes fly downrange and *unfold* into flat sheets: a creased mesh - whose fold angles are driven from one parameter, so the whole flock opens on one slider. Paper - must never stretch — the creases have to be isometric or it reads as rubber. - -## `sealscript` · Seal Script - ink · ZONE · BrushStroke(vertical) - THE TRICK — legible brush weight in three dimensions. A column of characters written - top-to-bottom in the air, each stroke with a real entry, body and exit, and each one drawn in - sequence. It has to still read as writing when you orbit it, which means the strokes need - thickness in the view direction — a flat billboard disappears edge-on and the column vanishes. - -## `splatterbrand` · Splatterbrand - ink · LINE · InkDiffusion + Projectile - THE TRICK — splatter morphology. A flung blob does not make a circle. It makes a directional - main mass, a crown of spikes on the leading edge, and satellite droplets thrown further along - the travel vector, sized by a power law. Get the satellites right and it is unmistakable. - -## `scrollward` · Scrollward - ink · ZONE · FoldMesh(unroll) - THE TRICK — cylindrical unrolling. Scrolls unroll into a standing wall of text; the paper's - curvature is real, tightest at the roll and flattening as it pays out, and the text on it is - correctly foreshortened by that curve as it comes off the spool. - ---- - -# XIV · CHRONO -*Pale amber and bone. Quiet, and slightly wrong.* - -## `echostep` · Echo Step - chrono · LINE · Recorder - THE TRICK — the caster's own recorded motion. Three ghosts of the character run the line, - replaying a transform track captured from the real caster, each delayed further and each fainter. - They are the character, not a proxy; that is what makes it unsettling. - -## `stasisfield` · Stasis Field - chrono · ZONE · TimeControl(clamp) - THE TRICK — it stops *other* effects. Particles inside the radius hold mid-air, another - ability's standing crystals stop growing, a bolt in flight stops guttering. Implemented by - clamping age inside a region rather than by drawing anything, which means the ability is almost - entirely invisible and reads entirely through what it does to everything else. - -## `rewind` · Rewind - chrono · LINE · TimeControl(reverse) - THE TRICK — a negative time step through the same shaders. Debris flies back up, cracks close, - dust gathers. Nothing bespoke is drawn: the ability runs the ordinary vocabulary backwards, and - the reason it works is that every effect in this project is a closed-form function of time. - -## `hourglass` · Hourglass - chrono · ZONE · VolumeHull(SAND) + SceneHooks(gravity) - THE TRICK — the inversion. Sand falls into a cone, the zone flips, and it falls *up* — the same - field, the same grains, one sign changed. The flip has to happen on a beat you can see coming. - -## `afterimage` · Afterimage - chrono · LINE · Recorder(snapshots) - THE TRICK — N frozen copies, all still live. The cast leaves snapshots of itself at intervals, - each held at the age it was taken, and each one still re-resolves from the sliders — so pausing - and dragging reshapes all six frozen moments at once. It is invariant I1 turned into an effect. - -## `entropy` · Entropy Wave - chrono · ZONE · SceneHooks(material aging) - THE TRICK — one parameter ages a material. Rust, dust, moss, pitting and bleaching all driven - from a single 0..1 that sweeps outward across the floor and then retreats. The floor is a real - PBR material, so this is a patch into its shader rather than a decal over it. - ---- - -# XV · HIVE -*Chitin amber and a sick green. Many small things behaving as one.* - -## `locusttide` · Locust Tide - hive · LINE · Colony(SDF-targeted swarm) - THE TRICK — the swarm forms shapes. Agents target positions sampled from a signed distance - field, so the cloud condenses into a fist, a wall and a spear as it travels, then disperses. - The silhouette is emergent — nothing is drawn but insects. - -## `webline` · Web Line - hive · LINE · Colony(graph + membrane) - THE TRICK — the membrane. A web is not just strands: it is strands plus the thin film between - them, which catches light at grazing angles and is invisible head-on. Strands sag under their - own tension against real anchor points, and the film is built from the graph's faces. - -## `hivecolumn` · Hive Column - hive · ZONE · Colony(hex lattice growth) - THE TRICK — growth on a real lattice. Hexagonal cells build outward from a seed, each one - snapping to the lattice its neighbours defined, so the structure is legibly *constructed* rather - than grown. Cells that would overlap are refused, which is what gives it its irregular edge. - -## `waspfunnel` · Wasp Funnel - hive · ZONE · Colony + Tube(FUNNEL) - THE TRICK — density waves. The funnel pulses — bands of higher agent density travel up it, the - way a real swarm surges. The wingbeat is per-agent and out of phase, so the mass shimmers - without anything being animated globally. - -## `carapace` · Carapace - hive · ZONE · Colony(shell tessellation) - THE TRICK — plates that interlock. A Voronoi tessellation on a hemisphere, each cell a chitin - plate that flies in and locks against its neighbours' edges. Because the cells come from one - tessellation, the seams are exact — the dome closes with no gaps and no overlaps. - -## `broodburst` · Broodburst - hive · ZONE · GrowthField + Colony - THE TRICK — swell, then burst, per egg. Each egg has its own timer: it inflates, goes - translucent as it stretches, and splits along a seam. The crawlers that come out follow the - floor rather than flying, which separates this from every other swarm in the sandbox. - ---- - -# Extensions to the nine existing schools - -## `avalanche` · Avalanche — *frost*, LINE - THE TRICK — granular flow. Snow piles, slumps and finds its angle of repose. The front is not a - wave; it is a heap that keeps collapsing forward over itself. - -## `blackice` · Black Ice — *frost*, ZONE - THE TRICK — a planar reflection. A mirror-smooth sheet that reflects the real scene, sharpening - as it freezes. The only true reflective surface in the sandbox. - -## `firewalk` · Firewalk — *flame*, LINE - THE TRICK — footprints. Actual footprint shapes ignite in sequence down the line, each throwing - a short pillar. A recognisable silhouette in a school made entirely of formless heat. - -## `wildfire` · Wildfire — *flame*, ZONE - THE TRICK — propagation as a cellular automaton. Fire spreads cell to cell across the floor, - jumps gaps, leaves unburnt islands and burns back on itself. Not a growing circle: a *front*. - -## `sheetlightning` · Sheet Lightning — *storm*, ZONE - THE TRICK — the shadows strobe. The flash is applied to the scene's key light, so every real - shadow in the world snaps with it. Cheap to do, and the first time it fires it is startling. - -## `obsidian` · Obsidian Bloom — *stone*, ZONE - THE TRICK — conchoidal fracture. Volcanic glass breaks in smooth curved shells, not flat facets. - Generate the geometry that way and it reads as glass at a glance, before the specular even - lands. - -## `mycelium` · Mycelial Web — *verdant*, LINE - THE TRICK — it is under the floor. A fungal network spreads *beneath* the surface and is visible - only as a glow coming up through the stone's own cracks and pores. - -## `unmake` · Unmake — *void*, LINE - THE TRICK — voxel dissolve. Matter comes apart into cubes that drift and wink out, the cube size - growing as the dissolve progresses so the loss accelerates visibly. - -## `silence` · Silence — *void*, ZONE - THE TRICK — a hole in the frame. Inside the zone, nothing renders at all — not black, *absent*. - The most aggressive thing in the sandbox, and it is achieved by drawing less than anything else. - -## `spellbreak` · Spellbreak — *arcane*, ZONE - THE TRICK — it reacts to other casts. The one ability aware of the rest: arcane glass shatters - and any other effect currently standing inside the zone is visibly disrupted — desaturated, - fragmented, and pushed. Cast it into an empty room and it is pretty; cast it into a Nova Beam - and it is an interaction. - -## `astralgate` · Astral Gate — *arcane*, ZONE - THE TRICK — geometry emerging *through* a plane. A ring gate that objects pour out of, correctly - clipped by the portal plane so nothing is ever seen on the wrong side of it. The clip is the - ability; without it, things pop into existence in front of a decal. - -## `bonecage` · Bone Cage — *blood*, ZONE - THE TRICK — a different material entirely. Ribs of dry bone close over the zone: warm - subsurface scatter, chalky micro-roughness, no wetness and no glow. Standing next to Crimson - Tide's viscous red, the contrast is the point. - -## `featherfall` · Featherfall — *aether*, ZONE - THE TRICK — flutter dynamics. Feathers do not fall; they stall, slip sideways, catch, and glide. - A real tumbling model with lift, so no two descend the same way. - -## `mirage` · Mirage — *aether*, LINE - THE TRICK — a silhouette made only of refraction. A duplicate of the caster runs the line, drawn - with nothing but distortion — you see it because the world bends into a human shape, and the - moment it stops moving you lose it. - ---- - -## Totals - -| | schools | abilities | -| --- | --- | --- | -| shipped | 1 (mixed) | 6 | -| roster I | 9 | 44 | -| roster II — new schools | +6 | 36 | -| roster II — extensions | — | 14 | -| **total** | **15** | **100** | diff --git a/docs/ROSTER.md b/docs/ROSTER.md deleted file mode 100644 index e21e489..0000000 --- a/docs/ROSTER.md +++ /dev/null @@ -1,416 +0,0 @@ -# The Roster — 44 new abilities - -The sandbox shipped with six. This document specifies forty-four more, taking the same rules the -first six were built under and pushing them into techniques the original build never used. - -**The rules that carry over, in order of importance:** - -1. **Nothing is a texture.** Every mark on screen is a signed distance field, a noise field, a - parametric path evaluated in a vertex shader, or procedural geometry generated on the CPU. -2. **No dimensions on the CPU.** A cast captures *dice rolls and timestamps only* — unitless - fractions and the moment an event fired. Every metre, radian and second is resolved against - `settings[id]` inside the update loop, which runs on a zero-length frame. Dragging a slider - must reshape an effect that is already standing, with the clock stopped. -3. **One trick per ability.** Each entry below names the single technique that makes it read. If a - new ability is a recolour of an old one it should not exist. Every one of these forty-four - contributes a silhouette, a motion or a rendering trick that no other slot has. -4. **The beats.** `travel → impact → fade`, with an optional wind-up bought by refusing to let - `advance()` leave the caster (this is how Nova Beam gets its fourth beat). -5. **Every colour is a picker, every dimension a slider.** Nothing is derived from another value - unless the derivation *is* the point (the way `snare.zoneRadius` drives five consumers at once). - ---- - -## Reading an entry - -``` -## id · Name - school · cast shape · key tech · beats - THE TRICK — the one thing that makes it read. - - Palette: ... -``` - -`cast shape` is `LINE` (arrow indicator) or `ZONE` (far-cast circle, needs `zoneRadius`). -`key tech` names modules from `docs/EXPANSION.md` §"The tech library". - ---- - -# I · FROST — the school that already exists - -Frost Lance and Glacial Crown ship in the build. These three take the palette somewhere the -crystals cannot. - -## `rime` · Rimewalker - frost · LINE · GroundField + GrowthField(plate) · travel → impact → fade - THE TRICK — the ice is *thin*. Sheets, not spikes: curved plates a few centimetres thick that - peel up off the floor like paper curling off a hot pan, translucent enough to see the plate - behind them, catching the light on their lip and nowhere else. - A freezing front runs the line and the floor glazes over behind it in interlocking plates. As - each plate locks, its downwind edge lifts and curls; by the time the front reaches the target - the path is a corridor of standing shards of sheet ice. It holds, then the curls snap off and - the glaze sublimates from the caster's end forward. - Palette: near-white with a green-blue body tint, `#dff6ff` / `#7ecbe0` / `#123b4a`. - -## `hail` · Hailwrath - frost · ZONE · Projectile(swarm) + GroundField · impact-only, staggered - THE TRICK — there is no travelling front. The front is *vertical*: hailstones arrive out of the - sky on staggered timers keyed off a spatial hash of where they land, so the zone fills from the - boundary inward and no two casts fill it in the same order. - A column of freezing air over the circle, and then it comes down — irregular stones that - stretch as they fall, punch a white pock into the floor, throw chips and bounce once. The rate - ramps up, peaks, and tails off; the pocks stay and slowly rime over. - Palette: `#f2fbff` / `#9fd8ee` / `#2a5d75`. - -## `shatterlance` · Shatterlance - frost · LINE · GrowthField(single) + ShatterField · wind-up → travel → impact → fade - THE TRICK — the wind-up is the ability. A single enormous ice lance assembles in the air out of - converging shards, hangs there long enough for you to see it, then *goes* — and the impact is a - ShatterField: the lance's own geometry breaks into two hundred instanced fragments that inherit - the flight velocity and tumble. - Palette: `#ffffff` / `#8fe3ff` / `#0d2f52`. - ---- - -# II · FLAME - -## `pyroclasm` · Pyroclasm - flame · ZONE · VolumeHull(dome) + Distortion · impact → hold → fade - THE TRICK — a raymarched ash dome that *collapses before it blows*. The volume is sampled in - world space so the ash keeps its grain while the dome contracts, which is what sells the - implosion; then the density inverts and it blasts outward through its own footprint. - Palette: soot to ember, `#1a1210` / `#5c2a10` / `#ff7a2a` / `#ffd9a0`. - -## `dragonbreath` · Wyrm's Breath - flame · LINE · VolumeHull(cone) + GroundField · travel → sustain → fade - THE TRICK — a real cone volume. Not a widening billboard: a raymarched cone whose density - profile is thickest just off-axis and hollow down the middle, so orbiting it shows you the - tongue of flame passing *through* itself. The floor beneath scorches progressively as an SDF - that grows along the cone's ground intersection. - Palette: `#fff2c0` / `#ffb03a` / `#e0400f` / `#2a0a04`. - -## `firewhip` · Ashen Lash - flame · LINE · Tube(whip) + Shell · travel → crack → fade - THE TRICK — whip kinematics. The lash is a tube along a curve whose curvature travels from the - handle to the tip; when the loop reaches the end the tip briefly exceeds the wave speed and a - small shock ring pops off *at that point*, mid-air. The crack is a real event in the geometry, - not a scheduled effect. - Palette: `#ffe9b0` / `#ff8a2a` / `#8a1c05`. - -## `emberflock` · Emberflight - flame · LINE · Swarm + RibbonTrails · travel → impact → fade - THE TRICK — flocking. Twenty-odd ember birds each carry their own trail; they cohere toward a - shared lead point that runs the cast line, separate from each other, and bank into their turns - (roll derived from lateral acceleration). At the target they collapse into one point and go up. - Palette: `#ffd27a` / `#ff6a1f` / `#3d0d04`. - -## `magma` · Magma Fount - flame · ZONE · LiquidSurface + Projectile(blobs) · impact → sustain → cool - THE TRICK — a real molten pool. A live heightfield on the floor with flow-mapped crust: the - black skin cracks along the flow direction and the seams glow, and the crust re-forms where the - surface is slow. Blobs arc out of the middle and land back in it, punching ripples into the - same heightfield. - Palette: `#ffe08a` / `#ff5a12` / `#7a1a04` / `#120806`. - -## `sunspear` · Sunspear - flame · LINE · Projectile(arc) + Distortion + Shell · travel(arc) → impact → fade - THE TRICK — heat shimmer as a first-class effect. The javelin's wake writes into the - distortion buffer, so the floor and the character genuinely warp behind it. On landing it opens - a low sun-disc lying on the ground with corona filaments licking off its rim. - Palette: `#ffffff` / `#ffe07a` / `#ff9a1f`. - ---- - -# III · STORM - -## `chainarc` · Chain Arc - storm · LINE · ArcNetwork · travel(hops) → impact → fade - THE TRICK — graph pathing. The bolt does not travel; it *hops*. A handful of nodes are placed - down the line with lateral scatter, and the discharge lights one segment at a time with a small - burst at each node. Re-rolling the node scatter live re-routes a chain already in the air. - Palette: `#eaf6ff` / `#5fb0ff` / `#0b2f7a`. - -## `thunderclap` · Thunderclap - storm · ZONE · Shell(dome) + Distortion · impact → delayed boom → fade - THE TRICK — the delay. The flash lands, and then nothing for a beat, and *then* the pressure - front arrives as three concentric refraction rings that push through the distortion buffer and - shove the dust. The gap is the whole effect; without it this is just another shockwave. - Palette: `#ffffff` / `#cfe4ff` / `#3f6fd0`. - -## `balllightning` · Fulminant Orb - storm · LINE · Shell + FilamentPaths(orbit) · travel(slow) → impact → fade - THE TRICK — a caged orb. Filaments do not radiate outward, they *orbit* — great slow loops - around a near-invisible shell, occasionally earthing to the floor beneath in a short spike. - It travels slowly enough that you watch it come, which no other storm slot does. - Palette: `#ffffff` / `#9fd0ff` / `#2f3fd0`. - -## `stormwall` · Tempest Wall - storm · LINE · Curtain + FilamentPaths · raise → hold → fall - THE TRICK — geometry perpendicular to the cast. The line you aim is the wall's *normal*, not - its length: a rain curtain rises across your heading, with its own lightning inside it and rain - streaking down its face, and the floor under it goes wet and reflective. - Palette: `#c8dcea` / `#5f7f9a` / `#101c2a`, lightning `#ffffff`. - -## `railcoil` · Railcoil - storm · LINE · Tube + FilamentPaths(helix) · wind-up → instant → decay - THE TRICK — zero travel time. Coils collapse inward along the barrel during the wind-up, and - when they meet the shot is simply *already there* — full length in one frame. Everything after - is decay: an ionisation channel that cools from white through blue to nothing over a second and - a half, sagging and breaking into segments as it dies. - Palette: `#ffffff` / `#a8e0ff` / `#1a4fd0`. - ---- - -# IV · STONE - -## `stonespine` · Stone Spine - stone · LINE · GrowthField(slab) · travel → impact → fade - THE TRICK — plates, not spikes. Flat slabs of floor heave up and tilt like ice floes on a - river, each one hinged along an edge rather than punched straight up, with the dirt underside - visible on the lifted face and rubble sliding off the top. - Palette: `#6b6357` / `#3a352e` / `#141210`. - -## `sinkhole` · Sinkhole - stone · ZONE · GroundField + Projectile(debris) · impact → collapse → settle - THE TRICK — the ground goes *down*. An inverted fissure: the floor quad is displaced into a - funnel, the lip cracks and calves inward, and debris falls into a hole that reads as depth - because its walls are shaded from a fake normal and its floor is never drawn. - Palette: `#4a4239` / `#221e19` / `#0a0908`. - -## `tectonic` · Tectonic Slam - stone · ZONE · Fissure + Shell(ring) · impact → propagate → settle - THE TRICK — the fissures whip *outward on a clock*, five of them racing to the boundary at - different speeds with the dust wave riding just behind each tip, so the footprint is drawn by - motion rather than revealed all at once. - Palette: `#8a7f6b` / `#463f34` / ember `#ff7a2a`. - -## `boulder` · Rolling Ruin - stone · LINE · Projectile(rolling) + GroundField · travel(roll) → impact → fade - THE TRICK — it *rolls*. Real rolling: the boulder's rotation is derived from distance over - radius, so it never skates, and it gouges a rut behind it whose depth follows the contact - force. It shatters on arrival into its own asteroid geometry, sliced. - Palette: `#6e6455` / `#39332b` / dust `#a89880`. - -## `petrify` · Petrifying Gaze - stone · LINE · GrowthField(facet) + VolumeHull(sand) · travel → hold → crumble - THE TRICK — accretion then collapse. Grey facets *accrete out of the air* along the line — - they grow inward toward the axis rather than up out of the floor — hold as a solid column, and - then crumble: the facets shrink to nothing and are replaced, in place, by a raymarched fall of - sand. - Palette: `#9a948a` / `#565049` / `#221f1c`. - ---- - -# V · VERDANT - -## `thornwake` · Thornwake - verdant · LINE · GrowthField(thorn) + FilamentPaths(link) · travel → impact → fade - THE TRICK — the brambles *interlace*. Filaments are drawn between neighbouring thorn instances - — each vine picks two instances by index and threads a sagging curve between them — so the - field reads as one tangled mass rather than as scattered props. - Palette: `#4a6b2a` / `#243d14` / thorn tips `#c8b06a`. - -## `bloomburst` · Bloomburst - verdant · ZONE · GrowthField(petal) + VolumeHull(pollen) · grow → open → burst - THE TRICK — unfurling. Each flower is a ring of petals whose bend parameter runs from fully - closed to fully open, driven per instance off a staggered clock, so the field opens in a wave - across the circle. Then every petal releases at once into a pollen volume. - Palette: `#f2d0e8` / `#a84f8a` / pollen `#ffe89a`. - -## `sporefall` · Sporefall - verdant · ZONE · VolumeHull(slab, low) · seep → hold → disperse - THE TRICK — a volume that *hugs the floor*. The density field is heavily flattened in Y and - advected outward, so it pours across the ground and pools in the middle instead of billowing. - Bioluminescent motes drift up out of it and die at head height. - Palette: `#7ad0a0` / `#2a6b4a` / glow `#c8ff9a`. - -## `vinelash` · Verdant Lash - verdant · LINE · Tube(grow) + Swarm(leaves) · travel(grow) → snap → wither - THE TRICK — it grows rather than flies. The tube's length is the front, its radius tapers to - nothing at the tip, and leaves unfurl along it as it passes. At full extension it *snaps back* - — the whole curve recoils on a spring and the leaves are stripped off. - Palette: `#6ba83a` / `#2a4a18` / `#c8d86a`. - -## `grovecall` · Grovecall - verdant · ZONE · GrowthField(tree) + Curtain(light shaft) · grow → hold → fade - THE TRICK — scale contrast. Six trunks with canopies grow to three metres in half a second on a - wave around the ring, and light shafts fall through the canopy onto the floor as real geometry - — tapered translucent volumes, not a post effect. - Palette: bark `#4a3a28`, leaf `#5f8a2a`, shaft `#e8f0c0`. - ---- - -# VI · VOID - -## `voidrift` · Void Rift - void · LINE · Portal + Distortion · tear → hold → close - THE TRICK — a hole. A slit in space along the line: pure black interior with a parallax - starfield behind it that moves against the camera at the wrong rate, a white-hot fracture rim, - and a distortion ring around the edge that bends the floor into it. - Palette: interior `#000000`, rim `#ffffff` / `#b07aff`. - -## `singularity` · Singularity - void · ZONE · Distortion(lens) + Swarm · form → pull → collapse - THE TRICK — screen-space lensing. The distortion pass, which has been sitting unused, finally - earns its keep: a radial UV displacement whose magnitude goes as 1/r² inside a falloff, so the - floor grid, the character and every particle behind it bend around the well. Everything nearby - spirals in on real angular-momentum paths, and then it inverts. - Palette: `#0a0612` / `#6a3fd0` / event horizon `#ffffff`. - -## `umbralspears` · Umbral Spears - void · LINE · GrowthField(spear) · travel → impact → fade - THE TRICK — anti-glow. In a scene tuned for bloom, these are the only objects that are - *darker* than the floor: near-black spears with a thin violet rim and a soft shadow pooled at - their base, rendered with a rim-only shading model so their silhouette is all you get. - Palette: `#050308` / rim `#8a5fd0`. - -## `nightfall` · Nightfall - void · ZONE · VolumeHull(dome) + GroundField · close → hold → open - THE TRICK — subtractive light. The dome multiplies rather than adds: it genuinely darkens what - is behind it, the floor texture goes to near-black under it, and the only things visible inside - are a slow starfield and the caster's own dynamic light struggling against it. - Palette: `#000000` / `#0a0a18` / stars `#c0d0ff`. - -## `soulchain` · Soul Tether - void · LINE · FilamentPaths(chain) + GrowthField(link) · throw → hold → snap - THE TRICK — discrete links. Not a smooth filament: real chain links, instanced tori threaded - along a sagging catenary, each one rotated 90° from its neighbour and free to swing. The chain - goes taut with a snap and the links jangle. - Palette: ghost-iron `#7a8a9a` / glow `#9affe0`. - ---- - -# VII · ARCANE - -## `runeseal` · Runic Seal - arcane · ZONE · GroundField(glyph SDF) · inscribe → ignite → discharge - THE TRICK — procedural glyphs. Three nested rings of runes drawn as real signed-distance - letterforms in metres — strokes, terminals, counters — that *draw themselves* stroke by stroke - as the seal inscribes, counter-rotate at different rates, and then ignite from the inside out. - This is the showpiece ground shader; it should stand up to being paused and stared at. - Palette: `#ffd27a` / `#ff8a2a` / `#2a1a0a`. - -## `prismlance` · Prism Lance - arcane · LINE · Tube + ShatterField(prism) · charge → fire → split - THE TRICK — dispersion. The beam strikes a floating prism at mid-span and leaves it as a fan of - six coloured child beams that continue to the target at slightly different angles, converging - again at the end. The prism itself is a real refracting solid with an env-mapped surface. - Palette: white in, spectrum out. - -## `chronofracture` · Chronofracture - arcane · ZONE · ShatterField(pane) + Distortion · freeze → hold → shatter - THE TRICK — panes that hold a still image. Sheets of frozen time hang in the air over the zone - sampling the scene behind them through the distortion/refraction path, desaturated and offset, - so each pane shows a *slightly older, colder* version of what is behind it. Then they break. - Palette: `#d0e8ff` / `#6a8ab0` / fracture `#ffffff`. - -## `starfall` · Starfall - arcane · ZONE · Projectile(swarm) + Shell · call → rain → fade - THE TRICK — staggered arrivals with a shared vanishing point. Every star comes from the same - point high above and behind the caster, so their trails converge in the sky and diverge on the - ground — the parallax is what makes the sky feel like it is above you. - Palette: `#ffffff` / `#c0d8ff` / `#3a5fd0`. - -## `arcanevolley` · Arcane Volley - arcane · LINE · Projectile(homing) + RibbonTrails · charge → volley → converge - THE TRICK — parametric homing. Seven bolts leave the hand on Lissajous paths whose amplitude - decays to zero at the target, so they weave apart, cross each other, and all arrive at the same - point at the same instant without any of it being simulated. - Palette: `#e0c0ff` / `#8a5fd0` / `#2a1a4a`. - -## `glyphstorm` · Glyphstorm - arcane · LINE · Swarm(card) + GroundField · gather → storm → disperse - THE TRICK — a blizzard of legible marks. Several hundred instanced quads each rendering one - procedural glyph, camera-facing but edge-on when they turn, so the storm flickers between a - wall of symbols and a scatter of bright lines. - Palette: `#ffe8c0` / `#c08a3a` / `#2a1a0a`. - ---- - -# VIII · BLOOD - -## `crimsontide` · Crimson Tide - blood · LINE · LiquidSurface + Swarm(droplet) · surge → break → drain - THE TRICK — a real wave. A heightfield wave travels the line with a curling crest whose front - face is thin enough to be translucent, breaking into droplets at the lip. It drains back into - the floor leaving wet, reflective stone. - Palette: `#8a0f18` / `#3a050a` / crest `#ff4a4a`. - -## `hemolance` · Hemorrhage - blood · LINE · Projectile(volley) + Shell · volley → impact → pool - THE TRICK — needles. Extremely thin, extremely fast lances that arrive in a ripple rather than - together, each one leaving a hairline mist trail that persists for a moment after the needle - has gone — the trail outlives the projectile, which is what makes the volley read. - Palette: `#c01a28` / `#5a0a10`. - -## `sanguinepact` · Sanguine Pact - blood · ZONE · GroundField + VolumeHull(mist) + FilamentPaths(orbit) · draw → hold → seal - THE TRICK — orbiting droplets. A pool on the floor sends up a slow mist column, and around it - beads of blood climb in real orbits — instanced spheres on inclined ellipses with proper - perspective — that flatten into the ring as the pact seals. - Palette: `#7a0a14` / `#c81a28` / mist `#4a1018`. - -## `plaguebloom` · Plague Bloom - blood · ZONE · VolumeHull(boil) + GroundField(pustule) · seep → boil → burst - THE TRICK — boiling. The gas volume's density is driven by a cellular field whose cells - *inflate and pop* on individual timers, so the cloud has visible internal events rather than - just drifting noise. The floor grows matching pustules that burst in sync. - Palette: `#9aa83a` / `#4a5a18` / `#c8d86a`. - ---- - -# IX · AETHER - -## `cyclone` · Cyclone - aether · ZONE · Tube(funnel) + FilamentPaths(spiral) + Swarm · form → hold → dissipate - THE TRICK — the funnel profile. Radius follows a real vortex profile (tight throat, flared - skirt at the floor, flared mouth at the top) and everything else — the debris ribbons, the dust - skirt, the ground scour — is placed against that one function, so dragging the profile moves - all of it together. - Palette: `#c0c8d0` / `#6a7480` / `#20262c`. - -## `slipstream` · Slipstream - aether · LINE · Distortion(blade) + Tube(thin) · travel → cut → fade - THE TRICK — almost nothing is drawn. A vacuum blade is a plane of pure refraction with a - hairline bright edge; you see it because the world behind it slides, not because it has colour. - The most restrained slot in the sandbox, and the proof that the distortion pass works. - Palette: edge `#ffffff`, everything else is the scene bent. - -## `resonance` · Resonant Chord - aether · LINE · Shell(ring train) + Distortion · strike → ring → decay - THE TRICK — standing waves. Rings travel out along the line at fixed spacing and *reflect* off - the far end, so the outbound and returning trains interfere and you can see the nodes: places - on the line where the air is still and places where it is violently compressed. - Palette: `#d0f0ff` / `#5fa0c0` / `#102030`. - -## `aurora` · Aurora Veil - aether · ZONE · Curtain · rise → ripple → fade - THE TRICK — curtains. Vertical sheets with a vertex-shader ripple travelling along their length - and an emission that falls off with height on a different curve than the alpha, which is the - thing that makes real aurora read as *light in air* rather than as a hanging ribbon. - Palette: `#5fffc0` / `#3a9aff` / `#c05fff`. - -## `skyfracture` · Sky Fracture - aether · LINE · FilamentPaths(crack) + GroundField + Shell · flash → delay → fall - THE TRICK — the reflection. A white fracture opens high above the cast line, and its *shadow - and reflection* appear on the floor before anything else happens — you read the shape on the - ground first, then the sky splits, then the pressure arrives. Reverse-ordered cause and effect. - Palette: `#ffffff` / `#c0d0ff` / `#1a2a4a`. - ---- - -## Slot summary - -| # | school | line | zone | -| --- | --- | --- | --- | -| 3 | frost | rime, shatterlance | hail | -| 6 | flame | dragonbreath, firewhip, emberflock, sunspear | pyroclasm, magma | -| 5 | storm | chainarc, balllightning, stormwall, railcoil | thunderclap | -| 5 | stone | stonespine, boulder, petrify | sinkhole, tectonic | -| 5 | verdant | thornwake, vinelash | bloomburst, sporefall, grovecall | -| 5 | void | voidrift, umbralspears, soulchain | singularity, nightfall | -| 6 | arcane | prismlance, arcanevolley, glyphstorm | runeseal, chronofracture, starfall | -| 4 | blood | crimsontide, hemolance | sanguinepact, plaguebloom | -| 5 | aether | slipstream, resonance, skyfracture | cyclone, aurora | - -**26 line casts, 18 far casts.** Plus the six that shipped: **50 abilities.** diff --git a/docs/VFX_API.md b/docs/VFX_API.md deleted file mode 100644 index 48e50f5..0000000 --- a/docs/VFX_API.md +++ /dev/null @@ -1,445 +0,0 @@ -# The VFX tech library — API reference - -Condensed from `src/vfx/README.md` (which is long, and worth reading for the *why*). Every -signature below was read back off the source by the integration pass. - -**Twenty-six modules under `src/vfx/`** — twenty-four renderers and toolkits plus `quads.js` and -`prefixedBlock.js`, which are shared plumbing. They exist so an ability is **configuration plus -beats** rather than a new renderer. An ability may still write a bespoke material when its trick -genuinely needs one — the library is a floor, not a ceiling. - -Every signature below was **read back off the source**, not taken from a report. The same text is in `src/vfx/README.md` under **API reference**. - -## Conventions — get these wrong and nothing renders - -**Attach.** *Parent-first* `new X(parent, opts)` — module adds its own meshes: `GrowthField`, `ShatterField`, `GroundField`, `FilamentPaths`, `ArcNetwork`, `Projectile`, `Swarm`, `Caustics`, `LightShaft`, `BrushStroke`, `InkDiffusion`, `FoldMesh`, `DissolveField`, `GhostRig`, `ColonySwarm`, `WebGraph`, `LatticeGrowth`, `PlateShell`. *Options-only* `new X(opts)` — you add its node: `Tube`(`.group`), `Shell`(`.group`), `VolumeHull`(`.mesh`), `DistortionField`/`Portal`/`LiquidSurface`/`Curtain`/`LensFlare`/`Mirror`(`.object3D`). *Neither*: `HardSurface` (a toolkit), `Dissolve`'s two patch modes (they compose into your material), `SceneHooks` (a singleton ledger), `timeField` (a singleton pool). - -**Settings.** *Canonical* (`p.key ?? default`, key list from `xxxParams()`): all except — *Prefixed* (`c[keys.x]`, spread `xxxDefaults(prefix,…)`): `Tube`, `Shell`, `VolumeHull`. `SceneHooks` is neither: it is driven by method calls on a borrowed token, because a hook is held across frames by one owner and a params bag has nowhere to put *who is asking*. - -**Clock.** `now` = the ability's `age` in seconds. Exceptions: `ArcNetwork.update(**dt**,…)`; `Swarm`, `Curtain`, `ColonySwarm`, `WebGraph`, `BrushStroke` and `FoldMesh` take `_now` and **ignore it**; `Caustics`, `LightShaft`, `LensFlare` and `Mirror` take **`update(p)`** with no clock at all; `Tube`/`Shell` take time on `state.time`; `VolumeHull.sync(c,g)` reads `frame.uTime` itself. - -**Borrowed globals.** Anything from `sceneHooks.acquire()` or `timeField.acquire()` is a piece of the *world*, not of your group. Take it through **`this.borrow(...)`** and `Ability#destroy()` gives it back however the cast ends. `npm run check` fails an ability that leaks one. - -```js -/* GrowthField.js ─ variants draw calls (3) ─ parent ─ canonical */ -GrowthLayout={LINE:0,ZONE:1}; GrowthEmerge={PUSH:0,SCALE:1}; growthParams()->object -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 at y=0, - // tip y=1. Throws if not a function. material is REQUIRED and yours; dispose() won't touch it. -field.count · drawCalls · meshes · records · material · factory · isFullyTriggered -field.onBreach = (index,position,radius,height)=>{} // assign ONCE at construction (I3) -field.plant(count, clusterShare=0)->number // the only dice roll -field.clear() · syncGeometry(shape)->boolean -field.triggerUpTo(now,limit,stagger,frontBias=1,includeCluster=false) -field.triggerRadial(now,limit,stagger,invert=false,includeCluster=true) -field.triggerAll(now,stagger) · triggerIndex(now,index,delay=0) -field.update(now,p,retract=0) -field.positionOf(index,p,out) · tipOf(index,p,out) · heightOf(index,p) · radiusOf(index,p) -field.emergenceOf(index,now,p) · dispose() -patchGrowthMaterial(material,{environment,uniforms,common,vertex,fragment})->material - // varyings: vGrowLocal, vGrowWorld, vGrowSeed, vGrowBirth · attributes: aSeed, aBirth - // NOTE: there is NO .object3D — it owns `variants` meshes and adds them to parent itself. - -/* ShatterField.js ─ variants draw calls (2) ─ parent ─ canonical */ -ShatterLayout (=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 -s.count · drawCalls · uniforms · material -s.burst(now,count,along=1,lateral=0)->number · clear() · sync(look) · setSceneTexture(tex|null) -s.update(now,p)->live count · positionOf(index,now,p,out) · dispose() - -/* GroundField.js ─ 1 draw call ─ parent ─ canonical */ -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 -f.object3D · drawCalls · mode · marks · markCount -f.setVisible(v) · setAdditive(v) · clearMarks() · update(p) · dispose() -f.mark(x,z,time,strength=1)->Vector4 // x,z are FRACTIONS of the radius, NEVER metres - -/* FilamentPaths.js ─ 2 draw calls ─ parent ─ canonical */ -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 -new FilamentPaths(parent,{samples=72,capacity=48,renderOrder=11,layer=LAYER.VFX}) -p.object3D · drawCalls · liveCount · visible(get/set) · nodeCount -p.role(i)->Role // i in 0..3 -p.setNodeCount(n) · setNode(i,along,lateral,lift) · nodePoint(roleIndex,i,out) -p.sync(look,fade=1,seed=0) // OVERWRITES uCount — set counts EVERY frame -p.clear() · dispose() -role.count=n · retire() · style(kink,width,dim,groundDamp) -role.ends(fadeStart,fadeEnd,taperStart,taperEnd) -role.draw(progress,tipLength,floorY,tipGlow) // progress default 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 ─ 0 extra draw calls when sharing a strip ─ parent ─ canonical */ -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} → chain+spikes+rim total TWO draw calls, not six -n.from · n.to (Vector3, caller writes each frame) · n.onNode=(index,position,count)=>{} -n.reset(seed) · reseed(seed) · update(dt,p,fade=1) // dt, NOT now -n.nodePoint(i,out) · clear() · dispose() -n.paths · object3D · drawCalls · nodeCount · segments · progress · arrived · firedCount · cursor - -/* Projectile.js ─ 2 draw calls (1 without trail) ─ parent ─ canonical */ -FlightMode={LINE,ARC,ROLL,FALL,HOMING,LISSAJOUS,VOLLEY} // string enum -Stagger={AUTO,NONE,RIPPLE,HASH}; 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 -b.count · drawCalls · trailUniforms -b.arrivals:Int32Array · b.arrivalCount // crossed tau=1 THIS frame — read straight after update() -b.contact:Vector3 · b.contactLoad // ROLL -> GroundField(RUT) -b.roll(seed=Math.random()*100) · reset() · setBasis(origin,direction,side,length) -b.setTrailColors(a,b,c,d) · syncGeometry() · update(now,params) -b.landPoint(i,out) · pointAt(i,tau,out) · headingAt(i,tau,out) · slotPosition(slot,out) · dispose() - -/* Swarm.js ─ 1 draw call ─ parent ─ canonical */ -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}) -s.count · drawCalls · uniforms -s.roll(seed=Math.random()*100) · reset() · setBasis(origin,direction,side,length) -s.setColors(a,b,c,d) // '#rrggbb' or THREE.Color, memoised -s.update(_now,params) // FIRST ARGUMENT IGNORED -s.leadPoint(out) · dispose() - -/* Tube.js ─ 3 draw calls ─ .group ─ PREFIXED */ -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) · tubeSchema(prefix,path) · createTubeMaterial(layer,path) -new Tube({path=TubePath.STRAIGHT,prefix='tube',nodes=96,sides=26,renderOrder=11}) -t.group · materials{core,sheath,halo} · meshes · keys · geometry · drawCalls · visible(get/set) -t.sync(c, state, g=settings.global) - // state={origin,target,side,progress,fade,widthFade,seed,time,grow,snapAge} -t.radiusAt(tau)->metres // THE profile fn — dust skirts/scour use this, never their own maths -t.pointAt(t,out) · tangentAt(t,out) · span · skirtRadius · mouthRadius -t.tipPoint:Vector3 · tipSpeed:m/s · waveSpeed:m/s -t.crack={fired,point,speed,at} // recomputed by sync(); poll IMMEDIATELY after -t.dispose() - -/* Shell.js ─ 1 draw call ─ .group ─ PREFIXED */ -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) · shellSchema(prefix,mode) · createShellMaterial(mode) -export { BurstMode, BurstSystem } from '../effects/BurstSphere.js' // re-exports -new Shell({mode=ShellMode.DOME,prefix='shell',nodes=48,sides=48,rings=24,segments=96, - renderOrder=14}) -s.group · mesh · material · keys · geometry · drawCalls · visible(get/set) · instanceCount -s.sync(c, state, g=settings.global) // state={origin,axis,side,span,t,fade,seed} -s.radius · span · nodeSpacing · nodeCount -s.standingAt(u) · nodePosition(i,out) · resonantSpacing(n) · dispose() - -/* VolumeHull.js ─ 1 draw call ─ .mesh ─ PREFIXED */ -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} -HULL_NAMES · MEDIUM_NAMES · VOLUME_HULL_KEYS · VOLUME_SAMPLE_BUDGET=20e6 -volumeHullDefaults(prefix,medium=Medium.SMOKE,overrides={}) · volumeHullSchema(prefix,{label,only}) -disposeVolumeHullGeometry() // app teardown; unit hulls are shared -new VolumeHull({hull,medium,prefix='volume',maxSteps=48,shadow,additive=false, - renderOrder=12,seed}) -h.mesh · material · steps · shadowTaps · hull · medium · prefix -h.place(position,direction=null)->this // yaw only; hull local +Z is the heading -h.setSize(x,y=x,z=x)->this // HALF-EXTENTS in metres, EVERY frame -h.setFade(k)->this // 0 hides the mesh -h.sync(c,g)->this · cost(coveredPixels) · dispose() - // scale with setSize(), NEVER mesh.scale, or the march's t stops meaning metres - // reads settings.global.volumeQuality (defaults to 1 if absent) - -/* Distortion.js ─ 1 draw call per emitter ─ .object3D ─ canonical */ -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}) -d.object3D · visible(get/set) // retains/releases the writer counter -d.setAnchor(v3) · setAnchorXYZ(x,y,z) · setBasis(along,up) · update(p) · dispose() - // magnitudes are SCREEN FRACTIONS, not metres. NEVER multiply global.distortion or - // post.distortion into strength — the pass applies both, once. - // Toggle field.visible; hiding the parent group leaks the writer for the session. - -/* Portal.js ─ 1 draw call ─ .object3D ─ canonical */ -new Portal({billboard=false,writeDepth=false,renderOrder=6,name='Portal'}) -p.object3D · visible(get/set) · setPlacement(anchor,along,up) · update(p) · dispose() - -/* LiquidSurface.js ─ 1 draw call ─ .object3D ─ canonical */ -LiquidMode={POOL:0,WAVE:1}; liquidParams()->object -new LiquidSurface({segments=96,mode=LiquidMode.POOL,depthWrite=true,doubleSide=true, - renderOrder=3,name='LiquidSurface'}) -s.object3D · uniforms · drawCalls · visible(get/set) · 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() on that frame -s.clearRipples() · reset() · update(now,p) -s.lipPosition(p,out,across=0) · lipHeight(p,across=0) · dispose() - // 8 ripple slots, oldest evicted. Fill-heavy: one per screen. - -/* Curtain.js ─ 1 draw call (2 with floor) ─ .object3D ─ canonical */ -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) · uniforms · drawCalls · instanceCount -c.visible(get/set) · mode(get/set) · layout(get/set) -c.setPlacement(anchor,along,up) · roll(seed=Math.random()*100) · reset() -c.update(_now,p) // FIRST ARGUMENT IGNORED -c.sheetPoint(index,p,out,across=0,height=0) · dispose() - // alphaCurve must differ from emissionCurve (2.4 vs 0.7) or it is a hanging ribbon - // stormwall passes the cast's SIDE vector as `along`; shafts pass -frame.uLightDir as `up` - -/* 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 - -/* 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 ─ 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 ─ 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 ─ 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 ─ 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 ─ 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 ─ 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 ─ 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 ─ 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() - -/* quads.js ─ no renderer ─ the two unit quads every quad-backed module draws on */ -acquireGroundQuad()->PlaneGeometry // 1x1 in XZ, normal +Y. Refcounted; releaseGroundQuad() in dispose() -releaseGroundQuad() · groundQuadRefs() -uprightQuad()->PlaneGeometry // 1x1 in XY, normal +Z. NOT refcounted, never disposed -disposeQuads() // teardown only - // neither carries a metre — scale the mesh, never the buffer - -/* prefixedBlock.js ─ NEW, no renderer ─ only needed for a new two-instance module */ -num(value,fallback) · str(value,fallback) · prefixed(prefix,name) -buildKeys(fieldNames,prefix) · buildDefaults(fieldNames,fields,tuning,prefix,overrides={}) -auditBlock(label,keys,fieldNames,block,remedy)->string[] -``` - -## What the ability agents must know - -1. **`npm run check` is the only gate that compiles `src/vfx/`.** A green `npm run build` says nothing about these modules — until an ability imports one, it is not in the bundle graph and a syntax error in it builds perfectly. -2. **Add a case to `VFX_CASES` in `scripts/check.mjs` if you add a class to `src/vfx/`** — the coverage rule fails the check otherwise. -3. **No backticks in GLSL comments.** This has now bitten four agents. -4. **Reserved words are the GLSL ES 3.00 list, not the 1.00 one.** three prepends `#version 300 es` to every non-`RawShaderMaterial`, so: `packed` is **fine** (illegal in 1.00, legal in 3.00) and `patch` is **fatal** (the reverse) — a `float patch` took the whole ground material out of the frame this pass. The ones you will actually reach for: `patch`, `sample`, `filter`, `input`, `output`, `flat`, `smooth`, `layout`, `common`, `active`, `this`, `interface`. Shadowing a built-in *function* with a local is fine (`float round = …`); defining a function with a built-in's name is not. **`npm run check` now sweeps every `/* glsl */` block for this.** Uniform arrays are still indexable only by a loop counter. -5. **`commonGLSL` does not compile in a vertex shader** — it carries `aastep`, which calls `fwidth`. Inject **`commonVertexGLSL`** from `shaders/lib/common.glsl.js` instead: the same chunk minus `aastep` and `softFade`, separate include guard, safe to spread alongside `commonGLSL` in the other stage. -6. **Patched `MeshStandardMaterial`s must park uniforms on `material.userData.uniforms`** (I8) or the pause test reports your working sliders as dead. -7. **Give borrowed globals back.** `sceneHooks.acquire()` and `timeField.acquire()` take a piece of the world. Wrap them in `this.borrow(...)` and the base class returns them on every path a cast can end by — including the player pressing **C**, a fifth cast pushing yours off the concurrency cap, and teardown. `timeField.acquire()` **returns `null`** when all four slots are taken, on exactly the `ctx.lights.acquire()` contract: guard it, and read acceptably with none. -8. Budget guide (I7 ≤ 12): beam `Tube` = 3 · tornado `Tube(FUNNEL)+GroundField(SCOUR)+Swarm` = 5 · chained bolt `FilamentPaths+ArcNetwork` sharing a strip = **2** · flooded lane `LiquidSurface+Caustics` bound to it `+GroundField(WET)` = 3 · hive dome `PlateShell+ColonySwarm+WebGraph` = 4. Three costs are not draw calls: a **`Mirror`** is one extra nested `renderer.render()` of `LAYER.WORLD` (capped at two a frame by `mirrorBudget`), a **`Caustics(SCROLL)`** is fill-bound at ~100 hashes a pixel, and a **`GhostRig`** is ~70 `Object3D`s of skeleton per ghost. -9. **Two shared quads, not five.** `vfx/quads.js` owns them. A new flat effect calls `acquireGroundQuad()`/`releaseGroundQuad()`; a new upright one calls `uprightQuad()`. -10. Still open, and not owned by anyone: `settings.global.volumeQuality` is read by `VolumeHull` but has no slider; `frame.uSceneColor` does not exist yet (adding it gives every `ShatterField` refraction for free — but must not point at the composer's write target, which is a feedback loop); bloom still bleeds across a `Hook.HOLE` rim; and the three modules that read `frame.uTime` in-shader (`Swarm`, `Curtain`, `VolumeHull`) are not reachable by a time region until one of them injects `timeWarpGLSL`. diff --git a/docs/VFX_API_II.md b/docs/VFX_API_II.md deleted file mode 100644 index 43cca73..0000000 --- a/docs/VFX_API_II.md +++ /dev/null @@ -1,172 +0,0 @@ -# The VFX tech library, part II — the twelve modules added for schools X–XV - -Read alongside `docs/VFX_API.md` (the original fourteen). Signatures below were read back off the -source by the integration pass, not taken from the authoring agents' reports. - -FINAL AUTHORITATIVE API SUMMARY — the ten new modules - -Read back off the source and mechanically verified. Full text in `docs/VFX_API.md` and `src/vfx/README.md` § API reference. - -### `src/vfx/SceneHooks.js` — 0 draw calls (2 while `HOLE` is held) · singleton · tokens -```js -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(h) · driver(h) · heldCount · reclaim(owner) · releaseAll() · observe(material) -sceneHooks.gravityAt(x,y,z) · disruptAt(x,y,z) · ageAt(x,z) // CPU mirrors of the GLSL -token.blend(0..1) · hold() · release() · token.driving/active -KEY_LIGHT t.aim(az,el)·tint(c)·brightness(i) GRADE t.saturate·temper·raise·darken -AGE t.at|atPoint · field(radius,edge,amount,inner=0) · wear(rust,dust,moss,pit,bleach) · scale(m) · colours(r,d,m) -HOLE t.at|atPoint · size(radius,squash=1) GRAVITY t.at|atPoint · well(r,edge=.25) · scale(inside,outside=1) -DISRUPT t.at|atPoint · region(r,edge=.35) · power(drain,fracture,dim) · shardSize(px) -// opt in: sharedUniforms({ ...disruptUniforms() }) + ${disruptGLSL}; -// VS: vDisrupt = disruptAt(worldPos); FS: disruptShade(colour, alpha, vDisrupt, gl_FragCoord.xy); -``` -**The one rule:** `this.borrow(sceneHooks.acquire(hook, this))`. One token per (hook, owner) — re-acquiring renews. Two owners resolve LIFO. Never call `install`/`apply`/`uninstall`; they are `App`'s. - -### `src/vfx/TimeControl.js` — 0 (the field) / 1 per ghost · pool + parent · canonical -```js -import { timeField, TimeRegion, MAX_TIME_REGIONS, timeRegionParams, TimeRecorder, recorderParams, - MAX_TRACK_SAMPLES, MAX_TRACK_BONES, GhostRig, createGhostMaterial, ghostLook, - applyGhostLook, findCaster, TimeWarpClock, RewindGate, reverseTime, reverseRate, - reverseParams } from '../../vfx/TimeControl.js'; -import { timeWarpGLSL } from '../../shaders/lib/timewarp.glsl.js'; -timeField.acquire(now?) -> TimeRegion | null // NULL when all four slots are taken -timeField.release(r) · reset() · liveCount · clockAt(clock,v3) · weightAt(v3) -region.lock(now?) · place(v3) · placeXYZ · sync(p) · weightAt(v3) · release() · isLive · hold -// p = { radius m, strength 0..1, core 0..1, rate } rate 0 stasis · -1 rewind · 0.25 slow · 1 identity -// shader: ${timeWarpGLSL} float t = warpedTime(uTime, vWorldPos) - uBirth; -// float held = timeRegionWeight(vWorldPos); -new TimeRecorder({ capacity=120, bones=MAX_TRACK_BONES }) - .attach(src)·detach()·clear()·sample(now,p)·trim(now,p)·transformAt(t,pos,quat)·poseAt(t,ghost) -new GhostRig(parent,{layer,renderOrder,material}) .setSource(src) /* ALLOCATES — I3 exception */ - .place(pos,heading=0)·setScale(s)·sync(look)·visible·drawCalls(1)·dispose() -new TimeWarpClock(start=0).advance(dt,rate,floor,ceiling) · emitDt · spanDt · reversing · stalled -``` -**The one rule:** `acquire()` can return `null` — guard it (I6), and `this.borrow()` it. A shader whose *position* depends on its clock cannot use `warpedTime` (feedback loop); probe each slot at the position the body had when that slot locked, as `ParticleSystem` does. - -### `src/vfx/Caustics.js` — 1 draw call · parent · canonical -```js -new Caustics(parent, { source=CausticSource.SCROLL, shape=CausticShape.DISC, custom='', - uniforms=null, additive=true, depthTest=true, layer, renderOrder=7, name }) -CausticSource={SCROLL:0,WAVE:1,CUSTOM:2} CausticShape={DISC:0,CONE:1,LANE:2} CAUSTIC_RIPPLE_SLOTS=8 -c.object3D · drawCalls(1) · boundCount · setVisible(v) -c.bindSource(liquid.uniforms, keys=CAUSTIC_BOUND_KEYS) · unbindSource() -c.ripple(u,v,strength=1,now=0) · clearRipples() // no-ops while uRipples is bound -c.reset() · update(p) /* NO CLOCK */ · setAdditive(b) · dispose() -``` -**The one rule:** `bindSource(liquid.uniforms)` and the wave and its light on the floor become one set of numbers — and `update()` then skips every bound key, because a number with two authors has none. `SCROLL` is fill-heavy (~100 hashes/px): one per screen. - -### `src/vfx/LightShaft.js` — 1 draw call · parent · canonical -```js -new LightShaft(parent, { capacity=6, layout=ShaftLayout.SINGLE, sides=14, maxSteps=48, - layer, renderOrder=10, name }) -ShaftLayout = { SINGLE:0, LINE:1, RING:2, SCATTER:3 } -s.object3D · drawCalls(1) · instanceCount · layout(get/set) · visible(get/set) -s.setPlacement(anchor, along, up) · roll(seed) · reset() · update(p) /* NO CLOCK */ -s.footPoint(i,p,out) · mouthPoint(i,p,out) · irradianceAt(point,p,out=null) -> 0..1 · dispose() -``` -**The one rule:** `irradianceAt()` is how the scene's own dust joins in — multiply it into your existing particles and set `mote: 0`. Second most expensive fragment in the library after `VolumeHull`; `steps` is the slider, `maxSteps` the compile-time cap. - -### `src/vfx/LensFlare.js` — 1 draw call · `.object3D` · canonical -```js -new LensFlare({ ghosts=8, renderOrder=3000, layer=LAYER.VFX, name }) // ghosts = CAPACITY -FlareRole={CORE:0,STREAK:1,RING:2,GHOST:3} MAX_FLARE_GHOSTS=8 lensFlareParams() -f.object3D · drawCalls(1) · capacity · visible(get/set) -f.setAnchor(v3) · setAnchorXYZ(x,y,z) · anchor(out?) · update(p) /* NO CLOCK */ · dispose() -``` -**The one rule:** with `frame.uSceneDepth` unbound, occlusion is forced to 0 and the flare never appears — the inverse of README trap 7, and correct in the app. - -### `src/vfx/Mirror.js` — 1 draw call **+ one nested `renderer.render()`** · `.object3D` · canonical -```js -new Mirror({ resolution=384, layer=LAYER.VFX, reflectLayer=LAYER.WORLD, renderOrder=4, - doubleSided=true, depthWrite=false, name }) -mirrorParams() · mirrorBudget={max:2,live,rendered,skipped,calls,triangles} · setMirrorBudget(n) -m.object3D · drawCalls(1) · resolution · visible(get/set) · priority · lastCalls · lastTriangles -m.setPlacement(anchor, normal, along) · update(p) /* NO CLOCK */ · dispose() -``` -**The one rule:** the reflection is a full extra traversal of `LAYER.WORLD`, driven from the mesh's own `onBeforeRender` — **no visible mirror, no pass**. Hard cap of two rendering mirrors a frame. `renderer.info` is contaminated on a mirror frame; read `mirrorBudget`/`lastCalls` instead. - -### `src/vfx/BrushStroke.js` — 1 draw call · `.object3D` · canonical -```js -new BrushStroke(parent, { strokes=6, bristles=14, samples=40, sides=6, tip=BrushTip.FLAT, - depthWrite=false, layer, renderOrder=7, name }) -BrushTip = { FLAT:0, ROUND:1, SPLIT:2 } -b.object3D·uniforms·drawCalls(1)·count·strokeCount·tip · setStrokeCount(n)·retip(t)·reset() -b.stroke(i) -> { curve(p0,p1,p2,p3) · line(from,to,bow=0,lift=0) · - pressure(entry,swell,hold,exit) · ink(load) · timing(start,span) · active · seed } -b.setPaper(normal)·setColors(a,b,c,d)·roll(seed)·update(_now, p) // FIRST ARG IGNORED -b.pointAt·tangentAt·headOf·tipPoint·pressureOf·widthAt·dispose() -``` -**The one rule:** `p.progress` is the only beat — `update()` ignores its clock (the `Swarm`/`Curtain` precedent). Every bristle spends its own ink load, so the stroke dries from the outside in. - -### `src/vfx/InkDiffusion.js` — 1 draw call · `.object3D` · canonical -```js -new InkDiffusion(parent, { mode=InkMode.BLOOM, sources=4, satellites=16, layer, renderOrder=6, name }) -InkMode = { BLOOM:0, SPLATTER:1, WASH:2 } // a #define, fixed at construction -k.object3D·uniforms·drawCalls(1)·age·setVisible(v)·setPlacement(anchor,along)·roll(seed)·reset() -k.update(now, p) // now = the ability's age -k.frontRadius(i=0)·sourcePoint(i,out)·satelliteSize(i)·satelliteReach(i)·satellitePoint(i,out)·satelliteAge(i) -``` -**The one rule:** the **anti-glow contract** — hard linear-luminance ceiling 0.62 against the 0.88 bloom threshold, `toneMapped: true`, no `uGlobalGlow`, no additive path. Ink is the one school that must not glow, and it is enforced rather than trusted. `satellitePoint(i)` matches the shader exactly, for projectiles. - -### `src/vfx/FoldMesh.js` — 1 draw call · parent · canonical -```js -new FoldMesh(parent, { pattern=FoldPattern.CRANE, layout=FoldLayout.LINE, capacity=32, - segments=20, segmentsV=segments, renderOrder=4, layer=LAYER.WORLD, name }) -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_PATTERNS -m.uniforms·count·drawCalls(1)·visible·layout(get/set)·setPattern(p) -m.setColors(paper,shade,transmit,ink,crease)·setBasis(origin,direction,side,length)·reset() -m.update(_now, p) // FIRST ARG IGNORED — p.fold is the beat -m.sheetPoint(index,p,out)·spoolPoint(index,p,out)·dispose() -``` -**The one rule:** nothing interpolates a position — each crease is a **rigid motion per material point**, so the paper never stretches. Crease tables are authored root-first and walked backwards; `UNROLL` places by arc length. - -### `src/vfx/Dissolve.js` — 0 (patch) / 1 (heap) · parent · canonical -```js -patchDissolveMaterial(material, { mode=DissolveMode.VOXEL, space=DissolveSpace.LOCAL, - uniforms=null, environment=null, vertex='', fragment='' }) -dissolveUniforms(overrides={}) · syncDissolve(target, p) /* every frame */ · dissolveParams() -dissolveSchema(label='Dissolve') · DissolveMode={VOXEL:0,GRANULAR:1,EROSION:2} · DissolveSpace={LOCAL:0,WORLD:1} -new DissolveField(parent, { along=72, across=40, renderOrder=3, layer=LAYER.WORLD, name }) -d.uniforms·drawCalls(1)·visible·setBasis(origin,direction,side,length)·setColors(fresh,settled,face,deep) -d.reset()·update(now, p)·frontPoint(now,p,out)·crestHeight(now,p)·dispose() -``` -**The one rule:** several meshes dissolving as one event **share the uniform box by identity** — `dissolveUniforms()` once, passed to every `patchDissolveMaterial`. Cloning it gives you two events that drift. `WORLD` space never on a projectile. - -### `src/vfx/Colony.js` — 1 / 2 / 1 / 1 · parent · canonical -```js -new ColonySwarm(parent, {…Swarm options}) // EXTENDS Swarm; splices its vertex shader -c.update(_now, p) // FIRST ARG IGNORED; calls super.update() first · c.shapeCentre(out) -ColonyShape = { BALL:0, WALL:1, SPEAR:2, FIST:3, RING:4, COLUMN:5 } -new WebGraph(parent,{maxRings=8,maxSpokes=16,samples=10,filmSubdiv=2,additive=false,renderOrder=11}) -w.drawCalls(2)·count·roll·reset·setPlacement(anchor,normal,up)·update(_now,p) /* IGNORED */ -w.nodePoint(ring,spoke,p,out) // ring -1 is the hub; omits the per-node jitter, deliberately -new LatticeGrowth(parent,{capacity=192,sides=6,wall=.24,recess=.62,renderOrder=2,…}) -g.drawCalls(1)·count·reset·setPlacement(anchor,forward)·update(now,p)·cellPoint(i,p,out,height=1) -new PlateShell(parent,{capacity=64,renderOrder=2,castShadow=true}) -s.drawCalls(1)·count·tessellate(sites,seed,jitter)·update(now,p)·progress(now,p)·plateCentre(i,p,out) -``` -**The one rule:** blend the **fields**, not the points — `mix(fieldA, fieldB, k)` is a real shape at every `k`; a point crossfade's midpoint is a smear. `LatticeGrowth`/`PlateShell` take the ability's `age`; the other two ignore their clock. - -### `src/vfx/HardSurface.js` — 0 draw calls (a toolkit) · no attach · canonical -```js -gearShape/pistonShape/sawbladeShape/plateShape/boltShape/anvilShape(overrides) · hardShape(kind,o) -createGeometry(shape) · hardSurfaceGeometry(kind,shape) // unit space, seated on y=0 -gearPitchFraction(shape) · gearRootFraction(shape) -new ShapeCache({capacity=8}).get(slot,kind,shape) · changed · size · dispose() // per ability, NEVER shared -new GearTrain({capacity=12}) .plant(count,seed)·clear()·solve(p) /* every frame, dt=0 too */ - .teethOf·pitchRadiusOf·tipRadiusOf·scaleOf(i,pitchFraction=0)·angleOf·rateOf·yawOf·positionOf·contactOf -createHardSurfaceMaterial({environment=null,flatShading=false}) · syncHardSurfaceMaterial(m,p) -hardSurfaceParams() · heatToKelvin(heat,p) · blackbodyColor(K,out?) -new GrindContact().solve(contact,normal,rimVel,p).jet(index,emit) · GrindContact.rimVelocity(out,axis,rate,point,centre) -``` -**The one rule:** a **tooth count is a shape, not a transform** — gears with different `teeth` cannot share an `InstancedMesh`. A gear is ~8.7 ms to build: never speculatively, always through a `ShapeCache`. Uniforms are parked on `material.userData.uniforms` (I8). - -### Plus, new and shared -`src/vfx/quads.js` — `acquireGroundQuad()` / `releaseGroundQuad()` (XZ, refcounted) and `uprightQuad()` (XY, module-lifetime). Neither carries a metre: scale the mesh. -`shaders/lib/common.glsl.js` — **`commonVertexGLSL`** for vertex stages; `commonGLSL` stays fragment-only. -`utils/color.js` — **`putColor(target, value, fallback)`** for string-or-`Color`-or-missing params. -`abilities/Ability.js` — **`this.borrow(handle)`**; `destroy()` gives every borrowed global back. \ No newline at end of file From 3d184edec529e5ad8055d8803967ea1f040aede0 Mon Sep 17 00:00:00 2001 From: Majid Manzarpour Date: Fri, 14 Aug 2026 00:34:00 -0400 Subject: [PATCH 9/9] Cleanup docs/scripts --- scripts/.check-local.mjs | 2342 -------------- scripts/check-vfx-distortion-portal.mjs | 360 --- scripts/check-vfx-ink.mjs | 629 ---- scripts/check-vfx-lensflare-mirror.mjs | 439 --- scripts/check-vfx-tube-shell.mjs | 313 -- scripts/check.mjs | 3939 ----------------------- 6 files changed, 8022 deletions(-) delete mode 100644 scripts/.check-local.mjs delete mode 100644 scripts/check-vfx-distortion-portal.mjs delete mode 100644 scripts/check-vfx-ink.mjs delete mode 100644 scripts/check-vfx-lensflare-mirror.mjs delete mode 100644 scripts/check-vfx-tube-shell.mjs delete mode 100644 scripts/check.mjs diff --git a/scripts/.check-local.mjs b/scripts/.check-local.mjs deleted file mode 100644 index 43b79ce..0000000 --- a/scripts/.check-local.mjs +++ /dev/null @@ -1,2342 +0,0 @@ -#!/usr/bin/env node -/** - * check.mjs — the headless verification harness (docs/EXPANSION.md §7). - * - * Forty-four abilities are about to be written by people who cannot see the - * screen this project renders to. This file is the thing that stands in for - * looking at it. It runs in plain Node with no WebGL, because three.js builds - * geometry, materials and scene graphs entirely on the CPU and every VFX system - * in this repo defers GL to render time — so an ability can be constructed, - * cast, ticked through all four phases and torn down without a canvas ever - * existing. - * - * Five passes, in increasing order of how much they cost and how much they find: - * - * 1. **Structure.** Every registered id has a settings block, a schema, a - * sigil and at most one keyboard letter; every block carries the five - * fields the framework indexes blind (`range`, `minRange`, `speed`, - * `cooldown`, `castAnim`, plus `zoneRadius` for a far cast) and the ranges - * are not nonsense. - * - * 2. **The static settings cross-check.** The highest-value pass in the file. - * Every source file under `src/` is lexed, every property read off a - * settings block is collected — `settings..`, `const c = - * settings.` then `c.`, `this.config.`, and destructuring - * from any of those — and each one is looked up in the block it names. A - * key that does not exist reads as `undefined`, multiplies into `NaN`, - * and produces geometry that silently vanishes with no error anywhere. - * That is the single most common way one of these abilities breaks, it - * costs an afternoon to find by eye, and it costs eight milliseconds to - * find here. - * - * The reverse direction — a key in the block that nothing ever reads — is - * a **warning**, never a failure, because a shared VFX module reads its - * configuration through a live object reference that no amount of - * grepping can attribute to an id. - * - * 3. **Runtime simulation.** Each ability is constructed against real - * subsystems on a bare `Scene`, cast, and ticked 240 frames at 1/60 with - * the frame clock driven forward and every subsystem flushed exactly as - * `App` flushes it. Any throw, any `NaN` or `Infinity` in a particle - * attribute array or a mesh transform, any mesh added to the group during - * a cast, any ability that never reaches `DONE` — all fatal. It is then - * destroyed and cast a *second* time, because the pooling contract says - * `destroy()` leaves the instance reusable and nothing else tests that. - * - * 4. **The pause test.** Invariant I1 has one observable consequence: stop - * the clock, drag a slider, and the standing effect changes. So we stop - * the clock (`dt = 0`, which is exactly what `App` feeds the manager when - * paused), snapshot every uniform, every transform and every instance - * matrix the ability owns, scale its numeric settings by 1.37, tick one - * zero-length frame, and demand that something moved. Twice — once - * mid-travel and once mid-impact — because an ability that resolves its - * dimensions during travel and then bakes them at impact passes the first - * sample and fails the second, and that is the exact shape of the mistake. - * - * When the ability is *stable* at rest (a zero-length frame with no - * mutation changes nothing) the harness goes further and probes each - * slider on its own, which turns "this ability responds" into "37 of its - * 54 sliders are observable while paused, and here are the seventeen that - * are not". Run with `--sliders` to see the list. - * - * 5. **Report.** One row per ability, then a detail section for the rows that - * failed. Non-zero exit on any failure. - * - * Usage: - * npm run check every registered ability - * npm run check -- --only ice one id, which is how you will actually use it - * npm run check -- --quiet the table and the failures, nothing else - * npm run check -- --sliders list the sliders that do nothing while paused - */ - -/* ------------------------------------------------------------------ */ -/* §0 · Browser shims — installed before a single project module loads */ -/* ------------------------------------------------------------------ */ -/** - * Nothing in `src/` needs a DOM today, and the harness would import cleanly - * without any of this. It is here for the ability that eventually reaches for - * `window.devicePixelRatio` at module scope, or for the three.js code path that - * decides it is in a browser and asks for a canvas: a missing global there - * throws during *import*, which reports as "the whole harness is broken" - * rather than "one module misbehaved". - * - * The stubs are deliberately inert. They exist to be present, not to work — a - * shim that half-works lets a module take a browser path in Node and fail two - * hundred lines later, which is worse than failing at the first call. - */ -const canvasStub = () => { - const context2d = { - canvas: null, - fillStyle: '#000', - strokeStyle: '#000', - globalAlpha: 1, - fillRect() {}, - clearRect() {}, - drawImage() {}, - getImageData: (_x, _y, w = 1, h = 1) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h }), - putImageData() {}, - createLinearGradient: () => ({ addColorStop() {} }), - createRadialGradient: () => ({ addColorStop() {} }), - measureText: () => ({ width: 0 }), - fillText() {}, - beginPath() {}, - closePath() {}, - moveTo() {}, - lineTo() {}, - arc() {}, - fill() {}, - stroke() {}, - save() {}, - restore() {}, - translate() {}, - scale() {}, - rotate() {} - }; - const element = { - width: 1, - height: 1, - style: {}, - className: '', - dataset: {}, - children: [], - getContext: (kind) => (kind === '2d' ? context2d : null), - toDataURL: () => 'data:,', - appendChild(child) { - this.children.push(child); - return child; - }, - removeChild() {}, - remove() {}, - setAttribute() {}, - getAttribute: () => null, - addEventListener() {}, - removeEventListener() {}, - dispatchEvent: () => true, - getBoundingClientRect: () => ({ x: 0, y: 0, width: 1, height: 1, top: 0, left: 0, right: 1, bottom: 1 }), - querySelector: () => null, - querySelectorAll: () => [] - }; - context2d.canvas = element; - return element; -}; - -if (typeof globalThis.window === 'undefined') { - const storage = new Map(); - globalThis.window = globalThis; - globalThis.self = globalThis; - globalThis.devicePixelRatio = 1; - globalThis.innerWidth = 1280; - globalThis.innerHeight = 720; - globalThis.addEventListener = () => {}; - globalThis.removeEventListener = () => {}; - globalThis.dispatchEvent = () => true; - globalThis.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); - globalThis.requestAnimationFrame = (fn) => setTimeout(() => fn(0), 16); - globalThis.cancelAnimationFrame = (handle) => clearTimeout(handle); - globalThis.localStorage = { - getItem: (key) => (storage.has(key) ? storage.get(key) : null), - setItem: (key, value) => void storage.set(key, String(value)), - removeItem: (key) => void storage.delete(key), - clear: () => storage.clear(), - key: (index) => [...storage.keys()][index] ?? null, - get length() { - return storage.size; - } - }; -} - -if (typeof globalThis.document === 'undefined') { - const root = canvasStub(); - globalThis.document = { - documentElement: root, - body: root, - head: root, - createElement: () => canvasStub(), - createElementNS: () => canvasStub(), - createTextNode: (text) => ({ nodeValue: String(text) }), - getElementById: () => null, - querySelector: () => null, - querySelectorAll: () => [], - addEventListener() {}, - removeEventListener() {}, - createEvent: () => ({ initEvent() {} }) - }; -} - -if (typeof globalThis.performance === 'undefined') { - globalThis.performance = { now: () => Number(process.hrtime.bigint() / 1000n) / 1000 }; -} - -/* ------------------------------------------------------------------ */ -/* §1 · Imports */ -/* ------------------------------------------------------------------ */ - -import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -import { - Scene, - Group, - PerspectiveCamera, - Vector3, - ConeGeometry, - IcosahedronGeometry, - TetrahedronGeometry, - MeshBasicMaterial, - MeshStandardMaterial -} from 'three'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const ROOT = path.resolve(HERE, '..'); -const SRC = path.join(ROOT, 'src'); - -/* - * `config/settings.js` is imported FIRST, and the order is load-bearing. - * - * Several settings modules now spread a prefixed defaults fragment from the - * tech library (`shellDefaults`, `tubeDefaults`, `volumeHullDefaults`), and - * those library modules import `config/settings.js` for their `settings.global` - * default argument. That closes a cycle: - * - * registry → config/abilities/index → → vfx/Shell → config/settings - * → config/abilities/index (already evaluating: TDZ) - * - * Entering the cycle at `config/settings.js` instead is fine, because the only - * thing the library wants from it is a live binding it reads at call time, and - * by the time anything calls, everything is initialised. Entering at the - * registry is not: `settings.js` reaches `...ABILITY_SETTINGS` while - * `abilities/index.js` is still half-way down its own import list, and Node - * throws `Cannot access 'ABILITY_SETTINGS' before initialization`. The browser - * happens to enter through `settings.js` (via `core/Renderer.js`); the harness - * has to do the same deliberately. - */ -const { settings, CAST_ANIMATIONS, CastShape } = await import('../src/config/settings.js'); -await import('../src/config/settings.js'); -const { ABILITIES, SCHOOLS } = await import('../src/abilities/registry.js'); -const { ABILITY_SETTINGS, ABILITY_SCHEMAS } = await import('../src/config/abilities/index.js'); -const { AbilityPhase } = await import('../src/abilities/Ability.js'); -const { frame } = await import('../src/core/FrameUniforms.js'); -const { ParticleEngine } = await import('../src/particles/ParticleEngine.js'); -const { LightPool } = await import('../src/effects/LightPool.js'); -const { DecalSystem } = await import('../src/effects/GroundDecals.js'); -const { FissureSystem } = await import('../src/effects/GroundFissures.js'); -const { BurstSystem } = await import('../src/effects/BurstSphere.js'); -const { ELEMENT_SIGILS } = await import('../src/ui/glyphs/index.js'); -const { patchOnBeforeCompile } = await import('../src/utils/shaderPatch.js'); - -/* ------------------------------------------------------------------ */ -/* §2 · Arguments and reporting plumbing */ -/* ------------------------------------------------------------------ */ - -const argv = process.argv.slice(2); -const flag = (name) => argv.includes(name); -const value = (name) => { - const index = argv.indexOf(name); - return index >= 0 ? argv[index + 1] : undefined; -}; - -const ONLY = value('--only'); -const QUIET = flag('--quiet'); -const SHOW_SLIDERS = flag('--sliders'); - -const COLOUR = process.stdout.isTTY && !process.env.NO_COLOR; -const paint = (code, text) => (COLOUR ? `[${code}m${text}` : text); -const red = (t) => paint('31', t); -const green = (t) => paint('32', t); -const yellow = (t) => paint('33', t); -const dim = (t) => paint('2', t); -const bold = (t) => paint('1', t); - -/** Simulation constants. Named because every one of them is a judgement call. */ -const DT = 1 / 60; // seconds per simulated frame -const REQUIRED_FRAMES = 240; // frames every cast is ticked for, minimum -const MAX_FRAMES = 900; // frames after which a cast that has not finished has hung -const REUSE_FRAMES = 60; // frames of the second cast, which tests the pooling contract -const NAN_STRIDE = 8; // scan the (large) particle arrays every Nth frame -const SANITY_RADIUS = 5000; // metres; a mesh further out than this is a broken transform -const DRAW_CALL_BUDGET = 12; // I7 — meshes in one ability's group -const MUTATION = 1.37; // the factor a slider is scaled by in the pause test -const CHANGE_EPSILON = 1e-6; // relative tolerance below which "it moved" is float noise - -/** Keys the framework indexes blind on every block, whether or not it reads them. */ -const CAST_KEYS = ['range', 'minRange', 'speed', 'cooldown', 'castAnim']; -/** Keys excluded from the pause mutation: they are consumed at spawn, not per frame. */ -const PAUSE_EXCLUDED = new Set(['range', 'minRange', 'speed', 'cooldown']); - -/** One of these per registered ability; the table is these rows. */ -class Report { - constructor(id) { - this.id = id; - this.failures = []; - this.warnings = []; - this.notes = []; - this.marks = { struct: null, static: null, runtime: null, travel: null, impact: null }; - } - - fail(stage, message) { - this.failures.push({ stage, message }); - if (stage in this.marks) this.marks[stage] = false; - return false; - } - - warn(stage, message) { - this.warnings.push({ stage, message }); - } - - note(message) { - this.notes.push(message); - } - - /** Mark a stage passed unless something already failed in it. */ - pass(stage) { - if (this.marks[stage] !== false) this.marks[stage] = true; - } - - get ok() { - return this.failures.length === 0; - } -} - -/** Failures that belong to the roster as a whole rather than to one ability. */ -const globalFailures = []; -const globalWarnings = []; -const failGlobal = (message) => globalFailures.push(message); -const warnGlobal = (message) => globalWarnings.push(message); - -const log = (...args) => { - if (!QUIET) console.log(...args); -}; - -/* ------------------------------------------------------------------ */ -/* §3 · Structure */ -/* ------------------------------------------------------------------ */ - -const ALL_IDS = ABILITIES.map((a) => a.id); -const TARGET = ONLY ? ABILITIES.filter((a) => a.id === ONLY) : ABILITIES; - -if (ONLY && TARGET.length === 0) { - console.error(red(`check: no registered ability with id "${ONLY}".`)); - console.error(dim(` known ids: ${ALL_IDS.join(', ')}`)); - process.exit(2); -} - -const reports = new Map(TARGET.map((a) => [a.id, new Report(a.id)])); -const SCHOOL_IDS = new Set(SCHOOLS.map((s) => s.id)); - -/** Registry-wide invariants: unique ids, unique keys, known schools. */ -function checkRoster() { - const seenIds = new Set(); - const seenKeys = new Map(); - - for (const descriptor of ABILITIES) { - if (seenIds.has(descriptor.id)) failGlobal(`duplicate registry id "${descriptor.id}"`); - seenIds.add(descriptor.id); - - if (descriptor.key) { - const key = String(descriptor.key).toUpperCase(); - if (seenKeys.has(key)) { - failGlobal(`key "${key}" is bound to both "${seenKeys.get(key)}" and "${descriptor.id}"`); - } - seenKeys.set(key, descriptor.id); - } - } - - // A settings block with no registry entry is a block nothing can ever cast. - for (const id of Object.keys(ABILITY_SETTINGS)) { - if (!seenIds.has(id)) warnGlobal(`settings block "${id}" is not registered in ABILITIES`); - } - for (const id of Object.keys(ABILITY_SCHEMAS)) { - if (!seenIds.has(id)) warnGlobal(`editor schema "${id}" is not registered in ABILITIES`); - } -} - -/** - * Everything about one descriptor that can be judged without running anything. - * - * The `zoneRadius` rule is the one worth stating: a far cast whose block has no - * `zoneRadius` still *aims* — `zoneRadiusOf` falls back to 0 — so the circle - * indicator collapses to a point and the ability lands on a footprint of - * nothing. It is a silent, plausible-looking failure, which is why it is - * checked here rather than trusted to review. - */ -function checkStructure(descriptor, report) { - const { id } = descriptor; - const block = settings[id]; - - if (!block || typeof block !== 'object') { - return report.fail('struct', `no settings block: settings.${id} is ${block}`); - } - if (ABILITY_SETTINGS[id] !== block) { - report.fail('struct', `settings.${id} is not the same object as ABILITY_SETTINGS.${id} — the editor will edit a copy`); - } - if (descriptor.settings !== block) { - report.fail('struct', `registry descriptor for "${id}" does not carry the live block`); - } - - if (!SCHOOL_IDS.has(descriptor.school)) { - report.fail('struct', `unknown school "${descriptor.school}"`); - } - if (!/^#[0-9a-f]{6}$/i.test(descriptor.accent ?? '')) { - report.fail('struct', `accent must be #rrggbb, got ${JSON.stringify(descriptor.accent)}`); - } - if (typeof descriptor.load !== 'function') { - report.fail('struct', 'descriptor.load is not a function'); - } - if (!descriptor.label || !descriptor.blurb) { - report.warn('struct', 'descriptor is missing a label or a blurb'); - } - - for (const key of CAST_KEYS) { - if (!(key in block)) report.fail('struct', `settings.${id}.${key} is missing`); - } - - const shape = descriptor.cast; - if (shape !== CastShape.LINE && shape !== CastShape.ZONE) { - report.fail('struct', `cast must be CastShape.LINE or CastShape.ZONE, got ${JSON.stringify(shape)}`); - } - if (shape === CastShape.ZONE && !('zoneRadius' in block)) { - report.fail('struct', `a ZONE cast needs settings.${id}.zoneRadius — without it the footprint is a point`); - } - if (shape === CastShape.LINE && 'zoneRadius' in block) { - report.warn('struct', 'a LINE cast carries zoneRadius, which nothing will draw'); - } - - if (!CAST_ANIMATIONS.includes(block.castAnim)) { - report.fail('struct', `castAnim ${JSON.stringify(block.castAnim)} is not one of ${CAST_ANIMATIONS.join(' / ')}`); - } - - const numeric = (key) => typeof block[key] === 'number' && Number.isFinite(block[key]); - for (const key of ['range', 'minRange', 'speed', 'cooldown']) { - if (key in block && !numeric(key)) report.fail('struct', `${key} must be a finite number, got ${block[key]}`); - } - if (numeric('range') && numeric('minRange') && block.minRange >= block.range) { - report.fail('struct', `minRange (${block.minRange} m) must be below range (${block.range} m)`); - } - if (numeric('range') && block.range <= 0) report.fail('struct', `range must be positive, got ${block.range} m`); - if (numeric('minRange') && block.minRange < 0) report.fail('struct', `minRange must not be negative, got ${block.minRange} m`); - if (numeric('speed') && block.speed <= 0) report.fail('struct', `speed must be positive, got ${block.speed} m/s`); - if (numeric('cooldown') && block.cooldown < 0) report.fail('struct', `cooldown must not be negative, got ${block.cooldown} s`); - if (shape === CastShape.ZONE && typeof block.zoneRadius === 'number' && block.zoneRadius <= 0) { - report.fail('struct', `zoneRadius must be positive, got ${block.zoneRadius} m`); - } - - // Colours are `#rrggbb` strings so lil-gui can bind them directly; anything - // else in a key named `color*` is a colour that will never get a picker. - for (const [key, entry] of Object.entries(block)) { - if (!/^colou?r/i.test(key)) continue; - if (typeof entry !== 'string' || !/^#[0-9a-f]{6}$/i.test(entry)) { - report.fail('struct', `${key} must be a "#rrggbb" string, got ${JSON.stringify(entry)}`); - } - } - - if (!ELEMENT_SIGILS[id]) { - report.fail('struct', `no sigil: ui/glyphs has no entry for "${id}"`); - } else if (!/]/i.test(ELEMENT_SIGILS[id])) { - report.fail('struct', 'sigil is not inline SVG markup'); - } - - checkSchema(id, block, report); - report.pass('struct'); - return report.ok; -} - -/** - * The editor schema names keys. A name that is not in the block builds a - * controller bound to `undefined`, which lil-gui renders as a dead row. - * - * A schema is allowed to be *incomplete* — unmentioned keys land in the - * trailing "More" folder by design — so the missing direction is a warning and - * only the wrong direction is a failure. - */ -function checkSchema(id, block, report) { - const schema = ABILITY_SCHEMAS[id]; - if (!schema || typeof schema !== 'object') { - return report.fail('struct', `no editor schema: ABILITY_SCHEMAS.${id} is missing`); - } - - const mentioned = new Set(); - for (const [folder, entries] of Object.entries(schema)) { - if (!Array.isArray(entries)) { - report.fail('struct', `schema folder "${folder}" is not an array`); - continue; - } - for (const entry of entries) { - const key = Array.isArray(entry) ? entry[0] : entry; - if (typeof key !== 'string') { - report.fail('struct', `schema folder "${folder}" holds an entry with no key: ${JSON.stringify(entry)}`); - continue; - } - if (key.endsWith('*')) { - // A gradient group: `colorMist*` stands for colorMistA/B/C/D. - const prefix = key.slice(0, -1); - for (const stop of ['A', 'B', 'C', 'D']) { - const full = prefix + stop; - mentioned.add(full); - if (!(full in block)) { - report.fail('struct', `schema gradient "${key}" in "${folder}" wants ${id}.${full}, which does not exist`); - } - } - continue; - } - mentioned.add(key); - if (!(key in block)) { - report.fail('struct', `schema key "${key}" in folder "${folder}" does not exist on settings.${id}`); - } - } - } - - const unfiled = Object.keys(block).filter((key) => !mentioned.has(key)); - if (unfiled.length) { - report.warn( - 'struct', - `${unfiled.length} key(s) not filed in the schema (they land in "More"): ${preview(unfiled)}` - ); - } - return true; -} - -/* ------------------------------------------------------------------ */ -/* §4 · The static settings cross-check */ -/* ------------------------------------------------------------------ */ - -/** - * Strip comments — and, by default, string literals — replacing them with - * spaces. - * - * Positions are preserved so a hit can still be reported with a line number. - * The first version of this pass ran the regexes over the raw source, and the - * very first thing it "found" was `settings.rime.plateSize` inside the doc - * comment of a module that did not exist yet — a wrong answer that reads as a - * right one, which is the worst kind for a tool people are meant to trust. - * - * `keepStrings` exists for exactly one caller: an ability declares which block - * `this.config` resolves to by writing `super('beam', context)`, and that id is - * a *string*. Reading it out of the fully stripped source finds `super( ,` and - * silently drops every aliased read in the file — which is how this harness - * first reported that Nova Beam touches eighty-two of its own settings when the - * real number is a hundred and sixty. - * - * Template literals resume code mode inside `${…}`, because a shader string is - * a template literal and the interpolations in it are real code. Regex - * literals are *not* handled: telling a regex from a division needs the parser - * we do not have, so a `/` is simply left alone. The failure mode of that - * choice is a missed read, never an invented one. - */ -function stripToCode(source, keepStrings = false) { - const out = new Array(source.length); - for (let i = 0; i < source.length; i++) out[i] = source[i]; - - const erase = (from, to) => { - for (let i = from; i < to && i < source.length; i++) { - if (source[i] !== '\n' && source[i] !== '\r') out[i] = ' '; - } - }; - /** Comments always go; string bodies stay when `keepStrings` is set. */ - const blank = keepStrings ? () => {} : erase; - - /** Template-literal nesting: each entry is the `${` depth of one template. */ - const templates = []; - let i = 0; - let braceDepth = 0; - - while (i < source.length) { - const c = source[i]; - const next = source[i + 1]; - - if (c === '/' && next === '/') { - let j = i; - while (j < source.length && source[j] !== '\n') j++; - erase(i, j); - i = j; - continue; - } - if (c === '/' && next === '*') { - let j = i + 2; - while (j < source.length && !(source[j] === '*' && source[j + 1] === '/')) j++; - erase(i, Math.min(j + 2, source.length)); - i = j + 2; - continue; - } - if (c === "'" || c === '"') { - let j = i + 1; - while (j < source.length && source[j] !== c) { - if (source[j] === '\\') j++; - if (source[j] === '\n') break; - j++; - } - blank(i, j + 1); - i = j + 1; - continue; - } - if (c === '`') { - // Walk the template, blanking its text but leaving `${…}` as code. - let j = i + 1; - blank(i, i + 1); - while (j < source.length) { - if (source[j] === '\\') { - blank(j, j + 2); - j += 2; - continue; - } - if (source[j] === '`') { - blank(j, j + 1); - j++; - break; - } - if (source[j] === '$' && source[j + 1] === '{') { - blank(j, j + 2); - // Hand control back to the outer loop for the interpolation body. - templates.push(braceDepth); - braceDepth++; - j += 2; - i = j; - break; - } - blank(j, j + 1); - j++; - } - if (j > i && (source[j - 1] === '`' || j >= source.length)) i = j; - else if (i !== j) i = j; - continue; - } - if (c === '{') braceDepth++; - if (c === '}') { - braceDepth--; - if (templates.length && braceDepth === templates[templates.length - 1]) { - // Closing a `${…}`: resume the template's text. - templates.pop(); - blank(i, i + 1); - let j = i + 1; - while (j < source.length) { - if (source[j] === '\\') { - blank(j, j + 2); - j += 2; - continue; - } - if (source[j] === '`') { - blank(j, j + 1); - j++; - break; - } - if (source[j] === '$' && source[j + 1] === '{') { - blank(j, j + 2); - templates.push(braceDepth); - braceDepth++; - j += 2; - break; - } - blank(j, j + 1); - j++; - } - i = j; - continue; - } - } - i++; - } - - return out.join(''); -} - -/** Every `.js` under `src/`, excluding the archive (it reads a dead settings tree). */ -function sourceFiles(dir, found = []) { - for (const entry of readdirSync(dir)) { - const full = path.join(dir, entry); - if (statSync(full).isDirectory()) { - if (entry === 'archive' || entry === 'node_modules') continue; - sourceFiles(full, found); - } else if (entry.endsWith('.js')) { - found.push(full); - } - } - return found; -} - -const IDENT = '[A-Za-z_$][A-Za-z0-9_$]*'; - -/** - * Which local identifiers in a file are safe to treat as "an alias for a - * settings block". - * - * `const c = settings.beam` makes `c.` a settings read for the rest of - * that method — and in this codebase the same file will bind `c` to - * `this.config` in the next method, which is the same block by another name. - * What must never happen is treating `c.foo` as a settings read when some - * *third* binding put an unrelated object in `c`, because that invents a - * failure out of nothing. - * - * So the pass keeps two sets, and the difference between them is the whole - * trick: - * - * - **strict** — every binding of the name in the file is a settings binding - * *and* the name never appears in anything that parses as a parameter list. - * Reads through a strict alias are *required*: a key that is not in the - * block is a failure. - * - **loose** — the name is bound to a block somewhere, and is also a - * parameter somewhere. `IceAbility` threads its block through helpers as - * `_halfWidth(s, c)`, which is idiomatic here and would otherwise blind the - * pass to two thirds of that file. Reads through a loose alias are recorded - * as *optional*: they count toward "this key is used" and can never raise a - * failure, so the worst a wrong guess can do is make a warning quieter. - * - * Anything bound to a non-settings right-hand side anywhere in the file is out - * of both sets. That test is deliberately over-eager: its failure mode is - * dropping a real alias, which loses coverage, and never inventing a fake one, - * which loses trust. - */ -function collectAliases(code, configIds) { - const candidates = new Map(); // name -> Set(blockName) | null when polluted - - const bind = (name, block) => { - if (candidates.get(name) === null) return; - if (!candidates.has(name)) candidates.set(name, new Set()); - if (block === null) candidates.set(name, null); - else candidates.get(name).add(block); - }; - - // Declarations and plain assignments, settings-shaped or not. - const declaration = new RegExp( - `(?:\\b(?:const|let|var)\\s+(${IDENT})|(?:^|[;{}(),]\\s*)(${IDENT}))\\s*=\\s*(?!=)([^;\\n]*)`, - 'g' - ); - for (const match of code.matchAll(declaration)) { - const name = match[1] ?? match[2]; - const rhs = match[3].trim(); - const settingsBlock = rhs.match(new RegExp(`^settings\\s*\\.\\s*(${IDENT})\\s*;?$`)); - if (settingsBlock) { - bind(name, settingsBlock[1]); - continue; - } - if (/^this\s*\.\s*config\s*;?$/.test(rhs)) { - for (const id of configIds) bind(name, id); - continue; - } - bind(name, null); - } - - /** Names that appear in a parameter list: strict-disqualifying, loose-tolerated. */ - const parameters = new Set(); - const paramForms = [ - new RegExp(`\\bfunction\\s*${IDENT}?\\s*\\(([^)]*)\\)`, 'g'), - new RegExp(`(?:^|[;{}\\s])(?:async\\s+)?(?:get\\s+|set\\s+|static\\s+)*${IDENT}\\s*\\(([^)]*)\\)\\s*\\{`, 'g'), - /\(([^)]*)\)\s*=>/g, - new RegExp(`\\bcatch\\s*\\(\\s*(${IDENT})\\s*\\)`, 'g') - ]; - for (const pattern of paramForms) { - for (const match of code.matchAll(pattern)) { - for (const raw of match[1].split(',')) { - const name = raw.trim().replace(/^\.\.\./, '').split('=')[0].trim(); - if (new RegExp(`^${IDENT}$`).test(name)) parameters.add(name); - } - } - } - for (const match of code.matchAll(new RegExp(`(?:^|[^.\\w$])(${IDENT})\\s*=>`, 'g'))) { - parameters.add(match[1]); - } - - const strict = new Map(); // name -> [blockName], reads are required - const loose = new Map(); // name -> [blockName], reads are advisory only - for (const [name, blocks] of candidates) { - if (blocks === null || blocks.size === 0) continue; - (parameters.has(name) ? loose : strict).set(name, [...blocks]); - } - return { strict, loose }; -} - -/** Split a destructuring pattern body into the source keys it names. */ -function destructuredKeys(body) { - const keys = []; - let depth = 0; - let current = ''; - const flush = () => { - const piece = current.trim(); - current = ''; - if (!piece || piece.startsWith('...')) return; - const name = piece.split(':')[0].split('=')[0].trim(); - if (new RegExp(`^${IDENT}$`).test(name)) keys.push(name); - }; - for (const ch of body) { - if (ch === '{' || ch === '[' || ch === '(') depth++; - else if (ch === '}' || ch === ']' || ch === ')') depth--; - if (ch === ',' && depth === 0) flush(); - else current += ch; - } - flush(); - return keys; -} - -/** - * The whole static pass. - * - * Returns `{ reads, unknownBlocks }` where `reads` is `blockName -> key -> - * [{ file, line, optional }]`. `optional` marks a read written defensively — - * `this.config.zoneRadius ?? 1`, `c?.foo` — which counts as a read for the - * unused-key warning but does not make the key mandatory. The aim controller - * reads `zoneRadius` off whatever block is in the slot; a line cast is entitled - * not to have one. - */ -function scanSources() { - const reads = new Map(); - const unknownBlocks = []; - const blockNames = new Set(Object.keys(settings)); - - const record = (block, key, file, line, optional) => { - if (!reads.has(block)) reads.set(block, new Map()); - const table = reads.get(block); - if (!table.has(key)) table.set(key, []); - table.get(key).push({ file, line, optional }); - }; - - for (const file of sourceFiles(SRC)) { - const relative = path.relative(ROOT, file); - const raw = readFileSync(file, 'utf8'); - const code = stripToCode(raw); - const withStrings = stripToCode(raw, true); - - // Line numbers, computed once per file. - const lineStarts = [0]; - for (let i = 0; i < code.length; i++) if (code[i] === '\n') lineStarts.push(i + 1); - const lineAt = (index) => { - let low = 0; - let high = lineStarts.length - 1; - while (low < high) { - const mid = (low + high + 1) >> 1; - if (lineStarts[mid] <= index) low = mid; - else high = mid - 1; - } - return low + 1; - }; - - // `super('ice', context)` is how a class declares which block `this.config` - // resolves to. A file with no such call and a `this.config` read is either - // the base class or the aim controller: it reads whatever is in the slot, - // so its reads apply to *every* id. - const ownIds = [...withStrings.matchAll(new RegExp(`\\bsuper\\s*\\(\\s*['"\`](${IDENT})['"\`]\\s*[,)]`, 'g'))] - .map((m) => m[1]) - .filter((id) => blockNames.has(id)); - const superIds = [...new Set(ownIds)]; - const configIds = superIds.length ? superIds : ALL_IDS; - - const isOptional = (index, length) => { - const before = code.slice(Math.max(0, index - 2), index); - const after = code.slice(index + length, index + length + 12); - return before.endsWith('?.') || /^\s*\?\?/.test(after) || /^\s*\?\./.test(after); - }; - - /* --- settings.. --- */ - for (const match of code.matchAll(new RegExp(`\\bsettings\\s*\\??\\.\\s*(${IDENT})\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { - const [text, block, key] = match; - if (!blockNames.has(block)) { - unknownBlocks.push({ block, file: relative, line: lineAt(match.index) }); - continue; - } - record(block, key, relative, lineAt(match.index), isOptional(match.index, text.length)); - } - - /* --- settings[]. — the framework's blind index --- */ - for (const match of code.matchAll(new RegExp(`\\bsettings\\s*\\[[^\\]]+\\]\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { - // Always recorded as optional: `settings[element].zoneRadius` is read for - // whichever ability happens to be in the slot, and a line cast is - // entitled not to have one. - for (const id of ALL_IDS) record(id, match[1], relative, lineAt(match.index), true); - } - - /* --- this.config. --- */ - // A framework file's `this.config` is whichever ability is in the slot, so - // its reads land on every block — and they are *required* there, which is - // how `Ability.js` reading `cfg.lightColor` becomes a contract every one of - // the fifty blocks has to honour without anybody writing it down twice. The - // one genuinely optional read in that set, `this.config.zoneRadius ?? 1` in - // the aim controller, marks itself optional by being written defensively. - for (const match of code.matchAll(new RegExp(`\\bthis\\s*\\.\\s*config\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { - const optional = isOptional(match.index, match[0].length); - for (const id of configIds) record(id, match[1], relative, lineAt(match.index), optional); - } - - /* --- aliases: `const c = settings.beam` then `c.foo` --- */ - const { strict, loose } = collectAliases(code, configIds); - const aliases = new Map([...loose, ...strict]); - for (const [name, blocks] of aliases) { - const advisory = loose.has(name) && !strict.has(name); - const pattern = new RegExp(`(?:^|[^.\\w$])${name}\\s*\\??\\.\\s*(${IDENT})`, 'g'); - for (const match of code.matchAll(pattern)) { - const optional = advisory || isOptional(match.index, match[0].length); - for (const block of blocks) { - if (!blockNames.has(block)) continue; - record(block, match[1], relative, lineAt(match.index), optional); - } - } - } - - /* --- destructuring off a block, an alias, or this.config --- */ - const destructure = new RegExp( - `\\b(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*(this\\s*\\.\\s*config|settings\\s*\\.\\s*${IDENT}|${IDENT})`, - 'g' - ); - for (const match of code.matchAll(destructure)) { - const source = match[2].replace(/\s+/g, ''); - let blocks = null; - if (source === 'this.config') blocks = configIds; - else if (source.startsWith('settings.')) blocks = [source.slice('settings.'.length)]; - else if (aliases.has(source)) blocks = aliases.get(source); - if (!blocks) continue; - const line = lineAt(match.index); - for (const block of blocks) { - if (!blockNames.has(block)) { - if (source.startsWith('settings.')) unknownBlocks.push({ block, file: relative, line }); - continue; - } - const advisory = loose.has(source) && !strict.has(source); - for (const key of destructuredKeys(match[1])) { - record(block, key, relative, line, advisory); - } - } - } - } - - return { reads, unknownBlocks }; -} - -const STATIC = scanSources(); - -for (const { block, file, line } of STATIC.unknownBlocks) { - failGlobal(`${file}:${line} reads settings.${block}, which is not a settings block`); -} - -function checkStaticReads(descriptor, report) { - const { id } = descriptor; - const block = settings[id] ?? {}; - const table = STATIC.reads.get(id) ?? new Map(); - - /* --- reads with no key behind them: the NaN factory --- */ - const missing = []; - for (const [key, sites] of table) { - if (key in block) continue; - const required = sites.filter((site) => !site.optional); - if (required.length === 0) continue; // a guarded read of an absent key is fine - missing.push({ key, sites: required }); - } - missing.sort((a, b) => a.key.localeCompare(b.key)); - for (const { key, sites } of missing) { - const where = sites.slice(0, 3).map((s) => `${s.file}:${s.line}`).join(', '); - report.fail( - 'static', - `reads settings.${id}.${key}, which does not exist — undefined here becomes NaN geometry (${where}${sites.length > 3 ? ', …' : ''})` - ); - } - - /* --- keys nothing reads: a warning, and a soft one --- */ - const unread = Object.keys(block).filter((key) => !table.has(key)); - if (unread.length) { - report.warn( - 'static', - `${unread.length} settings key(s) with no visible read — check they are not dead, or that they are consumed through a shared VFX module: ${preview(unread)}` - ); - } - - // Counted against the block, not against the table: a loose alias can pick up - // `c.length` off something that is not this block at all, and a "reads 205 of - // its 161 keys" line is a number nobody can act on. - const realised = [...table.keys()].filter((key) => key in block).length; - report.note( - `${realised}/${Object.keys(block).length} settings key(s) read, across ${countSites(table)} site(s)` - ); - report.pass('static'); - return report.ok; -} - -const countSites = (table) => [...table.values()].reduce((total, sites) => total + sites.length, 0); -const preview = (list, limit = 8) => - list.slice(0, limit).join(', ') + (list.length > limit ? `, … (+${list.length - limit})` : ''); - -/* ------------------------------------------------------------------ */ -/* §5 · The mock context */ -/* ------------------------------------------------------------------ */ - -/** - * `shake` and `flash` are stubs because both of them reach outside the scene — - * one into the camera rig, the other into a DOM overlay — and neither has any - * bearing on whether an ability's geometry is sane. Everything else is the real - * subsystem, on a real `Scene`, because a mock `ParticleEngine` would not have - * caught a single one of the bugs this harness exists for. - */ -function makeContext() { - const scene = new Scene(); - const camera = new PerspectiveCamera(46, 16 / 9, 0.1, 400); - camera.position.set(0, 8, 12); - camera.lookAt(0, 0, 0); - camera.updateMatrixWorld(true); - - const noop = { add() {}, rumble() {}, trigger() {}, reset() {}, update() {} }; - - return { - scene, - camera, - /** - * `Environment` itself needs a live `Renderer`, so it is stood in for — but - * the two methods materials actually call are reproduced faithfully rather - * than stubbed to `noop`. `registerShadowCasterWithPatch` is what attaches - * an ability's `onBeforeCompile` hook, and a version of it that does nothing - * would quietly turn the harness into a test that ice and cinder *do not* - * patch their shaders — which is the opposite of the truth and the reason - * both of them threw on the first run of this file. - */ - environment: { - scene, - camera, - renderer: null, - envMap: null, - registerShadowCaster: (material) => material, - registerShadowCasterWithPatch: (material, patch) => patchOnBeforeCompile(material, patch), - setFocus() {}, - update() {} - }, - particles: new ParticleEngine(scene), - lights: new LightPool(scene), - decals: new DecalSystem(scene), - fissures: new FissureSystem(scene), - bursts: new BurstSystem(scene), - shake: noop, - flash: noop - }; -} - -/** - * Capture whatever three.js (or the ability) prints while a block of work runs. - * - * Not censorship — the opposite. A cast that triggers a three.js warning on - * every one of nine hundred frames buries the report under nine hundred - * identical lines, and the useful information ("this geometry is already - * non-indexed") is one line and a count. So they are collected, de-duplicated - * and attached to the ability that caused them. - */ -async function captureConsole(report, fn) { - const seen = new Map(); - const original = { warn: console.warn, error: console.error }; - const sink = (...args) => { - const message = args.map((a) => (a instanceof Error ? a.message : String(a))).join(' '); - seen.set(message, (seen.get(message) ?? 0) + 1); - }; - console.warn = sink; - console.error = sink; - try { - return await fn(); - } finally { - console.warn = original.warn; - console.error = original.error; - for (const [message, count] of seen) { - report.warn('runtime', `console: ${message}${count > 1 ? ` (×${count})` : ''}`); - } - } -} - -/** One simulated frame, in exactly the order `App.update` runs it. */ -function step(ctx, ability, dt) { - frame.uTime.value += dt; - frame.uDelta.value = dt; - ability.update(dt); - ctx.particles.flush(); - ctx.decals.update(dt); - ctx.fissures.update(dt); - ctx.bursts.update(dt); - ctx.lights.update(dt); -} - -/* ------------------------------------------------------------------ */ -/* §6 · Non-finite and sanity scanning */ -/* ------------------------------------------------------------------ */ - -/** - * Every uniform a material actually drives, from both places they hide. - * - * A `ShaderMaterial` keeps them on `material.uniforms`. A patched - * `MeshStandardMaterial` — Frost Lance's crystal, Cinder Fall's rock — cannot: - * its uniforms are handed to the shader inside `onBeforeCompile`, which never - * runs without a GL context, so the live boxes are parked on - * `material.userData.uniforms` by convention and `material.uniforms` does not - * exist at all. - * - * Missing that second location is not a small gap. It made the pause test - * blind to every value on a patched standard material, and the first run of - * this harness cheerfully reported that thirty of Frost Lance's shading - * sliders did nothing while paused. All thirty of them work. - */ -function* uniformsOf(material) { - if (!material) return; - if (material.uniforms) yield* Object.entries(material.uniforms); - if (material.userData?.uniforms) yield* Object.entries(material.userData.uniforms); -} - -function scanArray(array, label, out) { - for (let i = 0; i < array.length; i++) { - const v = array[i]; - if (!Number.isFinite(v)) { - out.push(`${label}[${i}] is ${Number.isNaN(v) ? 'NaN' : v}`); - return; // one report per array; a NaN never arrives alone - } - } -} - -function scanParticles(ctx, out) { - for (const system of ctx.particles.systems.values()) { - for (const [key, array] of Object.entries(system.data)) { - scanArray(array, `particles["${system.name}"].${key}`, out); - } - } -} - -function scanGroup(group, out) { - let index = 0; - group.traverse((object) => { - // The meshes in an ability group are usually unnamed, so the traversal - // index is the only handle a reader has on *which* of the three it was. - const name = `${object.name || object.type}#${index++}`; - for (const [label, vector] of [ - ['position', object.position], - ['scale', object.scale] - ]) { - if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { - out.push(`${name}.${label} is (${vector.x}, ${vector.y}, ${vector.z})`); - } - } - const q = object.quaternion; - if (!Number.isFinite(q.x) || !Number.isFinite(q.y) || !Number.isFinite(q.z) || !Number.isFinite(q.w)) { - out.push(`${name}.quaternion is non-finite`); - } - if (object.position.lengthSq() > SANITY_RADIUS * SANITY_RADIUS) { - out.push( - `${name}.position is ${object.position.length().toFixed(0)} m from the origin — beyond the ${SANITY_RADIUS} m sanity radius` - ); - } - - if (object.isInstancedMesh) { - scanArray(object.instanceMatrix.array, `${name}.instanceMatrix`, out); - if (object.instanceColor) scanArray(object.instanceColor.array, `${name}.instanceColor`, out); - } - if (object.geometry) { - for (const [attributeName, attribute] of Object.entries(object.geometry.attributes ?? {})) { - if (attribute.isInstancedBufferAttribute) { - scanArray(attribute.array, `${name}.${attributeName}`, out); - } - } - } - const materials = Array.isArray(object.material) ? object.material : [object.material]; - for (const material of materials) { - for (const [uniformName, uniform] of uniformsOf(material)) { - const v = uniform?.value; - if (typeof v === 'number' && !Number.isFinite(v)) out.push(`${name}.${uniformName} is ${v}`); - else if (v?.isVector2 || v?.isVector3 || v?.isVector4 || v?.isQuaternion) { - if (!Number.isFinite(v.x) || !Number.isFinite(v.y) || !Number.isFinite(v.z ?? 0) || !Number.isFinite(v.w ?? 0)) { - out.push(`${name}.${uniformName} is non-finite`); - } - } else if (v?.isColor) { - if (!Number.isFinite(v.r) || !Number.isFinite(v.g) || !Number.isFinite(v.b)) { - out.push(`${name}.${uniformName} is a non-finite colour`); - } - } - } - } - }); -} - -/* ------------------------------------------------------------------ */ -/* §7 · Snapshots — what "something changed" means */ -/* ------------------------------------------------------------------ */ - -/** - * A snapshot is every number an ability owns that a viewer could see change: - * the transform of every object in its group, every numeric uniform on every - * material it draws with, every instance matrix and instanced attribute, and - * the uniforms of the particle systems it namespaces. - * - * Labels are built once per sample point and reused, because the pause test - * takes a few hundred snapshots per ability and building a few hundred - * thousand label strings to throw all of them away is the kind of thing that - * turns a two-second harness into a thirty-second one. - */ -/** djb2 over a uuid, so "the geometry object was replaced" is one number. */ -function hashString(text) { - let hash = 5381; - for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; - return hash; -} - -/** `[sum, index-weighted sum]` over a numeric array. */ -function checksum(array) { - let sum = 0; - let weighted = 0; - for (let i = 0; i < array.length; i++) { - const v = array[i]; - if (!Number.isFinite(v)) continue; - sum += v; - weighted += v * (i + 1); - } - return [sum, weighted]; -} - -class Snapshotter { - constructor(ability, ctx) { - this.ability = ability; - this.systems = [...ctx.particles.systems.values()].filter((s) => s.name.startsWith(`${ability.element}.`)); - this.labels = null; - } - - capture(withLabels = false) { - const values = []; - const labels = withLabels ? [] : null; - const push = (label, number) => { - values.push(number); - if (labels) labels.push(label); - }; - - const flatten = (label, v) => { - if (typeof v === 'number') push(label, v); - else if (typeof v === 'boolean') push(label, v ? 1 : 0); - else if (v == null) return; - else if (v.isColor) { - push(`${label}.r`, v.r); - push(`${label}.g`, v.g); - push(`${label}.b`, v.b); - } else if (v.isVector2 || v.isVector3 || v.isVector4 || v.isQuaternion) { - push(`${label}.x`, v.x); - push(`${label}.y`, v.y); - if (v.z !== undefined) push(`${label}.z`, v.z); - if (v.w !== undefined) push(`${label}.w`, v.w); - } else if (v.isEuler) { - push(`${label}.x`, v.x); - push(`${label}.y`, v.y); - push(`${label}.z`, v.z); - } else if (v.elements) { - for (let i = 0; i < v.elements.length; i++) push(`${label}[${i}]`, v.elements[i]); - } else if (ArrayBuffer.isView(v) || Array.isArray(v)) { - for (let i = 0; i < v.length; i++) { - const entry = v[i]; - if (typeof entry === 'number') { - push(`${label}[${i}]`, entry); - } else if (entry && (entry.isVector2 || entry.isVector3 || entry.isVector4 || entry.isColor)) { - // A uniform whose value is an **array of vectors**. This is not an - // exotic case: `vfx/FilamentPaths.js` keeps every role's entire - // geometry in six of them (`uFrom`, `uTo`, `uShape`, `uShape2`, - // `uStyle`, `uDraw`, indexed by role), and so does every ability - // built on it. Skipping them made this snapshot blind to the whole - // of such an ability's shape, and the pause test duly reported - // thirty perfectly live path sliders as dead — the same failure - // mode as the patched-`MeshStandardMaterial` gap, arrived at from - // the other direction. - flatten(`${label}[${i}]`, entry); - } - } - } - }; - - let index = 0; - this.ability.group.traverse((object) => { - const name = `${object.name || object.type}#${index++}`; - flatten(`${name}.position`, object.position); - flatten(`${name}.scale`, object.scale); - flatten(`${name}.quaternion`, object.quaternion); - push(`${name}.visible`, object.visible ? 1 : 0); - - if (object.isInstancedMesh) { - push(`${name}.count`, object.count); - flatten(`${name}.instanceMatrix`, object.instanceMatrix.array); - if (object.instanceColor) flatten(`${name}.instanceColor`, object.instanceColor.array); - } - if (object.geometry) { - const geometry = object.geometry; - push(`${name}.instanceCount`, geometry.instanceCount ?? -1); - push(`${name}.drawRange`, geometry.drawRange?.count ?? -1); - // A live rebuild swaps the whole object, so its identity is the cheapest - // and most direct evidence that `facets` or `roughness` did something. - push(`${name}.geometry.uuid`, hashString(geometry.uuid)); - for (const [attributeName, attribute] of Object.entries(geometry.attributes ?? {})) { - if (attribute.isInstancedBufferAttribute) { - flatten(`${name}.${attributeName}`, attribute.array); - } else { - // Per-vertex arrays are far too big to snapshot four hundred times, - // and nobody needs to know *which* vertex moved — only that the - // geometry was regenerated. Two checksums, one position-weighted so - // a permutation is not mistaken for a no-op. - const [sum, weighted] = checksum(attribute.array); - push(`${name}.${attributeName}.length`, attribute.array.length); - push(`${name}.${attributeName}.sum`, sum); - push(`${name}.${attributeName}.weighted`, weighted); - } - } - } - const materials = Array.isArray(object.material) ? object.material : [object.material]; - for (const material of materials) { - if (!material) continue; - push(`${name}.opacity`, material.opacity); - push(`${name}.emissiveIntensity`, material.emissiveIntensity ?? 0); - flatten(`${name}.color`, material.color); - flatten(`${name}.emissive`, material.emissive); - for (const [uniformName, uniform] of uniformsOf(material)) { - flatten(`${name}.u.${uniformName}`, uniform?.value); - } - } - }); - - for (const system of this.systems) { - for (const [uniformName, uniform] of Object.entries(system.uniforms ?? {})) { - flatten(`${system.name}.${uniformName}`, uniform?.value); - } - } - - // The dynamic light is not in the group — it lives in the pool, in the - // scene — but it is unambiguously something the ability drives and the - // player sees, so `lightIntensity` and `lightRadius` are measured here - // rather than written off as unobservable. `light.intensity` itself is - // damped and therefore frozen on a zero-length frame; `target` is not. - const handle = this.ability.light; - if (handle) { - push('light.target', handle.target); - push('light.distance', handle.light.distance); - flatten('light.position', handle.light.position); - flatten('light.color', handle.light.color); - } - - if (labels) this.labels = labels; - return values; - } -} - -/** Indices at which two snapshots differ by more than float noise. */ -function diffIndices(a, b, limit = 6) { - if (a.length !== b.length) return ['']; - const changed = []; - for (let i = 0; i < a.length; i++) { - const scale = Math.max(1, Math.abs(a[i]), Math.abs(b[i])); - if (Math.abs(a[i] - b[i]) > CHANGE_EPSILON * scale) { - changed.push(i); - if (changed.length >= limit) break; - } - } - return changed; -} - -const didChange = (a, b) => diffIndices(a, b, 1).length > 0; - -/* ------------------------------------------------------------------ */ -/* §8 · Runtime simulation */ -/* ------------------------------------------------------------------ */ - -const _origin = new Vector3(0, 0, 0); -const _direction = new Vector3(0, 0, 1); - -/** A cast distance inside the ability's own declared reach. */ -function castDistance(block) { - const range = Number.isFinite(block.range) ? block.range : 10; - const minRange = Number.isFinite(block.minRange) ? block.minRange : 0; - return Math.max(minRange + 0.5, range * 0.8); -} - -async function runAbility(descriptor, report) { - const { id } = descriptor; - const block = settings[id]; - - let Type; - try { - Type = await descriptor.load(); - } catch (error) { - return report.fail('runtime', `load() rejected: ${describeError(error)}`); - } - if (typeof Type !== 'function') { - return report.fail('runtime', `load() resolved to ${typeof Type}, not a class — did you forget \`.then(m => m.XAbility)\`?`); - } - - const ctx = makeContext(); - frame.uTime.value = 0; - frame.uDelta.value = 0; - - let ability; - try { - ability = new Type(ctx); - ctx.scene.add(ability.group); - } catch (error) { - return report.fail('runtime', `constructor threw: ${describeError(error)}`); - } - - if (ability.element !== id) { - report.fail('runtime', `constructed with element "${ability.element}" but registered as "${id}"`); - } - - /* --- the namespacing rule: ".", or a shared system --- */ - for (const name of ctx.particles.systems.keys()) { - if (!name.startsWith(`${id}.`) && !name.startsWith('shared.')) { - report.warn('runtime', `particle system "${name}" is not namespaced "${id}." — it will collide with another ability`); - } - } - - const meshCount = countDrawables(ability.group); - if (meshCount > DRAW_CALL_BUDGET) { - report.warn('runtime', `${meshCount} drawable(s) in the group; the budget in §0/I7 is ${DRAW_CALL_BUDGET}`); - } - report.note(`${meshCount} drawable(s), ${ctx.particles.systems.size} particle system(s)`); - - const distance = castDistance(block); - const problems = []; - - /* ---------------- first cast: the full run ---------------- */ - try { - ability.spawn(_origin, _direction, distance); - } catch (error) { - return report.fail('runtime', `spawn() threw: ${describeError(error)}`); - } - - const childrenAtSpawn = countDrawables(ability.group); - const systemsAtSpawn = ctx.particles.systems.size; - - let frames = 0; - let finishedAt = -1; - const seenPhases = new Set([ability.phase]); - try { - while (frames < MAX_FRAMES) { - step(ctx, ability, DT); - frames++; - seenPhases.add(ability.phase); - if (frames % NAN_STRIDE === 0 || ability.isFinished) { - scanParticles(ctx, problems); - } - scanGroup(ability.group, problems); - if (problems.length) break; - if (ability.isFinished) { - finishedAt = frames; - if (frames >= REQUIRED_FRAMES) break; - } - if (finishedAt >= 0 && frames >= REQUIRED_FRAMES) break; - } - } catch (error) { - return report.fail('runtime', `threw on frame ${frames + 1} (phase ${ability.phase}): ${describeError(error)}`); - } - - for (const problem of problems.slice(0, 5)) { - report.fail('runtime', `non-finite value after ${frames} frame(s): ${problem}`); - } - - if (finishedAt < 0 && problems.length) { - // The loop was cut short by the NaN above; "it never finished" is that - // failure wearing a second hat, and reporting both sends people hunting for - // a timing bug that is not there. - report.note('the tick loop was stopped early by the non-finite value above'); - } else if (finishedAt < 0) { - report.fail( - 'runtime', - `never reached DONE in ${MAX_FRAMES} frames (${(MAX_FRAMES * DT).toFixed(1)} s) — stuck in ${ability.phase}; ` + - `check impactDuration (${safeNumber(ability.impactDuration)} s) and fadeDuration (${safeNumber(ability.fadeDuration)} s)` - ); - } else { - report.note(`cast ran ${finishedAt} frames (${(finishedAt * DT).toFixed(2)} s) to DONE`); - } - - for (const phase of [AbilityPhase.TRAVEL, AbilityPhase.IMPACT, AbilityPhase.FADE]) { - if (!seenPhases.has(phase)) report.warn('runtime', `never entered the ${phase.toUpperCase()} phase`); - } - - const childrenAtEnd = countDrawables(ability.group); - if (childrenAtEnd !== childrenAtSpawn) { - report.fail( - 'runtime', - `the group grew from ${childrenAtSpawn} to ${childrenAtEnd} drawable(s) during the cast — I3 says nothing is built during a cast` - ); - } - if (ctx.particles.systems.size !== systemsAtSpawn) { - report.fail( - 'runtime', - `${ctx.particles.systems.size - systemsAtSpawn} particle system(s) created mid-cast — build them in createParticles()` - ); - } - - /* ---------------- destroy, then cast again ---------------- */ - try { - ability.destroy(); - } catch (error) { - return report.fail('runtime', `destroy() threw: ${describeError(error)}`); - } - if (ability.phase !== AbilityPhase.IDLE) { - report.fail('runtime', `destroy() left the instance in phase ${ability.phase}, not IDLE — the pool will hand out a live cast`); - } - - try { - ability.spawn(_origin, _direction, distance); - for (let i = 0; i < REUSE_FRAMES; i++) step(ctx, ability, DT); - } catch (error) { - return report.fail('runtime', `the second cast threw — destroy() did not leave the instance reusable: ${describeError(error)}`); - } - const reuseProblems = []; - scanParticles(ctx, reuseProblems); - scanGroup(ability.group, reuseProblems); - for (const problem of reuseProblems.slice(0, 3)) { - report.fail('runtime', `non-finite value on the second cast: ${problem}`); - } - ability.destroy(); - - report.pass('runtime'); - - /* ---------------- the pause test, on a fresh cast ---------------- */ - runPauseTest(descriptor, report, ctx, ability); - - try { - ability.dispose?.(); - ctx.particles.dispose(); - ctx.decals.dispose(); - ctx.fissures.dispose(); - ctx.bursts.dispose(); - ctx.lights.dispose(); - } catch (error) { - report.warn('runtime', `teardown threw: ${describeError(error)}`); - } - - return report.ok; -} - -function countDrawables(group) { - let total = 0; - group.traverse((object) => { - if (object.isMesh || object.isPoints || object.isLine || object.isSprite) total++; - }); - return total; -} - -/* ------------------------------------------------------------------ */ -/* §9 · The pause test (invariant I1) */ -/* ------------------------------------------------------------------ */ - -/** - * Two sample points, chosen because they are the two beats where the mistake - * lives: - * - * - **mid-travel**, when the front is halfway out and everything is being - * resolved from settings because it has to be; - * - **mid-impact**, when the effect is standing still and it is very tempting - * to have cached the metres it stood up with. - * - * At each one we stop the clock and feed the ability zero-length frames, which - * is precisely what `App` does when you press **P**. If scaling every dimension - * on the block by 1.37 does not move a single number the ability owns, the - * effect on screen would not move either, and the ability is not done. - */ -function runPauseTest(descriptor, report, ctx, ability) { - const { id } = descriptor; - const block = settings[id]; - const distance = castDistance(block); - - const mutable = Object.keys(block).filter( - (key) => typeof block[key] === 'number' && Number.isFinite(block[key]) && block[key] !== 0 && !PAUSE_EXCLUDED.has(key) - ); - if (mutable.length === 0) { - report.fail('travel', 'no non-zero numeric settings to mutate — every dimension must be a slider (I5)'); - report.fail('impact', 'no non-zero numeric settings to mutate'); - return; - } - - for (const sample of ['travel', 'impact']) { - let outcome; - try { - outcome = probeSample(ctx, ability, block, mutable, sample, distance); - } catch (error) { - report.fail(sample, `threw while probing at ${sample}: ${describeError(error)}`); - continue; - } - - if (outcome.skipped) { - report.fail(sample, outcome.skipped); - continue; - } - - if (!outcome.restless) { - report.note( - `${sample}: ${outcome.responsive.length}/${mutable.length} slider(s) observable while paused` + - (outcome.dead.length && SHOW_SLIDERS ? `\n dead here: ${outcome.dead.join(', ')}` : '') - ); - } else { - report.warn( - sample, - 'the ability is not stable at rest — a zero-length frame with nothing changed already moves it, ' + - 'so per-slider attribution is unavailable (usually Math.random() called from the per-frame sync). ' + - `First to drift: ${outcome.driftLabels.join(', ')}` - ); - } - - if (outcome.changed) report.pass(sample); - else { - report.fail( - sample, - `nothing observable changed at ${sample} when all ${mutable.length} numeric slider(s) were scaled by ${MUTATION} on a zero-length frame — ` + - 'invariant I1: every metre, radian and second must be re-resolved from settings every frame, including a paused one' - ); - } - } -} - -/** - * Run one sample point. Returns - * `{ changed, restless, driftLabels[], responsive[], dead[], skipped? }`. - */ -function probeSample(ctx, ability, block, mutable, sample, distance) { - ability.destroy(); - frame.uTime.value = 0; - ability.spawn(_origin, _direction, distance); - - /* --- tick to the sample point --- */ - let frames = 0; - if (sample === 'travel') { - // Half-way down the line, still travelling. An ability that buys a wind-up - // by refusing to advance (Nova Beam) sits at u = 0 for a while first, so - // this waits on `u`, not on a frame count. - while (frames < MAX_FRAMES && !(ability.phase === AbilityPhase.TRAVEL && ability.u >= 0.5)) { - step(ctx, ability, DT); - frames++; - if (ability.phase !== AbilityPhase.TRAVEL) break; - } - if (ability.phase !== AbilityPhase.TRAVEL) { - return { skipped: `the cast left TRAVEL before reaching the half-way point (phase ${ability.phase} after ${frames} frames)` }; - } - } else { - while (frames < MAX_FRAMES && ability.phase === AbilityPhase.TRAVEL) { - step(ctx, ability, DT); - frames++; - } - const half = Math.max(1, Math.round((ability.impactDuration * 0.5) / DT)); - for (let i = 0; i < half && ability.phase === AbilityPhase.IMPACT; i++) { - step(ctx, ability, DT); - frames++; - } - if (ability.phase !== AbilityPhase.IMPACT && ability.phase !== AbilityPhase.FADE) { - return { skipped: `the cast was already ${ability.phase} at the mid-impact sample (after ${frames} frames)` }; - } - } - - const snapshotter = new Snapshotter(ability, ctx); - - /* --- let the clock-stopped state settle --- */ - // The first zero-length frame after a real one is not idle: an impact punch - // is still decaying out of `lightBoost`, an eased value is still a step from - // its target. That is settling, not a slider responding, and mistaking one - // for the other would report every ability in the project as "restless". - // Three frames is enough for everything in the repo; the check below proves - // it rather than assuming it. - for (let i = 0; i < 3; i++) ability.update(0); - - /* --- is it stable at rest? --- */ - const before = snapshotter.capture(true); - ability.update(0); - const baseline = snapshotter.capture(); - const drift = diffIndices(before, baseline, 4); - const restless = drift.length > 0; - const driftLabels = drift.map((i) => (typeof i === 'number' ? snapshotter.labels[i] : i)); - - /* --- the gate: scale every slider at once --- */ - const originals = mutable.map((key) => block[key]); - for (const key of mutable) block[key] *= MUTATION; - ability.update(0); - const mutated = snapshotter.capture(); - const changed = didChange(baseline, mutated); - mutable.forEach((key, i) => { - block[key] = originals[i]; - }); - ability.update(0); - - /* --- attribution, one slider at a time --- */ - const responsive = []; - const dead = []; - if (!restless) { - for (let i = 0; i < mutable.length; i++) { - const key = mutable[i]; - const rest = snapshotter.capture(); - block[key] *= MUTATION; - ability.update(0); - const probe = snapshotter.capture(); - block[key] = originals[i]; - ability.update(0); - if (didChange(rest, probe)) responsive.push(key); - else dead.push(key); - } - } - - ability.destroy(); - return { changed, restless, driftLabels, responsive, dead }; -} - -/* ------------------------------------------------------------------ */ -/* §10 · The vfx tech library smoke test */ -/* ------------------------------------------------------------------ */ -/** - * Construct every exported class in `src/vfx/`, drive it, and take it apart - * again. - * - * The ability stages above only see a module in `src/vfx/` if an ability - * imports it, and on the day this was written not one of them did: fourteen - * modules, some of them seventeen hundred lines, and the entire harness walked - * straight past them. The twenty-two agents about to build abilities on top of - * this library would each have discovered its breakages personally, in a - * browser, as a blank screen. - * - * So this stage does the least that is still worth doing. It is not a test of - * whether a `FUNNEL` looks like a vortex — nothing without a GL context can - * tell you that, and every module's author ran their own harness for it. It - * asks four questions that a blank screen answers too late: - * - * 1. Does it **import**? A reserved word in a shader (`packed`, `flat`) or a - * backtick inside a GLSL comment is a syntax error at module scope, and - * three of those have already been found in this library by hand. - * 2. Does it **construct** with the options its own doc comment advertises? - * 3. Does driving it for a few frames — including a zero-length one — leave a - * NaN in a uniform, an instance matrix or a transform? A NaN uniform does - * not render wrongly, it renders *nothing*, silently. - * 4. Does `dispose()` run without throwing? - * - * The coverage rule at the end is the part that keeps this honest: every - * exported class in every file under `src/vfx/` must appear in `VFX_CASES`. A - * fifteenth module lands with no recipe and this stage fails, naming it. That - * is deliberate — an untested module in a shared library is worse than no - * module, because someone will build on it. - */ - -const VFX_DIR = path.join(SRC, 'vfx'); - -/** Cases are built lazily so one module failing to import cannot stop the rest. */ -const vfx = {}; - -/** - * Load every module under `src/vfx/` once, keeping the failures rather than - * throwing on the first one — a broken module should cost its own line in the - * report, not the whole stage. - */ -async function loadVfxModules() { - const modules = new Map(); - const names = readdirSync(VFX_DIR) - .filter((name) => name.endsWith('.js')) - .sort(); - for (const name of names) { - try { - modules.set(name, await import(path.join(VFX_DIR, name))); - } catch (error) { - modules.set(name, { __error: error }); - } - } - return modules; -} - -const VFX_MODULES = await loadVfxModules(); - -/** Shorthand: a module's export, or `undefined` if the module did not load. */ -const vx = (file, name) => VFX_MODULES.get(file)?.[name]; - -/* --- the shared props every case borrows ---------------------------- */ - -const _from = new Vector3(0, 0.2, 0); -const _to = new Vector3(0, 0.2, 12); -const _side = new Vector3(1, 0, 0); -const _up = new Vector3(0, 1, 0); -const _dirZ = new Vector3(0, 0, 1); - -/** A body geometry factory. Modules that own their geometry demand a factory. */ -const shardFactory = () => new TetrahedronGeometry(0.22, 0); -const spikeFactory = () => new ConeGeometry(0.5, 1, 5, 1, true); - -/** - * A case is `{ file, label, build }`, where `build` returns the handle this - * stage drives. `node` is whatever Object3D the module hung its meshes on — - * `scanGroup` walks it, so a group is as good as a mesh. - */ -function vfxCases() { - const cases = []; - const add = (file, label, build) => cases.push({ file, label, build }); - - /* --- GrowthField ------------------------------------------------- */ - const { GrowthField, GrowthLayout, GrowthEmerge, growthParams, patchGrowthMaterial } = - VFX_MODULES.get('GrowthField.js') ?? {}; - for (const [layoutName, layout] of [ - ['LINE', GrowthLayout?.LINE], - ['ZONE', GrowthLayout?.ZONE] - ]) { - add('GrowthField.js', `GrowthField ${layoutName}`, () => { - const parent = new Group(); - const material = patchGrowthMaterial(new MeshStandardMaterial(), {}); - const field = new GrowthField(parent, { - geometry: spikeFactory, - material, - variants: 2, - capacity: 48 - }); - const p = growthParams(); - p.layout = layout; - p.emerge = GrowthEmerge?.PUSH ?? 0; - field.plant(32, 0.25); - field.triggerAll(0, 0.4); - return { - node: parent, - drawCalls: () => field.drawCalls, - tick: (now) => field.update(now, p), - dispose: () => { - field.dispose(); - material.dispose(); - } - }; - }); - } - - /* --- ShatterField ------------------------------------------------- */ - const { ShatterField, shatterParams } = VFX_MODULES.get('ShatterField.js') ?? {}; - add('ShatterField.js', 'ShatterField', () => { - const parent = new Group(); - const field = new ShatterField(parent, { geometry: shardFactory, variants: 2, capacity: 64 }); - const p = shatterParams(); - field.sync(p); - field.burst(0, 40, 1, 0.6); - return { - node: parent, - drawCalls: () => field.drawCalls, - tick: (now) => field.update(now, p), - dispose: () => field.dispose() - }; - }); - - /* --- FilamentPaths: every path mode through one strip -------------- */ - const { FilamentPaths, PathMode, filamentLook } = VFX_MODULES.get('FilamentPaths.js') ?? {}; - add('FilamentPaths.js', 'FilamentPaths (9 path modes)', () => { - const parent = new Group(); - const paths = new FilamentPaths(parent, { samples: 32, capacity: 16 }); - const look = filamentLook(); - paths.setNodeCount(6); - for (let i = 0; i < 6; i++) paths.setNode(i, i / 5, (i % 2 ? 0.2 : -0.2), 0.1); - // One role per frame cycles through all nine modes over the tick loop, so - // every parametric path gets its uniforms written at least once. - const modes = Object.values(PathMode ?? {}); - return { - node: parent, - drawCalls: () => paths.drawCalls, - tick: (now, index) => { - const role = paths.role(index % 4); - role.count = 5; - role.style(0.3, 0.1, 0.5, 0.2); - role.ends(0.05, 0.95, 0.1, 0.9); - role.draw(2, 0.1, 0, 1.5); - switch (modes[index % modes.length]) { - case PathMode.HELIX: role.helix(_from, _to, 0.6, 0.3, 3, 1, 0.2, 0.4, 1.2); break; - case PathMode.ORBIT: role.orbit(_to, _up, 1.4, 1, 1.2, 0.3, 0.2, 0.4, 0.2); break; - case PathMode.MEANDER: role.meander(_to, _up, 0.4, 2.2, 1.1, 0.5, 0.3, 0.4, 0.6); break; - case PathMode.RIM: role.rim(_to, _up, 2.4, 1.6, 0.8, 0.3, 0.2, 0.3, 0.1); break; - case PathMode.CHAIN: role.chain(_from, _to, 0.4, 0.3, 0.2, 0.3, 3, 0.4, 0.2, 0.6); break; - case PathMode.LINK: role.link(_from, _to, 0.3, 0.6, 0.2, 1.1, 0.4, 0.3); break; - case PathMode.SPIRAL_IN: role.spiralIn(_from, _to, 1.2, 0.1, 2.5, 1, 1.3, 0.3, 0.2); break; - case PathMode.CRACK: role.crack(_from, _to, 0.5, 0.7, 0.6, 0.4, 0.1, 0.2, 0.5); break; - default: role.line(_from, _to, 0.2, 0.1, 0.4, 1.2, 0.3, 1.1, 0.2); - } - paths.sync(look, 1, now); - }, - dispose: () => paths.dispose() - }; - }); - - /* --- ArcNetwork ---------------------------------------------------- */ - const { ArcNetwork, arcNetworkParams } = VFX_MODULES.get('ArcNetwork.js') ?? {}; - add('ArcNetwork.js', 'ArcNetwork', () => { - const parent = new Group(); - const net = new ArcNetwork(parent, { samples: 48, capacity: 12 }); - const p = arcNetworkParams(); - net.from.copy(_from); - net.to.copy(_to); - net.reset(3); - return { - node: parent, - drawCalls: () => net.drawCalls, - tick: (now, index, dt) => net.update(dt, p, 1), - dispose: () => net.dispose() - }; - }); - - /* --- GroundField: all ten modes ------------------------------------ */ - const { GroundField, GroundMode, GROUND_MODE_NAMES, groundFieldParams } = - VFX_MODULES.get('GroundField.js') ?? {}; - for (const [name, mode] of Object.entries(GroundMode ?? {})) { - add('GroundField.js', `GroundField ${name}`, () => { - const parent = new Group(); - const field = new GroundField(parent, { mode, marks: 8 }); - const p = groundFieldParams(); - field.mark(0.3, -0.2, 0, 1); - field.mark(-0.5, 0.4, 0.2, 0.6); - return { - node: parent, - drawCalls: () => field.drawCalls, - tick: () => field.update(p), - dispose: () => field.dispose() - }; - }); - } - - /* --- VolumeHull: every hull, every medium -------------------------- */ - const { VolumeHull, HullShape, Medium, HULL_NAMES, MEDIUM_NAMES, volumeHullDefaults } = - VFX_MODULES.get('VolumeHull.js') ?? {}; - const hullCombos = []; - for (const hull of Object.values(HullShape ?? {})) hullCombos.push([hull, Medium?.FLAME ?? 0]); - for (const medium of Object.values(Medium ?? {})) hullCombos.push([HullShape?.BOX ?? 0, medium]); - for (const [hull, medium] of hullCombos) { - const label = `VolumeHull ${HULL_NAMES?.[hull]}/${MEDIUM_NAMES?.[medium]}`; - add('VolumeHull.js', label, () => { - const parent = new Group(); - const prefix = 'vol'; - const hullObject = new VolumeHull({ hull, medium, prefix, maxSteps: 12 }); - parent.add(hullObject.mesh); - const c = volumeHullDefaults(prefix, medium); - hullObject.place(_to, _dirZ).setSize(2, 3, 2).setFade(1); - return { - node: parent, - drawCalls: () => 1, - tick: () => hullObject.sync(c, settings.global), - dispose: () => hullObject.dispose() - }; - }); - } - - /* --- Tube: every path ---------------------------------------------- */ - const { Tube, TubePath, TUBE_PATH_NAMES, tubeDefaults } = VFX_MODULES.get('Tube.js') ?? {}; - for (const path of Object.values(TubePath ?? {})) { - add('Tube.js', `Tube ${TUBE_PATH_NAMES?.[path]}`, () => { - const parent = new Group(); - const tube = new Tube({ path, prefix: 'tube', nodes: 32, sides: 10 }); - parent.add(tube.group); - const c = tubeDefaults('tube', path); - const state = { - origin: _from, target: _to, side: _side, - progress: 0.6, fade: 1, widthFade: 1, seed: 4, time: 0, grow: 1, snapAge: 0 - }; - return { - node: parent, - drawCalls: () => tube.drawCalls, - tick: (now) => { - state.time = now; - tube.sync(c, state, settings.global); - // The crack is recomputed by sync(); poll it as an ability would. - void tube.crack.fired; - void tube.radiusAt(0.5); - }, - dispose: () => tube.dispose() - }; - }); - } - - /* --- Shell: every mode --------------------------------------------- */ - const { Shell, ShellMode, SHELL_MODE_NAMES, shellDefaults } = VFX_MODULES.get('Shell.js') ?? {}; - for (const mode of Object.values(ShellMode ?? {})) { - add('Shell.js', `Shell ${SHELL_MODE_NAMES?.[mode]}`, () => { - const parent = new Group(); - const shell = new Shell({ mode, prefix: 'shell', nodes: 16, sides: 16, rings: 6, segments: 32 }); - parent.add(shell.group); - const c = shellDefaults('shell', mode); - const state = { origin: _to, axis: _up, side: _side, span: 6, t: 0.4, fade: 1, seed: 2 }; - return { - node: parent, - drawCalls: () => shell.drawCalls, - tick: (now, index) => { - state.t = Math.min(1, index / 8); - shell.sync(c, state, settings.global); - void shell.standingAt(0.5); - }, - dispose: () => shell.dispose() - }; - }); - } - - /* --- BurstSystem, re-exported by Shell.js -------------------------- */ - const { BurstSystem, BurstMode } = VFX_MODULES.get('Shell.js') ?? {}; - add('Shell.js', 'BurstSystem (re-export)', () => { - const scene = new Group(); - const bursts = new BurstSystem(scene); - for (const mode of Object.values(BurstMode ?? {})) bursts.spawn(mode, _to, { life: 0.4 }); - return { - node: scene, - drawCalls: () => 1, - tick: (now, index, dt) => bursts.update(dt), - dispose: () => bursts.dispose?.() - }; - }); - - /* --- Projectile: every flight mode --------------------------------- */ - const { Projectile, FlightMode, projectileParams } = VFX_MODULES.get('Projectile.js') ?? {}; - const flights = Object.values(FlightMode ?? {}); - add('Projectile.js', `Projectile (${flights.length} flight modes)`, () => { - const parent = new Group(); - const material = new MeshBasicMaterial(); - const body = new Projectile(parent, { - geometry: () => new IcosahedronGeometry(0.3, 0), - material, - capacity: 12, - trail: true, - trailNodes: 12 - }); - const p = projectileParams(); - body.setBasis(_from, _dirZ, _side, 12); - body.roll(7); - return { - node: parent, - drawCalls: () => body.drawCalls, - tick: (now, index) => { - p.flight = flights[index % flights.length]; - body.update(now, p); - // Arrivals must be safe to read straight after update(), every frame. - void body.arrivalCount; - }, - dispose: () => { - body.dispose(); - material.dispose(); - } - }; - }); - - /* --- Swarm: every silhouette --------------------------------------- */ - const { Swarm, Silhouette, LeadPath, swarmParams } = VFX_MODULES.get('Swarm.js') ?? {}; - for (const [name, silhouette] of Object.entries(Silhouette ?? {})) { - add('Swarm.js', `Swarm ${name}`, () => { - const parent = new Group(); - const swarm = new Swarm(parent, { capacity: 64, silhouette }); - const p = swarmParams(); - p.lead = LeadPath?.ORBIT ?? 0; - swarm.setBasis(_from, _dirZ, _side, 12); - swarm.roll(5); - return { - node: parent, - drawCalls: () => swarm.drawCalls, - tick: (now) => swarm.update(now, p), - dispose: () => swarm.dispose() - }; - }); - } - - /* --- Distortion: every emitter ------------------------------------- */ - const { DistortionField, DistortionMode, DistortionFacing } = VFX_MODULES.get('Distortion.js') ?? {}; - for (const [name, mode] of Object.entries(DistortionMode ?? {})) { - add('Distortion.js', `DistortionField ${name}`, () => { - const parent = new Group(); - const field = new DistortionField({ mode, facing: DistortionFacing?.BILLBOARD, edge: true }); - parent.add(field.object3D); - field.visible = true; - field.setAnchorXYZ(0, 1, 8); - field.setBasis(_dirZ, _up); - // An empty params object is the interesting call: it proves every read - // falls back rather than writing `undefined` into a uniform. - const p = { radius: 2.5, strength: 0.4, seed: 3 }; - return { - node: parent, - drawCalls: () => 1, - tick: (now, index) => field.update(index % 2 ? p : {}), - dispose: () => { - field.visible = false; // release the writer counter - field.dispose(); - } - }; - }); - } - - /* --- Portal --------------------------------------------------------- */ - const { Portal } = VFX_MODULES.get('Portal.js') ?? {}; - add('Portal.js', 'Portal', () => { - const parent = new Group(); - const portal = new Portal({}); - parent.add(portal.object3D); - portal.setPlacement(_to, _dirZ, _up); - const p = { open: 0.8, radiusX: 2, radiusY: 3, seed: 9 }; - return { - node: parent, - drawCalls: () => 1, - tick: (now, index) => portal.update(index % 2 ? p : {}), - dispose: () => portal.dispose() - }; - }); - - /* --- LiquidSurface: both modes -------------------------------------- */ - const { LiquidSurface, LiquidMode, liquidParams } = VFX_MODULES.get('LiquidSurface.js') ?? {}; - for (const [name, mode] of Object.entries(LiquidMode ?? {})) { - add('LiquidSurface.js', `LiquidSurface ${name}`, () => { - const parent = new Group(); - const surface = new LiquidSurface({ mode, segments: 24 }); - parent.add(surface.object3D); - surface.setPlacement(_to, _dirZ, _up); - const p = liquidParams(); - return { - node: parent, - drawCalls: () => surface.drawCalls, - tick: (now, index) => { - surface.update(now, p); - // rippleAtWorld() has to be called after update() — it needs this - // frame's half-extents to turn a metre into the fraction it stores. - if (index === 2) surface.rippleAtWorld(_to, 1, now); - void surface.lipHeight(p, 0); - }, - dispose: () => surface.dispose() - }; - }); - } - - /* --- Curtain: every mode, every layout ------------------------------ */ - const { Curtain, CurtainMode, CurtainLayout, curtainParams } = VFX_MODULES.get('Curtain.js') ?? {}; - const curtainCombos = []; - for (const mode of Object.values(CurtainMode ?? {})) curtainCombos.push([mode, CurtainLayout?.LINE ?? 0]); - for (const layout of Object.values(CurtainLayout ?? {})) curtainCombos.push([CurtainMode?.RAIN ?? 0, layout]); - for (const [mode, layout] of curtainCombos) { - add('Curtain.js', `Curtain mode:${mode} layout:${layout}`, () => { - const parent = new Group(); - const curtain = new Curtain({ mode, layout, capacity: 6, segmentsX: 8, segmentsY: 6, floor: true }); - parent.add(curtain.object3D); - curtain.setPlacement(_to, _side, _up); - curtain.roll(11); - const p = curtainParams(); - return { - node: parent, - drawCalls: () => curtain.drawCalls, - tick: (now) => curtain.update(now, p), - dispose: () => curtain.dispose() - }; - }); - } - - return cases; -} - -/** Frames each case is driven for before the zero-length one. */ -const VFX_FRAMES = 6; - -/** - * Drive one case and collect everything that went wrong with it. - * - * The zero-length frame at the end is not decoration: it is the same I1 probe - * the ability pause test runs, reduced to "does a `dt = 0` tick throw or - * produce a NaN". A module that divides by `dt` fails here and nowhere else. - */ -function runVfxCase(entry, failures) { - let handle; - try { - handle = entry.build(); - } catch (error) { - failures.push(`${entry.label}: construction threw — ${describeError(error)}`); - return; - } - - const problems = []; - try { - for (let i = 0; i < VFX_FRAMES; i++) { - frame.uTime.value += DT; - frame.uDelta.value = DT; - handle.tick(frame.uTime.value, i, DT); - } - // The zero-length frame — a paused slider drag. - frame.uDelta.value = 0; - handle.tick(frame.uTime.value, VFX_FRAMES, 0); - - if (handle.node) { - handle.node.updateMatrixWorld(true); - scanGroup(handle.node, problems); - } - - const calls = handle.drawCalls?.(); - if (Number.isFinite(calls) && calls > DRAW_CALL_BUDGET) { - problems.push(`draws ${calls} times on its own — the whole-ability budget is ${DRAW_CALL_BUDGET} (I4/I7)`); - } - } catch (error) { - problems.push(`threw while ticking — ${describeError(error)}`); - } - - try { - handle.dispose?.(); - } catch (error) { - problems.push(`dispose() threw — ${describeError(error)}`); - } - - for (const problem of problems) failures.push(`${entry.label}: ${problem}`); -} - -/** True for an ES class, which is what the coverage rule is looking for. */ -function isClass(value) { - return typeof value === 'function' && /^class\s/.test(Function.prototype.toString.call(value)); -} - -/** - * Run the whole stage. Reports through the roster-level channels, because a - * broken shared module is not any one ability's fault — it is everyone's. - */ -async function checkVfxLibrary() { - const failures = []; - - for (const [name, module] of VFX_MODULES) { - if (module.__error) failures.push(`${name}: failed to import — ${describeError(module.__error)}`); - } - - let cases = []; - try { - cases = vfxCases(); - } catch (error) { - failures.push(`building the case list threw — ${describeError(error)}`); - } - - for (const entry of cases) { - if (VFX_MODULES.get(entry.file)?.__error) continue; // already reported - runVfxCase(entry, failures); - } - - /* --- the coverage rule --- */ - const covered = new Set(cases.map((entry) => entry.file)); - for (const [name, module] of VFX_MODULES) { - if (module.__error) continue; - const classes = Object.entries(module).filter(([, value]) => isClass(value)); - if (!classes.length) continue; - if (!covered.has(name)) { - failures.push( - `${name} exports ${classes.map(([key]) => key).join(', ')} but has no case in VFX_CASES — ` + - `add one to scripts/check.mjs so the abilities built on it get an error here rather than a blank screen` - ); - } - } - - vfx.cases = cases.length; - vfx.modules = VFX_MODULES.size; - vfx.failures = failures; - for (const message of failures) failGlobal(`[vfx] ${message}`); - return failures.length === 0; -} - -/* ------------------------------------------------------------------ */ -/* §11 · Output */ -/* ------------------------------------------------------------------ */ - -function describeError(error) { - if (!(error instanceof Error)) return String(error); - const frameLine = (error.stack ?? '').split('\n').find((line) => line.includes('/src/')); - const where = frameLine ? dim(` (${frameLine.trim().replace(/^at\s+/, '')})`) : ''; - return `${error.message}${where}`; -} - -const safeNumber = (n) => (Number.isFinite(n) ? n.toFixed(2) : String(n)); - -/** `null` means the stage never ran — usually because an earlier one failed. */ -const markText = (state) => (state === null ? '—' : state ? 'ok' : 'FAIL'); -const colourMark = (state, text) => (state === null ? dim(text) : state ? green(text) : red(text)); - -/* ------------------------------------------------------------------ */ -/* Run */ -/* ------------------------------------------------------------------ */ - -checkRoster(); - -log(bold(`\ncheck · ${TARGET.length} of ${ABILITIES.length} abilities · node ${process.version}\n`)); - -for (const descriptor of TARGET) { - const report = reports.get(descriptor.id); - if (checkStructure(descriptor, report)) { - checkStaticReads(descriptor, report); - await captureConsole(report, () => runAbility(descriptor, report)); - } else { - // Without a settings block there is nothing coherent left to test. - checkStaticReads(descriptor, report); - } -} - -/* --- the shared library --- */ -// Skipped under `--only `, which exists to make one ability's loop fast; -// `--vfx` forces it back on when the library is what you are working on. -const RUN_VFX = !ONLY || flag('--vfx'); -if (RUN_VFX) await checkVfxLibrary(); - -/* --- the table --- */ -// Widths are measured on the *plain* text and the colour is wrapped around the -// finished cell: `padEnd` counts escape sequences as characters, so colouring -// first shifts every row that failed a few columns to the right. -const COLUMNS = [ - ['struct', 'struct'], - ['static', 'static'], - ['runtime', 'runtime'], - ['travel', 'pause:travel'], - ['impact', 'pause:impact'] -]; -const idWidth = Math.max(10, ...TARGET.map((a) => a.id.length + 2)); -const cellWidth = COLUMNS.map(([, title]) => title.length + 2); - -const row = (first, cells) => - first.padEnd(idWidth) + cells.map((text, i) => centre(text, cellWidth[i])).join(''); - -const centre = (text, size) => { - const pad = Math.max(0, size - text.length); - const left = Math.floor(pad / 2); - return ' '.repeat(left) + text + ' '.repeat(pad - left); -}; - -const headerLine = row('ability', COLUMNS.map(([, title]) => title)); -console.log(bold(headerLine)); -console.log(dim('─'.repeat(headerLine.length))); - -let failed = 0; -let skipped = false; -for (const descriptor of TARGET) { - const report = reports.get(descriptor.id); - if (!report.ok) failed++; - const cells = COLUMNS.map(([key], i) => { - if (report.marks[key] === null) skipped = true; - return colourMark(report.marks[key], centre(markText(report.marks[key]), cellWidth[i])); - }); - console.log(descriptor.id.padEnd(idWidth) + cells.join('')); -} -if (skipped) console.log(dim('— · not reached, because an earlier stage failed')); - -/* --- notes and warnings --- */ -if (!QUIET) { - for (const descriptor of TARGET) { - const report = reports.get(descriptor.id); - if (!report.notes.length && !report.warnings.length) continue; - console.log(`\n${bold(descriptor.id)}`); - for (const note of report.notes) console.log(dim(` ${note}`)); - for (const { stage, message } of report.warnings) console.log(yellow(` ! [${stage}] ${message}`)); - } - for (const warning of globalWarnings) console.log(yellow(`\n ! [roster] ${warning}`)); - - if (RUN_VFX) { - const state = vfx.failures?.length ? red(`${vfx.failures.length} failed`) : green('all pass'); - console.log(`\n${bold('vfx')}`); - console.log(dim(` ${vfx.cases} case(s) across ${vfx.modules} module(s) — ${state}`)); - } -} - -/* --- failures --- */ -if (failed || globalFailures.length) { - console.log(`\n${bold(red('FAILURES'))}`); - for (const message of globalFailures) console.log(`\n ${red('roster')}\n ${message}`); - for (const descriptor of TARGET) { - const report = reports.get(descriptor.id); - if (report.ok) continue; - console.log(`\n ${red(descriptor.id)}`); - for (const { stage, message } of report.failures) { - console.log(` ${bold(`[${stage}]`)} ${message}`); - } - } -} - -const total = TARGET.length; -const broken = failed + (globalFailures.length ? 1 : 0); -const warnings = TARGET.reduce((sum, a) => sum + reports.get(a.id).warnings.length, 0) + globalWarnings.length; -console.log(''); -if (warnings && QUIET) console.log(dim(`${warnings} warning(s) — drop --quiet to read them.`)); -if (broken) { - console.log(red(bold(`${failed} of ${total} abilities failed${globalFailures.length ? `, plus ${globalFailures.length} roster-level failure(s)` : ''}.`))); - process.exit(1); -} -console.log(green(bold(`all ${total} abilities pass.`))); diff --git a/scripts/check-vfx-distortion-portal.mjs b/scripts/check-vfx-distortion-portal.mjs deleted file mode 100644 index 0a539b8..0000000 --- a/scripts/check-vfx-distortion-portal.mjs +++ /dev/null @@ -1,360 +0,0 @@ -/** - * `scripts/check-vfx-distortion-portal.mjs` — the harness for - * `vfx/Distortion.js` and `vfx/Portal.js`. - * - * Separate from `scripts/check.mjs` for the same reason - * `check-vfx-tube-shell.mjs` is: that one walks the **registry**, and neither of - * these modules is reachable from an ability yet. Fold it in — or add a - * `"check:vfx"` script — the moment one is. - * - * No WebGL, so this cannot prove the GLSL compiles. Three things stand in: - * - * 1. **The module imports at all.** That sounds trivial and is not. Both files - * keep their shaders in tagged template literals, and a backtick inside a - * GLSL *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. This harness caught it twice - * while the modules were being written. - * 2. **Static shader sanity.** Every `uXxx` a stage references is declared in - * that stage, the two stages agree on their varyings, and the braces and the - * preprocessor balance. Between them those catch essentially every typo that - * survives writing the file, including the reserved-word collisions (`flat`) - * that only a real compiler would otherwise find. - * 3. **Behaviour.** The writer counter, the premultiplied blend setup, and - * **I1** — a value changed on a zero-length frame must move a uniform. - * - * Run: `node scripts/check-vfx-distortion-portal.mjs` - */ -import { Vector3, IcosahedronGeometry, CustomBlending, OneFactor, OneMinusSrcAlphaFactor } from 'three'; -import { DistortionField, DistortionMode, DistortionFacing } from '../src/vfx/Distortion.js'; -import { Portal } from '../src/vfx/Portal.js'; -import { LAYER, distortionWriters } from '../src/core/Layers.js'; -import { settings } from '../src/config/settings.js'; - -let failures = 0; -let checks = 0; - -function ok(condition, label, detail) { - checks++; - if (condition) return; - failures++; - console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`); -} - -function section(name) { - console.log(`\n${name}`); -} - -/* ---------------------------------------------------------------- */ -/* 1 · static shader sanity */ -/* ---------------------------------------------------------------- */ - -/** Identifiers three declares for us, which a stage may use without declaring. */ -const PROVIDED = new Set([ - 'uv', - 'uv1', - 'uv2', - 'position', - 'normal', - 'tangent', - 'projectionMatrix', - 'modelViewMatrix', - 'modelMatrix', - 'viewMatrix', - 'normalMatrix', - 'cameraPosition', - 'isOrthographic', - 'logDepthBufFC' -]); - -/** Strip comments so a name mentioned in prose is not read as a reference. */ -function stripComments(src) { - return src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' '); -} - -function declared(src, prefix) { - const out = new Set(); - const re = new RegExp( - `\\b(?:uniform|varying|attribute|in|out)\\s+(?:lowp\\s+|mediump\\s+|highp\\s+)?\\w+\\s+([^;]+);`, - 'g' - ); - let m; - while ((m = re.exec(src))) { - for (const name of m[1].split(',')) { - const clean = name.trim().replace(/\[.*$/, '').trim(); - if (clean.startsWith(prefix)) out.add(clean); - } - } - return out; -} - -function referenced(src, prefix) { - const out = new Set(); - const re = new RegExp(`\\b${prefix}[A-Z]\\w*`, 'g'); - let m; - while ((m = re.exec(src))) out.add(m[0]); - return out; -} - -function checkStage(label, src, prefix) { - const body = stripComments(src); - const decls = declared(body, prefix); - for (const name of referenced(body, prefix)) { - if (PROVIDED.has(name)) continue; - ok(decls.has(name), `${label}: ${name} referenced but not declared`); - } - return decls; -} - -function checkBalance(label, src) { - const body = stripComments(src); - let braces = 0; - let parens = 0; - let underflow = false; - for (const ch of body) { - if (ch === '{') braces++; - else if (ch === '}') braces--; - else if (ch === '(') parens++; - else if (ch === ')') parens--; - if (braces < 0) underflow = true; - } - ok(!underflow, `${label}: closing brace before an opening one`); - ok(braces === 0, `${label}: unbalanced braces`, `${braces}`); - ok(parens === 0, `${label}: unbalanced parentheses`, `${parens}`); - - let depth = 0; - let stray = false; - for (const line of body.split('\n')) { - const t = line.trim(); - if (/^#if|^#ifdef|^#ifndef/.test(t)) depth++; - else if (/^#endif/.test(t)) depth--; - if (depth < 0) stray = true; - } - ok(!stray, `${label}: #endif without a matching #if`); - ok(depth === 0, `${label}: unbalanced preprocessor`, `${depth}`); -} - -/** GLSL ES keywords and reserved words that must never be a local name. */ -const RESERVED = [ - 'flat', - 'smooth', - 'noperspective', - 'input', - 'output', - 'sample', - 'filter', - 'active', - 'asm', - 'union', - 'namespace', - 'using', - 'this', - 'packed', - 'cast' -]; - -function checkReserved(label, src) { - const body = stripComments(src); - for (const word of RESERVED) { - const re = new RegExp(`\\b(?:float|int|bool|vec2|vec3|vec4|mat2|mat3|mat4)\\s+${word}\\b`); - ok(!re.test(body), `${label}: "${word}" is reserved and cannot be a variable name`); - } -} - -function auditMaterial(label, material) { - const v = material.vertexShader; - const f = material.fragmentShader; - checkBalance(`${label} vertex`, v); - checkBalance(`${label} fragment`, f); - checkReserved(`${label} vertex`, v); - checkReserved(`${label} fragment`, f); - - const vu = checkStage(`${label} vertex`, v, 'u'); - const fu = checkStage(`${label} fragment`, f, 'u'); - for (const name of [...vu, ...fu]) { - if (PROVIDED.has(name)) continue; - ok(material.uniforms[name] !== undefined, `${label}: ${name} declared but has no uniform box`); - } - - // A varying written by the vertex stage must be declared identically in the - // fragment stage, or the link fails with a message that names neither file. - const vVary = declared(stripComments(v), 'v'); - const fVary = declared(stripComments(f), 'v'); - for (const name of fVary) { - ok(vVary.has(name), `${label}: fragment declares varying ${name} the vertex stage does not`); - } -} - -section('static shader sanity'); - -const audited = [ - ['HEAT', new DistortionField({ mode: DistortionMode.HEAT })], - ['LENS', new DistortionField({ mode: DistortionMode.LENS })], - ['SHOCK', new DistortionField({ mode: DistortionMode.SHOCK, facing: DistortionFacing.GROUND })], - ['BLADE', new DistortionField({ mode: DistortionMode.BLADE, edge: true })], - ['REFRACT', new DistortionField({ mode: DistortionMode.REFRACT, geometry: new IcosahedronGeometry(1, 1) })] -]; -for (const [label, field] of audited) { - auditMaterial(`Distortion:${label}`, field.material); - if (field.edgeMaterial) auditMaterial(`Distortion:${label}:edge`, field.edgeMaterial); -} - -const portal = new Portal(); -auditMaterial('Portal', portal.material); -console.log(` ${checks} static assertion(s)`); - -/* ---------------------------------------------------------------- */ -/* 2 · the writer counter */ -/* ---------------------------------------------------------------- */ - -section('the distortion writer counter'); - -const base = distortionWriters.count; -const probe = new DistortionField({ mode: DistortionMode.LENS, name: 'probe' }); -ok(probe.object3D.layers.mask === (1 << LAYER.DISTORTION), 'the emitter sits on LAYER.DISTORTION alone'); -ok(distortionWriters.count === base, 'constructing an emitter does not retain'); - -probe.visible = true; -ok(distortionWriters.count === base + 1, 'showing retains'); -probe.visible = true; -ok(distortionWriters.count === base + 1, 'showing twice retains once'); -probe.visible = false; -ok(distortionWriters.count === base, 'hiding releases'); -probe.visible = false; -ok(distortionWriters.count === base, 'hiding twice releases once'); -probe.visible = true; -probe.dispose(); -ok(distortionWriters.count === base, 'disposing a visible emitter releases'); - -/* ---------------------------------------------------------------- */ -/* 3 · I1 — a zero-length frame moves the uniforms */ -/* ---------------------------------------------------------------- */ - -section('I1 — every dimension re-resolves on a zero-length frame'); - -const p = {}; -const lens = new DistortionField({ mode: DistortionMode.LENS }); -p.width = 4; -p.height = 4; -p.radius = 2; -p.strength = 0.3; -p.core = 0.2; -p.invert = 0; -lens.update(p); -const before = { - size: lens.material.uniforms.uSize.value.x, - radius: lens.material.uniforms.uRadius.value, - strength: lens.material.uniforms.uStrength.value -}; -p.width = 9; -p.radius = 7.5; -p.strength = 0.9; -lens.update(p); // no dt, no render — exactly what a paused frame does -ok(lens.material.uniforms.uSize.value.x === 9, 'width reaches uSize with the clock stopped', `${before.size} → 9`); -ok(lens.material.uniforms.uRadius.value === 7.5, 'radius reaches uRadius with the clock stopped'); -ok(lens.material.uniforms.uStrength.value === 0.9, 'strength reaches uStrength with the clock stopped'); - -const savedPost = settings.post.distortion; -const savedGlobal = settings.global.distortion; -settings.post.distortion = 0.08; -settings.global.distortion = 2; -lens.update(p); -ok( - Math.abs(lens.material.uniforms.uPostScale.value - 0.16) < 1e-9, - 'uPostScale mirrors post.distortion × global.distortion', - String(lens.material.uniforms.uPostScale.value) -); -settings.post.distortion = savedPost; -settings.global.distortion = savedGlobal; - -// The fold guard is the one place the module reads settings, and it must not -// have folded the master gains into the strength as well. -lens.update(p); -ok(lens.material.uniforms.uStrength.value === 0.9, 'strength is never pre-multiplied by the global gains'); - -/* ---------------------------------------------------------------- */ -/* 4 · the blade shares its uniform boxes with its hairline */ -/* ---------------------------------------------------------------- */ - -section('the blade and its hairline are one plane'); - -const blade = new DistortionField({ mode: DistortionMode.BLADE, edge: true }); -const bu = blade.material.uniforms; -const eu = blade.edgeMaterial.uniforms; -for (const key of ['uAnchor', 'uSize', 'uPivot', 'uAxisX', 'uAxisY', 'uCut', 'uEdge', 'uOpacity']) { - ok(bu[key] === eu[key], `${key} is the same uniform box in both passes`); -} -blade.setAnchorXYZ(1, 2, 3); -ok(eu.uAnchor.value.x === 1 && eu.uAnchor.value.z === 3, 'moving the blade moves the hairline'); -blade.setBasis(new Vector3(0, 0, 1), new Vector3(0, 1, 0)); -ok(Math.abs(eu.uAxisX.value.z - 1) < 1e-6, 'setBasis writes the shared axis'); -ok(Math.abs(eu.uAxisY.value.y - 1) < 1e-6, 'setBasis re-orthogonalises up against along'); -ok(blade.edgeMesh.layers.mask === (1 << LAYER.VFX), 'the hairline is on LAYER.VFX, not the offset buffer'); - -/* ---------------------------------------------------------------- */ -/* 5 · Portal */ -/* ---------------------------------------------------------------- */ - -section('Portal'); - -const rift = new Portal({ name: 'rift' }); -const ru = rift.material.uniforms; - -ok(rift.material.blending === CustomBlending, 'the portal blends custom'); -ok(rift.material.blendSrc === OneFactor, 'premultiplied: src = ONE'); -ok(rift.material.blendDst === OneMinusSrcAlphaFactor, 'premultiplied: dst = ONE_MINUS_SRC_ALPHA'); -ok(rift.material.blendSrcAlpha === OneFactor, 'premultiplied: srcAlpha = ONE'); -ok(rift.material.blendDstAlpha === OneMinusSrcAlphaFactor, 'premultiplied: dstAlpha = ONE_MINUS_SRC_ALPHA'); -ok(rift.material.depthTest === true, 'depthTest is on — nearer opaque geometry hides the hole'); -ok(rift.material.depthWrite === false, 'depthWrite is off by default'); -ok(new Portal({ writeDepth: true }).material.depthWrite === true, 'writeDepth: true turns depthWrite on'); - -const po = { radiusX: 3, radiusY: 1, margin: 0.5, open: 0.25 }; -rift.update(po); -ok(ru.uRadii.value.x === 3 && ru.uRadii.value.y === 1, 'the radii reach uRadii'); -ok(ru.uSize.value.x === 9 && ru.uSize.value.y === 3, 'the quad holds the aperture plus the crack margin'); -ok(ru.uOpen.value === 0.25, 'open reaches uOpen'); -po.radiusX = 6; -po.open = 0.8; -rift.update(po); -ok(ru.uSize.value.x === 18, 'resizing on a zero-length frame re-sizes the quad'); -ok(ru.uOpen.value === 0.8, 'the tear progresses on a zero-length frame'); - -// I5: ten pickers, none derived from another, none sharing a Color instance. -const colourKeys = Object.keys(ru).filter((k) => k.startsWith('uColor')); -ok(colourKeys.length === 10, 'the portal exposes ten colours', String(colourKeys.length)); -const instances = new Set(colourKeys.map((k) => ru[k].value)); -ok(instances.size === 10, 'no two colours share a Color instance'); - -rift.update({ ...po, colorRim: '#ff0000', colorCore: '#00ff00' }); -ok(ru.uColorRim.value.r > 0.9 && ru.uColorRim.value.g < 0.01, 'colorRim reaches its uniform'); -ok(ru.uColorCore.value.g > 0.9 && ru.uColorCore.value.r < 0.01, 'colorCore is independent of colorRim'); - -/* ---------------------------------------------------------------- */ -/* 6 · no allocation in the frame path */ -/* ---------------------------------------------------------------- */ - -section('I3 — the frame path does not allocate'); - -const sizeBox = ru.uSize.value; -const anchorBox = ru.uAnchor.value; -const colourBox = ru.uColorRim.value; -rift.setPlacement(new Vector3(1, 2, 3), new Vector3(1, 0, 0), new Vector3(0, 1, 0)); -rift.update(po); -ok(ru.uSize.value === sizeBox, 'update() mutates uSize rather than replacing it'); -ok(ru.uAnchor.value === anchorBox, 'setPlacement() mutates uAnchor rather than replacing it'); -ok(ru.uColorRim.value === colourBox, 'update() mutates the Color rather than replacing it'); - -const lensSize = lens.material.uniforms.uSize.value; -lens.update(p); -ok(lens.material.uniforms.uSize.value === lensSize, 'the field mutates its uniform boxes too'); - -/* ---------------------------------------------------------------- */ - -console.log(''); -if (failures === 0) { - console.log(`Distortion + Portal: all ${checks} assertion(s) pass.`); -} else { - console.error(`Distortion + Portal: ${failures} of ${checks} assertion(s) FAILED.`); - process.exitCode = 1; -} diff --git a/scripts/check-vfx-ink.mjs b/scripts/check-vfx-ink.mjs deleted file mode 100644 index 2279fd3..0000000 --- a/scripts/check-vfx-ink.mjs +++ /dev/null @@ -1,629 +0,0 @@ -/** - * A bench for `vfx/BrushStroke.js` and `vfx/InkDiffusion.js` — the Ink school. - * - * `npm run check` drives both modules for a handful of frames and looks for - * NaN. That is the right job for a shared stage and it is not enough for these - * two, because almost everything interesting about them is either in a shader - * (which nothing here can run) or is a *distribution* rather than a value. - * What this file adds: - * - * 1. **Static shader sanity.** Balanced braces, no reserved word used as a - * variable, no backtick, no `cosh`, every `u*` a stage references declared - * in it and boxed on the material, every fragment varying declared by the - * vertex stage, and no `fwidth` in a vertex stage. These are the failures - * that survive writing the file and turn up as a blank screen. - * 2. **The anti-glow contract.** This is the school's entire premise and it is - * one careless line from being lost, so it is asserted rather than trusted: - * no `uGlobalGlow` anywhere, no additive blending, a luminance ceiling in - * both fragment stages, and a default ceiling strictly below the shipped - * `post.bloomThreshold`. - * 3. **The bristle model.** A dry tail is only a dry tail if the bristles are - * distinct and their ink loads are not. Layout, spread and dice are checked - * per tip, and so is the parking path — the one that must not shuffle a - * stroke into another stroke's slot. - * 4. **The dispersion relation.** The instability's whole claim is that finer - * modes are *released* as the front grows. The JS mirror of the amplitude - * schedule asserts that a small front has one live mode and a large one has - * all five, and that the front law's closed-form inverse really inverts it. - * 5. **The power law.** The satellites are the readable half of a splatter and - * a uniform draw would look like a stencil. The bounded-Pareto inverse CDF - * is sampled four thousand times and its shape is asserted. - * 6. **I1** — a value changed on a zero-length frame must move a uniform. - * - * Run: `node scripts/check-vfx-ink.mjs` - */ -import { Group, Vector3 } from 'three'; -import { BrushStroke, BrushTip, BRUSH_TIP_NAMES, brushStrokeParams } from '../src/vfx/BrushStroke.js'; -import { InkDiffusion, InkMode, INK_MODE_NAMES, inkDiffusionParams } from '../src/vfx/InkDiffusion.js'; -import { settings } from '../src/config/settings.js'; - -let failures = 0; -let checks = 0; - -function ok(condition, label, detail) { - checks++; - if (condition) return; - failures++; - console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`); -} - -function section(name) { - console.log(`\n${name}`); -} - -const near = (a, b, eps, label) => ok(Math.abs(a - b) <= eps, label, `${a} vs ${b}`); - -/* ---------------------------------------------------------------- */ -/* 1 · static shader sanity */ -/* ---------------------------------------------------------------- */ - -/** Identifiers three declares for us, which a stage may use without declaring. */ -const PROVIDED = new Set([ - 'uv', - 'position', - 'normal', - 'projectionMatrix', - 'modelViewMatrix', - 'modelMatrix', - 'viewMatrix', - 'normalMatrix', - 'cameraPosition' -]); - -const RESERVED = [ - 'flat', - 'smooth', - 'noperspective', - 'input', - 'output', - 'sample', - 'filter', - 'active', - 'asm', - 'union', - 'namespace', - 'using', - 'this', - 'packed', - 'cast', - 'goto', - 'switch', - 'default', - 'inline', - 'volatile', - 'public', - 'static', - 'extern', - 'external', - 'interface', - 'long', - 'short', - 'double', - 'half', - 'fixed', - 'unsigned', - 'sizeof' -]; - -const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' '); - -function declared(src, prefix) { - const out = new Set(); - const re = /\b(?:uniform|varying|attribute|in|out)\s+(?:lowp\s+|mediump\s+|highp\s+)?\w+\s+([^;]+);/g; - let m; - while ((m = re.exec(src))) { - for (const name of m[1].split(',')) { - const clean = name.trim().replace(/\[.*$/, '').trim(); - if (clean.startsWith(prefix)) out.add(clean); - } - } - return out; -} - -function referenced(src, prefix) { - const out = new Set(); - const re = new RegExp(`\\b${prefix}[A-Z]\\w*`, 'g'); - let m; - while ((m = re.exec(src))) out.add(m[0]); - return out; -} - -function checkBalance(label, src) { - const body = stripComments(src); - let braces = 0; - let parens = 0; - let underflow = false; - for (const ch of body) { - if (ch === '{') braces++; - else if (ch === '}') braces--; - else if (ch === '(') parens++; - else if (ch === ')') parens--; - if (braces < 0) underflow = true; - } - ok(!underflow, `${label}: closing brace before an opening one`); - ok(braces === 0, `${label}: unbalanced braces`, `${braces}`); - ok(parens === 0, `${label}: unbalanced parentheses`, `${parens}`); -} - -/** `#if` / `#else` / `#endif` must nest, or the whole program disappears. */ -function checkPreprocessor(label, src) { - let depth = 0; - let underflow = false; - for (const line of src.split('\n')) { - const directive = line.trim(); - if (/^#if(n?def)?\b/.test(directive)) depth++; - else if (/^#endif\b/.test(directive)) depth--; - if (depth < 0) underflow = true; - } - ok(!underflow, `${label}: #endif without a matching #if`); - ok(depth === 0, `${label}: ${depth} unterminated #if`); -} - -function auditMaterial(label, material) { - const v = material.vertexShader; - const f = material.fragmentShader; - for (const [stage, src] of [ - [`${label} vertex`, v], - [`${label} fragment`, f] - ]) { - checkBalance(stage, src); - checkPreprocessor(stage, src); - const body = stripComments(src); - for (const word of RESERVED) { - const re = new RegExp(`\\b(?:float|int|bool|vec2|vec3|vec4|mat2|mat3|mat4)\\s+${word}\\b`); - ok(!re.test(body), `${stage}: "${word}" is reserved and cannot be a variable name`); - } - for (const name of referenced(body, 'u')) { - if (PROVIDED.has(name)) continue; - ok(declared(body, 'u').has(name), `${stage}: ${name} referenced but not declared`); - } - for (const name of declared(body, 'u')) { - if (PROVIDED.has(name)) continue; - ok(material.uniforms[name] !== undefined, `${stage}: ${name} declared but has no uniform box`); - } - } - - ok(!v.includes('`') && !f.includes('`'), `${label}: a backtick in the shader source`); - ok(!/\bcosh\s*\(/.test(v + f), `${label}: cosh() does not exist in ESSL 1.00`); - // Derivatives are fragment-only: injecting commonGLSL into a vertex stage - // takes aastep's fwidth with it and fails the whole program to compile. - ok(!/\bfwidth\s*\(/.test(v), `${label}: vertex stage is free of fwidth`); - - const vVary = declared(stripComments(v), 'v'); - for (const name of declared(stripComments(f), 'v')) { - ok(vVary.has(name), `${label}: fragment declares varying ${name} the vertex stage does not`); - } -} - -section('static shader sanity'); - -const _up = new Vector3(0, 1, 0); -const _from = new Vector3(0, 0.15, 0); -const _to = new Vector3(0, 0.15, 11); -const _dirZ = new Vector3(0, 0, 1); -const _probe = new Vector3(); - -const benchParent = new Group(); -const brushes = Object.values(BrushTip).map( - (tip) => new BrushStroke(benchParent, { strokes: 3, bristles: 8, samples: 10, sides: 6, tip }) -); -const fields = Object.values(InkMode).map( - (mode) => new InkDiffusion(benchParent, { mode, sources: 4, satellites: 8 }) -); - -auditMaterial('BrushStroke', brushes[0].material); -for (const field of fields) { - auditMaterial(`InkDiffusion ${INK_MODE_NAMES[field.mode]}`, field.material); -} - -/** - * Drop every `#if INK_MODE == n ... #endif` region that this mode does not - * take, the way the GLSL preprocessor will. Crude — it only understands the one - * directive form this file uses — but it is enough to prove the guards are on - * the right blocks, which is the thing a string search cannot tell you. - */ -function activeSource(src, mode) { - const out = []; - let skipping = 0; - for (const line of src.split('\n')) { - const trimmed = line.trim(); - let m = /^#if\s+INK_MODE\s*(==|!=)\s*(\d+)/.exec(trimmed); - if (m) { - const takes = m[1] === '==' ? mode === Number(m[2]) : mode !== Number(m[2]); - skipping = takes ? 0 : 1; - continue; - } - if (/^#(else|endif)\b/.test(trimmed)) { - skipping = /^#else/.test(trimmed) ? 1 - skipping : 0; - continue; - } - if (!skipping) out.push(line); - } - return out.join('\n'); -} - -// The mode is a #define, so the satellite loop must genuinely not exist outside -// SPLATTER — an eight-iteration loop per fragment over the whole zone is not -// something a bloom should be paying for. -for (const field of fields) { - const live = activeSource(field.material.fragmentShader, field.mode); - const wantsSatellites = field.mode === InkMode.SPLATTER; - ok( - live.includes('uSatDice[i]') === wantsSatellites, - `${INK_MODE_NAMES[field.mode]}: the satellite loop is ${wantsSatellites ? 'in' : 'compiled out'}` - ); - ok( - live.includes('fingers(q') === (field.mode !== InkMode.WASH), - `${INK_MODE_NAMES[field.mode]}: the instability is ${field.mode === InkMode.WASH ? 'compiled out' : 'in'}` - ); - ok( - live.includes('uCrown *') === wantsSatellites, - `${INK_MODE_NAMES[field.mode]}: the crown is ${wantsSatellites ? 'in' : 'compiled out'}` - ); - checkBalance(`${INK_MODE_NAMES[field.mode]} after preprocessing`, live); -} - -// Uniform arrays may be subscripted only by a loop counter in ESSL 1.00, and -// the obvious `uSatDice[someUniform]` compiles on the desktop driver and fails -// on the one the player has. The declarations are the only other subscript. -for (const field of fields) { - const body = stripComments(field.material.fragmentShader).replace( - /\buniform\s+vec4\s+u\w+\[[^\]]+\];/g, - ' ' - ); - ok( - !/u(?:Sat|Source)Dice\[\s*(?!i\s*\])/.test(body), - `${INK_MODE_NAMES[field.mode]}: uniform arrays are indexed only by the loop counter` - ); -} - -/* ---------------------------------------------------------------- */ -/* 2 · the anti-glow contract */ -/* ---------------------------------------------------------------- */ - -section('the anti-glow contract'); - -for (const [label, material] of [ - ['BrushStroke', brushes[0].material], - ['InkDiffusion', fields[0].material] -]) { - ok(material.toneMapped === true, `${label}: declares itself tone mapped`); - ok(material.blending === 1, `${label}: blends normally, never additively`, `${material.blending}`); - ok( - !stripComments(material.fragmentShader).includes('uGlobalGlow'), - `${label}: the global glow slider cannot reach ink` - ); - ok(material.fragmentShader.includes('uCeiling'), `${label}: clamps its output luminance`); -} - -// The bloom high pass runs on linear colour before the tone map, so its -// threshold is a linear luminance and the ceiling has to sit under it. -const threshold = settings.post.bloomThreshold; -ok( - brushStrokeParams().ceiling < threshold, - 'BrushStroke ceiling is under post.bloomThreshold', - `${brushStrokeParams().ceiling} vs ${threshold}` -); -ok( - inkDiffusionParams().ceiling < threshold, - 'InkDiffusion ceiling is under post.bloomThreshold', - `${inkDiffusionParams().ceiling} vs ${threshold}` -); - -/* ---------------------------------------------------------------- */ -/* 3 · the bristle model */ -/* ---------------------------------------------------------------- */ - -section('the bristle model'); - -for (const brush of brushes) { - const label = BRUSH_TIP_NAMES[brush.tip]; - const attr = brush.geometry.attributes.aBrush.array; - const n = brush.bristles; - const across = []; - const through = []; - for (let b = 0; b < n; b++) { - across.push(attr[b * 4 + 0]); - through.push(attr[b * 4 + 1]); - } - - // Distinct slots. Two bristles in the same place is one bristle with twice - // the ink, and the streak it leaves never breaks. - const unique = new Set(across.map((x, i) => `${x.toFixed(4)}:${through[i].toFixed(4)}`)); - ok(unique.size === n, `${label}: every bristle has its own place in the ferrule`, `${unique.size}/${n}`); - ok( - Math.max(...across.map(Math.abs)) <= 1.0001, - `${label}: no bristle hangs outside the ferrule` - ); - - if (brush.tip === BrushTip.FLAT) { - let ordered = true; - for (let b = 1; b < n; b++) if (across[b] <= across[b - 1]) ordered = false; - ok(ordered, 'FLAT: one rank, in order across the width'); - ok(through.every((t) => t === 0), 'FLAT: nothing spread through the paper normal'); - } - if (brush.tip === BrushTip.ROUND) { - ok( - through.filter((t) => Math.abs(t) > 0.1).length >= n / 2, - 'ROUND: most bristles are off the mid-plane' - ); - } - if (brush.tip === BrushTip.SPLIT) { - const sorted = [...across].sort((a, b) => a - b); - const gaps = []; - for (let b = 1; b < n; b++) gaps.push(sorted[b] - sorted[b - 1]); - const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length; - ok( - gaps.filter((g) => g > mean * 1.5).length >= 2, - 'SPLIT: at least two tuft gaps wider than the mean spacing', - gaps.map((g) => g.toFixed(3)).join(' ') - ); - } - - // Loads must differ or the tail is a clean cut, which is the whole failure - // this module exists to avoid. - const dice = new Set(); - for (let i = 0; i < brush.capacity; i++) dice.add(attr[i * 4 + 2].toFixed(6)); - ok(dice.size >= brush.capacity - 1, `${label}: ink-load dice are per bristle`, `${dice.size}`); -} - -// Parking. Stroke 1 goes inactive and must vanish without moving strokes 0 or 2. -{ - const brush = brushes[0]; - const p = brushStrokeParams(); - p.progress = 1; - brush.setPaper(_up); - for (let s = 0; s < 3; s++) { - brush.stroke(s).line(_from, _to, 0.3 * s, 0).pressure(0.1, 1, 0.8, 0.05).ink(7).timing(0, 1); - } - brush.update(0, p); - const before = brush.geometry.attributes.aP1.array.slice(); - const seedBefore = brush.stroke(2).seed; - brush.stroke(1).active = false; - brush.update(0, p); - const slot = 1 * brush.bristles * 4; - ok( - brush.geometry.attributes.aStroke.array[slot] > 1, - 'a parked stroke has its draw window pushed past the clock' - ); - const after = brush.geometry.attributes.aP1.array; - let moved = false; - for (let i = 2 * brush.bristles * 3; i < after.length; i++) { - if (after[i] !== before[i]) moved = true; - } - ok(!moved, 'parking stroke 1 does not slide stroke 2 into another slot'); - ok(brush.stroke(2).seed === seedBefore, 'and its seed stays with its slot'); -} - -/* ---------------------------------------------------------------- */ -/* 4 · the CPU mirrors of the spine and the pressure curve */ -/* ---------------------------------------------------------------- */ - -section('the spine mirrors'); - -{ - const brush = brushes[0]; - const stroke = brush.stroke(0); - stroke.line(_from, _to, 0.8, 0.35).pressure(0.11, 1, 0.77, 0.04).timing(0.25, 0.5); - - brush.pointAt(0, 0, _probe); - near(_probe.distanceTo(_from), 0, 1e-6, 'pointAt(0) is the start of the stroke'); - brush.pointAt(0, 1, _probe); - near(_probe.distanceTo(_to), 0, 1e-6, 'pointAt(1) is the end of it'); - near(brush.pressureOf(0, 0), 0.11, 1e-6, 'the entry pressure is hit exactly'); - near(brush.pressureOf(0, 1), 0.04, 1e-6, 'so is the exit'); - ok( - brush.pressureOf(0, 0.5) > brush.pressureOf(0, 0), - 'the body carries more weight than the entry' - ); - - const p = brushStrokeParams(); - p.progress = 0.2; - brush.update(0, p); - near(brush.headOf(0), 0, 1e-9, 'a stroke whose window has not opened is unwritten'); - p.progress = 0.5; - brush.update(0, p); - near(brush.headOf(0), 0.5, 1e-6, 'halfway through its window is halfway through the stroke'); - p.progress = 0.9; - brush.update(0, p); - near(brush.headOf(0), 1, 1e-9, 'and past the end of it, it is finished'); - brush.tipPoint(0, _probe); - near(_probe.distanceTo(_to), 0, 1e-6, 'the brush ends up where the stroke does'); -} - -/* ---------------------------------------------------------------- */ -/* 5 · the dispersion relation */ -/* ---------------------------------------------------------------- */ - -section('the fingering instability'); - -/** - * The JS mirror of `fingers()`'s amplitude schedule. Not used at runtime — the - * shader owns the field — but the *admission order* is the physical claim the - * module makes and the thing that separates a growing bloom from an fbm ring - * being scaled up, so it is worth being able to fail on it. - * - * Returns the amplitude, in metres, of each octave at a given front radius. - * Octave 0 is the coarsest. - */ -function octaveAmplitudes(p, frontRadius) { - const out = []; - let L = Math.max(p.coarse, 0.05); - for (let i = 0; i < 5; i++) { - const onset = p.onset * L; - const e = Math.min((p.growth * Math.max(frontRadius - onset, 0)) / L, 8); - out.push(L * Math.min(Math.exp(e) - 1, p.growthMax)); - L *= 0.5; - } - return out; -} - -const liveCount = (p, r) => octaveAmplitudes(p, r).filter((a) => a > 1e-4).length; - -{ - const p = inkDiffusionParams(); - const finest = p.coarse / 16; - - ok(liveCount(p, p.onset * finest * 0.5) === 0, 'a nucleus smaller than every mode is a disc'); - ok(liveCount(p, p.onset * finest * 1.4) === 1, 'the finest crinkle arrives first'); - ok( - liveCount(p, p.onset * p.coarse * 0.9) === 4, - 'the coarsest lobe is still inadmissible just below its own onset' - ); - ok(liveCount(p, p.onset * p.coarse * 1.5) === 5, 'and a mature front has grown into all five'); - - let previous = -1; - let monotone = true; - for (let r = 0; r <= 12; r += 0.05) { - const live = liveCount(p, r); - if (live < previous) monotone = false; - previous = live; - } - ok(monotone, 'modes are only ever admitted, never withdrawn'); - - // The whole shape claim: a young front is crinkle, a mature one is lobes with - // the crinkle riding on them. So the coarse octave must go from contributing - // nothing to dominating, and the fine one must never dominate again. - const young = octaveAmplitudes(p, 0.4); - const old = octaveAmplitudes(p, 9); - ok(young[0] === 0 && young[4] > 0, 'a young bloom is all crinkle and no lobe'); - ok(old[0] > old[4] * 8, 'a mature bloom is dominated by its coarsest lobe', `${old[0] / old[4]}`); - ok(old[4] > 0, 'and it keeps the crinkle'); - - // Saturation: without it the first octave to mature runs away and the bloom - // is two lobes for ever. With it, a mature mode settles at its own wavelength. - near(old[0], p.coarse * p.growthMax, 1e-9, 'a mature mode saturates at growthMax wavelengths'); -} - -{ - const field = fields[InkMode.BLOOM]; - const p = inkDiffusionParams(); - field.setPlacement(_to, _dirZ); - field.update(2.25, p); - const r = field.frontRadius(0); - near(r, p.spread * Math.sqrt(2.25), 1e-9, 'Fickian spreading: r goes as the square root of t'); - // The closed-form inverse is what gives every fragment its arrival time, and - // therefore the whole wet-to-dry gradient. If it stops inverting, the gloss - // detaches from the front. - const arrival = Math.pow(r / p.spread, 1 / p.spreadPower); - near(arrival, 2.25, 1e-6, 'arrivalOf(frontAt(t)) is t'); -} - -/* ---------------------------------------------------------------- */ -/* 6 · the satellites */ -/* ---------------------------------------------------------------- */ - -section('splatter morphology'); - -{ - const field = fields[InkMode.SPLATTER]; - const p = inkDiffusionParams(); - p.satellites = 8; - field.setPlacement(_to, _dirZ); - field.roll(3); - field.update(3, p); - - const sizes = []; - for (let i = 0; i < field.satellites; i++) sizes.push(field.satelliteSize(i)); - ok( - sizes.every((s) => s >= p.satMin - 1e-6 && s <= p.satMax + 1e-6), - 'every droplet is inside the authored size range' - ); - - // The distribution, sampled properly. A bounded Pareto with exponent 2.3 puts - // most of its mass at the small end; a uniform draw would put half of it - // above the midpoint and the splatter would read as a stencil of dots. - const a = p.satAlpha; - const loA = Math.pow(p.satMin, -a); - const hiA = Math.pow(p.satMax, -a); - const draw = (u) => Math.pow(u * (hiA - loA) + loA, -1 / a); - near(draw(0), p.satMin, 1e-9, 'the draw bottoms out at satMin'); - near(draw(1), p.satMax, 1e-9, 'and tops out at satMax'); - let big = 0; - const N = 4096; - for (let i = 0; i < N; i++) if (draw((i + 0.5) / N) > p.satMin * 2) big++; - ok(big / N < 0.35, 'fewer than a third of droplets are twice the minimum', `${(big / N).toFixed(3)}`); - ok(draw(0.5) < (p.satMin + p.satMax) * 0.5, 'the median sits well below the midpoint'); - - // Small droplets fly furthest. If this correlation inverts, the far field - // becomes a row of large blobs and the throw stops reading as a throw. - let smallest = 0; - let largest = 0; - for (let i = 1; i < field.satellites; i++) { - if (field.satelliteSize(i) < field.satelliteSize(smallest)) smallest = i; - if (field.satelliteSize(i) > field.satelliteSize(largest)) largest = i; - } - ok( - field.satelliteReach(smallest) > field.satelliteReach(largest), - 'the smallest droplet outflies the largest' - ); - - // Placement, against a rotated cast — the mirror splatterbrand aims with. - const along = new Vector3(1, 0, 0); - field.setPlacement(_to, along); - field.update(3, p); - for (let i = 0; i < field.satellites; i++) { - field.satellitePoint(i, _probe); - const reach = field.satelliteReach(i); - near(_probe.x - _to.x, reach, 1e-5, `satellite ${i} lands down the travel vector`); - ok( - Math.abs(_probe.z - _to.z) <= p.throwSpread * reach + 1e-5, - `satellite ${i} stays inside the throw cone` - ); - near(_probe.y, p.height, 1e-9, `satellite ${i} lands on the floor`); - } - - // And the canvas has to reach past the furthest one, tail included, or the - // far field is clipped and the whole thing reads as a rectangle of ink. - const half = field.mesh.scale.x * 0.5; - ok( - half >= p.throwFar + p.satMax * (1 + p.satTail), - 'the quad covers the furthest droplet and its tail', - `${half.toFixed(2)} m` - ); -} - -/* ---------------------------------------------------------------- */ -/* 7 · I1 — a paused slider must move something */ -/* ---------------------------------------------------------------- */ - -section('I1 on a zero-length frame'); - -{ - const brush = brushes[0]; - const p = brushStrokeParams(); - brush.update(1, p); - const before = brush.geometry.attributes.aStroke.array[3]; - p.width = 0.9; - p.inkLoad = 2.5; - p.dryBand = 5.5; - brush.update(1, p); // same clock, new sliders - ok(brush.material.uniforms.uWidth.value === 0.9, 'a paused width drag reaches the uniform'); - ok(brush.material.uniforms.uDryBand.value === 5.5, 'so does the dry band'); - ok(brush.geometry.attributes.aStroke.array[3] !== before, 'and the ink load re-expands'); - - const field = fields[InkMode.BLOOM]; - const q = inkDiffusionParams(); - field.update(1.5, q); - const scaleBefore = field.mesh.scale.x; - q.coarse = 0.75; - q.radius = 11; - q.dryTime = 4; - field.update(1.5, q); - ok(field.material.uniforms.uCoarse.value === 0.75, 'a paused finger-wavelength drag lands'); - ok(field.material.uniforms.uDryTime.value === 4, 'so does the drying time'); - ok(field.mesh.scale.x !== scaleBefore, 'and the canvas re-sizes to the new radius'); -} - -/* ---------------------------------------------------------------- */ - -for (const brush of brushes) brush.dispose(); -for (const field of fields) field.dispose(); -ok(benchParent.children.length === 0, 'dispose() takes every node back off the parent'); - -console.log( - failures === 0 - ? `\n✓ ${checks} assertions, no failures\n` - : `\n✗ ${failures} of ${checks} assertions failed\n` -); -process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/check-vfx-lensflare-mirror.mjs b/scripts/check-vfx-lensflare-mirror.mjs deleted file mode 100644 index f11b412..0000000 --- a/scripts/check-vfx-lensflare-mirror.mjs +++ /dev/null @@ -1,439 +0,0 @@ -/** - * A bench for `vfx/LensFlare.js` and `vfx/Mirror.js`. - * - * `npm run check` drives both modules for a handful of frames and looks for - * NaN, which is the right thing for a shared stage to do and is not enough for - * either of these two. What is specific here: - * - * 1. **Static shader sanity.** Balanced braces and preprocessor, no reserved - * word used as a variable, every `u*` referenced by a stage declared in it, - * every declared uniform actually boxed, every fragment varying declared by - * the vertex stage. Those are the failures that survive writing the file - * and turn up as a blank screen and a link error naming no file. - * 2. **`commonGLSL` is not injected into a vertex stage.** It carries `aastep`, - * which calls `fwidth`, and derivatives do not exist in a vertex shader — - * the whole program fails to compile, pointing at a helper you are not - * using. `LensFlare`'s vertex stage restates the two helpers it wants; this - * asserts nobody "tidies" that back into an injection. - * 3. **The mirror scheduler**, which is pure CPU and is the part most likely - * to be wrong: a frame token that survives nested `render()` calls, a hard - * per-frame budget, and a starvation weighting that must not leave one - * mirror showing a frozen reflection forever. - * 4. **I1** — a value changed on a zero-length frame must move a uniform. - * - * Run: `node scripts/check-vfx-lensflare-mirror.mjs` - */ -import { Scene, Group, PerspectiveCamera, Vector3 } from 'three'; -import { LensFlare, lensFlareParams, MAX_FLARE_GHOSTS } from '../src/vfx/LensFlare.js'; -import { Mirror, mirrorParams, mirrorBudget, setMirrorBudget } from '../src/vfx/Mirror.js'; -import { LAYER } from '../src/core/Layers.js'; -import { frame } from '../src/core/FrameUniforms.js'; - -let failures = 0; -let checks = 0; - -function ok(condition, label, detail) { - checks++; - if (condition) return; - failures++; - console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`); -} - -function section(name) { - console.log(`\n${name}`); -} - -/* ---------------------------------------------------------------- */ -/* 1 · static shader sanity */ -/* ---------------------------------------------------------------- */ - -/** Identifiers three declares for us, which a stage may use without declaring. */ -const PROVIDED = new Set([ - 'uv', - 'position', - 'normal', - 'projectionMatrix', - 'modelViewMatrix', - 'modelMatrix', - 'viewMatrix', - 'normalMatrix', - 'cameraPosition' -]); - -const RESERVED = [ - 'flat', - 'smooth', - 'noperspective', - 'input', - 'output', - 'sample', - 'filter', - 'active', - 'asm', - 'union', - 'namespace', - 'using', - 'this', - 'packed', - 'cast' -]; - -/** - * Built-in function names that read like perfectly ordinary variable names. - * - * `three` converts a `ShaderMaterial` (as opposed to a `RawShaderMaterial`) to - * `#version 300 es`, so the ES 3.00 built-in set applies — and `round` is in - * it. `float round = …` inside an iris SDF is the obvious name, compiles on - * some drivers, and is refused by others; the flare's aperture parameter is - * called `roundness` for exactly that reason and this list is what stops it - * quietly going back. - */ -const SHADOWS = [ - 'round', - 'sign', - 'step', - 'mix', - 'length', - 'distance', - 'reflect', - 'refract', - 'fract', - 'floor', - 'ceil', - 'trunc', - 'min', - 'max', - 'abs', - 'mod', - 'pow', - 'log', - 'exp', - 'sqrt', - 'cross', - 'dot', - 'texture', - 'degrees', - 'radians' -]; - -const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' '); - -function declared(src, prefix) { - const out = new Set(); - const re = /\b(?:uniform|varying|attribute|in|out)\s+(?:lowp\s+|mediump\s+|highp\s+)?\w+\s+([^;]+);/g; - let m; - while ((m = re.exec(src))) { - for (const name of m[1].split(',')) { - const clean = name.trim().replace(/\[.*$/, '').trim(); - if (clean.startsWith(prefix)) out.add(clean); - } - } - return out; -} - -function referenced(src, prefix) { - const out = new Set(); - const re = new RegExp(`\\b${prefix}[A-Z]\\w*`, 'g'); - let m; - while ((m = re.exec(src))) out.add(m[0]); - return out; -} - -function checkStage(label, src, prefix) { - const body = stripComments(src); - const decls = declared(body, prefix); - for (const name of referenced(body, prefix)) { - if (PROVIDED.has(name)) continue; - ok(decls.has(name), `${label}: ${name} referenced but not declared`); - } - return decls; -} - -function checkBalance(label, src) { - const body = stripComments(src); - let braces = 0; - let parens = 0; - let underflow = false; - for (const ch of body) { - if (ch === '{') braces++; - else if (ch === '}') braces--; - else if (ch === '(') parens++; - else if (ch === ')') parens--; - if (braces < 0) underflow = true; - } - ok(!underflow, `${label}: closing brace before an opening one`); - ok(braces === 0, `${label}: unbalanced braces`, `${braces}`); - ok(parens === 0, `${label}: unbalanced parentheses`, `${parens}`); -} - -function checkReserved(label, src) { - const body = stripComments(src); - for (const word of RESERVED) { - const re = new RegExp(`\\b(?:float|int|bool|vec2|vec3|vec4|mat2|mat3|mat4)\\s+${word}\\b`); - ok(!re.test(body), `${label}: "${word}" is reserved and cannot be a variable name`); - } - for (const word of SHADOWS) { - const re = new RegExp(`\\b(?:float|int|bool|vec2|vec3|vec4|mat2|mat3|mat4)\\s+${word}\\b`); - ok(!re.test(body), `${label}: "${word}" shadows a built-in function`); - } -} - -function auditMaterial(label, material) { - const v = material.vertexShader; - const f = material.fragmentShader; - checkBalance(`${label} vertex`, v); - checkBalance(`${label} fragment`, f); - checkReserved(`${label} vertex`, v); - checkReserved(`${label} fragment`, f); - ok(!v.includes('`') && !f.includes('`'), `${label}: a backtick in the shader source`); - ok(!/\bcosh\s*\(/.test(v + f), `${label}: cosh() does not exist in ESSL 1.00`); - - const vu = checkStage(`${label} vertex`, v, 'u'); - const fu = checkStage(`${label} fragment`, f, 'u'); - for (const name of [...vu, ...fu]) { - if (PROVIDED.has(name)) continue; - ok(material.uniforms[name] !== undefined, `${label}: ${name} declared but has no uniform box`); - } - - const vVary = declared(stripComments(v), 'v'); - const fVary = declared(stripComments(f), 'v'); - for (const name of fVary) { - ok(vVary.has(name), `${label}: fragment declares varying ${name} the vertex stage does not`); - } -} - -section('static shader sanity'); - -const flare = new LensFlare({ name: 'bench' }); -const mirror = new Mirror({ name: 'bench', resolution: 128 }); -auditMaterial('LensFlare', flare.material); -auditMaterial('Mirror', mirror.material); - -// Derivatives are fragment-only. Injecting `commonGLSL` into a vertex stage -// takes `aastep`'s fwidth with it and fails the whole program to compile. -ok(!/\bfwidth\s*\(/.test(flare.material.vertexShader), 'LensFlare vertex stage is free of fwidth'); -ok(!/\bfwidth\s*\(/.test(mirror.material.vertexShader), 'Mirror vertex stage is free of fwidth'); -ok( - flare.material.vertexShader.includes('#include '), - 'LensFlare vertex stage pulls in for the depth unpack' -); -console.log(` ${checks} static assertion(s)`); - -/* ---------------------------------------------------------------- */ -/* 2 · LensFlare behaviour */ -/* ---------------------------------------------------------------- */ - -section('LensFlare'); - -ok(flare.drawCalls === 1, 'the whole flare is one draw call'); -ok(flare.object3D.layers.mask === 1 << LAYER.VFX, 'the flare sits on LAYER.VFX alone'); -ok(flare.material.depthTest === false, 'the flare is not depth tested — it is on the lens'); -ok(flare.material.toneMapped === false, 'the flare is not tone mapped by the renderer'); -ok(flare.object3D.renderOrder >= 3000, 'the flare draws after the scene'); -ok(flare.object3D.frustumCulled === false, 'an NDC-built quad must not be frustum culled'); - -const fp = lensFlareParams(); -flare.setAnchorXYZ(0, 2, 12); - -for (let ghosts = 0; ghosts <= MAX_FLARE_GHOSTS + 2; ghosts++) { - fp.ghosts = ghosts; - flare.update(fp); - const expected = 3 + Math.min(ghosts, MAX_FLARE_GHOSTS); - ok( - flare.object3D.geometry.instanceCount === expected, - `ghosts=${ghosts} draws ${expected} instances`, - String(flare.object3D.geometry.instanceCount) - ); -} - -// README trap 7, inverted: with no depth buffer bound the kernel would decide -// the source is buried in a wall and the flare would never appear at all. -const restoreDepth = frame.uSceneDepth.value; -frame.uSceneDepth.value = null; -fp.occlusion = 1; -flare.update(fp); -ok(flare.material.uniforms.uOcclusion.value === 0, 'no depth buffer bound disables the occlusion test'); -frame.uSceneDepth.value = { isTexture: true }; -flare.update(fp); -ok(flare.material.uniforms.uOcclusion.value === 1, 'a bound depth buffer re-enables it'); -frame.uSceneDepth.value = restoreDepth; - -// I1 — a zero-length frame is still a frame. -fp.streakLength = 0.9; -fp.colorStreak = '#ff0000'; -flare.update(fp); -ok(flare.material.uniforms.uStreakLength.value === 0.9, 'a size dragged while paused reaches the uniform'); -ok(flare.material.uniforms.uColorStreak.value.r > 0.9, 'a colour dragged while paused reaches the uniform'); - -// Nothing dimensioned may be captured: every metre and every fraction has to -// come back out of the params object on the next update. -fp.streakLength = 0.1; -flare.update(fp); -ok(flare.material.uniforms.uStreakLength.value === 0.1, 'and it re-resolves the frame after'); - -const taps = flare.material.uniforms.uOccTaps; -fp.occTaps = 40; -flare.update(fp); -ok(taps.value <= 9, 'the occlusion kernel is clamped to the unrolled loop bound', String(taps.value)); - -/* ---------------------------------------------------------------- */ -/* 3 · Mirror — the scheduler */ -/* ---------------------------------------------------------------- */ - -section('Mirror'); - -ok(mirror.drawCalls === 1, 'the surface is one draw call'); -ok(mirror.object3D.frustumCulled === false, 'a uniform-built quad culls itself, in _wantsRender'); -ok(mirror.material.uniforms.uHasReflection.value === 0, 'nothing is reflected before the first pass'); - -const baseLive = mirrorBudget.live; -ok(baseLive === 0, 'constructing a mirror does not register it'); -mirror.visible = true; -ok(mirrorBudget.live === 1, 'showing a mirror registers it'); -mirror.visible = true; -ok(mirrorBudget.live === 1, 'showing it twice registers it once'); -mirror.visible = false; -ok(mirrorBudget.live === 0, 'hiding it releases'); - -/** The least renderer `_renderReflection()` will accept. */ -function stubRenderer() { - return { - info: { render: { frame: 0, calls: 9, triangles: 5300 } }, - xr: { enabled: false }, - shadowMap: { autoUpdate: true }, - autoClear: false, - state: { buffers: { depth: { setMask() {} } }, viewport() {} }, - getRenderTarget: () => null, - setRenderTarget() {}, - clear() {}, - render() { - // The real one increments here too, which is the whole reason the module - // subtracts its own nested renders back out of the frame token. - this.info.render.frame++; - } - }; -} - -const scene = new Scene(); -const group = new Group(); -scene.add(group); -const camera = new PerspectiveCamera(50, 16 / 9, 0.1, 400); -camera.position.set(0, 2.4, -7); -camera.lookAt(new Vector3(0, 1.2, 12)); -camera.updateMatrixWorld(true); -camera.matrixWorldInverse.copy(camera.matrixWorld).invert(); - -const gl = stubRenderer(); -const previousBudget = mirrorBudget.max; -setMirrorBudget(2); - -const panes = []; -for (let i = 0; i < 5; i++) { - const m = new Mirror({ resolution: 128, name: `pane${i}` }); - group.add(m.object3D); - m.visible = true; - panes.push(m); -} -ok(mirrorBudget.live === 5, 'five live mirrors'); - -const mp = mirrorParams(); -const at = new Vector3(); -const firstSeen = new Map(); -let worstFrame = 0; - -for (let f = 0; f < 12; f++) { - gl.info.render.frame++; // the outer frame's own render call - for (let i = 0; i < panes.length; i++) { - at.set(i * 2.2 - 4.4, 1.2, 12); - mp.width = 2 + i * 0.3; - mp.height = 2; - mp.roughness = f % 2 ? 0.8 : 0; - panes[i].setPlacement(at, new Vector3(0, 0, -1), new Vector3(1, 0, 0)); - panes[i].update(mp); - } - for (const pane of panes) pane.object3D.onBeforeRender(gl, scene, camera); - - worstFrame = Math.max(worstFrame, mirrorBudget.rendered); - for (const pane of panes) { - if (pane.material.uniforms.uHasReflection.value > 0.5 && !firstSeen.has(pane.mesh.name)) { - firstSeen.set(pane.mesh.name, f); - } - } -} - -ok(worstFrame <= 2, 'never more than the budget in one frame', `worst frame rendered ${worstFrame}`); -ok(worstFrame === 2, 'and the budget is actually spent'); -ok(firstSeen.size === panes.length, 'every mirror got a turn', `${firstSeen.size} of ${panes.length}`); -ok( - Math.max(...firstSeen.values()) <= 3, - 'starvation weighting gives everyone a first turn inside four frames', - `worst wait ${Math.max(...firstSeen.values())} frames` -); -ok(mirrorBudget.calls === 2 * 9, 'the reflection cost is measured off renderer.info', String(mirrorBudget.calls)); - -for (const pane of panes) { - const e = pane.material.uniforms.uReflectMatrix.value.elements; - ok(e.every(Number.isFinite), `${pane.mesh.name}: the oblique lookup matrix is finite`); -} - -// The oblique near plane has to *move* when the mirror does, or it is not -// clipped to the mirror at all. -const before = panes[0].material.uniforms.uReflectMatrix.value.elements.slice(); -at.set(0, 4.5, 6); -panes[0].setPlacement(at, new Vector3(0, 1, 0), new Vector3(0, 0, 1)); -panes[0].update(mp); -gl.info.render.frame++; -setMirrorBudget(5); -for (const pane of panes) pane.object3D.onBeforeRender(gl, scene, camera); -const after = panes[0].material.uniforms.uReflectMatrix.value.elements; -ok(before.some((v, i) => Math.abs(v - after[i]) > 1e-6), 'moving the plane rebuilds the reflection matrix'); - -// A recursion guard: a mirror asked to render from inside another mirror's -// reflection must decline rather than blow the stack. -let depth = 0; -const recursive = stubRenderer(); -recursive.render = function render() { - this.info.render.frame++; - if (depth++ > 4) return; - for (const pane of panes) pane.object3D.onBeforeRender(recursive, scene, camera); -}; -gl.info.render.frame++; -recursive.info.render.frame = gl.info.render.frame; -for (const pane of panes) pane.object3D.onBeforeRender(recursive, scene, camera); -ok(depth <= panes.length, 'a nested reflection is refused, not recursed', `depth ${depth}`); - -// The depth prepass draws WORLD with an override material; a mirror on that -// layer must not render its reflection from inside it. -const overridden = new Scene(); -overridden.overrideMaterial = mirror.material; -const beforePrepass = mirrorBudget.rendered; -gl.info.render.frame++; -for (const pane of panes) pane.object3D.onBeforeRender(gl, overridden, camera); -ok(mirrorBudget.rendered === beforePrepass, 'the depth prepass does not trigger a reflection'); - -// I1 and the live re-size. -mp.resolution = 256; -panes[0].update(mp); -ok(panes[0].resolution === 256, 'the resolution slider re-sizes the target'); -ok(panes[0].material.uniforms.uHasReflection.value === 0, 'a re-sized target is not trusted until it is redrawn'); -mp.roughness = 0.42; -panes[0].update(mp); -ok(panes[0].material.uniforms.uRoughness.value === 0.42, 'roughness dragged while paused reaches the uniform'); -mp.width = 7.5; -panes[0].update(mp); -ok(panes[0].material.uniforms.uSize.value.x === 7.5, 'a metre dragged while paused reaches the uniform'); - -for (const pane of panes) pane.dispose(); -ok(mirrorBudget.live === 0, 'disposing every mirror empties the register'); -setMirrorBudget(previousBudget); -flare.dispose(); -mirror.dispose(); - -/* ---------------------------------------------------------------- */ - -console.log( - failures === 0 - ? `\n✓ ${checks} assertions, no failures\n` - : `\n✗ ${failures} of ${checks} assertions failed\n` -); -process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/check-vfx-tube-shell.mjs b/scripts/check-vfx-tube-shell.mjs deleted file mode 100644 index 9e6e6a3..0000000 --- a/scripts/check-vfx-tube-shell.mjs +++ /dev/null @@ -1,313 +0,0 @@ -/** - * `scripts/check-vfx-tube-shell.mjs` — the harness for `vfx/Tube.js` and - * `vfx/Shell.js`. - * - * Separate from `scripts/check.mjs` only because that one walks the **registry** - * and these two modules are not yet reachable from an ability. Fold it in — or - * add `"check:vfx"` to package.json — the moment one is. - * - * No WebGL needed, for the same reason `check.mjs` does not need it: three - * builds geometry, materials and scene graphs on the CPU and both modules defer - * GL to render time. What that cannot check is whether the GLSL *compiles*, so - * the first section does the next best thing statically — every `uXxx` used in - * a shader must be declared in the same stage, the preprocessor and the braces - * must balance, and the two stages must agree on their varyings. Those three - * catch essentially every typo that survives writing the file. - * - * The interesting assertions are further down: - * - * - the whip's tip actually beats its own wave speed, and cracks exactly once; - * - the funnel's skirt and mouth are both wider than its throat; - * - the ring train's far end is a node to within floating point; - * - and **I1**: a slider dragged on a zero-length frame moves the standing - * shape, including re-arming a crack that had not fired. - */ -import { Vector3 } from 'three'; -import { Tube, TubePath, TubeLayer, tubeDefaults, tubeKeys, tubeSchema, createTubeMaterial } from '../src/vfx/Tube.js'; -import { Shell, ShellMode, shellDefaults, shellSchema, BurstMode, BurstSystem } from '../src/vfx/Shell.js'; -import { settings } from '../src/config/settings.js'; -import { frame } from '../src/core/FrameUniforms.js'; - -let failures = 0; -const ok = (name, cond, extra = '') => { - if (!cond) { - failures++; - console.error(` FAIL ${name} ${extra}`); - } else { - console.log(` ok ${name}`); - } -}; - -const finite = (v) => Number.isFinite(v); - -function scanUniforms(material, label) { - for (const [key, box] of Object.entries(material.uniforms)) { - const v = box.value; - if (typeof v === 'number' && !finite(v)) return `${label}.${key} = ${v}`; - if (v && v.isVector3 && !(finite(v.x) && finite(v.y) && finite(v.z))) return `${label}.${key} = ${v.toArray()}`; - if (v && v.isColor && !(finite(v.r) && finite(v.g) && finite(v.b))) return `${label}.${key} = colour NaN`; - } - return null; -} - -/* ---------------------------------------------------------------- */ -/* 1. GLSL identifier sanity — every uXxx used must be declared */ -/* ---------------------------------------------------------------- */ -console.log('\n[glsl] uniform declaration / usage cross-check'); -for (const [layer, path] of [[TubeLayer.CORE, TubePath.WHIP], [TubeLayer.SHEATH, TubePath.FUNNEL], [TubeLayer.HALO, TubePath.VINE], [TubeLayer.CORE, TubePath.ARC], [TubeLayer.CORE, TubePath.STRAIGHT]]) { - const m = createTubeMaterial(layer, path); - checkShader(`Tube(layer=${layer},path=${path})`, m); -} -for (const mode of Object.values(ShellMode)) { - const { createShellMaterial } = await import('../src/vfx/Shell.js'); - checkShader(`Shell(mode=${mode})`, createShellMaterial(mode)); -} - -function checkShader(label, material) { - for (const [stage, src] of [['vs', material.vertexShader], ['fs', material.fragmentShader]]) { - const declared = new Set([...src.matchAll(/uniform\s+\w+\s+(\w+)\s*;/g)].map((m) => m[1])); - // three injects these - for (const k of ['modelMatrix', 'viewMatrix', 'projectionMatrix', 'cameraPosition', 'normalMatrix', 'modelViewMatrix']) declared.add(k); - const used = new Set([...src.matchAll(/\bu[A-Z]\w*/g)].map((m) => m[0])); - const missing = [...used].filter((u) => !declared.has(u) && !u.startsWith('uv')); - ok(`${label} ${stage} uniforms declared`, missing.length === 0, missing.join(', ')); - - // balanced preprocessor - let depth = 0; - for (const line of src.split('\n')) { - const t = line.trim(); - if (t.startsWith('#if')) depth++; - else if (t.startsWith('#endif')) depth--; - if (depth < 0) break; - } - ok(`${label} ${stage} #if/#endif balanced`, depth === 0, `depth=${depth}`); - - // varyings written in vs must be declared, and vice versa - const brace = (src.match(/{/g) || []).length - (src.match(/}/g) || []).length; - ok(`${label} ${stage} braces balanced`, brace === 0, `delta=${brace}`); - } - const vsVary = new Set([...material.vertexShader.matchAll(/varying\s+\w+\s+(\w+)\s*;/g)].map((m) => m[1])); - const fsVary = new Set([...material.fragmentShader.matchAll(/varying\s+\w+\s+(\w+)\s*;/g)].map((m) => m[1])); - ok(`${label} varying sets match`, [...fsVary].every((v) => vsVary.has(v)) && [...vsVary].every((v) => fsVary.has(v))); -} - -/* ---------------------------------------------------------------- */ -/* 2. Tube — every path, 240 frames, no NaN */ -/* ---------------------------------------------------------------- */ -console.log('\n[tube] path modes'); -const origin = new Vector3(0, 1.2, 0); -const target = new Vector3(0, 0.6, 12); -const side = new Vector3(1, 0, 0); - -for (const [name, path] of Object.entries(TubePath)) { - const config = { ...tubeDefaults('tube', path) }; - const tube = new Tube({ path, nodes: 64, sides: 20 }); - const state = { origin, target, side, progress: 0, fade: 1, widthFade: 1, seed: 0.37, time: 0, grow: 0, snapAge: -1 }; - - let bad = null; - let sawCrack = 0; - let peakTip = 0; - for (let i = 0; i < 240; i++) { - const t = i / 60; - frame.uTime.value = t; - state.time = t; - state.progress = Math.min(1, t * 1.6); - state.grow = Math.min(1, t * 1.2); - state.snapAge = path === TubePath.VINE && t > 1.2 ? t - 1.2 : -1; - tube.sync(config, state); - if (tube.crack.fired) sawCrack++; - peakTip = Math.max(peakTip, tube.tipSpeed); - for (const key of ['core', 'sheath', 'halo']) { - bad = bad || scanUniforms(tube.materials[key], `${name}.${key}`); - } - for (const q of [0, 0.25, 0.5, 0.75, 1]) { - if (!finite(tube.radiusAt(q))) bad = bad || `${name}.radiusAt(${q}) NaN`; - const p = tube.pointAt(q, new Vector3()); - if (!finite(p.x + p.y + p.z)) bad = bad || `${name}.pointAt(${q}) NaN`; - if (p.length() > 500) bad = bad || `${name}.pointAt(${q}) escaped: ${p.toArray()}`; - const tg = tube.tangentAt(q, new Vector3()); - if (!finite(tg.x + tg.y + tg.z)) bad = bad || `${name}.tangentAt(${q}) NaN`; - } - } - ok(`${name}: 240 frames clean`, bad === null, bad || ''); - ok(`${name}: draw calls = 3`, tube.drawCalls === 3); - if (path === TubePath.WHIP) { - ok('WHIP: the crack fired', sawCrack > 0, `fired ${sawCrack}×`); - ok('WHIP: tip beats the wave', peakTip > tube.waveSpeed, `peak ${peakTip.toFixed(2)} vs wave ${tube.waveSpeed.toFixed(2)}`); - console.log(` peak tip speed ${peakTip.toFixed(2)} m/s, wave speed ${tube.waveSpeed.toFixed(2)} m/s, cracks ${sawCrack}`); - } - if (path === TubePath.VINE) { - ok('VINE: tip radius is zero', tube.radiusAt(1) < 1e-3, `${tube.radiusAt(1)}`); - ok('VINE: recoil moved the tip', peakTip > 0, `${peakTip}`); - } - if (path === TubePath.FUNNEL) { - const throat = tube.radiusAt(0.4); - ok('FUNNEL: skirt > throat', tube.skirtRadius > throat, `${tube.skirtRadius.toFixed(2)} vs ${throat.toFixed(2)}`); - ok('FUNNEL: mouth > throat', tube.mouthRadius > throat, `${tube.mouthRadius.toFixed(2)} vs ${throat.toFixed(2)}`); - console.log(` skirt ${tube.skirtRadius.toFixed(2)}m throat ${throat.toFixed(2)}m mouth ${tube.mouthRadius.toFixed(2)}m`); - } - tube.dispose(); -} - -/* ---------------------------------------------------------------- */ -/* 3. I1 — the paused-slider test, on a zero-length frame */ -/* ---------------------------------------------------------------- */ -console.log('\n[I1] paused-slider test (dt = 0)'); -{ - const config = { ...tubeDefaults('tube', TubePath.FUNNEL) }; - const tube = new Tube({ path: TubePath.FUNNEL }); - const state = { origin: new Vector3(), target: new Vector3(0, 8, 0), side, progress: 1, fade: 1, widthFade: 1, seed: 0.5, time: 1.5, grow: 1, snapAge: -1 }; - frame.uTime.value = 1.5; - tube.sync(config, state); - const before = tube.skirtRadius; - const beforeU = tube.materials.core.uniforms.uSkirtFlare.value; - - config.tubeSkirtFlare = 4.4; // the drag - tube.sync(config, state); // zero-length frame: same time, same state - ok('funnel skirt re-resolved', tube.skirtRadius !== before, `${before} → ${tube.skirtRadius}`); - ok('funnel uniform re-resolved', tube.materials.core.uniforms.uSkirtFlare.value !== beforeU); - - settings.global.opacity = 0.5; - tube.sync(config, state); - ok('global multiplier applies live', tube.materials.core.uniforms.uOpacity.value === config.tubeOpacity * 0.5); - settings.global.opacity = 1; - tube.dispose(); -} -{ - // The crack must be re-armable by a slider while paused. - const config = { ...tubeDefaults('tube', TubePath.WHIP) }; - config.tubeWaveAmp = 0.02; // far too small to crack - const tube = new Tube({ path: TubePath.WHIP }); - const state = { origin, target, side, progress: 1, fade: 1, widthFade: 1, seed: 0.1, time: 0.72, grow: 1, snapAge: -1 }; - frame.uTime.value = 0.72; - tube.sync(config, state); - const quiet = tube.crack.fired; - config.tubeWaveAmp = 0.45; // the drag, clock stopped - tube.sync(config, state); - ok('whip crack fires off a slider while paused', quiet === false && tube.crack.fired === true); - tube.sync(config, state); - ok('whip crack does not re-fire while held', tube.crack.fired === false); - tube.dispose(); -} - -/* ---------------------------------------------------------------- */ -/* 4. Contract guard */ -/* ---------------------------------------------------------------- */ -console.log('\n[contract] missing-key guard'); -{ - const tube = new Tube({}); - const warnings = []; - const realWarn = console.warn; - console.warn = (m) => warnings.push(m); - tube.sync({}, { origin, target, side }); - tube.sync({}, { origin, target, side }); - console.warn = realWarn; - ok('Tube warns once about an incomplete block', warnings.length === 1 && /missing 79 key/.test(warnings[0]), warnings[0] || ''); - ok('Tube still draws finite geometry from the defaults', scanUniforms(tube.materials.core, 'bare') === null); - tube.dispose(); -} - -/* ---------------------------------------------------------------- */ -/* 5. Shell — every mode */ -/* ---------------------------------------------------------------- */ -console.log('\n[shell] modes'); -for (const [name, mode] of Object.entries(ShellMode)) { - const config = { ...shellDefaults('shell', mode) }; - const shell = new Shell({ mode, rings: 16 }); - const state = { origin: new Vector3(0, 0, 0), axis: new Vector3(0, 1, 0), side, span: 14, t: 0, fade: 1, seed: 0.8 }; - - let bad = null; - for (let i = 0; i < 240; i++) { - frame.uTime.value = i / 60; - state.t = Math.min(1, i / 120); - shell.sync(config, state); - bad = bad || scanUniforms(shell.material, name); - if (!finite(shell.radius)) bad = bad || `${name}.radius NaN`; - } - ok(`${name}: 240 frames clean`, bad === null, bad || ''); - ok(`${name}: one draw call`, shell.drawCalls === 1); - if (mode === ShellMode.RING_TRAIN) { - ok('RING_TRAIN: instance count follows the slider', shell.geometry.instanceCount === 10); - const lambda = shell.resonantSpacing(9); - config.shellSpacing = lambda; - shell.sync(config, state); - // With 9 half-waves on the line the far end and every λ/2 back is a node. - const atNode = shell.standingAt(shell.span); - const atAnti = shell.standingAt(shell.span - shell.nodeSpacing * 0.5); - ok('RING_TRAIN: far end is a node', Math.abs(atNode) < 1e-6, `${atNode}`); - ok('RING_TRAIN: antinode is brighter than the node', atAnti > atNode); - ok('RING_TRAIN: node count sane', shell.nodeCount === 10, `${shell.nodeCount}`); - const p = shell.nodePosition(3, new Vector3()); - ok('RING_TRAIN: node position finite', finite(p.x + p.y + p.z)); - console.log(` resonant λ ${lambda.toFixed(3)} m, ${shell.nodeCount} nodes, spacing ${shell.nodeSpacing.toFixed(3)} m`); - } - if (mode === ShellMode.DOME) { - const r0 = shell.radius; - config.shellRadiusEnd = 12; - shell.sync(config, state); - ok('DOME: radius re-resolves on a paused frame', shell.radius !== r0, `${r0} → ${shell.radius}`); - } - shell.dispose(); -} - -/* ---------------------------------------------------------------- */ -/* 6. Degenerate frames */ -/* ---------------------------------------------------------------- */ -console.log('\n[degenerate] vertical axes and zero-length casts'); -{ - const config = { ...tubeDefaults('tube', TubePath.FUNNEL) }; - const tube = new Tube({ path: TubePath.FUNNEL }); - // Vertical axis with `side` parallel to it — the frame must not go NaN. - tube.sync(config, { - origin: new Vector3(), - target: new Vector3(0, 6, 0), - side: new Vector3(0, 1, 0), - progress: 1, fade: 1, widthFade: 1, seed: 0, time: 0, grow: 1, snapAge: -1 - }); - const p = tube.pointAt(0.5, new Vector3()); - ok('vertical funnel, parallel side ref', finite(p.x + p.y + p.z), p.toArray().join(',')); - // Zero-length cast. - tube.sync(config, { - origin: new Vector3(3, 0, 3), target: new Vector3(3, 0, 3), side, - progress: 1, fade: 1, widthFade: 1, seed: 0, time: 0, grow: 1, snapAge: -1 - }); - ok('zero-length cast', finite(tube.pointAt(1, new Vector3()).x) && finite(tube.tipSpeed)); - tube.dispose(); - - const shell = new Shell({ mode: ShellMode.CONE }); - shell.sync({ ...shellDefaults('shell', ShellMode.CONE) }, { - origin: new Vector3(), axis: new Vector3(0, 0, 0), side: new Vector3(0, 1, 0), span: 0, t: 0.5, fade: 1, seed: 0 - }); - ok('shell with a zero axis and zero span', scanUniforms(shell.material, 'cone') === null); - shell.dispose(); -} - -/* ---------------------------------------------------------------- */ -/* 7. Schemas + the BurstSphere re-export */ -/* ---------------------------------------------------------------- */ -console.log('\n[schema] editor coverage'); -{ - const config = tubeDefaults('tube'); - const schema = tubeSchema('tube', TubePath.WHIP); - const covered = new Set(); - for (const list of Object.values(schema)) for (const e of list) covered.add(Array.isArray(e) ? e[0] : e); - const uncovered = Object.keys(config).filter((k) => !covered.has(k)); - ok('tubeSchema references only real keys', [...covered].every((k) => k in config), [...covered].filter((k) => !(k in config)).join(', ')); - console.log(` ${Object.keys(config).length} tube keys, ${covered.size} in named folders, ${uncovered.length} fall through to "More"`); - - const sconfig = shellDefaults('shell'); - const sschema = shellSchema('shell', ShellMode.RING_TRAIN); - const scovered = new Set(); - for (const list of Object.values(sschema)) for (const e of list) scovered.add(Array.isArray(e) ? e[0] : e); - ok('shellSchema references only real keys', [...scovered].every((k) => k in sconfig), [...scovered].filter((k) => !(k in sconfig)).join(', ')); - console.log(` ${Object.keys(sconfig).length} shell keys, ${scovered.size} in named folders`); - - ok('two prefixes do not collide', Object.keys(tubeDefaults('lash', TubePath.WHIP)).every((k) => !(k in config))); - ok('per-path tuning applies', tubeDefaults('lash', TubePath.WHIP).lashRadius !== tubeDefaults('lash', TubePath.STRAIGHT).lashRadius); - ok('prefixed overrides win', tubeDefaults('lash', TubePath.WHIP, { lashRadius: 9 }).lashRadius === 9); - ok('BurstSphere still re-exports', typeof BurstSystem === 'function' && BurstMode.FIRE === 0); - ok('tubeKeys maps unprefixed → prefixed', tubeKeys('lash').radius === 'lashRadius'); -} - -console.log(failures === 0 ? '\nALL CHECKS PASSED\n' : `\n${failures} CHECK(S) FAILED\n`); -process.exit(failures === 0 ? 0 : 1); diff --git a/scripts/check.mjs b/scripts/check.mjs deleted file mode 100644 index b5dc6ae..0000000 --- a/scripts/check.mjs +++ /dev/null @@ -1,3939 +0,0 @@ -#!/usr/bin/env node -/** - * check.mjs — the headless verification harness (docs/EXPANSION.md §7). - * - * Forty-four abilities are about to be written by people who cannot see the - * screen this project renders to. This file is the thing that stands in for - * looking at it. It runs in plain Node with no WebGL, because three.js builds - * geometry, materials and scene graphs entirely on the CPU and every VFX system - * in this repo defers GL to render time — so an ability can be constructed, - * cast, ticked through all four phases and torn down without a canvas ever - * existing. - * - * Five passes, in increasing order of how much they cost and how much they find: - * - * 1. **Structure.** Every registered id has a settings block, a schema, a - * sigil and at most one keyboard letter; every block carries the five - * fields the framework indexes blind (`range`, `minRange`, `speed`, - * `cooldown`, `castAnim`, plus `zoneRadius` for a far cast) and the ranges - * are not nonsense. - * - * 2. **The static settings cross-check.** The highest-value pass in the file. - * Every source file under `src/` is lexed, every property read off a - * settings block is collected — `settings..`, `const c = - * settings.` then `c.`, `this.config.`, and destructuring - * from any of those — and each one is looked up in the block it names. A - * key that does not exist reads as `undefined`, multiplies into `NaN`, - * and produces geometry that silently vanishes with no error anywhere. - * That is the single most common way one of these abilities breaks, it - * costs an afternoon to find by eye, and it costs eight milliseconds to - * find here. - * - * The reverse direction — a key in the block that nothing ever reads — is - * a **warning**, never a failure, because a shared VFX module reads its - * configuration through a live object reference that no amount of - * grepping can attribute to an id. - * - * 3. **Runtime simulation.** Each ability is constructed against real - * subsystems on a bare `Scene`, cast, and ticked 240 frames at 1/60 with - * the frame clock driven forward and every subsystem flushed exactly as - * `App` flushes it. Any throw, any `NaN` or `Infinity` in a particle - * attribute array or a mesh transform, any mesh added to the group during - * a cast, any ability that never reaches `DONE` — all fatal. It is then - * destroyed and cast a *second* time, because the pooling contract says - * `destroy()` leaves the instance reusable and nothing else tests that. - * - * 4. **The pause test.** Invariant I1 has one observable consequence: stop - * the clock, drag a slider, and the standing effect changes. So we stop - * the clock (`dt = 0`, which is exactly what `App` feeds the manager when - * paused), snapshot every uniform, every transform and every instance - * matrix the ability owns, scale its numeric settings by 1.37, tick one - * zero-length frame, and demand that something moved. Twice — once - * mid-travel and once mid-impact — because an ability that resolves its - * dimensions during travel and then bakes them at impact passes the first - * sample and fails the second, and that is the exact shape of the mistake. - * - * When the ability is *stable* at rest (a zero-length frame with no - * mutation changes nothing) the harness goes further and probes each - * slider on its own, which turns "this ability responds" into "37 of its - * 54 sliders are observable while paused, and here are the seventeen that - * are not". Run with `--sliders` to see the list. - * - * 5. **Report.** One row per ability, then a detail section for the rows that - * failed. Non-zero exit on any failure. - * - * Usage: - * npm run check every registered ability - * npm run check -- --only ice one id, which is how you will actually use it - * npm run check -- --quiet the table and the failures, nothing else - * npm run check -- --sliders list the sliders that do nothing while paused - */ - -/* ------------------------------------------------------------------ */ -/* §0 · Browser shims — installed before a single project module loads */ -/* ------------------------------------------------------------------ */ -/** - * Nothing in `src/` needs a DOM today, and the harness would import cleanly - * without any of this. It is here for the ability that eventually reaches for - * `window.devicePixelRatio` at module scope, or for the three.js code path that - * decides it is in a browser and asks for a canvas: a missing global there - * throws during *import*, which reports as "the whole harness is broken" - * rather than "one module misbehaved". - * - * The stubs are deliberately inert. They exist to be present, not to work — a - * shim that half-works lets a module take a browser path in Node and fail two - * hundred lines later, which is worse than failing at the first call. - */ -const canvasStub = () => { - const context2d = { - canvas: null, - fillStyle: '#000', - strokeStyle: '#000', - globalAlpha: 1, - fillRect() {}, - clearRect() {}, - drawImage() {}, - getImageData: (_x, _y, w = 1, h = 1) => ({ data: new Uint8ClampedArray(w * h * 4), width: w, height: h }), - putImageData() {}, - createLinearGradient: () => ({ addColorStop() {} }), - createRadialGradient: () => ({ addColorStop() {} }), - measureText: () => ({ width: 0 }), - fillText() {}, - beginPath() {}, - closePath() {}, - moveTo() {}, - lineTo() {}, - arc() {}, - fill() {}, - stroke() {}, - save() {}, - restore() {}, - translate() {}, - scale() {}, - rotate() {} - }; - const element = { - width: 1, - height: 1, - style: {}, - className: '', - dataset: {}, - children: [], - getContext: (kind) => (kind === '2d' ? context2d : null), - toDataURL: () => 'data:,', - appendChild(child) { - this.children.push(child); - return child; - }, - removeChild() {}, - remove() {}, - setAttribute() {}, - getAttribute: () => null, - addEventListener() {}, - removeEventListener() {}, - dispatchEvent: () => true, - getBoundingClientRect: () => ({ x: 0, y: 0, width: 1, height: 1, top: 0, left: 0, right: 1, bottom: 1 }), - querySelector: () => null, - querySelectorAll: () => [] - }; - context2d.canvas = element; - return element; -}; - -if (typeof globalThis.window === 'undefined') { - const storage = new Map(); - globalThis.window = globalThis; - globalThis.self = globalThis; - globalThis.devicePixelRatio = 1; - globalThis.innerWidth = 1280; - globalThis.innerHeight = 720; - globalThis.addEventListener = () => {}; - globalThis.removeEventListener = () => {}; - globalThis.dispatchEvent = () => true; - globalThis.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); - globalThis.requestAnimationFrame = (fn) => setTimeout(() => fn(0), 16); - globalThis.cancelAnimationFrame = (handle) => clearTimeout(handle); - globalThis.localStorage = { - getItem: (key) => (storage.has(key) ? storage.get(key) : null), - setItem: (key, value) => void storage.set(key, String(value)), - removeItem: (key) => void storage.delete(key), - clear: () => storage.clear(), - key: (index) => [...storage.keys()][index] ?? null, - get length() { - return storage.size; - } - }; -} - -if (typeof globalThis.document === 'undefined') { - const root = canvasStub(); - globalThis.document = { - documentElement: root, - body: root, - head: root, - createElement: () => canvasStub(), - createElementNS: () => canvasStub(), - createTextNode: (text) => ({ nodeValue: String(text) }), - getElementById: () => null, - querySelector: () => null, - querySelectorAll: () => [], - addEventListener() {}, - removeEventListener() {}, - createEvent: () => ({ initEvent() {} }) - }; -} - -if (typeof globalThis.performance === 'undefined') { - globalThis.performance = { now: () => Number(process.hrtime.bigint() / 1000n) / 1000 }; -} - -/* ------------------------------------------------------------------ */ -/* §1 · Imports */ -/* ------------------------------------------------------------------ */ - -import { readFileSync, readdirSync, statSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; - -import { - Scene, - Group, - PerspectiveCamera, - Vector3, - Quaternion, - ConeGeometry, - IcosahedronGeometry, - TetrahedronGeometry, - BoxGeometry, - Mesh, - BufferAttribute, - Bone, - Skeleton, - SkinnedMesh, - MeshBasicMaterial, - MeshStandardMaterial -} from 'three'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const ROOT = path.resolve(HERE, '..'); -const SRC = path.join(ROOT, 'src'); - -/* - * `config/settings.js` is imported FIRST, and the order is load-bearing. - * - * Several settings modules now spread a prefixed defaults fragment from the - * tech library (`shellDefaults`, `tubeDefaults`, `volumeHullDefaults`), and - * those library modules import `config/settings.js` for their `settings.global` - * default argument. That closes a cycle: - * - * registry → config/abilities/index → → vfx/Shell → config/settings - * → config/abilities/index (already evaluating: TDZ) - * - * Entering the cycle at `config/settings.js` instead is fine, because the only - * thing the library wants from it is a live binding it reads at call time, and - * by the time anything calls, everything is initialised. Entering at the - * registry is not: `settings.js` reaches `...ABILITY_SETTINGS` while - * `abilities/index.js` is still half-way down its own import list, and Node - * throws `Cannot access 'ABILITY_SETTINGS' before initialization`. The browser - * happens to enter through `settings.js` (via `core/Renderer.js`); the harness - * has to do the same deliberately. - */ -const { settings, CAST_ANIMATIONS, CastShape } = await import('../src/config/settings.js'); -const { ABILITIES, SCHOOLS } = await import('../src/abilities/registry.js'); -const { ABILITY_SETTINGS, ABILITY_SCHEMAS } = await import('../src/config/abilities/index.js'); -const { AbilityPhase } = await import('../src/abilities/Ability.js'); -const { frame } = await import('../src/core/FrameUniforms.js'); -const { ParticleEngine } = await import('../src/particles/ParticleEngine.js'); -const { LightPool } = await import('../src/effects/LightPool.js'); -const { DecalSystem } = await import('../src/effects/GroundDecals.js'); -const { FissureSystem } = await import('../src/effects/GroundFissures.js'); -const { BurstSystem } = await import('../src/effects/BurstSphere.js'); -const { ELEMENT_SIGILS } = await import('../src/ui/glyphs/index.js'); -const { patchOnBeforeCompile } = await import('../src/utils/shaderPatch.js'); - -/** - * The two **global** pools an ability can take a piece of the world from: the - * app-wide `sceneHooks` ledger and the app-wide `timeField`. They are read here - * so the runtime pass can assert that `destroy()` gave everything back. - * - * Imported softly. A syntax error in either file is the VFX stage's business to - * report, with a filename and a line; if it also took the ability stage down - * with it, fifty green abilities would read as fifty broken ones. - */ -let _sceneHooks = null; -let _timeField = null; -try { - ({ sceneHooks: _sceneHooks } = await import('../src/vfx/SceneHooks.js')); -} catch { - /* reported by the VFX stage */ -} -try { - ({ timeField: _timeField } = await import('../src/vfx/TimeControl.js')); -} catch { - /* reported by the VFX stage */ -} -const sceneHooksLive = () => _sceneHooks?.heldCount ?? 0; -const sceneHooksReleaseAll = () => _sceneHooks?.releaseAll(); -const timeRegionsLive = () => _timeField?.liveCount ?? 0; -const timeRegionsReset = () => _timeField?.reset(); - -/* ------------------------------------------------------------------ */ -/* §2 · Arguments and reporting plumbing */ -/* ------------------------------------------------------------------ */ - -const argv = process.argv.slice(2); -const flag = (name) => argv.includes(name); -const value = (name) => { - const index = argv.indexOf(name); - return index >= 0 ? argv[index + 1] : undefined; -}; - -const ONLY = value('--only'); -const QUIET = flag('--quiet'); -const SHOW_SLIDERS = flag('--sliders'); - -const COLOUR = process.stdout.isTTY && !process.env.NO_COLOR; -const paint = (code, text) => (COLOUR ? `[${code}m${text}` : text); -const red = (t) => paint('31', t); -const green = (t) => paint('32', t); -const yellow = (t) => paint('33', t); -const dim = (t) => paint('2', t); -const bold = (t) => paint('1', t); - -/** Simulation constants. Named because every one of them is a judgement call. */ -const DT = 1 / 60; // seconds per simulated frame -const REQUIRED_FRAMES = 240; // frames every cast is ticked for, minimum -const MAX_FRAMES = 900; // frames after which a cast that has not finished has hung -const REUSE_FRAMES = 60; // frames of the second cast, which tests the pooling contract -const NAN_STRIDE = 8; // scan the (large) particle arrays every Nth frame -const SANITY_RADIUS = 5000; // metres; a mesh further out than this is a broken transform -const DRAW_CALL_BUDGET = 12; // I7 — meshes in one ability's group -const MUTATION = 1.37; // the factor a slider is scaled by in the pause test -const CHANGE_EPSILON = 1e-6; // relative tolerance below which "it moved" is float noise - -/** Keys the framework indexes blind on every block, whether or not it reads them. */ -const CAST_KEYS = ['range', 'minRange', 'speed', 'cooldown', 'castAnim']; -/** Keys excluded from the pause mutation: they are consumed at spawn, not per frame. */ -const PAUSE_EXCLUDED = new Set(['range', 'minRange', 'speed', 'cooldown']); - -/** One of these per registered ability; the table is these rows. */ -class Report { - constructor(id) { - this.id = id; - this.failures = []; - this.warnings = []; - this.notes = []; - this.marks = { struct: null, static: null, runtime: null, travel: null, impact: null }; - } - - fail(stage, message) { - this.failures.push({ stage, message }); - if (stage in this.marks) this.marks[stage] = false; - return false; - } - - warn(stage, message) { - this.warnings.push({ stage, message }); - } - - note(message) { - this.notes.push(message); - } - - /** Mark a stage passed unless something already failed in it. */ - pass(stage) { - if (this.marks[stage] !== false) this.marks[stage] = true; - } - - get ok() { - return this.failures.length === 0; - } -} - -/** Failures that belong to the roster as a whole rather than to one ability. */ -const globalFailures = []; -const globalWarnings = []; -const failGlobal = (message) => globalFailures.push(message); -const warnGlobal = (message) => globalWarnings.push(message); - -const log = (...args) => { - if (!QUIET) console.log(...args); -}; - -/* ------------------------------------------------------------------ */ -/* §3 · Structure */ -/* ------------------------------------------------------------------ */ - -const ALL_IDS = ABILITIES.map((a) => a.id); -const TARGET = ONLY ? ABILITIES.filter((a) => a.id === ONLY) : ABILITIES; - -if (ONLY && TARGET.length === 0) { - console.error(red(`check: no registered ability with id "${ONLY}".`)); - console.error(dim(` known ids: ${ALL_IDS.join(', ')}`)); - process.exit(2); -} - -const reports = new Map(TARGET.map((a) => [a.id, new Report(a.id)])); -const SCHOOL_IDS = new Set(SCHOOLS.map((s) => s.id)); - -/** Registry-wide invariants: unique ids, unique keys, known schools. */ -function checkRoster() { - const seenIds = new Set(); - const seenKeys = new Map(); - - for (const descriptor of ABILITIES) { - if (seenIds.has(descriptor.id)) failGlobal(`duplicate registry id "${descriptor.id}"`); - seenIds.add(descriptor.id); - - if (descriptor.key) { - const key = String(descriptor.key).toUpperCase(); - if (seenKeys.has(key)) { - failGlobal(`key "${key}" is bound to both "${seenKeys.get(key)}" and "${descriptor.id}"`); - } - seenKeys.set(key, descriptor.id); - } - } - - // A settings block with no registry entry is a block nothing can ever cast. - for (const id of Object.keys(ABILITY_SETTINGS)) { - if (!seenIds.has(id)) warnGlobal(`settings block "${id}" is not registered in ABILITIES`); - } - for (const id of Object.keys(ABILITY_SCHEMAS)) { - if (!seenIds.has(id)) warnGlobal(`editor schema "${id}" is not registered in ABILITIES`); - } -} - -/* ------------------------------------------------------------------ */ -/* §3b · The schools */ -/* ------------------------------------------------------------------ */ -/** - * A school is four things that have to land together, and the reason this - * check exists is that only one of them fails loudly. - * - * An entry in `SCHOOLS` with no `ui/glyphs/.js`, or with a module that - * `ui/glyphs/index.js` never folds into `ELEMENT_SIGILS`, is *invisible*: the - * school builds a spellbook section and an editor folder perfectly happily, - * and every ability in it silently draws no sigil. The per-ability structure - * pass catches that eventually — one "no sigil" failure per ability — but only - * once somebody has written an ability, which is days after the school landed - * and in someone else's diff. Here it fails on the school itself, immediately, - * and names the two files to create. - * - * The fold is verified two ways on purpose. The source scan proves the wiring - * exists even when the module is an empty stub with nothing to prove it with — - * which is exactly the state a new school is in — and the identity comparison - * proves the marks actually arrive, which catches the other failure: two - * schools exporting a sigil under the same ability id, where the later spread - * silently wins and one school's mark disappears. - */ -const GLYPH_DIR = path.join(SRC, 'ui', 'glyphs'); - -/** Modules in `ui/glyphs/` that are not a school. */ -const GLYPH_INFRASTRUCTURE = new Set(['index.js', 'wrap.js']); - -/** - * The bar for "two accents nobody can tell apart", set by the tightest pair - * that already ships: frost `#7ecbe0` against aether `#8fe8d8` scores 0.081. - * A new school closer to an existing one than two schools the player has - * lived with for fifty abilities is a warning, not a failure — it is a - * judgement about a 7px dot, and the author may have a reason. - */ -const ACCENT_FLOOR = 0.075; - -/** - * Redmean colour distance, normalised to roughly 0..1. - * - * Not CIE ΔE: this runs 105 times at fifteen schools and the only question it - * has to answer is "are these two the same dot". Redmean is the cheapest - * approximation that is right about that, and unlike a plain RGB distance it - * knows that two dark blues are closer together than two bright yellows. - */ -function accentDistance(a, b) { - const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); - const [r1, g1, b1] = channels(a); - const [r2, g2, b2] = channels(b); - const dr = r1 - r2; - const dg = g1 - g2; - const db = b1 - b2; - const mean = (r1 + r2) / 2; - return Math.sqrt((2 + mean) * dr * dr + 4 * dg * dg + (3 - mean) * db * db) / 3.1623; -} - -/** - * The school ids that have a leading mark in the editor. - * - * Read out of the source rather than imported: `ui/Editor.js` pulls in lil-gui, - * which is a DOM library, and importing it here would trade a real check for a - * shim big enough to have its own bugs. The table is a frozen object literal of - * `id: 'glyph'` pairs and is not going to grow a computed key. - */ -const EDITOR_SCHOOL_MARKS = (() => { - try { - const source = readFileSync(path.join(SRC, 'ui', 'Editor.js'), 'utf8'); - const block = source.match(/const SCHOOL_MARK = Object\.freeze\(\{([\s\S]*?)\n\}\)/); - return new Set([...(block?.[1] ?? '').matchAll(/^\s*([a-z][a-z0-9]*)\s*:/gm)].map((m) => m[1])); - } catch { - return new Set(); - } -})(); - -/** Roster-level lines that are neither failures nor warnings. */ -const rosterNotes = []; - -async function checkSchools() { - const seen = new Set(); - let index = ''; - try { - index = readFileSync(path.join(GLYPH_DIR, 'index.js'), 'utf8'); - } catch (error) { - failGlobal(`ui/glyphs/index.js could not be read — ${describeError(error)}`); - } - - for (const school of SCHOOLS) { - const { id } = school; - - if (seen.has(id)) failGlobal(`duplicate school id "${id}" in SCHOOLS`); - seen.add(id); - - if (!/^[a-z][a-z0-9]*$/.test(id ?? '')) { - failGlobal(`school id ${JSON.stringify(id)} must be lower case with no punctuation — it is a file name`); - continue; - } - if (!school.label) failGlobal(`school "${id}" has no label`); - if (!/^#[0-9a-f]{6}$/i.test(school.accent ?? '')) { - failGlobal(`school "${id}" accent must be #rrggbb, got ${JSON.stringify(school.accent)}`); - } - - /* --- the glyph module --- */ - const file = `${id}.js`; - const full = path.join(GLYPH_DIR, file); - let exists = false; - try { - exists = statSync(full).isFile(); - } catch { - exists = false; - } - if (!exists) { - failGlobal( - `school "${id}" has no glyph module — create src/ui/glyphs/${file} from the stub shape the ` + - `others use (both append anchors included) and fold it into src/ui/glyphs/index.js` - ); - continue; - } - - const exported = `${id.toUpperCase()}_SIGILS`; - let map = null; - try { - const module = await import(full); - map = module[exported] ?? null; - if (!map || typeof map !== 'object') { - failGlobal(`src/ui/glyphs/${file} must export \`${exported}\`, an object keyed by ability id`); - } - } catch (error) { - failGlobal(`src/ui/glyphs/${file} failed to import — ${describeError(error)}`); - continue; - } - - if (!index.includes(`'./${file}'`)) { - failGlobal(`src/ui/glyphs/index.js never imports './${file}' — school "${id}" draws no sigils at all`); - } else if (!new RegExp(`\\.\\.\\.\\s*${exported}\\b`).test(index)) { - failGlobal(`src/ui/glyphs/index.js imports './${file}' but never spreads \`${exported}\` into ELEMENT_SIGILS`); - } - - for (const [ability, markup] of Object.entries(map ?? {})) { - if (ELEMENT_SIGILS[ability] !== markup) { - failGlobal( - `src/ui/glyphs/${file} exports a sigil for "${ability}" that is not the one in ELEMENT_SIGILS — ` + - `two schools are claiming the same ability id and the later spread wins` - ); - } - } - - /* --- the editor's leading mark --- */ - if (!EDITOR_SCHOOL_MARKS.has(id)) { - warnGlobal(`school "${id}" has no SCHOOL_MARK in ui/Editor.js — its folder falls back to "◇"`); - } - } - - /* --- a module in the directory that no school claims --- */ - for (const name of readdirSync(GLYPH_DIR)) { - if (!name.endsWith('.js') || GLYPH_INFRASTRUCTURE.has(name)) continue; - if (!seen.has(name.slice(0, -3))) { - warnGlobal(`src/ui/glyphs/${name} is not a school in SCHOOLS — its sigils are orphaned`); - } - } - - /* --- accents that are the same dot --- */ - for (let i = 0; i < SCHOOLS.length; i++) { - for (let j = i + 1; j < SCHOOLS.length; j++) { - const a = SCHOOLS[i]; - const b = SCHOOLS[j]; - if (!/^#[0-9a-f]{6}$/i.test(a.accent ?? '') || !/^#[0-9a-f]{6}$/i.test(b.accent ?? '')) continue; - const distance = accentDistance(a.accent, b.accent); - if (distance < ACCENT_FLOOR) { - warnGlobal( - `school accents ${a.id} ${a.accent} and ${b.id} ${b.accent} score ${distance.toFixed(3)} apart, ` + - `under the ${ACCENT_FLOOR} floor — at 7px on the rail they are one colour` - ); - } - } - } - - /* --- schools with nothing in them yet --- */ - const populated = new Set(ABILITIES.map((a) => a.school)); - const empty = SCHOOLS.filter((school) => !populated.has(school.id)).map((school) => school.id); - if (empty.length) { - rosterNotes.push( - `${SCHOOLS.length} school(s), ${empty.length} still empty: ${empty.join(', ')} — wired, but ` + - `absent from the spellbook and the editor until their first ability lands` - ); - } -} - -/** - * Everything about one descriptor that can be judged without running anything. - * - * The `zoneRadius` rule is the one worth stating: a far cast whose block has no - * `zoneRadius` still *aims* — `zoneRadiusOf` falls back to 0 — so the circle - * indicator collapses to a point and the ability lands on a footprint of - * nothing. It is a silent, plausible-looking failure, which is why it is - * checked here rather than trusted to review. - */ -function checkStructure(descriptor, report) { - const { id } = descriptor; - const block = settings[id]; - - if (!block || typeof block !== 'object') { - return report.fail('struct', `no settings block: settings.${id} is ${block}`); - } - if (ABILITY_SETTINGS[id] !== block) { - report.fail('struct', `settings.${id} is not the same object as ABILITY_SETTINGS.${id} — the editor will edit a copy`); - } - if (descriptor.settings !== block) { - report.fail('struct', `registry descriptor for "${id}" does not carry the live block`); - } - - if (!SCHOOL_IDS.has(descriptor.school)) { - report.fail('struct', `unknown school "${descriptor.school}"`); - } - if (!/^#[0-9a-f]{6}$/i.test(descriptor.accent ?? '')) { - report.fail('struct', `accent must be #rrggbb, got ${JSON.stringify(descriptor.accent)}`); - } - if (typeof descriptor.load !== 'function') { - report.fail('struct', 'descriptor.load is not a function'); - } - if (!descriptor.label || !descriptor.blurb) { - report.warn('struct', 'descriptor is missing a label or a blurb'); - } - - for (const key of CAST_KEYS) { - if (!(key in block)) report.fail('struct', `settings.${id}.${key} is missing`); - } - - const shape = descriptor.cast; - if (shape !== CastShape.LINE && shape !== CastShape.ZONE) { - report.fail('struct', `cast must be CastShape.LINE or CastShape.ZONE, got ${JSON.stringify(shape)}`); - } - if (shape === CastShape.ZONE && !('zoneRadius' in block)) { - report.fail('struct', `a ZONE cast needs settings.${id}.zoneRadius — without it the footprint is a point`); - } - if (shape === CastShape.LINE && 'zoneRadius' in block) { - report.warn('struct', 'a LINE cast carries zoneRadius, which nothing will draw'); - } - - if (!CAST_ANIMATIONS.includes(block.castAnim)) { - report.fail('struct', `castAnim ${JSON.stringify(block.castAnim)} is not one of ${CAST_ANIMATIONS.join(' / ')}`); - } - - const numeric = (key) => typeof block[key] === 'number' && Number.isFinite(block[key]); - for (const key of ['range', 'minRange', 'speed', 'cooldown']) { - if (key in block && !numeric(key)) report.fail('struct', `${key} must be a finite number, got ${block[key]}`); - } - if (numeric('range') && numeric('minRange') && block.minRange >= block.range) { - report.fail('struct', `minRange (${block.minRange} m) must be below range (${block.range} m)`); - } - if (numeric('range') && block.range <= 0) report.fail('struct', `range must be positive, got ${block.range} m`); - if (numeric('minRange') && block.minRange < 0) report.fail('struct', `minRange must not be negative, got ${block.minRange} m`); - if (numeric('speed') && block.speed <= 0) report.fail('struct', `speed must be positive, got ${block.speed} m/s`); - if (numeric('cooldown') && block.cooldown < 0) report.fail('struct', `cooldown must not be negative, got ${block.cooldown} s`); - if (shape === CastShape.ZONE && typeof block.zoneRadius === 'number' && block.zoneRadius <= 0) { - report.fail('struct', `zoneRadius must be positive, got ${block.zoneRadius} m`); - } - - // Colours are `#rrggbb` strings so lil-gui can bind them directly; anything - // else in a key named `color*` is a colour that will never get a picker. - for (const [key, entry] of Object.entries(block)) { - if (!/^colou?r/i.test(key)) continue; - if (typeof entry !== 'string' || !/^#[0-9a-f]{6}$/i.test(entry)) { - report.fail('struct', `${key} must be a "#rrggbb" string, got ${JSON.stringify(entry)}`); - } - } - - if (!ELEMENT_SIGILS[id]) { - report.fail('struct', `no sigil: ui/glyphs has no entry for "${id}"`); - } else if (!/]/i.test(ELEMENT_SIGILS[id])) { - report.fail('struct', 'sigil is not inline SVG markup'); - } - - checkSchema(id, block, report); - report.pass('struct'); - return report.ok; -} - -/** - * The editor schema names keys. A name that is not in the block builds a - * controller bound to `undefined`, which lil-gui renders as a dead row. - * - * A schema is allowed to be *incomplete* — unmentioned keys land in the - * trailing "More" folder by design — so the missing direction is a warning and - * only the wrong direction is a failure. - */ -function checkSchema(id, block, report) { - const schema = ABILITY_SCHEMAS[id]; - if (!schema || typeof schema !== 'object') { - return report.fail('struct', `no editor schema: ABILITY_SCHEMAS.${id} is missing`); - } - - const mentioned = new Set(); - for (const [folder, entries] of Object.entries(schema)) { - if (!Array.isArray(entries)) { - report.fail('struct', `schema folder "${folder}" is not an array`); - continue; - } - for (const entry of entries) { - const key = Array.isArray(entry) ? entry[0] : entry; - if (typeof key !== 'string') { - report.fail('struct', `schema folder "${folder}" holds an entry with no key: ${JSON.stringify(entry)}`); - continue; - } - if (key.endsWith('*')) { - // A gradient group: `colorMist*` stands for colorMistA/B/C/D. - const prefix = key.slice(0, -1); - for (const stop of ['A', 'B', 'C', 'D']) { - const full = prefix + stop; - mentioned.add(full); - if (!(full in block)) { - report.fail('struct', `schema gradient "${key}" in "${folder}" wants ${id}.${full}, which does not exist`); - } - } - continue; - } - mentioned.add(key); - if (!(key in block)) { - report.fail('struct', `schema key "${key}" in folder "${folder}" does not exist on settings.${id}`); - } - } - } - - const unfiled = Object.keys(block).filter((key) => !mentioned.has(key)); - if (unfiled.length) { - report.warn( - 'struct', - `${unfiled.length} key(s) not filed in the schema (they land in "More"): ${preview(unfiled)}` - ); - } - return true; -} - -/* ------------------------------------------------------------------ */ -/* §4 · The static settings cross-check */ -/* ------------------------------------------------------------------ */ - -/** - * Strip comments — and, by default, string literals — replacing them with - * spaces. - * - * Positions are preserved so a hit can still be reported with a line number. - * The first version of this pass ran the regexes over the raw source, and the - * very first thing it "found" was `settings.rime.plateSize` inside the doc - * comment of a module that did not exist yet — a wrong answer that reads as a - * right one, which is the worst kind for a tool people are meant to trust. - * - * `keepStrings` exists for exactly one caller: an ability declares which block - * `this.config` resolves to by writing `super('beam', context)`, and that id is - * a *string*. Reading it out of the fully stripped source finds `super( ,` and - * silently drops every aliased read in the file — which is how this harness - * first reported that Nova Beam touches eighty-two of its own settings when the - * real number is a hundred and sixty. - * - * Template literals resume code mode inside `${…}`, because a shader string is - * a template literal and the interpolations in it are real code. Regex - * literals are *not* handled: telling a regex from a division needs the parser - * we do not have, so a `/` is simply left alone. The failure mode of that - * choice is a missed read, never an invented one. - */ -function stripToCode(source, keepStrings = false) { - const out = new Array(source.length); - for (let i = 0; i < source.length; i++) out[i] = source[i]; - - const erase = (from, to) => { - for (let i = from; i < to && i < source.length; i++) { - if (source[i] !== '\n' && source[i] !== '\r') out[i] = ' '; - } - }; - /** Comments always go; string bodies stay when `keepStrings` is set. */ - const blank = keepStrings ? () => {} : erase; - - /** Template-literal nesting: each entry is the `${` depth of one template. */ - const templates = []; - let i = 0; - let braceDepth = 0; - - while (i < source.length) { - const c = source[i]; - const next = source[i + 1]; - - if (c === '/' && next === '/') { - let j = i; - while (j < source.length && source[j] !== '\n') j++; - erase(i, j); - i = j; - continue; - } - if (c === '/' && next === '*') { - let j = i + 2; - while (j < source.length && !(source[j] === '*' && source[j + 1] === '/')) j++; - erase(i, Math.min(j + 2, source.length)); - i = j + 2; - continue; - } - if (c === "'" || c === '"') { - let j = i + 1; - while (j < source.length && source[j] !== c) { - if (source[j] === '\\') j++; - if (source[j] === '\n') break; - j++; - } - blank(i, j + 1); - i = j + 1; - continue; - } - if (c === '`') { - // Walk the template, blanking its text but leaving `${…}` as code. - let j = i + 1; - blank(i, i + 1); - while (j < source.length) { - if (source[j] === '\\') { - blank(j, j + 2); - j += 2; - continue; - } - if (source[j] === '`') { - blank(j, j + 1); - j++; - break; - } - if (source[j] === '$' && source[j + 1] === '{') { - blank(j, j + 2); - // Hand control back to the outer loop for the interpolation body. - templates.push(braceDepth); - braceDepth++; - j += 2; - i = j; - break; - } - blank(j, j + 1); - j++; - } - if (j > i && (source[j - 1] === '`' || j >= source.length)) i = j; - else if (i !== j) i = j; - continue; - } - if (c === '{') braceDepth++; - if (c === '}') { - braceDepth--; - if (templates.length && braceDepth === templates[templates.length - 1]) { - // Closing a `${…}`: resume the template's text. - templates.pop(); - blank(i, i + 1); - let j = i + 1; - while (j < source.length) { - if (source[j] === '\\') { - blank(j, j + 2); - j += 2; - continue; - } - if (source[j] === '`') { - blank(j, j + 1); - j++; - break; - } - if (source[j] === '$' && source[j + 1] === '{') { - blank(j, j + 2); - templates.push(braceDepth); - braceDepth++; - j += 2; - break; - } - blank(j, j + 1); - j++; - } - i = j; - continue; - } - } - i++; - } - - return out.join(''); -} - -/** Every `.js` under `src/`, excluding the archive (it reads a dead settings tree). */ -function sourceFiles(dir, found = []) { - for (const entry of readdirSync(dir)) { - const full = path.join(dir, entry); - if (statSync(full).isDirectory()) { - if (entry === 'archive' || entry === 'node_modules') continue; - sourceFiles(full, found); - } else if (entry.endsWith('.js')) { - found.push(full); - } - } - return found; -} - -const IDENT = '[A-Za-z_$][A-Za-z0-9_$]*'; - -/** - * Which local identifiers in a file are safe to treat as "an alias for a - * settings block". - * - * `const c = settings.beam` makes `c.` a settings read for the rest of - * that method — and in this codebase the same file will bind `c` to - * `this.config` in the next method, which is the same block by another name. - * What must never happen is treating `c.foo` as a settings read when some - * *third* binding put an unrelated object in `c`, because that invents a - * failure out of nothing. - * - * So the pass keeps two sets, and the difference between them is the whole - * trick: - * - * - **strict** — every binding of the name in the file is a settings binding - * *and* the name never appears in anything that parses as a parameter list. - * Reads through a strict alias are *required*: a key that is not in the - * block is a failure. - * - **loose** — the name is bound to a block somewhere, and is also a - * parameter somewhere. `IceAbility` threads its block through helpers as - * `_halfWidth(s, c)`, which is idiomatic here and would otherwise blind the - * pass to two thirds of that file. Reads through a loose alias are recorded - * as *optional*: they count toward "this key is used" and can never raise a - * failure, so the worst a wrong guess can do is make a warning quieter. - * - * Anything bound to a non-settings right-hand side anywhere in the file is out - * of both sets. That test is deliberately over-eager: its failure mode is - * dropping a real alias, which loses coverage, and never inventing a fake one, - * which loses trust. - */ -function collectAliases(code, configIds) { - const candidates = new Map(); // name -> Set(blockName) | null when polluted - - const bind = (name, block) => { - if (candidates.get(name) === null) return; - if (!candidates.has(name)) candidates.set(name, new Set()); - if (block === null) candidates.set(name, null); - else candidates.get(name).add(block); - }; - - // Declarations and plain assignments, settings-shaped or not. - const declaration = new RegExp( - `(?:\\b(?:const|let|var)\\s+(${IDENT})|(?:^|[;{}(),]\\s*)(${IDENT}))\\s*=\\s*(?!=)([^;\\n]*)`, - 'g' - ); - for (const match of code.matchAll(declaration)) { - const name = match[1] ?? match[2]; - const rhs = match[3].trim(); - const settingsBlock = rhs.match(new RegExp(`^settings\\s*\\.\\s*(${IDENT})\\s*;?$`)); - if (settingsBlock) { - bind(name, settingsBlock[1]); - continue; - } - if (/^this\s*\.\s*config\s*;?$/.test(rhs)) { - for (const id of configIds) bind(name, id); - continue; - } - bind(name, null); - } - - /** Names that appear in a parameter list: strict-disqualifying, loose-tolerated. */ - const parameters = new Set(); - const paramForms = [ - new RegExp(`\\bfunction\\s*${IDENT}?\\s*\\(([^)]*)\\)`, 'g'), - new RegExp(`(?:^|[;{}\\s])(?:async\\s+)?(?:get\\s+|set\\s+|static\\s+)*${IDENT}\\s*\\(([^)]*)\\)\\s*\\{`, 'g'), - /\(([^)]*)\)\s*=>/g, - new RegExp(`\\bcatch\\s*\\(\\s*(${IDENT})\\s*\\)`, 'g') - ]; - for (const pattern of paramForms) { - for (const match of code.matchAll(pattern)) { - for (const raw of match[1].split(',')) { - const name = raw.trim().replace(/^\.\.\./, '').split('=')[0].trim(); - if (new RegExp(`^${IDENT}$`).test(name)) parameters.add(name); - } - } - } - for (const match of code.matchAll(new RegExp(`(?:^|[^.\\w$])(${IDENT})\\s*=>`, 'g'))) { - parameters.add(match[1]); - } - - const strict = new Map(); // name -> [blockName], reads are required - const loose = new Map(); // name -> [blockName], reads are advisory only - for (const [name, blocks] of candidates) { - if (blocks === null || blocks.size === 0) continue; - (parameters.has(name) ? loose : strict).set(name, [...blocks]); - } - return { strict, loose }; -} - -/** Split a destructuring pattern body into the source keys it names. */ -function destructuredKeys(body) { - const keys = []; - let depth = 0; - let current = ''; - const flush = () => { - const piece = current.trim(); - current = ''; - if (!piece || piece.startsWith('...')) return; - const name = piece.split(':')[0].split('=')[0].trim(); - if (new RegExp(`^${IDENT}$`).test(name)) keys.push(name); - }; - for (const ch of body) { - if (ch === '{' || ch === '[' || ch === '(') depth++; - else if (ch === '}' || ch === ']' || ch === ')') depth--; - if (ch === ',' && depth === 0) flush(); - else current += ch; - } - flush(); - return keys; -} - -/** - * The whole static pass. - * - * Returns `{ reads, unknownBlocks }` where `reads` is `blockName -> key -> - * [{ file, line, optional }]`. `optional` marks a read written defensively — - * `this.config.zoneRadius ?? 1`, `c?.foo` — which counts as a read for the - * unused-key warning but does not make the key mandatory. The aim controller - * reads `zoneRadius` off whatever block is in the slot; a line cast is entitled - * not to have one. - */ -function scanSources() { - const reads = new Map(); - const unknownBlocks = []; - const blockNames = new Set(Object.keys(settings)); - - const record = (block, key, file, line, optional) => { - if (!reads.has(block)) reads.set(block, new Map()); - const table = reads.get(block); - if (!table.has(key)) table.set(key, []); - table.get(key).push({ file, line, optional }); - }; - - for (const file of sourceFiles(SRC)) { - const relative = path.relative(ROOT, file); - const raw = readFileSync(file, 'utf8'); - const code = stripToCode(raw); - const withStrings = stripToCode(raw, true); - - // Line numbers, computed once per file. - const lineStarts = [0]; - for (let i = 0; i < code.length; i++) if (code[i] === '\n') lineStarts.push(i + 1); - const lineAt = (index) => { - let low = 0; - let high = lineStarts.length - 1; - while (low < high) { - const mid = (low + high + 1) >> 1; - if (lineStarts[mid] <= index) low = mid; - else high = mid - 1; - } - return low + 1; - }; - - // `super('ice', context)` is how a class declares which block `this.config` - // resolves to. A file with no such call and a `this.config` read is either - // the base class or the aim controller: it reads whatever is in the slot, - // so its reads apply to *every* id. - const ownIds = [...withStrings.matchAll(new RegExp(`\\bsuper\\s*\\(\\s*['"\`](${IDENT})['"\`]\\s*[,)]`, 'g'))] - .map((m) => m[1]) - .filter((id) => blockNames.has(id)); - const superIds = [...new Set(ownIds)]; - const configIds = superIds.length ? superIds : ALL_IDS; - - const isOptional = (index, length) => { - const before = code.slice(Math.max(0, index - 2), index); - const after = code.slice(index + length, index + length + 12); - return before.endsWith('?.') || /^\s*\?\?/.test(after) || /^\s*\?\./.test(after); - }; - - /* --- settings.. --- */ - for (const match of code.matchAll(new RegExp(`\\bsettings\\s*\\??\\.\\s*(${IDENT})\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { - const [text, block, key] = match; - if (!blockNames.has(block)) { - unknownBlocks.push({ block, file: relative, line: lineAt(match.index) }); - continue; - } - record(block, key, relative, lineAt(match.index), isOptional(match.index, text.length)); - } - - /* --- settings[]. — the framework's blind index --- */ - for (const match of code.matchAll(new RegExp(`\\bsettings\\s*\\[[^\\]]+\\]\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { - // Always recorded as optional: `settings[element].zoneRadius` is read for - // whichever ability happens to be in the slot, and a line cast is - // entitled not to have one. - for (const id of ALL_IDS) record(id, match[1], relative, lineAt(match.index), true); - } - - /* --- this.config. --- */ - // A framework file's `this.config` is whichever ability is in the slot, so - // its reads land on every block — and they are *required* there, which is - // how `Ability.js` reading `cfg.lightColor` becomes a contract every one of - // the fifty blocks has to honour without anybody writing it down twice. The - // one genuinely optional read in that set, `this.config.zoneRadius ?? 1` in - // the aim controller, marks itself optional by being written defensively. - for (const match of code.matchAll(new RegExp(`\\bthis\\s*\\.\\s*config\\s*\\??\\.\\s*(${IDENT})`, 'g'))) { - const optional = isOptional(match.index, match[0].length); - for (const id of configIds) record(id, match[1], relative, lineAt(match.index), optional); - } - - /* --- aliases: `const c = settings.beam` then `c.foo` --- */ - const { strict, loose } = collectAliases(code, configIds); - const aliases = new Map([...loose, ...strict]); - for (const [name, blocks] of aliases) { - const advisory = loose.has(name) && !strict.has(name); - const pattern = new RegExp(`(?:^|[^.\\w$])${name}\\s*\\??\\.\\s*(${IDENT})`, 'g'); - for (const match of code.matchAll(pattern)) { - const optional = advisory || isOptional(match.index, match[0].length); - for (const block of blocks) { - if (!blockNames.has(block)) continue; - record(block, match[1], relative, lineAt(match.index), optional); - } - } - } - - /* --- destructuring off a block, an alias, or this.config --- */ - const destructure = new RegExp( - `\\b(?:const|let|var)\\s*\\{([^}]*)\\}\\s*=\\s*(this\\s*\\.\\s*config|settings\\s*\\.\\s*${IDENT}|${IDENT})`, - 'g' - ); - for (const match of code.matchAll(destructure)) { - const source = match[2].replace(/\s+/g, ''); - let blocks = null; - if (source === 'this.config') blocks = configIds; - else if (source.startsWith('settings.')) blocks = [source.slice('settings.'.length)]; - else if (aliases.has(source)) blocks = aliases.get(source); - if (!blocks) continue; - const line = lineAt(match.index); - for (const block of blocks) { - if (!blockNames.has(block)) { - if (source.startsWith('settings.')) unknownBlocks.push({ block, file: relative, line }); - continue; - } - const advisory = loose.has(source) && !strict.has(source); - for (const key of destructuredKeys(match[1])) { - record(block, key, relative, line, advisory); - } - } - } - } - - return { reads, unknownBlocks }; -} - -const STATIC = scanSources(); - -for (const { block, file, line } of STATIC.unknownBlocks) { - failGlobal(`${file}:${line} reads settings.${block}, which is not a settings block`); -} - -function checkStaticReads(descriptor, report) { - const { id } = descriptor; - const block = settings[id] ?? {}; - const table = STATIC.reads.get(id) ?? new Map(); - - /* --- reads with no key behind them: the NaN factory --- */ - const missing = []; - for (const [key, sites] of table) { - if (key in block) continue; - const required = sites.filter((site) => !site.optional); - if (required.length === 0) continue; // a guarded read of an absent key is fine - missing.push({ key, sites: required }); - } - missing.sort((a, b) => a.key.localeCompare(b.key)); - for (const { key, sites } of missing) { - const where = sites.slice(0, 3).map((s) => `${s.file}:${s.line}`).join(', '); - report.fail( - 'static', - `reads settings.${id}.${key}, which does not exist — undefined here becomes NaN geometry (${where}${sites.length > 3 ? ', …' : ''})` - ); - } - - /* --- keys nothing reads: a warning, and a soft one --- */ - const unread = Object.keys(block).filter((key) => !table.has(key)); - if (unread.length) { - report.warn( - 'static', - `${unread.length} settings key(s) with no visible read — check they are not dead, or that they are consumed through a shared VFX module: ${preview(unread)}` - ); - } - - // Counted against the block, not against the table: a loose alias can pick up - // `c.length` off something that is not this block at all, and a "reads 205 of - // its 161 keys" line is a number nobody can act on. - const realised = [...table.keys()].filter((key) => key in block).length; - report.note( - `${realised}/${Object.keys(block).length} settings key(s) read, across ${countSites(table)} site(s)` - ); - report.pass('static'); - return report.ok; -} - -const countSites = (table) => [...table.values()].reduce((total, sites) => total + sites.length, 0); -const preview = (list, limit = 8) => - list.slice(0, limit).join(', ') + (list.length > limit ? `, … (+${list.length - limit})` : ''); - -/* ------------------------------------------------------------------ */ -/* §5 · The mock context */ -/* ------------------------------------------------------------------ */ - -/** - * `shake` and `flash` are stubs because both of them reach outside the scene — - * one into the camera rig, the other into a DOM overlay — and neither has any - * bearing on whether an ability's geometry is sane. Everything else is the real - * subsystem, on a real `Scene`, because a mock `ParticleEngine` would not have - * caught a single one of the bugs this harness exists for. - */ -function makeContext() { - const scene = new Scene(); - const camera = new PerspectiveCamera(46, 16 / 9, 0.1, 400); - camera.position.set(0, 8, 12); - camera.lookAt(0, 0, 0); - camera.updateMatrixWorld(true); - - const noop = { add() {}, rumble() {}, trigger() {}, reset() {}, update() {} }; - - return { - scene, - camera, - /** - * `Environment` itself needs a live `Renderer`, so it is stood in for — but - * the two methods materials actually call are reproduced faithfully rather - * than stubbed to `noop`. `registerShadowCasterWithPatch` is what attaches - * an ability's `onBeforeCompile` hook, and a version of it that does nothing - * would quietly turn the harness into a test that ice and cinder *do not* - * patch their shaders — which is the opposite of the truth and the reason - * both of them threw on the first run of this file. - */ - environment: { - scene, - camera, - renderer: null, - envMap: null, - registerShadowCaster: (material) => material, - registerShadowCasterWithPatch: (material, patch) => patchOnBeforeCompile(material, patch), - setFocus() {}, - update() {} - }, - particles: new ParticleEngine(scene), - lights: new LightPool(scene), - decals: new DecalSystem(scene), - fissures: new FissureSystem(scene), - bursts: new BurstSystem(scene), - shake: noop, - flash: noop - }; -} - -/** - * Capture whatever three.js (or the ability) prints while a block of work runs. - * - * Not censorship — the opposite. A cast that triggers a three.js warning on - * every one of nine hundred frames buries the report under nine hundred - * identical lines, and the useful information ("this geometry is already - * non-indexed") is one line and a count. So they are collected, de-duplicated - * and attached to the ability that caused them. - */ -async function captureConsole(report, fn) { - const seen = new Map(); - const original = { warn: console.warn, error: console.error }; - const sink = (...args) => { - const message = args.map((a) => (a instanceof Error ? a.message : String(a))).join(' '); - seen.set(message, (seen.get(message) ?? 0) + 1); - }; - console.warn = sink; - console.error = sink; - try { - return await fn(); - } finally { - console.warn = original.warn; - console.error = original.error; - for (const [message, count] of seen) { - report.warn('runtime', `console: ${message}${count > 1 ? ` (×${count})` : ''}`); - } - } -} - -/** One simulated frame, in exactly the order `App.update` runs it. */ -function step(ctx, ability, dt) { - frame.uTime.value += dt; - frame.uDelta.value = dt; - ability.update(dt); - ctx.particles.flush(); - ctx.decals.update(dt); - ctx.fissures.update(dt); - ctx.bursts.update(dt); - ctx.lights.update(dt); -} - -/* ------------------------------------------------------------------ */ -/* §6 · Non-finite and sanity scanning */ -/* ------------------------------------------------------------------ */ - -/** - * Every uniform a material actually drives, from both places they hide. - * - * A `ShaderMaterial` keeps them on `material.uniforms`. A patched - * `MeshStandardMaterial` — Frost Lance's crystal, Cinder Fall's rock — cannot: - * its uniforms are handed to the shader inside `onBeforeCompile`, which never - * runs without a GL context, so the live boxes are parked on - * `material.userData.uniforms` by convention and `material.uniforms` does not - * exist at all. - * - * Missing that second location is not a small gap. It made the pause test - * blind to every value on a patched standard material, and the first run of - * this harness cheerfully reported that thirty of Frost Lance's shading - * sliders did nothing while paused. All thirty of them work. - */ -function* uniformsOf(material) { - if (!material) return; - if (material.uniforms) yield* Object.entries(material.uniforms); - if (material.userData?.uniforms) yield* Object.entries(material.userData.uniforms); -} - -function scanArray(array, label, out) { - for (let i = 0; i < array.length; i++) { - const v = array[i]; - if (!Number.isFinite(v)) { - out.push(`${label}[${i}] is ${Number.isNaN(v) ? 'NaN' : v}`); - return; // one report per array; a NaN never arrives alone - } - } -} - -function scanParticles(ctx, out) { - for (const system of ctx.particles.systems.values()) { - for (const [key, array] of Object.entries(system.data)) { - scanArray(array, `particles["${system.name}"].${key}`, out); - } - } -} - -function scanGroup(group, out) { - let index = 0; - group.traverse((object) => { - // The meshes in an ability group are usually unnamed, so the traversal - // index is the only handle a reader has on *which* of the three it was. - const name = `${object.name || object.type}#${index++}`; - for (const [label, vector] of [ - ['position', object.position], - ['scale', object.scale] - ]) { - if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { - out.push(`${name}.${label} is (${vector.x}, ${vector.y}, ${vector.z})`); - } - } - const q = object.quaternion; - if (!Number.isFinite(q.x) || !Number.isFinite(q.y) || !Number.isFinite(q.z) || !Number.isFinite(q.w)) { - out.push(`${name}.quaternion is non-finite`); - } - if (object.position.lengthSq() > SANITY_RADIUS * SANITY_RADIUS) { - out.push( - `${name}.position is ${object.position.length().toFixed(0)} m from the origin — beyond the ${SANITY_RADIUS} m sanity radius` - ); - } - - if (object.isInstancedMesh) { - scanArray(object.instanceMatrix.array, `${name}.instanceMatrix`, out); - if (object.instanceColor) scanArray(object.instanceColor.array, `${name}.instanceColor`, out); - } - if (object.geometry) { - for (const [attributeName, attribute] of Object.entries(object.geometry.attributes ?? {})) { - if (attribute.isInstancedBufferAttribute) { - scanArray(attribute.array, `${name}.${attributeName}`, out); - } - } - } - const materials = Array.isArray(object.material) ? object.material : [object.material]; - for (const material of materials) { - for (const [uniformName, uniform] of uniformsOf(material)) { - const v = uniform?.value; - if (typeof v === 'number' && !Number.isFinite(v)) out.push(`${name}.${uniformName} is ${v}`); - else if (v?.isVector2 || v?.isVector3 || v?.isVector4 || v?.isQuaternion) { - if (!Number.isFinite(v.x) || !Number.isFinite(v.y) || !Number.isFinite(v.z ?? 0) || !Number.isFinite(v.w ?? 0)) { - out.push(`${name}.${uniformName} is non-finite`); - } - } else if (v?.isColor) { - if (!Number.isFinite(v.r) || !Number.isFinite(v.g) || !Number.isFinite(v.b)) { - out.push(`${name}.${uniformName} is a non-finite colour`); - } - } - } - } - }); -} - -/* ------------------------------------------------------------------ */ -/* §7 · Snapshots — what "something changed" means */ -/* ------------------------------------------------------------------ */ - -/** - * A snapshot is every number an ability owns that a viewer could see change: - * the transform of every object in its group, every numeric uniform on every - * material it draws with, every instance matrix and instanced attribute, and - * the uniforms of the particle systems it namespaces. - * - * Labels are built once per sample point and reused, because the pause test - * takes a few hundred snapshots per ability and building a few hundred - * thousand label strings to throw all of them away is the kind of thing that - * turns a two-second harness into a thirty-second one. - */ -/** djb2 over a uuid, so "the geometry object was replaced" is one number. */ -function hashString(text) { - let hash = 5381; - for (let i = 0; i < text.length; i++) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0; - return hash; -} - -/** `[sum, index-weighted sum]` over a numeric array. */ -function checksum(array) { - let sum = 0; - let weighted = 0; - for (let i = 0; i < array.length; i++) { - const v = array[i]; - if (!Number.isFinite(v)) continue; - sum += v; - weighted += v * (i + 1); - } - return [sum, weighted]; -} - -class Snapshotter { - constructor(ability, ctx) { - this.ability = ability; - this.systems = [...ctx.particles.systems.values()].filter((s) => s.name.startsWith(`${ability.element}.`)); - this.labels = null; - } - - capture(withLabels = false) { - const values = []; - const labels = withLabels ? [] : null; - const push = (label, number) => { - values.push(number); - if (labels) labels.push(label); - }; - - const flatten = (label, v) => { - if (typeof v === 'number') push(label, v); - else if (typeof v === 'boolean') push(label, v ? 1 : 0); - else if (v == null) return; - else if (v.isColor) { - push(`${label}.r`, v.r); - push(`${label}.g`, v.g); - push(`${label}.b`, v.b); - } else if (v.isVector2 || v.isVector3 || v.isVector4 || v.isQuaternion) { - push(`${label}.x`, v.x); - push(`${label}.y`, v.y); - if (v.z !== undefined) push(`${label}.z`, v.z); - if (v.w !== undefined) push(`${label}.w`, v.w); - } else if (v.isEuler) { - push(`${label}.x`, v.x); - push(`${label}.y`, v.y); - push(`${label}.z`, v.z); - } else if (v.elements) { - for (let i = 0; i < v.elements.length; i++) push(`${label}[${i}]`, v.elements[i]); - } else if (ArrayBuffer.isView(v) || Array.isArray(v)) { - for (let i = 0; i < v.length; i++) { - const entry = v[i]; - if (typeof entry === 'number') { - push(`${label}[${i}]`, entry); - } else if (entry && (entry.isVector2 || entry.isVector3 || entry.isVector4 || entry.isColor)) { - // A uniform whose value is an **array of vectors**. This is not an - // exotic case: `vfx/FilamentPaths.js` keeps every role's entire - // geometry in six of them (`uFrom`, `uTo`, `uShape`, `uShape2`, - // `uStyle`, `uDraw`, indexed by role), and so does every ability - // built on it. Skipping them made this snapshot blind to the whole - // of such an ability's shape, and the pause test duly reported - // thirty perfectly live path sliders as dead — the same failure - // mode as the patched-`MeshStandardMaterial` gap, arrived at from - // the other direction. - flatten(`${label}[${i}]`, entry); - } - } - } - }; - - let index = 0; - this.ability.group.traverse((object) => { - const name = `${object.name || object.type}#${index++}`; - flatten(`${name}.position`, object.position); - flatten(`${name}.scale`, object.scale); - flatten(`${name}.quaternion`, object.quaternion); - push(`${name}.visible`, object.visible ? 1 : 0); - - if (object.isInstancedMesh) { - push(`${name}.count`, object.count); - flatten(`${name}.instanceMatrix`, object.instanceMatrix.array); - if (object.instanceColor) flatten(`${name}.instanceColor`, object.instanceColor.array); - } - if (object.geometry) { - const geometry = object.geometry; - push(`${name}.instanceCount`, geometry.instanceCount ?? -1); - push(`${name}.drawRange`, geometry.drawRange?.count ?? -1); - // A live rebuild swaps the whole object, so its identity is the cheapest - // and most direct evidence that `facets` or `roughness` did something. - push(`${name}.geometry.uuid`, hashString(geometry.uuid)); - for (const [attributeName, attribute] of Object.entries(geometry.attributes ?? {})) { - if (attribute.isInstancedBufferAttribute) { - flatten(`${name}.${attributeName}`, attribute.array); - } else { - // Per-vertex arrays are far too big to snapshot four hundred times, - // and nobody needs to know *which* vertex moved — only that the - // geometry was regenerated. Two checksums, one position-weighted so - // a permutation is not mistaken for a no-op. - const [sum, weighted] = checksum(attribute.array); - push(`${name}.${attributeName}.length`, attribute.array.length); - push(`${name}.${attributeName}.sum`, sum); - push(`${name}.${attributeName}.weighted`, weighted); - } - } - } - const materials = Array.isArray(object.material) ? object.material : [object.material]; - for (const material of materials) { - if (!material) continue; - push(`${name}.opacity`, material.opacity); - push(`${name}.emissiveIntensity`, material.emissiveIntensity ?? 0); - flatten(`${name}.color`, material.color); - flatten(`${name}.emissive`, material.emissive); - for (const [uniformName, uniform] of uniformsOf(material)) { - flatten(`${name}.u.${uniformName}`, uniform?.value); - } - } - }); - - // Materials the ability owns but has not hung on a mesh *yet*. - // - // The traversal above finds everything in the group, which is every - // material in almost every ability. The exception is an ability whose - // meshes are conditional on something the harness cannot supply: `mirage` - // clones the caster's skinned rig, and in a headless scene there is no - // caster, so its refraction material exists as a field and is attached to - // nothing. Walking the instance's own properties finds it anyway, and the - // ability's sliders are measured instead of being written off as dead — - // which is the whole job of this probe. One shallow pass over own - // properties, no recursion, so it cannot wander into the context object. - let field = 0; - for (const value of Object.values(this.ability)) { - const candidates = Array.isArray(value) ? value : [value]; - for (const candidate of candidates) { - if (!candidate?.isMaterial) continue; - const name = `field:${candidate.name || candidate.type}#${field++}`; - push(`${name}.opacity`, candidate.opacity); - for (const [uniformName, uniform] of uniformsOf(candidate)) { - flatten(`${name}.u.${uniformName}`, uniform?.value); - } - } - } - - for (const system of this.systems) { - for (const [uniformName, uniform] of Object.entries(system.uniforms ?? {})) { - flatten(`${system.name}.${uniformName}`, uniform?.value); - } - } - - // The dynamic light is not in the group — it lives in the pool, in the - // scene — but it is unambiguously something the ability drives and the - // player sees, so `lightIntensity` and `lightRadius` are measured here - // rather than written off as unobservable. `light.intensity` itself is - // damped and therefore frozen on a zero-length frame; `target` is not. - const handle = this.ability.light; - if (handle) { - push('light.target', handle.target); - push('light.distance', handle.light.distance); - flatten('light.position', handle.light.position); - flatten('light.color', handle.light.color); - } - - if (labels) this.labels = labels; - return values; - } -} - -/** Indices at which two snapshots differ by more than float noise. */ -function diffIndices(a, b, limit = 6) { - if (a.length !== b.length) return ['']; - const changed = []; - for (let i = 0; i < a.length; i++) { - const scale = Math.max(1, Math.abs(a[i]), Math.abs(b[i])); - if (Math.abs(a[i] - b[i]) > CHANGE_EPSILON * scale) { - changed.push(i); - if (changed.length >= limit) break; - } - } - return changed; -} - -const didChange = (a, b) => diffIndices(a, b, 1).length > 0; - -/* ------------------------------------------------------------------ */ -/* §8 · Runtime simulation */ -/* ------------------------------------------------------------------ */ - -const _origin = new Vector3(0, 0, 0); -const _direction = new Vector3(0, 0, 1); - -/** A cast distance inside the ability's own declared reach. */ -function castDistance(block) { - const range = Number.isFinite(block.range) ? block.range : 10; - const minRange = Number.isFinite(block.minRange) ? block.minRange : 0; - return Math.max(minRange + 0.5, range * 0.8); -} - -async function runAbility(descriptor, report) { - const { id } = descriptor; - const block = settings[id]; - - let Type; - try { - Type = await descriptor.load(); - } catch (error) { - return report.fail('runtime', `load() rejected: ${describeError(error)}`); - } - if (typeof Type !== 'function') { - return report.fail('runtime', `load() resolved to ${typeof Type}, not a class — did you forget \`.then(m => m.XAbility)\`?`); - } - - const ctx = makeContext(); - frame.uTime.value = 0; - frame.uDelta.value = 0; - - let ability; - try { - ability = new Type(ctx); - ctx.scene.add(ability.group); - } catch (error) { - return report.fail('runtime', `constructor threw: ${describeError(error)}`); - } - - if (ability.element !== id) { - report.fail('runtime', `constructed with element "${ability.element}" but registered as "${id}"`); - } - - /* --- the namespacing rule: ".", or a shared system --- */ - for (const name of ctx.particles.systems.keys()) { - if (!name.startsWith(`${id}.`) && !name.startsWith('shared.')) { - report.warn('runtime', `particle system "${name}" is not namespaced "${id}." — it will collide with another ability`); - } - } - - const meshCount = countDrawables(ability.group); - if (meshCount > DRAW_CALL_BUDGET) { - report.warn('runtime', `${meshCount} drawable(s) in the group; the budget in §0/I7 is ${DRAW_CALL_BUDGET}`); - } - report.note(`${meshCount} drawable(s), ${ctx.particles.systems.size} particle system(s)`); - - const distance = castDistance(block); - const problems = []; - - /* ---------------- first cast: the full run ---------------- */ - try { - ability.spawn(_origin, _direction, distance); - } catch (error) { - return report.fail('runtime', `spawn() threw: ${describeError(error)}`); - } - - const childrenAtSpawn = countDrawables(ability.group); - const systemsAtSpawn = ctx.particles.systems.size; - - let frames = 0; - let finishedAt = -1; - const seenPhases = new Set([ability.phase]); - try { - while (frames < MAX_FRAMES) { - step(ctx, ability, DT); - frames++; - seenPhases.add(ability.phase); - if (frames % NAN_STRIDE === 0 || ability.isFinished) { - scanParticles(ctx, problems); - } - scanGroup(ability.group, problems); - if (problems.length) break; - if (ability.isFinished) { - finishedAt = frames; - if (frames >= REQUIRED_FRAMES) break; - } - if (finishedAt >= 0 && frames >= REQUIRED_FRAMES) break; - } - } catch (error) { - return report.fail('runtime', `threw on frame ${frames + 1} (phase ${ability.phase}): ${describeError(error)}`); - } - - for (const problem of problems.slice(0, 5)) { - report.fail('runtime', `non-finite value after ${frames} frame(s): ${problem}`); - } - - if (finishedAt < 0 && problems.length) { - // The loop was cut short by the NaN above; "it never finished" is that - // failure wearing a second hat, and reporting both sends people hunting for - // a timing bug that is not there. - report.note('the tick loop was stopped early by the non-finite value above'); - } else if (finishedAt < 0) { - report.fail( - 'runtime', - `never reached DONE in ${MAX_FRAMES} frames (${(MAX_FRAMES * DT).toFixed(1)} s) — stuck in ${ability.phase}; ` + - `check impactDuration (${safeNumber(ability.impactDuration)} s) and fadeDuration (${safeNumber(ability.fadeDuration)} s)` - ); - } else { - report.note(`cast ran ${finishedAt} frames (${(finishedAt * DT).toFixed(2)} s) to DONE`); - } - - for (const phase of [AbilityPhase.TRAVEL, AbilityPhase.IMPACT, AbilityPhase.FADE]) { - if (!seenPhases.has(phase)) report.warn('runtime', `never entered the ${phase.toUpperCase()} phase`); - } - - const childrenAtEnd = countDrawables(ability.group); - if (childrenAtEnd !== childrenAtSpawn) { - report.fail( - 'runtime', - `the group grew from ${childrenAtSpawn} to ${childrenAtEnd} drawable(s) during the cast — I3 says nothing is built during a cast` - ); - } - if (ctx.particles.systems.size !== systemsAtSpawn) { - report.fail( - 'runtime', - `${ctx.particles.systems.size - systemsAtSpawn} particle system(s) created mid-cast — build them in createParticles()` - ); - } - - /* ---------------- destroy, then cast again ---------------- */ - try { - ability.destroy(); - } catch (error) { - return report.fail('runtime', `destroy() threw: ${describeError(error)}`); - } - if (ability.phase !== AbilityPhase.IDLE) { - report.fail('runtime', `destroy() left the instance in phase ${ability.phase}, not IDLE — the pool will hand out a live cast`); - } - /* The two global pools an ability can take a piece of the *world* from. A - * leaked light costs the next cast its light; a leaked scene hook 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. Neither - * failure is traceable back to the cast that caused it, so it is caught here. - * `Ability#borrow()` is the net — see `abilities/Ability.js`. */ - if (sceneHooksLive() !== 0) { - report.fail( - 'runtime', - `destroy() left ${sceneHooksLive()} scene hook(s) held — acquire through this.borrow(), or release in onDestroy()` - ); - sceneHooksReleaseAll(); - } - if (timeRegionsLive() !== 0) { - report.fail( - 'runtime', - `destroy() left ${timeRegionsLive()} time region(s) live — acquire through this.borrow(), or release in onDestroy()` - ); - timeRegionsReset(); - } - - try { - ability.spawn(_origin, _direction, distance); - for (let i = 0; i < REUSE_FRAMES; i++) step(ctx, ability, DT); - } catch (error) { - return report.fail('runtime', `the second cast threw — destroy() did not leave the instance reusable: ${describeError(error)}`); - } - const reuseProblems = []; - scanParticles(ctx, reuseProblems); - scanGroup(ability.group, reuseProblems); - for (const problem of reuseProblems.slice(0, 3)) { - report.fail('runtime', `non-finite value on the second cast: ${problem}`); - } - ability.destroy(); - - report.pass('runtime'); - - /* ---------------- the pause test, on a fresh cast ---------------- */ - runPauseTest(descriptor, report, ctx, ability); - - try { - ability.dispose?.(); - ctx.particles.dispose(); - ctx.decals.dispose(); - ctx.fissures.dispose(); - ctx.bursts.dispose(); - ctx.lights.dispose(); - } catch (error) { - report.warn('runtime', `teardown threw: ${describeError(error)}`); - } - - return report.ok; -} - -function countDrawables(group) { - let total = 0; - group.traverse((object) => { - if (object.isMesh || object.isPoints || object.isLine || object.isSprite) total++; - }); - return total; -} - -/* ------------------------------------------------------------------ */ -/* §9 · The pause test (invariant I1) */ -/* ------------------------------------------------------------------ */ - -/** - * Two sample points, chosen because they are the two beats where the mistake - * lives: - * - * - **mid-travel**, when the front is halfway out and everything is being - * resolved from settings because it has to be; - * - **mid-impact**, when the effect is standing still and it is very tempting - * to have cached the metres it stood up with. - * - * At each one we stop the clock and feed the ability zero-length frames, which - * is precisely what `App` does when you press **P**. If scaling every dimension - * on the block by 1.37 does not move a single number the ability owns, the - * effect on screen would not move either, and the ability is not done. - */ -function runPauseTest(descriptor, report, ctx, ability) { - const { id } = descriptor; - const block = settings[id]; - const distance = castDistance(block); - - const mutable = Object.keys(block).filter( - (key) => typeof block[key] === 'number' && Number.isFinite(block[key]) && block[key] !== 0 && !PAUSE_EXCLUDED.has(key) - ); - if (mutable.length === 0) { - report.fail('travel', 'no non-zero numeric settings to mutate — every dimension must be a slider (I5)'); - report.fail('impact', 'no non-zero numeric settings to mutate'); - return; - } - - for (const sample of ['travel', 'impact']) { - let outcome; - try { - outcome = probeSample(ctx, ability, block, mutable, sample, distance); - } catch (error) { - report.fail(sample, `threw while probing at ${sample}: ${describeError(error)}`); - continue; - } - - if (outcome.skipped) { - report.fail(sample, outcome.skipped); - continue; - } - - if (!outcome.restless) { - report.note( - `${sample}: ${outcome.responsive.length}/${mutable.length} slider(s) observable while paused` + - (outcome.dead.length && SHOW_SLIDERS ? `\n dead here: ${outcome.dead.join(', ')}` : '') - ); - } else { - report.warn( - sample, - 'the ability is not stable at rest — a zero-length frame with nothing changed already moves it, ' + - 'so per-slider attribution is unavailable (usually Math.random() called from the per-frame sync). ' + - `First to drift: ${outcome.driftLabels.join(', ')}` - ); - } - - if (outcome.changed) report.pass(sample); - else { - report.fail( - sample, - `nothing observable changed at ${sample} when all ${mutable.length} numeric slider(s) were scaled by ${MUTATION} on a zero-length frame — ` + - 'invariant I1: every metre, radian and second must be re-resolved from settings every frame, including a paused one' - ); - } - } -} - -/** - * Run one sample point. Returns - * `{ changed, restless, driftLabels[], responsive[], dead[], skipped? }`. - */ -function probeSample(ctx, ability, block, mutable, sample, distance) { - ability.destroy(); - frame.uTime.value = 0; - ability.spawn(_origin, _direction, distance); - - /* --- tick to the sample point --- */ - let frames = 0; - if (sample === 'travel') { - // Half-way down the line, still travelling. An ability that buys a wind-up - // by refusing to advance (Nova Beam) sits at u = 0 for a while first, so - // this waits on `u`, not on a frame count. - while (frames < MAX_FRAMES && !(ability.phase === AbilityPhase.TRAVEL && ability.u >= 0.5)) { - step(ctx, ability, DT); - frames++; - if (ability.phase !== AbilityPhase.TRAVEL) break; - } - if (ability.phase !== AbilityPhase.TRAVEL) { - return { skipped: `the cast left TRAVEL before reaching the half-way point (phase ${ability.phase} after ${frames} frames)` }; - } - } else { - while (frames < MAX_FRAMES && ability.phase === AbilityPhase.TRAVEL) { - step(ctx, ability, DT); - frames++; - } - const half = Math.max(1, Math.round((ability.impactDuration * 0.5) / DT)); - for (let i = 0; i < half && ability.phase === AbilityPhase.IMPACT; i++) { - step(ctx, ability, DT); - frames++; - } - if (ability.phase !== AbilityPhase.IMPACT && ability.phase !== AbilityPhase.FADE) { - return { skipped: `the cast was already ${ability.phase} at the mid-impact sample (after ${frames} frames)` }; - } - } - - const snapshotter = new Snapshotter(ability, ctx); - - /* --- let the clock-stopped state settle --- */ - // The first zero-length frame after a real one is not idle: an impact punch - // is still decaying out of `lightBoost`, an eased value is still a step from - // its target. That is settling, not a slider responding, and mistaking one - // for the other would report every ability in the project as "restless". - // Three frames is enough for everything in the repo; the check below proves - // it rather than assuming it. - for (let i = 0; i < 3; i++) ability.update(0); - - /* --- is it stable at rest? --- */ - const before = snapshotter.capture(true); - ability.update(0); - const baseline = snapshotter.capture(); - const drift = diffIndices(before, baseline, 4); - const restless = drift.length > 0; - const driftLabels = drift.map((i) => (typeof i === 'number' ? snapshotter.labels[i] : i)); - - /* --- the gate: scale every slider at once --- */ - const originals = mutable.map((key) => block[key]); - for (const key of mutable) block[key] *= MUTATION; - ability.update(0); - const mutated = snapshotter.capture(); - const changed = didChange(baseline, mutated); - mutable.forEach((key, i) => { - block[key] = originals[i]; - }); - ability.update(0); - - /* --- attribution, one slider at a time --- */ - const responsive = []; - const dead = []; - if (!restless) { - for (let i = 0; i < mutable.length; i++) { - const key = mutable[i]; - const rest = snapshotter.capture(); - block[key] *= MUTATION; - ability.update(0); - const probe = snapshotter.capture(); - block[key] = originals[i]; - ability.update(0); - if (didChange(rest, probe)) responsive.push(key); - else dead.push(key); - } - } - - ability.destroy(); - return { changed, restless, driftLabels, responsive, dead }; -} - -/* ------------------------------------------------------------------ */ -/* §10 · The vfx tech library smoke test */ -/* ------------------------------------------------------------------ */ -/** - * Construct every exported class in `src/vfx/`, drive it, and take it apart - * again. - * - * The ability stages above only see a module in `src/vfx/` if an ability - * imports it, and on the day this was written not one of them did: fourteen - * modules, some of them seventeen hundred lines, and the entire harness walked - * straight past them. The twenty-two agents about to build abilities on top of - * this library would each have discovered its breakages personally, in a - * browser, as a blank screen. - * - * So this stage does the least that is still worth doing. It is not a test of - * whether a `FUNNEL` looks like a vortex — nothing without a GL context can - * tell you that, and every module's author ran their own harness for it. It - * asks four questions that a blank screen answers too late: - * - * 1. Does it **import**? A reserved word in a shader (`packed`, `flat`) or a - * backtick inside a GLSL comment is a syntax error at module scope, and - * three of those have already been found in this library by hand. - * 2. Does it **construct** with the options its own doc comment advertises? - * 3. Does driving it for a few frames — including a zero-length one — leave a - * NaN in a uniform, an instance matrix or a transform? A NaN uniform does - * not render wrongly, it renders *nothing*, silently. - * 4. Does `dispose()` run without throwing? - * - * The coverage rule at the end is the part that keeps this honest: every - * exported class in every file under `src/vfx/` must appear in `VFX_CASES`. A - * fifteenth module lands with no recipe and this stage fails, naming it. That - * is deliberate — an untested module in a shared library is worse than no - * module, because someone will build on it. - */ - -const VFX_DIR = path.join(SRC, 'vfx'); - -/** Cases are built lazily so one module failing to import cannot stop the rest. */ -const vfx = {}; - -/* ------------------------------------------------------------------ */ -/* §5b · The GLSL reserved-word sweep */ -/* ------------------------------------------------------------------ */ - -/** - * Words that cannot be an identifier in the language this project actually - * compiles to, and the reason the list is exactly this. - * - * **Every material here is GLSL ES 3.00.** three's `WebGLProgram` prepends - * `#version 300 es` to anything that is not a `RawShaderMaterial` — built-in - * materials *and* every plain `ShaderMaterial` — and adds `#define varying in` - * and friends so that source written in the 1.00 dialect still compiles. That - * one line of three's source decides this whole list, and it means the README's - * old advice was half wrong in both directions: `packed` is illegal in ESSL - * 1.00 and perfectly legal in 3.00 (`VolumetricFireMaterial` has used it as a - * local for as long as the project has existed), while `patch` is legal in 1.00 - * and reserved in 3.00 — which is how a `float patch` sat in the floor's ageing - * patch and took the entire ground material out of the frame with a syntax - * error that never mentions the word "reserved". - * - * The list was not copied from a specification. Every candidate was compiled at - * `#version 300 es` on a real driver in three positions — as a local, as a - * parameter and as a function name — and only the words that failed **all - * three** are here. That is deliberately the narrow, false-positive-free class: - * shadowing a *built-in function* with a local (`float round = …`) is legal and - * common and is not flagged, though redefining one as a function is not. - * - * `varying` and `attribute` are on the list even though three `#define`s them - * away, because by the time one is a variable *name* the define has already - * turned the declaration into nonsense. - */ -const GLSL3_RESERVED = new Set( - ( - 'active asm atomic_uint attribute cast centroid class coherent common double dvec2 dvec3 ' + - 'dvec4 dmat2 dmat3 dmat4 enum extern external filter fixed flat fvec2 fvec3 fvec4 goto half ' + - 'hvec2 hvec3 hvec4 inline input interface invariant layout long namespace noinline ' + - 'noperspective output partition patch public readonly resource restrict sample short sizeof ' + - 'smooth static subroutine superp template this typedef union unsigned using varying volatile ' + - 'writeonly' - ).split(' ') -); - -/** Every type name a declaration can open with. */ -const GLSL_TYPE = - '(?:void|bool|int|uint|float|vec2|vec3|vec4|ivec2|ivec3|ivec4|bvec2|bvec3|bvec4|uvec2|uvec3|' + - 'uvec4|mat2|mat3|mat4|mat2x2|mat2x3|mat2x4|mat3x2|mat3x3|mat3x4|mat4x2|mat4x3|mat4x4|' + - 'sampler2D|samplerCube|sampler3D|sampler2DArray)'; - -/** - * Pull every `/* glsl *​/` template literal out of one JS source file. - * - * Tagging is the convention the whole project already follows for editor - * highlighting, so it costs nothing to rely on and it is exact: a shader that - * is not tagged is not scanned, and a JS string that merely mentions `float` is - * not mistaken for one. `${…}` interpolations are skipped by brace depth so - * that a nested template literal cannot end the block early. - */ -function glslBlocks(source) { - const blocks = []; - const marker = /\/\*\s*glsl\s*\*\/\s*`/g; - let hit; - while ((hit = marker.exec(source))) { - const start = marker.lastIndex; - let i = start; - let depth = 0; - for (; i < source.length; i++) { - const c = source[i]; - if (c === '\\') { - i++; - } else if (c === '$' && source[i + 1] === '{') { - depth++; - i++; - } else if (c === '}' && depth > 0) { - depth--; - } else if (c === '`' && depth === 0) { - break; - } - } - blocks.push({ start, text: source.slice(start, i) }); - marker.lastIndex = i + 1; - } - return blocks; -} - -/** - * Sweep every tagged GLSL block under `src/` for a reserved identifier. - * - * A source-text sweep rather than a walk over constructed materials, because - * the two failures this exists to catch both live in source that no constructed - * material in the harness carries: a `patchOnBeforeCompile` body, which needs a - * renderer before it is ever assembled, and a shader behind a `#define` that - * this run's options did not select. - * - * Comments are stripped first, so the word may appear in prose as often as it - * is useful to — and it is, because explaining *why* a variable is not called - * `patch` is the only thing that stops the next author calling it that. - */ -function sweepGlslReservedWords(failures) { - let blocks = 0; - const decl = new RegExp(`\\b${GLSL_TYPE}\\s+([A-Za-z_][A-Za-z0-9_]*)`, 'g'); - for (const file of sourceFiles(SRC)) { - const source = readFileSync(file, 'utf8'); - for (const block of glslBlocks(source)) { - blocks++; - const body = block.text.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' '); - const before = source.slice(0, block.start).split('\n').length - 1; - decl.lastIndex = 0; - let found; - while ((found = decl.exec(body))) { - if (!GLSL3_RESERVED.has(found[1])) continue; - const line = before + body.slice(0, found.index).split('\n').length; - failures.push( - `${path.relative(ROOT, file)}:${line} declares "${found[1]}", which is reserved in ` + - `GLSL ES 3.00 — three compiles every non-raw material at #version 300 es, so this ` + - `material does not compile at all and its whole mesh is missing from the frame` - ); - } - sweepUnguardedPow(file, block, body, before, failures); - } - } - return blocks; -} - -/** - * `pow()` whose base is a raw `sin()` or `cos()`. - * - * This one cost a day and blacked out three abilities, so it gets a rule. - * - * `pow(x, y)` is `exp2(y * log2(x))` on every driver, so a **negative base is - * NaN at every exponent** — including 1.0, which is why turning the exponent's - * slider to zero never masks it. And a trig base that is non-negative on paper - * is not non-negative in float32: `sin(PI * t)` at `t = 0` or `t = 1` comes back - * at about ±1e-7, and half the time the sign is wrong. - * - * One NaN fragment is not one bad pixel. It lands in the HDR buffer, the very - * next bloom blur convolves it across its whole kernel, and tone-mapping NaN - * gives black — so a single fragment on a ground quad turns the entire frame - * black. That is precisely what `GroundField`'s SCOUR mode did to Pyroclasm, - * Cyclone and Hourglass. - * - * The rule is narrow on purpose — only a *literal* `sin(`/`cos(` immediately - * inside `pow(` — so it has no false positives and needs no dataflow analysis. - * The fix is always the same: `pow(max(sin(...), 0.0), e)`. - */ -function sweepUnguardedPow(file, block, body, before, failures) { - const unguarded = /\bpow\s*\(\s*(sin|cos)\s*\(/g; - let hit; - while ((hit = unguarded.exec(body))) { - const line = before + body.slice(0, hit.index).split('\n').length; - failures.push( - `${path.relative(ROOT, file)}:${line} calls pow() on a raw ${hit[1]}() — float32 ` + - `${hit[1]}() returns ~-1e-7 at its zeroes, pow() of a negative base is NaN at every ` + - `exponent, and one NaN fragment is smeared over the whole frame by the bloom blur. ` + - `Wrap the base: pow(max(${hit[1]}(...), 0.0), e)` - ); - } -} - -/** - * Load every module under `src/vfx/` once, keeping the failures rather than - * throwing on the first one — a broken module should cost its own line in the - * report, not the whole stage. - */ -async function loadVfxModules() { - const modules = new Map(); - const names = readdirSync(VFX_DIR) - .filter((name) => name.endsWith('.js')) - .sort(); - for (const name of names) { - try { - modules.set(name, await import(path.join(VFX_DIR, name))); - } catch (error) { - modules.set(name, { __error: error }); - } - } - return modules; -} - -const VFX_MODULES = await loadVfxModules(); - -/** Shorthand: a module's export, or `undefined` if the module did not load. */ -const vx = (file, name) => VFX_MODULES.get(file)?.[name]; - -/* --- the shared props every case borrows ---------------------------- */ - -const _from = new Vector3(0, 0.2, 0); -const _to = new Vector3(0, 0.2, 12); -const _side = new Vector3(1, 0, 0); -const _up = new Vector3(0, 1, 0); -const _dirZ = new Vector3(0, 0, 1); - -/** A body geometry factory. Modules that own their geometry demand a factory. */ -const shardFactory = () => new TetrahedronGeometry(0.22, 0); -const spikeFactory = () => new ConeGeometry(0.5, 1, 5, 1, true); - -/** - * A case is `{ file, label, build }`, where `build` returns the handle this - * stage drives. `node` is whatever Object3D the module hung its meshes on — - * `scanGroup` walks it, so a group is as good as a mesh. - */ -function vfxCases() { - const cases = []; - const add = (file, label, build) => cases.push({ file, label, build }); - - /* --- GrowthField ------------------------------------------------- */ - const { GrowthField, GrowthLayout, GrowthEmerge, growthParams, patchGrowthMaterial } = - VFX_MODULES.get('GrowthField.js') ?? {}; - for (const [layoutName, layout] of [ - ['LINE', GrowthLayout?.LINE], - ['ZONE', GrowthLayout?.ZONE] - ]) { - add('GrowthField.js', `GrowthField ${layoutName}`, () => { - const parent = new Group(); - const material = patchGrowthMaterial(new MeshStandardMaterial(), {}); - const field = new GrowthField(parent, { - geometry: spikeFactory, - material, - variants: 2, - capacity: 48 - }); - const p = growthParams(); - p.layout = layout; - p.emerge = GrowthEmerge?.PUSH ?? 0; - field.plant(32, 0.25); - field.triggerAll(0, 0.4); - return { - node: parent, - drawCalls: () => field.drawCalls, - tick: (now) => field.update(now, p), - dispose: () => { - field.dispose(); - material.dispose(); - } - }; - }); - } - - /* --- ShatterField ------------------------------------------------- */ - const { ShatterField, shatterParams } = VFX_MODULES.get('ShatterField.js') ?? {}; - add('ShatterField.js', 'ShatterField', () => { - const parent = new Group(); - const field = new ShatterField(parent, { geometry: shardFactory, variants: 2, capacity: 64 }); - const p = shatterParams(); - field.sync(p); - field.burst(0, 40, 1, 0.6); - return { - node: parent, - drawCalls: () => field.drawCalls, - tick: (now) => field.update(now, p), - dispose: () => field.dispose() - }; - }); - - /* --- FilamentPaths: every path mode through one strip -------------- */ - const { FilamentPaths, PathMode, filamentLook } = VFX_MODULES.get('FilamentPaths.js') ?? {}; - add('FilamentPaths.js', 'FilamentPaths (9 path modes)', () => { - const parent = new Group(); - const paths = new FilamentPaths(parent, { samples: 32, capacity: 16 }); - const look = filamentLook(); - paths.setNodeCount(6); - for (let i = 0; i < 6; i++) paths.setNode(i, i / 5, (i % 2 ? 0.2 : -0.2), 0.1); - // One role per frame cycles through all nine modes over the tick loop, so - // every parametric path gets its uniforms written at least once. - const modes = Object.values(PathMode ?? {}); - return { - node: parent, - drawCalls: () => paths.drawCalls, - tick: (now, index) => { - const role = paths.role(index % 4); - role.count = 5; - role.style(0.3, 0.1, 0.5, 0.2); - role.ends(0.05, 0.95, 0.1, 0.9); - role.draw(2, 0.1, 0, 1.5); - switch (modes[index % modes.length]) { - case PathMode.HELIX: role.helix(_from, _to, 0.6, 0.3, 3, 1, 0.2, 0.4, 1.2); break; - case PathMode.ORBIT: role.orbit(_to, _up, 1.4, 1, 1.2, 0.3, 0.2, 0.4, 0.2); break; - case PathMode.MEANDER: role.meander(_to, _up, 0.4, 2.2, 1.1, 0.5, 0.3, 0.4, 0.6); break; - case PathMode.RIM: role.rim(_to, _up, 2.4, 1.6, 0.8, 0.3, 0.2, 0.3, 0.1); break; - case PathMode.CHAIN: role.chain(_from, _to, 0.4, 0.3, 0.2, 0.3, 3, 0.4, 0.2, 0.6); break; - case PathMode.LINK: role.link(_from, _to, 0.3, 0.6, 0.2, 1.1, 0.4, 0.3); break; - case PathMode.SPIRAL_IN: role.spiralIn(_from, _to, 1.2, 0.1, 2.5, 1, 1.3, 0.3, 0.2); break; - case PathMode.CRACK: role.crack(_from, _to, 0.5, 0.7, 0.6, 0.4, 0.1, 0.2, 0.5); break; - default: role.line(_from, _to, 0.2, 0.1, 0.4, 1.2, 0.3, 1.1, 0.2); - } - paths.sync(look, 1, now); - }, - dispose: () => paths.dispose() - }; - }); - - /* --- ArcNetwork ---------------------------------------------------- */ - const { ArcNetwork, arcNetworkParams } = VFX_MODULES.get('ArcNetwork.js') ?? {}; - add('ArcNetwork.js', 'ArcNetwork', () => { - const parent = new Group(); - const net = new ArcNetwork(parent, { samples: 48, capacity: 12 }); - const p = arcNetworkParams(); - net.from.copy(_from); - net.to.copy(_to); - net.reset(3); - return { - node: parent, - drawCalls: () => net.drawCalls, - tick: (now, index, dt) => net.update(dt, p, 1), - dispose: () => net.dispose() - }; - }); - - /* --- GroundField: all ten modes ------------------------------------ */ - const { GroundField, GroundMode, GROUND_MODE_NAMES, groundFieldParams } = - VFX_MODULES.get('GroundField.js') ?? {}; - for (const [name, mode] of Object.entries(GroundMode ?? {})) { - add('GroundField.js', `GroundField ${name}`, () => { - const parent = new Group(); - const field = new GroundField(parent, { mode, marks: 8 }); - const p = groundFieldParams(); - field.mark(0.3, -0.2, 0, 1); - field.mark(-0.5, 0.4, 0.2, 0.6); - return { - node: parent, - drawCalls: () => field.drawCalls, - tick: () => field.update(p), - dispose: () => field.dispose() - }; - }); - } - - /* --- VolumeHull: every hull, every medium -------------------------- */ - const { VolumeHull, HullShape, Medium, HULL_NAMES, MEDIUM_NAMES, volumeHullDefaults } = - VFX_MODULES.get('VolumeHull.js') ?? {}; - const hullCombos = []; - for (const hull of Object.values(HullShape ?? {})) hullCombos.push([hull, Medium?.FLAME ?? 0]); - for (const medium of Object.values(Medium ?? {})) hullCombos.push([HullShape?.BOX ?? 0, medium]); - for (const [hull, medium] of hullCombos) { - const label = `VolumeHull ${HULL_NAMES?.[hull]}/${MEDIUM_NAMES?.[medium]}`; - add('VolumeHull.js', label, () => { - const parent = new Group(); - const prefix = 'vol'; - const hullObject = new VolumeHull({ hull, medium, prefix, maxSteps: 12 }); - parent.add(hullObject.mesh); - const c = volumeHullDefaults(prefix, medium); - hullObject.place(_to, _dirZ).setSize(2, 3, 2).setFade(1); - return { - node: parent, - drawCalls: () => 1, - tick: () => hullObject.sync(c, settings.global), - dispose: () => hullObject.dispose() - }; - }); - } - - /* --- Tube: every path ---------------------------------------------- */ - const { Tube, TubePath, TUBE_PATH_NAMES, tubeDefaults } = VFX_MODULES.get('Tube.js') ?? {}; - for (const path of Object.values(TubePath ?? {})) { - add('Tube.js', `Tube ${TUBE_PATH_NAMES?.[path]}`, () => { - const parent = new Group(); - const tube = new Tube({ path, prefix: 'tube', nodes: 32, sides: 10 }); - parent.add(tube.group); - const c = tubeDefaults('tube', path); - const state = { - origin: _from, target: _to, side: _side, - progress: 0.6, fade: 1, widthFade: 1, seed: 4, time: 0, grow: 1, snapAge: 0 - }; - return { - node: parent, - drawCalls: () => tube.drawCalls, - tick: (now) => { - state.time = now; - tube.sync(c, state, settings.global); - // The crack is recomputed by sync(); poll it as an ability would. - void tube.crack.fired; - void tube.radiusAt(0.5); - }, - dispose: () => tube.dispose() - }; - }); - } - - /* --- Shell: every mode --------------------------------------------- */ - const { Shell, ShellMode, SHELL_MODE_NAMES, shellDefaults } = VFX_MODULES.get('Shell.js') ?? {}; - for (const mode of Object.values(ShellMode ?? {})) { - add('Shell.js', `Shell ${SHELL_MODE_NAMES?.[mode]}`, () => { - const parent = new Group(); - const shell = new Shell({ mode, prefix: 'shell', nodes: 16, sides: 16, rings: 6, segments: 32 }); - parent.add(shell.group); - const c = shellDefaults('shell', mode); - const state = { origin: _to, axis: _up, side: _side, span: 6, t: 0.4, fade: 1, seed: 2 }; - return { - node: parent, - drawCalls: () => shell.drawCalls, - tick: (now, index) => { - state.t = Math.min(1, index / 8); - shell.sync(c, state, settings.global); - void shell.standingAt(0.5); - }, - dispose: () => shell.dispose() - }; - }); - } - - /* --- BurstSystem, re-exported by Shell.js -------------------------- */ - const { BurstSystem, BurstMode } = VFX_MODULES.get('Shell.js') ?? {}; - add('Shell.js', 'BurstSystem (re-export)', () => { - const scene = new Group(); - const bursts = new BurstSystem(scene); - for (const mode of Object.values(BurstMode ?? {})) bursts.spawn(mode, _to, { life: 0.4 }); - return { - node: scene, - drawCalls: () => 1, - tick: (now, index, dt) => bursts.update(dt), - dispose: () => bursts.dispose?.() - }; - }); - - /* --- Projectile: every flight mode --------------------------------- */ - const { Projectile, FlightMode, projectileParams } = VFX_MODULES.get('Projectile.js') ?? {}; - const flights = Object.values(FlightMode ?? {}); - add('Projectile.js', `Projectile (${flights.length} flight modes)`, () => { - const parent = new Group(); - const material = new MeshBasicMaterial(); - const body = new Projectile(parent, { - geometry: () => new IcosahedronGeometry(0.3, 0), - material, - capacity: 12, - trail: true, - trailNodes: 12 - }); - const p = projectileParams(); - body.setBasis(_from, _dirZ, _side, 12); - body.roll(7); - return { - node: parent, - drawCalls: () => body.drawCalls, - tick: (now, index) => { - p.flight = flights[index % flights.length]; - body.update(now, p); - // Arrivals must be safe to read straight after update(), every frame. - void body.arrivalCount; - }, - dispose: () => { - body.dispose(); - material.dispose(); - } - }; - }); - - /* --- Swarm: every silhouette --------------------------------------- */ - const { Swarm, Silhouette, LeadPath, swarmParams } = VFX_MODULES.get('Swarm.js') ?? {}; - for (const [name, silhouette] of Object.entries(Silhouette ?? {})) { - add('Swarm.js', `Swarm ${name}`, () => { - const parent = new Group(); - const swarm = new Swarm(parent, { capacity: 64, silhouette }); - const p = swarmParams(); - p.lead = LeadPath?.ORBIT ?? 0; - swarm.setBasis(_from, _dirZ, _side, 12); - swarm.roll(5); - return { - node: parent, - drawCalls: () => swarm.drawCalls, - tick: (now) => swarm.update(now, p), - dispose: () => swarm.dispose() - }; - }); - } - - /* --- Distortion: every emitter ------------------------------------- */ - const { DistortionField, DistortionMode, DistortionFacing } = VFX_MODULES.get('Distortion.js') ?? {}; - for (const [name, mode] of Object.entries(DistortionMode ?? {})) { - add('Distortion.js', `DistortionField ${name}`, () => { - const parent = new Group(); - const field = new DistortionField({ mode, facing: DistortionFacing?.BILLBOARD, edge: true }); - parent.add(field.object3D); - field.visible = true; - field.setAnchorXYZ(0, 1, 8); - field.setBasis(_dirZ, _up); - // An empty params object is the interesting call: it proves every read - // falls back rather than writing `undefined` into a uniform. - const p = { radius: 2.5, strength: 0.4, seed: 3 }; - return { - node: parent, - drawCalls: () => 1, - tick: (now, index) => field.update(index % 2 ? p : {}), - dispose: () => { - field.visible = false; // release the writer counter - field.dispose(); - } - }; - }); - } - - /* --- Portal --------------------------------------------------------- */ - const { Portal } = VFX_MODULES.get('Portal.js') ?? {}; - add('Portal.js', 'Portal', () => { - const parent = new Group(); - const portal = new Portal({}); - parent.add(portal.object3D); - portal.setPlacement(_to, _dirZ, _up); - const p = { open: 0.8, radiusX: 2, radiusY: 3, seed: 9 }; - return { - node: parent, - drawCalls: () => 1, - tick: (now, index) => portal.update(index % 2 ? p : {}), - dispose: () => portal.dispose() - }; - }); - - /* --- LiquidSurface: both modes -------------------------------------- */ - const { LiquidSurface, LiquidMode, liquidParams } = VFX_MODULES.get('LiquidSurface.js') ?? {}; - for (const [name, mode] of Object.entries(LiquidMode ?? {})) { - add('LiquidSurface.js', `LiquidSurface ${name}`, () => { - const parent = new Group(); - const surface = new LiquidSurface({ mode, segments: 24 }); - parent.add(surface.object3D); - surface.setPlacement(_to, _dirZ, _up); - const p = liquidParams(); - return { - node: parent, - drawCalls: () => surface.drawCalls, - tick: (now, index) => { - surface.update(now, p); - // rippleAtWorld() has to be called after update() — it needs this - // frame's half-extents to turn a metre into the fraction it stores. - if (index === 2) surface.rippleAtWorld(_to, 1, now); - void surface.lipHeight(p, 0); - }, - dispose: () => surface.dispose() - }; - }); - } - - /* --- Curtain: every mode, every layout ------------------------------ */ - const { Curtain, CurtainMode, CurtainLayout, curtainParams } = VFX_MODULES.get('Curtain.js') ?? {}; - const curtainCombos = []; - for (const mode of Object.values(CurtainMode ?? {})) curtainCombos.push([mode, CurtainLayout?.LINE ?? 0]); - for (const layout of Object.values(CurtainLayout ?? {})) curtainCombos.push([CurtainMode?.RAIN ?? 0, layout]); - for (const [mode, layout] of curtainCombos) { - add('Curtain.js', `Curtain mode:${mode} layout:${layout}`, () => { - const parent = new Group(); - const curtain = new Curtain({ mode, layout, capacity: 6, segmentsX: 8, segmentsY: 6, floor: true }); - parent.add(curtain.object3D); - curtain.setPlacement(_to, _side, _up); - curtain.roll(11); - const p = curtainParams(); - return { - node: parent, - drawCalls: () => curtain.drawCalls, - tick: (now) => curtain.update(now, p), - dispose: () => curtain.dispose() - }; - }); - } - - /* --- LensFlare ------------------------------------------------------ */ - const { LensFlare, lensFlareParams, MAX_FLARE_GHOSTS } = VFX_MODULES.get('LensFlare.js') ?? {}; - add('LensFlare.js', 'LensFlare', () => { - const parent = new Group(); - const flare = new LensFlare({ ghosts: MAX_FLARE_GHOSTS }); - parent.add(flare.object3D); - flare.visible = true; - const p = lensFlareParams(); - return { - node: parent, - drawCalls: () => flare.drawCalls, - tick: (now, index) => { - flare.setAnchor(_to); - // Sweep the ghost count so the instanceCount arithmetic is exercised - // at both ends, including 0 — an off-by-one there draws a ghost with - // a hashed spacing and no tint, which is a stripe across the frame. - p.ghosts = index % (MAX_FLARE_GHOSTS + 1); - p.occTaps = 1 + (index % 9); - p.intensity = 0.5 + 0.5 * Math.sin(now); - flare.update(p); - if (flare.object3D.geometry.instanceCount > 3 + MAX_FLARE_GHOSTS) { - throw new Error(`instanceCount ${flare.object3D.geometry.instanceCount} exceeds the built instances`); - } - }, - dispose: () => flare.dispose() - }; - }); - - /* --- Mirror: the surface, and the scheduler that feeds it ----------- */ - /** - * The scheduler is the part of `Mirror` that a blank screen tells you about - * far too late, and it is pure CPU: a frame token, a budget, a starvation - * score and the oblique projection maths. All of that runs against a stub - * renderer here — no GL, no reflection, but every line of `_renderReflection` - * except the draw itself, which is where a NaN in the clip plane would come - * from. - */ - const { Mirror, mirrorParams, mirrorBudget, setMirrorBudget } = VFX_MODULES.get('Mirror.js') ?? {}; - add('Mirror.js', 'Mirror + scheduler', () => { - const parent = new Group(); - const scene = new Scene(); - scene.add(parent); - - const camera = new PerspectiveCamera(50, 16 / 9, 0.1, 400); - camera.position.set(0, 2.4, -7); - camera.lookAt(_to); - camera.updateMatrixWorld(true); - camera.matrixWorldInverse.copy(camera.matrixWorld).invert(); - - /** The least renderer `_renderReflection()` will accept. */ - const gl = { - info: { render: { frame: 0, calls: 7, triangles: 4200 } }, - xr: { enabled: false }, - shadowMap: { autoUpdate: true }, - autoClear: false, - state: { buffers: { depth: { setMask() {} } }, viewport() {} }, - getRenderTarget: () => null, - setRenderTarget() {}, - clear() {}, - render() { - this.info.render.frame++; - } - }; - - const previousBudget = mirrorBudget.max; - setMirrorBudget(2); - - const mirrors = []; - for (let i = 0; i < 4; i++) { - const mirror = new Mirror({ resolution: 128, name: `mirror${i}` }); - parent.add(mirror.object3D); - mirror.visible = true; - mirrors.push(mirror); - } - - const p = mirrorParams(); - const seen = new Set(); - const where = new Vector3(); // built once per case, not per frame - - return { - node: parent, - drawCalls: () => mirrors[0].drawCalls, - tick: (now, index) => { - gl.info.render.frame++; // the outer frame's own render call - for (let i = 0; i < mirrors.length; i++) { - const mirror = mirrors[i]; - where.set(i * 2.5 - 3.75, 1.2, 12); - mirror.setPlacement(where, _dirZ, _up); - p.width = 2 + i * 0.5; - p.height = 2; - p.roughness = (index % 4) / 3; - p.resolution = index === 3 ? 256 : 128; // exercise the live re-size - p.priority = 1 + i * 0.1; - mirror.update(p); - } - // Scene order, as the renderer would walk it. - for (const mirror of mirrors) mirror.object3D.onBeforeRender(gl, scene, camera); - - if (mirrorBudget.rendered > 2) { - throw new Error(`${mirrorBudget.rendered} reflections in one frame with a budget of 2`); - } - for (const mirror of mirrors) { - const m = mirror.material.uniforms.uReflectMatrix.value.elements; - for (let e = 0; e < 16; e++) { - if (!Number.isFinite(m[e])) throw new Error(`${mirror.mesh.name}: uReflectMatrix[${e}] is ${m[e]}`); - } - if (mirror.material.uniforms.uHasReflection.value > 0.5) seen.add(mirror.mesh.name); - } - // Four mirrors, two slots, starvation weighting: everyone must have had - // a turn well inside six frames, or the scheduler is starving someone. - if (index === 5 && seen.size < mirrors.length) { - throw new Error(`only ${seen.size} of ${mirrors.length} mirrors rendered in six frames`); - } - }, - dispose: () => { - for (const mirror of mirrors) mirror.dispose(); - setMirrorBudget(previousBudget); - } - }; - }); - - /* --- SceneHooks: the borrow/restore discipline ---------------------- */ - /* - * This one is not a renderer, so "does it produce a NaN" is the least - * interesting question about it. What can actually go wrong is the ledger: - * a hook still held by an ability that was destroyed, a double release taking - * away somebody else's borrow, a restore that does not restore. All three are - * testable without a GL context, so this case asserts them and throws — and a - * throw inside `tick` is reported by `runVfxCase` like any other failure. - * - * The mock world is the smallest thing the module will accept: a Group - * standing in for the DirectionalLight (it wants `.position`, `.color` and - * `.intensity`, and a Group plus a borrowed Color covers it), a bare - * MeshStandardMaterial for the floor, and a plain uniform block for the - * grade. `mockEnvironment()` reproduces what `Environment.update()` does to - * the sun every frame, because the module's central claim — that KEY_LIGHT - * and GRADE need no restore, since their owners re-author them from settings - * before the hook runs — is only true if something does that, and asserting - * the sun comes back is worthless without it. - */ - const { SceneHooks, Hook } = VFX_MODULES.get('SceneHooks.js') ?? {}; - add('SceneHooks.js', 'SceneHooks (six hooks, borrow/restore)', () => { - const parent = new Group(); - const hooks = new SceneHooks(); - - const sun = new Group(); - sun.color = new MeshBasicMaterial().color; // a Color without a new import - sun.intensity = 1; - const sunTarget = new Group(); - const environment = { sun, sunTarget, focus: new Vector3(1, 0, -2) }; - const groundMaterial = new MeshStandardMaterial(); - const grade = { - uSaturation: { value: 1 }, - uTemperature: { value: 0 }, - uLift: { value: 0 }, - uVignette: { value: 0 } - }; - parent.add(sun, sunTarget); - - hooks.install({ scene: parent, environment, ground: { material: groundMaterial }, grade }); - - // Exercise the floor patch. `onBeforeCompile` is never called without a - // renderer, so the harness calls it by hand against the real chunk tokens. - const probe = { - uniforms: {}, - vertexShader: '#include \nvoid main(){\n#include \n}', - fragmentShader: '#include \nvoid main(){\n#include \n}' - }; - groundMaterial.onBeforeCompile(probe, null); - if (!probe.fragmentShader.includes('sceneAgeField')) { - throw new Error('the floor ageing patch did not reach the fragment shader'); - } - if (!probe.vertexShader.includes('vSceneAgeWorld =')) { - throw new Error('the floor ageing patch did not reach the vertex shader'); - } - for (const key of ['uAgeField', 'uAgeMix', 'uAgeRustColor']) { - if (!probe.uniforms[key]) throw new Error(`the ageing patch did not add ${key}`); - } - - const owner = { id: 'harness' }; - const rivalOwner = { id: 'rival' }; - const baseDir = new Vector3(); - const baseSunPos = new Vector3(); - const held = {}; - - /** What `Environment.update()` does to the key light, every frame. */ - const mockEnvironment = () => { - const e = settings.environment; - const cosE = Math.cos(e.sunElevation); - baseDir - .set( - -Math.cos(e.sunAzimuth) * cosE, - -Math.sin(e.sunElevation), - -Math.sin(e.sunAzimuth) * cosE - ) - .normalize(); - baseSunPos.copy(environment.focus).addScaledVector(baseDir, -70); - sunTarget.position.copy(environment.focus); - sun.position.copy(baseSunPos); - sun.intensity = e.sunIntensity; - grade.uSaturation.value = settings.post.saturation; - grade.uVignette.value = settings.post.vignette; - }; - - const takeAll = () => { - for (const hook of Object.values(Hook)) held[hook] = hooks.acquire(hook, owner); - held[Hook.KEY_LIGHT].aim(1.1, 0.2).brightness(9).tint('#ffd0a0').blend(0.8); - held[Hook.GRADE].saturate(0.1).temper(-0.4).raise(-0.06).darken(0.9).blend(1); - held[Hook.AGE] - .at(2, 0, -3) - .field(7, 0.4, 1, 0.35) - .wear(0.7, 0.4, 0.3, 0.5, 0.2) - .scale(1.8) - .colours('#7a3b1c', '#8a8375', '#3d5a20'); - held[Hook.HOLE].at(0, 1.5, 6).size(2.4, 0.8); - held[Hook.GRAVITY].at(0, 1, 6).well(5, 0.3).scale(-1.4, 1); - held[Hook.DISRUPT].at(0, 1, 6).region(6, 0.3).power(0.9, 0.5, 0.4).shardSize(7); - }; - - const holeMesh = () => parent.children.find((child) => child.name === 'SceneHooks.Hole'); - - return { - node: parent, - drawCalls: () => 1, // the depth proxy, and only while the hole is held - tick: (now, index) => { - mockEnvironment(); - - if (index === 0) { - takeAll(); - } else if (index === 1) { - // Last acquirer wins, and the earlier holder stays live underneath. - const rival = hooks.acquire(Hook.KEY_LIGHT, rivalOwner); - rival.aim(2.4, 1.3).blend(1); - if (held[Hook.KEY_LIGHT].driving) throw new Error('the earlier holder is still driving'); - if (!rival.driving) throw new Error('the last acquirer is not driving'); - if (rival === held[Hook.KEY_LIGHT]) { - throw new Error('two owners were handed the same token'); - } - // Re-acquiring a hook you already hold renews it; it does not stack a - // second borrow on top of your own. - if (hooks.acquire(Hook.KEY_LIGHT, rivalOwner) !== rival) { - throw new Error('re-acquiring your own hook did not return your token'); - } - rival.release(); - rival.release(); // a double release must be a no-op - if (!held[Hook.KEY_LIGHT].driving) throw new Error('LIFO did not hand the hook back'); - - /* The recycled-token hazard, which the first design claimed to catch - * with a serial and did not: a released token must never be handed to - * a second owner, or the previous owner's late `onDestroy()` evicts - * whoever is holding the hook now. Assert both halves. */ - const relay = hooks.acquire(Hook.HOLE, rivalOwner).size(1); - const stale = held[Hook.HOLE]; - stale.release(); // the earlier holder, from underneath - stale.release(); // and again, late — must not touch `relay` - if (!relay.active || hooks.driver(Hook.HOLE) !== relay) { - throw new Error('a stale release evicted the current holder'); - } - relay.release(); - held[Hook.HOLE] = hooks.acquire(Hook.HOLE, owner).at(0, 1.5, 6).size(2.4, 0.8); - } else if (index === 3) { - // Everything has been written, so everything must have moved — but - // only after an apply(), since mockEnvironment() has just put the - // settings-authored values back on top, exactly as the app does. - hooks.apply(); - if (!holeMesh()?.visible) throw new Error('the hole proxy is not visible while held'); - if (Math.abs(grade.uSaturation.value - settings.post.saturation) < 1e-6) { - throw new Error('the grade hook did not reach the grade uniforms'); - } - if (sun.position.distanceTo(baseSunPos) < 1e-3) { - throw new Error('the key light hook did not move the sun'); - } - if (hooks.gravityAt(0, 1, 6) > 0) throw new Error('gravity was not inverted in the well'); - if (hooks.gravityAt(0, 1, 40) !== 1) throw new Error('gravity leaked outside the well'); - if (hooks.disruptAt(0, 1, 6) < 0.99) throw new Error('the disrupt region reads as empty'); - if (hooks.ageAt(6, -3) <= 0) throw new Error('the age field is empty inside its own annulus'); - } else if (index === 4) { - // Reclaim by owner — the one line an ability's onDestroy() wants — - // and then assert the world is *exactly* back. For the two - // settings-authored hooks that means mockEnvironment() alone put it - // back, which is the claim the whole restore story rests on. - hooks.reclaim(owner); - hooks.apply(); - if (hooks.heldCount !== 0) throw new Error('reclaim(owner) left a hook held'); - if (holeMesh()?.visible) throw new Error('the hole proxy survived the release'); - if (hooks.gravityAt(0, 1, 6) !== 1) throw new Error('gravity did not restore to 1'); - if (hooks.disruptAt(0, 1, 6) !== 0) throw new Error('disrupt did not restore to 0'); - if (hooks.ageAt(6, -3) !== 0) throw new Error('the age field did not restore to 0'); - mockEnvironment(); - hooks.apply(); - if (Math.abs(grade.uSaturation.value - settings.post.saturation) > 1e-9) { - throw new Error('the grade did not come back bit-for-bit'); - } - if (Math.abs(sun.intensity - settings.environment.sunIntensity) > 1e-9) { - throw new Error('the key light did not come back bit-for-bit'); - } - if (sun.position.distanceTo(baseSunPos) > 1e-9) { - throw new Error('the sun did not come back bit-for-bit'); - } - takeAll(); // and back on, so the zero-length frame has work to do - } - - hooks.apply(); - }, - dispose: () => { - hooks.reclaim(owner); - if (hooks.heldCount !== 0) throw new Error('a hook survived teardown'); - hooks.dispose(); - groundMaterial.dispose(); - } - }; - }); - - - /* --- Caustics: every shape, every source, and the binding hook --------- */ - const { Caustics, CausticSource, CausticShape, CAUSTIC_BOUND_KEYS, causticsParams } = - VFX_MODULES.get('Caustics.js') ?? {}; - for (const [shapeName, shape] of Object.entries(CausticShape ?? {})) { - add('Caustics.js', `Caustics SCROLL/${shapeName}`, () => { - const parent = new Group(); - const net = new Caustics(parent, { source: CausticSource.SCROLL, shape }); - const p = causticsParams(); - p.centre = _to; - p.lightAxis = new Vector3(0.2, -1, 0.1); - return { - node: parent, - drawCalls: () => net.drawCalls, - tick: (now, index) => { - p.front = (index % 8) / 8; - p.now = now; - if (index === 2) net.ripple(0.3, -0.2, 1, now); - net.update(p); - }, - dispose: () => net.dispose() - }; - }); - } - - add('Caustics.js', 'Caustics WAVE bound to a LiquidSurface', () => { - const parent = new Group(); - const water = new LiquidSurface({ mode: LiquidMode?.WAVE ?? 1, segments: 16 }); - parent.add(water.object3D); - water.setPlacement(_to, _dirZ, _up); - - const net = new Caustics(parent, { source: CausticSource.WAVE, shape: CausticShape.LANE }); - const taken = net.bindSource(water.uniforms); - if (taken < 10) throw new Error(`bindSource() only took ${taken} of ${CAUSTIC_BOUND_KEYS.length} boxes`); - for (const key of CAUSTIC_BOUND_KEYS) { - if (net.material.uniforms[key] !== water.uniforms[key]) { - throw new Error(`${key} is not shared by identity after bindSource()`); - } - } - - const wet = liquidParams(); - const p = causticsParams(); - p.centre = _to; - return { - node: parent, - drawCalls: () => net.drawCalls, - tick: (now, index) => { - wet.waveFront = (index % 6) / 6; - water.update(now, wet); - // Written by the surface; the net must not have taken a second author. - const before = water.uniforms.uCrestHeight.value; - p.crestHeight = before + 5; - net.update(p); - if (water.uniforms.uCrestHeight.value !== before) { - throw new Error('Caustics.update() overwrote a bound uniform'); - } - }, - dispose: () => { - net.dispose(); - water.dispose(); - } - }; - }); - - add('Caustics.js', 'Caustics CUSTOM source', () => { - const parent = new Group(); - const net = new Caustics(parent, { - source: CausticSource.CUSTOM, - shape: CausticShape.DISC, - custom: ` - uniform float uTestAmp; - float causticHeight(vec2 xz) { return uTestAmp * sin(xz.x) * cos(xz.y + uTime); } - float causticRidge(vec2 xz) { return 0.0; } - `, - uniforms: { uTestAmp: { value: 0.3 } } - }); - const p = causticsParams(); - p.centre = _to; - return { - node: parent, - drawCalls: () => net.drawCalls, - tick: () => net.update(p), - dispose: () => net.dispose() - }; - }); - - /* --- LightShaft: every layout, plus the CPU mirrors -------------------- */ - const { LightShaft, ShaftLayout, lightShaftParams } = VFX_MODULES.get('LightShaft.js') ?? {}; - for (const [layoutName, shaftLayout] of Object.entries(ShaftLayout ?? {})) { - add('LightShaft.js', `LightShaft ${layoutName}`, () => { - const parent = new Group(); - const shafts = new LightShaft(parent, { capacity: 5, layout: shaftLayout, maxSteps: 16 }); - shafts.setPlacement(_to, _dirZ, _up); - shafts.roll(23); - const p = lightShaftParams(); - p.layout = shaftLayout; - p.count = 4; - const probe = new Vector3(); - const out = new Vector3(); - return { - node: parent, - drawCalls: () => shafts.drawCalls, - tick: (now, index) => { - p.sweep = (index % 6) / 6; - shafts.update(p); - shafts.footPoint(1, p, out); - shafts.mouthPoint(1, p, probe); - // Halfway down its own axis, a shaft must light its own centre line. - probe.lerp(out, 0.5); - const lit = shafts.irradianceAt(probe, p); - if (!Number.isFinite(lit)) throw new Error(`irradianceAt() returned ${lit}`); - if (index === 3 && lit <= 0) { - throw new Error('irradianceAt() is dark on the shaft’s own centre line'); - } - }, - dispose: () => shafts.dispose() - }; - }); - } - - /* --- BrushStroke: every tip layout ---------------------------------- */ - const { BrushStroke, BrushTip, BRUSH_TIP_NAMES, brushStrokeParams } = - VFX_MODULES.get('BrushStroke.js') ?? {}; - for (const [name, tip] of Object.entries(BrushTip ?? {})) { - add('BrushStroke.js', `BrushStroke ${BRUSH_TIP_NAMES?.[tip] ?? name}`, () => { - const parent = new Group(); - const brush = new BrushStroke(parent, { strokes: 3, bristles: 6, samples: 12, sides: 6, tip }); - const p = brushStrokeParams(); - brush.setPaper(_up); - brush.roll(5); - const probe = new Vector3(); - return { - node: parent, - drawCalls: () => brush.drawCalls, - tick: (now, index) => { - // The head walks across the whole set, so every stroke gets a frame - // where it is unstarted, one where it is mid-draw and one where it is - // finished — the three cases the remap in the vertex shader has. - p.progress = (index % 8) / 7; - for (let s = 0; s < brush.strokeCount; s++) { - const stroke = brush.stroke(s); - stroke.line(_from, _to, s % 2 ? 0.6 : -0.4, 0.2); - stroke.pressure(0.12, 1, 0.8, 0.03); - stroke.ink(6 + s); - stroke.timing(s / 3, 1 / 3); - // Stroke 2 spends a frame parked, which is the path that must not - // shuffle the remaining strokes into other slots. - stroke.active = !(s === 2 && index === 4); - } - brush.update(now, p); - void brush.widthAt(0, 0.5); - brush.tipPoint(0, probe); - brush.tangentAt(0, 0.5, probe); - }, - dispose: () => brush.dispose() - }; - }); - } - - /* --- InkDiffusion: every mode ---------------------------------------- */ - const { InkDiffusion, InkMode, INK_MODE_NAMES, inkDiffusionParams } = - VFX_MODULES.get('InkDiffusion.js') ?? {}; - for (const [name, mode] of Object.entries(InkMode ?? {})) { - add('InkDiffusion.js', `InkDiffusion ${INK_MODE_NAMES?.[mode] ?? name}`, () => { - const parent = new Group(); - const ink = new InkDiffusion(parent, { mode, sources: 4, satellites: 8 }); - const p = inkDiffusionParams(); - p.sources = 3; - p.satellites = 8; - ink.setPlacement(_to, _dirZ); - ink.roll(7); - const probe = new Vector3(); - return { - node: parent, - drawCalls: () => ink.drawCalls, - tick: (now) => { - ink.update(now, p); - // The CPU mirrors are what splatterbrand aims its projectiles with, - // so a NaN in one of them is a projectile thrown at the origin. - if (!Number.isFinite(ink.frontRadius(1))) throw new Error('frontRadius() is not finite'); - ink.sourcePoint(2, probe); - ink.satellitePoint(3, probe); - if (!Number.isFinite(probe.x + probe.y + probe.z)) { - throw new Error('satellitePoint() is not finite'); - } - if (!Number.isFinite(ink.satelliteSize(3))) throw new Error('satelliteSize() is not finite'); - if (!Number.isFinite(ink.satelliteAge(3))) throw new Error('satelliteAge() is not finite'); - }, - dispose: () => ink.dispose() - }; - }); - } - - /* --- TimeControl: recorder, ghosts, the field, the reverse driver --- */ - const { - GhostRig, - RewindGate, - TimeRecorder, - TimeWarpClock, - ghostLook, - recorderParams, - reverseParams, - reverseRate, - reverseTime, - timeField, - timeRegionParams - } = VFX_MODULES.get('TimeControl.js') ?? {}; - - /** - * A minimal skinned rig, standing in for the character. - * - * The recorder and the ghost are the only things in this library that need a - * *skeleton* rather than a mesh, and the character is an FBX this harness has - * no way to load. Eight bones in a chain under one `SkinnedMesh` is enough to - * exercise everything that can go wrong: bone discovery, the parallel clone, - * the rebind, and the interpolation. - */ - const probeRig = (boneCount = 8) => { - const root = new Group(); - root.name = 'Character'; - const geometry = new BoxGeometry(0.3, 1.8, 0.3, 1, boneCount - 1, 1); - const verts = geometry.attributes.position.count; - geometry.setAttribute('skinIndex', new BufferAttribute(new Uint16Array(verts * 4), 4)); - geometry.setAttribute('skinWeight', new BufferAttribute(new Float32Array(verts * 4), 4)); - for (let i = 0; i < verts; i++) geometry.attributes.skinWeight.setX(i, 1); - - const bones = []; - let parent = null; - for (let i = 0; i < boneCount; i++) { - const bone = new Bone(); - bone.name = `probe${i}`; - bone.position.y = i === 0 ? 0 : 0.22; - if (parent) parent.add(bone); - parent = bone; - bones.push(bone); - } - const mesh = new SkinnedMesh(geometry, new MeshStandardMaterial()); - mesh.add(bones[0]); - root.add(mesh); - mesh.bind(new Skeleton(bones)); - root.updateMatrixWorld(true); - return { root, bones, geometry, mesh }; - }; - - add('TimeControl.js', 'TimeRecorder + GhostRig (skeletal replay)', () => { - const parent = new Group(); - const rig = probeRig(8); - parent.add(rig.root); - - const recorder = new TimeRecorder({ capacity: 24, bones: 16 }); - recorder.attach(rig.root); - const ghosts = [new GhostRig(parent, { source: rig.root }), new GhostRig(parent)]; - // The second ghost is sourced late on purpose: `setSource()` is the one - // allocating call in the class and abilities are told to make it from - // `createShaders()`, but the character is an async load and the fallback - // path — source it on the first cast — has to work too. - ghosts[1].setSource(rig.root); - - const rp = recorderParams(); - const look = ghostLook(); - const probePos = new Vector3(); - const probeQuat = new Quaternion(); - - return { - node: parent, - drawCalls: () => ghosts.reduce((total, ghost) => total + ghost.drawCalls, 0), - tick: (now, index) => { - for (let b = 0; b < rig.bones.length; b++) { - rig.bones[b].rotation.z = Math.sin(now * 3 + b) * 0.35; - } - rig.root.updateMatrixWorld(true); - rp.rate = 30 + index; - recorder.sample(now, rp); - recorder.trim(now, rp); - - for (let g = 0; g < ghosts.length; g++) { - const at = now - 0.08 * (g + 1); - const live = recorder.poseAt(at, ghosts[g]); - recorder.transformAt(at, probePos, probeQuat); - if (!Number.isFinite(probePos.x + probeQuat.w)) { - throw new Error('transformAt() wrote a non-finite root transform'); - } - look.fade = 0.7 / (g + 1); - look.seed = g * 3.7; - // Asking for an instant off the end of the track is normal — the - // oldest echo has no history for its first `delay` seconds — and it - // must place the ghost rather than NaN it. - ghosts[g].visible = live; - ghosts[g].place(probePos, g * 0.4).setScale(1 - g * 0.05).sync(look); - } - }, - dispose: () => { - for (const ghost of ghosts) ghost.dispose(); - recorder.detach(); - rig.geometry.dispose(); - rig.mesh.material.dispose(); - } - }; - }); - - add('TimeControl.js', 'TimeField (stasis, rewind, slow, and the pool ceiling)', () => { - const parent = new Group(); - const regions = []; - const params = []; - // Every rate the field is documented to support, plus the exhaustion path: - // the pool is MAX_TIME_REGIONS wide and `acquire()` past that returns null, - // on the `ctx.lights.acquire()` contract. A case that never sees the null - // is a case that never checks the guard is needed. - const rates = [0, -1, 0.25, 1, 0]; - for (let i = 0; i < rates.length; i++) { - const region = timeField.acquire(0); - regions.push(region); - const p = timeRegionParams(); - p.rate = rates[i]; - params.push(p); - region?.lock(0.25 * i); - } - if (regions[regions.length - 1] !== null) { - throw new Error('acquire() past the pool ceiling must return null'); - } - - return { - node: parent, - drawCalls: () => 0, - tick: (now, index) => { - for (let i = 0; i < regions.length; i++) { - const region = regions[i]; - if (!region) continue; // the guard every ability has to write - region.placeXYZ(i * 2, 1, 6); - params[i].radius = 3 + i + index * 0.1; - params[i].core = 0.2 * i; - params[i].strength = 1 - i * 0.15; - region.sync(params[i]); - if (!Number.isFinite(region.weightAt(_to))) throw new Error('weightAt() is not finite'); - } - if (!Number.isFinite(timeField.clockAt(now, _to))) { - throw new Error('clockAt() is not finite'); - } - if (!Number.isFinite(timeField.weightAt(_from))) { - throw new Error('weightAt() is not finite'); - } - }, - dispose: () => { - for (const region of regions) region?.release(); - // A leaked region is invisible and freezes a piece of the world for the - // rest of the session, so the case asserts the pool actually emptied. - timeField.reset(); - if (frame.uTimeRegionCount.value !== 0) { - throw new Error('the time field did not return to idle'); - } - } - }; - }); - - add('TimeControl.js', 'reverseTime / TimeWarpClock / RewindGate', () => { - const clock = new TimeWarpClock(); - const gate = new RewindGate(); - const p = reverseParams(); - return { - node: null, - drawCalls: () => 0, - tick: (now, index, dt) => { - p.turnAt = 0.1 + index * 0.02; - const bent = reverseTime(now, p); - const rate = reverseRate(now, p); - if (!Number.isFinite(bent) || !Number.isFinite(rate)) { - throw new Error('the closed form is not finite'); - } - clock.advance(dt, index % 3 === 2 ? -2 : 1, 0); - if (!Number.isFinite(clock.now) || clock.emitDt < 0) { - throw new Error('TimeWarpClock produced a bad step'); - } - gate.poll(bent, p.turnAt); - }, - dispose: () => { - clock.reset(); - gate.reset(); - } - }; - }); - - /* --- FoldMesh: every pattern, every layout --------------------------- */ - const { FoldMesh, FoldPattern, FoldLayout, foldMeshParams, fanCreases } = - VFX_MODULES.get('FoldMesh.js') ?? {}; - const foldCombos = []; - for (const [name, pattern] of Object.entries(FoldPattern ?? {})) { - foldCombos.push([`${name} LINE`, pattern, FoldLayout?.LINE ?? 0]); - } - for (const [name, layout] of Object.entries(FoldLayout ?? {})) { - foldCombos.push([`CRANE ${name}`, FoldPattern?.CRANE ?? 2, layout]); - } - for (const [label, pattern, layout] of foldCombos) { - add('FoldMesh.js', `FoldMesh ${label}`, () => { - const parent = new Group(); - const paper = new FoldMesh(parent, { - pattern, - layout, - capacity: 6, - segments: 6, - segmentsV: 8 - }); - paper.setBasis(_from, _dirZ, _side, 12); - paper.roll(5); - const p = foldMeshParams(); - p.count = 5; - const probe = new Vector3(); - return { - node: parent, - drawCalls: () => paper.drawCalls, - tick: (now, index) => { - // Sweep the fold across the frames: the whole module is one slider and - // the interesting arithmetic (the hinge radius, the spiral) divides by - // things that are zero at exactly one value of it. - p.progress = index / 6; - p.payout = index / 6; - p.curl = index === 3 ? 0 : 0.4; // the straight-run limit of sin(ka)/k - paper.update(now, p); - void paper.sheetPoint(index % 5, p, probe); - void paper.spoolPoint(index % 5, p, probe); - }, - dispose: () => paper.dispose() - }; - }); - } - add('FoldMesh.js', 'FoldMesh custom table', () => { - const parent = new Group(); - const paper = new FoldMesh(parent, { capacity: 2, segments: 5, segmentsV: 5 }); - paper.setPattern(fanCreases(12, 1)); - paper.setBasis(_from, _dirZ, _side, 8); - const p = foldMeshParams(); - p.count = 2; - p.hinge = 0.0005; // under a grid cell: the degenerate hinge - return { - node: parent, - drawCalls: () => paper.drawCalls, - tick: (now) => paper.update(now, p), - dispose: () => paper.dispose() - }; - }); - - /* --- Dissolve: the heap, and the patch ------------------------------- */ - const { - DissolveField, - DissolveMode, - DissolveSpace, - heapParams, - dissolveParams, - dissolveUniforms, - syncDissolve, - patchDissolveMaterial - } = VFX_MODULES.get('Dissolve.js') ?? {}; - - add('Dissolve.js', 'Dissolve GRANULAR', () => { - const parent = new Group(); - const heap = new DissolveField(parent, { along: 16, across: 10 }); - heap.setBasis(_from, _dirZ, _side, 14); - heap.roll(9); - const p = heapParams(); - const probe = new Vector3(); - return { - node: parent, - drawCalls: () => heap.drawCalls, - tick: (now, index) => { - // Repose at zero would make tan(0) a divide-by-nothing in the radius, - // and the clamp inside heapAt is the thing being tested here. - p.repose = index === 4 ? 0 : 0.58; - p.rate = index === 5 ? 0 : 5; - heap.update(now, p); - void heap.frontPoint(now, p, probe); - if (!Number.isFinite(heap.crestHeight(now, p))) throw new Error('crestHeight is not finite'); - }, - dispose: () => heap.dispose() - }; - }); - - for (const [name, mode] of [ - ['VOXEL', DissolveMode?.VOXEL ?? 0], - ['EROSION', DissolveMode?.EROSION ?? 2] - ]) { - add('Dissolve.js', `Dissolve patch ${name}`, () => { - const parent = new Group(); - const material = patchDissolveMaterial(new MeshStandardMaterial(), { mode }); - const mesh = new Mesh(new BoxGeometry(1, 1, 1), material); - parent.add(mesh); - const p = dissolveParams(); - return { - node: parent, - drawCalls: () => 1, - tick: (_now, index) => { - p.progress = index / 6; - p.span = index === 3 ? 0 : 0.22; // the zero-width let-go - syncDissolve(material, p); - }, - dispose: () => { - mesh.geometry.dispose(); - material.dispose(); - } - }; - }); - } - - add('Dissolve.js', 'Dissolve patch shared box (WORLD)', () => { - const parent = new Group(); - // The reason the box is a separate function: two materials, one event. - const box = dissolveUniforms({ space: DissolveSpace?.WORLD ?? 1, erode: 1 }); - const materials = [new MeshStandardMaterial(), new MeshStandardMaterial()]; - for (const m of materials) { - patchDissolveMaterial(m, { uniforms: box }); - const mesh = new Mesh(new BoxGeometry(0.5, 0.5, 0.5), m); - parent.add(mesh); - } - const p = dissolveParams(); - p.space = DissolveSpace?.WORLD ?? 1; - return { - node: parent, - drawCalls: () => materials.length, - tick: (_now, index) => { - p.progress = index / 6; - syncDissolve(box, p); - if (materials[1].userData.uniforms.uDisProgress !== box.uDisProgress) { - throw new Error('the shared box stopped being shared'); - } - }, - dispose: () => { - for (const m of materials) m.dispose(); - } - }; - }); - - /* --- HardSurface: six generators, one material, two solvers ---------- */ - const { - HardShape, - HARD_SHAPE_NAMES, - HardAxis, - hardShape, - hardSurfaceGeometry, - gearShape, - gearPitchFraction, - gearRootFraction, - ShapeCache, - GearTrain, - gearTrainParams, - GrindContact, - grindParams, - BrushMode, - hardSurfaceParams, - createHardSurfaceMaterial, - syncHardSurfaceMaterial, - blackbodyColor, - heatToKelvin - } = VFX_MODULES.get('HardSurface.js') ?? {}; - - /** - * `scanGroup` only reads *instanced* attributes, and every one of these is a - * plain BufferGeometry — so the geometry scan is done here by hand. It is - * worth the twelve lines: an involute flank is an `acos` of a ratio and a - * `tan` of the result, and the whole family of ways to get a NaN out of that - * shows up as a hole in the mesh and nowhere else. - */ - const scanHardGeometry = (label, geometry) => { - for (const name of ['position', 'normal', 'aEdge']) { - const attribute = geometry.getAttribute(name); - if (!attribute) throw new Error(`${label}: no ${name} attribute`); - const array = attribute.array; - for (let i = 0; i < array.length; i++) { - if (!Number.isFinite(array[i])) throw new Error(`${label}: ${name}[${i}] is ${array[i]}`); - } - } - // The unit-space contract GrowthField's factory relies on. - const p = geometry.getAttribute('position').array; - let minY = Infinity; - let maxY = -Infinity; - let maxR = 0; - for (let i = 0; i < p.length; i += 3) { - minY = Math.min(minY, p[i + 1]); - maxY = Math.max(maxY, p[i + 1]); - maxR = Math.max(maxR, Math.hypot(p[i], p[i + 2])); - } - if (maxR > 0.5001 || minY < -1e-4 || maxY > 1.0001) { - throw new Error( - `${label}: outside unit space — footprint r=${maxR.toFixed(3)} (max 0.5), y=${minY.toFixed(3)}..${maxY.toFixed(3)} (0..1)` - ); - } - }; - - /* Every generator, on every axis, plus the awkward settings of each: a - pointed tooth, a solid blank, a slotless blade, a boltless plate, an - unthreaded bolt and a hornless anvil are all reachable from the editor. */ - const hardVariants = [ - ['GEAR', HardShape?.GEAR, { teeth: 7, addendum: 1.4, backlash: 0, bore: 0, lightenHoles: 6 }], - ['GEAR pointed', HardShape?.GEAR, { teeth: 5, addendum: 2.2, rootFillet: 0, chamfer: 0 }], - ['PISTON', HardShape?.PISTON, { axis: HardAxis?.Z, rings: 4, faceRecess: 0 }], - ['SAWBLADE', HardShape?.SAWBLADE, { teeth: 9, rake: 40, clearance: 55, slots: 0 }], - ['SAWBLADE fine', HardShape?.SAWBLADE, { teeth: 48, rake: -12, gullet: 0.05 }], - ['PLATE', HardShape?.PLATE, { bolts: 6, counterSink: 0.08 }], - ['PLATE bare', HardShape?.PLATE, { bolts: 0, bevel: 0, corner: 0 }], - ['BOLT', HardShape?.BOLT, { threadTurns: 14, washer: 0 }], - ['BOLT smooth', HardShape?.BOLT, { threadTurns: 0, threadDepth: 0, tipTaper: 0 }], - ['ANVIL', HardShape?.ANVIL, { axis: HardAxis?.X }], - ['ANVIL unfilleted', HardShape?.ANVIL, { fillet: 0, horn: 0, corner: 0 }] - ]; - for (const [label, kind, overrides] of hardVariants) { - add('HardSurface.js', `HardSurface ${label}`, () => { - const parent = new Group(); - const material = createHardSurfaceMaterial({}); - const shape = hardShape(kind, overrides); - const geometry = hardSurfaceGeometry(kind, shape); - scanHardGeometry(`HardSurface ${label}`, geometry); - parent.add(new Mesh(geometry, material)); - const p = hardSurfaceParams(); - return { - node: parent, - drawCalls: () => 1, - tick: (now, index) => { - // A slider being dragged while the clock is stopped: every one of - // these has to land in a uniform on the zero-length frame too. - p.heat = (index % 7) / 6; - p.brush = index % 3; - p.grainScale = 60 + index * 11; - syncHardSurfaceMaterial(material, p); - void heatToKelvin(p.heat, p); - }, - dispose: () => { - geometry.dispose(); - material.dispose(); - } - }; - }); - } - - add('HardSurface.js', 'HardSurface ShapeCache', () => { - const cache = new ShapeCache({ capacity: 3 }); - const shape = gearShape(); - return { - node: null, - drawCalls: () => 0, - tick: (now, index) => { - // Every other frame moves a shape number; the frames in between must - // hand back the identical geometry rather than rebuild it. - shape.teeth = 10 + (index % 3); - const first = cache.get('gear', HardShape.GEAR, shape); - const rebuilt = cache.changed; - const second = cache.get('gear', HardShape.GEAR, shape); - if (second !== first) throw new Error('ShapeCache rebuilt an unchanged shape'); - if (cache.changed) throw new Error('ShapeCache reported a change with nothing changed'); - if (rebuilt) scanHardGeometry('ShapeCache gear', first); - if (!Number.isFinite(gearPitchFraction(shape) + gearRootFraction(shape))) { - throw new Error('gear pitch/root fraction is not finite'); - } - }, - dispose: () => cache.dispose() - }; - }); - - add('HardSurface.js', 'HardSurface GearTrain (meshing)', () => { - const train = new GearTrain({ capacity: 8 }); - const p = gearTrainParams(); - p.origin = _to; - p.direction = _dirZ; - p.side = _side; - p.teeth = [9, 14, 22]; - train.plant(6, 3.5); - const a = new Vector3(); - const b = new Vector3(); - return { - node: null, - drawCalls: () => 0, - tick: (now, index) => { - p.time = now; - // The ratios are dragged mid-cast, which is the whole point of them - // living in the params: the train must stay meshed through it. - p.teeth = index % 2 ? [9, 14, 22] : [11, 13, 30]; - p.module = 0.12 + index * 0.01; - train.solve(p); - for (let i = 0; i < train.count; i++) { - train.positionOf(i, p, a); - if (!Number.isFinite(a.x + a.y + a.z)) throw new Error(`gear ${i} position is not finite`); - if (!Number.isFinite(train.yawOf(i) + train.rateOf(i))) throw new Error(`gear ${i} spin is not finite`); - if (i === 0) continue; - // Standard centre distance: the sum of the PITCH radii, to the - // millimetre. If this drifts the teeth are interpenetrating. - train.positionOf(i - 1, p, b); - const want = train.pitchRadiusOf(i - 1) + train.pitchRadiusOf(i); - if (Math.abs(a.distanceTo(b) - want) > 1e-6) { - throw new Error(`gears ${i - 1}/${i} sit ${a.distanceTo(b).toFixed(4)} m apart, should be ${want.toFixed(4)} m`); - } - // And counter-rotating at exactly the inverse tooth ratio. - const ratio = -train.rateOf(i) / train.rateOf(i - 1); - if (Math.abs(ratio - train.teethOf(i - 1) / train.teethOf(i)) > 1e-9) { - throw new Error(`gears ${i - 1}/${i} turn at ${ratio}, not the tooth ratio`); - } - train.contactOf(i, p, b); - } - }, - dispose: () => train.clear() - }; - }); - - add('HardSurface.js', 'HardSurface GrindContact', () => { - const grind = new GrindContact(); - const p = grindParams(); - const velocity = new Vector3(); - const contact = new Vector3(0, 0.02, 8); - const centre = new Vector3(0, 1.1, 8); - const emit = {}; - const axis = new Vector3(1, 0, 0); - return { - node: null, - drawCalls: () => 0, - tick: (now, index) => { - // Frame 3 is the degenerate one: a stalled blade flat against the - // floor, where the tangent, the binormal and the speed all collapse. - const rate = index === 3 ? 0 : 90 - index * 6; - GrindContact.rimVelocity(velocity, index === 3 ? new Vector3() : axis, rate, contact, centre); - grind.solve(contact, index === 3 ? new Vector3() : _up, velocity, p); - if (!Number.isFinite(grind.speed) || grind.speed < p.speedFloor - 1e-6) { - throw new Error(`spark speed ${grind.speed} is below the floor`); - } - for (let j = 0; j < grind.jets; j++) { - grind.jet(j, emit); - const d = emit.direction; - if (!Number.isFinite(d.x + d.y + d.z)) throw new Error(`jet ${j} direction is not finite`); - if (Math.abs(d.length() - 1) > 1e-3) throw new Error(`jet ${j} direction is not unit`); - // The read the whole helper exists for: sparks leave along the - // tangent, so a jet must never point into the workpiece. - if (index !== 3 && d.dot(_up) < -1e-3) throw new Error(`jet ${j} is aimed into the floor`); - } - void blackbodyColor(900 + index * 180); - }, - dispose: () => {} - }; - }); - - /* --- Colony: the SDF swarm, every target shape ---------------------- */ - const { - ColonySwarm, - ColonyShape, - COLONY_SHAPE_NAMES, - colonySwarmParams, - WebGraph, - webGraphParams, - LatticeGrowth, - latticeGrowthParams, - PlateShell, - plateShellParams - } = VFX_MODULES.get('Colony.js') ?? {}; - - for (const [name, shape] of Object.entries(ColonyShape ?? {})) { - add('Colony.js', `ColonySwarm ${name}`, () => { - const parent = new Group(); - const swarm = new ColonySwarm(parent, { capacity: 64 }); - const p = colonySwarmParams(); - // Morph this shape into the next one, because the transition is the - // thing the module exists for and the blended field is the code path - // that only runs when the two shapes differ. - p.shapeA = shape; - p.shapeB = (shape + 1) % (COLONY_SHAPE_NAMES?.length ?? 6); - p.waveAmp = 0.4; - swarm.setBasis(_from, _dirZ, _side, 12); - swarm.roll(5); - const out = new Vector3(); - return { - node: parent, - drawCalls: () => swarm.drawCalls, - tick: (now, index) => { - p.condense = index / VFX_FRAMES; - p.shapeBlend = (index * 0.3) % 1; - p.cling = index === 4 ? 1 : 0; - swarm.update(now, p); - void swarm.shapeCentre(out); - }, - dispose: () => swarm.dispose() - }; - }); - } - - /* --- Colony: the web and its membrane -------------------------------- */ - add('Colony.js', 'WebGraph', () => { - const parent = new Group(); - const web = new WebGraph(parent, { maxRings: 6, maxSpokes: 14, filmSubdiv: 2 }); - const p = webGraphParams(); - web.setPlacement(_to, _dirZ, _up); - web.roll(11); - const out = new Vector3(); - return { - node: parent, - drawCalls: () => web.drawCalls, - tick: (now, index) => { - p.grow = index / VFX_FRAMES; - // The topology rebuild is the interesting path: it rewrites both - // meshes' index attributes mid-flight and must never leave the film - // pointing at a spoke the strands no longer have. - p.rings = 3 + (index % 4); - p.spokes = 8 + (index % 7); - web.update(now, p); - void web.nodePoint(index % 4, index, p, out); - }, - dispose: () => web.dispose() - }; - }); - - /* --- Colony: lattice growth, with the refusal doing work -------------- */ - add('Colony.js', 'LatticeGrowth', () => { - const parent = new Group(); - const comb = new LatticeGrowth(parent, { capacity: 160 }); - const p = latticeGrowthParams(); - comb.setPlacement(_to, _dirZ); - const out = new Vector3(); - return { - node: parent, - drawCalls: () => comb.drawCalls, - tick: (now, index) => { - // Sweep the two structural sliders, so the regrow path runs every - // frame rather than once. Drift 0 must give a clean lattice and a - // high refusal must not deadlock the frontier. - p.drift = index * 0.12; - p.refuse = 0.8 + index * 0.04; - comb.update(now, p); - if (comb.count < 1) throw new Error('the growth placed no cells at all'); - void comb.cellPoint(index, p, out); - }, - dispose: () => comb.dispose() - }; - }); - - /* --- Colony: the shell tessellation ---------------------------------- */ - add('Colony.js', 'PlateShell', () => { - const parent = new Group(); - const shell = new PlateShell(parent, { capacity: 64 }); - const p = plateShellParams(); - shell.setPlacement(_to, _dirZ); - const out = new Vector3(); - return { - node: parent, - drawCalls: () => shell.drawCalls, - tick: (now, index) => { - p.sites = 12 + index * 8; - p.jitter = index * 0.15; - shell.update(now, p); - // The claim the module is built on. Every plate must survive the - // clipper with a real polygon; a cell reduced below three vertices - // means a gap in the dome. - if (shell.count < 1) throw new Error('the tessellation produced no plates'); - void shell.plateCentre(index, p, out); - void shell.progress(now, p); - }, - dispose: () => shell.dispose() - }; - }); - - return cases; -} - -/** Frames each case is driven for before the zero-length one. */ -const VFX_FRAMES = 6; - -/** - * Drive one case and collect everything that went wrong with it. - * - * The zero-length frame at the end is not decoration: it is the same I1 probe - * the ability pause test runs, reduced to "does a `dt = 0` tick throw or - * produce a NaN". A module that divides by `dt` fails here and nowhere else. - */ -function runVfxCase(entry, failures) { - let handle; - try { - handle = entry.build(); - } catch (error) { - failures.push(`${entry.label}: construction threw — ${describeError(error)}`); - return; - } - - const problems = []; - try { - for (let i = 0; i < VFX_FRAMES; i++) { - frame.uTime.value += DT; - frame.uDelta.value = DT; - handle.tick(frame.uTime.value, i, DT); - } - // The zero-length frame — a paused slider drag. - frame.uDelta.value = 0; - handle.tick(frame.uTime.value, VFX_FRAMES, 0); - - if (handle.node) { - handle.node.updateMatrixWorld(true); - scanGroup(handle.node, problems); - } - - const calls = handle.drawCalls?.(); - if (Number.isFinite(calls) && calls > DRAW_CALL_BUDGET) { - problems.push(`draws ${calls} times on its own — the whole-ability budget is ${DRAW_CALL_BUDGET} (I4/I7)`); - } - } catch (error) { - problems.push(`threw while ticking — ${describeError(error)}`); - } - - try { - handle.dispose?.(); - } catch (error) { - problems.push(`dispose() threw — ${describeError(error)}`); - } - - for (const problem of problems) failures.push(`${entry.label}: ${problem}`); -} - -/** True for an ES class, which is what the coverage rule is looking for. */ -function isClass(value) { - return typeof value === 'function' && /^class\s/.test(Function.prototype.toString.call(value)); -} - -/** - * Run the whole stage. Reports through the roster-level channels, because a - * broken shared module is not any one ability's fault — it is everyone's. - */ -async function checkVfxLibrary() { - const failures = []; - - // Before anything is constructed: the one class of shader breakage that a - // headless harness can find with certainty, and that a browser finds by - // deleting a mesh from the frame without saying which. - vfx.glslBlocks = sweepGlslReservedWords(failures); - - for (const [name, module] of VFX_MODULES) { - if (module.__error) failures.push(`${name}: failed to import — ${describeError(module.__error)}`); - } - - let cases = []; - try { - cases = vfxCases(); - } catch (error) { - failures.push(`building the case list threw — ${describeError(error)}`); - } - - for (const entry of cases) { - if (VFX_MODULES.get(entry.file)?.__error) continue; // already reported - runVfxCase(entry, failures); - } - - /* --- the coverage rule --- */ - const covered = new Set(cases.map((entry) => entry.file)); - for (const [name, module] of VFX_MODULES) { - if (module.__error) continue; - const classes = Object.entries(module).filter(([, value]) => isClass(value)); - if (!classes.length) continue; - if (!covered.has(name)) { - failures.push( - `${name} exports ${classes.map(([key]) => key).join(', ')} but has no case in VFX_CASES — ` + - `add one to scripts/check.mjs so the abilities built on it get an error here rather than a blank screen` - ); - } - } - - vfx.cases = cases.length; - vfx.modules = VFX_MODULES.size; - vfx.failures = failures; - for (const message of failures) failGlobal(`[vfx] ${message}`); - return failures.length === 0; -} - -/* ------------------------------------------------------------------ */ -/* §11 · Output */ -/* ------------------------------------------------------------------ */ - -function describeError(error) { - if (!(error instanceof Error)) return String(error); - const frameLine = (error.stack ?? '').split('\n').find((line) => line.includes('/src/')); - const where = frameLine ? dim(` (${frameLine.trim().replace(/^at\s+/, '')})`) : ''; - return `${error.message}${where}`; -} - -const safeNumber = (n) => (Number.isFinite(n) ? n.toFixed(2) : String(n)); - -/** `null` means the stage never ran — usually because an earlier one failed. */ -const markText = (state) => (state === null ? '—' : state ? 'ok' : 'FAIL'); -const colourMark = (state, text) => (state === null ? dim(text) : state ? green(text) : red(text)); - -/* ------------------------------------------------------------------ */ -/* Run */ -/* ------------------------------------------------------------------ */ - -checkRoster(); -await checkSchools(); - -log( - bold( - `\ncheck · ${TARGET.length} of ${ABILITIES.length} abilities · ` + - `${SCHOOLS.length} schools · node ${process.version}\n` - ) -); - -for (const descriptor of TARGET) { - const report = reports.get(descriptor.id); - if (checkStructure(descriptor, report)) { - checkStaticReads(descriptor, report); - await captureConsole(report, () => runAbility(descriptor, report)); - } else { - // Without a settings block there is nothing coherent left to test. - checkStaticReads(descriptor, report); - } -} - -/* --- the shared library --- */ -// Skipped under `--only `, which exists to make one ability's loop fast; -// `--vfx` forces it back on when the library is what you are working on. -const RUN_VFX = !ONLY || flag('--vfx'); -if (RUN_VFX) await checkVfxLibrary(); - -/* --- the table --- */ -// Widths are measured on the *plain* text and the colour is wrapped around the -// finished cell: `padEnd` counts escape sequences as characters, so colouring -// first shifts every row that failed a few columns to the right. -const COLUMNS = [ - ['struct', 'struct'], - ['static', 'static'], - ['runtime', 'runtime'], - ['travel', 'pause:travel'], - ['impact', 'pause:impact'] -]; -const idWidth = Math.max(10, ...TARGET.map((a) => a.id.length + 2)); -const cellWidth = COLUMNS.map(([, title]) => title.length + 2); - -const row = (first, cells) => - first.padEnd(idWidth) + cells.map((text, i) => centre(text, cellWidth[i])).join(''); - -const centre = (text, size) => { - const pad = Math.max(0, size - text.length); - const left = Math.floor(pad / 2); - return ' '.repeat(left) + text + ' '.repeat(pad - left); -}; - -const headerLine = row('ability', COLUMNS.map(([, title]) => title)); -console.log(bold(headerLine)); -console.log(dim('─'.repeat(headerLine.length))); - -let failed = 0; -let skipped = false; -for (const descriptor of TARGET) { - const report = reports.get(descriptor.id); - if (!report.ok) failed++; - const cells = COLUMNS.map(([key], i) => { - if (report.marks[key] === null) skipped = true; - return colourMark(report.marks[key], centre(markText(report.marks[key]), cellWidth[i])); - }); - console.log(descriptor.id.padEnd(idWidth) + cells.join('')); -} -if (skipped) console.log(dim('— · not reached, because an earlier stage failed')); - -/* --- notes and warnings --- */ -if (!QUIET) { - for (const descriptor of TARGET) { - const report = reports.get(descriptor.id); - if (!report.notes.length && !report.warnings.length) continue; - console.log(`\n${bold(descriptor.id)}`); - for (const note of report.notes) console.log(dim(` ${note}`)); - for (const { stage, message } of report.warnings) console.log(yellow(` ! [${stage}] ${message}`)); - } - for (const note of rosterNotes) console.log(dim(`\n [roster] ${note}`)); - for (const warning of globalWarnings) console.log(yellow(`\n ! [roster] ${warning}`)); - - if (RUN_VFX) { - const state = vfx.failures?.length ? red(`${vfx.failures.length} failed`) : green('all pass'); - console.log(`\n${bold('vfx')}`); - console.log(dim(` ${vfx.cases} case(s) across ${vfx.modules} module(s) — ${state}`)); - console.log(dim(` ${vfx.glslBlocks} GLSL block(s) swept for GLSL ES 3.00 reserved words`)); - } -} - -/* --- failures --- */ -if (failed || globalFailures.length) { - console.log(`\n${bold(red('FAILURES'))}`); - for (const message of globalFailures) console.log(`\n ${red('roster')}\n ${message}`); - for (const descriptor of TARGET) { - const report = reports.get(descriptor.id); - if (report.ok) continue; - console.log(`\n ${red(descriptor.id)}`); - for (const { stage, message } of report.failures) { - console.log(` ${bold(`[${stage}]`)} ${message}`); - } - } -} - -const total = TARGET.length; -const broken = failed + (globalFailures.length ? 1 : 0); -const warnings = TARGET.reduce((sum, a) => sum + reports.get(a.id).warnings.length, 0) + globalWarnings.length; -console.log(''); -if (warnings && QUIET) console.log(dim(`${warnings} warning(s) — drop --quiet to read them.`)); -if (broken) { - console.log(red(bold(`${failed} of ${total} abilities failed${globalFailures.length ? `, plus ${globalFailures.length} roster-level failure(s)` : ''}.`))); - process.exit(1); -} -console.log(green(bold(`all ${total} abilities pass.`)));

3#_Kb#$WIKN_r)D8^S*wK6y1wx+xIeelNG-bk)|T@vpcyxN_%;C~h3mniv9 zE=#GUE9FP!uU*r1tldsHw9ojMG>Yt}bF7B}m)KXWTlg#C^~7QeUl=T{j4XpwwJdSy zcGR~20JFD&brLlz?-J@4wkY_N{k{_q7~qvbMYIH)FaFEF3gq~`;z+flZo|VmW2=^t zaJMrKk&N~o{p;)Ri{BZ%Kk!$>Epc};dBrxx0&>oKDvtGoZ{r;c;P1ozJ_|+Dv|SNl z3u?~dAs{L_3|j=AxfS!Ti+nZVkBMF*lV8?+WhKP&W6O;-3x6&V&H@6bABm?Gb~$LQ zc~6bLBI_R({7U!!B5$(63vRO0)cmop_kix+^+wW33bNa<5P^d7_x8u7dd$BH{vW(c z{{R&R9y7UR`+Nn556|w`L>09;-!tlY?T(=OG+|ooyiI_U zN`KWKr%YC&YqL55ZG6`kKgSw!G5o8sZ-)K_p9?RDMZyEhHS`1@{C1>&2KXY_v}eTY z3~q1{Z|&4Cp!>P4eW4tT;ZCnSvU!u3;+`+Mh%(ss_pN<8?XE41_WoS6Hzj2tH|1SS ze+_&X?Avw1%ttOSq0jz&Y7l`g{W20% ze!neEf8hTB2}-&C*@oGU6^hRWSYx>>TB@DVlcz3+1#7KuSp3A1VV9%0$Th8`_|D$N z-5%jZxx(XbLsBM>;F~in7d|uJ4(?-^~3u4|<|lb^bbhx6lmAV_SC^4e(ik-oSSnoIsQaO|f$NWI zt1hwRUds#%J>#LvM*z%m`t#qK(Yx?JhVP{NL-?v#Kpd=B&?qC{<*Sw&Z-Z^3h6ub& zh_LVxFXGAi`qqmR6lUW4D_0CPUo ze^~I(gtcprFzcw1l>kQ_+&KF34?gs^o&fObCLq_Rk8tFR$d2Lv0DRS~O6{UlIh2@t?N9KlWOvo*D3$oYFz!l9CnM<_qXEk59W<%NHJok;2Iw zkHlRv4Ma@PPLNIz1Y{UIf1b7GnsM2dr zS?)M(xyv8aW*Sraw53m5UmPalPPY+efRWFf>pB_=XH@7tgu zfAQLEUk|(uETU%dA(SZSZ=u>h4YfSYkJ-N?&n?0Bko-wk{j;!+U!wpEF`M;4=Q0sjEU zIi<}TkJ+=!^^b^9>FCz*r`m3FjqA!EaamT{ezD@`SubUKdu-$>9Y?UQYSMlP{5#Tv z6xUi-?(<(jcK-n5yjHXx3-E2A7&25Nur28IEVWvajc+8=9#CS6} z%a8uGUW-{5LpFfAwsUaZ53sLGm*MY&u9!_Pi45bmZ(-O!4YgSK2f)@d8CS%jFx)h4 zeE?ScHt|c^N2t{)uE&+wS_afEZzDOmUVd&>{k^MN4-i|iDolxXep3Sd@BaW*dL5U7 zybh}`lj0Qt{_z*EXa4{}s}1n;z|9(u?UW6ii3Q{#zdf|MmqSHPT~8s_ydrgmYrAL? z3y?GAc0T^bv9wPW%b;Iw5-Q{{S84l9(!QW0rkR$62uprlfZ%Amxa{C*kW=iNw?*(`||pL4XYMNJXHspe(;Y_wIV}zb z=H5f%IPPu^AsBGTxQ$P+^{iirUN6#ZE`C{_?(xSF?%dh@I#;7ycxS=-{0|+kiwtlu z!$}4Gzfb$*)eSS?@59X&Ln!eZT+Dj4#-VOc{)1W0r!eD6-A@%Ou4{H%uG&~^cW&|& z;lTHVe}N~u6Ss+2H=^Ic zwEafjYB`;aR1@6t*{?1nk)KZ{B6#x%I}hRO?@Wea9N})1$93P#KYQt3$i5QrEv^<% z5+V==3tzwo{)16pcoV?dyn9>3O2|4g-#{>ZN6S&x3d;4ES zA&%xZ7}#%MC;tFJrF)LYkx16pc4_65`!@tiy%*CSl@-3FbfuC8Hxai6;fLM-02=ft zz6W?0NoIS06x{g)%oirO5ZE^Vv8}@^C;kqu$83y zM1*5;Khw2UxKHfX-vg*;{{WUb&QG;^AIN_vU z^+W1u>!d|1hrA7OGjb%(-M{YoSFRrid_S?%hW`K*!yTtU&3|qe^}g2cYKd& zW#eH2O~xSAZAm{*l#xwDRy?!Bo;Hiai}qxdbqS7k#6I$R7Oq21(lwumdVredZzkPO zGB*l^g)5ul_q#uKpMN zI@P3-JI@j=(=fnUFW}03K3ZC_*zxNNh`d(`c;xcoPDw+F5BLhPdJ=pE@D7sZ>c_?RaH^cJ8lAWwj#8{m;qQkx3Lu-t(8T>2{{Xb@Lm&Khm6%xb zwbW8j(b@k1q)bK(XQKPHRk!f`mb3k~IRo4Sosai9^z^S{wfJlB>_Hrso+lXSC5KYC zJHDhZH6*?c@FWf8&El0TI~_vbKZx7K4^Xk?$*HVxx7rzPWb|>t`ZZ)vX)djI<|4hF zy9^0qZ<=qY^si@chMoe{3cbgPKG7KavHiXRi|#&NwKx1L?*rNlCyCq2AHv#%AEDdD z2@c1Nqsga0u(WFN1OBoxf%uABo496GhA=KAet;b9_x7(~PX_o7__9Odg)Detx`nwv z`VB{K@SDJJOXl17k>h;ivfn}TeMZ`)exgY7NOUWkS6hQ1zuU)_dZ_lN>RRTXrC7Da z%$Q&ZFu3`%>5BC`{RiRgl!gQ1_@TNyU}~1?+4^F$o8U))^{ds?FZ@w)c1ZbE^$T*N z>GD+DQQFAxi#;n*@w~=BjbWpfKPe;!_QiI(Mw?}#+Y3Puv<>o&!z(BJ3cZFuhu#U( zVDgWM&ya(FZ=f4T{qs>v;GY9(_U1VJQVbc!^P3iDH7vE#BXo2Xj-t}BF+FhABQ{VOpnu5OTdj9S1DcB$IV zJ^icJ=J*fr{qKI;Z{k_302HiE{{TH@)GgG1=s2mc z?0LKqLvs*V(R`&{qA)N3^u=Xe;=qe*C*LvUp<|Gv*FCG)pTPbC)>k&(Ee(N#G#ZV$ z9BJ|1~QpA^R;bUDAbARneUH3NJu@HB^Wc%j-1?hqYD0;Kw%mzsX|$XuxL zV{t9~g-WzmW0Nd=7yLSbS`phpa{?d=ss=$$fOa{rVjcwW3}<2SFjL)H{sI30vekKh z8+Zloh=;_+JCHe8^#}(Zq-~-90CW^@x#Y6n&!$3&Y@+H?ctiKd_4-q7++4`g+>tHe z;H&YldIj|NuU)nHXW&gge68YVXJeL`wFn75+1tRX`rn0q8b*O`d|z&o{{R6xov=S% zlv}u2^gLI@ddxl-wl^Mft4a4Q`tCo{yxhyEc+*%{k%$E2U_T^FUqipa4~G6FyOQPn zZ9UudEV`|$1Rmcdby_dNPlmcxpV@6ZMy;Mnd;4O(8Xe7YmQONr7Uq?5?p8(r7*t}F$Oy^+KAUOBN-YOWr z7yLhfN@npk_Ln=_Tl;ng+Z$>cySOD|#B@vB9bPkZ(upqLo#C)o*n3vG&v9$ZGTf(L zm|!ctyZ8g}%4xpUZ{mq;pCz*frEaVGr9HkJd_RWPG`jJV$!ZV!$u&Dr8|ronVPnl6 z{X9a=Zij869JwRrbLo%kQ=4_Q^8U{tjZl2Cbx)u*>d<%};f?%4C_G&Zs<_+b>Q>nw zObW9901o^GZ?z=xS#15peL~+S{{S7OV6o$OTFP6-eB~=6@QP30KU$-2q%@Lw(h!#o z$SZ&&_5;0pUBAOmhks~kU&Tu#Ndpv zKJOIh{ug{YjUzF5qYAwpbr=ul^7BsD8RKHztl?CQiIigrjAETOqY6YzFjihXlD{(_ zO7=(ibKn@WB%UeHKX~8U{{ZndUH%t*I=#5_Ch=dG%l8qrD}R&l+9~pr%6Rk^4Duzz zOtH>B>`l=JrB8o-6cD++j7JKt4|3XbwU=E!5ourt%=yBT>yLWsZ6>|&%pxm`K_#O9 z0L#t4f2gl+vG_6Y-%7R-TX>=4$0fhCt;uh%%T`;!9}lh7L-@PqJPhhq`ESE*H)3tB zr-(kQV|fyrX*}DR4in2bVfCr4Bbvbq?n#_YJCfVH z*mXN)EA;s)?Z3j`0obP5JYM&x2i})aw%kAQ%1t&FJdoT+dZAgwiPWTvf<5as!(Xws zvycl!IdXa4eF3j!e*t_k)Q8#E#*wQx9BOvE$G6?9wtot~9@!|09~7}q?zW+A{{X~O zTMpMee%|s}V)7ZJiavoelr>)dXzi_h+$&+zx*%uyAW#lkfH>C~;q z{>xP3_&@OF?pEk{wZj4O0jS$;`r~CJuE8rFHDhNaYV%ygE+l`v$=m&(rCYax-sVrA zk~t&{A8PjW@E3rsU=cTp*I{khZ9?C-(Q1vy!*7Q-j1n&w1#W-U>UO}W`fbnWM6UJ{ zUC$G{`%V4RTd(h+jOB+yf7#EqS!adu3$DgtjgB}N^si;K_*L-s(h(efDrr!2&ZBLg zsXHmp@bBTk%E{u@$mzdQxBmd*Xi>22?{mX%pf7;qwn*YS*2$w#LmGj;P$NdnBI^{uXcYt~_TPQj?9ZsoLj1 z_svXx82mDT2VWU4&GM-J)V1g7^3rRtl6#&Pb#^6@ZCDgVIh2l=^rh9zGU+$Z0tm>r zlRj4i8+u~B$}fbU4PXo(#l@M<_GZ-Wc|Z5XLw)d<;e@tx=f)cl#@m@SOHsxSe}bK? zg=_A7hn#W8=SyEmpMt*!!6W_?@BFOU{{Zn6-}q9W2fyfkANV=4{{Z4EQtW)+nk(rK z_)?z-zvzA+_&Kuw0OBa1XO{dvxoGrTh|za3nTP;jebuc?IHj`GB(x4;jlOlk9Zz0s zmhi5*HIA7jt-Z5NGs?`Q4mnUirDwLlf<(uP*V6nor0L48w{&2KEF znj^hNa0;5#wz{{9J&HGFPYKO6x*n2NXDRUeS(@h2wAm)|BofHO zaUA6T07~J(cv3 z!kgig?una$p5nIBYpAZHm1l9c=1_+Ke~nVqqkkLSR9Q-iZM;9zr8GiE+0(*+a(e;blkhVH) zo>@sT&vU@p+aHB%Md1h3B;Tvu%X|h1jT>ZQn$uEka)paU2-r7sc)%hJ@7+yP* zQ(owrw~6cuYEs8#ZUJcRqZ^2Ca(|_0Ucm-|BzHP=UzW$n)IKc_7)h>tE3YJ>Ld|aEI+4Q+`d6&!TFYy;Ydx?F6Abx{+uFI`h`t(2 zn^n|o*KE;^vgGbAzQ0=Lyc4fUq+Sar!&=VYC}zM`^{SH5G%LQRqF-C?k~^5)q?rfK z2*%<0RmRofo*2Yr$S?>*9lhy3#WmD}%zfD#u)HY$02;5D^n2eg2(V0Y2cviV4MwFR zS(aPaO1M~}Kf{1n{y3)C2<)e8Nx(@0Z;n!OU4EqYt7WCQL;^o4I+Gyh@~AZ(C1$r0 z`J1jI`O({;^;~)yq{nj97lnq{;x2Qq zEQ5xR9I5a4QcVQ6S>DDNh`~fOu1h30&_Mzfa0?8CQIbVeyoy~~9e{nDC(RtEmIkTD z6G@q%k%Gr3F-T87<27Y2Ov4a@z<xgb{WP7`?-#R;>G04ZI&PbJ*lp%^n>1=kY&A)UUM-J5-X|>grP^z-~jiJxJ+Y z4~V`dc%R}tuu0;rOZ!sdG}w_OVHE-Ef!O=jwB^aUGxv+)-8}V>v7dK2B<&;VQ0o#S zEM_SdIoF89L#Xt{Vl3C1dn{8(Qpwwf9Z>ocTK1tWt_;dh1sUi0=C}5U$CpxT13mS& z(Ti4y?P&VGYV=Uq+oK)6V}Bbt;_IapSq}=!+#m6x-s}58cgrI?^mOU8 z`-+Zn*ysn0ZS>?zXdud-ml@$trfT%QBy(zpSkg(OZORXCt#Y^K6@bSk(5yb_*NEx<9c#e5SOAkzcT0Ns%6KkEOr{l}WkV^z{MEJv?Bo`H%{{RV<=BsYXzap09 zr2E70teuhDub9L$4zfske}!ON_>oSf{{RxGTK0SqF2Vb=>7S)nGhxV<=+yBZh|}50 zGz6P~H%YwUFsvU8{7jcZ^I^DCBm?C|CxvR#ve7Oz=>F5IUYCzNT0!zCJ;AE}EbuLz z)q>4svI~gE%DBjodSln#j$&m^ZjC2hQ^eAJuKxf~b7tJFtW0)(HD<^`;rpL5G#*Qz5f>R55_!xcI705N(>5?)=8dzBW0IQFxAFribZPVMUNhT1UBa4Ihn%XmHhfe3C zwd@i)q#GV$GWl_ZsaINPk~2NpAnD{H@O|@Mc9y~I360IOuPSrYb`;n(%ek$V7~&#e zaLoNb16uoM+{f6Xx47}8q*reg*bp$lW+v-$lSef+uiQmohJm*eKZ})!zP8yB6Aj=h(I5Bq=v)xa3 zBKZsC#7Ex7ScoCM*!ke`)rUh8(=7z=MK``{zxNcdoV4PsB?d#@AeGU?V< z1cs24aLxBY_NiOyJ0qaE@qV2Jy2%u~i!MY;IjsFN#2SvK^RAy~ zAJ()jC6-56?v0U21d3Da&0(c01@3Wrueka>(cI*mA z8#+|6C1qj}X=#55d$PCQV8B)xLG&GsaDFN9gxV~Rsp&36NOQ4%^j}X=T~j2DD?Nrw z{{YKEc#XdgTDhgi4ALJnb6kP*M=$XG2XRr%(V|_>BSH9v(@d6k&bIO&l-zTlr?qx- z>DHfTFSrqn-)P9l_s@Fc{vv4_Gm|B&i1k8vw}@mvxvn_h-)a6uj-&R4wNeX4Nd%8- ziCipt5%J-KJ*J-sy}So#pZG_=rAepwK21jJ?V8-*ZJ+NfK>qD(Q^L?`7aB(WJIZJ= zh9emt?^6exYb1wH2IAObabfbW`?;hVmM>vl7T7hb1c;xS*!27`I@4iR)gsEfM5C;1 zK6CyRG%05@2@*z@c09WtSKA!`sr48w(my$*`!lcIV18yk^j#E}QWespb^X(MHw(yM zdi{NBr}m>;+rhZNAu? zM|BXu{D1oOvp%g3sFAInE>!;j9zIm}`qlaEM~JQ1Ordnv{{WUfSo&wuxcyH-y6|kT zsc83#wpsn^*f#959qm7mu{jZ1(U1Q}ce&@Mv-%6tP(pt*1-Gg)i z`DIc5ul@u5YTdq@E|(EYbr%pGS~LEzd*{7Q_x8&U?=;NJFd;#P&$y_#$s@Rm_SvL& zM?=YSMwVdH@H92je`AyOj~wS9BR-{_nBDy=ifc)+RIogMq~ut9uUL#&;I~gtKFc!ceb+v zW*;$>^iOboYQ43zx?H|=#ji>G(_`oV0QG$-TnS=Jr3)scr|j9vZ2P(Y0BCyq(rf-4 z(!5U?^P@$Sugg3@DFe4X>rmfYn`WKl1|l#|^#1_sRvfxb)%!Es6K8C|e&vt)&#$1T zsM0uLZLD}kdE~L3@3iFL$cvBRjQf@-W_g~zvYBbnKK`d#fshT-b0KAy`tAF+BhUN8E z5nidZwmgB*$><66uRdK%SMYW1yjs1hO#*+bE%$z)_xx+WgT%T%rK8C_w=o!k?r5Cg zsrDYfN-fGWOzHNXN6d>9agG^I-M@`-y2p(!r=DFWM*jdyalyG!%8JK%wNDyO_S$fk zEgT$!Av?Z+*Ja_29?M3y@-0(r%lFz(zE8ic9;3fvFA8`%!p<+X+%oDn(toUZIof-S z9+lF(qf3NK1ge1bf%#MZ@4aSf$YW)aBjw%y00@u2)~edcJU1R@qMO`svM9Jh!<${*|M5rdrP1m44512l~iH(mlP$;Y<%}cNj?u$fRU9 z9D9RUlTUl*Nd}oCTtdHkXXjw<19zdf(3=xoYO-mvGz}QIln=cZ-2>IfYITLA6QQ1F zOS_i-KA}gT^s5?Fc97cnQ5?v5kV*I0`}&Hbs9M9POl~8YZRG3neb)Bd>p?}ml34Fy zFPjRCnB{I%f4%)GjMI-41-Bq2nfuEi`J31S{Odi%&ZTn`>N~BifyysCT>7`C(zd3x zh1Y8+W$2PAX#{@V)MfJ5QnAVlWA7IgUZcU8l8HhsDdOO176EX^u|U zlN=M=DWvYg-Oc;U^wgY1X|hmwdE4&C^!KORY0_y53@6V*H!@52kG}4|g*q)7(#;jE zTgfD3NQWW4{p*eKhmYmcM3xq>9Jg4*NSy^6Y&$lK-YfWx4vP_v%jUiyua>}m?>$NN zt|vh7<>!j^2A)?cncXbRG63`yXTkmox$#BOxx983q~=*qRz8BgO2W5Q@*=dC0Jl8^PYFF9#^?FVogpv<~DX< z3A(s*k1PCYxAs7TKWC7`FIHl_6W=1Mm9@+bsIFE=Z*6g>GaUV=aLkOuEZ^cPdn@Q= z-W`VnCOHL0Zq29y?}*c#=zddCM`}Dt`^f-~$$sYE8eggOs3e8fZBlj?pu=VSN_q27 zu=K4MFXFdB46uabVZh8}R4oLygn38Jj0`Fc3W`a!TX^k4Z7*@1q5v>+Kp6V(g>>Xm zKA$UT_pWgoMas$eXNslv-CM&KG0Cl28-&KuaGy9)?OWP@ojtt&0BL5$!;eg=`vF=~ zT$f^(F$4#L{{TG5pbXm$YfkYDV`Pdz=^M7zA45<}b8s#m&c;l{rxHrv!|JNt&1UL; z6SCEHCS5UF%HR*)OA~ya!yffZezoAuqVCh}Rz&2_7|1_dazB*-c6Rr$+Q7Dn5rhsI zW&6kRJ!-ApymkvE!cFA&Z$s!YRO~!irt5a@Sf$@A*vhB;vPJu&{h~eVL<@Vhc^@ArBup?V$uvtaj$mPPo!iB69xT?@ z`%oHQlby_V$t-7wtKH~Y*sZVY7h3h-_Ew(lGz#3m8;}10TDtEJ>9(3hhmeu4oZQLg z3=(_#RX+{*fBq4xawkJ0H{X>-ZAmq`wPumlRma~vk?rkE$cRCfGnklN37%W^?zV>pT@>OJ%8R-%_I=_$mY+>`wCOHF{Z z5-GlLcD{c4V~?#=)U^ri3A8{YoGfwu(z2?Q{!D z=Q2vX?)LT+3sGRvwB16{VUZQ)Q=c>LFh04e^4MycBD9v53wvz5n5FyRdm7MtcIINq4J@vy|#O(Iya_!vXuryd@Xm#i{twAEW2F(|d4BQ7kx#?XKS(xKVA&_it zgBcmB?H!Jz5Vg3qL4k!y2T!4_%iS*PM2yR)Uoyt0n|Y2`=qTGn28HgW_AnM$vn%kS zKq~(Lrx>EcO+;BIc0P0c_CtbwzY4~()`H%dby(WoRv3+l`2elBS~*XdZy+uG^gLtf zLicwK%M7{>B)F4>Dc2pz6zfZC zVQdvKAw0Vt7@uFQD~CA)5q93zfD16-er{7Tk#&1euU%%g5IoC?_2byK0( zw7zed9tc>Uu6>Pp_M2@#i8WBp$9#u#Wbg)i)kgb?D?Lpw1Nd3>37UC9@=)ZzUBl?jWG-KBliFi(4YBZMgvACg6|8sOeVm`F>=;{jJ+{jt&bNwKC5H;z;FL zK;vWb-<=OlffaQNYb{0-_SnY!XA%LNdo^<~XcDu1r%s)sg%9pyn3ht0y{mUnz8amv z*=#X5`K8~^(dnAD_DgJo%5xHdg-)xFrAk)Rcj#d!h%Pjy5bCy15pVfypD;9M*9d#y^%b*mcIugx2FxAD8C-oUl)cgIFD&o12*I{H35{YqRDcgr?^d53?jK#Uu9UeUQ;dYZj^b@oYci}M~b$23t=i79GU`>t&!b}Y~GU~$3bwQRsh zf`tG8c^nf(6fKGBbG|x-FZA$PLir>S+?a!)f!J|H6$6mQ3t@K*&<%?* z*wOZ-ztkMwt)yT^pWf#-jm& z^)=Gl+Q)AgSkx%vsLA~3qO&B6SGZJ-V=;$Wc)>kA>6$DV`$RLjouMEj9smcnJJCe~ zdzy~nl@usk^YZar-mNoP-gr z3FLsA+YSfud)CZ0>kEVTrElXu;YAcLh@OW%@dLy5vT3qvXueeFcCcQJ^{#utTGhzA zO+DPbu^Gm2N4999y1Ed0G-f2WbXY>K*P72a-`Vn@jxbVoE47c^$8kjzktn)tTHv$G zDSYwzMkUrh71c?Y@r=DMYgbi1gnAVo+eJSZ63MHP$O zqe?k`({wF@go~pNxfO-t-5w7I+$O1|mbYT~Xo2CmqKforqZys1fvo=kZ0I8PND?^! z3_zbT zIVb!m(c~abhXtcz3*#HH-ij(^u|>%)7+JiG1FDa>dK$pYfnu1*#@ulgt5L{tTRRfCi?;CiI;!S2fGTB9=1A%gfE(dxjq-)gL@HdBS;Yn_!LveJh zN1G|ht$z}DddtMN68*9v=EjARRN;jbQYi5lZ)fo3{KnQpwl*8ko+TOR!o8R@Hp*76iZ=rDR`ZRowDlOd3OgNH)Hi3>c*20xq>^XiI#bDmVR;cqKeE+ z(fcfOTQWG@xXul8X6|FJN2Lh}w{_eY^2_WE6jdUQnj1LnVs$}*-1k3?QM|QFNM7<1 zW0hAY9U6)%P!u$Y4!*x>xyV&KM28Fb`qtc$#dM-3h$tfpNATmgqKXD{`nIh;gEHIX zT!03~eh3G*zAHyhwnUKJN<8V<1G&3rupOwPrX!xkUl3frjbn4DTN08)GFLo5*1UsI z*Y5mHd2aVFwIy%_YteK3=%SL`Nc5|3hWdP3Lr5i%T`KvaVZjHlO3KiDWqP-B>DM4# ztYDRqjvMjyqKevE2_0sWYBgj@?vI*WWm6w2{uLeN#JWR69C8FX7z#*1n5;b$A;ogktr+L?A}q)VJZ&^lRnR(^IwcfQMm)@MHB&k#uSDJn;WEK^D*jw z!mY_+70)k`An5DCqKW|LT6u4E_R=Jfh!k&UUKz34wDk=t&r`EV=C?y1-2iY$<3$t$ zJmbX|m!1o|lTNX_klMI4wNGBOGk{QAGegTiG}yW3fz z3HF?PQ@T6|J1sq&0xZgHK**B-S_3)Z-_DpUn-2+s%WMHB(aTzFE` zR=m8DEZDucCov8f`-;fXd|!Q~Y6i{NT*i!47vS)5MHNg9pC0&E*y2a{r<ZAVv@+EbX|I8p1*TJQWJ zp~0u<$vO?vOs8>S`cXw~_D3T`+kmE6*rwig^%YY~nCiNe^UMG?!)hw@qKeO98k1Sa zAuY6rdj{l-~*@T diff --git a/superbeam.jpg b/superbeam.jpg deleted file mode 100644 index c3138516f49e4012d02ddf8f8cca213ea016f00e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45915 zcmc%wcT|&46fOz}L5ei#ok$aARyAa^iU%py@aBGbU~UlktQ|tDj>br z&}%{o0Rr6o&N=IRYkmLRbMHU*O=hiRWim5+&+NUQ_u2cso4s2FJbb01t^&Zr!vnm> zy#RL$03`tay?g(?aTfvZeV_RLeFB2}4~U2eiOC+2k&!+iB_*ezen?J1ML|mXkp3YR z4J{oV9T_D9BRwr6H7yOLEr~eYX+=Ylz7umPR(H_pjUlL_(J^r!#;;cL`Fr&B&U4&nwpmWEhE3+ zM`2NMN$JnuHMMp14SyP&I=i}idi(kZ2FE8Rr>19S=jPYe5gVIZ+dI2^sFTyPbMyt~ z^6Edi@BsM#Q>_0j+5ewhR5-cr5fI=L5d9|?-aTJj!>1y+&mlrc{X&<>(w&A=G=!K| zG3k4C2ML#$9+K{Z$JhgUZt*o9)PJJ=56S+|1PlFtCE5QL?Efd%Jb(-z54U;vQ~)pl z3B$Akk;jymY4A1eJAebU?xySx0L;A@O+&*lKrCUKde!$kz#1dc0xJSV!?A)Ds<$-2 z!*_Rp&em(_mGIS4j@?|-(a9aaZ#!sZt=DNg2EhLdBNgCQ6ljiASeh(#adNAAJ1qYD zV^pj=ldaV(fFh_2mj4N?A1Hy`@4nK;(ysK}inaU`J`8*`t#3~5ul8WJR%K9vd^@PC zU*1kW$h~mwm=UW!e#>?{61i59-TS@3iMgj+b}@C{e^H4i#f&;|?m-IkfzVw4?f`EL zcB~OIin@2&#oLv}E{hVlWf!^RRZlVcKlVL3y?K0=>c;N9f4Y4?w=|Ek0qag*fvoCa=W4vk^-3?CJZ|F}vZ(^a zTCy!xo04r&XPR0EL^0x%{y7`y(?^x96Sg9ohR`sSRAeRBubH#!APNn?d4eyBTaTJM zrTLRm;=;z^Pg;ags=dL)*S=V;9HAL6%7#>^QE{bzMw$Vp0g>&o zqTevt;;_!9xMDz6ft4~TKPjBm{=@Ue1L)>5*XOK5w71f(DcmQe4`Ie@|ChhEfn; zQoZ9tYQI?iRVU1Xff@f>AM+-M8nlh&HwMg}?Pm^fH>j`3ZBnJw%DFn%i+Xry+2ik7 zVE712*PTY^B{j_6F5dw@n9M8RBnojnefTDxY+3)k>;39v5y2hKm|LSpT?qv zo4FmI|AcJBsr}sXeahD!);~i0=%Q-im=1CW=wX*N8)I=St-`Brmnf&g0I0dk@6+p?R4oJqWM;lu2TWvuE= zItUMQ+^ttxus*E9|3~Ot?19Q@o@Ez-&L2irE4CmluV8TqLpPKGYE?Bi3E_PSN9h~) zx!ZV%M=>J~PU#K1GgSu$6E)tGc{m{yw0naeP1h>ODNjlazq(eP@C}O8YHUu{2ryd- z7>tiL82v?8utpiPEQtwnP^b!Tv8uv2&aX8LQ^0;|+b2uC{@puB(#Ir^hymPaVTut$ zqePH0bjoy#9F*2%D{K``86uni+C@lbiS^;yC-23I2v+L=J&2T9^Hhs3a-nY~pR|h3 z2}md`d`^1N@B&?GSive*!lJf#;QmRbS0n~o*nrxOcHx*_3+pzi6><7RZphX4)kp`B za*b-fXsH3_%2zAwE3T|>Csfc6zeg2ES(?hxj~{;aFW=uVu3wtoJ;heDm7S1LesOQl zqZ|i^f*I<9tk4d>F`4qq{L@(PGDyfO>i$JY3)lt<tLkT)8cN~c*Y_6J@Xe4)H`7x znVEKvF6PiyH-Wgn zWB2lFXYV79@ zI~!8F+YQ#nhrG@HsfzG26IDs+^!IK(D!bp3#F4B4dCUQ_C5Hnxo~|b1VWiNPC^}fo zEe|HZD7F*Cg)A!b%zr)~*YS;t6o_j=d0loX3--lSNZ)tR0AF zPICt!m`Gp|o-Z)acsl=<<6j*I0U~s#(5J!e#i50S5-On2=avq{7~9*%3F}f3qeP4D z)x?4Y&b7IMhudb$VPp`vIZ~y_8%W{gpAb^QI8WnUxHRDMR8huk=f(U-=@IEX$_Y3L zq)QRAc`b-F!_+x!EI`_>+h=5fF)klg>y}ysto{JJDB?bPW&z}w$8OtE3h0JaHmfd% zdD|pV67u)cqRFi>i#L;kUlZ`E>s8Ihv)DJhR=41SwP0pQWS4AknPtKLe9l*^G9`47 z;&ktc+J_bxf%7-RPe~uux~x5dG@VSaR;ZhVLaUMm$aZ(?1)3iIp|&a3=#R0>;T>Q* z&KbxL#NS|KpTxWR5hzl$2WM`>-#330UN_35y*g}e@8*5;)Y(n=*z#JW!J#7BC4p{} zLtUES%19zlss|Peu2~67>3buY1UWhGLvgFGFYyjEoFI&BL=}HVDC<--&G|* zYIg@9r(CVV7{TXOkm*4`wSV0K9JdePd)A^P?jtM0P^}=D9$;M>_AwOs?^fat5FUP$ zeFq?koG-ZcMZXCix-Al8jFmq;L3?k{WKF9coEO>8kM`!0%wZyX8rbiTZ8U?RQ7%IA z(axV#_=b32@`P3*+84DQ!S-fiDr0TpnBSva%YuJU(=fK#6L}?H_w6D72TyGsWM#;V zeJLpZ1^gq*(N(6!J2S%4{6nDoFz&NA`TKFJ(7voYz>Pn8Wo}ov76Ntz(n2`@nq%LL z9~YWE>Tqq4@n3W^7iYXc^&vd`b>(P5Z^6 z@siYTI0!UGLUoaqvCz`Wsvl0zb{nECOCEm{ zPnh}%D+b}ezyx0r#Yzlwy&3;LU6HHJPUYlQXEtBLY5X{WUN<@OxmfNqdRPDH+ftNv zM^i$`+Ksi-MY2M1-1j!v>(ow0?$9}|XgUHXcP;@9=CybCG{}EY)Z(q*ieQvVAW9l7 zIKPro(Pil`zK<##;+Ocb0sPw4`;CZzP;n?JZbq0Wysqq9?aqgB;mu54J+y9NS!$Dx z+GCEmwr8FV(j8UCs1_6GXDk(>S*dc@q;w+jlh``HM zF>q~{F2Y!?KhE}EztPY@#UDqy2)Kxsv6K+P{J5uYz2kD6KVJUszqKx8LI|*htlqQ7 z-!_)#t>_Zm(6gTp)vyyaT`Vy9^GxlM7}huVSLI$k$)RdgAa5l^#Xf&V+(k(x)lit( zk-o}xr1834nu-1P{0vlwGSzH_wa@qFR_soLIA$cIB{XfFzHjw7NYt7uk|b%ZCiJW{ z5c#&c16eA?yVhKo`CSy}Nzw4eoFE2`=`@3y*pC)xUlQr^~C=)f?4xHq?3ZzOf$Tt0NaT$Ae;6Yqn(lusn`!G^88oiG+< zfiZAc-B~i@=W#BFlzT~8YqKtET9gXXpsx)`pmD=LdHX3 z3AxFVpX1AKd}t-=Fqf&T8xJPxabG9?&K%t6EpejZPixAN{cL;#`>8aLzP-Z{fD+{D z$a#b!%=Hc`+%d^1v~jv^+Zq{B{}366J^E>=BA3kPBG%6SWcuv*kX)|IzwS1-`p30l zMcnUhLv=D=zNC<_QQUpNvVcSx+nek5w0YH-vkL02F8|hz`7D|}68!s4MPG)Qsma#W zqF@_j(9bB9G!&^Sls=F*W5PI0du(mGPNVTKXXj;V(g37Keu|smuS-ZXtF>1ir6@AD zU7L2l2^JAiTx4Ny0%|$osV|i%^%^XYzVskoYS>(A_&D(HlT++W3vUI+uwue=td5+m zlD38Oc4}3$I@iiKFm&f9MKfBpJ7ctI@c*;SY{J|wWWcK-5 z+9TVR33C*IY;L>egXti3p>qiKg`Zdl_u3J)RD|feQn}C?{82^EVSu|&4k?QKHd3mLmnn%ud$SRSKP129~Txpwwtb-NjREzAjn%#a<(y~$|@aFX;0QV zZq`P|4XwNQDouL@`E+;%Xy4Z?9x4>dqhDuAp4R1LaXxwkABEr7Hk-?%%v?vh!aeia z8rdUDHS>F4)#s-sOEjIlAk=JDedj#_Dfhp!P(n$jX5a#1peZgM3T0A;iyxCg?aX!# z{bN5`(;u>?cFxG<{!J`%ePGnc*=C=2FXqYrZUpji>^a$RK8wkKQ)OBJF2MaCH=jO7 zIjcHa9Xw@P9U>d#G0x&pqJ9MvLgEXS51liwC&RNo3)gWC_)6 zWp&hSiNUk#;|{^h7$Q@8uD&*O4poqLnLquFtcv2Av6|#bM&HVbxI2!8uPuJ+F!tyt z5hyOWrwo92l~{cpGb}RvKoAAzr_@cRp!`ELt81YQ$?XFY^P+?*c+szYO^>$RK%{S| z2GYQypG3kY19YDQW&ejZu1+(a`$juvn7Z=wa>#5w`{H8(aWtbr&sQ0 zyEwI_-}vX6==N!e;k1jtP{(T#ym)%+KR)*SKAV01CmO7$>T91KL0W+ScGlGxu77S4 z_Ngimf%^oFkiGSuQ9{}E=s>)7lJ?Y6A5YG?(kBMxnQnu$=Oz8xEUsD5=pGMo9e|eS z@l5Og0jl~xL0&}fuFnhV<-FUEa0_JCq1MvgR+fzcJjwt6u7wCwW!`5aDy4fS!^9f) zFJ_btT*TK?lX$JVr0}y`b4XlQJ6e8@PSrCeshXOJn;>6|Qs-&#C>V=`{)NW7)UeIclE~Nypvip0^%m7~rgr&D% zv-ROd7>4%3su!W7MA#Xulk+I!Ue`?O57QFk&`3S)_lslJay1MGM&Q#PxX7(I{KKC+ zKo0pO3|0B*7d0&xnRKroHsR@hNLIosMUdw$?WMBs>^7*&^9~?ydJ06S&bmUkV}q@* zPCphi)2(g%KY79`<@agrY!I}XGaz-yP|r_yB!fREa(k-nh@#x=XDCKl4ho>LwpK)R0JAe*UM2lH) zcNY)a2!4^#gCdh`#c~va>^IvwhOMLq z(tvyBnZGSAal}@k6XQGK*Fmbkz|-G08!l;+&3Z;RY@KoAfOlW0L$^QY7CIbU>AyPt zq~dT=iP#dO{FZwz8k~zz!LgY+|9vo_U@|3Gy>WQ(>sEL=L1*iUt@b5?clTC2JPSI+ za0BkGH8k!U?9MGM*;rGeZ&#oQlD#M#Y>rJY*pkAcQqFKYBj5YK#xkONyS;T|KRef} zj&(SRl+pg{_CLqUak_}mp1?Zp>apDKcU}$4h!0~|vi9nCzUUD$IAfbL9dWzd{BKgf z3f#G~#?4yX3}H-oyV6Agd?Szi-&z=i&I2c0+I4CMIg6!%#zH2T(SY zxAJRruAlt?jxbqf`t`!qmCIc|{ayG5UqPBZwUT=suQl5ig!DiAX)+>#SLQHG0`9}{rO7!C7i2)88`=URM5_? zzXLogpKyEG$2(X#`U7Wap*Dd;K_?n(yL1LecsvWW0ar7dD~J4RZVxaF2zYd$*y_G& z&Da@S2%n|?c-e(K>xKzxax;OFtZgz6DK%>wW2GPEuv*x>1DqS-$YT_~1(Y7Ej&iL( zY_0NUVgU{MK)LBAUI`lNCS_R{Rl-zn5<%1saF#aEE<5ei`n6UZx@Ot@Ie0T)r5`Zr ze0;-yR(%Ip{bq5mfgS`*yytg=T5mJLty3E#{tl~*a*KH!2Q4t=DW|bI%1YkH;C$3> zTVr{T)erOGM^yQW-OWCi?zgnB$m=I&hbLdXD@G7?k++G2r01>>~#mb(^tkP%-I zKipK0j;qaW;a#v)%DACu_)isBGAA#GKsf;qs${smJaDj$>n3R(8f(!|S+)L@>y*Q^ z(KcvF(e+`~CN7&(HNGeiY!xwB2|)ulrgin4JXOVEX_>bqDM&V!T*{#{v7{Tmgk~J4 zScl>NX%clHD4{^Do3cH-_-VQ8tGrP*)*Uxzx-v3$y|;DsG|z0Gzo$yavbD%I72?&?T}|b-{H-@&d8i|7 zR;qiYrrcs-UU>}#{%oSilcl`5I1lxDHb6lsA79rV+>b-3wWA4OwlfQ|z5WYku|A%j z1NSpXXL7+!Rw5C?qBfVNHq(L*LTNExMXO=wZO5(mbT_mJE)8ByT_#OfL1@FyiF@}I zcs9p5`UhSnm<_6fW@$Atu=(+7b;JLzzQI@1?*R9Vak2iht7YE{nPN)JW2yEm_q72y z(7`_2&82T2mX z$$j?oiw%+O1KpgG3*P^+MI;uNX^;DEe8FoLPlH!hYJi>axrKHUiP6vE0|J4{zNOt! zy=Dt}^P9b(W~U!+82)lBArc%F5m5I91UI>yLFeC;x`Ca{oVr9zchdZ=b$-QLH9l># zvwcTPtX<$;Ug%RRt|_Qp3AcgGQm*B8!$X!?P{7!;Z?5NM*Y?|~x*WRwM2uLcX_i|O z7?Kgk{SZ&K2WZ2#_?K@P?0woOFhTkD=mov;>FFoy52R=Y4HGA@Qj7&y5D?Cn4j^-7%l_o>`PeU$3)g)KXS4*#Eeg=;~k$TO*_!TM+WcL-% z^f&P?FBb@%uhp^36Ey_?%yip|SlE-%qGy_Vp9^bf_f&XKZ365(i&hrr%oYY*0$h(- z|EWD2YeE`qvtIj_Oy*#!iomM{IgG!NGl%MBf}ho*gKWspVu;9vw%^@+{1i-g>Hw4O z@9`OrU-SKwmCXMxjfyT@V-OSn(D%c~g43#5Xao?c2@R92>Z+n#+H0b1ko`LRMgQIF zcclVDDOX!?{x%ATVT%PX`@4kxtMlfu^StS(xpHqp(A77WnH0~GFr^48c9lX4Q~Gek=o#Jb1@9ZTDE78iFi{&UCDgmTwcfat=~UZdMbmsQkMC=%lx6^a%H^AP$*+z}^S}5ogICj`c%#^%-_Vu4(sF2r zL@?eiv-0M~GV_-nGC>Yba|i*_{f2*g?GA81s7ZG;wB%IinFWx@vlBXIRD--G$o{^q30xZTsv4`p@I)TW z&`m9k_EZG-%iEZ&C>u|Z!6n&sDCIxRg5u4NQ0l&7tuPl1j?^Y-#b~;%D$vPm7Pft_ zVlj|PBfeHrQ;SAKXtxM|%m&e~|6e zD+yvY1=rPg`FgUItAz;|r;{Q6+3}7SPg%kAf3?-5I3%poD<#d|0j~DHb~MlJMSYp{ z1!wF~6~L@ee94uR1kYB(Fk|xJTzD@8Fv_GeyoLT!heWH?h89wulE>pwc?7w0aUXOM18pgnapa^<$la-IhEFhk-xAr2oZ7D3Ne5&|cI@ zmjB(Jreo1MRP0+;LM#8)3g$`ff!rE2r0xD#i!t&-%gGc{eBwNaO-GJw|44l~o6^#Cm|C;byBCynGSCD?r& z4lV0Ow_VbyiMd>H_eA>>t2H_3w`=tcen;F%AJkHkk5;^&ALqEARZvo6Y!RI%=(2+4 zFuJW=xeftW8Af@Ub4GA{5Dv8wjNK$|P1o^5qnCwhPRa#l>ywHX`);cQu~W*k9s&!$i-5lUE$6u!XF4m%UlnK&(<8Y;EB%tIOz?_yMX9@|$(xvqc7X>oaj#+-S zO1;{6jtAb?t$_ny3va*sUHqhw(_rdS_wqiWeAJ&yH2GX1SB}FR)5x_4-+Vpc@GGz6Y&0agUk^G6R)pgYtsQv2suRwHr7IkAfRf23bkHoQKM`)B z&}Yv>pD`UjQoMw#W&UO(ZrU7$!6^io z8g4!M8B8Zwq-oSCnPetnMK_$YlOw5My7`_GOU#VjK?)znqgFbMVx8Ozm-nj4fBw5k zRWln6>LQR@R}TIX$c;LxLRj2$XliRN%I4Q?YB0{3*R4yWN+#Y0X8Y^R?3F zB(sR1DK)Iqsj$*bZW}iQW}x|Crjt3la3YDeX0ex=|06`WSspR_=1b$PW~x&O`TEBdlz9?Ak+Eun$ z=r(RxH7+--JY3kR8paWgK3$BX+_s>{jV^_i!l9#?_FW0q`!0H$S>fmU4d6swjM0QS zH>x*nwc^>H|I%u04K;zN)bnRr+M7m^`PuA#_6e_jETuo(#0zSNwcsp?;0W^_q0R1O z&ZX(8(f~6^ABxVmwPEGk-r5hzxSBz0-DDlYZDAyf#_GWmW+qok?!NAMa z>OILSR}^U!jH0k9ETEC>W<>j5O@rS`OWk=VWALwk1|`>$YKmMr*K$>fzNg zjS3AjhnOw`lCAf@F5i~OhtzKG@vo<#`p`wA*iT1LlKVU`Pw3V7Iau6~=YLYn7iDAzbf- zraZ4K!@b)cVRElEFmUCzF>f=%-Ls)t6Vk+tkNTUx6T7K+i3l6u@3l#Xy{})?O(ywK z?bMUAsC1<->w$GQfg^vW3UyUPs`p;$6{_fcsSK_aJTdK|eMK}kLCOp}3W+o7gVy^V z6e28eO1&~1ft5L2seCJn;FO#6u}j==W3=pDDqzo>nShn{kqI6A{Sfa<-SU<3tO|_7 z8Hc2+aB56C##ei)J7Y9mXTE_9$V)m?s!%r9ZTTJ2tJOEOb}Ad+s3<=zFNM~<33-eW zM1-v$)9y=VjFh^t2>zh2{@wdIQRAUHH@2WPsJH}E$;LUZv5;z5-=KK(x19!t+J*UV zt?1gHHf*{2lsdaguNrML9OhoXh0L3IT(sZosO2%d+4$+fEOyfgLVJb=7`!Q975DRX zK5k#%v-K8tF#W!2Q3vfVX%oZ-&eS#gh4&QxGL_yiS}ZrH?#ETpT+XB*e6`MY^50Sw z7~%`O&GJy+fAugVX@uNE?{jhsEdT5AU=Rb&YOawPm`rrq*ik()?47|p+!0r`I?fVocTBg`$GJ!H7DZ_{9_K?zHN(ONNl(6><+^P*+D$3 zkP=?b)R-bnoXzOpWmXsa_o#T{8dFn@_ZpDOJhRob%ipIk@H+OLSyyYOg0=$Ra^xB% z1;p0K1^8m3w$FQh)CHiZTI6n#aLaR38Z6nfF8JKR-MlDS$Xdhwan{iS9)cJ26-SsI z@Svm+l<~N#25p2|Q3LYe4Wi#6?-_mI70vFu+C>|XAx%?$nM!76NyY5d9RN`blQ&w0 z<2j}+)8ia_-BHEi@|$?;E(B5WcZIeHI0w{jYZ4?AJk`cDEny?&t8Wohk7SompHedf z9@PlbU6YsWjb%C&rEikGuE_ap1>bTyg`;JWg{>EdktqDPwT7lK#tryt?+_e3@fuq)dtliRqrg@~<6JF9#Lt{i~3WVLavDZ3)KD z3O0nfSNxz)UbHTaC(T|iNU_Msr3Fa`k4@vO3#&vFUK=|EBd=HpN?M-zEB8@m>5uQ@ z`tGXt`HgO#v3PIZ9y!MN)PP@)c0mc*dIFyoJ|@zx5k9B8gyB%Q*wmT*?Nf+b*A*zq z&XO&=Y;gdsM&RsAuSa4I_i6l&T0s}<%>Ao`-E`I50cvIv0}UE6Ve2s3j`G0gruefSd;3r&V3Oa6w8duXNb&Qjb)*^)cPXeA-vLTyO0~)P;4R5#@&e`# z#D-|E<-YEfI?BU{@!PRwJmhq^*B!vJrY&69MC@x{24Sb1NFVQ5WMOcqgY3K`J$*yc z2v5RGBWem?A#%TJq+28@p{!8OVfI1>9j`RL%=vo5p-{m@Ud-}+wbl50`MgUyat8m} zht*Fj{)+ewOVJJ1fkmLJMYjZI_=xbf<_&^KlLqeo7Mspcc;dScjTRVQqp-dxutPTJ z;7RJr>w1BXMt=S*4lJ?6IT4n2sFQy^_p=Z4fxCFk&P6ommpyz*rnV3bYoknPhk&@j z?F`Byn)zW7t8qDexzm+3z;zVy?sq=o8YVWMASCfD*Z2T7QFi0)vYP5=y77E8{P-j# zIs48ZCmJaJ-7AwpzfXu;1 z?M-u%YZB~7x2QJ!$pfLv?wM4EGytvK?;I-^#L`En%8zw6FDeB5Xv?(f>DE7>?HEwL zXiC_+>Y;1XzZXQ|ZXYz345{j)f4q5gFTfQ*M&?!3SXtM~)ed6xYDnN$knEtNmcG?f zZ#TqmxY{^i2?`gePD4j(ESTc21a z@cwW>A6Dr|V&KgCh-yJ*^bTOUT&e4>ux$M%KlGV>705JOclIz)W+cfKimSE8A^yDb zT=LT&FL3v4|9y4NM-AJj{OZ9zDSm&YtN*v>FN_W7U*0!_Zy0G(16-zE?sf9;uP%yP zRsq3rR9v`GnaTU%Go^iOylsPc0Lu$^ZG75hh0<47Zz@0$3X~P__h2RrWru7;jpxth z!iE^3bj5hlJHU|WB45G}cF>Lj{~4F|v|ycq_HQ$j#hNM-+Wv04V!%eQA;wQI?Hgx5 zSyF#vS8vZ-yMJLlUh{6TO)XngYN)|->|4r#M>NB)NSmVoNv?Qxf(~f=&NHQ_E~cx>;pJA><|wn zq~S?2wQ3-56L@e3*csgPKBmGH^lX9~y!VGzzjcaz)lrb%$80qfr$2Gh%6NUk)m78< zHs%6mwF1S}uA68lFk_kNV}EN?I*eC+6Ro0ds^8SjZ4Dby#@Tx{@KA!N>l{ZYGIY-< z&UdVM$DOcJ3WI$Wq|dnAmwEy#++6EX#Qy^Gs7B@4YZuChkJ8BUz;;Ip9aRdRjo03d z=cD&kAkeZx``mhewPLkx|1nYeLE?++K$1# z8@qjkS?NUe5q@1KooF=sZj39pJ&4YD?>s%a7)lwlUHReC@uAApJ>8Ro*NLa$Fs~ z6{flj>wwDMj;PSmcpa{oW|7-CPW1694K{Yo_kJsk;m^mO_@k z_k0%?XxIp1#&oK6D|MQ5K3td-EMDs9f3#icR|TYrE{*aDU`Jb|jtyDbw+7^vKmqTo zk-nWka);{0xpuBPRY=YnKK*;f4Vpe~SZ7Nb`WChEW`!8|y5Ua@;xL8O1$`3vltbqE z$AF&$mqxv%Zm1^4DK6X&R1Fy}Fh1lR%*pjB-OHiD6t25J*iQ}SnXJf@Lxdhpy1ePd zLoiA6R#iB$>VLhj*heQ&B#wg7Z#Qo5uW3pR8J>K}19;@2%joCnzJ4cs!lfs7$b{Xm zc8g>eiVDX~p#RpbupU~$f$?5HTBe=$+;MTgaiTE%cO|8#@K=FXE6AwZACyVvmwa;d zWQuq{l1z>0AHKz}9JW6bXJ$m&zcp?^JfQ?}yz}cjw3NpIMjJn|@~A9d$r%xdoq0oR z2)5sqqoGS>>1w;eO)k*mYb?)b%onRi3P1b+-e8~Ft`EUw`q^bUT9#?$y~)MHR_A_! zN;d54P1&EgUAWD9Da87(7i}|dT5WPKNoJv|ub}b4I!UO1R#L%yY&bkJB^zw8M%jl` zmGJUow#Ir~kdCh0{?>E7JgVOXBbTmxJM920zLi47=cKG2nr}*w8jIj>es9#%bq`(P z_Y4^h#9?j%JRB5$%5CwBZ*o z1rr(5;_w`1L!lM@bGe=zfts-vo-*^kk_9=Fg*2)c9}^B2aNJ-5JUkeLvFKxHBf%8% z#=Dqni%6(fK7QYw7GEd>Oq4#WyxQtRDBNay+IdRF*fo43hP)(+ASwY1L2tafdXQX;QQVndMe)?xr#Fzp;`q3KG-SIrheF-tC;oJg zVy;YF+_W-y$ zWgY~kymVO4b)GB67?}eZcluKKwuh`d?#EUgZtDHLag@!8>j~2=xIeNgrPNWF*2Aos z>>6-Q`~i@%F&Oup4P81M{K{{I-89vW4tVXQjVwQOdWjjTT1b~*lsA-@-UN+2w3^tw zR}a~!GZ?oOj$Ay;wzE2d&0CsC(?53>@rJhU%V*-V)_`jir+ zZb{%pN;q1nyY2C$aLQrEFv>0Jocg}Y@Y?#stbCmIVx|Ty9SDveXj)a7^ z+cBr+%*+n0DP`aCWc?Q?NqH5GI#>+Ub_o56&K`jd?|R(}Yu2 z@lC-fnns@Z<2JXSm2tuOUr5C82Ef9h?{?V=|8gb*)7S0;roEN7_b;mQv=s-FS2x1` zW{CbCQ6b{aA*4mENL=1lp*F~p)~nJ&ga2j)p7gzz^_Q4!C|Tm`9DaMVk3(o{CZG;~S_4KMk-lmOqMF)Z z6|r*l2nf~ojA@#UHqwPRB_fRtS!uI=f^4s|Q-Wu~jD#dJCzRK`SpFmJ*)oL9akqA) z5rZr`FL`hOqi$hiQ0}wqL9v8ybeBsEonV%sKQQ8bX(ikl?oi+_j$CW#b&L3AIr#Ohls(#98e_Sx$#qySsxDER zPsi{?{mXZN6?mBw7e~m(1a5(W)Ku?=+^m9p<$u3!py75N=Vu(~km(?nsroFj!W#G< ziHg0pFqx_C-`1SeB z5=K>eO=XOW+r_E9sU)j~MxJLY=vK_<49a%xhoe@04&wX71}B9^Ea`_`E~AWA7P+~< zb9-lTkDy^z_GYMyV7{)L={+oq0*zP z!>v}9E+|sl#RtXd63{Kl?3fa5)etq& zp%;dELKG=+jK6FG5#93h4u~T!8Zn^Ek#e=c$Il9xsdLArr?lAaS>v7&3sf$BryA>J zaQ*Idl?ixE{`dKLS;p}Jo^QTcP(jyKt!(!rOT7a>?IR^$_NfjhFi|ikmOr4e+;+-~ zG$1KL1aDIVljwP#Z*1eQt_gBt_3|ul@>*t~lv6R&9(SX{v<KF^-v_F%kzu8 z)IzIdtUY~XF`UT?*4eni-~fKm!hlGxy^&lnLHQ(E_qbI~--^==tPI`Eycn(Y)GA`G z=X5k|U2yhiBvQg-(k~G5xsb;trK)1<;>FA-#GTxWMlDO0R7pF^Oq2xPe!$~yN-o$@ zbu2}NJ;zlLc2_`OO%%qgkCWzp&AS5_i2m#r4kY4nKQzzgsjKSMNXN<{yVg8*q*Qbz zhW8g02I7R}8}W2i@VLXy3JfqTYYNOHb|{AKeU?_8BZqP?Co7MapA&T}r7X3ea}p7} zOE5koyP5}Mo?2+tQEFgT&gA?GYAco~607fR<0wQwt4cOTnn%#g9nEdl?%S0x6=DHe zI#=i~v#ZKFpBR#lNW{v2Lq1;p?BPI-F1IR|NK5GMBxn&AlX6 z!rTG4j4OuO`_u>>4E=tXBsm#1CbPGE$SE6bD17Eq%21*A&zHR=Mn_gp_?7N-3Oyn; zZv4yRud3pxQGfNiUq{F8fJ?RGeYI(iz2qKC*G*TqY!?p88?>{ad|*-2XgC^KoN!j5 zVl8TFzolsw@-B3GRrWiY`$N1-o@wJUf@lr3aWRiBrb;%Q-EyaE<8bH)Bk4F>G&<)OK|oY+bk$zw zal!9N`XiK*$DmlUir*f2(2LSQZ;S;lbr@%|-Ma+t)x~6XGe{CD@IJ4PVu`Y~5B#M) zloZYj%G#z|VRrvQwWd|0@6b~d+koLhah|SKC1CA}FijVFzmcs6=nvCO8=}iUM9S2; zH4;9)t)!qZ7>78mHpI*8{|MxV1O|B19m53YkCw-cH%?vFbyIynVf>~bs+)@PU}mf= z#?7g~QDjeVsAOpwQ=r*^x2VkB9Fr9^(;?l*JKnU9<7=hX%AxGFW{zFP6M|TgH8)Z- zlimEs3sj9s`JA-ue^luBVnory&9bh2_z3M!=FgC$q4K)xKys{c1FD6cMO3wPZr(2Yz5xzZ|*a1XDj?OKN>=&F% zS{-85Z9(E}h1`&}2svkSGmCFA`f(p@pNjucO7d;$6QeEH7S~-fi(cUba~4#Lqr3eI zgS@cQ4~-&K8^TPXOc(h*I-kTEwK#+ff;VC6RhpIBq^~(b{&-cmGnX~^-~33mQ$A#5 zt51Er{mqnXr5V?sZ#A3UVFbzTG!t=#;P4glKu;9cIxMP%HPF)UhxXb|B{#e&V_hZQ zDneCdHw(XpYu_v<`uYV{2!kuaAj4>=*G$SAx&2sDE?shJQr=FYS$@Vhg5mOCTi9fM z)0>U5T62a{w+Zjl2g^)|dm1lF3CiQ^QSi2Autx7@^AV@ztJvi+6^1%aZ_PN#Jy zsjn#dV$QOr2nO=g`ya$+is_EnsWB2|xXc$BRtg~)uzn5En=p^{&SD7NQdOXQEIo@K zhs#7mU@$LAN{FLHHO8*Tr%j}R{?=W> zYrNyHI(x)9DZtU}u=#-S%z>^xf%0CRSAkO*4fAOj##9g#bg7j73237L*@2#zrnn9a-?$ zHX;j*>5Ccc=sdn!W_J@$@w}UtYMa->Cn?T>xU_+Gd{m#kICKutcQpwotI%I(MPHbU zg*ugd7aeHorF$Le3UW?VW3T(aczW-6w*EJ6SgWm;mZEA?wOV`Eh^SGssJ*FGBlZk} zmf9mmQJdN`HDcEaLhZe0jM|AAgzuBz{k-n`-*f&u=QFPBy{-|9MZ6JJ<~tw|fcMe? ze5nyRpu2=f)X=qM-F2Z-_?i>~jS)VSt6?6?Tj(wRNQ^rU>Ciqle=#g;rSo>||nGAK% z=)!^j(10a|@9Nbgzxzu*mvsZ4+RtQ7_na_&d;37npfP+Ib!N{4;Kvvse2jTh<9UA7 zwEPY~TECG0%_YV18Qe)b;yzQCE$aM~#n{5}=~V^70QefO2WRB(@_!f04=0*}D@^jj z?@2|@)e!uZ-LW4naXbR@fhkby((pMM+DEOh1=HK8`T(Eicy*K%bd;`)FkTiZ+6AGU z78#~Q`uO*(h(J538?;)D_JtOauN>&b*(7WE#P=ThuGT~Q7zvT>B>f-w(1_4OsZ|LjkekWGAtI4rF!rnkE_I-=Dlkez{@>&Maj4jn&&pAlSwDy~A$ zDr3C2mNG<#(0g=~W|GE>l?vQalt%^(o*4zz5J_xW5zabu-7FPe3R*bMd>nE_Qmkwn zuiUfZ;4)s`po07$K2U`}UA@r;h|x!&%z}1IZY0E2q8;;Kfp;~HhsDIB$A)NY^8&EZ zN`p5p!tOh)Qnv}C0*p-OR{jwxPCZo^bT7me-rux|g_K*g3me}CP>$MZYPPu&DH3B+ zi+!WppKGpXlNbgBLo>vRVE@J|EF8FlxxVELa4faDftm5t{@Q34G>VxPKvoTIpX_a< z<4d%GUMN=|Sqx7Rvr>~ot%v}m{;Gk? zI;ed+p-hqhteyAz%S3d5EFzc~Fm_?}0lb8|jc8Z|YarjGe*9WhmV3&!B2*$-ifCva zFyo0d$kL9bzcNe-EqZ%1F}5B=45sYC7q84O_R}n$y8zx6f}N-NtIywV&Q-tXY5uen zx6EY`6HL}-ht41)+ZzKfVJi?TujDzhOLx;e1k%5Cz5o8`NTO>Lm{PHJ9J@k@1))cK zuT@RvvCX2Z6zTzaeX_VHHq z%Ff4l%!0GT3I{}O@l`$}2>(8`%>Xmlo7VCS8drVxM{#BHu%>9}#A?m+#{;`8ya+{Q zgKUAntK-ET15R$7f4u`;bkPhj&Ioogiz>{peC4FHtnq|~FdxQ;6~wRvQQ(ZxvoS7g zCp^wu_Q%9!-pYj*{O)H+b5I3|0l5L0gl=$K(h98VwHgaVD3`V;qvq!e6KW)EGbyEol(}z2K+7`8AA-TGYmL9F!F` zJ6LJAhGf^|_z`%Hfa0-5m;yvQKZf%=gmwL!oOubVFCBPbHVz}G>B2Bg(d@$SN+W=ypP!Mdw6egm)zxiW6lsxd!cW5Ng{p{YwNfbYg0=}B% zua}{8^pUiPJ38N2lYoRxze<&Tak%QTMhdi0y?{Q#d0;Kqa_{XiKd%2{warYt=Oa{g z90eJ8@C-Z@w4@a~S{w7a4CM+o2=Qr#Ep|!mrZ4O{!S{=@V}O)z z0ABy(UNY$G*jd!+MvG_6>S^MouV4=7y9)bcrNX`Ew>jsERECzemlnn|Efaa2paCl0 z7M^<{+;+G#Sh8PX+25J5n{tS#+#gZ4L;ij|3aHe@-H2wtNJrn@NmFaI{ZKD;nA!i) zWHz5rc6V`)Ui0tV<}6W35aQY{k~BRqPfdF3$Hc>o6E)ArD}t?F!HC$K9ja^EN{9e_ ziYUF9%b_+mJyr0eA+XhM%BpD?Xr|OjcO&ZoXv(^~j4h1GdCUl%TxbohXEkP+I#08H zvW^jdRA!Z(cej9}1Z?onw{w5u@j6S>oM88u)xUO|OLLwSB8-~@RQnT8y3PxCzRS?w zeTF9hZHHNSCQWV{|NHw4xG%W-oG+nTz}aY z;rxHQM0XXW@nI34UY^D~#hryJU3@-`cFm}Pp7l|<&w>s z)WvB2LNSD7QD-O|YV#5hbG?@gnBZ4U@F~3iVAu(4f)CPAJh?EK=z}=~+-poFtEVw> zSX+-yT%D-9SMoGxdAG>@UH;g+`1+)I&4=dP*S2QslW_OOubfx(>ZhDoA4pF6Q@-h3&6WhKsu$>2F8|!(` zd~d1lcsX^(z@7;)uz##uz!Mz#O@}97k*!9?IB58ib=mA0H3k^>)=y@guq{=QhD@x! z@_nD+T}iQc`60~V-h9XUgC@Bi$~aRW8s?MkV&%4`{|I7ajA=cZ0FxJ*S5X>oAWU2n z)^iEimwTd&uj%?9cgSwKYYkNXgT3Wnqg{4W!i5vj;=4MYx0YYMdzHue{Bei-ch6O~Rk|B1;Zf1{bI)BbI`_FwD)04T z$k z6ppgC^U%wuU25th8{0Xnq9U4*J$wzX$tKPJl_!IdGv#N4@|zMuZ!nq>`s3ER%WG;O zgGplhpP#LC>~X8`Hl{HdQ%DjRZ~{ZCyy*Dxq|ys)xrj4ABF#4dOS;#9fnpiLl=jU; z(0aB@+c_K|2F?uMX1#o*HqoL8vM zHkLW4C{=uEK@Ph1iDK4|r*L7na801P1y%gM%>q1h>co3x^|4eVM9EHMcfe=<42Gvp-e+R||PKLV_sW`xfPOG_>0c`Vb%+WyYFkD&ZbZ<(JPxfGW9 zB^)LF7)O8IByQvyH#~68R^172&)OuF5jOGX|LzLVqLI|>&i-;opnN6j+M=m5+%z0G zyw3js$BF*<+P+F8vgBqZsJ48Pat|<``}dEg0>(BKX1(n|J>xsZ(gjj^Q3{Np;^&A7 zI)k#3_nHp_RX<`WWp8sKW`V7)-DL+P9=7eXa!0_D=M3Pl@-}#`p*Zp@&}; zHBEz1=b{(X@%YwXT}H(ZAiR$Cc2=@{`>eM=l5+9EyP0-wED~dP5jM%1;yriVCTz#T zqL^b%Of5~PJb*+Q^HgVvIHy~BwrTL5%BQlEtbWz{rxT+#N;-!Sw)vjiLrl-vcn#gr zH(87Ok??pV$|he?W)qMF)jT(ZQKCl?qc0&Q7@0Yd!GLU#Nj6|fUFevLY-s367Kt(e za@?l3vtT(^)UvI+8&iGyAK3}_?1uKK7>Mu8&J^76ggknhcb5mszcz# zF%O~-!vwKWKsRt7AVEsLR2$L#2_)c1~7v9R>Je}$ho>v?ss8rHMY zTqTmnm5u8ie?sLZ^w`qV?7So%5V)?!e{d_i+9N6Dew)1^U>l|6*6uo#`-wZ=@FS1e zujP>iZ}*s0|AO#IoK7*w<~FzUorj+tec_DIi|gQ0ap2la{iT3(Rwjsrt#YazX}p#{i+42C_L&SnRHn9a&ALWU%?2=btup ztvwd-KQCFqakfvM)1FPEpPocMiR~B7dTZGyxS12)pzts=EI#f=5L@zLnkT)DQnY|*^*;g@sGv3eAYm1~wl0LcX+WPPjapT( z6teISu##err@JVINlq9dCfwFTGiun)sH8s z-(Q^4UP_FB>hTgO+mEa8nO}D{5j1b+`osy*oibs8vbM$ZyNS+m_rL9qIzleTOo}gT zm66(DnG~!+b!h@%1i;b>Rr-4ll#o}Z5T zzP|oUt&fK!75pTo0(~F>XQ&E%ZqFa?T`mn zas&JWG)IpDFu47cQA%Uq_R6!{9FuQ(yWMY#rmilL_?|-+ZG0`wzY!jLd6Ab}gN;#+ zp=D}SS&u08E9DJh%Z*XOK-dSHKIL`=ufZ{+q!OLdyFHej;T z@EI%eeUep4D-LO54T7=`02kngwEWL`Ul+e^(v6`Fpw2i4Z;=^E?~(}MTQ(JUrico$ zVIVd(Q52>duahL;bF}e;g{QqX#mvCdNw*|@ZA&BRvNcF40F!$WH+A>Yyxd{otAkUz zZCia)y*0xOjQ;Z@A{dw7M*C-r2%tD7KB5X+B3~0esEtt=N!|B^+;{Nz#0@K`uQIS(EW$XC6fi{F&JOhCTDKO{~x$RN;T&<%x)`s+;DjIr!%ew@^EwE1Y&7^`s3^fe>h zip}d66&1kxHM~y91+muHw>#*|k4A(HO?}C0=4$Ye{8& zo{nj1+{woDn~8zH4%e>)dG9VN!O1$FH!)D-GxqR0i9c=TjY8Usf4|n;FBe!@B3_=V zg23O9iKaR$zj`R6V_H|@Og>H)tZQI63RkVU#@uq*A2Kdm>)IvO-Y?y}v4&kS!Dk)r zo-|If#vk`I{zovcKO&TxsI+4?d{7f2BlN)xAhLZvw_Ookq7pE)&2tt7mzW^2r`!JW zFH%Tz%PhMCamXp#vm%J0f<;WlMw)x6<(MdUtde}g?$eX`c^%mhoR@bf!e zUzwmuSu&tWPg@t2WrryRsN>U1nC}xMm)Bs^FjPP4Lom^E3wGG_AS;t`H{nq8mV9|K zCff=?nRW9@#ue{hH=F|cGQ9rLbQ|~Dm8)}N2uBGZNY_I^{B}{5G|;VWhU)T=5M`z+ z-Eh&+V(3yikxmobDNAy#|FW)ZPjU;>I>lGinwwpTu>a&AMu0Au$o_@(*)f}1ulcS- zDfLE3Rki%SdsBiia-snbBh-g%De|S#@h|92MK8EgV@GvE2E5fZ_1apU;CgGStVBSA-qT3`K=|W$HCsTg4 zY034J=BkD=8=I=1R?ZHil<$?=1{B%x0%xD9J7txtIhK%28fVKfEpw>G`Cw<)q<6af z+zE{_?4NQ;p_gO4&edEF8e=93qL95!URyMTAAYhC&50~t&yLWZ!1cBH_I1EVdt_*O zJwb)@Mp(Jtk#Ww{I#cV}?f%hqbLt|nNe^Xhw%EJ~mbp#ktLD_kAO7`Yyu@n*#%Oxw zU>jH}gOQ3$gnA(JE@`Wc&&|l9_Vme!R12^2n$x%IQm5b?xu2$BZ7P+5OLU6MQ0Ibg zSNbavpp6(4c;?6EG{j_u9o~1skb6nQTokC27c`Xf0_)%Ii)SgFU2}qK>lIE5t^W=A zd^3wr+sGzC@UWt(L!7)C?V1b=w_>2h5K=W7~u^i z#2`Lgbmj!4W%_J`ZZ1o zDU-6@+i^1*h#uH%Ll2rdBw#2xP)V<JzsRzEgp~dKkAO0VFqnGq+MtQE+whn_Yzj3< z!kqfM5BOdJ|IY9P!Z#hnv@~1>1}eZQI?_GAAU2y^gZ{3Z?V-x1G(=WPUQ*tREi%&U z`Ow6N)I;yA!6JYRyWLVI^nNb=w}(A7;cPc{c@^ejSci2Pn%$&1#~_B^?M5%Y;@vTN zT$*l(2SMKHc{r;{JKe?^y|Zk27CNb%7r7TEO_$Zzeb=K}kt(i|>a?3LKFh-JNw|Hc z=VX%(R%mP_mu?}17d49II90W2LN~Nwt|B}aR`OUBg~MKv-ZjG*#=Tsbf(CN9dyjHo zP1^iWVQ-7c1XcR#OVjUKlz%U$Or`ffjWrNP#Ze;KdB=+<&nuMqs|vNBdcI*r1L1pX zrz(o3$xtD?;q7!0PW2|;t<>v3v|YFt*y(lV7=K66@I`fX>dS>d#ND|JDQe}94~=uk z)7J!wAA%flfoS&QgVT+FIQca*qpjL6SNUI)rOQSMP~|y(Fh#8VO+fLV5@Uy!+j>FA zR`;D@vHl~$-7s+=5@v1ov$A>m_K30FU_6V4VQU?AfblwXDZTk={@`3YUBy#0+4gk$Q64s0ZM2Jt z3p!T9IyiOuIrs^*Of69e)(XuHjLMZzA@+pZvx|Zq7$04ld?Ve$P(#@S^Q|Wb;rED& z1;||BLl7mbAZqk`j%dU5QT-K8<@3Zr(%uqndO~ROh=7|X)N$*M#Rxpuy-*Wys2VUz zR!`y$650DC-Hgb4<1Z9fj%X$-@(1&L1gT|Iz@ePXKl(^gqnG|c#Q1^SIHQTPKNdY{ zbg3~#CfScT~3c-((MGxPT}jW_Tgzau0^99w$Fg~_^>nJW%q^?saR&0&U@aRyCU}O3--q!P)?hP!7XFz!h;^?8i;p1`Ro*pQtnnqR z*7JAXz(^GSwF{yPu37?naITGZyp8dlHKvq{e+=;4O~~L{ak_Igc+ouBc=nhTd?1o3 zgz2v%XmS-0lgnR>y?0rmbRjiW&mVF}j-ieeEptikrH-DtSnz+E8dGY=_~K~OyIRMj zX%b##yrVs3l?19tH&)^B9$>FQn$N*%^y2 z8R&2!K4h44abcM?Al=A_dU$~-9c~z#Ywl^v91F4AIHuPu8-`h^RSpKp`Zj&(0ZVkh zVbS#gcyP~DiR6+lRfZd-3Uzp!fyi`16(anLyNU322{q0VRX%}~#AT*e-g}%}?*d0N z;JWt5TT1mNH*L4#=M||$d=I7crm1!3S)#0da?ESKIM}4ic_%|54dIf%!x4ZoVE*7R%V==xmnHb4ck3*wsc+-S;#+ zfR7KN;ytdco_`X?u4Ebin2625$w5_hmq8Ttrt0s7*cWBJke6&sL^G{WQY@ifE&{e8 zf6wf#ZTzgKo3WBUH?$a$F@p_8@BK&67gufSka`$Nltb=ElH`^xiI$PPj_G24ayCr) z0Y2HLRr=Sf>B~E@1;=|WufbPP3Ep^YpY(n?Xs(Fc(T|E-v2IHPIYKuRWefJIkbXH~ zv>m-1H1A!X%(mpxc`@=f!yuG3>Bzd=lu?W()Xsx(^Q&MO(*Gt|Zd}C7|6&HIJf=UW z(U?eLN#NVHR9JJ4IxRN`-lmpdp&KSKi!oa@Duylrwq1td0tL6#WTI6J-C0G0T829n z;bnA?v*q!t#WLCLWkn44U-nn8%TMNa&5&X31mFVG$-auRNC0m^-I|%>_kspefyCVO zC;-7dPSNw*4ulzh=jF3PtUMy2x*!2owV&wMUn!YT;Ie&5uLSp52u5TEKIJI#n7}6Y z7$QvfNbi>c4&&V6FPMlPd|UnF^Y?jji_*~aXmN5JBM;;f-ErXvu70=`_G`{qg*o+l zq*7+W_A-2F^VR-f=^ppO9W(Ae+7}Tue3V;!+iK{UE~>7+u(v*1r`80+5m7DJXZVyk zGc?kLVOz5^xgWSYN3)+1Y~D#A8M!@HH52V4rtqlHC^u7D zO{7}#DT?4`cCv$}?E@;NFH;W9m+a2*-lR&Ig)O*-2B$P^&b!V(PuXkEIh7Hpzp^oG znwq+OEOm>N7^t#W{l3~ElJu^y?kU%HiTdgo8XAsQr30KXQl@Hwd~Z;Z$}2Wo?;F1b zgu7ZeUW-Bq*EQ#l9v3ux!Ywr8wam@r6C(sA;%EaeIgF$^>DJ0-;VziQ-XqDTWkvgwa$s16xI zlz-T0IaeGTybk-06MNdg#GKjVTMllT>N*iVB*lyJ;%C)7*GK8F?T^>|zD#V z0X`lLhlRQiutaiVsOEN=>uytiUcj0J>{Ue~(j< zG9lg#YpZwY#MEFtKl5d5+nkm-L3+H9@1-SYjX=fnj^C``fAuHkXp3KU|3XZ(ZX^t@ zCFDX-Mx;mG`{1GK?a1%<&wt8o4#4<>qPeZ25wKA)2>9^Wm*$Af-LRb(LkG4-T++oM zti%mGZ-}w0NM^WxiR8*a2}i#by}2Lro9}1;luiqtMSvq*dQf#lVbErhrEAv>yYD!D zlo?Zi+Bk!@Bvriq$c+Ah=Sn+p*84vKrn}*sS4JXn<}%OiH5OOs{S<1sLPvB@b1!A9 zDe;j_sTCV)3gc~3R6Uoown5{8!f7dm;xuuJ_p9A!CCW1l5H$4xy0h(Xu_0!!M}72> zBlFz3+Rp;RNn?tb?cRBB2L;Dfyq(fJw3|Rqh;_hiXJa7ZEyZzB$*x1zw25Q|eSQ`_ z29$o7x?Z|yRjamC&NK;|d+~yEKB7Alnt9+7-Arw)Yq1&oaG65V$;zRE@00nDA0E3x zo(JyXEGR_QD~eOZa)YU(5>rUioI!WZO`BwGPdI0(_PfD64D_W*fL3ckZ=PRULHI3l z0gD9E?DQC~hVTL8i&l|QY}Ajxbd4PJnebwxv6ma15t>mr+j7UCr(;J|X9Gok_!$tt zqLvR|1a2UI(*W!U;ep8;w?ZeNfPjt|AV)CSuBE)}`mN-r%TuzWnmh8&JGz0%Hp6zF zX=Dh5p#{IPc+gjAm`=zb-m6zFG%?h9i-M-vb9zHkOeerCOF#93fe|y7C;jVh0g8f) z{Z~6{u06x#Fw2R%*L%K~8EiZ+7e*#GPd7R5OR&)_l*Fr09Wd8Jd+~n_%&HI}Hz7h2 z{0JNbZj4dq*kNy0KUplad6<0s)`coNXZqD1d|O?=7)-0a&P+JhMcy$D zdubB&gKXKT!X>di0z}r=WD%Mpj4jFAO?y@hmO9ALn;mI1KA%lWfSR>>1QQz3A$zKX zl`(;MFNY{Ul|v3ErV!0rauJkDY+@vzz)nBlMDeZj+=}}&OV3`eqD0O>vmDqR@}sOz zuTcods#fwYGV{=lh9yL#z9vMrZmgWc_%n)%Y3xja`#*w1%gO9 zh!N8Jo|;NY3N{3&W|&-bdjyqTf4vyXxNS|#6Hc}hY3@k?8Tu~xp4v)-0W${>;+wh1~tm>TlM_uMP6C5$hoq;C>Qa3*Ohy zghJkXJeC0ddFW3Rf}?twX@}3xw2Ob_YvrF#lALTm-xBD`-PIV#b5I5mS8G+j<8}J`3%2@>gLYJiDIWeVQ?(R=SK} z+kHHmOxt}2AHtiKU8?d$28#by3E!*bE%^I2SjTwWJNKw;uNT_2Ryu4|V}8KwvXig0 zmI-?N@iL7oQqozXoSI2s?ChUSL&Ico3tQj>^|R~Q7z>CMM$7C{K)$r>j)*-t^{rF8 zG%)LLpeX;LL@g|7`M4}z+(?nl$ z@X%|9Zh9Fxuz7{N^Cz!2_yP942tmVVrdn%TesjXoX>FMkU<%`RoM8QPLfJCoUpd3C zbgnZn`<5jdyOVa~>%w&26YVUF3U4MAKaLlZp;6IwFDzd;zpvQU<%Oset&(q=cX0%_ ztj&DQd1)H)AiyA9R1|+JeoA@k%ywt3{`;p*7M(|D;@OK_2e~H(w1l~i$YxAK`j>+` zv(9fGBIkKBV92*O%?_J2pK>aY5%MEQDb^2rjqlzSjnUF&)hC zWkCSBZ{gBMzx><|{@M1Q++okvqjJ)S6Iecw-PrNk zAjF%Qdhc|ow)wen7nK;$flDY9#AE|m#?@ynA(@pM#@-UO(xVM}L7>&IRiyr&e_;!7g#AU|$H8k6n zLEcaQYMB*PtFksq`dAhBQ;6%DYljx+Za!IzW0)2F!^a3hwzLI39^0aggNrSCy9tIH z=5zWe?}lyT5A{(}h}h)TDm-IC#uS}asIRz2Wdn`LbomiR=Fwl(tttmor9{kMUIv?Mc03K!5qh zA97~E)41}siJPO~wKc7F;cwn}iH$ZHEllrrk4jt4;ijKgso?e%w5g(&Wv)@%QAnQM>E zL!`(5No-i;rAR7%V1-u&uhOBu$=Qar;f{$`;~`gGa3ykbrKYmYv^?`$*O4dw5|ahk z-_L4U^V)Q`2ng>ULv$&|RR-y$TM4Tk_GxUv=>J`7B(Ixk9HEr%W@@9CGfWh;*pih- z37FRG(6Fz^Yth?#`zGUBPF@uOO&g8j4xW-PN#7$S2){fzOtkP=#;J+*Mw0)J^lN$` za8&3|-s_h=lev~US$*&eJ)Fe8axC`oWkTw!O$oL#8Whc*XU3`UNB&0Bu@tChI7Scc zwIujr(P>x4&dj={P09zR z6ig%9zcLw#wD@^?WzeO0PadL>d6k=opfs<9DT>7$Ib$C zUa&l=vg6Kb@1Sv@xBMHL@WJDqmgQAfb%fq@Xk92Yui9~Y$8!hnFJhKRrFZtyudOw( zb$}_Pt~m2jLEuDZ?8H75otr{$$*69#Y!W@Q}x zN?`q}Oqf?q-DwGpI262_z55P=#Rg9F+jb56PqF65smFcu`f6?etDK;Tx#d=!YPsKU zC-eu>F=SV)d$}c8vVBm`_3Mq)9rH+gV3GBV(B4Xtd+GlHkMy`^lJT7V<6!1ZkZ4m5 zQuzyFAC znBub_Pm7`|ugQwatEG7Ues|x@H>}=Bl;j5FHpt-8NiAccI*8tF>Sz(e^d!~!O8?nk z1aPDKR(llt7P_yzZY{Hg0ZWQP?p?&;p1>$Q=@RM5q zrJG}0Y9?KGCiY1fOfN6OlSL|$al{k1R~%Q!qoo9Lr~AMS+=vSQa-D1zUc@w`XslHQPQ#a;s~R z?Lf5>AbjPOO>%xWntLKYbEB+&s~qsu_nB>NOx;$HQjf4>;(R~GRPN%E^_Z1oOoqV< zwzy*Qw|t;}rzJjjqW$-5+q;te#Gh~}ZqQFj*XB4)nK%J>EA@Z8? zdCYOhdsuNn&Uk536+JVhMVL*IBc)w75^}Nge2|M-HxTYmMgwXv1UZ}U@kU4)t9MS5 zF@U*bBCZYfG51&ZYXy|gV#IM*C;pE}vaFlMna<;G(@^47FIBw6!1J=*FIMiI>5UfYy*7mW)yBB<5Ti?C)gL$*hi%KTuDS&DNFbj*Jhw2$Q9o=PrdHzC~@=;U?S-f zNHke}KztGju>@Id&@YlysJy^+eqOuI%Hb}bICzDjYH3d^TUbuM%=g@CCzFkip8vwA zlreLvl+SR?tck^FD-}90@xjNBsz$?bT4IHdmzk1yn_0(_P*n5w!BpOHTH6uk=r$kB zH@LA{7%ff$mLX~;X+~-bs*s8i8|e8f&UBL|I%;9xqF=o)cB7UU`vp9G#puUc1*VCO zn)NOCbn~$_`i%xda>HFg9^qW+*bUb8-6#yQ}=v{e|O#IyMX26h0qROX5!1> z0+R@h*k`L>;1D6GB2wboAl-!TZe+p`8ZdTkXw}}7#x70vc+$&)?86J6ixsZ+=E&Ss zX;xX1!~*cI|0<|DwcK4X^>?%nI$ z(DOi>g@&<=@M^$sME(X>r$3|W0MRrIALo$TgS`P$8F+2Ssplj*a{ zK+oj!U(Uek=LcLJ@)|kva+k?Y>#$z&D2Sl)RYxz7dyM8gva|iK$j*$Rb9;m(?`pqFF$O zoE~^PrrKX!9CxiK`aZ-FiKdCAP~|(1-}es*FenCxe)^tV|K_rqOh0#+?|t8+hYoKi zivtY(Q}+2+?5zPCuibp2dt0Z9^`%S`GyVinQn6*}i_RXc0ZwN{yL4z$k*#WEOr`OG z>VHo;b&{KOwN?dg!rX5+xWbSlLMjra&eK*NI^1`dVKBT#C{1UwC_L;!5@aT~p2oms zO}|=XZvwhcW%(QwrTK8qt$oekfKzl+HD;$@`Ht$B+CcFKo^G1=3qr~?*U^~rchaL@ z#G-3D0$>DkOCz(Sl05j`Q|>DX?@7H`(StWiLpf@KRvL(RDO&#qZxwzOlOZkx;QQLj zrbLC3LRA&k{ar&YAMSK$TidCJ&QVS*qRPh1^zh3dZn^VNd)O05BNv@_ozc_us^4?a zS7P6ULv1Q>ctufmyRJCb(YVWt9~;eK+* z+^=U0FUORsVS`!pK-A4sP))SxrcXOl!-`^%Q}C){3)!I&tw7tR4@?Ms(NNO=dqMt{ zgv(OaY~bhr2)z8Fx^_6)Bgja)gp(?2e!HUG47a1VH>VCHVMc zxw>C=nw7|`tY)C;$)2^B_%X1vdnju$2p$K{k38-_JT^ZqHClx{90LHa15qZ93$?sc z+MTNOhKT3oNxW@R>`Ei)fHSGHlzsS{jx>ciETn?nR+=a0D z=X?%jt+Bc6w{MaNkd`vAo6GBPrmpq((?7y4}NuP0=Q?K z!ical($*Rd&(~YtNt5MOx09R;DyR_rUQ6zUFd9?F-lEQyn1Ct9YClG1*=(C1PTLTb z(Dr(^^NpRE1uF`v@z2f6Y!%AqU;9=Bam*U`UC>OY_`c)0<&t?Wn!OUmGUZ$t`=UQ- zj_}*<-eJ+OW~vr|$HWloK=k5PgRJXW$!_~=HF|Y5T?5YT47Nv)t}(pv@PWU3$_%yDnKOj?g~!LL>a+Yp}Acrm4>+ihA#?%b)M$Fk4H=sJ1=RGe#0*&g?O zxHzkh_OmXd8IT_A{qnneG^tiS=NLt{DAm2dJ{0kBVrzy&XK1$8Swj*nXe8;{#~&%f z$fIiia^{Y5ypYA>Q^;$oh|5oxz^h)rpa^u@x|6S)rhD0*e+B2z%m!n(tM#b=xwBGF z4)=7`q$=7joPE;kdn}u;5_!tSV(yQdQ}Yy_29ec2mpP=YzzQRe^@6hIJVUI-T2;eh zOh?1s=Pv<^^N_`KG6?fJ)ee4)YgHw7))=JDvZ-bu9=h^b%$d$d)o>&F-{ZYa?@L$BC{CUNG1o`yNEG`+V_3i3u~6`CBou zf7^n@*0-RCtXs&BD2aX3++Np(y=@2ltj8yGeLv?Gme<9DuXmX5hD^|~G|^`i`{pz3 zQ*gD`Kl)b#U)ov{S{tV(e^eMa7OrH+{IZ*xhRo9*6|ONA>F> z+MuwKkbGu;a%tvn$-DB|=PN6#so4hHo6ml_Y8{)GIAA!g4aZ$v=nORCL@JTzqU#L{ zu|94Dq!VxPgRUt?^2ckXH62gNTf9pSxsLp~7d%c73IV+x@p^2X-h~M>3FTR;-|?h@@*RBG6sI2K zGcs`4()M@bNT-z{78RP2&-QI9Wx(~LyAh!G_J@x=OJSo_l{}uoKYG2-9dFQFNqZ{%DkbY*|2M6Bo1C)^`4E~unvpvR`1SP-4Niru;;M zCh6Yt-9Ue3v$j}`kGhK0!`FYI$Ee(yIyEw3Hy)*Y>kOsjp*zgQ>xAcAHrfiw%Vhix zBcn(0;iUtThilG2IM0aK^b?DWVM1AVjt_Q!m7TUr65}POC1^4}Cm`9pO9zHkHLDEL zBv3^n4%WHTejRD(JGt`dY`mMJMCE_?UIl0CJH?LgteS^!SLDR*b;)S{M_}g5?5YNi zc^msQ06*mvT)lx%kDs8IQlS8BE74jf=YZt)dnq;Vll`9GJaNmt^}o(yu-S-LT`1;X zAa7o3yM0p}=kt*an`Rum%6#k3y>tOoim-lI4nM{Pbgbj{P{d^rnekloo%hx0+_k4r z^}nS(tst5HX|C2^kcC24ymuupV?^qgZOzncy|DO{SK_<+uR}%u^}Q3%ik#q?ZgTG9 zGUOFrfXUl?#=4t5FI<(+@#Zm5`=o9X-KB00)x1&?&3FA=F8<(->YrTLr8s94$XbY3 zBGhZ6il#UA%hr#^RA1|v#>)B$6psMB&UL+`p5>8@s7V&{zS;icEHfyd;hY>OJp)KG zwQ}<*WTEeI+(4{~G$L@k6SW^|VF*#X&4$aw=8&QrT8sBmYxiGDdUP*pd`#B)kWB=)cQ zR`W1&#cOLVQ)SM^T5G5sGRYVHn*~x$ai}XUfC2^`o_<;QtT+8f^*;jRLx>E_kw2)B znnJjhi7J424?ie`RUO;HYc~ZE4Yb^NXu&7_shGkuUJHBckij3$LCI5F-Q6B4Qxq8Z zVoE3RD#tP^Bqej|%=HOGxn=ygGCzK;>xF`F+HTef=%4FDY#Gk4$apC2?ZX4L-=z$~ z*1z#69f<#F{i(?Jm)8FT7a8}*aqPYU>xac}3uF86G}*mV?~6;A3~gAL?!wacV4a1f z=o1KM6E^+G<&a_p%jTbD%@E5|@$Z>TH}JJx@(mvb5E-&o^>^*vP2mjtY;+aC>7vXx zljGq>;qbm$M!i2Rvu1n|fdThTL=yUOLD#w?Ti1lSNbw6;X2ylrZPumI@+UR&cgEk3 zsmD%M>2WfP8|nP46ALR#&jvkd{xT1y;JB;M+kd>*N1yf|i?@AK)G`(9JOWyph36Km zfP=WhT7L{WuI~LBk%RgGW`978X5YQ7t4ZSKNN`@#_;d>*(k~X$Dq_?IM&~evTSRzs zx9bmyOtn41$eRU(H8z;(uL5n`Nr(XgydbP@-Q z{a~~0rrp6R`2p0)Iyw+Ht3M~8=}|UUf8c&SJFnF}6-)6EmrEa2xcApBWz?|=Yh7+6 zrydbwH=%innykWSA`ZcK%z0+vn#9VhE?8867lQC$#$T^;M<#@dIot29qRO#Rkj7@p zr3hNw>uCy~Nj(*`qbI9)nlZnAbgY&3y+T{#-!L``!`Yj|5zMcSagB=9IaOottml(X zqIhAzRORP0-cVJ#C;9!a0Nu53=Ku6Zvu3-y&yg#x+FWtV1zcaLqvz#*h(-g$<2c!m z|G(FNR>nxO_sGuLdwuIG^P7`$CYpy@$ZVBS@#1D$00JM9kV-W> z>QfRXt~^p#Nahj#k$ji7HvOwW(!fjaScbDc5MQh`g~ceoe$SHc?xOp$D9$2%8!z?h zsd*brS)0pZ?ZBld z;k}p4Gv43T_XU16DdIWjwxTOKk8B+3w(w+@i-s}fl`CJ}U5eVGHK$`ABGO7!xH78U zcC(Wj--&Y(H=gxd8ri$@xxXLB`Ab58(03}8{q(4i#z~{cpSk)#``a{+69v=f6q3k7 zJNSNp_`si^$G>^Y29)Xal;q9&H8PPSd7OWuCR0d-Nmr6WL_ykahSI^}%NI?f0F039oLn>9%>K z)YT9w8AB)i+L*{4{ygje}UeIH-u9P$P$eLUp+Nv zgP-I1?+v$b+jZ-ua{-Ea7CYtosz8ZKB> zB#JuhFbJH5ty$yE74A;Wd$n$iq-A)NqHGhc$A5qB{&1$oLd^<7 zIQh@4j~DFqXdBi!AtkeWsW{Lrg0>AW6pe3kqt@Fwlt*Y(Xc%G>UtY$>hh9}JDo8Tu3#25cp(+gx;r+f1yu-9R(&_BE!m;Bb_!!48 z_j2)6@$*AF>5{HR1Kl{9X<;Z|bR8ZUet+Bh-=kh8#1d-3D|7Z7P67HPhkgQ+4Qnlm z3|EPP9VPHBquMMV`l(}u^c8c0@FxNMceK;zn zG0N$3bLAzmwZNG@%^KPK7jwQOF*|MZhGdVy9OV_O+i)u}i4>nX0dGE?XI@eld*$)s%zY(cuti#$%h$M@3D5-~?IydG-2NME zw=(0|;s1fOw94*c0TR~@Z?~meb=`k8-K-=+5?g|c>i=l+mevmyq{G9*So;7R_EAMy z*b`h#ZgWGg@jN@0C_eT5CpJ-N(08s|foyQ`$K|CbNUtiz+zKw;#GWUy(Pf{_%^%6N zrhlbuq_9#GlHOuDGx@Y2Nh?9o-q%H5dsP13NR_B5A$9Dtt0Gr^NRnMkOa8U8KdaF8 zJN84*fw#4G3bYRDdY+IO!;ILSZR@eQXv^~2(}Uz|ZL3u}DWfLStC% z@=Cq{&c%6!WgHo0#p0QYXIF9Br)oUwmAcK0FEhPQt*}JzOdlLputEcxq=)A z{-;dcPt%{gOF`y@rGEm|N3=J^@(KQQRm##*MzEM?B#CRj`AA*L=f@-2nNZ$f{~o$m z`#&>3!^d_%h3cD?ieAH0+@H57>m%fi_yg4{oo8!}${nIv-5mFuE0qn6-^g7%Sbb}@wJq8+IYlX z+nh*xtBmzNxYqKV`>cvvF1F-+nh1nlx7sksIF<^q4uZ(R@biI5L1L-H7jfSePOqK6 zXu)&kczT^36(vcWO>E0J?&3A;@9~ZM!co;jPF}e}7d24^D^ySIFFTREcX+d^c4%!+ z``KRTe;m!K9HvkF-jwu=DTcb8m*uI(uVY45RF1BU?dsoK1tWr1>Yrr=4Zce~pOEq> z1efdwSmZdISe(9q$Q|<6f~)OzpGoLZvn06&`uGdQ_Haf59bNl(l$RL0y8ZWFUTjNF zl~B}f`%u-8!kL}swnVFyBAqAkNNmK3fxSoC2dDU#1UcA-{ssFZ5ADi(P0UOdjD=EP zN=%JcjQA@%{Kj58j4cxp-%#Y2srqA4DZ4U%*Y9SQ8RfF<{XB+VG@Y)$iy}wh zPKgcf8Hz&#A^dZisxC>Zief7V668gQCkTRzDxazM+UsA~X)j0Y7Z0LeQKU(pncRkc zthkioD0)FBHl^d_I$e)nKj|>bDWSI>B}eu63C{JLv~9UJi~kw)@Ur?v1L}UyOS*7L zt@BEIRZrHFO)#R$R&!2fH0OcI5mTL0HR*|Yeg1672S?iT5pSz+4cV@Fl@12>&ZWIS zj8Mr3`Ym!Au?3^ss`}CrYEq#y>uGJK`Xfq5c~W>5Mk7=)p!iEkn$mHwtgd~|G?whxT;VA?+Wk{}h+-?TZ}Di&7rEvSw#$a4e5Y8NX?NOn zlFXt{_vg@ya8ky1h(&eqACw4t5`@vj7 z!_0ITj_+k2TOg^zO5W^pvB8T{>sGS3!X1n8=CS;b?jj(_S`A6`ZDYbvLCuqaV4n3VolctnR*KDB?Pc&19w~FhbWn z!;xA4@s^&m=_i=mT>^{%AcG595uSxX*V)}#ok(nMD!&#A9_u*1vl{MPBB3J{bO8QMl;qdZTMfnzC*g7cn*44v|!p8~8QLLh;0=K1Ff2O1XEGyHm_@iR@YcP`Z3biuZX3j1&5i$gL$4le`e{l;iy=F zc4XFOXQ@d&d_=-=Q91Jimtx@Wu4nYTmhq^+{9o<5Od9^bW|o6vIAE4s#MD`(Dv6<= z)-M*-Mow%kfrP@fcWnLOE#wmc_vJKTN3H5>w zRJ^RAwBAa3K~Q}{fS$9SMPLR6fA9xbo^vu9XN3juE;Kl}%SIB3NHuI=V3V386v&qR0lQ7-fsSVLiRG5`E{j(!=lTtDm~xq+56iq>0E|K z&UH*Be@p@lMy7*TWQv2!0zy|hrrXlG%%TfcxcHN6hBQ!2o*fR)g1 zd+VD+)+;o=*fh=GZZGUR*;8jZZ3aC>f$uJ%1K*{-;;--kyzU+Su1ambIefj>)LYFg znL$;Gw5*xMFTc{UUL^*scB?lm$zu%%|AG9XyJtH!%PAM;Y3Mbwx4$dDVkFk~ZHmA& z#$d4zgN`@GZqS`4YZX?4SJo;YR^RmgZYkuqzV|(=6pm>GuE-Si4IpER&8wFyob#$M zDqNb^Oy1RFl^mSR&)l&_?(FF&*)zFbt1Hvto+$khy|djDWafuOmVc%jhu3mns+ zc6+Evgv`6P|L=P`!Dx*nCZA3ExTRcixRP9wz$C!F(h>G{a-j2c272;UU(Bff%SJz( z1rWh5((>1rF0%hJhc#fmyW|jVi2?fohbVOD$aC3|Sa|4QclR{-GVqnWLndr#<#6hjTKl_{vXIIMAP!u-wQY$cCmvEwP^6NWUUh~i}WbD zdf&j!$6;{3A#0WM*O?RLsOOq%!$YD#XMHKky9R$wG_=>BYuo{*>gQQ>a7kS9qVo8w zH-{}B1<)cgdhZ^=FHvTj0nQQZm@OdN{Q;9!D0~@k_KS4b)+N7HoKXfbTO-nghj#sD zlSva%uV@)<{O&-hK9N_*GjdzFC3U&cf%TdC^Bn*dUK}ilmL@2U{!&kLy>_a@^}JZSmCCGeM$| zI{NvE28ZXr&KF1A9azA<>@f2-R*lBQz$AVbd_sas%k)ndLaS<&`KgH$^kv-e^3M7; zac}y{45kjd2~#d__(h{5qQX`w!F=_lUDv-VEsT5&RSDfx+e>(32A*JnO?ueR;N7 zSf9?ja~l!<1nJZ<4B@n!#t6^GCfr=wmF*uWtr}Q@^ioqIn2Uhh-Dx+EQCC2-@;}+i zo%GL}(Yq?VRRqdW@))9t%f;A`o#Di(9f%(XJt?K@?zO%eSfD3V!m2gnCVg0VM(zt~-+5Swt|dTmkyegjWe zM;!0Miz7}rnymC6XiO?^2a~CCgY@s*!CaP%@gTO~=4w)=*Sr5fR}+O}0MGpYI_H(^ zR9-Pe!|rrY78Ggi^zUli>P5F$k;5O=%pK5GGw{Gp1y~)=K+@ z5=3BUzzzz>kxuvfPvpdp+2(WODu3-4c!0dQNGb z%ar*z(FSa^8nToi>*%=rxKRTki(~dp1XIK}uj0XTOegPIG$?eT?;WfGm^fJ~AxfNM zD6+hRCZLxipabATWUAHX$`~&IUj$AKdL`S^b{YU|ZevuI9 zdJIrfgRZ~dha%GfDo~*1^vx~l4G-i9Yq}tQeA?;0<%Wf}_rp-H@7trrC10vh??zSE zeG8l$=g9}O(te)Nj0}u>FBq0U+$y4Hk@0Q0)TnpK6{;or2V{E2vaSG!=G$6nm(s3! z7v_7#`d(ref;czKsJY(9UYZKC7pA2CV*sR~<0*zHPZd`UMG)HOY?oGO9t3GrU{d++ zv>dt^OO7CbireJX=w6P+cMzgou@t@7KeQBHJSEw>HW5u-@qw5EKSj2WSQPxgjm{}vS z0{Xh+4qsX>W1`7LT;M6b7Mg1cO2OVJkC2M6UWCd@6Uxfr$3`Xd2D zhljHc5^wW#(=N3So<_fXpST9kXmL&8OFmwG+`$54o9ZBg5u)bcpCH=qMB>HR#v((T z;6-lMg+Jx#&*f>Ke3hU*CETg>ZcoQhlFFif#%xrA2a>(cx)W9oPVL1V%a8~SWy2vv zK+57DgSSLYo7suU3HTEUB*pAayONTdO=???_(;+(?= zpg{}iu>sa`6j<`rFcT`Y1IkGYr6sNtX21TO_VE4^3CIG-y1(iKj6sB3ai-oGX>LSZR52$rBDR0~>?2Z~T8JhKqx=CELoI|qRgwMwoP5>?*-mqCf znnd}VSFfJy27|GWJ-|j)%XN=Hp0DSJ0Epq^-O4l;Ok5ipeb4>HlWBYER4obX|$QxKq; zT|^nKGgE&+%so5)R;w)!=u9sT8vEoLArR($e%>HpOuuk9^>&~|MhKNZQS^VJyRS&w+xJr z;YE^m-#yZva{nDOMu5(}GU!bDwL+lyA#-Kp1sQdQ;s>ku-GLzdO+*5T+D7AHTmf2S zoi!5Txy~EK=e4lf>oY#y@LgxZY6?5@S&#kisf2gF0NWZ!#*%&<9k6zXe};X4Ldo~J z?)tc4pVguyQ`>)5auyIe$u_Q^MP0RF}Iw=Dlu)x zqQmE`S02CKP)Sf^0|M{)q~23dt7=k=6Pn=`Vl#`YiuA9pT-?`O^$K29jMqPgq;eI! zuVhdhCrU@Gv|UbJKA425+OORy_dqENQA`mP=&&|1Kfk*m?y?`*oJqf*B!zHn3Gc8^ z`!mXiLml%XI`C1Ay~a?Pafm`535KlLWI^sK#_465CuFa_wP2{Glz=t-`15^p49Q(x z8wK+B`bmTvv6|A{cKZ~@4$G2y#J$Eg+xO~W@i)BTGtzXNfQ9Wqjs^d2Dex>rZNpv? zys=?-!y1~1KrxlruV$1;&V%ho^TEXvp;`lpJ-KjHxS=g_f|i^D0X%x1w_+dX7NTWD%YcU zI?K9C4}9Ez^YKd7Ds8hh5;ym*rI?6s;uhZJ0cy>}XfT2|{P_5!zBvTq;+i*;so$P!#3HgijdaYJ4mUZA4YDABo7nr)MtlgAZAJ`jyzsXdP)QBT>f?vqT=l_@2L8eNEz z>BABOCTT3R0|zZ(w&9?%Xo!aQ<}%gOD6@PX<{4WyvMeK%e*YSvtvfrR!1n%3_7X?v zqkU1-q>Rgdy^_F>_J!37#!6!Bn_0->st#Ao!>xYb$HmJ=Mdz(s_npjlu9ftY0-e#l z%o{m(mx*4Jica4b_P&WkeRca#ASTV7PD{r~tcEL~TPV&{KH;)yFXvCJp$M08@`**Ld zAP;Lenqph}lIs`s$O*Zcnc?YERp{hf@L#X9jd#^dB^!CfXi^pANl*nu+SPUa=cT(mWL_7+9j+ZsT)a6LPIFWtGKIY~vu zndXEB&k^Fc0P2guKG<>I0F!yKp_K=J*snZ7y-anj5Q$=)e9<7B@LFBU;dh+?`977* zJ@37E*x4nZQJ%im8Qdm@U}Weg2dKuyN2b%B+b>i5J>P#6ikD*V9rfw)1A-1wJTjAc zGk)bsT1`F-4$aW2cR${8!UUGVJ@(k!Zu#j8(2*Moa=RzjilrIwieXoGX z7W}9pv~}MqB}OSNoquU*3`J^SSTqcJ8cpe`{nU}+l!Pt_?=uKc*toPGZ{sWTAIT-D zMQ)nj=k0iDXD~8pvo;%XwtKVB33!hK3HzR^qyYyva&uz+P+-b%29h$ z&pb(>M)4{2JlF+4fCF@U2ILHCBZ4;Xs$sYr)kI#_a#`IaI_YmvtP$^fD6H*0ekLhW z2r_uwcV}DXYqj0C-&u_U;-d3q`}NUCsl_$;{XKekUz~+9eZxTfPfu#~LGIt?_G2QX z4IH zh$$lwqb)ZeIBG=RD%BxFMX=N}7b%kv&vJC;#-qq*WLfsfpUv5Cdx(a2o-On`YQ-(@ z;zrE@im!%#hCSl)ksY)!=|*%|$qG6QBEqRytXM)=#rX4knJxHFY!b=(-yMQtM=$s# zIdUMU5Thb4GQcKu@)#yHwQ!<0so6XE`a2t5g|fuRk1~#~rqLy$YN0(7_PdTY&Pues z>Nti|B(7nV3{I_YFPv^7Y%f~bQ}Ii+*=w_pFDR7&4`=v!yZr`F*t!}3t|JESjvABc zF8x)wBh2>W525NvK-a#mv)Q~$-U3)6E5baAJn|Y{-CYDjX#q9X5isTT0CH%k|0`Q5 z9+YQ9y+);}Exlvrk@XLvHjUK>N_B8I#=3oOboJ#+q-O{>gps5U)vGw+K1M!oXs7;n zzXg(p>~(rc6n`aU8%ssEP;!5iuTiYpy6qn*I10v_r#QF%vmSMqEnX5~C>PV4*lTf@ z_^mm{5uIqIBPO|+O>r7Kmuiari^Xk0?;+s>$!H7B8Mgq!T{2sd+4LVhFV1G@V7oMb zEFFr1MN-E!LtZm^qkJ!oCNMO};JFRm;Et!Vh!Y}4?emzj_>=`-nOCE9GD;h7Su8#e zYIKi*rREmE^(9BiYk4uII8Km9MJ8TndNW0{g)`>UFoU#+E7uz|7GAT!5$;4!`hrthP|2KI0#_t2^+&s9Em&^er}mQyT@mm5%f?$xN3vYcwVd@ z%m4@^JKT=`x2{ zaBIln>AV>wf4xO0ORxtCz76i^Zb3BYmh%#fED48cAQuOnZRrU@VA`l0$LHe0afncT zhmUnV8AKY*4<|`iNS#uW?)!jy)gIWIaNZSO5Yr5Taq6KF3&(kDeo789s<#kL+qp*0 zt;3eaMmCEWu;_idQ?-?~=FZYCM=4(hqas?3F$%#ffZhsJKpB zX5nW2gm}pkHN|vAdUUd;3}!<6DEo2tdrHwFuR?g+1Z;iF1S$5@la0I$E%V4j7a)lf zU(O~2A&r1A*}JhAEF1DTwPHbUFUr1jq2K%Ah}Zr)%jBZtNAf95+@+(#eV9zFCs`+SNQ{UFzb7%bgg)^3@mieP}FS493 z>iZKxO(HDg1PKVbbGp3)wC9}-M$I4FaL&?6$OUwx>C#_!hA)s+vxLxr}qXJck&Gir;=-ao<#)$KNcV9M+ps`XMfgBpNF( z7d+WP;b2}toVCA{Z8v!X?Fb_@#kwj9R==v@_Pgr6EB%_f$p*0%$#1iIu=$V4Tisoj z>hs!oXPKK=UW_=vHDLcu)y$$|Z+l-c-LV)VlAuPPSgYswL0f)kGk@4rb2bn%_LJXv zFB5bqDfR>mbbne^pcxdW{ONc7U;IQF$6+f=jTDIoBQ|i4sO?&7m=pREJmSj<{=8Ck zH^m)=m+!6cOrErfm6$Bc=G)Pfl^K5@7L;W+<+gagNJD4Q#B+$)&L?GlLgaBy=`pCW zYhQU4iJ1X?28FO-_uy#e=(MPyzy201dTQFL{1 zx2E;b#vQfZ#Vf3uCFTv?d#yZpYDT!j| zRZ<89ze#Eh8hE!3tS1W3Pl*7s#-C}W-OpD1o&*!;-fDOU4f1Eu25%URj=1q%Rh_qQ znY6d}%dgu-xvy;&ulx-V$){_vMB%HwqyT|Y2uy;e_Xymd%OIXjJtrPEkR??0UC^at z7XY8>ynWpo;u1UQ6V-jkpO`B;e9H@~m$5|WzlYnxkw zFA?f>jiG{O(UC{A89Qp}__7>0yCe@vY@vjZr;RPv5f@?&%@A47^_@AU?OCt)v%>s<>YP3wh^2d9GSg{A@Cs z4TtagW+FbvtGFPLXk1k6B-VN%nhkWacv>`geGYs`0j&6&i+`Yt$*U1cb(}pwZ&s3> zZ)&j^$Ps!BIwEGH+4ArGKMa!Ab&-BzC%}BF(hTG>y$blW$6x>BQPZD9;Y~{`t05LD zk0+1iTnL}Ohv}ogR${EtQI+VazL%s&IR@HE&bD;%FBYPs%bQt1rG-g~1Bxt}o2CEr z2k|ZkaciCs1w>4SbnW^PJfXOg2*~ZoLWXE$dOsg`b9!hi{;k2)gSBbKJ;vtvYw!rZ zv5L!Ay#$vL9@kiqtMR*t3Y!IZAGfY7k~h@Nx7GH-qs326uOWV$P;hf#i|kLR3@yW# z4w=VVmXFzRd#-T0WfJ!R3^->8N)^Z&oA%wb+WHuuT$;6gS-56>u;nBn95Te<9D61U z`b?=I3!<`kH(hlev&;L*@ndaU&{e;))3bPmziu2|!GxFP=jsGsjW96O@>Lj(bSsLf z(3^adH-T!Rr_1M_cBvi=18rCWsOw zYE?+_9sCrJ2;)`pNEeZM(v-WuqBt>}vz0XxAVDMo+q_$Dw+|}_lw^2|-uk}lyt`tO zTC`+jbLA=Gp(`*bk^#=k6NaX#KYX&2pwPARM3>JQp?r{sJCD{bXr%Y6JZ#*1{@uHH znd)J3HWiK>zQ9R5=e=mq`3Vs$U>3q_Tg>9a6cXT9yr5`iTV`UYFh{o7aX6Tos2g__ zzSx>P_Ocbg#p@ukiR-@hcZTmL-BnGdr1O-6p@+YVW+-dLxlBH)Hpj`}Zu{M}SyaNV zAjA)y6gCg&b^$zb?dN0xjDI$L1FElKWgfg}l#-rHrPO>S@$dO~W3 zO_y~A%$w~0Z-&P7|F*g|Yz5Ia(z2BAK?Tw5&+>tnr2p(sFkqA)0}g&T@aEP3%Zi1qJCv z6oG(AP~S7 z`~yx#0CfOLLUQ^Ken`Q8vNL34q@-k&6cprVs41zbsVJ$aXlT!$qoJjzrJ_2=e2$)h zk%@_kn(hJ%Gb77+MkdD7Mj%k|8B(&dWMpR3;-ZxptPp~|NTHnKpDv?&YY#B0xP^e2arIZP!dw;snlTYVDLUbN>6s4TSAqb z!Pt(1$CFVqEHUp4uUd5zlS$tipOn2<_*qKk3oNV`FYyZq3JJrdWn|^#71Xb3Xd<<= zbx@{e<`$OMtsHLOaddJCq=cg`0|G>Aw??XR^C#R-oe$CF!FD$NaY;JAu?C$L!oazMu zpnuf*SF`_%Ui6?|B&4KJQi@Z(ASAxv7fMe`#w|g9Ue%bw&Xa*hGVBbaT4G*x(^+0A zlQkxLuRcmu`)i z&i+Bf28PIA;k|@ZuAwgbACDladybA(_Yo%mgL&`ybce;#>RnaL$w` zzz^8gF2&3VaHJ3W@1-SJdR$~Hasu4O^pE}p<=;zbhjn-ee=@`W_IAPx3u5O9Q1lKV zZ~{y@|Bq{zjw$COeTY{zLFEKU`;0llfXEaOyI`BU?k7MzsJ`^=7=l}2_zdiaIuXP5 z7db&p&&)rPNGlKPp{lH-5C8oIj=4j(fAo*R3fou;nr9II&kxE7wsFJtEw3X@=X6|$sSOSQ+>ehoF8TX6(0USs`hVcqQ&3!{QmYo1bk)juYlQTI~Vh-tIBl zQWW+BLghDjHJbw4@O}_5b>OUrq%x)#Ky;K5-W@nvMKX%Gvj?sv(LJ$JRP`sbRddp5?1w}3eC5(MGt3Gk8a zNT^}h2eY}+u-k%(^0wT;cLVpMO<%$`w*L7=UG#%1%OzR8z;ohsQ?9_KK5ue%4a9!u z;Kp~duZ*1M?w_`}q1oShO8K&3gK0Ar|Q zgzzu#J(Oo$W-kQ_@wkH5=I6s_@hapLv#u1z1G58}thA>M!j1!qdP)wARUY=~zDWv; z65lQ&7_ps>zEE|+B9t==3OxW0zxaY&k_?0Ap_iQ&kXF0*_wG#AH^^kN9PDp^oHdPK z``qGCm~jFm{sOsIGL%u^r#+R%v{HQ}xi%K!CVK+#em()(OdHn9>!eQrmGPr^u@itv zBdXiG=>(wHA$R@R(IQ3z+kbch%=lyWN7&R+=~3wqHefdiI;;5VAAxj~S^V6IVdMWimL{0_A)Dq+THLplWl%rA6ClC&Psas@ z4@1~{6#ka-Q?l>-SxgB@-P%#U;O8830yts*QNnnw;Z%q}t;HFaSg)zj;pGBoL!Pe4 zVkoyR#NTChgf3q4A-b7yuhB&>lYj2M6h})M?yrT|+h%5X8h^1(fKWdx_Hc|TlLQws zx$FsgHj@daxpJ*{q3~a9=GSaoD5O_y*2<1RpjG~=VgRF7l#1Uc$IP@g9GTJ3WpPqq zMaD6c7Jg2|u;>$j-NATJaCB3hX5>7C!`sY!(b?J7SjWzy>a3 zsE#B97IHHGv9}l494TXem6FIj!&+tY-n8xs@aZvVH^V)%k1U{toc>yd!Q@*fV_y^0N z82QA_A#1|&!S`PENsFixVE)1hkbM4>eXDzBx0u0BZHRiH9BqUdT8FtWCKkOdV7*76 za({nE88+uoXX6|Z|0pDSgF+J76I@)Gmo{k=RsHp8&Y|CbNQIiTIUxH3oCsAGOwn6Y zBlw&$!yY(}e-SwW_)no*plil}>hyv<{)gMfj_h+ijIX!Ej)dc>dR ziTK}@N9mx1Pw*gXbtml+GyeimYq{UISxNDlX-hXqa4lQn7zf3C2hDy{{OHPB3MNo* zj`;CZ=F(ays11=kjcH40&t_72Sl+$h)ExIQ%|&bd$3HocgR{mbz$=p(+WSG9Ih%=^ z3g3HU*L+)k%TjSrLo#HPOBZ^Zp$8P={H6OAK`cG6ATpi+k%s!9^Z#KUY?2}}TAvrTv;L1ryr8kZ)%3kx+P5HJ z){29`dMfdMsRE-o;zJ&VV>TQ?8^oD|Mvz`UAew$qT3SHtp@`=}uQ4R*eLnRXP=lOP zyeNpI`e3=wQ5kF&Y}EM8M=+F$QaT0wk55Q^1f}Q0+};9XGtlYZo7;oBr7J&-fvMZ* z&_!~dM{J(`O9Gq!c5-TjzOEsVsRn{aG{GVuLJ`e~t-o|Br2{8|Gx7V8n3*pivfz*~ z*KPUyV+!JoBF|5F&L4F^b3`E~CBX5z`~(;|H9vbk*mX`={+jew&L7io{V@$}tymGH zyh`E;5C&?wZYBc~_3l4J{bN)Re%`;MU@_RN`l)(!f9Oz{08-^=J|W{@NKae(3uzvx zycReP2>gZLpa~?P%v?SHc))=Yk>1Zj5pDC2S(!i-L8|^cl2FRR2!hcT?{61DNyaP*wz*v}ElAb*xmx z384QAY`Pexpd8fAwA!;)))GOqdv^l7**FDPTQ>bETrJJKf}XRsY_M;Eu%(xyKX-&K zxFEhmurPmbo;|+N0o&A!%b?Ev z9yn8FzF{+hN_<-9FJB0l=7P1#W0IafBb%`uI9Eu%D@?ROf(FO0_$hA?|EWVa z{4*0}rGvFDT4ORT`Y>xHK|&y-|IR1*L?+oN5EiaKWo(ga{4=RJJxr#F+ z*?(smy2y`AO7Q=9A1(Kn_$+cd7yXahTZ#>ob^HqGF1q8O)8XWs&z{?)wA{t(4$Ge4 zD8+>80>{*&a6v_O&m6t)AK$jPQ>QjNw_@rQK^r?2!Xk(HGM~_s98f)G3Axq>h7FvQ znl!MiU@};<4NZZHa#r2JJ|X@%8=O^aqW(<&|HPW5<-N6io4bq|!DaJeduAbjf{#CO z0H(QOt>YMq*iZxm7_K>F&j?z=Mg}3xmKt19e*nXWQ};3*lJA@J@4giH{78Cj z7Bj=~pJ0`3vM5?2=l9>JPG2NiK~W5J_PYq$qpP3||3=bU<8+EjVDUF*9QJ3?Cqeabc@;50@09w4Ark*(rz zn%~!>XRT^KH=MD)ky%D=W8G&vEC2O)As{=wjIiK6Qaui7Ts2~fliK80iYI@4W6fDO3INMjzFz;8C%eJ3X4yNSY;kpENl5;}L0VxB&Htc9Z?MjKY-wa77UA zPW)O#M9&e;w_!xBT&Kxjq9VH#M2-)K3f?CG^J-izabzGM@B}FE)JLr8ul^XwR%Vyr zEGu8+!z^e3A@@D89&B+EnmqvkPkGbm&Gd=BH&3;d{PFC)mvSSp9>$u(w+4D;1X=Ul zs0F(8TM`dG`8RcDE;sgOX~gmK5DnV)urvugqR|Wdz{V0 z99DO~C&r@|tZKf;THA(dN<@rhMal>{WI=f!QWVlz`EeYYovq1ZV7)TqT%cr{O(S!~ zMAu?t#op~_77`YZXTAt;M=y5w!-wu@_j^fJDV>pL;`AQv@!(_QFYa;CeZf;w|try0=A&>p3gR(250S5p$G`x)dQxL1aHq_X;s-h4Jfkx z3bep{XQ*RNSd3?mgTCWqq^Q)wSk^;92oj#_!{Nf~Q7jdXd<&m>?{iq4n}E%&;xVoM*h#~B@=q65gH8aeAdmL^U#jt* zCd@pC+P}{ppa_%kN3V_+yPg-RP!JN@EoVY%hPr0u?{Jlo?eL`imb}-Ki99S~x?>hb zn%q&L97$%#BdKgxTd%K^(4J4357SBKIWU>NBa-YTo_pU&IprwWxri`p=P9AeiqG}d zBLz$nG+6Mgt7;bK2vUP7xp$ft77Pj~z4h)~hj-q5OWh8x&Tr9N>-%{_*@A&?JsbM#vOj2-Q%Z>CkcAS}>$w%$*J8Hv4HnKVyoQ(xXrCerH5NHUh>c z5I8%|`UJ=%yaSqkqYHVw(1yx2byJ}i8SV&8W#{hZE*Fww1nwEW<`tdpfLUQuuTsNY z)Xpe|)IeBJ*+Pbg>hMxp^q#;AL8eE~qzLiC1>zBdQ4qA{Wlejzf_8<0nSRTAV6@X` zTTPd0SWg{EXO-5~G>yoTo+f>8Bn1k~QCrZsl{foXp)>a|0g6 zpP14k@IoFwE{Rs2xlL!RFLYm1_61#TAsd8D)6fcg-QKJv;bR9b;ri{P4XI=FLQ!*O zBrIN(tMK;U{Ue9WcGPde~iOszOCX#427$eE=T#>GfI!j zzYRK7xxF>2Lk?gMC|$YsS=t;IIoaRT#gz9JgvNP2kMq$*CXOr_8PsY+j$B49j62Vo ze{~JC*fU0Z*H);j3OvuW5PLAe;j2CB)>#{0wdk@R<)9@wEzkH!UE+nuVww-@jnC+F zaSc~)y!y_S9)nPR_+qA}`!z*9w~D?h)9|O>h%Fxc^#}=)^w*4+)P2OCm%EqlM*m`r z@-ZvmNyU;(#z_pv*sdpTJ;y@5+Uk$H%U0r?jOXc?YK!)kj9x)}SD}aBy8CeE>a`0p zu=7*NPf`Sfk1xjqrvJ*VV9?Xmk8jP=T3a_Zlxg)I3 z2<&+Ym4zV>6%>!g>k!MPpGZB|N+nPL(yZVPqh)~F}@30g2-ZKdZaVaPq|6J2ayDqDs{kdd}tQ<7vG{} zoSg|0&xcj#v(Bbb8HM69!xaW>TD{He5;+4=J~J0zZMFvJXA*^RW%TtA=Ff?KvCshbITU~bNd1H4^D}c?Cq==_Ce{MXwhY< zsw5?vfSD*(f09=#S9p?HtqD||It$+9XEjNZm+wM8wJW(2Ues6?PCGFr`%h{;vM*Ln zFEgoTmfKr}u=3d#9O{Ud?S8zl0hN4+TD_j^yel)q`Ee+(y7AapSZdPLM9^=% z4QKK)v59=+^7l*&e8`u&5CY;Xm~-eHDaiS{Aw0Y2!+UU+{r+^3&csK#AhY<1j+UFc zZ4~;+Rv)6CzpWP@?z=my>PM@WyO|72TJWas=k*6 z4OfZlmQ8XUd}|8kAz6orBcnAUHk+>YRYdE>ThG>E2J0Ge)sD6=qkfN>!(^qXN;J-cf&5GijrQT)G$jVI>%Js0qqf`}p zjj-PRxTl&Ln#H*uE*e~vdZglUE##4MlkxDQzAEvi8PtG!8p~|BlopCbIjh8Uy10Z7 zXj(G7tRcv{pw;Di4UjMTy;DdlbXY%c%aR*|iXcI&t;j8+L!;5{>kX1dq*Or@F_C|FW1?#&D-E1Shra&txr+mpnt=X`!UP?Ot=wfo&2UGE4V`*-J zSCR0+P+6SET!o;WZp==bq-Bb2Uxbb}hx5yG=|w`My|n0->;d4}4xX2g%sCZ0!@ivd z&_{HOj&d3Zb;)M?75O{VOXT5$+u+Me!iJY=GfkioF-BLZo2-l^kq*lyGt%%90g}+F zLar8Eg-ixx-P7Sf#tEYG>)Y3Y_2usPZy_3QgR#v=rOX(}jBo!F%}C*W_RFfoN*O?-Ysq_w(*mUK!b4 zzh^xG51LJas@SLTlr;sD&Me!WAu=?TQ8LENtre22FQ}qMus7dvcm)@e;sE{V!-1c# zuka58w$_C7_FX{kXHW=uqg$ zlCks`n=#S@-{OzW>_;oDhwP@!UGyiwqkDJ}4eAGGqnTT>z? zyo!rDXJB8l3jIoBBXqrRj&*`EG43i#W;RSB!<&#$sI>5ZOEx$X^ywTi2pIjC6}I zFIW109`<7+oPrOshxG7B<3gx)s~%Iw<0ho9i*pAG!* zn_V2CT&knH<(gN${;Q^(+3puivr>jVPKdQSjDX+2SQse%=9Sxh%}=ItMbaKsa!o9i zj1L!GgIH!n;-2CirrDOH!hb~{np)!d0zUD+UCiVYCw5w(9^M6M%`dE zP)ZnesHYt{w9iXYBviH6j65V$nXatY{gp^az{5jd^v!r{^Qi`!C8flK#-ZbWDMb+S zoL|yvSbIjaX)Y%c!mRTz1Jf~PtoQfEuO^tkios#E)SVc~zJ|C_3fxi{)bDv{n&jE` zV3~P1Tw`XFT zT10gd(cHi+5E>$|hiKi%^V_=V*kf{AHL003u@l-rb?{vRxqonAPGNk^?HAa3(f0|Q z9lA^HAA-~{zlXjwX0O7 zQe+i3jah$0cS>zfWxSbE&3UT)yqrqqwuzhSs2S$f7eld*T)7gmmkgVCKGuX@be@*G z(t{Vi*)9)BKcvJ7@|rao8{8%h?lZp*%i#^~YyyuENW~lvCkS!K6+M)nSvMiGbNn%S zo9low(JkL$6U>N@&6fWbD9Qi4i}xVA2D@*cZ6uixaPi*Hk6XVmw}kfdth+O`Bsg~N z*}AKC7Q`Fl1|sYA)8(k^BCP5THud~kOVx{Qk!nKs+C1&D<$EQ??G_O&6}mMryrXV` zzhBZYG8W8cWeZ(E1*(<#bG96B&$~1d+Laa-X97(<3>g<{t7~7nT|K!%2psXSgVLI zw_XHE9t>rOP#M8e+9#a9YEnfzj6TD)RYaa&FOZvF6M5TPSA)vfTEC_ijtcE@v6bb| z^v`!6Oy=CyQVYZ058~A4iQ&}ODm}Yh02lkLH^(7tb2(t(8oOs_uFr+fyKQp!D9CYV zmA{FO#_2=Z>QL=8{C%1LBT!B**CxP=7xfVQ8el~7up>auVM+TJA(9sl@PE0_PI%fa z2%l>7nGNOX;d0n4k;(}$XJw~FlO!&yFul0nDf;!M$8ox+Sya}yM1EG^%3YhvIV(Tj z$|WrwacD^a8PhitsV8B-a#m@ddI_1>Qi|IKgYardjWkFy34AM!tZIeG%xaAu+&^jq zT#2mt6ukT9lH)E$`L9O&A1N=~LKwVO`k4FO^>v}C_Yw7nTj{YpT{-@t2RK)2&3bFf z$9LaS!(#=e5Y16lv1hqGRb_q8jkOwfvYr4xSql|+Nr{W}@!k>pX4MxfAsVc}))AV~ zRO5X@5P=l-a|Gw{vi)pge3cxN`>MD0+X$7e*>;AWRM`ORGv1NjoUbWD6oV;m0#T3; z@rBAMbvWy|t+M11#`6TWUo*WeoLjl)oDbGjdPffkGn^@PzO%9UZyBlWND1cx`#-_Q z46Oh?!n$sl_M6a)T{N1Vs+}d(aa%u!rb4;h+G7INO^C1x%|`HW2xW^F%N$mZ&dQq` z-Yn6NBW3%T5{dI=bWjqc=Me4kB&F5h&Lzj`4WyGE7*_|Q;ZPwN(+o`^TBJ+1apG@9 zaFYt2>t2tdMVHH!R#`u^G*Nw>=CN{CT7T=8p1;5KePAL)twyFVRIqASisfcojBwZ0 z?3CDm&_qFr6QKTB1f}G+WL&4sGf~{p;EtzKsYc%Q0=(o4Ki!3V^((nV7&!0Xma~F; z@~JLz`tkQ(YS`D%YKCT{sTz&=Z?c`6xJDWcc95RFE z*%n38z7bg#tn%+nn7Nw*7q>K{rAbpq;#rF9vN=kF_&(HJ-jUU#FhhaqqZa|QEjRjn zsh*f8h?yHF+Gu=ot@D!9KxOD+jW^p_0_fYW4y6Vi(tb=_7nI1^iE?f#X_Jwunj83D z=*kK7ON%w!m0GN`U@JM|Pvz+_$ja~d!qkqTt?`z;8?&82zp!L+#gOzn!vDGJFzclW zG-|;3PGuCt(c*DEi`lJNH@NGPdXT4R$QD+)ZZOU>hH}n*Tk8eiD^m|l5Z$fjxAyOl z#u1qZW=~AiyaG(^WEqCD-WjFOhNIYOiYErV&MV+@6irRyGjQe{t>>$ZajwcMhMPs= zY{=J|oeJMv;DYi3Sf*#RrU}8Sv-ukGOD47l1lA(=DYhkPHvYSGs2k?WSF^Mj@;sNV zpVW>RJo;tecVM~^;iDwl(l(f;$SfGVaqX+H6R*28xdkCBGKnsYv7KuxZ#1k;!unXS zYKJiq=Nf~HsqGd~5}};%E7a0vH<6HLsMWcA;k*XlfIcBQ6BmWw#oMy4KxPKq1)&kf zAN*6s#Rf~8JmZC=+0F-=5{F&Z^U0KU@Q155T&WQ&0T*w8OOP5{&Ko1cl&()AfP zZ>5LwRd4OWrf)qfC3X|V`;L_Ot9=V~$qGCl<3E@9^~OBBL&0Sm(O>UQDJ@*qqD)Z=;b-AA4><%c~x@`4Xb8*&fePBiqckTMz35S4DD9rW;+WohCK{f zYm!@2h%r|5sG8k3 zLe*7iND0WbX%#8nb}SkokLhSCq9C2lD~jAM+*g-hMdjA8zk`2JUDjg6h6I=9Bt+(- zqPvGvO7O)|w`!8Ey%eR42^Jhi`%-VVp?j$p?k7W^U10$a?fs;{@_w6pn|5cEk8ZVe z-O6P&Ug!R!m!hodbn4oSk>S5YO<6L}VS20*(+aN+n!dlP31%YNWr*|h7}}J_WuIM| zuMX)zPJk<>K{XP%4D@7^G&>5Z@Qa;(yQFhsnqt8uRHFqIr`2Js4~-*(@)SeudPrg> zHqiTULdLt2ceJFk0angW+!V6X!&8&t6VDhe)lgMRf8aHG5 zAy6mAe4I=~CMD@z1xnwG?DpN{k`WOG=3KV@Z*M;+FlMn=h!g7S^5YQvxrbHBz262X z^7}K2+Y6CK(h4y0Ogl!cxnnWp}y&QvX{+}(x6T3nF&6q^WOMp&L}-Mhrz$$J8Lr89pt{)DiKoqHV6nfHNu z(};>z*~#(96LqCr$GZxq<`0K3xKI}SHoqHaYl8HLcTmyx>{eT7_8swZrss!yhaw=V zvcfN|<=WSBRtPjwzGA%c2UFm2X&t)PBp*aN?AU|Bto&~2eafOJs`(4p2ZB&I+0bpz zqtc4@9Hw?{n~cGVOu7I!s8mSX1NSdd?gsO7*dVKCnm;@lkn<`(b3RMw(kj<4aB}mj z1FB1jnZ4YJqexl7iMYKIaXK!?;-2Tts~;u%5Qluz8{Ojd%g-QbJu$u<&O#SD@u(%! z$!~e-)H#>8xI0;-z5VXmK43&}*(c-l=ya^THEFJBc!~>otKd6xNk0{cyvM3%4>VE& zO6c06oqUpnv+olUhqJ;K^zFjhpn#M%u~@w-u6b` z`X=^ykp_w-Y4f7>BxLjQ5njGoA){pBG!SGCYi9Q?D4O7WC-5DfVKErWtGLA&Q-BQ& zD#G4dQ4&1&vxpE%cGb6@D<<2SMOZ9AEjO_LQAdD|s+UqnFJXJhpmDpv9WO%GSrEoY zD&7fhy55wO`PyuBfPLS>6H1F7a{l5P_{47aOT((S@ssYN^X+}vKbT%OWW>Ip$%waM;2WFmnCj-O0vEgMlM%bTRMcDiNpQcl8#N z*M!@C&pNYU&McUHaqLsECLyPRe3qlAzkO@Z^+sQ2jmy1r7++k0qN;RV(>*)C&TS?g zEpvr76?A#7^zGrLJ_Cpa>sGh5?sme1*%$K}%p?CQ3BUR(sf; z@k9yt3I~(NXE>aztd}p>dG&?9iU}M%uEdtFs2opFjMY6yHE<3L_{4;lt1@Ng4~YgU zjJHlO%><(O(m4YyQdD339I?IJ9q(l#}Q zaZfoMOi{>0g;j?gp|9hv=vMO6sk!j{_Al7{7A9Xj#|*Ws#iN|Pg1(sJDi@Y-DOc|z=Hn111qHxJZ}Qw5;22`H;* zj?k)aT!4b-H27-wHyyNGD6pKF8BD=Mw6?i8)#zdxp#=y$K_{Jm@0`tsA}4ULG0yu5L>l}=}- zFM4_f2)W!u3zjQ>kRi@7$WC8&by%Zo&QKvSJZGIr?fq!FL+9Xg``FdJ2a_;H8}$2~ zWSX&Z%27j+71C$Mcc9_qB%KtKHdjJ=6?UVKkZV#+7Go?)JHdQQZ-sN*A3LOZXf=_U z=FHik{FWNWNY~S_+cXx|-`w0DXtUbkE~JWEbZI>H6GcQpDgz`##wuTaLp#iR;|Q(O z0v6=>u3sV}_21o}ZQA?*meOF$-sU_S2BDMznOIHEr|U&kSLGEk!q#(fAGd}w(6fcN zkc@p=j?INRZ0r;LDW@w!-M$EpFil9|?;>u%3w=*3!(yyYUJ9-o@6 z;BC+-6pz!RH#Ac6HfR$kTQJc?P{t8V2s+2?K zaQ+=@1Ac75^Qfxb5)vb?fx9f7jqp;{TfKkk<(4$0`NOBzgd|PpDnya@&@JRIb|DUTv(bm9 zTg6>*13BV~ln|;aC&hl8qIjEZQ4neX9JFa0lW2xW3dYVBb51cGe$73c&2v*_s={jT z%!Z|D{an@Gs0#R|?&~S=^U1Gml*+?J&rQAD00fW7MjMJUF# z^x}MX9=w+^SG)+IKSsyq74+<)V=oipli2BkzEv@KM!^z_qA3>kddVOh)4_8<0OixW zZz1*L+4-JjPuloLlT~H8%s@)|+HTBX00jraCdJkYVt|!Zq%9xt=9+-fzPY_JldnJW_=e{%Il)mdF$9}PVkU>StU7}-`#c8|j zrvl+&8{l{Xpmpy_C|=U^uw=(J&`;ur-sBq7HHYVB#Gk?A`Pk^QT5|E9Rt-ie>M62QxJR8? z3sYuOBD5p)SO_vW&6JXPeJurV9iOdZ^bkF>KDYH7eYs+eVkmkl%$xpEGdtFIFw;8@ zT>L1Z>C#oE2?<%#@xM&=U1Lx_%kzQ^H?@u$@SMimO#J$+@bgj-KRAIgj!Zj3%Yo~* z6X$F5%TGvRzmQ)={h)k6UZz`oqjAXp9L$F#?5>WsKwd#<2kN>3&*SvNID}rm<Rdk4<#9w-|=5@APN9;~$*yUiM7F0N@=3LQOvIUg?Cc@V^ zarL?dk>OQ?=Jkrc`t_T(W{F-!(rjv4iYBk^J-yaj{wqD>|t)V4zJiR`ZLybNiX-^B-B$ z9A8~haGBcg^2UP8VqdM>i?@DGUb1>~u$*#40cjX6ol&2IkL^sE=;Q(empI)&&Iwc% zhYVHh+Qd7U@P(c?gx8u-jp9UD8u#N?D-3uw7Q*(-1k$qWP)998vt>`u|9q0pkwKD< zQK+n~*SPwHB#J8WOLT1;JQM5i38&)aYDu{L{t-@2HmB!4QY3%k=*Oe)=6nw!+ZhG(|cCGSvoLC6-NNxS?Be$F6 zIg|AUD_s{l@1$OJx?`{4AU&V+QtWps@zVR6b+JdwHx93`-51xX(?b5VKQm+T9rpT4 z`?JaTA5W$tMSF+&J~9tX#F9jPeqD~urSn2Ey%DWf6uyrZ`9!3MkBq(B>`H%q17*01 z$ayJs4S8NHh+acLlV!R-C<37om#A@r>M-`I@ur=%WRm6$0JFU`-Y}UPBI1W>xu~31 zGP0N&pmadizb*1vjl0ei;>lQ^VZ+iv`n{+0%=qS=Dorhl^@HyVPA!$8A3670*7A2y zy+_9W*w=x^c@#%1CH?GXM2n&?zuk8m_o!TYQx`WcN=Vtevp61yrEq;N4f%~NN}AO8+M;S zGzVYUajM#jT06Y?}U$3AmEGiREX zbV^mH3aHEdmQ>(8ktW`TK=u})pzk3Cz(k0a7OCTLHD3DMRYUl;har2f#MpJ~ zM4Yo*y=oCf=Yq-IQGb#I`iI@xRdiMktrjb|`%bWcC*qu%&YyOQ$jM{OfF~wo6xe&? z&B<5Z$DVbFR0}^dm7T8`hYona$W24RxaA!uX!~vo*C3b|d;V?@oN#7A2Zjxyx}V1= zVHsf(O^&P13?pQIj%X8S@`sn#7Oqka=DcH?ifz2d;yr+Rez)Q(E6cKW&0GXFE%!VG z7dxpHZcT*1KZ!nn62aANjvZ#pW$ywvhlGc(H%1=i=IoT5zRp$C zFK22$7Ntc@naQ3)ZM2#Oo^!$QUpmaE7@*WkK?Hhi!P_llzDvHL4x=b#H^~x#g;-=9 zZxUI`)J#Sl7}r>QyScdIh6OtgUUz(S@!KhG4pj~h&xqoFRH=>yLLiGm;%al1gJ3XH zLQAIuJh@^5Ci2rb2QuWRc|u`T7wx9fU{O$JpBL+TFNRku zX|O4^k@oxK7!GDe82iv!sW(o=1+MEzj1{Q+!t_jT_cyZ6^q(oRtCTn+fQMg0f6*b3 z|J*I9fb2sOxM#z7-m2@_9c$gNV8{Pt6Oiub-?0@>x3Bu`8#5bRQeOF2XL zmE}MT(Up6V&uR4om~SdaIDJ1O(QCrqECQp+M~misnNta6fxTB+4;4Cg0poeO z0w@L6hb{p4gtukLu`;S<;-4wLeJ{kyf&TgQ+c|yu3Bo;j{J4s>j_ZRWTY}tdUWM8& zi)J9fO?hAueb<-CoUyW(a>fasXHhTfv4nGds1_dU>f zJ!hX{4Mrm40w!o8D<}w>)?vHQqzI$PVT625)b))PR z;%BMJ?RWWP(xeFtE3a^CM6~h2^LA_JLP-<$9LP zeq~i(7qGPOV3{};CVYu&2$a4(#QNE!_^XhtsF3M~FgfBg;FtzwZ4y3CX{UET}h+a*-VgEhvr$vL7OzD|`lzBEWp45r9d z>+V{^+Uveu66wdjb`QQRAUqQy{Su#-QQCt%U&1bfZofr)2B9-~MY{I+-L!7E;k|Hq ziP@lv zg}`pd1*=Y*45LjexM{kGt|LjhuE^4`mpo>n4PSZ@FMYpG-W6W!t7{|o&<0>8(n zNaf|>5 z;Z=;qeOdiq4zD(N)F`ZSc+5<>Xvgs+@h^%rFNJ;*miGQwthD0g$g zwioR+cky|3Yg<{GEfP2+ju>|k2r1lt6*ui8`%dcr02Osft%Uwgl^bOtUI8ExpVZgO zekQ*2>@LRqW+&8C>h932@|tE}4lhdf;K8r0zh%T6s@&c~M#ibBCb&taNz8KyS~ z*z~V}d$aXu>}Pr4teZ|qCyl4pzMc4e`+Inoz?w(GeLma5T4m7qefE)SEYrvh;FvIP zGaq*wyJw|*%#jr7jOWz)RGWTmWZbRBI<3@lq>hl$bG~W4$?B1DaYwl_DHRy(-f%N4-j$Nb8ER4aXfRM-;ui>A+-X z98qC{4b9C+tB#cylzNJEY(W{OhKWi7DU6|d3X~9g@k&)q10hGj=~JoiNOt3(rVYcf zqhKWK(tw#6%{6%IQUy7saa?K0_owb2)VTJf+wDMyDF&Axoiu#Cso{a(Q?P~@nOh){ zPgW0MLOD{{U&OFcF*SK%VAKQZNVmXBBhHU+#~{ zQV8+)X)t6970jHpfj`;nP(GUGb>)g<=55t~b~{tox7Mi&<#e)%F@yyAny|Z9pv5}| zm&zx$DFwvOwrM1dCryr^uckPqBVp$zmv|%6jY8&Jnj1pRC7gy9?x5rvNOeiA7^VDW zG5-K90Lq`nwZT2J(upBi6AYsSb5t}Xy~I~Y9ySa;s%@+7Skq{)3pAE;`7P*qAC)9p zyta-K_A-Ef7~~q8jMyp8M-+U^??e|7+#q<^Fg}K*9Ou0-8m*FP#YvPqsyj@iUhSJ12o--{E9akUB;Hduz+K|E>}3s6a?=| z;c-a%Qcy-HsvhA>PNIP^qpdCl8)<0>Xc*5=+|ECJIr<9GECBWT&}A)V^mt(wPu-G}e+oT1H~|f}54cI5iu& zZa@Hf)J2Xd+`F_`Wm35nS=(>DAUGXq8P~luAq0mVDH-q&d{Z7(1A|bRv7dSwE!eQH z^3oRqtNFx(%|vH8>qdjQY34NpTmUjnLi6)cw2H&loy5nRjl|$%G>s{$vdOTHdew7v z^L3+expm}hRBba3r;4oiUiA^1aO*{ZGSggc8%_>7RXd37<_C0OpHWe?p?Rw9b_RRY zl*E$9W(-oYduKIHTkET7Fv;gGdsNcuB0QceI(=C~bW)m=0-b}H zX)NG^Dzvr>Hg^C=y=xC4uvCm5l(I&lcs%`RCg&-C;age2B0a6@1z_EHK6_>@1fiGE z*Pyuqs!RbS;;CGRspj*o<(pyPEIW}z8mckc!)f;w-CPBA!2q88Rz3EMYjC;P6}?3= zY+{S}+oOtmUM4ZPR2M!TXxNyf>Dsd9g67?}x*x){T9RlR&J3 zq!E$WRH7s3D^*Ri2~4gMo+CA3VJ4#!SL z9yVDP2G8$78OP;{%l(f(WSg&uI-FX*s~Lkvwvi-sbs?qif$d+RUODmChdv4TE=eVL z?UvpWki-rP5V%o~!n(0o&#R?|!|PH02hY=>v6sZ+CCf>EK824Mc)LOPH{i)G=MmXj z+r)gRqZ!;l=m7jH^9%Nr{iU@}i#q(frZ(DA0I=IBIL92HUPUkM5&KMQ9~N(Xxh9f3 z7|+RW8|57OepU0O(YVy5#9^7qJmR}B8EV2WX;)f|;lyI)m`>66Xsws($Tb~AOveX3 zbBgD6VDng9l~!%Bu{a&Db6%g}UxV7VzqQ&ek&-e`9DX(E+SkH+PYnD%@f@-uJgcSL zux_U!CjPbGh{H;yDb8AG&NZCi@RjM>?oNT6&)tRB~{Q^fe_%I){aU=s^a*AA7WZ z;+};v*zP|uNG4T0oPA^!UMn~Ten4^RO%mE~EYCseaXNKjpMP z_th*gub6Qd=}Ley^GIUJ$Rjkaz>tC3muI=C z6qPt8tQP=eoDSsDdWno=%XOg{g*EP?Q}@9>#-eNWKXF-&D-v4_NT>D3^{O6y#GIr| zpHWJQJcNd0AN!`M0eFN5<{(rpJW;lHujf#WJAdDHZ_=#CWg2z|>&;Nn7}b=Ws;Br|z6Wlojrh;X#9;6{RV6u~3aM!vM*tJ`s}P9C0Yd#L%A17-6+d)h zkP>7O(vE4PDmkSL390}I&S>X$DZ#T&{3ln)0IISnoJB+N|L$h zOr%S~`_mYJX@_t=wDkeH(in#zQmbTh-lk;@(1A_Z205XKX~K#?K^-Y2Iq6Irsiklw zF?_ik8Ut(?>q;_v{uH7-@@N3_0UbXIX*-7$_Eic_G0)feRd%>Y4q1YadH|Qpq-4PL zs?V%K4iq!Dpi@zs%b9-VzR&lu{xpymIZ@9PvQHQQy9%fFTgiU>qA}>Se`6pyc6L8n zVq@P_w@iTB+tCJ1S8M5IU);h^`$v!GR~FJJTpfU)TvWS;Isx>cqbx;#AQ^Q<^dkrJ zrzV@bnPhkNB!7iz?oy|KDGKs1MJ+(AmJyB>LH_`Fll<)OO=Ny_f z12%0OBW|~qp>Fs+Y9_wDvOm9s%@=n;iq|Nl=9J)ieNS3pp-K%-3z2}u8$WgaQ~K2B z8Rnt7(=F~lgJWawC(3_Hq-`%W^ZSWR%l+3pP$N{JBAP%>oYs13uCKD<-QjQV5ye}M zbeLrrKhlC+uDM4Old+Xr*Iu=Z!~*T42zDRr3DOMo9`M@tBPZ> zVe*P%qvs`!0div)rf%E7G^CnL1MNUu47^iW^WK%Q>roOY8U~45wM8YkJq{`PKD5_z zJJ2U$CM6Vtc&In8tv&v1)P7L)pq9qF;B>0e9o_yH^`Rhavo@F2sj6`iW%Vt`3;?KPyhrHV)fuKzIqU0Gt|bGIaA~CW2GM?Nfu1_mIj(@`HJ3i3 zQ1aDMO;3(ZTxs7{W9>CGr@R>?n$5ktMi?LyRVBMJ^r(Wgjgm5JqBLE|bqJ-1P5dAq zT8(WQw4$^p7!`IK0&A`~X8Ih`#3j2iQq_ZWs|jx2mAej>8>kg+G{y9;*x_z+OBRlO zEm-!c($R?NTj^sO^{L~ujPw|;xZy5w%NBFXWaqU^whVh$NfnY4(=}-MFDs6IIik=BZjU$MlG`sOVFBWkpl8h zr@eiz`zn6O*M2j;wAQs-St8P-a7OQtzZvL$wWa$k{>RdI%Hqpi))B3&5g)r)ox~?LHK;-`boL8TW#CsWiR}8tTKfvLmAFZ#f;-4)A{{WLe zA-+5D$A>-v_$hV>ZKI70aYGpZgMvpH$sYCjm+_RfWkLq&p7K|wc$^P7IsK3C$$`rg=5Wj8g7GS;ag0q zjt3>HkMV!Rezw|rd6VP#J6F-DDC&GgY5 z0NG>0e+B;lW>1ONQ!ICy$NVBXmf5a$pDtgrOUiNAcS`x}&oaZ&r6@OfG}V#x`40{` z)`e-S^0e;u`}&`Zbjf&#{SWe~2&(tP{SJTk5Av_b9p2~kcS5ve{{VeY(A9)dz~l2Z z%Z@s6^rx)yuSp;Ctn69Qs0$-uDo|G(j%%40lc~s=g-o~e;AZTvwlhV5`%G{>X?Jnb zq6CAEwJt{$+0f?_XcFQwjsa8DRvg-0&XXfeV&$VJ=7^f;DBF?GO3f>hiaKVm9q`)6 zWaFwgTF?M{es!6s=*f1%SlC56^kG%)wOQ<4VZK~0aI#~AM<_FLfG9#FP|86BWD!ff zKs<`1gbKxa)70+ts9x>}{_}zLsG9pH5uua)(Nr{Ojo!TdDFXLA)e(1bCkqJ&)SOd< zS5q7pBiITGEVx9@PTz8PsLr7bWbR;ST`V{zS1q2$rAWGL2nHij^b`Q!?Ks$e>~H@7 zbczMl%%65JzwcC_81fYH=~5)w;~1&t0<%MNDEXaKdI3#cGn|)p_AGH%!Ou!^f=xTz zOoBUDBR_BhQiM^^4AQc5%{TzPX2<>RQS zR4B+iQA}e%1XP2ktwz!0e6-~ZfM=Qja1?E&1QAZ42=`!9(B_@6Qx5~>=71ar<4l^_ zE*BCausuN(q0V_2sYBa$mqBZHGFeDsVf+B_X=2tUwPlX-FE9PDG7W6t(Ao2)HH4#PjO1fSc9AjVPIAr>Hh#NCMeix1%;&~jB!Y$1BR(KsQ?4b z784nk9ZqRipK4RhCNa{f3{afYyu5eCM}`NbIePrrp@0#ANEolZNw+71M!gkSpbu72BlU~pm#K;+%cy4XO1vO zu4#*5N^UfHWOt_dqCdQNs9#sLPCVr&*#7{H1rwNU;E^9O=tu%YDh=! zgGhhb6oT&lZ@Umx^>d2QRgEwJ0DURy6p9Hb3n}h0WSFl%bPRtgl07CKt(gz|My;}E zzckPaw;d@B4Hi*GxDld{_eBOjFYfVDXEg1hs40;wrw2UJ`M&t61WGf~nqwbIWU;#$ z`OoP{(ByGXW+-qvQyNb|Xcfk^1W*_Q%}L8GH_FN8fuXG6o++g6;~1whZ9G#s2bu;3 zGCI==8kfw^Q%Bwu1&Guf^F~EFLCr9X&?lg?MI56ALk~*JxY6TR^DW(>ap}nXt3!Dm z=~ae4V?j5$j<3hUWuz9QLcEsRx>=cV!LN-$2LI^FR}*+`v-F z7~--X*5=nO42;9ksjIF_0Sk2#FZ)B0Kw9Y4DYlfz2cjKo3CO-tsAV>zQjTezY@80M4A2c<;VZoE;*9Yq=f z)R4)Fh07M=hHg61=Yho<3f3A+DCVLqPm*oIq2KLBfJsb}nplcqbB=hYpoD2V)pwNB z-LX+KiY$uQMsi0r3lB4jB#>gOG}t^*VG8hodgiLj9(z=cDa}PB#~o>b6C$eeD!hs+ zNv;MkYOJ?PC;_gcJu0Mw=IKi{-Va)*HU239B)A;nt4S~%U{o)Ey>e=e#ot^SyEX*! zTxY#fxx4_?S?@sTYbNti3Qs*Kaj>pjy1fBUYR8iIbe(FI&a)ZH=bx=-t^Qx0KN_f_ zTRJH2PCC`d?*=QHTl7(!(zddQIn7y}&Y$h8>qy#)sL3Z4$~L$=W~kh2Q@ap^fmEWy znCs@%S3PQ^+Q1zc3ghI|?il_0h3=)0e9xM$M{-<|)J?4@{wl2|x<9{M=2-P6oD+9Y zYfon0#9z$Vms@80yw!KqWq$QeD|S6IS0J!sgI!eUH_*mYOQ``ZCsIvHmgT|YHGVxm z92`~Xtz%F)=DVYYIOm8+$%e(4@U?y|G=a#)UH~ciX-gkk>4g;&l2w}{irG^Hjs;JY zjBq*nQ%mRROvop#buA8Od2UVihGE4XV|vtJaZbi+uE};LMyVD;y$wR6ijV?&R*ADI zBwC8$mj^XzBzX_YaxqX_!?=Ttk4oq?eIezLe1rmeV>Q`LN?N0WwMbJ+YWx_^V0yN8uKIs}e+;B8D-`HaGx&6~m9r96#A`+Rgq49SRDw`sOd)qaA&GivBLjVuhIDS0~@zu`P5fJqq9FR5B8Ck&=1i>0ZuLlq?iItw(x~bK$eT zDULE+log>JqQ6s>@OOdXw%I+}5J4E-U6ua3WuQRk<=hmJo-urC)1_@L_4_O`F+cDVTgNS@g`qjUWYg81^zoH(m8q(-v{P1!(S660H9>pL zoc{psH}b0lR5vmCmhvDtC0Kry`GlUQ>?>oF#T9w9mQ^IlQ^leJ+{#Jx#ZNLU%v@w= z1a~0UwD^DaZ@krXTPxd-5?E<++{7mPB+g65t;&YS8Nmk~vtDTxlxo2lcX5-I9<{w| zapJVML2ap+ZOGh+E|6dzdLDbz-(*8+ZqKMXoPkVe@ImQS)@zA+MpNoVJIJ_@^Cr*t zYoNrkC139W^`Wm}j2y_kdJ$C{OAd0(N&3|BTcl(uU(A|TNI-QIIKsakqx>^b+^b*0 zQQ-zN>T2?;sQCaNPAS8!0M1+NI+l%-42ElL^10*>O4QTmx74G_l$RfRIXEJ=26|RD zp`eLoHx|+~7X!NYqU4&9*}{W`?Vnn(fe`WhtDmvfq=I2@e5N<-{f%y>*BgPz^#+TS zi5XS8b@il43}+j2?@m?06z&M7hAPeG9)g|}nt)|FQJRuD!*)NFFdgOfrkJvLr+`V% zYI=e?(y$S(Ql(h|KC}=BrtR8zr?3w^pITfK)|ju(4;3IZtchSEEy$yQd8eb1z{Mjb z;nIs(5W7w@N{1BHR!_Qf^`}Nd9(wvy20tKylB%>|e@Ow^zy9Xe8hO;#qJwO}-<2Z{spf-z3%LF8hy7Q%)Up+~Jp-=#ebJs5@C z#Qd}X!%YJFc%cQQ6iOgd(*_3_57&?>_otZ85KGe zJ?S!Ats#eS^rli|xdXK|k6!e_kx~E%=71wq$)}93Y|@dAoKlhU^`HSjG^(I;nv5e4 zyGxc#0xF4a zwbyX^QELwO78{uE6clhl=t1jJW9wMcXpd~|C5){R?wyS-zPlx>c~|flzv-qpriwNS zZxo(O0#9mfz0^~t=$%|+v8hXv2*II_ZIxrB~C zixjJEd33mz;KTmP9zT@r|C#ZLB}+~vX}NiVf)z{Pi)kQ zro$P^t`E7-^Q%iIJu&Z3=M=h>Mv5aC1Nwg|Q!3}~)52~%)N({K#W2uSaL0lAQ=6XD z#6k@?^rTBM4bpPXe7AZZrS{BfU3%8TA zazz6}U9#L$e6qti%`1Qp6vULNpjRE`5l#bd2dyJZyQL;`$?HiD)+FF(6xT$QVbl@n zNwk_ap7gF=^fPYsNSZ?|EYV1Np{izGOHKoF8KfP+Aw+%iqW^!V7OePgY8W&;z!RDG|6mM2Nb5Te@IPX%{qp}~rbug&SMe)u_ybR4+bPtwv?fdUgV5pFY&zJY$nk78MiA=|B^_fYoMWQMCT`43`7e znArJb#~G^TOm#I3*9Ub}lHm5HB#V;H0X&MQCBpR0Q)4ynzoT#%A@5V`_}Y&Ocv@1QIS#| zh&bl8j5O5E)a+wNrcOpn98{@b6#T@Wt!T8Qi~uoAWOwaaMvH9bm0NCA`#KNc6$?a+ zN60Gifa9O%Qb%M-JXdsZ@3Dlj3dUix1pVLAfoKo#ewDbk2N@vKyoHaGT~WfV*@MLx zKVr}D)X`X|#%oGPMLYpjHRJ!<*WFyv)`!Fp2f2eGQ&cpXh1cm|V_A0c3j&<0IHF0<=STa2ES zS4@`{Tv37PP8~Yb5tfq!9D3EbEw}pCu|^Wpa9*4Y)yrE}k$?mAtCl)jToqX2F4@Ku zXPWfC2z)HR)n<);$srA%0vwEgkzR!=5|g{w@v&I?IB4E%s!3SOaHA&xes$#E9KUHzN5;C%$JvxYZWxHv zXQ$&{Tj5^=_`l-rrS@$q+FOM!^GFlrAB}b8*=%rY>lNR<-1&^_9a}qpSEcSzPei)> zj+?|k6yg(@EhCK&-8*`ZYV<#YzX^O%@jCK3&5X9XaRJTE%w#JA({4!5K8C%U;J@rs zpmRu%O0Ew2Ogi5H#3mM03WAY%^aq(mJoA77BO)6VEA0m7B#>BIOj1k5TI(`+V zD>#)`>~L}BefkrH;fy5+D!9kX9^F~D;J<_Z75GtR*ZQNwJ=tB)HPIMcb=#kQ~P*vljWAJn{Yl zxhtFKk5}BpbDG87BY<*2pj*o+`=p;*_tcU}K^#%TMhjM5lUZ1!!l-X^SUw@u-c2I; zIs*fa2W_UdBGBi!$H-250ZXp{i z%<$S_UHMbcuD7Yo$K|~RAzbwtr*9tAQ1lkVN6S*K4Ko>|XE_{I zt28~yawy0n{{XE_1RrXdK;(7B77G;bbog(hD$ST~g?Qm3J(>6-9)1-0UNHv^`6H_*@q z=uw=CQlRFL4|;afR56N5`-+id27;iky)drclmPr3=AX8sN9UTBfib%{p?Ekn;gTu- zR~+*|ZUK-}w{UPOHi(18J}CgmGz>Xz*q}ms=bDsKgVul|+R>hQsX+#ieBEdXiU5BA zdQ*W0qe5}>Q@{hIEr1BzGAV(W4n<3mN%yF@3al7Yw)@n3cAhDBQDi1K44Pe{l~$d$ znIi$Gf(5r(O26;!&(yY5q4JcwyN^ys9>r{b` z1QEwH;MEw3koCnoaLzl^DQr6))f>b~$UJ&fgyZS-rS0oThjKXg1M^gaX*^P&o2@fp z29rW0$nC`hjwykSNP?fB?8a>%aZXnw?+jD%f$Kp4_n;Vn1Kymd2TEqr2NVWu`cTG6 zux6RFY57e$mu(0G&A|DGPC#FhDgF)~Prs>I$Bq^r&Ul z?V(_;AO!T}RDas7FA%`&dlERHqN8|ytz>CzB{9EJgz#vGUVBNrtwIwM_HZ~qO0%2! zPBO5J_50cWRM|8^c7-k_X%O{X@P4DE0xgY7-Isbu7<*JlSWzN~RbbgP+pmf^qMAN^|4hUzPY z-5Z7nrVU6~W4GWa12R1_+9n&6yJxE${{T9@6cMl7RRh>k=W*Ic6vG=S%J69dWcwG; zbo8V+Cpe}lVfT$aaNP|6+ahq;$Qh?0Zv0fZ+G!7|pij(97C9!Jvm&E8?Mdem!Ob#y zk~CpxIRlCmmg`Onkx3S{A`St;CX|3`Ll`}&3}jOzVIqu*7y(8G+|VJxNi`skj%i?x zob;}XO7OMrr5SjwWRV9_s!#N;%5-H@W0I|PPvSY#9?(ebOl*_WHPByZv)XT2At=C* zK^*3>rijLQ6zb5GJy=t$s%%kUcOPnUWS(inf=@W5+@qS|-0J{%rO2wL@mhZ^d2Dp2 z7Bmd72t3mzAy?9vvNkD87vR$o333lwcItM}4`WSb80$eo5193*>;ZGdLfDHQK6cpG#UVgM#qz8&ZKvwni zsLcG+8f+3maR9Q~lRD6wmF;t3(TP5n=u1=}$s+Iwz1Q z`@*C1kN1UQjdBUV!wmXTwxl}|imMLhn_c6*MJ4732D6^)jPq5cy#ODZr4|dCQeE-) zs=Sw8;S~m_h*8%SlQy6mzH2K`)lwxTp8!*^n>Q{s zRrSSWU+bv=Exc#-u1`_%IwKIIwR0Dm#Meh;NX?%3qm_}&b^4c#))A3{eXEt!d|z{N z4&*MaTY_|jg4?RtFk!i6+ zbF^paQh8X;6jkz(?Gb@*;ggVCAXc5Wm2Ymp?RKl|ha8?gDWVk}>sZok!gZpaTA-54ZV@U zJW-SOd~_8wR!rTiXj#Po%MpQ5&3P1KsD!yaY9$Wcs#xtDn&wiIXHBCNN+qXxx#%g$X5BGXA=BKq-OtjxV;RZpV<|yu zQ!eMRr>t@}Kx<|_C9pn1N$3Ep*4`buU>nJ79{8=+8l&AD(!|q>-5lCl%H2V!(#4Nl z*JQpIk~9q_6rbVmQ^(=fas+71PoS=dVI%MvuoGIfq2Xfl{5=HurjHoY{68(o+NqFF99MbaPlT7+l*-R< zBFE}_SI{0E_&05%?RSY}Z~(y^kIOaLS!iz?Lj|3T)5{^iNaHva>_ZDq9~(&cJa-h; zs{Ni4Ew*tU2lz>6qs0qLXtFuM+!r_}rG0(yC*XMB9;JO^B8&xSlWsQy)SiBn?}C2> z?{98l)V27GDIOX-cJ4cF74%K+p$CWTaTrh)eo(dYS*C5T)o{Og?qnGaOhZ|oXxfU^ zCTDoN#oAZH-3EKBxuUg-7|R7G?;LajzdL?7{9f0-HF%}%G`J;bmBK+3BPF{6abHFF z@8REyzB+ifeca~5K!t*$s~?>^@qvS0f#E-eUI_RhrcHd&!5al0WzEjg#~kPLtT0l; z<*`$K{a5%N<~ibXimsYo&V)PZdv&+ueA)0<_9VF0Rt;OmbIAp>seiH!$lvfhWBJ$9 z`nJ2^AAnvOk=N|jF+!w>;!tFB*kE_B3;65%XX)A_&!+ffrPP5+8Fu`teY+a?kH1ALz z{0Xl|_@ny;X}%Qr8{^LqYC5#{7ur{c@AVkt=Wh6|oz!r{jE_qDE5*MTd?nxrf?w(p zLi=*DsT-Jk4EtBh-?W$QMXGqC;P;O-&ky)=dz~ixLewp$)ER&g#MbVt*!EMNE5pQQ zIhHmJLMjnGY&Huh%d18eYB|(xXtmV*bt4q1!>RWjXlfHI>IWw^@eKV?(2i#-!nfoq zcw{a2qy9ts)CnA+M&%#y6H~%i0!f$w-EwOZ%T{)iY8TMi+S*Ay)XpP{2+;3$CjbC_ z@n1*i-?Da^`dzc?+J>U~i!SN@!*#M1W6sqDPfnT7BooGQT)*tUq1at(TAsUg3h&cn zF-BZ{!v6qyhI7s_UqV@U{>D2vKGS&&6S^#q8%hCE!*(&-twqL>&UmSK12opc7*Mg|lw@Qc^yGR| zgVw6|8Xlu7!O5lAeQF>IjEjTYJjMBkMjc1eukED(4|*&I zFqe0p9+0KLY3&PTUmSs54W;x~u?Zq@%4%5^C0(qcffyjyKY5}{VK3S=_9}bF*wX`| z3zp7!r&dOlug7+LnMajyzbRMaKH|04<{d>%#KqV#N*JvG6G0x-#5gde4o`Y!&N|Yo z3IKlMd8Zgb9mN2H&}O6uj+DlT!7+8@RH)mt#wj-L>M|*Y6P$)L43$kfqDRT(5%sBP z04utif~Os6z~Y<^YHk7y5svhrVwQadr}k*dHm^2sxi+#l)J&8FJXZUeaRInvg6*HgX>3@Saa9bf?K&Jd-kB3 zj|`)cm8Te23z!CiEipIDj9MdJ9D96mo57x7Voz%zYxY_>U#Y+~I7;K!# zea!M?N;GwDWpKv<0|AVI`q^{4q;I9>;!r-PB498-cd0kFiE zZ(qYS?c1sTKs3Vcn)!=*^BVacG$ZhXr*OlP`v z6zFxgwUkAD8B8C%2L`r!bf!yglc`rgz>ea9TrKV*okJnqzN3(Sl`-R<-qnLXfiwk7H@D!`7RJ9XOi(>*Keui0N-&{s^KOT6;Ut2;Mr5;x7c=h7>l#;!Vt$%560O=8U zZ0i;Y1+3Aw91c9dLFw!NoG6`O1g3BP?AYJ z(_NShWSVxk$;kv%2XQ$Rw*TCQ*7&n=Twm4w-) z{k!sj!v>-{m~cor6@huGz=VQ8YR0+Ngi1_ubM>oA=59w}HlxA8tgEeAQ|~YXy>V0c zit126;QETDWx2Z`Ic{rJ24^Xwe^l{ULeayPZ&6q>Uf*958*o$8HD=pQ34^c`?^-cw z`NraSt5LC^v_?&~j_muUZQj+dZ(^|HYG75^;*KHHpHotKz#LsvL@; zajOz|mzD=^4Z*Z`qq5+_#JapS*4REk!@Xw z&TE2IokV&OqX=#*+_Urq(+Wt&uES*AFUNriN^>ezVFMX09jeq?eme7A zo{``^Q&ubXdl}^o(UvA7Kb3lKhQDSn5bAqnxr^+vxBT?L$Jd@K(xrfut)b##v#c%| zKWQ|N3y)99;eR1omcAUjyLDJ?-a>i+weza@IHs<&DcGlYz^gDW2ym(XKS-+Kk(M--q02BTd z?7j~Dn{TdS1ZjjL9l2#;$FC|WR;`D_tIkOtJTcgc^R^lioR5%ne}y-9#a?6gx|~;~ z_*dZNtT-(Pn#ULcYwaBi_FsZ1+H2FaKZKqf@cb%F2sVIsKmPz;yej$r z5|)&e+3eHd-19Kjv-WZGK05Hf!aKQ{MYfG3gCBd#)Jz-#%Q>GBWn8$X9VtFShn5AY-dBF@EQUanMN zekQyhQt`iyJYH1X>()LNfSm0zBQYOc)yiu(z6bEW`St$*6kO}`{{S3Yfl_dPyPrzI z;4wAd@9H-DFY3pqOEth@ApZcf@X2|}ugi1ZwO^0gBv;F)=`l6+<2J=u@wA_)&1&iG zE~^jPwOJT00Wq)&gOAp}Rk;1Ed@G^Aqek%JiP3PfupANJI3LouFA)5G_=E8taTb+r zd}8H7S;9!pf7!_V>uBWCkHWcEcJ(oqe^!3|SI++cm1vKwzAApuo*ejVrAuoC#8O@b zZz&OhUD(e&*XQ@fKaYMP_?4(iX1SItAaIZ?5V-nRp=p1!Kg4ez&i9(LHl1$dGtGA( zesY|OlYShMK=X%P~ z=(hg=hdwHkPrNXFu1KUX6aKWR#!pUqRdmxYE*sG7iHV=^ZnLtaheFWU3N+H}ujpxIpbhW7^%*~Zdi9?Ci7divLuEYlr}e7IV6UC&Km zGW-JO%qyn<0F{5hIzIt^%w7ZV6bR61P)Ts0W@bg)e<59lwd1c0coy5sp7zqu%U&bg zs(9!zUoPw3Klp>=%ZVEER<`hdmyiLHL=U&!S+XmN*7d)IJ^^!e;vIVD#uwxO_JUSu z*p8$X7$2Q;EV8Y4d`#PYm-*~M&S}z(vib@u`JVp(cIUn7{{Xd=I;78S;VmBbUAk5Z z-b6)}Gu+@FE1!K2;&;S#d%a&*gToqOUSitBK~8682dlrQ9YS(;gy&r(^=e>&m3VSJKUT_kP6iT?of8ru<<#uVo& zvTfV?(Cf>l-D+x_6jE!ZzkcVG5t<@j-l)OqFa;E;lOkbD5v`rwo4~&*@Vwt2W{$B%k5IAE4%{tg?v9i#w>5e~dD=et}z`z*0r6`H7W# z##R0i0}=eOShGXa{u6kLeFI3<^vyOkyVdn6QY&cr!6izTK8m=>KHl}|T5s&*;m5Is zwTZ5^JxzvZx@p-zwFbZe{0Ww;u!%e5BfA4@(4T@Jb}k-ezo)$nIiFw zdsZ!)#Usg5MKtWx4-X}>L8l?rWY^#LVqeMu$+tvxSXhfq+Q$1ik# z2sLj&Mv^HahKn7=J8)hzL2S~v>_(?;SylvaY9Gdhh>m%tkZf276oii4P^TYztvdkI zXk<;CcRyNOI`+t^exj2-yHx5S7C_%$N{|dxEQdK-db!UuST0CTX#fQBXdH}!4Jcf8 ztFW#a$3aT3xYLKdAy7>wgvSIV@lFY}^{CcDjmI?q05B7rQ$j+rgU%_KBc(~yaYDy~ zlR&~bfLt1S%ah5bd1_mcibEMg>-3{QEOSlr$vmDtDZgonMp=UWYP_1gvNk-?_)`|d z^1>S%M?U7BtXGC##8n2>B%6pJa(~(!Q+D6n5FczxeGX_5s98efwg;_4HlYjv`9KE0 zJzppNkWEoKF)f)Vc|Ec~{&ivBP5 zBBn3^#XR8AaT!0^w-WJlJ23QKKb2dDNw!QKyU70k04A;R=}jPxl)!Xwq6{i1AMT2i z01OZ1#Yec)lop_9OknXw^BLose#1@*0qL4US`n!S+MTqX`4ln?=cOXau;Awu0V2hb zN@C+YQY@JqaZV(jIHn9>BphcVoU)8{=7O}INMY|luX06~Ja?mWXCPB@$$^dqIV1#g z*V2L5Qn~F>x)9l4%BAxKDz`ieNK9%ssKKX-jn{Whw80#Mx%{XRKU|EQRCB=J3S-dR82|6WNnBgpCsq;OZ&1{vetHBh(=BSbq}o4IvxNjB`yV z9FDadQ^RtBqj2E=0Ce+Ggc-@{?M$RbvXvMjq*vWaK>E`RTTVp+Bm)_w*tn?6W33)~ zjyupH?x9B&9{da%Qr=;}q!4q@Gytl@H6WCXb5Iz_q`~#A6s%{FK5OnSTQNyFq%DrM8J3Z<^^-Qxagvy2W7?S9RBbR?Xa_umbf}ri)4b3| zX^g<0Y3xfA&L)ttCYaL3z3QZwI41|CD~Ptzfz+CUNrxvqnyB|ckG-0!HR`YN3ZN~$ zz^ddaB7edIfVvOW-m|=~blE6dtufx4uWMM$o=_rKrmi>RXNgs&Zb* zQk!)E`ufx+4AFK4c`s$!erly9%#GEnK*7o5twSxSHEEE{nWc8f8LD!lxUILhP%1Ae z06jRX7UwrC51#c}Yi8!UDI<^Cs?8E)^rjX$iEWtpg%zeR?f z&iZ{`IMn3rsuK93-Z6mLHO0$$G{o+5YA?JpPEAsX?sbEh1nzhSv{c!XlP|$%8YuPmsr;1V z91-5B!tPjRuE%cD9AM|tyCH{^jw#l*cpj)Xd#vaIe$1lEDXz}jLASQY$+?HEYC~=m1ylmPx_M$-o+WJ3 z`b6a{JSOtWOT z+z_!uS!T$iVJ^j;py1cR3!lM{>12yPY z-wZw(cmZ*La+h~;a>yjeKTdhBBgCHjUI*)5_ux<1PgcAE^*JN8M(gE#&-omGI>NRu4@2Xq zkyv~WJGLH-ru($dj;*ww<9^n(|MJP%@7zRO_>4Dp2+A4>3QaTO%_Fz|9`)h-*x z)Q>hIq-jUg`1j%G?3Jv@k+s!?c9|LZy8boq9vl6bG`%)8Yne%qb{}+p6+eqVYQF_| zbW07r=h|i+8r?%D;&WaN! z%bn{@){^<3Tj~B4@ZW>&!!pBc6mukm57gH);=kGx!5#|?uYVM>fsq_Xll0C7et3AZ zViUQ(j%_1Qy=yoO6@;S6x0F=X}_zu3m&z;%QQkE(VNY9`@Ax zcf^0UZI!>Anr@n8U_L`B9R5vSALD=8*Txb#G+PT`IR%(GKA79n-n|us%P>01$njn)Hq75El%%}2 zJi5o?Rpb{Y=H>>}CSq8$vghXY#tn3md|B~+wF1MY*)_aCXKkgp<0FHWT-Vi}4gH8b zH>TU6)ucDFk{hPdJ+cjYj+yZ1z`qV{)mF~d4U2&zN4dxyGQed1b;Vmg!bjYr9(#Em zba+!2FWzDrrylLP^IgP#&UAUet9UkF(4^Cxnkei_Yr=Y#(MVJqEH1;>dsGX!mh zRf+P$o`)yizN=3hLwJ0R3r?2iPr8y73m4*ZpVF8-J>m^^OzJoP0B3}M%PVJP{a0;x zwX@7j)s>*X6W_z|-VQA#%~knnAH4DX5B6R7Y2aOx+~ZNZMhqOY5=!UzRFF>xsIJ>f zuofy8!yXs6upl`fg4ySs_3mqy_=EA!!M_V) zkNZmb1d;~Yk!>X79dbvguPf_Vj8o-Y(|>{QO94X#4r-1xm*e;DTeA3hf2qzcHN8q{ z0V6QPRaE+Np=+nL(zI_4LS)+v3Zt0_1M@v=;-85>@KA{%x{@ysTVfSFO2mKYB!61@ z%f~;p=Z!pGXAP@O94-FVB5^$M%Wv zPl97%ajM&yhJSXC^M0IH&0iP)0N|h&@#)iAcw#>)>5e|oYcsOSW_PRuuZDnAvvc*CxHHIX({!?@!)qxyf4{m}2gnxntQLx8sl61H?WqxOp#i z8;MC>y91S6eFqiKcvs^tx#0V$?ljhkZY52dl2Boem$Gh&L< zD<`p{S7*~-4fHSgMZ6=W+LQay51B5}kKQoJ{)6zXd39u27-p*7U#+xK4Ds70xp{RK zX&CMt`_?HM@|lF>nrkk4aZ>!glpI%i=Mkptb5aEclA1|c6R;Af6rc>#Q0Elzhc%_{ zG(JokVzEw@DyH7F?W>&7G8JZShNe}>=A&dP*;-VSoD3YN(lxwlJxazIJtwkV#ft=N+Vr1+xVYu1P>S4;&NKC>n_7*J?&Ehp%5gx^q8A-S4xkP=`t+>vb$f6>cORBLTbe;( zbt(Pj#?k)I0;c0HeYPkE?_^ZtsWl7hmdbF4&OhuLYxqCdTVNjSaY!xi=g*lUqMzXlkLy}U!H60B>Rb*p^L=O&GXDUuk;pPTNPpTj zJT`FJ90-6PO1++(Q(P7t3SqdY1~?ywPvuVNJJdc~gOSBKCG5us-hgDAkG&m7r6@V< zDcJ+9AvVbHaZXn3diqoHI#gvtXB-+<3*3|_IHgq;3b9OPr9uyStAOkbQ!rDdKLpdZ zdr(mrkdsmZNWh?=VN6`505~ps^HLbpdYWK$rwMc3fgv_?k;Mu~JkqvWov@M8ftK%O zvimi-fmlukB_!u3)~flK9Wzn+45uNf%OE^-H8&+I4<~TVHK84cH8O#>ibCFkoyLeZ zlo>rLWo5@4QWp)zIHt5^&lJIuf(JdRbM&YHbnEwONWeX50u--$LR=0xslp6)q;&(@ ziwt5meW^eOn0EH2mMStRB%M?NQBQ3Z%9N26l#W5B`P+v~QVh^5nG^VaOGfi-W)Ttp z04&MMnn=7r_GCwW6C;E7*OjfW*Xv6)w0ALWbXNp)BZ?afkwbQuC>l2mJ*r^GpF!TS zC(&Nf{M#vvWPbtVRB5l635NPMLVt}%27z4P+8k6;pa+h%1Q)Qne(FHW+XkfFyPl?) z3lzH>C#S7SE)9ZB`Ca;NV0|*6tyUGjDv&Lp*a;O zkY}w&G{~npr(z|kAaE%fQQL~IB)httR1Yv6TAhT8Eg|k|2bm32mh5AKYOCFrI0puc z6>{vCL>>)Smh&A=QF-0J#-fui-is31Y3~<5da28F+~D=5Y&5$DXt0Q6xOq6J9^KgF zo+|R9XMsq|+Zqg>oBzgApP(9eqM9RSOY8O1!*v{ffHs(CwR1!(S$9jB)R2r=$ zi;tAk(dahpK_eXItIa9qpET%*a=?9SKI2kA2m>aqM9s?Np|=8i4_cz+bCN2(#r)0) zP!u0Zr!0=Z7ACaiA;>Pu9P`a$UM@-6D@sOFj&gl#KH;#(+N+njo0W_^yEb#pP?p7% zA6nd$JJZA`6{GfY-Kt3J8P5uTD%Lbx8?2Jl%UF(T#CGKO z0=1*jUGQ*6)~>~&FkzmR-3%joob$xRbuuEe=yvBdp>3xb4@_2&)0R9Ab5^0YAaE