Phase 10: variety, twelve scenes, and tooling to write the next one
Watching several finished tracks side by side turned up the problem neither Phase 8 (too few cuts) nor Phase 9 (no through-line) addressed: the same scene cast in two different videos looked like the same footage twice. Section bias is nearly identical between two tracks' drops, so both sampled their parameters around the same centre, and the library's own averageness did the rest. Three answers, none of them a new scene: Temperament — a per-track hand on every parameter dial: intensity, pace, detail, and an extremity that decides how far toward the ends of a range the track is willing to sample. Bias comes from the section and is shared between tracks; temperament comes from the track and is not. Overlays — sometimes a second full scene composited over the shot, from a different family, in a blend that preserves what is underneath and never above 0.6 opacity. Not always: a stack that always doubled up would read as permanently cluttered rather than as occasionally layered. A wider palette — hue now derives from SPECTRAL TILT, the log ratio of treble to body. The centroid is a number most masters sit in the middle of, and the plain body/(body+treble) fraction is worse: low frequencies carry most of the energy in all music, so it read 0.98-1.00 for everything and four different battery tracks came out within 0.02 of each other. The ratio is multiplicative, so its logarithm is what spreads — the same four measure -9.3, -5.0, -4.1, -3.8. Also both ways round the wheel (violet, magenta and pink were unreachable by construction), four new schemes, and seeded chroma profile and lightness curve. Closest battery pair went from 0.005 to 0.113. Twelve scenes take the library to 36, six per family: Aurora Veil, Vortex Drift, Tide Rings, Ink Bleed, Dust Chamber, Salt Flat, Cargo Belt, Gate Corridor, Circuit Bloom, Truchet Fold, Signal Decay, Storm Rift. Weighted toward the 'space' and 'shape' traits, which were thinnest and so the signatures most likely to run a track out of cast — the Phase 9 casting rule means the pool a track draws from is smaller than the library. Also fixes a real one in shots.js: heavy LRU weighting was not enough to make a section reach its whole roster, and a five-shot section still came out 0,2,0,2,0 about a fifth of the time. An unseen companion now wins outright; which one is still free, so only the coverage is guaranteed. Block Mosh declared the camera trait, assigned sigCamera(p) to a p it then never read, and passed the lint's evidence grep. The Phase 9 render gate measured its response to the camera at exactly zero. --- tooling --- Adding a scene was mostly boilerplate and round-trips, which is expensive in both senses. The irreducible cost is the shader body; everything around it is now mechanical: npm run new:scene -- "Name" --family=... --traits=... writes the module, registers it, and leaves a skeleton that already passes every gate, with name-derived constants so two skeletons are not twins. The lint grew the rules that previously needed a GPU to catch: the dead camera above, prev() with no base image, and large loops with no early break (with a `// lint: fixed-cost` opt-out for a genuinely fixed-cost sampling loop). checks.html?scene=Name runs the per-scene acceptance battery for one scene — ten lines and a verdict instead of rendering the whole library to find out whether one shader is alive. The same procedure is a repo skill under .claude/skills/build-visualizer/. --- checks changed, with the measurements --- P5 determinism compared two WebGL CONTEXTS, which is not what it is for. Measured: one context is bit-exact over 40 frames with feedback at 0.6; two contexts disagree by up to 2/255 whether feedback is on or off. It now asserts generation is byte-identical (hard) and rendering within 2/255, since feedback compounds single-level variance. P10's cross-track comparison measures distance RELATIVE to how much image there is. Most scenes are mostly dark, so two genuinely different renders — 25 bars against 53 — scored under 0.02 absolute purely because the black background agrees with itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
0ee7a6a3b3
commit
2806ef1386
99
flow-state/.claude/skills/build-visualizer/SKILL.md
Normal file
99
flow-state/.claude/skills/build-visualizer/SKILL.md
Normal file
@ -0,0 +1,99 @@
|
||||
---
|
||||
name: build-visualizer
|
||||
description: Add a new visualizer (scene) to flow-state, or fix one that is failing its gates. Use when asked to build/add/write a visualizer, scene, or shader for this project, when a scene fails lint or the phase gates, or when the scene library needs another entry in some family. Covers the scaffolder, the shader contract, the personality traits, and the single-scene gate loop.
|
||||
---
|
||||
|
||||
# Build a visualizer
|
||||
|
||||
A scene is **a fragment shader plus a params block**. Uniform binding, UI sliders,
|
||||
per-track sampling, arc drift and every gate are derived from the schema — there
|
||||
is no per-scene wiring to write.
|
||||
|
||||
The full reference is [HOWTO-visualizers.md](../../../HOWTO-visualizers.md). Read
|
||||
it once for the contract details. This file is the *procedure*, and following it
|
||||
in order is what keeps a new scene from costing several rounds of trial and error.
|
||||
|
||||
## The loop
|
||||
|
||||
```bash
|
||||
npm run new:scene -- "Salt Flat" --family=minimal --traits=shape,camera,space,style
|
||||
```
|
||||
|
||||
That writes `src/scenes/shader/salt-flat.js`, registers it, and leaves a skeleton
|
||||
that already passes every gate — live, animated, seeded, distinct from every
|
||||
other scene, honouring exactly the traits it declares. Add `--feedback` if the
|
||||
scene will read `prev()`.
|
||||
|
||||
Then, in order:
|
||||
|
||||
1. **Write the concept comment first.** Two lines: what it looks like, and what
|
||||
makes it different from the scenes it sits beside. "No two scenes render the
|
||||
same image" is a gate with a numeric floor, not a guideline. If you cannot
|
||||
write the second line, the scene does not exist yet.
|
||||
2. **Replace the `scene()` body.** Keep the skeleton's trait calls; they are
|
||||
what the casting rule is checked against.
|
||||
3. **Lint.** `npm run lint:scenes` — one second, no browser, catches schema and
|
||||
shader disagreeing, undeclared uniforms, missing `rate: true`, a declared
|
||||
trait with no evidence, a dead camera, `prev()` with no base image, and
|
||||
unbounded loops.
|
||||
4. **Gate the one scene.** Open
|
||||
`http://localhost:5180/checks.html?scene=Salt%20Flat`.
|
||||
Ten-ish lines: schema, renders, animates, deterministic, distinct, param
|
||||
sweep, flash rate, and one line per declared trait. This is the same battery
|
||||
Phases 2, 5 and 7 apply library-wide, filtered to your scene.
|
||||
5. **Run the library gates** once at the end: `checks.html?slow=1`. Phases 2, 5
|
||||
and 7 iterate the registry, so the new scene is covered automatically.
|
||||
|
||||
Do not skip 3 before 4, or 4 before 5. Each step is roughly ten times cheaper
|
||||
than the next and catches a different class of mistake.
|
||||
|
||||
## Choosing family and traits
|
||||
|
||||
**Family** decides which section kinds can cast the scene — a breakdown never
|
||||
lands on a strobing glitch scene. Aim for 4-6 scenes per family; check the
|
||||
current spread with:
|
||||
|
||||
```bash
|
||||
node -e "import('./src/scenes/registry.js').then(({scenes})=>{const b={};for(const m of scenes)(b[m.family]??=[]).push(m.name);console.log(b)})"
|
||||
```
|
||||
|
||||
**Traits** are a contract, not a hint. Each track is built on a signature of one
|
||||
or two traits and **a scene that does not honour all of them is never cast in
|
||||
that track**. Declare only what the shader genuinely uses:
|
||||
|
||||
| trait | call | what it means for your scene |
|
||||
|---|---|---|
|
||||
| `shape` | `sigShape(p)` / `sigForm(p, at, size)` | every element you draw is the track's signature form, not your own circle or box |
|
||||
| `camera` | `sigCamera(p)` | your coordinate is filmed by the track's operator. **Must feed the image** — assigning it to a `p` you then ignore is a dead camera, and both the lint and the render gate will say so |
|
||||
| `space` | `sigHorizonY()`, `sigAir(col, p, d)` | your ground is at the track's horizon and your distance haze is the track's |
|
||||
| `style` | `sigEdge(d)`, `sigGrain(uv)`, `sigFolded(p)`, `u_sigLine/Soft/Texture/Fold` | your lines are drawn in the track's weight |
|
||||
|
||||
Prefer thin traits. `space` and `shape` carry the most identity and have the
|
||||
fewest scenes, so they are usually where another scene is worth most.
|
||||
|
||||
## The five mistakes that actually happen
|
||||
|
||||
1. **A rate param that is not flagged.** Anything multiplying `u_time` needs
|
||||
`rate: true`, or reactivity jumps the phase by `elapsed × Δrate` and the scene
|
||||
strobes. Add a bounded term instead: `u_time * u_speed + u_bandLow * 2.0`.
|
||||
2. **Whole-frame luminance on the beat.** That is the WCAG 2.3.1 failure the
|
||||
flash gate exists for. Pulse something local; quantise glitches onto
|
||||
`floor(u_barPhase * n)` so they step with the music.
|
||||
3. **A declared trait the image does not respond to.** Passes the eye, fails the
|
||||
gate. Both cost the same to fix before you commit and much more after.
|
||||
4. **Reading `prev()` with nothing underneath.** Black for the first frames,
|
||||
different after a seek than after playback. Always draw a base field.
|
||||
5. **A contract uniform name reused as a param** (`u_width`, `u_time`, `u_seed`).
|
||||
GLSL redefinition; the only symptom is a black frame.
|
||||
|
||||
## When a gate fails
|
||||
|
||||
- **not distinct** — the closest scene is named in the output. Change the
|
||||
structure, not the palette; colour comes from the track.
|
||||
- **dead/blown in the param sweep** — the sweep pushes each param to its limits
|
||||
alone. Usually a range that should not reach 0, or one that saturates.
|
||||
- **flash rate over 3/s** — find the term that swings the whole frame and make it
|
||||
local or smooth.
|
||||
- **not deterministic** — something is reading wall-clock or unseeded randomness;
|
||||
the lint greps for the usual suspects, but `fwidth`-style derivative tricks can
|
||||
also differ. Everything must be a function of `u_time`/`u_frame` and `u_seed`.
|
||||
@ -1,5 +1,14 @@
|
||||
# How to build a visualizer (scene)
|
||||
|
||||
> **Start here:** `npm run new:scene -- "My Scene" --family=glitch --traits=camera,style`
|
||||
>
|
||||
> That writes the module, registers it, and leaves a skeleton that already passes
|
||||
> every gate. Then write the `scene()` body, `npm run lint:scenes`, and open
|
||||
> `checks.html?scene=My%20Scene`. The rest of this file is the reference.
|
||||
>
|
||||
> There is also a repo skill — `.claude/skills/build-visualizer/` — which is the
|
||||
> same procedure in the form an agent will follow.
|
||||
|
||||
A scene is **a fragment shader plus a params block** — nothing else. Uniform
|
||||
binding, the generated UI sliders, seeded per-track sampling, arc drift and the
|
||||
phase-gate checks are all derived from the schema. There is no per-scene wiring.
|
||||
@ -36,7 +45,10 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
};
|
||||
```
|
||||
|
||||
Register it in `src/scenes/registry.js` (import + push into `MODULES`). Done.
|
||||
The scaffolder writes all of that for you, including the registry import and
|
||||
entry, and bakes name-derived constants into the skeleton field so two freshly
|
||||
scaffolded scenes are not identical to each other. To do it by hand: write the
|
||||
file and add the import + `MODULES` entry in `src/scenes/registry.js`.
|
||||
|
||||
## The shader contract
|
||||
|
||||
@ -128,19 +140,46 @@ The lint greps your shader and fails a declared trait with no evidence. Declarin
|
||||
## Families
|
||||
|
||||
Chosen by section kind in the arc driver — a breakdown never lands on a strobing
|
||||
glitch scene. Current counts (aim: 4-6 each): flow 2, organic 3, minimal 3,
|
||||
structural 2, geometric 3, **glitch 3**. The thin families are the best place to
|
||||
contribute next.
|
||||
glitch scene. The library is at **six per family** (36 scenes), which is the
|
||||
target. Check the current spread before adding another:
|
||||
|
||||
```bash
|
||||
node -e "import('./src/scenes/registry.js').then(({scenes})=>{const b={};for(const m of scenes)(b[m.family]??=[]).push(m.name);console.log(b)})"
|
||||
```
|
||||
|
||||
Depth matters more than it looks: the casting rule in `look/Personality.js`
|
||||
disqualifies scenes that do not honour the track's signature traits, so the pool
|
||||
a given track draws from is smaller than the library. Thin traits (`space`,
|
||||
`shape`) are worth more than thin families.
|
||||
|
||||
## Verify
|
||||
|
||||
Three rungs, each about ten times cheaper than the next. Climb them in order.
|
||||
|
||||
```bash
|
||||
npm run lint:scenes # schema/shader agreement both ways + determinism grep
|
||||
npm test # audio pipeline (unaffected, but cheap)
|
||||
npm run lint:scenes # ~1s, no browser
|
||||
```
|
||||
|
||||
Then the GPU gates in the browser at `http://localhost:5180/checks.html`:
|
||||
Phase 2 (param range sweep — no NaN/black/white), Phase 5 (flash-rate sweep),
|
||||
Phase 7 (per-scene acceptance: distinctness, liveness, animation, determinism,
|
||||
4K budget). These iterate the registry, so a new scene is covered automatically
|
||||
once registered.
|
||||
Static gates: schema and shader agreeing both ways, determinism grep, rate flags,
|
||||
a declared trait with no evidence in the source, a **dead camera**
|
||||
(`p = sigCamera(p)` and then nothing reads `p`), `prev()` with no base image, and
|
||||
loops with a large bound and no early break. A loop whose cost is genuinely fixed
|
||||
— sampling a curve at a set resolution — can say so with a `// lint: fixed-cost`
|
||||
comment just above it.
|
||||
|
||||
```
|
||||
http://localhost:5180/checks.html?scene=My%20Scene
|
||||
```
|
||||
|
||||
The per-scene acceptance battery for one scene: schema, renders, animates,
|
||||
deterministic, distinct from every other scene, param sweep, flash rate, and one
|
||||
line per declared trait proving the image actually responds to it. Ten lines and
|
||||
a verdict — this is the loop to stay in while writing.
|
||||
|
||||
```
|
||||
http://localhost:5180/checks.html?slow=1
|
||||
```
|
||||
|
||||
Everything. Phases 2, 5 and 7 iterate the registry so a new scene is covered
|
||||
automatically; Phases 8-10 cover how the look generator uses it. Run this once
|
||||
before committing.
|
||||
|
||||
@ -528,6 +528,57 @@ to draw a hexagon should not appear in the hexagon video.
|
||||
- **Watch the battery.** The target is that a viewer could name the through-line on a second
|
||||
viewing — not the first. No metric expresses that.
|
||||
|
||||
### Phase 10 — variety
|
||||
The third finding from watching real tracks, after Phase 8 (too few cuts) and Phase 9 (no
|
||||
through-line): **the same scene cast in two different videos looked like the same footage
|
||||
twice.** Section bias is nearly identical between two tracks' drops, so both sampled their
|
||||
parameters around the same centre, and the library's own averageness did the rest.
|
||||
|
||||
Three answers, none of them a new scene:
|
||||
|
||||
- **Temperament.** A per-track hand on every parameter dial — intensity, pace, detail, and an
|
||||
*extremity* that decides how far toward the ends of a range the track is willing to sample.
|
||||
Bias comes from the section and is shared; temperament comes from the track and is not.
|
||||
- **Overlays.** Sometimes a second full scene is composited over the shot at partial opacity,
|
||||
from a different family, in a blend that preserves what is underneath. Not always — a stack
|
||||
that always doubled up would read as permanently cluttered rather than as occasionally
|
||||
layered.
|
||||
- **A wider palette.** Hue derives from **spectral tilt** — the log ratio of treble to body —
|
||||
rather than the centroid or a plain body fraction. Both of those collapse: the centroid is a
|
||||
number most masters sit in the middle of, and low frequencies carry most of the energy in
|
||||
all music, so the plain fraction read 0.98-1.00 for everything and four different battery
|
||||
tracks came out within 0.02 of each other. The ratio is multiplicative, so its logarithm is
|
||||
what spreads. Plus: both ways round the wheel (violet, magenta and pink were previously
|
||||
unreachable by construction), four new schemes, and seeded chroma profile and lightness
|
||||
curve.
|
||||
|
||||
The library also grew to **36 scenes, six per family** — depth matters more than it looks,
|
||||
because the Phase 9 casting rule means the pool a given track draws from is smaller than the
|
||||
library.
|
||||
|
||||
**Gate:**
|
||||
- Temperaments spread across a battery rather than collapsing to one value.
|
||||
- One scene rendered under two tracks' parameters differs — measured RELATIVE to how much
|
||||
image there is, because most scenes are mostly dark and an absolute frame distance scores
|
||||
two genuinely different sparse renders as nearly identical.
|
||||
- Overlays occur on 5-55% of stacks, never at `normal` blend, never above 0.6 opacity.
|
||||
- Every sixth of the colour wheel is reachable across a sampled population, and two tracks
|
||||
that sound different do not get the same palette.
|
||||
|
||||
### Tooling, added with Phase 10
|
||||
Adding a scene was mostly boilerplate and round-trips, which is expensive in both senses.
|
||||
|
||||
- `npm run new:scene -- "Name" --family=… --traits=…` writes the module, registers it, and
|
||||
leaves a skeleton that already passes every gate (with name-derived constants, so two
|
||||
skeletons are not identical to each other).
|
||||
- The lint grew the rules that used to need a GPU to catch: a **dead camera**
|
||||
(`p = sigCamera(p)` and then nothing reads `p` — a real scene shipped like that and the
|
||||
Phase 9 render gate measured its response at exactly zero), `prev()` with no base image, and
|
||||
large loops with no early break (with a `// lint: fixed-cost` opt-out).
|
||||
- `checks.html?scene=Name` runs the per-scene acceptance battery for ONE scene: ten lines and
|
||||
a verdict, instead of rendering the whole library to find out whether one shader is alive.
|
||||
- `.claude/skills/build-visualizer/` is the same procedure as a repo skill.
|
||||
|
||||
---
|
||||
|
||||
## 10. Detachment from `party-stage`
|
||||
|
||||
@ -8,7 +8,8 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint:scenes": "node tools/lint-scenes.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
"test": "node --test test/*.test.js",
|
||||
"new:scene": "node tools/new-scene.js"
|
||||
},
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { runAll, summarize, allChecks } from './framework.js';
|
||||
import { runSceneGate } from './scene-gate.js';
|
||||
|
||||
// Registering a phase's checks is a side effect of importing it.
|
||||
import './phase0.js';
|
||||
@ -11,6 +12,7 @@ import './phase6.js';
|
||||
import './phase7.js';
|
||||
import './phase8.js';
|
||||
import './phase9.js';
|
||||
import './phase10.js';
|
||||
|
||||
const out = document.getElementById('results');
|
||||
const summaryEl = document.getElementById('summary');
|
||||
@ -29,6 +31,25 @@ function row(result) {
|
||||
|
||||
async function main() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
// Single-scene mode: the per-scene acceptance battery for one scene, in a
|
||||
// form that is cheap to run and cheap to read. This is the loop you are in
|
||||
// while writing a scene; running all ten phases to find out whether one new
|
||||
// shader is alive is both slow and a page of output to wade through.
|
||||
const sceneArg = params.get('scene');
|
||||
if (sceneArg) {
|
||||
summaryEl.textContent = `gating "${sceneArg}"…`;
|
||||
const { ok, lines } = runSceneGate(sceneArg);
|
||||
out.innerHTML = `<pre class="scene-gate">${lines.join('\n')}</pre>`;
|
||||
summaryEl.textContent = `${sceneArg}: ${ok ? 'PASS' : 'FAIL'} — ` +
|
||||
`${lines.filter((l) => l.startsWith('PASS')).length}/${lines.length} criteria`;
|
||||
summaryEl.className = ok ? 'ok' : 'bad';
|
||||
window.__CHECKS__ = { scene: sceneArg, ok, lines };
|
||||
window.__CHECKS_DONE__ = true;
|
||||
console.log('[scene-gate]', sceneArg, ok ? 'PASS' : 'FAIL', '\n' + lines.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
const phaseArg = params.get('phase');
|
||||
const phases = phaseArg ? phaseArg.split(',').map(Number) : null;
|
||||
const skipSlow = params.get('slow') !== '1';
|
||||
|
||||
265
flow-state/src/checks/phase10.js
Normal file
265
flow-state/src/checks/phase10.js
Normal file
@ -0,0 +1,265 @@
|
||||
// Phase 10 gate — variety.
|
||||
//
|
||||
// Phase 8 gave a track more cuts, Phase 9 gave it a coherent identity. Watching
|
||||
// several tracks side by side exposed what neither addressed: the SAME scene
|
||||
// cast in two different videos looked like the same footage twice. Section bias
|
||||
// is nearly identical between two tracks' drops, so both sampled their params
|
||||
// around the same centre, and the library's own averageness did the rest.
|
||||
//
|
||||
// Three answers, and this gate is what holds them honest:
|
||||
//
|
||||
// temperament — a per-track hand on every parameter dial (Personality.js)
|
||||
// overlays — a second full scene composited over the first, sometimes
|
||||
// palette — a wider reachable colour space, so two tracks differ on
|
||||
// colour before they differ on anything else
|
||||
//
|
||||
// The hard part of testing "variety" is that it is a property of a POPULATION,
|
||||
// not of one render. Every check here therefore samples many tracks and asks
|
||||
// about the spread, never about a single value.
|
||||
|
||||
import { check, expect } from './framework.js';
|
||||
import { Engine } from '../engine/Engine.js';
|
||||
import { scenes } from '../scenes/registry.js';
|
||||
import { generateLook } from '../look/LookGenerator.js';
|
||||
import { AudioPalette, generateUsablePalette, relativeLuminance } from '../look/palette.js';
|
||||
import { Rng } from '../engine/rng.js';
|
||||
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
|
||||
import { synthesizeSectioned } from '../audio/synth.js';
|
||||
import { sampleValues } from '../params/schema.js';
|
||||
import { frameDistance, frameLuminance } from '../engine/hash.js';
|
||||
import { battery as timbreBattery } from './phase3.js';
|
||||
|
||||
/**
|
||||
* Several tracks that genuinely differ in what they sound like — the population
|
||||
* every check here measures. Synthetic, so the gate does not depend on assets.
|
||||
*/
|
||||
let cachedBattery = null;
|
||||
function battery() {
|
||||
if (!cachedBattery) {
|
||||
cachedBattery = [
|
||||
{ name: 'slow ambient', buffer: synthesizeSectioned({ bpm: 84, duration: 110, changeAt: 55 }) },
|
||||
{ name: 'mid house', buffer: synthesizeSectioned({ bpm: 122, duration: 110, changeAt: 50 }) },
|
||||
{ name: 'fast techno', buffer: synthesizeSectioned({ bpm: 148, duration: 110, changeAt: 45 }) },
|
||||
{ name: 'broken beat', buffer: synthesizeSectioned({ bpm: 104, duration: 110, changeAt: 70 }) },
|
||||
].map((t) => ({ ...t, track: FeatureTrack.fromAudioBuffer(t.buffer, { fps: 60 }) }));
|
||||
}
|
||||
return cachedBattery;
|
||||
}
|
||||
|
||||
function makeEngine(track, width = 160, height = 90) {
|
||||
const engine = new Engine({ width, height });
|
||||
engine.timeline.setDuration(track.duration);
|
||||
engine.setFeatureProvider(featureProviderFor(track));
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** Every layer stack in a look, flattened. */
|
||||
const stacksOf = (look) => look.sections.flatMap((s) => s.variants || [s.layers]);
|
||||
|
||||
check(10, 'two tracks do not share a temperament', () => {
|
||||
// The per-track hand on the dials. If these collapsed toward one value the
|
||||
// whole mechanism would be decorative, and the symptom — every video
|
||||
// sampling around the library average — is exactly what it was built for.
|
||||
const looks = battery().map(({ track }, i) => generateLook(track, { seed: 100 + i * 7919 }));
|
||||
const t = looks.map((l) => l.personality.temperament);
|
||||
|
||||
const spread = (key) => Math.max(...t.map((x) => x[key])) - Math.min(...t.map((x) => x[key]));
|
||||
const worst = Math.min(spread('intensity'), spread('pace'), spread('extremity'));
|
||||
|
||||
return expect(worst > 0.25,
|
||||
`intensity ${spread('intensity').toFixed(2)} · pace ${spread('pace').toFixed(2)} · ` +
|
||||
`detail ${spread('detail').toFixed(2)} · extremity ${spread('extremity').toFixed(2)}`);
|
||||
});
|
||||
|
||||
check(10, 'one scene looks different in two different videos', () => {
|
||||
// The complaint, as a number. Take a scene, render it with the parameters
|
||||
// and personality two different tracks gave it, and require the images to
|
||||
// actually differ — far above the 1/255 the determinism checks treat as
|
||||
// noise, but far below "unrecognisable". The same scene should still be
|
||||
// itself; it just should not be the same footage.
|
||||
const looks = battery().map(({ track }, i) => ({
|
||||
track,
|
||||
look: generateLook(track, { seed: 500 + i * 6841 }),
|
||||
}));
|
||||
|
||||
const distances = [];
|
||||
const problems = [];
|
||||
|
||||
for (const module of scenes.filter((m) => m.kind === 'fragment').slice(0, 12)) {
|
||||
const engine = makeEngine(looks[0].track);
|
||||
try {
|
||||
const frames = looks.map(({ look }) => {
|
||||
// Sample this scene the way each track's own generator would.
|
||||
// If the track did not cast it, sample it anyway with that
|
||||
// track's temperament — the question is what this scene WOULD
|
||||
// look like in that video, and falling back to defaults would
|
||||
// compare two identical parameter sets and prove nothing.
|
||||
const stack = stacksOf(look).find((s) => s[0].module === module);
|
||||
const params = stack ? stack[0].params : sampleValues(
|
||||
module,
|
||||
new Rng(look.seed ^ 0x51ed270b),
|
||||
look.sections[0].bias,
|
||||
look.personality.temperament,
|
||||
);
|
||||
engine.setLayerSpecs([{
|
||||
module,
|
||||
params,
|
||||
seed: look.seed & 0x7fffffff,
|
||||
opacity: 1,
|
||||
blend: 'normal',
|
||||
palette: look.palette,
|
||||
personality: look.personality,
|
||||
}]);
|
||||
engine.prime(300);
|
||||
engine.compositor.reset();
|
||||
return Uint8Array.from(engine.readPixels(engine.renderFrame(300)));
|
||||
});
|
||||
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
for (let j = i + 1; j < frames.length; j++) {
|
||||
// Relative to how much image there is, not absolute.
|
||||
// frameDistance averages over every pixel, and most scenes
|
||||
// here are mostly dark — two genuinely different renders of
|
||||
// a sparse scene (25 bars versus 53) score under 0.02 in
|
||||
// absolute terms simply because the black background agrees
|
||||
// with itself. Dividing by the images' own brightness asks
|
||||
// the question that was meant: how different is this, as a
|
||||
// fraction of what is actually on screen.
|
||||
const brightness = Math.max(1e-3,
|
||||
(frameLuminance(frames[i]) + frameLuminance(frames[j])) * 0.5);
|
||||
const d = frameDistance(frames[i], frames[j]) / brightness;
|
||||
distances.push(d);
|
||||
if (d < 0.12) problems.push(`${module.name}: ${d.toFixed(3)}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
engine.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
const mean = distances.reduce((a, b) => a + b, 0) / Math.max(1, distances.length);
|
||||
return expect(problems.length === 0,
|
||||
problems.slice(0, 4).join(' · ') ||
|
||||
`${distances.length} cross-track pairs, mean relative distance ${mean.toFixed(2)}, ` +
|
||||
`closest ${Math.min(...distances).toFixed(2)} (floor 0.12)`);
|
||||
}, { slow: true });
|
||||
|
||||
check(10, 'overlays happen sometimes and not always', () => {
|
||||
// A second scene over the first is the variation valve. Always-on would read
|
||||
// as permanently cluttered and never-on is the state this fixed, so the
|
||||
// check is on the RATE across many tracks rather than on any one stack.
|
||||
let stacks = 0;
|
||||
let withOverlay = 0;
|
||||
const blends = new Set();
|
||||
|
||||
for (const { track } of battery()) {
|
||||
for (let s = 0; s < 6; s++) {
|
||||
const look = generateLook(track, { seed: 900 + s * 5231 });
|
||||
for (const stack of stacksOf(look)) {
|
||||
stacks++;
|
||||
const overlay = stack.slice(1).find((l) => l.module.role !== 'accent');
|
||||
if (overlay) {
|
||||
withOverlay++;
|
||||
blends.add(overlay.blend);
|
||||
if (overlay.blend === 'normal') {
|
||||
// 'normal' would hide the shot entirely rather than
|
||||
// compositing over it — that is what a cut is for.
|
||||
return expect(false, `${overlay.module.name} overlaid with 'normal'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rate = withOverlay / Math.max(1, stacks);
|
||||
return expect(rate > 0.05 && rate < 0.55,
|
||||
`${withOverlay}/${stacks} stacks carry an overlay (${(rate * 100).toFixed(0)}%), ` +
|
||||
`blends: ${[...blends].join(', ')}`);
|
||||
});
|
||||
|
||||
check(10, 'an overlay never hides the shot underneath it', () => {
|
||||
// Opacity is the other half of "composited over" — a 0.9 overlay is a
|
||||
// replacement with extra steps.
|
||||
const problems = [];
|
||||
for (const { track } of battery()) {
|
||||
for (let s = 0; s < 4; s++) {
|
||||
for (const stack of stacksOf(generateLook(track, { seed: 1300 + s * 8641 }))) {
|
||||
for (const layer of stack.slice(1)) {
|
||||
if (layer.module.role === 'accent') continue;
|
||||
if (layer.opacity > 0.6) {
|
||||
problems.push(`${layer.module.name} at ${layer.opacity.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return expect(problems.length === 0, problems.slice(0, 3).join(' · ') || 'all overlays stay under 0.6');
|
||||
});
|
||||
|
||||
check(10, 'the palette reaches the whole colour wheel', () => {
|
||||
// Hue used to be a one-way sweep from red down to blue, which made violet,
|
||||
// magenta and pink unreachable for every track ever generated — a third of
|
||||
// the wheel the tool simply could not produce. Sampled across many tracks,
|
||||
// every sixth of the wheel should now show up.
|
||||
const buckets = new Array(6).fill(0);
|
||||
const samples = 40;
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const rng = new Rng(2000 + i * 7919);
|
||||
const summary = {
|
||||
meanCentroid: 0.2 + (i % 7) * 0.1,
|
||||
meanFlatness: 0.1 + (i % 5) * 0.08,
|
||||
dynamicRange: 0.3 + (i % 4) * 0.15,
|
||||
bpm: 80 + (i % 9) * 12,
|
||||
bandBalance: {
|
||||
sub: 0.2 + (i % 5) * 0.12, low: 0.3 + (i % 3) * 0.2, mid: 0.25 + (i % 4) * 0.1,
|
||||
high: 0.2 + (i % 6) * 0.1, air: 0.15 + (i % 7) * 0.09,
|
||||
},
|
||||
};
|
||||
for (const [r, g, b] of generateUsablePalette(new AudioPalette(summary, rng), 6)) {
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
if (max - min < 0.08) continue; // greys carry no hue
|
||||
let h;
|
||||
if (max === r) h = ((g - b) / (max - min) + 6) % 6;
|
||||
else if (max === g) h = (b - r) / (max - min) + 2;
|
||||
else h = (r - g) / (max - min) + 4;
|
||||
buckets[Math.floor(h) % 6]++;
|
||||
}
|
||||
}
|
||||
|
||||
const empty = buckets.filter((n) => n === 0).length;
|
||||
const names = ['red', 'yellow', 'green', 'cyan', 'blue', 'magenta'];
|
||||
return expect(empty === 0,
|
||||
buckets.map((n, i) => `${names[i]}:${n}`).join(' ') +
|
||||
(empty ? ` — ${empty} sixth(s) unreachable` : ''));
|
||||
});
|
||||
|
||||
check(10, 'two tracks do not get the same palette', () => {
|
||||
// Distinctness, not just coverage: a wheel that is fully reachable but where
|
||||
// every track lands in the same place would pass the check above.
|
||||
//
|
||||
// Measured on the Phase 3 battery rather than this file's, because those
|
||||
// four tracks differ in TIMBRE and this file's differ mostly in tempo.
|
||||
// Palette follows timbre by design, so two tracks that sound alike SHOULD
|
||||
// get similar colour — asking otherwise would be asking the generator to
|
||||
// ignore its own input.
|
||||
const palettes = timbreBattery().map(({ track }) =>
|
||||
generateLook(track, { seed: 61 }).palette); // fixed seed: difference must come from the audio
|
||||
|
||||
let closest = Infinity;
|
||||
for (let i = 0; i < palettes.length; i++) {
|
||||
for (let j = i + 1; j < palettes.length; j++) {
|
||||
let sum = 0;
|
||||
for (let k = 0; k < palettes[i].length; k++) {
|
||||
const a = palettes[i][k], b = palettes[j][k];
|
||||
sum += Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
}
|
||||
closest = Math.min(closest, sum / palettes[i].length);
|
||||
}
|
||||
}
|
||||
const lums = palettes.map((p) => p.map(relativeLuminance).join(','));
|
||||
return expect(closest > 0.08 && new Set(lums).size === palettes.length,
|
||||
`closest pair mean channel distance ${closest.toFixed(3)} (floor 0.08), ` +
|
||||
`${new Set(lums).size}/${palettes.length} distinct`);
|
||||
});
|
||||
@ -13,7 +13,7 @@ import { synthesizeSectioned } from '../audio/synth.js';
|
||||
import { defaultValues, sampleValues } from '../params/schema.js';
|
||||
import { scenes } from '../scenes/registry.js';
|
||||
import { Rng } from '../engine/rng.js';
|
||||
import { frameLuminance, frameVariance } from '../engine/hash.js';
|
||||
import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js';
|
||||
import { peakFlashRate } from '../engine/flash.js';
|
||||
import { particleField } from '../scenes/layers3d/particles.js';
|
||||
import { nebula } from '../scenes/shader/nebula.js';
|
||||
@ -300,25 +300,61 @@ check(5, 'multi-layer looks render live frames across the library', () => {
|
||||
}, { slow: true });
|
||||
|
||||
check(5, 'determinism survives feedback, post and 3D layers together', () => {
|
||||
const build = () => {
|
||||
const show = new Show({ width: 128, height: 72 });
|
||||
show.useTrack(track5(), generateLook(track5(), { seed: 1357 }));
|
||||
show.look.feedback.amount = 0.6;
|
||||
return show;
|
||||
};
|
||||
const a = build();
|
||||
const b = build();
|
||||
// Two INDEPENDENTLY GENERATED looks, rendered through ONE engine.
|
||||
//
|
||||
// This used to build two Shows and compare them, which also compared two
|
||||
// WebGL contexts — and once the library grew heavier scenes that started
|
||||
// failing at 1-2/255 with nothing wrong: measured, the same context renders
|
||||
// the same frames bit-exactly (delta 0 over 40 frames, feedback at 0.6),
|
||||
// while two contexts on the same GPU disagree by up to 2/255 whether
|
||||
// feedback is on or off. That is driver-level variance between contexts, and
|
||||
// it is not what this check is for.
|
||||
//
|
||||
// Sharing the engine isolates the question that matters — does generating
|
||||
// the look twice, and driving layers, feedback, post and a 3D layer twice,
|
||||
// produce the same images — and lets it stay bit-exact rather than
|
||||
// acquiring a tolerance that would hide a real fault.
|
||||
// The two halves are asked separately, because only one of them can be
|
||||
// answered bit-exactly. Generation is pure JS and must match EXACTLY —
|
||||
// anything else is a real fault. Rendering the same look twice comes back
|
||||
// within 1/255 but not always at 0: measured, rebuilding a look recompiles
|
||||
// its programs, and a freshly linked program can differ from the previous
|
||||
// one by a single level on the heavier scenes. That is the same GPU variance
|
||||
// Phase 7 and PLAN.md §1 already account for, and hashing cannot express it.
|
||||
const show = new Show({ width: 128, height: 72 });
|
||||
try {
|
||||
const run = (show) => {
|
||||
const shape = (look) => JSON.stringify(look.sections.map((s) =>
|
||||
(s.variants || [s.layers]).map((v) => v.map((l) =>
|
||||
[l.module.name, l.blend, l.opacity, l.seed, l.params]))));
|
||||
|
||||
const lookA = generateLook(track5(), { seed: 1357 });
|
||||
const lookB = generateLook(track5(), { seed: 1357 });
|
||||
const generationMatches = shape(lookA) === shape(lookB)
|
||||
&& JSON.stringify(lookA.personality) === JSON.stringify(lookB.personality);
|
||||
|
||||
const run = (look) => {
|
||||
show.setLook(look);
|
||||
show.look.feedback.amount = 0.6;
|
||||
show.engine.compositor.reset();
|
||||
const out = [];
|
||||
for (let f = 3000; f < 3060; f++) out.push(show.hashFrame(f));
|
||||
for (let f = 3000; f < 3060; f++) {
|
||||
out.push(Uint8Array.from(show.readPixels(show.renderFrame(f))));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const ha = run(a), hb = run(b);
|
||||
const mismatches = ha.filter((h, i) => h !== hb[i]).length;
|
||||
return expect(mismatches === 0, `${mismatches}/60 frames differed`);
|
||||
|
||||
show.useTrack(track5(), lookA);
|
||||
const fa = run(lookA);
|
||||
const fb = run(lookB);
|
||||
const worst = Math.max(...fa.map((frame, i) => frameMaxDelta(frame, fb[i])));
|
||||
|
||||
// Two levels rather than one, and only because feedback is on: the loop
|
||||
// re-reads its own output at 0.6 gain every frame, so a single-level
|
||||
// difference on frame n is still a fraction of a level on frame n+5.
|
||||
// Measured at 2/255 over 60 frames; a real fault scores in the tens.
|
||||
return expect(generationMatches && worst <= 2,
|
||||
`generation identical: ${generationMatches} · worst render delta ${worst}/255 over 60 frames`);
|
||||
} finally {
|
||||
a.dispose(); b.dispose();
|
||||
show.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
160
flow-state/src/checks/scene-gate.js
Normal file
160
flow-state/src/checks/scene-gate.js
Normal file
@ -0,0 +1,160 @@
|
||||
// The per-scene acceptance battery, runnable for ONE scene.
|
||||
//
|
||||
// The library-wide gates iterate the registry, so a new scene is covered the
|
||||
// moment it is registered — but running them means rendering all thirty-six
|
||||
// scenes and reading a page of results to find out whether the one you just
|
||||
// wrote is alive. That is slow to run and expensive to read, and it is the loop
|
||||
// you are in constantly while writing a scene.
|
||||
//
|
||||
// This runs the same acceptance criteria against a single scene and prints one
|
||||
// line per criterion plus a single verdict. Open:
|
||||
//
|
||||
// checks.html?scene=Aurora%20Veil
|
||||
//
|
||||
// The criteria are deliberately the same ones Phase 2, 5 and 7 apply — this is
|
||||
// a filter over the existing gates, not a second, weaker set of them.
|
||||
|
||||
import { Engine } from '../engine/Engine.js';
|
||||
import { sceneByName, scenes } from '../scenes/registry.js';
|
||||
import { defaultValues, sampleValues, sweepValues, validateModule } from '../params/schema.js';
|
||||
import { Rng } from '../engine/rng.js';
|
||||
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
|
||||
import { synthesizeSectioned } from '../audio/synth.js';
|
||||
import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js';
|
||||
import { peakFlashRate } from '../engine/flash.js';
|
||||
import { generatePersonality } from '../look/Personality.js';
|
||||
|
||||
const PALETTE = [
|
||||
[0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95],
|
||||
[0.98, 0.85, 0.35], [0.55, 0.25, 0.85], [0.2, 0.95, 0.6],
|
||||
];
|
||||
|
||||
const SUMMARY = { meanCentroid: 0.5, meanFlatness: 0.25, dynamicRange: 0.5, bpm: 126, meanLoudness: 0.4 };
|
||||
|
||||
/**
|
||||
* @param {string} name scene name as registered
|
||||
* @returns {{ok: boolean, lines: string[]}}
|
||||
*/
|
||||
export function runSceneGate(name) {
|
||||
const module = sceneByName(name);
|
||||
const lines = [];
|
||||
if (!module) {
|
||||
return {
|
||||
ok: false,
|
||||
lines: [`FAIL no scene named "${name}" — registered: ${scenes.map((m) => m.name).join(', ')}`],
|
||||
};
|
||||
}
|
||||
|
||||
let ok = true;
|
||||
const record = (pass, label, detail) => {
|
||||
ok = ok && pass;
|
||||
lines.push(`${pass ? 'PASS' : 'FAIL'} ${label.padEnd(26)} ${detail}`);
|
||||
};
|
||||
|
||||
const errors = validateModule(module);
|
||||
record(errors.length === 0, 'schema', errors.length ? errors.join(' · ') : 'valid');
|
||||
|
||||
const track = FeatureTrack.fromAudioBuffer(
|
||||
synthesizeSectioned({ bpm: 128, duration: 60, changeAt: 30 }), { fps: 60 });
|
||||
|
||||
const engine = new Engine({ width: 192, height: 108 });
|
||||
engine.timeline.setDuration(track.duration);
|
||||
engine.setFeatureProvider(featureProviderFor(track));
|
||||
|
||||
const personality = generatePersonality(SUMMARY, new Rng(9001));
|
||||
const draw = (params, frame, seed = 4242) => {
|
||||
engine.setLayerSpecs([{
|
||||
module, params, seed, opacity: 1, blend: 'normal', palette: PALETTE, personality,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
return Uint8Array.from(engine.readPixels(engine.renderFrame(frame)));
|
||||
};
|
||||
|
||||
try {
|
||||
// --- alive -------------------------------------------------------
|
||||
const base = draw(defaultValues(module), 600);
|
||||
const lum = frameLuminance(base);
|
||||
const variance = frameVariance(base);
|
||||
record(lum > 0.004 && variance > 0.0008, 'renders something',
|
||||
`luminance ${lum.toFixed(4)} · variance ${variance.toFixed(4)}`);
|
||||
|
||||
// --- animates ----------------------------------------------------
|
||||
const later = draw(defaultValues(module), 600 + 120);
|
||||
const motion = frameMaxDelta(base, later);
|
||||
record(motion > 3, 'animates', `max channel delta ${motion} over 2s`);
|
||||
|
||||
// --- deterministic -----------------------------------------------
|
||||
const again = draw(defaultValues(module), 600);
|
||||
const repeat = frameMaxDelta(base, again);
|
||||
record(repeat <= 1, 'deterministic', `repeat delta ${repeat}/255`);
|
||||
|
||||
// --- distinct from every other scene -------------------------------
|
||||
let closest = 255;
|
||||
let closestName = '';
|
||||
for (const other of scenes) {
|
||||
if (other === module || other.kind !== 'fragment') continue;
|
||||
engine.setLayerSpecs([{
|
||||
module: other, params: defaultValues(other), seed: 4242,
|
||||
opacity: 1, blend: 'normal', palette: PALETTE, personality,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
const d = frameMaxDelta(base, Uint8Array.from(engine.readPixels(engine.renderFrame(600))));
|
||||
if (d < closest) { closest = d; closestName = other.name; }
|
||||
}
|
||||
record(closest >= 24, 'distinct', `closest ${closestName} at ${closest} (floor 24)`);
|
||||
|
||||
// --- param sweep ---------------------------------------------------
|
||||
const dead = [];
|
||||
for (const [pname, def] of Object.entries(module.params || {})) {
|
||||
if (def.type === 'palette') continue;
|
||||
for (const value of sweepValues(def, 4)) {
|
||||
const params = { ...defaultValues(module), [pname]: value };
|
||||
const pixels = draw(params, 700);
|
||||
const l = frameLuminance(pixels);
|
||||
const v = frameVariance(pixels);
|
||||
if (!(l > 0.002) || !(v > 0.0002) || l > 0.97) {
|
||||
dead.push(`${pname}=${Array.isArray(value) ? value.join(',') : value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
record(dead.length === 0, 'param sweep',
|
||||
dead.length ? `dead/blown at ${dead.slice(0, 4).join(', ')}` : 'all values live');
|
||||
|
||||
// --- flash rate ------------------------------------------------------
|
||||
const hot = sampleValues(module, new Rng(77), { energy: 0.95, density: 0.9, motion: 0.9 });
|
||||
engine.setLayerSpecs([{
|
||||
module, params: hot, seed: 99, opacity: 1, blend: 'normal', palette: PALETTE, personality,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
const luminance = [];
|
||||
for (let f = 600; f < 900; f++) {
|
||||
luminance.push(frameLuminance(engine.readPixels(engine.renderFrame(f))));
|
||||
}
|
||||
const rate = peakFlashRate(luminance, 60);
|
||||
record(rate <= 3, 'flash rate', `${rate}/s at aggressive settings (ceiling 3)`);
|
||||
|
||||
// --- personality response --------------------------------------------
|
||||
// Every declared trait must move the image; a trait declared and ignored
|
||||
// gets the scene cast in tracks it cannot express.
|
||||
for (const trait of module.traits || []) {
|
||||
const other = generatePersonality(SUMMARY, new Rng(9001));
|
||||
if (trait === 'shape') other.shape = { sides: 6, roundness: 0.05, elongation: 1.3, tilt: 0.7 };
|
||||
if (trait === 'camera') other.camera = { ...other.camera, driftAngle: 1.1, driftRate: 0.06, sway: 0.06, swayRate: 0.2, spin: 0.05, breathe: 0.05 };
|
||||
if (trait === 'space') other.space = { horizon: 0.68, depth: 0.9, washAngle: 2.4, wash: 0.5 };
|
||||
if (trait === 'style') other.style = { lineWeight: 0.95, softness: 0.9, texture: 0.5, symmetry: 4 };
|
||||
|
||||
engine.setLayerSpecs([{
|
||||
module, params: defaultValues(module), seed: 4242,
|
||||
opacity: 1, blend: 'normal', palette: PALETTE, personality: other,
|
||||
}]);
|
||||
engine.compositor.reset();
|
||||
const changed = frameMaxDelta(base,
|
||||
Uint8Array.from(engine.readPixels(engine.renderFrame(600))));
|
||||
record(changed > 1, `trait: ${trait}`, `delta ${changed}/255`);
|
||||
}
|
||||
} finally {
|
||||
engine.dispose();
|
||||
}
|
||||
|
||||
return { ok, lines };
|
||||
}
|
||||
@ -179,27 +179,68 @@ function derivePost(summary, rng) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One layer stack: a background scene plus an optional accent over it.
|
||||
* One layer stack: a background scene, sometimes a second scene composited over
|
||||
* it, sometimes an accent on top of that.
|
||||
*
|
||||
* The accent is composited additively at low opacity and drawn from a DIFFERENT
|
||||
* family, so it reads as depth rather than as a second competing scene. Quiet
|
||||
* material mostly goes without — an intro is supposed to be sparse.
|
||||
* Three deliberately different jobs:
|
||||
*
|
||||
* background — the shot. Always present, always opaque.
|
||||
* overlay — a SECOND full scene at partial opacity. Not always: this is the
|
||||
* variation valve, and a stack that always doubled up would read
|
||||
* as permanently cluttered rather than as occasionally layered.
|
||||
* Drawn from a different family so the two images argue instead
|
||||
* of blurring, and kept off scenes that are already busy.
|
||||
* accent — the depth pass. Mostly-empty by design (role: 'accent'),
|
||||
* additive, low opacity.
|
||||
*
|
||||
* Quiet material mostly goes without either — an intro is supposed to be sparse.
|
||||
*/
|
||||
function buildStack(module, accentRoster, bias, rng) {
|
||||
function buildStack(module, accentRoster, overlayRoster, bias, rng, temperament) {
|
||||
const layers = [{
|
||||
module,
|
||||
params: sampleValues(module, rng, bias),
|
||||
params: sampleValues(module, rng, bias, temperament),
|
||||
seed: rng.int(0, 0x7fffffff),
|
||||
blend: 'normal',
|
||||
opacity: 1,
|
||||
}];
|
||||
|
||||
// --- overlay --------------------------------------------------------
|
||||
// Roughly a third of stacks on busy material, rarely on quiet material, and
|
||||
// never on a background that is itself a full-frame glitch — two competing
|
||||
// corruption passes is noise, not depth.
|
||||
const overlayChance = module.family === 'glitch'
|
||||
? 0.05
|
||||
: 0.12 + bias.energy * 0.35 + (temperament ? Math.max(0, temperament.detail) * 0.2 : 0);
|
||||
|
||||
const overlays = overlayRoster.filter((m) => m.family !== module.family && m.name !== module.name);
|
||||
if (overlays.length && rng.bool(Math.min(0.6, overlayChance))) {
|
||||
const overlay = rng.pick(overlays);
|
||||
// Screen and add keep the background readable underneath; softlight and
|
||||
// overlay tint it instead. All four preserve the shot; 'normal' would
|
||||
// simply replace it, which is what the shot cut is for.
|
||||
const blend = rng.pickWeighted(['screen', 'add', 'softlight', 'overlay'], [3, 2, 2, 1]);
|
||||
layers.push({
|
||||
module: overlay,
|
||||
params: sampleValues(overlay, rng.fork(`overlay:${overlay.name}`), {
|
||||
// An overlay reads as texture over the shot, so it is sampled
|
||||
// sparser and calmer than it would be as a background.
|
||||
...bias,
|
||||
density: Math.max(0, bias.density - 0.25),
|
||||
energy: Math.max(0, bias.energy - 0.2),
|
||||
}, temperament),
|
||||
seed: rng.int(0, 0x7fffffff),
|
||||
blend,
|
||||
opacity: blend === 'add' ? rng.range(0.18, 0.42) : rng.range(0.25, 0.55),
|
||||
});
|
||||
}
|
||||
|
||||
// --- accent ---------------------------------------------------------
|
||||
if (accentRoster.length && rng.bool(bias.energy * 0.8)) {
|
||||
const eligible = accentRoster.filter((m) => m.family !== module.family);
|
||||
const accent = rng.pick(eligible.length ? eligible : accentRoster);
|
||||
layers.push({
|
||||
module: accent,
|
||||
params: sampleValues(accent, rng.fork('accent'), bias),
|
||||
params: sampleValues(accent, rng.fork('accent'), bias, temperament),
|
||||
seed: rng.int(0, 0x7fffffff),
|
||||
blend: rng.pickWeighted(['add', 'screen'], [2, 1]),
|
||||
opacity: rng.range(0.18, 0.5),
|
||||
@ -242,13 +283,20 @@ export function generateLook(track, { seed = null, samples = null, overrides = n
|
||||
const accentRoster = scenes.filter((m) => m.role === 'accent'
|
||||
&& sceneHonours(m, personality.signature));
|
||||
|
||||
// Scenes eligible to be composited OVER a background. Same casting rule as
|
||||
// everything else — an overlay is on screen as much as the shot under it,
|
||||
// so an off-design one would be just as visible.
|
||||
const overlayRoster = scenes.filter((m) => m.role !== 'accent'
|
||||
&& sceneHonours(m, personality.signature));
|
||||
|
||||
const sections = track.sections.map((section) => {
|
||||
const roster = rosterByKind.get(section.kind) || [scenes[0]];
|
||||
const sectionRng = rng.fork(`section:${section.index}:${roster[0].name}`);
|
||||
const bias = biasFor(section, summary);
|
||||
|
||||
const variants = roster.map((module, v) => buildStack(
|
||||
module, accentRoster, bias, sectionRng.fork(`variant:${section.index}:${v}`),
|
||||
module, accentRoster, overlayRoster, bias,
|
||||
sectionRng.fork(`variant:${section.index}:${v}`), personality.temperament,
|
||||
));
|
||||
|
||||
const shots = planShots(
|
||||
@ -313,8 +361,10 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
|
||||
if (!roster.length) roster.push(scenes[0]);
|
||||
|
||||
const accentRoster = scenes.filter((m) => m.role === 'accent');
|
||||
const overlayRoster = scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature));
|
||||
section.variants = roster.map((module, v) => buildStack(
|
||||
module, accentRoster, section.bias, rng.fork(`variant:${v}`),
|
||||
module, accentRoster, overlayRoster, section.bias, rng.fork(`variant:${v}`),
|
||||
look.personality && look.personality.temperament,
|
||||
));
|
||||
section.shots = planShots(
|
||||
section, track, section.bias, section.variants.length, rng.fork('shots'),
|
||||
|
||||
@ -21,6 +21,14 @@
|
||||
// style — the art direction. Line weight, edge softness, texture, and how
|
||||
// many times the frame is folded.
|
||||
//
|
||||
// Plus a fifth thing that is not a trait and is not declared by anyone: the
|
||||
// TEMPERAMENT. Traits decide what a track looks like; temperament decides how
|
||||
// hard it commits. It is the track's hand on every scene's parameter dials, and
|
||||
// it exists because section bias alone is nearly identical between two tracks'
|
||||
// drops — so one scene cast in two videos sampled around the same centre both
|
||||
// times and the videos looked like each other. Temperament is per track and
|
||||
// pushes those samples apart. See params/schema.js sampleValues.
|
||||
//
|
||||
// A scene declares which traits it can honour. Each track picks a SIGNATURE of
|
||||
// one or two traits, and a scene that does not honour all of them is
|
||||
// disqualified from that track — the library shrinks per track, on purpose. A
|
||||
@ -64,6 +72,7 @@ export function generatePersonality(summary, rng, countEligible = null) {
|
||||
const bright = summary.meanCentroid;
|
||||
const noisy = Math.min(1, summary.meanFlatness * 3);
|
||||
const fast = Math.min(1, Math.max(0, (summary.bpm - 80) / 80));
|
||||
const loud = Math.min(1, (summary.dynamicRange ?? 0.5) + (summary.meanLoudness ?? 0.3));
|
||||
|
||||
const shape = {
|
||||
// 0 sides means round. Everything else is a polygon the whole track
|
||||
@ -107,9 +116,25 @@ export function generatePersonality(summary, rng, countEligible = null) {
|
||||
symmetry: rng.pickWeighted([1, 1, 2, 3, 4, 6], [6, 4, 2, 2, 2, 1]),
|
||||
};
|
||||
|
||||
// How hard this track pushes every scene it casts. Deliberately wide, and
|
||||
// deliberately not derived from the section: two tracks must be able to
|
||||
// disagree about what "a drop" means.
|
||||
const temperament = {
|
||||
// Up or down on the energy/density dials.
|
||||
intensity: rng.range(-0.85, 0.85) * (0.5 + loud * 0.9),
|
||||
// Up or down on anything that moves.
|
||||
pace: rng.range(-0.8, 0.8) * (0.55 + fast * 0.8),
|
||||
// Fine and busy, or few and large. Independent of loudness on purpose —
|
||||
// a quiet track can be intricate and a loud one can be blunt.
|
||||
detail: rng.range(-0.6, 0.6),
|
||||
// How far toward the ends of a range this track is willing to sample.
|
||||
// The single most effective knob against "every video looks average".
|
||||
extremity: rng.range(0.25, 0.95),
|
||||
};
|
||||
|
||||
const signature = pickSignature(rng, countEligible);
|
||||
|
||||
return { signature, shape, camera, space, style };
|
||||
return { signature, shape, camera, space, style, temperament };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -205,5 +230,9 @@ export function describePersonality(personality) {
|
||||
SHAPE_NAMES[shape.sides] || `${shape.sides}-sided`,
|
||||
];
|
||||
if (style.symmetry > 1) parts.push(`${style.symmetry}-fold`);
|
||||
if (personality.temperament) {
|
||||
const t = personality.temperament;
|
||||
parts.push(`${t.intensity >= 0 ? 'hot' : 'cool'} ${t.extremity.toFixed(2)} bold`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
@ -70,10 +70,31 @@ const SCHEMES = {
|
||||
triad: (h) => [h, h + 2.094, h + 4.189, h + 0.5, h + 2.6, h + 4.7],
|
||||
split: (h) => [h, h + 2.6, h + 3.7, h + 0.35, h + 2.9, h + 3.4],
|
||||
duo: (h) => [h, h + 1.9, h + 0.2, h + 2.1, h - 0.25, h + 1.7],
|
||||
// Four evenly spaced hues plus two repeats: the widest spread available, and
|
||||
// the reason a track can now come out looking like four colours rather than
|
||||
// a gradient between two.
|
||||
tetrad: (h) => [h, h + 1.571, h + 3.142, h + 4.712, h + 0.8, h + 2.4],
|
||||
// One hue family carrying the frame, with a single far-off pop. Reads as a
|
||||
// deliberate art-directed choice rather than as a spectrum.
|
||||
accented: (h) => [h, h + 0.25, h - 0.2, h + 0.45, h + 2.9, h + 3.05],
|
||||
// One hue, everything else carried by lightness and chroma. Needs the
|
||||
// widened L range below to stay legible, and gives the library the
|
||||
// near-monochrome look it could not previously reach at all.
|
||||
mono: (h) => [h, h + 0.12, h - 0.1, h + 0.18, h - 0.16, h + 0.08],
|
||||
};
|
||||
|
||||
export const SCHEME_NAMES = Object.keys(SCHEMES);
|
||||
|
||||
/**
|
||||
* Stretch a value around a centre so a narrow real-world range fills 0..1.
|
||||
*
|
||||
* A logistic rather than a linear rescale, because the tails must stay bounded:
|
||||
* an unusually bass-heavy track should land at the warm end, not past it.
|
||||
*/
|
||||
function expand(x, centre = 0.5, slope = 3.0) {
|
||||
return 1 / (1 + Math.exp(-slope * (x - centre) * 4));
|
||||
}
|
||||
|
||||
/** The interface a palette source implements. */
|
||||
export class PaletteSource {
|
||||
/** @returns {number[][]} array of [r,g,b] in 0..1 */
|
||||
@ -107,25 +128,50 @@ export class AudioPalette extends PaletteSource {
|
||||
const rng = this.rng;
|
||||
|
||||
// --- Temperature: the track's timbre signature, not its loudness ---
|
||||
// Warmth places spectral mass from the body (sub/low/mid) against the
|
||||
// trebles (high/air). It is folded through the whole band profile rather
|
||||
// than the centroid alone, because the centroid is a one number that
|
||||
// most mastered pop sits in the middle of — which was why every track
|
||||
// flared green/purple. This is the feel of the sound: a voice-and-body
|
||||
// forward track belongs to the warm end of the wheel, a crisp or airy
|
||||
// track to the cool end.
|
||||
const body = (bandBalance.sub ?? 0.4) * 0.6
|
||||
+ (bandBalance.low ?? 0.4) * 0.9
|
||||
+ (bandBalance.mid ?? 0.3) * 0.4;
|
||||
const treble = (bandBalance.high ?? 0.3) * 0.7
|
||||
+ (bandBalance.air ?? 0.3) * 0.5
|
||||
+ meanCentroid * 0.5;
|
||||
const warmth = body / (body + treble + 1e-6); // 0 = cold, 1 = warm
|
||||
// SPECTRAL TILT — the log ratio of treble energy to body energy — rather
|
||||
// than either the centroid or a plain body/(body+treble) fraction.
|
||||
//
|
||||
// Both of those were tried and both collapse. The centroid is one number
|
||||
// most mastered music sits in the middle of. The plain fraction is worse:
|
||||
// low frequencies carry most of the energy in essentially all music, so
|
||||
// it reads 0.98-1.00 for everything and the four check-battery tracks
|
||||
// came out within 0.02 of each other. The ratio is MULTIPLICATIVE, so its
|
||||
// logarithm is what actually spreads: the same four tracks measure -9.3,
|
||||
// -5.0, -4.1 and -3.8, which is a real axis to hang a palette on.
|
||||
const bands = {
|
||||
sub: bandBalance.sub ?? 0.2, low: bandBalance.low ?? 0.2, mid: bandBalance.mid ?? 0.2,
|
||||
high: bandBalance.high ?? 0.2, air: bandBalance.air ?? 0.2,
|
||||
};
|
||||
const body = bands.sub * 1.0 + bands.low * 0.9 + bands.mid * 0.35 + 1e-7;
|
||||
const treble = bands.high * 0.9 + bands.air * 1.0 + bands.mid * 0.15 + 1e-7;
|
||||
const tilt = Math.log(treble / body);
|
||||
|
||||
// Hue sweeps cold(blue, 240°) -> cyan -> green -> yellow -> warm(red),
|
||||
// so warm material finally reaches red/yellow rather than pooling in the
|
||||
// blue/green gap. A little seeded jitter keeps identical tracks apart.
|
||||
const baseHue = (1 - warmth) * (Math.PI * 4 / 3) + rng.range(-0.45, 0.45);
|
||||
// -9 (nothing above the low mids) .. -2 (bright, airy) covers the range
|
||||
// real material occupies; the centroid keeps a minority vote so two
|
||||
// tracks with the same tilt but different brightness still differ.
|
||||
const tiltWarmth = Math.max(0, Math.min(1, (-2 - tilt) / 7));
|
||||
const warmth = tiltWarmth * 0.7 + (1 - meanCentroid) * 0.3;
|
||||
|
||||
// Hue sweeps cold -> warm, but which WAY round the wheel is seeded.
|
||||
// Going down from red through yellow and green to blue is the obvious
|
||||
// route and the only one that existed; it also means violet, magenta and
|
||||
// pink were unreachable for every track ever generated, because they sit
|
||||
// on the arc the sweep skipped. Half of tracks now take the other way
|
||||
// round, so the same warm/cool reading can land on crimson-through-
|
||||
// magenta instead of crimson-through-amber.
|
||||
//
|
||||
// Both routes span the same arc. A short return leg would mean tracks
|
||||
// that took it barely differ in hue however different they sound.
|
||||
const clockwise = rng.bool(0.5);
|
||||
const span = (clockwise ? 1 : -1) * Math.PI * 4 / 3;
|
||||
|
||||
// Tempo and dynamics nudge the hue too. Timbre is the main axis, but two
|
||||
// tracks can be timbrally alike and still feel different — a slow
|
||||
// spacious one and a fast compressed one should not be handed the same
|
||||
// colour just because they occupy the same part of the spectrum.
|
||||
const feel = ((bpm - 120) / 200 + (dynamicRange - 0.5) * 0.5) * 0.6;
|
||||
|
||||
const baseHue = (1 - warmth) * span + feel + rng.range(-0.45, 0.45);
|
||||
|
||||
const schemeName = rng.pick(SCHEME_NAMES);
|
||||
const hues = SCHEMES[schemeName](baseHue, rng);
|
||||
@ -135,25 +181,50 @@ export class AudioPalette extends PaletteSource {
|
||||
// stays muted. This is the "does it pop" axis, orthogonal to timbre.
|
||||
const fast = Math.min(1, Math.max(0, (bpm - 80) / 150));
|
||||
const energy = Math.min(1, fast * 0.4 + (1 - Math.min(1, meanFlatness)) * 0.4 + dynamicRange * 0.3);
|
||||
const chromaBase = 0.10 + energy * 0.16;
|
||||
// Vividness is the track's, but how far it commits is seeded — the old
|
||||
// fixed mapping meant two tracks with similar statistics got not just
|
||||
// similar hues but the same saturation, which is most of why they read
|
||||
// as the same palette.
|
||||
const vividness = rng.range(0.55, 1.45);
|
||||
const chromaBase = (0.09 + energy * 0.19) * vividness;
|
||||
|
||||
// Chroma profile: does the palette saturate in the middle (the old fixed
|
||||
// behaviour), at the bright end, or barely at all? A near-neutral set
|
||||
// with one vivid accent is a look the generator could not previously
|
||||
// produce.
|
||||
const profile = rng.pickWeighted(['arch', 'rising', 'flat', 'accent'], [3, 2, 2, 2]);
|
||||
const chromaAt = (t) => {
|
||||
switch (profile) {
|
||||
case 'rising': return 0.35 + t * 1.1;
|
||||
case 'flat': return 0.9;
|
||||
case 'accent': return t > 0.72 ? 1.5 : 0.28;
|
||||
default: return 0.55 + Math.sin(t * Math.PI) * 0.75;
|
||||
}
|
||||
};
|
||||
|
||||
// A dynamic mercury gets a wider light-to-dark range; warmth keeps warm
|
||||
// tones from sinking into brown, since dark + orange is mud.
|
||||
const spread = 0.30 + Math.min(1, dynamicRange) * 0.30;
|
||||
const anchor = 0.40 - warmth * 0.06 + rng.range(-0.05, 0.10);
|
||||
const spread = (0.30 + Math.min(1, dynamicRange) * 0.30) * rng.range(0.85, 1.5);
|
||||
const anchor = 0.40 - warmth * 0.06 + rng.range(-0.14, 0.16);
|
||||
// How the lightness steps are distributed: 1.7 keeps most entries dark
|
||||
// with a couple of bright accents (the old fixed curve), below 1 spreads
|
||||
// them evenly, above 2 makes the set almost entirely dark with one
|
||||
// highlight. Another axis two similar tracks can differ on.
|
||||
const curve = rng.range(0.75, 2.4);
|
||||
|
||||
const colors = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = count > 1 ? i / (count - 1) : 0;
|
||||
// Deliberately non-linear: most entries mid-dark, one or two bright.
|
||||
// Scenes use pal(0) as a base and higher indices as accents.
|
||||
const L = Math.max(0.06, Math.min(0.95, anchor + Math.pow(t, 1.7) * spread));
|
||||
const C = chromaBase * (0.55 + Math.sin(t * Math.PI) * 0.75) + rng.range(-0.012, 0.012);
|
||||
const L = Math.max(0.05, Math.min(0.97, anchor + Math.pow(t, curve) * spread));
|
||||
const C = chromaBase * chromaAt(t) + rng.range(-0.012, 0.012);
|
||||
const h = hues[i % hues.length] + rng.range(-0.08, 0.08);
|
||||
colors.push(oklchToRgb(L, Math.max(0, C), h));
|
||||
}
|
||||
|
||||
this.lastScheme = schemeName;
|
||||
this.lastProfile = profile;
|
||||
return colors;
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,14 +147,26 @@ export function planShots(section, track, bias, variantCount, rng) {
|
||||
function pickVariant(variantCount, previous, lastSeen, shotIndex, rng) {
|
||||
if (previous !== 0 && rng.bool(0.75)) return 0;
|
||||
|
||||
// A companion this section has not shown yet wins outright. Weighting it
|
||||
// heavily was not enough — measured, a five-shot section still came out
|
||||
// 0,2,0,2,0 about a fifth of the time, so the roster existed and the shots
|
||||
// never reached it. Which unseen one is still a free choice, so the order
|
||||
// varies between sections; only the coverage is guaranteed.
|
||||
const unseen = [];
|
||||
for (let v = 1; v < variantCount; v++) {
|
||||
if (v !== previous && lastSeen[v] < 0) unseen.push(v);
|
||||
}
|
||||
if (unseen.length) return rng.pick(unseen);
|
||||
|
||||
const options = [];
|
||||
const weights = [];
|
||||
for (let v = 0; v < variantCount; v++) {
|
||||
if (v === previous) continue;
|
||||
options.push(v);
|
||||
// Unseen variants sort first, then by how long ago they were last up.
|
||||
// The anchor stays in the draw so the rotation cannot become rigid.
|
||||
weights.push(v === 0 ? 1 : 2 + (lastSeen[v] < 0 ? variantCount : shotIndex - lastSeen[v]));
|
||||
// Everything has been shown at least once: fall back to least recently
|
||||
// seen, with the anchor kept in the draw so the rotation cannot become
|
||||
// a rigid cycle.
|
||||
weights.push(v === 0 ? 1 : 2 + (shotIndex - lastSeen[v]));
|
||||
}
|
||||
if (!options.length) return 0;
|
||||
return rng.pickWeighted(options, weights);
|
||||
|
||||
@ -91,32 +91,48 @@ export function clampValue(def, value) {
|
||||
* this is how a track's measured character reaches the parameters without every
|
||||
* scene needing to know about audio features. `energy: 0.8` on a hard track
|
||||
* pushes density-ish params up without pinning them, so seed variation survives.
|
||||
*
|
||||
* `temperament` is the track's own hand on the same dials — see
|
||||
* look/Personality.js. Bias comes from the SECTION and is therefore nearly the
|
||||
* same for every track's drop; temperament comes from the TRACK and is not.
|
||||
* Without it, one scene cast in two different videos sampled around the same
|
||||
* centre both times and the two videos looked like the same video, which is
|
||||
* exactly the complaint temperament exists to answer.
|
||||
*/
|
||||
export function sampleValues(module, rng, bias = {}) {
|
||||
export function sampleValues(module, rng, bias = {}, temperament = null) {
|
||||
const out = {};
|
||||
for (const [name, def] of Object.entries(module.params || {})) {
|
||||
if (def.type === 'palette') { out[name] = null; continue; }
|
||||
if (def.fixed) { out[name] = defaultValue(def); continue; }
|
||||
|
||||
const b = def.bias && bias[def.bias] !== undefined ? bias[def.bias] : 0.5;
|
||||
let b = def.bias && bias[def.bias] !== undefined ? bias[def.bias] : 0.5;
|
||||
if (temperament) b = clamp01(b + temperamentShift(def.bias, temperament));
|
||||
|
||||
if (def.type === 'bool') {
|
||||
out[name] = rng.bool(0.25 + b * 0.5);
|
||||
continue;
|
||||
}
|
||||
const [lo, hi] = def.range || [0, 1];
|
||||
// Triangular-ish blend of a uniform draw with the bias target: keeps the
|
||||
// full range reachable (so the seed contact sheet stays wide) while still
|
||||
// letting track character shift the centre of mass.
|
||||
const u = rng.next();
|
||||
|
||||
// How far this track is willing to push a param toward its limits. A
|
||||
// timid track samples near the middle of everything and reads as the
|
||||
// library's average; a bold one commits. This is the difference between
|
||||
// "the same scene again" and "that scene, but this video's version".
|
||||
const extremity = temperament ? temperament.extremity : 0.5;
|
||||
|
||||
// Bias still moves the centre of mass, but a bold track overrides more
|
||||
// of it — otherwise every drop in every video converges on one point.
|
||||
const mixAmount = (def.biasStrength !== undefined ? def.biasStrength : 0.45)
|
||||
* (1 - extremity * 0.45);
|
||||
|
||||
const u = boldUniform(rng.next(), extremity);
|
||||
const target = lo + (hi - lo) * b;
|
||||
const mixAmount = def.biasStrength !== undefined ? def.biasStrength : 0.45;
|
||||
let v = (lo + (hi - lo) * u) * (1 - mixAmount) + target * mixAmount;
|
||||
|
||||
if (def.type === 'vec2') {
|
||||
const u2 = rng.next();
|
||||
const u2 = boldUniform(rng.next(), extremity);
|
||||
const v2 = (lo + (hi - lo) * u2) * (1 - mixAmount) + target * mixAmount;
|
||||
out[name] = [v, v2];
|
||||
out[name] = [clampValue(def, [v, v2])[0], clampValue(def, [v, v2])[1]];
|
||||
continue;
|
||||
}
|
||||
if (def.type === 'int') v = Math.round(v);
|
||||
@ -125,6 +141,31 @@ export function sampleValues(module, rng, bias = {}) {
|
||||
return out;
|
||||
}
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
|
||||
/**
|
||||
* Reshape a uniform draw so a bold track reaches the ends of a range.
|
||||
*
|
||||
* At extremity 0 this is unchanged. As it rises the distribution hollows out:
|
||||
* the same draw lands further from the centre, so a track that wants density
|
||||
* gets scenes at their dense end rather than at a polite 60%.
|
||||
*/
|
||||
function boldUniform(u, extremity) {
|
||||
const signed = (u - 0.5) * 2;
|
||||
const shaped = Math.sign(signed) * Math.pow(Math.abs(signed), 1 - clamp01(extremity) * 0.65);
|
||||
return clamp01(0.5 + shaped * 0.5);
|
||||
}
|
||||
|
||||
/** Which way this track leans on each of the three bias axes. */
|
||||
function temperamentShift(axis, temperament) {
|
||||
switch (axis) {
|
||||
case 'energy': return temperament.intensity * 0.3;
|
||||
case 'density': return temperament.intensity * 0.2 + temperament.detail * 0.3;
|
||||
case 'motion': return temperament.pace * 0.35;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Evenly spaced probe values across a param's range, for the range-sweep check. */
|
||||
export function sweepValues(def, steps = 5) {
|
||||
if (def.type === 'bool') return [false, true];
|
||||
|
||||
@ -24,6 +24,18 @@ import { silkRibbon } from './shader/silk-ribbon.js';
|
||||
import { pylonGrid } from './shader/pylon-grid.js';
|
||||
import { prismBloom } from './shader/prism-bloom.js';
|
||||
import { pitchShatter } from './shader/pitch-shatter.js';
|
||||
import { auroraVeil } from './shader/aurora-veil.js';
|
||||
import { tideRings } from './shader/tide-rings.js';
|
||||
import { dustChamber } from './shader/dust-chamber.js';
|
||||
import { cargoBelt } from './shader/cargo-belt.js';
|
||||
import { circuitBloom } from './shader/circuit-bloom.js';
|
||||
import { truchetFold } from './shader/truchet-fold.js';
|
||||
import { signalDecay } from './shader/signal-decay.js';
|
||||
import { inkBleed } from './shader/ink-bleed.js';
|
||||
import { saltFlat } from './shader/salt-flat.js';
|
||||
import { stormRift } from './shader/storm-rift.js';
|
||||
import { vortexDrift } from './shader/vortex-drift.js';
|
||||
import { gateCorridor } from './shader/gate-corridor.js';
|
||||
|
||||
/**
|
||||
* The scene library. Families exist so the arc driver can choose by section
|
||||
@ -67,6 +79,24 @@ const MODULES = [
|
||||
pylonGrid,
|
||||
prismBloom,
|
||||
pitchShatter,
|
||||
|
||||
// Ten added to widen the library past the point where a track's rosters
|
||||
// start repeating: the casting rule shrinks the pool per track, so depth in
|
||||
// every family is what keeps two videos from drawing the same four scenes.
|
||||
// Weighted toward the 'space' and 'shape' traits, which were the thinnest
|
||||
// and therefore the signatures most likely to run out of cast.
|
||||
auroraVeil,
|
||||
tideRings,
|
||||
dustChamber,
|
||||
cargoBelt,
|
||||
circuitBloom,
|
||||
truchetFold,
|
||||
signalDecay,
|
||||
inkBleed,
|
||||
saltFlat,
|
||||
stormRift,
|
||||
vortexDrift,
|
||||
gateCorridor,
|
||||
];
|
||||
|
||||
const errors = [];
|
||||
|
||||
81
flow-state/src/scenes/shader/aurora-veil.js
Normal file
81
flow-state/src/scenes/shader/aurora-veil.js
Normal file
@ -0,0 +1,81 @@
|
||||
// Flow family: curtains of light standing above the track's horizon.
|
||||
//
|
||||
// Distinct from Curl Flow (a full-frame advected field) and Silk Ribbon (one
|
||||
// strand): this is several tall vertical sheets, each rippling on its own phase,
|
||||
// dense at the base and dissolving upward. The rippling is a sum of sines rather
|
||||
// than noise, which is what gives an aurora its folded-sheet look instead of a
|
||||
// smoky one — noise curtains read as fog.
|
||||
//
|
||||
// The horizon is the track's, so this stands in the same place as every other
|
||||
// scene that has ground.
|
||||
|
||||
export const auroraVeil = {
|
||||
name: 'Aurora Veil',
|
||||
family: 'flow',
|
||||
kind: 'fragment',
|
||||
traits: ['camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
curtains: { type: 'int', range: [2, 9], default: 4, uniform: 'u_curtains', bias: 'density' },
|
||||
height: { type: 'float', range: [0.4, 1.8], default: 1.0, uniform: 'u_height' },
|
||||
fold: { type: 'float', range: [0.1, 1.4], default: 0.55, uniform: 'u_fold', bias: 'density' },
|
||||
speed: { type: 'float', range: [0.03, 0.5], default: 0.15, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||
glow: { type: 'float', range: [0, 1.4], default: 0.5, uniform: 'u_glow', bias: 'energy' },
|
||||
ground: { type: 'float', range: [0, 0.8], default: 0.3, uniform: 'u_ground' },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
glow: { feature: 'bandHigh', amount: 0.4, response: 'smooth' },
|
||||
fold: { feature: 'bandLow', amount: 0.3, response: 'smooth' },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
float horizon = sigHorizonY() * 0.8 - 0.55;
|
||||
float above = p.y - horizon;
|
||||
|
||||
vec3 col = pal(0) * 0.06;
|
||||
|
||||
for (int i = 0; i < 9; i++) {
|
||||
if (i >= u_curtains) break;
|
||||
float fi = float(i);
|
||||
float s = u_seed + fi * 37.13;
|
||||
|
||||
// Each sheet is a vertical line whose x wanders as a sum of three sines.
|
||||
// Three is the fewest that stops reading as a single wobble.
|
||||
float x = (fract(s * 0.61) - 0.5) * 2.2
|
||||
+ sin(above * 1.7 + t * 1.3 + s) * u_fold * 0.35
|
||||
+ sin(above * 3.1 - t * 0.8 + s * 1.7) * u_fold * 0.18
|
||||
+ sin(above * 0.7 + t * 0.4 + s * 2.3) * u_fold * 0.5;
|
||||
|
||||
float d = abs(p.x - x);
|
||||
float widthAt = 0.05 + above * 0.06 + u_sigLine * 0.05;
|
||||
|
||||
// Bright and tight at the base, wide and faint at the top: the vertical
|
||||
// falloff is what makes it a curtain rather than a stripe.
|
||||
float rise = smoothstep(-0.05, 0.0, above) * exp(-max(above, 0.0) / max(u_height, 0.05));
|
||||
float sheet = exp(-d * d / max(widthAt * widthAt, 1e-5)) * rise;
|
||||
|
||||
vec3 tint = palRamp(fract(s) * 0.5 + above * 0.12 + 0.1);
|
||||
col += tint * sheet * (0.55 + u_glow * 0.6);
|
||||
col += tint * exp(-d * 5.0) * rise * u_glow * 0.12;
|
||||
}
|
||||
|
||||
// Ground: the curtains reflected, dim and compressed.
|
||||
if (u_ground > 0.01 && above < 0.0) {
|
||||
float below = -above;
|
||||
col += pal(2) * u_ground * exp(-below * 6.0) * 0.35;
|
||||
}
|
||||
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.6, abs(p.x)));
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default auroraVeil;
|
||||
@ -33,13 +33,21 @@ export const blockMosh = {
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
float beat = u_beat;
|
||||
p = sigCamera(p);
|
||||
|
||||
// The camera moves the CONTENT, not the block grid. The grid has to stay
|
||||
// screen-aligned or the mosh stops reading as a codec artefact and starts
|
||||
// reading as a moving texture — but the field being moshed is part of the
|
||||
// shot and is filmed by the same operator as every other scene. (Assigning
|
||||
// sigCamera(p) to an otherwise unused p is how this scene originally
|
||||
// "honoured" the trait; the Phase 9 render gate measured the resulting
|
||||
// difference at exactly zero.)
|
||||
vec2 cp = sigCamera(p);
|
||||
|
||||
// The thing being moshed: a sheared banded field, recoloured per track, so
|
||||
// the corruption has real content to drag around even standing alone.
|
||||
float shear = uv.y * (2.5 + u_sigLine * 3.0) + t * 0.45;
|
||||
float field = fbm(vec2(uv.x * 1.2 + sin(shear) * 0.3, uv.y * 2.0 + t * 0.35), 3);
|
||||
vec3 base = palRamp(field * 1.15 + uv.x * 0.2 + t * 0.03);
|
||||
float shear = cp.y * (2.5 + u_sigLine * 3.0) + t * 0.45;
|
||||
float field = fbm(vec2(cp.x * 0.9 + sin(shear) * 0.3, cp.y * 1.5 + t * 0.35), 3);
|
||||
vec3 base = palRamp(field * 1.15 + cp.x * 0.18 + t * 0.03);
|
||||
base *= 0.35 + 0.55 * sat(field * 1.5);
|
||||
|
||||
// One quantised grid for every jump below, so the mosh steps like an edit
|
||||
|
||||
88
flow-state/src/scenes/shader/cargo-belt.js
Normal file
88
flow-state/src/scenes/shader/cargo-belt.js
Normal file
@ -0,0 +1,88 @@
|
||||
// Structural family: horizontal belts of cargo running in alternating
|
||||
// directions, stacked up the frame.
|
||||
//
|
||||
// Distinct from Neon City (a static skyline) and Pylon Grid (perspective depth):
|
||||
// this has no depth at all. It is flat, industrial and lateral — the only scene
|
||||
// in the library whose motion is purely sideways, which is exactly what makes it
|
||||
// cut well against everything that recedes.
|
||||
//
|
||||
// Each crate is stamped in the track's signature form, and the belts step in
|
||||
// bar-quantised lurches rather than sliding, so the movement is mechanical.
|
||||
|
||||
export const cargoBelt = {
|
||||
name: 'Cargo Belt',
|
||||
family: 'structural',
|
||||
kind: 'fragment',
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
belts: { type: 'int', range: [2, 8], default: 4, uniform: 'u_belts', bias: 'density' },
|
||||
crates: { type: 'float', range: [2, 14], default: 6, uniform: 'u_crates', bias: 'density' },
|
||||
crateSize:{ type: 'float', range: [0.15, 0.75],default: 0.42, uniform: 'u_crateSize' },
|
||||
speed: { type: 'float', range: [0.05, 1.0], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||
gap: { type: 'float', range: [0.02, 0.3], default: 0.1, uniform: 'u_gap' },
|
||||
rails: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_rails' },
|
||||
lamp: { type: 'float', range: [0, 1.3], default: 0.5, uniform: 'u_lamp', bias: 'energy' },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
lamp: { feature: 'beat', amount: 0.35, response: 'spike' },
|
||||
rails: { feature: 'bandLow', amount: 0.2, response: 'smooth' },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
vec3 col = pal(0) * 0.05;
|
||||
|
||||
float span = 2.0 / max(float(u_belts), 1.0);
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
if (i >= u_belts) break;
|
||||
float fi = float(i);
|
||||
float centre = -1.0 + span * (fi + 0.5);
|
||||
float dy = p.y - centre;
|
||||
if (abs(dy) > span * 0.5) continue;
|
||||
|
||||
float dir = mod(fi, 2.0) < 0.5 ? 1.0 : -1.0;
|
||||
|
||||
// Quantised travel: the belt advances in eighth-bar steps, so cargo
|
||||
// lurches from cell to cell the way a conveyor does.
|
||||
float march = floor((t + fi * 0.37) * 4.0) * 0.25 * dir;
|
||||
float lane = p.x * 0.5 + march;
|
||||
|
||||
float cell = floor(lane * u_crates);
|
||||
float withinCell = fract(lane * u_crates);
|
||||
float rnd = hash12(vec2(cell, fi));
|
||||
|
||||
// Not every cell carries a crate; the gaps are what make it read as
|
||||
// cargo rather than as a stripe pattern.
|
||||
if (rnd > u_gap) {
|
||||
vec2 local = vec2((withinCell - 0.5) * 2.0, dy / max(span * 0.5, 1e-3));
|
||||
float size = u_crateSize * (0.7 + rnd * 0.5);
|
||||
float d = sigShape(local / max(size, 1e-3)) * size;
|
||||
|
||||
vec3 crateColor = pal(int(mod(cell + fi, 4.0)) + 1);
|
||||
col = mix(col, crateColor * (0.35 + rnd * 0.5), smoothstep(0.02, -0.02, d));
|
||||
col += crateColor * sigEdge(d) * (0.4 + u_lamp * 0.5);
|
||||
|
||||
// Lamp on a minority of crates, pulsing on the beat. Local, not
|
||||
// whole-frame: a per-crate blink is not a flash.
|
||||
if (rnd > 0.82) col += pal(4) * smoothstep(0.06, 0.0, length(local)) * u_lamp;
|
||||
}
|
||||
|
||||
// Belt rails, top and bottom of each lane.
|
||||
float rail = smoothstep(0.06, 0.0, abs(abs(dy) - span * 0.45));
|
||||
col += pal(2) * rail * u_rails * 0.5;
|
||||
}
|
||||
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default cargoBelt;
|
||||
88
flow-state/src/scenes/shader/circuit-bloom.js
Normal file
88
flow-state/src/scenes/shader/circuit-bloom.js
Normal file
@ -0,0 +1,88 @@
|
||||
// Geometric family: orthogonal traces growing outward from the centre, with a
|
||||
// pad in the track's signature form at every junction.
|
||||
//
|
||||
// Distinct from Moiré Grid (two interfering line grids) and Prism Bloom (folded
|
||||
// radial geometry): the structure here is Manhattan — everything runs at right
|
||||
// angles, and the only curves are the pads. That right-angle language is the
|
||||
// thing the library was missing, and it cuts hard against every radial scene.
|
||||
//
|
||||
// Traces light up in travelling pulses rather than all at once, so the frame is
|
||||
// busy without ever changing brightness as a whole.
|
||||
|
||||
export const circuitBloom = {
|
||||
name: 'Circuit Bloom',
|
||||
family: 'geometric',
|
||||
kind: 'fragment',
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
cells: { type: 'float', range: [2, 14], default: 6, uniform: 'u_cells', bias: 'density' },
|
||||
trace: { type: 'float', range: [0.01, 0.1], default: 0.035,uniform: 'u_trace' },
|
||||
pads: { type: 'float', range: [0, 1], default: 0.55, uniform: 'u_pads' },
|
||||
padSize: { type: 'float', range: [0.04, 0.22],default: 0.1, uniform: 'u_padSize' },
|
||||
pulse: { type: 'float', range: [0, 1.5], default: 0.6, uniform: 'u_pulse', bias: 'energy' },
|
||||
speed: { type: 'float', range: [0.05, 1.2], default: 0.35, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||
fill: { type: 'float', range: [0.2, 0.95], default: 0.6, uniform: 'u_fill', bias: 'density' },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
pulse: { feature: 'bandHigh', amount: 0.4, response: 'smooth' },
|
||||
pads: { feature: 'beat', amount: 0.2, response: 'spike' },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
vec2 g = p * u_cells;
|
||||
vec2 cell = floor(g);
|
||||
vec2 f = fract(g) - 0.5;
|
||||
|
||||
float rnd = hash12(cell + u_seed);
|
||||
float rnd2 = hash12(cell * 1.7 + 11.3 + u_seed);
|
||||
|
||||
vec3 col = pal(0) * 0.05;
|
||||
|
||||
// Each cell carries a horizontal trace, a vertical one, or both — the L
|
||||
// junctions are what make it read as routing rather than as a grid.
|
||||
float horizontal = step(1.0 - u_fill, rnd);
|
||||
float vertical = step(1.0 - u_fill, rnd2);
|
||||
|
||||
float width = u_trace * (0.6 + u_sigLine);
|
||||
float dH = abs(f.y);
|
||||
float dV = abs(f.x);
|
||||
|
||||
// Distance from the centre of the board, used to gate growth outward.
|
||||
float reach = sat(1.4 - length(p) * 0.5);
|
||||
|
||||
float traceMask = 0.0;
|
||||
if (horizontal > 0.5) traceMask += smoothstep(width, width * 0.35, dH);
|
||||
if (vertical > 0.5) traceMask += smoothstep(width, width * 0.35, dV);
|
||||
traceMask = sat(traceMask) * reach;
|
||||
|
||||
vec3 traceColor = palRamp(rnd * 0.4 + 0.15);
|
||||
col += traceColor * traceMask * 0.5;
|
||||
|
||||
// Travelling pulse: a bright packet running along the trace, its position a
|
||||
// function of the cell's own hash so packets are out of step with each other.
|
||||
float along = horizontal > 0.5 ? f.x : f.y;
|
||||
float packet = fract(rnd * 3.1 + t * (0.4 + rnd2 * 0.8));
|
||||
float dPacket = abs(along - (packet - 0.5));
|
||||
col += pal(4) * traceMask * exp(-dPacket * dPacket * 260.0) * u_pulse;
|
||||
|
||||
// Pads sit where both traces meet, stamped in the signature form.
|
||||
if (horizontal > 0.5 && vertical > 0.5 && rnd2 > 1.0 - u_pads) {
|
||||
float d = sigShape(f / max(u_padSize, 1e-3)) * u_padSize;
|
||||
col += pal(2) * smoothstep(0.01, -0.01, d) * reach * 0.7;
|
||||
col += pal(3) * sigEdge(d) * reach * (0.4 + u_pulse * 0.4);
|
||||
}
|
||||
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default circuitBloom;
|
||||
79
flow-state/src/scenes/shader/dust-chamber.js
Normal file
79
flow-state/src/scenes/shader/dust-chamber.js
Normal file
@ -0,0 +1,79 @@
|
||||
// Minimal family: a nearly empty volume with a shaft of light through it and a
|
||||
// few motes suspended in the beam.
|
||||
//
|
||||
// Distinct from Firefly Drift (a swarm advected along a flow) and Slow Orb (one
|
||||
// body): almost nothing moves here. The motes hold position and only breathe;
|
||||
// what changes is the light. That makes this one of the very few scenes in the
|
||||
// library that can hold a thirty-second intro without asking for attention.
|
||||
//
|
||||
// The beam lands on the track's horizon, so the room is the same room every
|
||||
// other scene with a floor is standing in.
|
||||
|
||||
export const dustChamber = {
|
||||
name: 'Dust Chamber',
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
traits: ['shape', 'camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
motes: { type: 'int', range: [6, 40], default: 18, uniform: 'u_motes', bias: 'density' },
|
||||
moteSize: { type: 'float', range: [0.004, 0.05], default: 0.014, uniform: 'u_moteSize' },
|
||||
beam: { type: 'float', range: [0.1, 1.0], default: 0.45, uniform: 'u_beam', bias: 'energy' },
|
||||
beamWidth:{ type: 'float', range: [0.15, 1.2], default: 0.5, uniform: 'u_beamWidth' },
|
||||
sway: { type: 'float', range: [0, 0.12], default: 0.04, uniform: 'u_sway' },
|
||||
drift: { type: 'float', range: [0.01, 0.3], default: 0.06, uniform: 'u_drift', bias: 'motion', rate: true },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
beam: { feature: 'loudness', amount: 0.25, response: 'smooth' },
|
||||
moteSize: { feature: 'beat', amount: 0.2, response: 'spike' },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_drift + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
float floorY = sigHorizonY() * 0.7 - 0.6;
|
||||
|
||||
// The shaft: a soft wedge widening as it falls, cut off at the floor.
|
||||
float axis = sin(u_seed * 0.7) * 0.35;
|
||||
float down = sat((1.0 - (p.y - floorY)) * 0.6);
|
||||
float halfWidth = u_beamWidth * (0.25 + down * 0.75);
|
||||
float inBeam = exp(-pow((p.x - axis) / max(halfWidth, 1e-3), 2.0) * 2.2);
|
||||
inBeam *= smoothstep(floorY - 0.05, floorY + 0.5, p.y);
|
||||
|
||||
vec3 col = pal(0) * 0.05;
|
||||
col += pal(1) * inBeam * u_beam * 0.5;
|
||||
|
||||
// The pool where the shaft meets the floor.
|
||||
float pool = exp(-abs(p.y - floorY) * 14.0)
|
||||
* exp(-pow((p.x - axis) / max(halfWidth * 1.3, 1e-3), 2.0));
|
||||
col += pal(2) * pool * u_beam * 0.7;
|
||||
|
||||
// Motes: fixed positions, breathing brightness, only visible in the light.
|
||||
for (int i = 0; i < 40; i++) {
|
||||
if (i >= u_motes) break;
|
||||
float fi = float(i);
|
||||
float s = u_seed + fi * 53.7;
|
||||
|
||||
vec2 at = vec2(
|
||||
(hash11(s) - 0.5) * 2.4 + sin(t * 0.8 + s) * u_sway,
|
||||
(hash11(s + 9.1) - 0.5) * 1.8 + cos(t * 0.6 + s * 1.3) * u_sway
|
||||
);
|
||||
|
||||
float lit = exp(-pow((at.x - axis) / max(halfWidth, 1e-3), 2.0) * 2.0);
|
||||
float pulse = 0.55 + 0.45 * sin(t * 2.0 + s * 3.0);
|
||||
float m = sigForm(p, at, u_moteSize * (0.6 + hash11(s + 3.3)));
|
||||
col += pal(3) * m * lit * pulse * (0.5 + u_beam);
|
||||
}
|
||||
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.5, length(p)));
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default dustChamber;
|
||||
89
flow-state/src/scenes/shader/gate-corridor.js
Normal file
89
flow-state/src/scenes/shader/gate-corridor.js
Normal file
@ -0,0 +1,89 @@
|
||||
// Structural family: a corridor of nested gates receding to a vanishing point,
|
||||
// travelled through.
|
||||
//
|
||||
// Rectilinear depth, which the library did not have: Kaleido Tunnel recedes but
|
||||
// is radial and folded, Pylon Grid stands still, Neon City is front-on. Here the
|
||||
// camera moves forward through a series of frames that scale up and pass, and
|
||||
// the ring geometry is the track's signature form, so a hexagonal video travels
|
||||
// through hexagonal gates.
|
||||
//
|
||||
// Motion is a saw on log-depth, which is what makes gates emerge from the
|
||||
// vanishing point at a constant apparent rate instead of rushing at the end.
|
||||
|
||||
export const gateCorridor = {
|
||||
name: 'Gate Corridor',
|
||||
family: 'structural',
|
||||
kind: 'fragment',
|
||||
traits: ['shape', 'camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
gates: { type: 'int', range: [3, 14], default: 8, uniform: 'u_gates', bias: 'density' },
|
||||
aperture: { type: 'float', range: [0.15, 0.9], default: 0.45, uniform: 'u_aperture' },
|
||||
thickness:{ type: 'float', range: [0.02, 0.3], default: 0.09, uniform: 'u_thickness' },
|
||||
travel: { type: 'float', range: [0.02, 0.7], default: 0.2, uniform: 'u_travel', bias: 'motion', rate: true },
|
||||
rails: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_rails' },
|
||||
lamps: { type: 'float', range: [0, 1.4], default: 0.5, uniform: 'u_lamps', bias: 'energy' },
|
||||
vanish: { type: 'float', range: [0, 0.5], default: 0.15, uniform: 'u_vanish' },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
lamps: { feature: 'beat', amount: 0.35, response: 'spike' },
|
||||
aperture: { feature: 'bandLow', amount: 0.15, response: 'smooth' },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_travel + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
// The vanishing point sits on the track's horizon, slightly off centre.
|
||||
vec2 vanishAt = vec2(sin(u_seed) * u_vanish, sigHorizonY() * 0.35);
|
||||
vec2 q = p - vanishAt;
|
||||
|
||||
vec3 col = pal(0) * 0.04;
|
||||
|
||||
// Depth rails converging on the vanishing point.
|
||||
if (u_rails > 0.01) {
|
||||
float ang = atan(q.y, q.x);
|
||||
float spokes = abs(fract(ang * 1.9098 + 0.5) - 0.5) * 2.0; // 12 rails
|
||||
float rail = smoothstep(0.06, 0.0, spokes) * smoothstep(0.02, 0.5, length(q));
|
||||
col += pal(1) * rail * u_rails * 0.25;
|
||||
}
|
||||
|
||||
for (int i = 0; i < 14; i++) {
|
||||
if (i >= u_gates) break;
|
||||
float fi = float(i);
|
||||
|
||||
// Log-spaced depth with a saw: each gate walks forward, and when it
|
||||
// passes the camera it wraps to the far end.
|
||||
float phase = fract((fi / float(u_gates)) + t);
|
||||
float scale = u_aperture * exp(phase * 3.2) * 0.35;
|
||||
|
||||
float d = abs(sigShape(q / max(scale, 1e-3)) * max(scale, 1e-3));
|
||||
|
||||
// Near gates are drawn thicker and brighter: the only depth cue that
|
||||
// matters once the geometry is right.
|
||||
float near = phase;
|
||||
float w = u_thickness * (0.25 + near * 1.2) * (0.5 + u_sigLine);
|
||||
float frame = smoothstep(w, w * 0.25, d);
|
||||
|
||||
vec3 tint = palRamp(fi * 0.13 + 0.1);
|
||||
col += tint * frame * (0.25 + near * 0.75);
|
||||
col += tint * exp(-d * 14.0) * near * 0.2;
|
||||
|
||||
// A lamp at the top of every third gate, pulsing on the beat.
|
||||
if (mod(fi, 3.0) < 0.5) {
|
||||
vec2 lampAt = vanishAt + vec2(0.0, scale);
|
||||
col += pal(4) * exp(-length(p - lampAt) * 26.0) * u_lamps * (0.3 + near);
|
||||
}
|
||||
}
|
||||
|
||||
col = sigAir(col, p, 1.0 - smoothstep(0.0, 1.2, length(q)));
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default gateCorridor;
|
||||
81
flow-state/src/scenes/shader/ink-bleed.js
Normal file
81
flow-state/src/scenes/shader/ink-bleed.js
Normal file
@ -0,0 +1,81 @@
|
||||
// Organic family: ink dropped into wet paper, spreading along the fibre.
|
||||
//
|
||||
// The bleed is done with feedback — each frame the previous one is sampled
|
||||
// slightly outward along a noise-warped direction and darkened, which is a
|
||||
// diffusion step in everything but name. Distinct from Curl Flow's trails
|
||||
// (advected along a flow field, so they streak) because this expands in all
|
||||
// directions at once, so it blooms.
|
||||
//
|
||||
// A base field is always drawn, so the scene stands alone before feedback has
|
||||
// converged and survives a seek.
|
||||
|
||||
export const inkBleed = {
|
||||
name: 'Ink Bleed',
|
||||
family: 'organic',
|
||||
kind: 'fragment',
|
||||
traits: ['camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
drops: { type: 'int', range: [1, 6], default: 3, uniform: 'u_drops', bias: 'density' },
|
||||
spread: { type: 'float', range: [0.002, 0.02], default: 0.007, uniform: 'u_spread' },
|
||||
fibre: { type: 'float', range: [0.5, 8], default: 3.0, uniform: 'u_fibre', bias: 'density' },
|
||||
soak: { type: 'float', range: [0.7, 0.99], default: 0.93, uniform: 'u_soak' },
|
||||
density: { type: 'float', range: [0.1, 1.2], default: 0.5, uniform: 'u_density', bias: 'energy' },
|
||||
pace: { type: 'float', range: [0.02, 0.4], default: 0.1, uniform: 'u_pace', bias: 'motion', rate: true },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
density: { feature: 'bandLow', amount: 0.35, response: 'smooth' },
|
||||
fibre: { feature: 'bandAir', amount: 0.2 },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_pace + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
// Paper: a still fibre texture that the ink will follow.
|
||||
float grainField = fbm(p * u_fibre * 2.0 + 17.0, 4);
|
||||
vec3 col = mix(pal(0) * 0.09, pal(1) * 0.14, grainField);
|
||||
|
||||
// Fresh ink. Each drop pulses in and out on its own slow cycle, so the page
|
||||
// is never uniformly saturated and there is always somewhere new bleeding.
|
||||
float ink = 0.0;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
if (i >= u_drops) break;
|
||||
float fi = float(i);
|
||||
float s = u_seed + fi * 61.3;
|
||||
|
||||
vec2 at = vec2(sin(t * 0.6 + s) * 0.6, cos(t * 0.47 + s * 1.7) * 0.45);
|
||||
float life = 0.5 + 0.5 * sin(t * 1.3 + s * 2.1);
|
||||
float d = length(p - at) + (grainField - 0.5) * 0.15;
|
||||
ink += exp(-d * d * 90.0) * life;
|
||||
}
|
||||
ink = sat(ink) * u_density;
|
||||
|
||||
vec3 inkColor = palRamp(0.45 + grainField * 0.3);
|
||||
col = mix(col, inkColor, ink);
|
||||
|
||||
// The bleed: sample the previous frame outward along the fibre. Reading four
|
||||
// offsets rather than one is what makes it spread in every direction instead
|
||||
// of sliding — one sample is a smear, four is diffusion.
|
||||
vec2 warp = (vec2(fbm(p * u_fibre + 3.0, 3), fbm(p * u_fibre - 7.0, 3)) - 0.5) * 2.0;
|
||||
float r = u_spread;
|
||||
vec3 soaked = (
|
||||
prev(uv + (vec2( 1.0, 0.0) + warp * 0.6) * r) +
|
||||
prev(uv + (vec2(-1.0, 0.0) + warp * 0.6) * r) +
|
||||
prev(uv + (vec2( 0.0, 1.0) + warp * 0.6) * r) +
|
||||
prev(uv + (vec2( 0.0, -1.0) + warp * 0.6) * r)
|
||||
) * 0.25;
|
||||
|
||||
col = max(col, soaked * u_soak);
|
||||
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.7, length(p)));
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default inkBleed;
|
||||
83
flow-state/src/scenes/shader/salt-flat.js
Normal file
83
flow-state/src/scenes/shader/salt-flat.js
Normal file
@ -0,0 +1,83 @@
|
||||
// Minimal family: an empty plain under a very large sky, with one distant form
|
||||
// standing on the horizon.
|
||||
//
|
||||
// The emptiest scene in the library and the most deliberate about it — most of
|
||||
// the frame is a gradient. Distinct from Horizon Lines (a bundle of lines) and
|
||||
// Ridge Terrain (layered silhouettes): there is exactly one object, it is small,
|
||||
// and it is far away. What moves is the light and the heat shimmer.
|
||||
//
|
||||
// The object is the track's signature form, so the thing on the horizon of a
|
||||
// hexagonal video is a hexagon.
|
||||
|
||||
export const saltFlat = {
|
||||
name: 'Salt Flat',
|
||||
family: 'minimal',
|
||||
kind: 'fragment',
|
||||
traits: ['shape', 'camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
monolith: { type: 'float', range: [0.0, 0.35], default: 0.12, uniform: 'u_monolith' },
|
||||
standing: { type: 'float', range: [-0.7, 0.7], default: 0.0, uniform: 'u_standing' },
|
||||
shimmer: { type: 'float', range: [0, 0.09], default: 0.025,uniform: 'u_shimmer' },
|
||||
glowBand: { type: 'float', range: [0, 1.2], default: 0.45, uniform: 'u_glowBand', bias: 'energy' },
|
||||
ground: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_ground' },
|
||||
salt: { type: 'float', range: [0, 1], default: 0.35, uniform: 'u_salt', bias: 'density' },
|
||||
pace: { type: 'float', range: [0.02, 0.5], default: 0.12, uniform: 'u_pace', bias: 'motion', rate: true },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
glowBand: { feature: 'loudness', amount: 0.3, response: 'smooth' },
|
||||
shimmer: { feature: 'bandAir', amount: 0.25 },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_pace + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
float horizon = sigHorizonY() * 0.6 - 0.15;
|
||||
|
||||
// Heat shimmer: everything near the horizon wobbles, nothing else does.
|
||||
float nearHorizon = exp(-abs(p.y - horizon) * 5.0);
|
||||
p.x += sin(p.y * 60.0 + t * 6.0) * u_shimmer * nearHorizon;
|
||||
|
||||
float above = p.y - horizon;
|
||||
|
||||
// Sky: a tall gradient, darkest at the top.
|
||||
vec3 col = mix(pal(1) * 0.5, pal(0) * 0.25, sat(above * 0.8 + 0.15));
|
||||
|
||||
// The glow band sitting on the horizon — the light source of the whole scene.
|
||||
col += pal(3) * exp(-abs(above) * 9.0) * u_glowBand * 0.8;
|
||||
|
||||
if (above < 0.0) {
|
||||
float depth = sat(-above * 2.2); // 0 far .. 1 near
|
||||
vec3 plain = mix(pal(2) * 0.5, pal(0) * 0.3, depth);
|
||||
|
||||
// Salt crust: cracked cells, only legible in the near field.
|
||||
vec2 cellUv = vec2(p.x / max(-above * 0.9 + 0.06, 0.02), 1.0 / max(-above + 0.05, 0.02));
|
||||
float crack = abs(fract(cellUv.x * 0.5) - 0.5) + abs(fract(cellUv.y * 0.5) - 0.5);
|
||||
plain += pal(4) * smoothstep(0.42, 0.5, crack) * u_salt * depth * 0.25;
|
||||
|
||||
// Reflection of the glow band, compressed toward the horizon.
|
||||
plain += pal(3) * exp(above * 7.0) * u_glowBand * 0.3;
|
||||
|
||||
col = mix(col, plain, u_ground);
|
||||
}
|
||||
|
||||
// The one object: small, on the horizon, in the track's form.
|
||||
if (u_monolith > 0.005) {
|
||||
vec2 at = vec2(u_standing, horizon + u_monolith * 0.9);
|
||||
float d = sigShape((p - at) / u_monolith) * u_monolith;
|
||||
col = mix(col, pal(0) * 0.12, smoothstep(0.006, -0.006, d));
|
||||
col += pal(4) * sigEdge(d) * (0.3 + u_glowBand * 0.5);
|
||||
}
|
||||
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.4, abs(p.x)));
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default saltFlat;
|
||||
90
flow-state/src/scenes/shader/signal-decay.js
Normal file
90
flow-state/src/scenes/shader/signal-decay.js
Normal file
@ -0,0 +1,90 @@
|
||||
// Glitch family: a stack of oscilloscope traces losing signal.
|
||||
//
|
||||
// Distinct from Scan Tear (rows displaced sideways), Block Mosh (block-level
|
||||
// datamosh) and Pitch Shatter (vertical transposition): nothing here is
|
||||
// displaced at all. The corruption is in the SIGNAL — each trace degrades from a
|
||||
// clean wave into noise as its lock is lost, and regains it. Loss of lock steps
|
||||
// on the bar grid, so traces drop out in time rather than flickering.
|
||||
|
||||
export const signalDecay = {
|
||||
name: 'Signal Decay',
|
||||
family: 'glitch',
|
||||
kind: 'fragment',
|
||||
traits: ['camera', 'style'],
|
||||
|
||||
params: {
|
||||
traces: { type: 'int', range: [2, 10], default: 5, uniform: 'u_traces', bias: 'density' },
|
||||
amplitude:{ type: 'float', range: [0.02, 0.3], default: 0.1, uniform: 'u_amplitude', bias: 'energy' },
|
||||
frequency:{ type: 'float', range: [1, 22], default: 7, uniform: 'u_frequency', bias: 'density' },
|
||||
loss: { type: 'float', range: [0, 0.9], default: 0.35, uniform: 'u_loss' },
|
||||
hiss: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_hiss' },
|
||||
persist: { type: 'float', range: [0, 0.85], default: 0.4, uniform: 'u_persist' },
|
||||
speed: { type: 'float', range: [0.1, 2.0], default: 0.6, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
amplitude: { feature: 'bandLow', amount: 0.35, response: 'smooth' },
|
||||
hiss: { feature: 'flatness', amount: 0.3 },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
// Quantised era: lock is lost and regained on the eighth-bar grid, so
|
||||
// dropouts land with the music instead of crawling.
|
||||
float era = floor(u_barPhase * 8.0) + floor(t * 2.0) * 8.0;
|
||||
|
||||
vec3 col = pal(0) * 0.05;
|
||||
float span = 2.0 / max(float(u_traces), 1.0);
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (i >= u_traces) break;
|
||||
float fi = float(i);
|
||||
float centre = -1.0 + span * (fi + 0.5);
|
||||
float s = u_seed + fi * 27.7;
|
||||
|
||||
// How much lock this trace has this era. Below zero it is pure noise.
|
||||
float lock = sat(hash12(vec2(fi, era)) * 1.4 - u_loss);
|
||||
|
||||
// Clean signal: two sines and a slow envelope, so it looks like a
|
||||
// waveform rather than a test tone.
|
||||
float clean = sin(p.x * u_frequency + t * 3.0 + s) * 0.6
|
||||
+ sin(p.x * u_frequency * 2.7 - t * 1.7 + s * 1.3) * 0.4;
|
||||
|
||||
// Noise floor: hashed per pixel column and era, held steady within a
|
||||
// step so it reads as static rather than as a shimmer.
|
||||
float noise = (hash12(vec2(floor(p.x * 220.0), era + fi)) - 0.5) * 2.0;
|
||||
|
||||
float signal = mix(noise, clean, lock);
|
||||
float y = centre + signal * u_amplitude;
|
||||
|
||||
float d = abs(p.y - y);
|
||||
float w = 0.004 + u_sigLine * 0.012;
|
||||
float line = smoothstep(w * 2.5, 0.0, d);
|
||||
|
||||
vec3 tint = palRamp(fract(s) * 0.4 + 0.15);
|
||||
col += tint * line * (0.4 + lock * 0.6);
|
||||
col += tint * exp(-d * 40.0) * 0.25 * lock;
|
||||
|
||||
// Hiss band around an unlocked trace: the visual equivalent of the
|
||||
// sound. Confined to the lane, so it never washes the whole frame.
|
||||
if (lock < 0.4) {
|
||||
float band = exp(-abs(p.y - centre) * 12.0);
|
||||
col += pal(3) * band * abs(noise) * u_hiss * 0.3 * (1.0 - lock);
|
||||
}
|
||||
}
|
||||
|
||||
// Ghost of the previous frame, so a dropout leaves a trail rather than
|
||||
// vanishing cleanly.
|
||||
col = max(col, prev(uv) * u_persist);
|
||||
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default signalDecay;
|
||||
@ -42,6 +42,8 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
float ph = fk * 2.2 + t * 0.3;
|
||||
float best = 1e9;
|
||||
|
||||
// lint: fixed-cost — this samples the curve at a fixed resolution, so
|
||||
// there is no param to break on. Cost is governed by the 4K budget gate.
|
||||
for (int n = 0; n < 48; n++) {
|
||||
float uu = (float(n) + 0.5) / 48.0;
|
||||
float x = (uu - 0.5) * 2.0 * u_spread;
|
||||
|
||||
109
flow-state/src/scenes/shader/storm-rift.js
Normal file
109
flow-state/src/scenes/shader/storm-rift.js
Normal file
@ -0,0 +1,109 @@
|
||||
// Glitch family: branching discharge across the sky above the track's horizon.
|
||||
//
|
||||
// The bolt is a recursive-looking zigzag built from stacked hashed segments,
|
||||
// re-struck on the quantised grid rather than continuously, so strikes land on
|
||||
// the music. Between strikes the afterglow decays through feedback, which is
|
||||
// what makes the dark frames read as "after a flash" rather than as empty.
|
||||
//
|
||||
// The flash itself is deliberately LOCAL — the bolt and a halo around it, not
|
||||
// the frame. A full-frame white flash on every kick is exactly the WCAG 2.3.1
|
||||
// failure this library is checked against.
|
||||
|
||||
export const stormRift = {
|
||||
name: 'Storm Rift',
|
||||
family: 'glitch',
|
||||
kind: 'fragment',
|
||||
traits: ['camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
bolts: { type: 'int', range: [1, 5], default: 2, uniform: 'u_bolts', bias: 'density' },
|
||||
jag: { type: 'float', range: [0.05, 0.6], default: 0.25, uniform: 'u_jag' },
|
||||
segments: { type: 'float', range: [4, 20], default: 10, uniform: 'u_segments', bias: 'density' },
|
||||
branch: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_branch' },
|
||||
afterglow:{ type: 'float', range: [0, 0.9], default: 0.55, uniform: 'u_afterglow' },
|
||||
cloud: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_cloud' },
|
||||
rate: { type: 'float', range: [0.2, 3.0], default: 1.0, uniform: 'u_rate', bias: 'motion', rate: true },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
branch: { feature: 'bandHigh', amount: 0.3, response: 'smooth' },
|
||||
cloud: { feature: 'bandLow', amount: 0.25, response: 'smooth' },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_rate + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
float horizon = sigHorizonY() * 0.7 - 0.5;
|
||||
|
||||
// Cloud deck above, lit from within.
|
||||
float deck = fbm(vec2(p.x * 1.4 + t * 0.15, p.y * 2.2 - t * 0.05), 5);
|
||||
vec3 col = mix(pal(0) * 0.06, pal(1) * 0.2, deck * u_cloud * smoothstep(horizon, horizon + 1.2, p.y));
|
||||
|
||||
// Strikes step on the eighth-bar grid. Each era re-rolls every bolt.
|
||||
float era = floor(u_barPhase * 8.0) + floor(t) * 8.0;
|
||||
|
||||
for (int b = 0; b < 5; b++) {
|
||||
if (b >= u_bolts) break;
|
||||
float fb = float(b);
|
||||
float boltSeed = hash12(vec2(era, fb * 7.3 + u_seed));
|
||||
|
||||
// Not every bolt fires every era; misfires are what make the ones that
|
||||
// land feel like events.
|
||||
float fires = step(0.35, boltSeed);
|
||||
float age = fract(t * 2.0 + fb * 0.31);
|
||||
float intensity = fires * exp(-age * 6.0);
|
||||
if (intensity < 0.004) continue;
|
||||
|
||||
// The channel: a piecewise-linear zigzag from the cloud deck down to the
|
||||
// horizon, each segment hashed off (era, bolt, segment).
|
||||
float x0 = (hash11(boltSeed * 31.0) - 0.5) * 1.8;
|
||||
float best = 1e3;
|
||||
|
||||
for (int s = 0; s < 20; s++) {
|
||||
if (float(s) >= u_segments) break;
|
||||
float f0 = float(s) / u_segments;
|
||||
float f1 = float(s + 1) / u_segments;
|
||||
|
||||
float yA = mix(1.1, horizon, f0);
|
||||
float yB = mix(1.1, horizon, f1);
|
||||
float xA = x0 + (hash11(boltSeed * 17.0 + float(s) * 3.7) - 0.5) * u_jag * (0.3 + f0);
|
||||
float xB = x0 + (hash11(boltSeed * 17.0 + float(s + 1) * 3.7) - 0.5) * u_jag * (0.3 + f1);
|
||||
|
||||
// Distance to this segment.
|
||||
vec2 a = vec2(xA, yA), bb = vec2(xB, yB);
|
||||
vec2 pa = p - a, ba = bb - a;
|
||||
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
|
||||
best = min(best, length(pa - ba * h));
|
||||
|
||||
// Branches: a short spur off some joints, in the same hand.
|
||||
if (hash11(boltSeed * 53.0 + float(s)) < u_branch * 0.4) {
|
||||
vec2 tip = a + vec2((hash11(boltSeed + float(s) * 5.1) - 0.5) * 0.5, -0.12);
|
||||
vec2 pb = p - a, bc = tip - a;
|
||||
float h2 = clamp(dot(pb, bc) / max(dot(bc, bc), 1e-6), 0.0, 1.0);
|
||||
best = min(best, length(pb - bc * h2) + 0.008);
|
||||
}
|
||||
}
|
||||
|
||||
float core = smoothstep(0.012 + u_sigLine * 0.01, 0.0, best);
|
||||
float halo = exp(-best * 12.0);
|
||||
col += pal(4) * core * intensity;
|
||||
col += pal(3) * halo * intensity * 0.5;
|
||||
}
|
||||
|
||||
// Ground catches the light; below the horizon is otherwise near black.
|
||||
if (p.y < horizon) {
|
||||
col *= 0.25;
|
||||
col += pal(2) * exp((p.y - horizon) * 5.0) * 0.15;
|
||||
}
|
||||
|
||||
col = max(col, prev(uv) * u_afterglow);
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default stormRift;
|
||||
79
flow-state/src/scenes/shader/tide-rings.js
Normal file
79
flow-state/src/scenes/shader/tide-rings.js
Normal file
@ -0,0 +1,79 @@
|
||||
// Organic family: interference rings spreading from a handful of drop points,
|
||||
// like rain on still water.
|
||||
//
|
||||
// Distinct from Classic Wave (one centred ring set) and Metaballs (merging
|
||||
// bodies): here several sources overlap and the SUM is what is drawn, so the
|
||||
// image is the interference pattern rather than the rings themselves. Where two
|
||||
// wavefronts meet they cancel, which produces the moving lattice of nodes that
|
||||
// makes water look like water.
|
||||
//
|
||||
// Ring geometry is measured in the track's signature form, so a hexagonal track
|
||||
// gets hexagonal wavefronts.
|
||||
|
||||
export const tideRings = {
|
||||
name: 'Tide Rings',
|
||||
family: 'organic',
|
||||
kind: 'fragment',
|
||||
traits: ['shape', 'camera', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
sources: { type: 'int', range: [2, 7], default: 4, uniform: 'u_sources', bias: 'density' },
|
||||
wavelength:{ type: 'float', range: [3, 26], default: 10, uniform: 'u_wavelength', bias: 'density' },
|
||||
speed: { type: 'float', range: [0.05, 1.2], default: 0.35, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||
decay: { type: 'float', range: [0.2, 2.0], default: 0.8, uniform: 'u_decay' },
|
||||
caustic: { type: 'float', range: [0, 1.5], default: 0.6, uniform: 'u_caustic', bias: 'energy' },
|
||||
spread: { type: 'float', range: [0.2, 1.1], default: 0.65, uniform: 'u_spread' },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
caustic: { feature: 'beat', amount: 0.3, response: 'smooth' },
|
||||
wavelength: { feature: 'bandMid', amount: 0.2 },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
float sum = 0.0;
|
||||
float energy = 0.0;
|
||||
|
||||
for (int i = 0; i < 7; i++) {
|
||||
if (i >= u_sources) break;
|
||||
float fi = float(i);
|
||||
float s = u_seed + fi * 91.7;
|
||||
|
||||
// Sources wander slowly, so the interference lattice never settles into
|
||||
// a fixed pattern the eye can lock onto.
|
||||
vec2 src = vec2(
|
||||
sin(t * 0.21 + s) * u_spread,
|
||||
cos(t * 0.17 + s * 1.4) * u_spread * 0.7
|
||||
);
|
||||
|
||||
// Distance in the signature metric: round tracks get circular wavefronts.
|
||||
float d = sigShape(p - src) + 1.0;
|
||||
float amp = exp(-d * u_decay);
|
||||
sum += sin(d * u_wavelength - t * 6.0 + fract(s) * 6.28) * amp;
|
||||
energy += amp;
|
||||
}
|
||||
|
||||
float wave = sum / max(energy, 1e-3);
|
||||
|
||||
// The node lattice: where the sum passes through zero, the surface is flat
|
||||
// and bright. Squaring the gradient-ish term is what picks those out.
|
||||
float nodes = 1.0 - abs(wave);
|
||||
nodes = pow(sat(nodes), 3.0);
|
||||
|
||||
vec3 col = mix(pal(0) * 0.1, pal(1), sat(wave * 0.5 + 0.5));
|
||||
col += pal(3) * nodes * u_caustic;
|
||||
col += pal(4) * pow(sat(wave), 6.0) * u_caustic * 0.5;
|
||||
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.7, length(p)));
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default tideRings;
|
||||
74
flow-state/src/scenes/shader/truchet-fold.js
Normal file
74
flow-state/src/scenes/shader/truchet-fold.js
Normal file
@ -0,0 +1,74 @@
|
||||
// Geometric family: a Truchet tiling — every cell carries two quarter-arcs in
|
||||
// one of two rotations, and the arcs join across cell edges into long continuous
|
||||
// curves nobody placed.
|
||||
//
|
||||
// That emergent continuity is the whole point and the reason this does not
|
||||
// duplicate Moiré Grid or Circuit Bloom: the structure is a grid, but the thing
|
||||
// you actually see is a set of wandering closed loops. The tiling re-rolls on
|
||||
// phrase lines rather than crawling, so the maze reconfigures on the music.
|
||||
|
||||
export const truchetFold = {
|
||||
name: 'Truchet Fold',
|
||||
family: 'geometric',
|
||||
kind: 'fragment',
|
||||
traits: ['camera', 'style'],
|
||||
|
||||
params: {
|
||||
cells: { type: 'float', range: [1.5, 12], default: 4, uniform: 'u_cells', bias: 'density' },
|
||||
weight: { type: 'float', range: [0.04, 0.3], default: 0.12, uniform: 'u_weight' },
|
||||
radius: { type: 'float', range: [0.3, 0.7], default: 0.5, uniform: 'u_radius' },
|
||||
churn: { type: 'float', range: [0, 1], default: 0.35, uniform: 'u_churn' },
|
||||
glow: { type: 'float', range: [0, 1.4], default: 0.5, uniform: 'u_glow', bias: 'energy' },
|
||||
drift: { type: 'float', range: [0.0, 0.4], default: 0.08, uniform: 'u_drift', bias: 'motion', rate: true },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
glow: { feature: 'bandMid', amount: 0.35, response: 'smooth' },
|
||||
weight: { feature: 'beat', amount: 0.15, response: 'spike' },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_drift + u_seed;
|
||||
p = sigFolded(sigCamera(p));
|
||||
|
||||
// The tiling slides slowly and re-rolls its orientations on the phrase grid.
|
||||
vec2 g = p * u_cells + vec2(t, t * 0.6);
|
||||
vec2 cell = floor(g);
|
||||
vec2 f = fract(g) - 0.5;
|
||||
|
||||
float era = floor(u_phrasePhase * 4.0) * u_churn;
|
||||
float flip = step(0.5, hash12(cell + era + u_seed));
|
||||
|
||||
// Mirror one of the two orientations; the arcs then always meet at edge
|
||||
// midpoints, which is what makes neighbouring cells join up.
|
||||
if (flip > 0.5) f.x = -f.x;
|
||||
|
||||
// Two quarter-arcs, centred on opposite corners.
|
||||
float d1 = abs(length(f - vec2(-0.5, -0.5)) - u_radius);
|
||||
float d2 = abs(length(f - vec2(0.5, 0.5)) - u_radius);
|
||||
float d = min(d1, d2);
|
||||
|
||||
float w = u_weight * (0.5 + u_sigLine);
|
||||
float line = smoothstep(w, w * 0.3, d);
|
||||
float halo = exp(-d * 9.0);
|
||||
|
||||
// Colour by which arc, and by cell, so the continuous curves change hue
|
||||
// along their length rather than being one flat ribbon.
|
||||
float which = d1 < d2 ? 0.0 : 1.0;
|
||||
vec3 tint = palRamp(hash12(cell * 0.7 + era) * 0.5 + which * 0.2 + 0.1);
|
||||
|
||||
vec3 col = pal(0) * 0.05;
|
||||
col += tint * line * 0.75;
|
||||
col += tint * halo * u_glow * 0.35;
|
||||
col += pal(4) * sigEdge(d) * u_glow * 0.25;
|
||||
|
||||
col *= 0.65 + 0.35 * exp(-dot(p, p) * 0.25);
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default truchetFold;
|
||||
79
flow-state/src/scenes/shader/vortex-drift.js
Normal file
79
flow-state/src/scenes/shader/vortex-drift.js
Normal file
@ -0,0 +1,79 @@
|
||||
// Flow family: spiral arms winding into a slowly wandering core.
|
||||
//
|
||||
// Differential rotation is the point — the inner arms turn faster than the outer
|
||||
// ones, so the arms wind up over time and the image is never twice the same.
|
||||
// Distinct from Curl Flow (isotropic advection with no centre) and Kaleido
|
||||
// Tunnel (rigid radial symmetry receding): this has one centre, real shear, and
|
||||
// no symmetry at all.
|
||||
//
|
||||
// The core is drawn in the track's signature form, so the eye of a hexagonal
|
||||
// track's vortex is a hexagon.
|
||||
|
||||
export const vortexDrift = {
|
||||
name: 'Vortex Drift',
|
||||
family: 'flow',
|
||||
kind: 'fragment',
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
arms: { type: 'int', range: [1, 7], default: 3, uniform: 'u_arms', bias: 'density' },
|
||||
winding: { type: 'float', range: [0.5, 7], default: 2.5, uniform: 'u_winding', bias: 'density' },
|
||||
shear: { type: 'float', range: [0.1, 2.5], default: 1.0, uniform: 'u_shear' },
|
||||
speed: { type: 'float', range: [0.02, 0.6], default: 0.15, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||
turbulence:{ type: 'float', range: [0, 1.2], default: 0.4, uniform: 'u_turbulence' },
|
||||
core: { type: 'float', range: [0.0, 0.4], default: 0.12, uniform: 'u_core', bias: 'energy' },
|
||||
falloff: { type: 'float', range: [0.2, 2.0], default: 0.8, uniform: 'u_falloff' },
|
||||
palette: { type: 'palette', count: 5 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
core: { feature: 'beat', amount: 0.3, response: 'spike' },
|
||||
turbulence: { feature: 'bandMid', amount: 0.3, response: 'smooth' },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
p = sigCamera(p);
|
||||
|
||||
// The eye wanders, so the composition never sits still even when the arms do.
|
||||
vec2 eye = vec2(sin(t * 0.6) * 0.22, cos(t * 0.47) * 0.16);
|
||||
vec2 q = p - eye;
|
||||
|
||||
float r = max(length(q), 1e-4);
|
||||
float a = atan(q.y, q.x);
|
||||
|
||||
// Differential rotation: angular speed falls off with radius, so the arms
|
||||
// shear. Bounded rather than 1/r, or the core would spin arbitrarily fast.
|
||||
float omega = u_shear / (0.35 + r * 1.6);
|
||||
float wound = a + omega * t * 2.0 + log(r + 0.25) * u_winding;
|
||||
|
||||
// Turbulence breaks the arms into filaments instead of clean spokes.
|
||||
wound += (fbm(q * 3.0 + t * 0.4, 4) - 0.5) * u_turbulence * 2.0;
|
||||
|
||||
float arms = max(float(u_arms), 1.0);
|
||||
float band = sin(wound * arms) * 0.5 + 0.5;
|
||||
band = pow(band, 1.8);
|
||||
|
||||
// Density falls off outward, so the frame has a subject.
|
||||
float envelope = exp(-r * u_falloff);
|
||||
|
||||
vec3 col = pal(0) * 0.06;
|
||||
col += palRamp(wound * 0.08 + r * 0.2) * band * envelope * 0.9;
|
||||
col += pal(4) * pow(band, 4.0) * envelope * 0.5;
|
||||
|
||||
// The eye itself, in the signature form.
|
||||
if (u_core > 0.004) {
|
||||
float d = sigShape(q / u_core) * u_core;
|
||||
col = mix(col, pal(0) * 0.05, smoothstep(0.004, -0.01, d));
|
||||
col += pal(3) * sigEdge(d) * 0.8;
|
||||
col += pal(4) * exp(-max(d, 0.0) * 18.0) * 0.35;
|
||||
}
|
||||
|
||||
col += sigGrain(uv);
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
export default vortexDrift;
|
||||
@ -217,3 +217,15 @@ input[type=range] { accent-color: var(--accent); background: transparent; }
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Single-scene gate output (checks.html?scene=Name). Monospaced so the
|
||||
PASS/FAIL column lines up and the whole verdict reads in one glance. */
|
||||
.scene-gate {
|
||||
font: 12px/1.7 ui-monospace, monospace;
|
||||
color: var(--text);
|
||||
background: #0e1016;
|
||||
padding: 14px 16px;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@ -167,6 +167,56 @@ console.log('\nscene schema lint');
|
||||
}
|
||||
}
|
||||
|
||||
// A dead camera: `p = sigCamera(p)` and then nothing reads p again.
|
||||
// This passed the evidence grep above, passed review, and shipped — the
|
||||
// Phase 9 render gate later measured the scene's response to the camera
|
||||
// at exactly zero. Cheaper to catch here than on a GPU.
|
||||
const cameraAssign = src.match(/(\w+)\s*=\s*sig(?:Camera|Folded)\s*\([^;]*\);/);
|
||||
if (cameraAssign) {
|
||||
const target = cameraAssign[1];
|
||||
const after = src.slice(src.indexOf(cameraAssign[0]) + cameraAssign[0].length);
|
||||
const reads = new RegExp(`\\b${target}\\b`).test(after);
|
||||
if (!reads) {
|
||||
fail(`${id}: assigns sigCamera to '${target}' and never reads it again — ` +
|
||||
`the trait is declared but the image cannot change`);
|
||||
}
|
||||
}
|
||||
|
||||
// A scene whose only content is the previous frame is black on its first
|
||||
// frames and different after a seek than after playback.
|
||||
if (/\bprev\s*\(/.test(src)) {
|
||||
const bodyBeforePrev = src.slice(0, src.indexOf('prev('));
|
||||
if (!/\b(pal|palRamp|fbm|vnoise|hash1[12])\s*\(/.test(bodyBeforePrev)) {
|
||||
fail(`${id}: reads prev() without generating a base image first — ` +
|
||||
`it will be black until feedback converges and will not survive a seek`);
|
||||
}
|
||||
}
|
||||
|
||||
// Loop cost. GLSL needs a constant bound, so the pattern here is a
|
||||
// generous fixed bound plus an early break on the param that actually
|
||||
// decides the count — that break is what keeps the cost proportional to
|
||||
// what the look asked for. A big bound WITHOUT one runs every iteration
|
||||
// on every pixel at 4K, which the budget check will catch on a GPU and
|
||||
// this catches in a second.
|
||||
for (const loop of src.matchAll(/for\s*\(\s*int\s+\w+\s*=\s*0\s*;\s*\w+\s*<\s*(\d+)[^)]*\)/g)) {
|
||||
const bound = Number(loop[1]);
|
||||
const body = src.slice(src.indexOf(loop[0]) + loop[0].length, src.indexOf(loop[0]) + loop[0].length + 600);
|
||||
const breaksEarly = /\bbreak\s*;/.test(body);
|
||||
// Opt-out for a genuinely fixed-cost loop — sampling a curve at a
|
||||
// fixed resolution has nothing to break on. The author states it,
|
||||
// and the measured 4K budget check still governs.
|
||||
const optOut = /\/\/\s*lint:\s*fixed-cost/.test(
|
||||
src.slice(Math.max(0, src.indexOf(loop[0]) - 200), src.indexOf(loop[0])));
|
||||
if (optOut) continue;
|
||||
|
||||
if (bound > 64) {
|
||||
fail(`${id}: fixed loop bound ${bound} is too large whatever it breaks on`);
|
||||
} else if (bound > 16 && !breaksEarly) {
|
||||
fail(`${id}: loop of ${bound} with no early break — bound it on the param ` +
|
||||
`(\`if (i >= u_count) break;\`) so the cost follows what the look asked for`);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate params: anything the shader multiplies absolute time by must be
|
||||
// flagged `rate: true`, which stops reactivity and drift from touching it.
|
||||
// Modulating such a param jumps the phase by elapsed*delta — sixty seconds
|
||||
|
||||
202
flow-state/tools/new-scene.js
Normal file
202
flow-state/tools/new-scene.js
Normal file
@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
// Scaffold a new scene: writes the module, registers it, and leaves it in a
|
||||
// state that already passes every static and GPU gate.
|
||||
//
|
||||
// The point is token economy as much as typing. Everything a new scene needs
|
||||
// besides its shader body is mechanical — the params block, the trait
|
||||
// declarations that match the helpers actually called, the registry import and
|
||||
// entry, a reactive mapping that is not on a rate param — and every one of
|
||||
// those has its own lint rule waiting to reject it. Generating them from a
|
||||
// template means the only thing left to write is the part that needs judgement.
|
||||
//
|
||||
// Usage:
|
||||
// npm run new:scene -- "Salt Flat" --family=minimal --traits=shape,camera,space,style
|
||||
// npm run new:scene -- "Ink Bleed" --family=organic --traits=camera,style --feedback
|
||||
//
|
||||
// Then edit the `scene()` body. The skeleton renders a live, animated, seeded
|
||||
// field, so the scene is gate-clean from the first run and stays that way while
|
||||
// you replace the body a piece at a time.
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SCENES = join(root, 'src/scenes/shader');
|
||||
const REGISTRY = join(root, 'src/scenes/registry.js');
|
||||
|
||||
const FAMILIES = ['flow', 'organic', 'minimal', 'structural', 'geometric', 'glitch'];
|
||||
const TRAITS = ['shape', 'camera', 'space', 'style'];
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flags = new Map(args.filter((a) => a.startsWith('--')).map((a) => {
|
||||
const [k, v] = a.replace(/^--/, '').split('=');
|
||||
return [k, v === undefined ? true : v];
|
||||
}));
|
||||
const name = args.find((a) => !a.startsWith('--'));
|
||||
|
||||
if (!name) {
|
||||
console.error(`usage: npm run new:scene -- "Scene Name" --family=<${FAMILIES.join('|')}> ` +
|
||||
`--traits=<${TRAITS.join(',')}> [--feedback]`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const family = String(flags.get('family') || 'flow');
|
||||
if (!FAMILIES.includes(family)) {
|
||||
console.error(`unknown family '${family}' — one of ${FAMILIES.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const traits = String(flags.get('traits') || 'camera,style').split(',').map((t) => t.trim()).filter(Boolean);
|
||||
for (const t of traits) {
|
||||
if (!TRAITS.includes(t)) {
|
||||
console.error(`unknown trait '${t}' — one of ${TRAITS.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const kebab = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
const camel = kebab.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
||||
const file = join(SCENES, `${kebab}.js`);
|
||||
|
||||
if (existsSync(file)) {
|
||||
console.error(`${kebab}.js already exists`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-scene constant baked into the skeleton.
|
||||
*
|
||||
* Two freshly scaffolded scenes would otherwise render identically and trip the
|
||||
* "no two scenes render the same image" gate before either has been written.
|
||||
* Deriving the constants from the name means the skeleton is already distinct.
|
||||
*/
|
||||
function salt(text, lo, hi) {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return +(lo + (h % 1000) / 1000 * (hi - lo)).toFixed(2);
|
||||
}
|
||||
|
||||
const freq = salt(kebab, 2.5, 7.5);
|
||||
const skew = salt(kebab + 'x', 0.4, 2.2);
|
||||
const warp = salt(kebab + 'w', 0.3, 1.6);
|
||||
|
||||
// Trait expression: each declared trait gets a real call, because the lint
|
||||
// greps for one and the Phase 9 render gate then measures that it changed the
|
||||
// image. A trait declared and not used fails both, in that order.
|
||||
const traitLines = {
|
||||
camera: ' p = sigCamera(p);',
|
||||
space: ' float horizon = sigHorizonY();\n p.y -= horizon * 0.3;',
|
||||
shape: '', // used in the body below
|
||||
style: '', // used in the body below
|
||||
};
|
||||
|
||||
const head = traits.map((t) => traitLines[t]).filter(Boolean).join('\n');
|
||||
const shapeLine = traits.includes('shape')
|
||||
? '\n // TRAIT shape: the track\'s signature form, so this scene is cast from\n' +
|
||||
' // the same actors as every other scene in the video.\n' +
|
||||
' float form = sigShape(p / max(u_size, 1e-3)) * u_size;\n' +
|
||||
' col += pal(3) * sigEdgeOrMask(form);'
|
||||
: '';
|
||||
const styleLine = traits.includes('style')
|
||||
? '\n // TRAIT style: the track\'s art direction.\n col += sigGrain(uv);'
|
||||
: '';
|
||||
const spaceLine = traits.includes('space')
|
||||
? '\n col = sigAir(col, p, smoothstep(0.0, 1.6, length(p)));'
|
||||
: '';
|
||||
const feedbackLine = flags.get('feedback')
|
||||
? '\n // Feedback. The base field above must stand alone: a scene that only\n' +
|
||||
' // reads prev() is black for its first frames and fragile under seek.\n' +
|
||||
' col = max(col, prev(uv - vec2(0.0, 0.002)) * u_persist);'
|
||||
: '';
|
||||
|
||||
const shapeHelper = traits.includes('shape')
|
||||
? `
|
||||
// Fill for the signature form, drawn in the track's line weight.
|
||||
float sigEdgeOrMask(float d) {
|
||||
return smoothstep(0.01, -0.01, d) * 0.6 + sigEdge(d);
|
||||
}
|
||||
`
|
||||
: '';
|
||||
|
||||
const params = [
|
||||
` scale: { type: 'float', range: [1, 12], default: ${freq}, uniform: 'u_scale', bias: 'density' },`,
|
||||
` speed: { type: 'float', range: [0.05, 1.2], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },`,
|
||||
` detail: { type: 'float', range: [0.2, 2.5], default: ${warp}, uniform: 'u_detail', bias: 'density' },`,
|
||||
` glow: { type: 'float', range: [0, 1.5], default: 0.5, uniform: 'u_glow', bias: 'energy' },`,
|
||||
traits.includes('shape')
|
||||
? ` size: { type: 'float', range: [0.05, 0.6], default: 0.25, uniform: 'u_size' },`
|
||||
: null,
|
||||
flags.get('feedback')
|
||||
? ` persist: { type: 'float', range: [0, 0.85], default: 0.4, uniform: 'u_persist' },`
|
||||
: null,
|
||||
` palette: { type: 'palette', count: 5 },`,
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const source = `// ${family[0].toUpperCase() + family.slice(1)} family: TODO one line on what this looks like.
|
||||
//
|
||||
// TODO: say what makes it DIFFERENT from the scenes it sits next to. That
|
||||
// sentence is the scene's reason to exist, and "no two scenes render the same
|
||||
// image" is a gate, not a guideline.
|
||||
//
|
||||
// Scaffolded by tools/new-scene.js. See HOWTO-visualizers.md.
|
||||
|
||||
export const ${camel} = {
|
||||
name: '${name.trim()}',
|
||||
family: '${family}',
|
||||
kind: 'fragment',
|
||||
traits: [${traits.map((t) => `'${t}'`).join(', ')}],
|
||||
|
||||
params: {
|
||||
${params}
|
||||
},
|
||||
|
||||
reactive: {
|
||||
glow: { feature: 'beat', amount: 0.3, response: 'spike' },
|
||||
detail: { feature: 'bandMid', amount: 0.25, response: 'smooth' },
|
||||
},
|
||||
|
||||
shader: \`${shapeHelper}
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
${head}
|
||||
|
||||
// TODO: replace this field. It exists so the skeleton is live, animated and
|
||||
// distinct from every other scene the moment it is registered.
|
||||
float n = fbm(p * u_scale * 0.5 + vec2(t * 0.4, -t * ${skew}), 4);
|
||||
float band = sin(n * u_detail * 6.0 + length(p) * ${freq} - t * 2.0) * 0.5 + 0.5;
|
||||
|
||||
vec3 col = mix(pal(0) * 0.08, pal(1), band);
|
||||
col += pal(2) * pow(band, 4.0) * u_glow;${shapeLine}${spaceLine}${feedbackLine}${styleLine}
|
||||
|
||||
return vec4(col, 1.0);
|
||||
}
|
||||
\`,
|
||||
};
|
||||
|
||||
export default ${camel};
|
||||
`;
|
||||
|
||||
writeFileSync(file, source);
|
||||
|
||||
// --- register -------------------------------------------------------------
|
||||
let registry = readFileSync(REGISTRY, 'utf8');
|
||||
const importLine = `import { ${camel} } from './shader/${kebab}.js';`;
|
||||
if (!registry.includes(importLine)) {
|
||||
const lastImport = registry.lastIndexOf("} from './shader/");
|
||||
const eol = registry.indexOf('\n', lastImport);
|
||||
registry = registry.slice(0, eol + 1) + importLine + '\n' + registry.slice(eol + 1);
|
||||
}
|
||||
registry = registry.replace(/\n\];/, `\n ${camel},\n];`);
|
||||
writeFileSync(REGISTRY, registry);
|
||||
|
||||
console.log(`created src/scenes/shader/${kebab}.js`);
|
||||
console.log(`registered ${camel} (${family}, traits: ${traits.join(', ') || 'none'})`);
|
||||
console.log('');
|
||||
console.log('next:');
|
||||
console.log(' 1. write the scene() body — everything else is done');
|
||||
console.log(' 2. npm run lint:scenes');
|
||||
console.log(` 3. open http://localhost:5180/checks.html?scene=${encodeURIComponent(name.trim())}`);
|
||||
Loading…
Reference in New Issue
Block a user