Compare commits
4 Commits
f015a66283
...
5a85360ef9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a85360ef9 | ||
|
|
f31e36de72 | ||
|
|
2982862e83 | ||
|
|
3dd423fe2b |
@ -63,3 +63,86 @@ measured ms). NOTES written.
|
||||
### Round N — YYYY-MM-DD — [your model]
|
||||
SHIPPED: / DECISIONS: / BLOCKED/BROKEN: / CONTRACT REQUEST: / PROPOSAL: / NEXT:
|
||||
```
|
||||
|
||||
### Round 1 — 2026-07-17 — Opus 4.8
|
||||
|
||||
SHIPPED: all 5 orders done and verified on the dev build.
|
||||
- `params.ts` — the 8 params + frame-rate-independent `ease()`.
|
||||
- `glitchStack.ts` — GLYTCH shader stack ported. Raw WebGL, NOT three.js (order 1 said my
|
||||
call): it's one fullscreen triangle + one texture; three.js would cost more than the
|
||||
whole effect is allowed to. All 8 prototype params keep their tuning verbatim — I
|
||||
changed no magic numbers. Added 3 uniforms of my own: `u_drift`, `u_brownout`, `u_flash`.
|
||||
- `baseTexture.ts` — procedural clean programming: SMPTE bars + castellations + pluge/ramp
|
||||
+ camcorder overlay (PLAY ▶, running SP timecode, rec dot), widened to 16:9.
|
||||
- `corruptionMap.ts` — the ledger + item→artifact table + escalation curve.
|
||||
- `index.ts` — compositor: ledger, easing, wobble, brownout, awakening.
|
||||
- `corruptionMap.test.ts` — 24 tests, all green.
|
||||
|
||||
DECISIONS:
|
||||
- **Mix vs. total are separate axes.** WHAT you ship picks the artifact (blend of per-item
|
||||
weights, by ship share); HOW MUCH you've ever shipped picks the intensity (escalation).
|
||||
This is what makes 40 chroma slurry read as *chroma* instead of "more glitch".
|
||||
- Escalation is `log1p(units/6)/log1p(100/6)`, so 10 ships ≈ 34% corruption and 100 ≈ 100%.
|
||||
Front-loaded hard, per orders — there's a test pinning early-vs-late slope.
|
||||
- Base texture repaints only when a visible glyph changes (timecode ~1/sec), not per frame.
|
||||
Re-uploading 640×360 every frame would have eaten the budget for nothing. Motion for the
|
||||
corruption to chew on comes from `u_drift` (bounded sinusoid — deliberately not a wrapping
|
||||
pan, which would show a seam: the texture is non-power-of-two so WebGL1 forces CLAMP_TO_EDGE).
|
||||
- Wobble above 0.8 total is CPU-side, 8 distinct slow rates, ±0.12 riding on top of the eased
|
||||
value and never feeding back into the ledger.
|
||||
- **I extended the item table well past the orders' examples.** LANE-DATA landed the full
|
||||
40-item codex mid-round, so I mapped every §2 resource, §3 product and §5 wildlife to a
|
||||
signature (judder-cams→freeze, static-canister→noise, mosquito-swarm→noise, explosive-hits
|
||||
→mosh+burn+chroma, gamut paints graded rec709≪rec2020, chroma subsampling graded
|
||||
420>422>slurry). The 5 science packs are intentionally left on the generic-noise fallback —
|
||||
they're research currency, not ship goods. Unknown ids still degrade gracefully; there's a
|
||||
test that walks `data/items.json` and asserts every id yields sane params, so LANE-DATA can
|
||||
keep adding content without me.
|
||||
|
||||
VERIFIED (dev build, driving the real module on the real canvas, reading GL pixels — not vibes):
|
||||
- Starts sterile: bars + "NOTHING IS WRONG" chyron, `idle:true`, all params 0.
|
||||
- 40× chroma-slurry → `chroma 0.684`, every other param 0 except a 0.082 burn trace. Screenshot
|
||||
shows channel-split fringing on bars and doubled glyphs. Item-specific: confirmed.
|
||||
- Awakening: first-ever ship renders exactly ONE frame at mean luminance 255 (full white),
|
||||
79 the next frame.
|
||||
- Mature: 120× melt → total 1.0, melt 1.0 / mosh 0.451; screenshot shows the bars dripping
|
||||
into black. Above 0.8, params drift ~0.095 between frames — alive, not maxed-out static.
|
||||
- Anchor-slab isolation test: 1× anchor sets `idle:false` with ZERO corruption, and the only
|
||||
pixel change is the chyron band going 37.1→100.8 luminance (dark overlay removed, bars
|
||||
revealed). Chyron logic and sanitation both confirmed in one shot.
|
||||
- Brownout: 1.0 when on, 0.0 when lifted, freeze+dropout+judder all driven off it.
|
||||
- **Frame budget: 0.004–0.013 ms/frame CPU** (rolling avg, `__fktryScreen.stats().msPerFrame`).
|
||||
Budget was <2ms. GPU side is one fullscreen triangle, no readbacks in the hot path.
|
||||
|
||||
BLOCKED/BROKEN: nothing blocking me. Two things worth knowing:
|
||||
- LANE-SIM emits no `shipped` events yet, so THE SCREEN can't be driven by the real chain.
|
||||
I shipped a debug hook instead: `__fktryScreen.ship('melt',40)`, `.brownout(true)`,
|
||||
`.reset()`, `.stats()`. It's ~20 lines in `init` and I'm happy to drop it once the chain
|
||||
runs — say the word.
|
||||
- `npm test` currently has 2 red in `src/sim/sim.test.ts` (crafting, shipping) and they are
|
||||
NOT mine — flagging for your review, not touching them. My 24 pass; tsc is clean repo-wide.
|
||||
|
||||
NOTE ON A THING I TOUCHED OUTSIDE MY LANE: I briefly created `.claude/launch.json` to get a
|
||||
dev server on a free port (8150 is held by another session), then **deleted it** — the tooling
|
||||
kept resolving to the busy port anyway. Net diff outside `src/screen/**` is zero. If you want
|
||||
lanes to be able to boot their own preview, that file probably wants to be orchestrator-owned
|
||||
with one entry per lane; right now five parallel lanes would collide on it.
|
||||
|
||||
CONTRACT REQUEST: none. `ScreenFX` + `SimEvent` gave me everything I needed.
|
||||
|
||||
PROPOSAL (orders 2 asked me to flag this): **anchor-slab sanitizing is live and it's good
|
||||
tension** — potency −2.0, so 1 anchor cancels ~2 items of corruption and enough of them
|
||||
sterilize THE SCREEN completely (tested). Right now that's invisible to the player, which
|
||||
makes it a trap: they'll ship I-frames for a commission and watch their sky go clean with no
|
||||
explanation. Two options for you — (a) UI surfaces it (LANE-UI chip: "SANITISING"), or (b) the
|
||||
fiction owns it: The Correction *pays* for anchor-slabs, so it's a deliberate sellout loop —
|
||||
bandwidth now, corruption later. I'd build (b); it's the codex's whole conflict in one item.
|
||||
Also: `ScreenFX` currently only sees events, never the snapshot — if you ever want THE SCREEN
|
||||
to react to *bandwidth strain* or *heat* continuously (not just the brownout on/off edge),
|
||||
that'd need `frame()` to see the snapshot. Not asking yet; noting the seam.
|
||||
|
||||
NEXT (my read, not self-assigning): item-specific artifacts that the 8 prototype params can't
|
||||
express — moiré needs a real interference lattice, ringing needs edge-detected halos,
|
||||
macroblock bricks want the 8×8 grid to be *legible*. That's new shader passes, not new
|
||||
weights. Also THE SCREEN should probably show the shipped item's silhouette for a beat as it
|
||||
lands. Say the word on round 2.
|
||||
|
||||
94
fktry/src/screen/baseTexture.ts
Normal file
94
fktry/src/screen/baseTexture.ts
Normal file
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* THE SCREEN's base content: procedural "clean programming".
|
||||
* Sterile SMPTE-ish test pattern + camcorder overlay, ported from the prototype's
|
||||
* makeBaseTexture and widened to 16:9. This is what the factory's output is painted
|
||||
* ONTO — it must be bland enough that any corruption reads instantly.
|
||||
*
|
||||
* Cost control: the pattern is static, so we only repaint when a visible glyph changes
|
||||
* (timecode ticks ~1/sec, chyron toggles once). Motion comes from the shader's slow
|
||||
* drift, not from re-uploading 640x360 pixels every frame.
|
||||
*/
|
||||
|
||||
export const BASE_W = 640;
|
||||
export const BASE_H = 360;
|
||||
|
||||
export interface BaseFeed {
|
||||
canvas: HTMLCanvasElement;
|
||||
/** Repaint if anything visible changed. Returns true when the texture needs re-upload. */
|
||||
update(timeMs: number, idle: boolean): boolean;
|
||||
}
|
||||
|
||||
function timecode(timeMs: number): string {
|
||||
// Tape-style running timecode, starting at a suspiciously specific point.
|
||||
const total = Math.floor(timeMs / 1000) + 754; // 0:12:34
|
||||
const h = Math.floor(total / 3600);
|
||||
const m = Math.floor((total % 3600) / 60);
|
||||
const s = total % 60;
|
||||
return `SP ${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function createBaseFeed(): BaseFeed {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = BASE_W;
|
||||
canvas.height = BASE_H;
|
||||
const g = canvas.getContext('2d')!;
|
||||
let lastTC = '';
|
||||
let lastIdle: boolean | null = null;
|
||||
|
||||
function paint(tc: string, idle: boolean) {
|
||||
const bars = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0'];
|
||||
const bw = BASE_W / bars.length;
|
||||
bars.forEach((col, i) => {
|
||||
g.fillStyle = col;
|
||||
g.fillRect(i * bw, 0, bw + 1, BASE_H * 0.62);
|
||||
});
|
||||
// castellations strip
|
||||
const casts = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
|
||||
casts.forEach((col, i) => {
|
||||
g.fillStyle = col;
|
||||
g.fillRect(i * bw, BASE_H * 0.62, bw + 1, BASE_H * 0.1);
|
||||
});
|
||||
// pluge / ramp
|
||||
g.fillStyle = '#101010';
|
||||
g.fillRect(0, BASE_H * 0.72, BASE_W, BASE_H * 0.28);
|
||||
const ramp = g.createLinearGradient(0, 0, BASE_W, 0);
|
||||
ramp.addColorStop(0, '#000');
|
||||
ramp.addColorStop(1, '#fff');
|
||||
g.fillStyle = ramp;
|
||||
g.fillRect(BASE_W * 0.55, BASE_H * 0.8, BASE_W * 0.4, 18);
|
||||
|
||||
// camcorder overlay
|
||||
g.fillStyle = '#eee';
|
||||
g.font = "bold 22px 'Courier New', monospace";
|
||||
g.fillText('PLAY ▶', 20, BASE_H * 0.86);
|
||||
g.fillText(tc, 20, BASE_H * 0.95);
|
||||
g.font = "bold 18px 'Courier New', monospace";
|
||||
g.fillStyle = '#f4f4f4';
|
||||
g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95);
|
||||
g.fillStyle = '#ff4444';
|
||||
g.beginPath();
|
||||
g.arc(BASE_W - 26, BASE_H * 0.83, 7, 0, Math.PI * 2);
|
||||
g.fill();
|
||||
|
||||
// Idle chyron: OSHA-poster deadpan. Disappears forever once the factory ships.
|
||||
if (idle) {
|
||||
g.fillStyle = 'rgba(8,8,12,0.82)';
|
||||
g.fillRect(0, BASE_H * 0.5, BASE_W, 34);
|
||||
g.fillStyle = '#d8ffd8';
|
||||
g.font = "bold 20px 'Courier New', monospace";
|
||||
g.fillText('NOTHING IS WRONG', 18, BASE_H * 0.5 + 24);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canvas,
|
||||
update(timeMs: number, idle: boolean) {
|
||||
const tc = timecode(timeMs);
|
||||
if (tc === lastTC && idle === lastIdle) return false;
|
||||
lastTC = tc;
|
||||
lastIdle = idle;
|
||||
paint(tc, idle);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
145
fktry/src/screen/corruptionMap.test.ts
Normal file
145
fktry/src/screen/corruptionMap.test.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { escalation, resolveLedger, corruptionFor, ESCALATION_ITEMS } from './corruptionMap';
|
||||
import { PARAMS } from './params';
|
||||
|
||||
const tally = (o: Record<string, number>) => new Map(Object.entries(o));
|
||||
/** The param carrying the most pressure — "what does the player actually SEE". */
|
||||
const dominant = (t: Record<string, number>) =>
|
||||
PARAMS.reduce((a, b) => (t[a] >= t[b] ? a : b));
|
||||
|
||||
describe('escalation curve', () => {
|
||||
it('is 0 when nothing has shipped and 1 at the tuned item count', () => {
|
||||
expect(escalation(0)).toBe(0);
|
||||
expect(escalation(-5)).toBe(0);
|
||||
expect(escalation(ESCALATION_ITEMS)).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it('clamps above the tuned count', () => {
|
||||
expect(escalation(10_000)).toBe(1);
|
||||
});
|
||||
|
||||
it('is monotonic and front-loaded: the first 10 ships outrun the last 10', () => {
|
||||
let prev = -1;
|
||||
for (let n = 0; n <= 120; n += 5) {
|
||||
const e = escalation(n);
|
||||
expect(e).toBeGreaterThanOrEqual(prev);
|
||||
prev = e;
|
||||
}
|
||||
const early = escalation(10) - escalation(0);
|
||||
const late = escalation(100) - escalation(90);
|
||||
expect(early).toBeGreaterThan(late * 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('item -> artifact legibility', () => {
|
||||
it('40 chroma slurry reads as chroma damage specifically', () => {
|
||||
const { targets } = resolveLedger(tally({ 'chroma-slurry': 40 }));
|
||||
expect(dominant(targets)).toBe('chroma');
|
||||
expect(targets.chroma).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it('melt reads as melt, not generic mush', () => {
|
||||
const { targets } = resolveLedger(tally({ melt: 40 }));
|
||||
expect(dominant(targets)).toBe('melt');
|
||||
expect(targets.melt).toBeGreaterThan(targets.mosh);
|
||||
});
|
||||
|
||||
it('hf-dust reads as static', () => {
|
||||
expect(dominant(resolveLedger(tally({ 'hf-dust': 40 })).targets)).toBe('noise');
|
||||
});
|
||||
|
||||
it('mixing shifts the blend toward whatever dominates the shipping', () => {
|
||||
const mixed = resolveLedger(tally({ 'chroma-slurry': 38, 'hf-dust': 2 })).targets;
|
||||
expect(dominant(mixed)).toBe('chroma');
|
||||
expect(mixed.noise).toBeGreaterThan(0); // the dust is still visible, just outvoted
|
||||
});
|
||||
|
||||
it('unknown items degrade to a generic noise bump instead of vanishing', () => {
|
||||
const { targets, total } = resolveLedger(tally({ 'megaglitch-prototype': 20 }));
|
||||
expect(total).toBeGreaterThan(0);
|
||||
expect(dominant(targets)).toBe('noise');
|
||||
expect(corruptionFor('megaglitch-prototype').potency).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codex signatures read as themselves', () => {
|
||||
it.each([
|
||||
['static-canister', 'noise'], // bottled analog snow
|
||||
['judder-cams', 'freeze'], // 3:2 pulldown, machined
|
||||
['macroblock-bricks', 'mosh'], // the quantizer's confession
|
||||
['false-color-speckle', 'chroma'],
|
||||
['mosquito-swarm', 'noise'], // literal mosquito noise
|
||||
['v-hold-roll', 'roll'],
|
||||
['silver-frames', 'burn'], // celluloid
|
||||
['bloom-concentrate', 'mosh'], // motion compounded on itself
|
||||
])('%s reads as %s', (item, param) => {
|
||||
expect(dominant(resolveLedger(tally({ [item]: 40 })).targets)).toBe(param);
|
||||
});
|
||||
|
||||
it('grades chroma subsampling by how much colour was thrown away', () => {
|
||||
expect(corruptionFor('chroma-420').potency).toBeGreaterThan(corruptionFor('chroma-422').potency);
|
||||
expect(corruptionFor('chroma-422').potency).toBeGreaterThan(corruptionFor('chroma-slurry').potency);
|
||||
});
|
||||
|
||||
it('rates laser-primary gamut paint far above house-gray', () => {
|
||||
expect(corruptionFor('gamut-paint-rec2020').potency).toBeGreaterThan(
|
||||
corruptionFor('gamut-paint-rec709').potency * 3,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('survives whatever LANE-DATA ships', () => {
|
||||
it('every item in data/items.json produces sane params', async () => {
|
||||
const items = (await import('../../data/items.json')).default as Array<{ id: string }>;
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
for (const { id } of items) {
|
||||
const { targets, total } = resolveLedger(tally({ [id]: 25 }));
|
||||
expect(Number.isFinite(total), `${id} total`).toBe(true);
|
||||
for (const p of PARAMS) {
|
||||
expect(Number.isFinite(targets[p]), `${id}.${p}`).toBe(true);
|
||||
expect(targets[p], `${id}.${p}`).toBeGreaterThanOrEqual(0);
|
||||
expect(targets[p], `${id}.${p}`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('anchor slabs sanitize', () => {
|
||||
it('shipping I-frames pulls corruption back down', () => {
|
||||
const dirty = resolveLedger(tally({ melt: 20 })).total;
|
||||
const cleaned = resolveLedger(tally({ melt: 20, 'anchor-slab': 5 })).total;
|
||||
expect(cleaned).toBeLessThan(dirty);
|
||||
});
|
||||
|
||||
it('enough anchors fully sterilize THE SCREEN', () => {
|
||||
const { total, targets } = resolveLedger(tally({ melt: 10, 'anchor-slab': 50 }));
|
||||
expect(total).toBe(0);
|
||||
for (const p of PARAMS) expect(targets[p]).toBe(0);
|
||||
});
|
||||
|
||||
it('anchors alone never drive corruption negative', () => {
|
||||
const { units, total } = resolveLedger(tally({ 'anchor-slab': 99 }));
|
||||
expect(units).toBe(0);
|
||||
expect(total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('param hygiene', () => {
|
||||
it('never emits a param outside 0..1, even shipping everything at once', () => {
|
||||
const everything = tally({
|
||||
melt: 500, 'chroma-slurry': 500, 'hf-dust': 500, 'delta-wafer': 500,
|
||||
'gop-crate': 500, 'luma-bars': 500, 'coefficient-pack': 500, 'mdat-ore': 500,
|
||||
});
|
||||
const { targets } = resolveLedger(everything);
|
||||
for (const p of PARAMS) {
|
||||
expect(targets[p]).toBeGreaterThanOrEqual(0);
|
||||
expect(targets[p]).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('an empty ledger is fully sterile', () => {
|
||||
const { targets, total } = resolveLedger(tally({}));
|
||||
expect(total).toBe(0);
|
||||
for (const p of PARAMS) expect(targets[p]).toBe(0);
|
||||
});
|
||||
});
|
||||
133
fktry/src/screen/corruptionMap.ts
Normal file
133
fktry/src/screen/corruptionMap.ts
Normal file
@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Item -> artifact mapping. This is the design core of THE SCREEN: a player who ships
|
||||
* 40 chroma slurry must SEE chroma damage specifically, not "more glitch".
|
||||
*
|
||||
* Two independent quantities come out of the ledger:
|
||||
* - MIX (which artifacts) = the weighted blend of what you ship
|
||||
* - TOTAL (how much) = the escalation curve over lifetime shipments
|
||||
* Shipping a lot of one thing bends the mix hard toward that item's signature.
|
||||
*/
|
||||
import type { GameData } from '../contracts';
|
||||
import { PARAMS, type Param, type ParamSet, zeroParams } from './params';
|
||||
|
||||
export interface ItemCorruption {
|
||||
/** How much this item escalates overall corruption. Negative = sanitizes. */
|
||||
potency: number;
|
||||
/** Which artifacts this item argues for. Values 0..1, blended by ship share. */
|
||||
weights: Partial<Record<Param, number>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed by ItemDef.id (codex canon, see docs/FKTRY_LORE.md §2/§3). Items not listed
|
||||
* here still work — they get FALLBACK, a small generic noise bump — so LANE-DATA can
|
||||
* add content without touching this lane. Unrecognised ids are reported at init.
|
||||
*/
|
||||
const CORRUPTION: Record<string, ItemCorruption> = {
|
||||
// ---- raw (§2) : undifferentiated damage, cheap to ship, weak signature
|
||||
'mdat-ore': { potency: 0.4, weights: { noise: 0.5, burn: 0.25 } },
|
||||
'bayer-grit': { potency: 0.4, weights: { noise: 0.55, chroma: 0.25 } },
|
||||
'field-splinters': { potency: 0.5, weights: { tear: 0.6, roll: 0.45 } }, // interlace
|
||||
'silver-frames': { potency: 0.5, weights: { burn: 0.7 } }, // celluloid
|
||||
|
||||
// ---- refined (§2)
|
||||
'luma-bars': { potency: 0.6, weights: { roll: 0.6, tear: 0.5 } },
|
||||
'chroma-slurry': { potency: 1.0, weights: { chroma: 1.0, burn: 0.12 } },
|
||||
// Subsampling grades: the more you throw away, the harder the colour bleeds.
|
||||
'chroma-422': { potency: 1.05, weights: { chroma: 1.0, melt: 0.15 } },
|
||||
'chroma-420': { potency: 1.1, weights: { chroma: 1.0, melt: 0.3 } },
|
||||
'color-plate': { potency: 0.7, weights: { chroma: 0.75 } },
|
||||
'coefficient-pack': { potency: 0.7, weights: { freeze: 0.55, noise: 0.35, tear: 0.3 } },
|
||||
'hf-dust': { potency: 0.8, weights: { noise: 0.9, tear: 0.2 } },
|
||||
'mv-flux': { potency: 1.0, weights: { mosh: 0.9, melt: 0.45 } }, // motion vectors
|
||||
'v-hold-roll': { potency: 0.7, weights: { roll: 1.0 } },
|
||||
|
||||
// ---- frames (§2)
|
||||
// I-frames are CLEAN REFERENCE. Shipping anchors sanitizes THE SCREEN.
|
||||
// See NOTES: deliberate tension, flagged for the orchestrator.
|
||||
'anchor-slab': { potency: -2.0, weights: {} },
|
||||
'delta-wafer': { potency: 0.9, weights: { mosh: 0.85, melt: 0.3 } }, // P: prediction, no anchor
|
||||
'janus-wafer': { potency: 1.0, weights: { mosh: 0.6, freeze: 0.6, melt: 0.25 } }, // B: bidirectional
|
||||
'gop-crate': { potency: 1.0, weights: { freeze: 0.7, mosh: 0.45, roll: 0.25 } },
|
||||
|
||||
// ---- glitch products (§3) : the signatures. These must read INSTANTLY.
|
||||
melt: { potency: 1.2, weights: { melt: 0.95, mosh: 0.55 } },
|
||||
'bloom-concentrate': { potency: 1.3, weights: { mosh: 0.9, melt: 0.6 } },
|
||||
'macroblock-bricks': { potency: 1.1, weights: { mosh: 1.0 } },
|
||||
'ringing-halos': { potency: 0.9, weights: { noise: 0.5, tear: 0.45 } },
|
||||
'static-canister': { potency: 1.0, weights: { noise: 1.0 } },
|
||||
'moire-crystal': { potency: 1.1, weights: { chroma: 0.6, noise: 0.5, tear: 0.35 } },
|
||||
'false-color-speckle': { potency: 1.2, weights: { chroma: 1.0, noise: 0.3 } },
|
||||
'judder-cams': { potency: 1.1, weights: { freeze: 1.0 } }, // 3:2 pulldown
|
||||
'explosive-hits': { potency: 1.5, weights: { mosh: 0.8, burn: 0.6, chroma: 0.5 } },
|
||||
// Gamut paints: house-gray is nearly inert, laser primaries are weapons-grade.
|
||||
'gamut-paint-rec709': { potency: 0.3, weights: { chroma: 0.2 } },
|
||||
'gamut-paint-dci-p3': { potency: 0.8, weights: { chroma: 0.7 } },
|
||||
'gamut-paint-rec2020': { potency: 1.3, weights: { chroma: 1.0, burn: 0.5 } },
|
||||
|
||||
// ---- wildlife (§5)
|
||||
'mosquito-swarm': { potency: 0.9, weights: { noise: 0.95 } }, // literal mosquito noise
|
||||
'gibbs-wraith': { potency: 1.0, weights: { noise: 0.55, tear: 0.5, freeze: 0.3 } },
|
||||
};
|
||||
|
||||
const FALLBACK: ItemCorruption = { potency: 0.5, weights: { noise: 0.6 } };
|
||||
|
||||
export function corruptionFor(id: string): ItemCorruption {
|
||||
return CORRUPTION[id] ?? FALLBACK;
|
||||
}
|
||||
|
||||
/** Items whose corruption profile is unmapped — logged at init so new data is visible. */
|
||||
export function unmappedItems(data: GameData): string[] {
|
||||
return data.items.filter((i) => !(i.id in CORRUPTION)).map((i) => i.id);
|
||||
}
|
||||
|
||||
/** Shipments needed to reach full corruption. Tuned by feel; log-ish, so early ships land hard. */
|
||||
export const ESCALATION_ITEMS = 100;
|
||||
const KNEE = 6; // lower = steeper early curve
|
||||
|
||||
/** Corruption units -> 0..1 total. log-shaped: the first handful of ships feel enormous. */
|
||||
export function escalation(units: number): number {
|
||||
if (units <= 0) return 0;
|
||||
const t = Math.log1p(units / KNEE) / Math.log1p(ESCALATION_ITEMS / KNEE);
|
||||
return Math.min(1, t);
|
||||
}
|
||||
|
||||
export interface LedgerResult {
|
||||
targets: ParamSet;
|
||||
/** net corruption units after anchor-slab sanitation */
|
||||
units: number;
|
||||
/** 0..1 escalation */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blend the per-item tally into target param pressures.
|
||||
* Mix uses only positive-potency items (you can't corrupt *toward* clean); anchors
|
||||
* pull down `units`, which scales everything back down through `total`.
|
||||
*/
|
||||
export function resolveLedger(tally: ReadonlyMap<string, number>): LedgerResult {
|
||||
const targets = zeroParams();
|
||||
let units = 0;
|
||||
let mixWeight = 0;
|
||||
|
||||
for (const [id, count] of tally) {
|
||||
if (count <= 0) continue;
|
||||
units += count * corruptionFor(id).potency;
|
||||
const p = corruptionFor(id).potency;
|
||||
if (p > 0) mixWeight += count * p;
|
||||
}
|
||||
units = Math.max(0, units);
|
||||
const total = escalation(units);
|
||||
if (mixWeight <= 0) return { targets, units, total };
|
||||
|
||||
for (const [id, count] of tally) {
|
||||
if (count <= 0) continue;
|
||||
const { potency, weights } = corruptionFor(id);
|
||||
if (potency <= 0) continue;
|
||||
const share = (count * potency) / mixWeight;
|
||||
for (const p of PARAMS) {
|
||||
targets[p] += (weights[p] ?? 0) * share;
|
||||
}
|
||||
}
|
||||
for (const p of PARAMS) targets[p] = Math.min(1, targets[p] * total);
|
||||
return { targets, units, total };
|
||||
}
|
||||
208
fktry/src/screen/glitchStack.ts
Normal file
208
fktry/src/screen/glitchStack.ts
Normal file
@ -0,0 +1,208 @@
|
||||
/**
|
||||
* The GLYTCH shader stack, ported from the WIMVEE GLYTCH prototype (../index.html).
|
||||
*
|
||||
* The 8 glitch params and their maths are TUNED — the prototype is the reference and
|
||||
* the magic numbers are respected verbatim. Only three things are new here:
|
||||
* u_drift - slow sinusoidal pan of the clean feed (subtle motion to chew on)
|
||||
* u_brownout - the factory's pain: freeze-frame + dropout bars + desync judder
|
||||
* u_flash - one-frame full-white awakening on the first shipment ever
|
||||
*
|
||||
* Raw WebGL, not three.js: this is one fullscreen triangle with one texture. Pulling
|
||||
* three.js in for that would cost more than the whole effect is allowed to cost.
|
||||
*/
|
||||
import { BASE_H, BASE_W, type BaseFeed } from './baseTexture';
|
||||
import { PARAMS, type Param, type ParamSet } from './params';
|
||||
|
||||
const VS = `
|
||||
attribute vec2 p;
|
||||
varying vec2 vUv;
|
||||
void main(){ vUv = p*0.5+0.5; vUv.y = 1.0-vUv.y; gl_Position = vec4(p,0.,1.); }`;
|
||||
|
||||
const FS = `
|
||||
precision mediump float;
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D tex;
|
||||
uniform float t;
|
||||
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
|
||||
uniform float u_drift, u_brownout, u_flash;
|
||||
|
||||
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
|
||||
float vnoise(vec2 p){
|
||||
vec2 i=floor(p), f=fract(p); f=f*f*(3.-2.*f);
|
||||
return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),
|
||||
mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);
|
||||
}
|
||||
float fbm(vec2 p){ return 0.5*vnoise(p)+0.25*vnoise(p*2.3)+0.125*vnoise(p*5.1); }
|
||||
|
||||
void main(){
|
||||
// freeze quantizes the timebase everything else animates on.
|
||||
// A brownout slams the whole timebase into a hard stutter regardless of freeze.
|
||||
float fz = clamp(max(u_freeze, u_brownout*0.85)*1.4, 0., 1.);
|
||||
float tt = mix(t, floor(t*2.5)/2.5, fz);
|
||||
vec2 uv = vUv;
|
||||
|
||||
// slow drift of the clean feed: bounded sinusoid, so no wrap seam
|
||||
uv += vec2(sin(t*0.07)*0.004, cos(t*0.05)*0.003) * u_drift;
|
||||
|
||||
// desync judder: the frame itself loses its footing during a brownout
|
||||
if(u_brownout > 0.001){
|
||||
float j = floor(tt*12.0);
|
||||
uv.x += (hash(vec2(j, 3.0))-0.5) * u_brownout * 0.07;
|
||||
uv.y += (hash(vec2(j, 8.0))-0.5) * u_brownout * 0.04;
|
||||
}
|
||||
|
||||
// v-hold roll
|
||||
uv.y = fract(uv.y + u_roll * tt * 0.35);
|
||||
|
||||
// scanline tears: bands shove sideways
|
||||
float band = floor(uv.y * 36.0);
|
||||
float tr = hash(vec2(band, floor(tt*7.0)));
|
||||
if(tr > 1.0 - u_tear*0.85){
|
||||
uv.x += (hash(vec2(band, floor(tt*7.0)+9.0)) - 0.5) * u_tear * 0.5;
|
||||
}
|
||||
|
||||
// datamosh: macroblocks pick up stale offsets
|
||||
vec2 blk = floor(uv * vec2(16.0, 12.0));
|
||||
float mb = hash(blk + floor(tt*3.0));
|
||||
if(mb > 1.0 - u_mosh*0.9){
|
||||
vec2 off = vec2(hash(blk+1.7), hash(blk+4.2)) - 0.5;
|
||||
uv += off * u_mosh * 0.35;
|
||||
}
|
||||
|
||||
// melt: columns drip downward
|
||||
float drip = pow(vnoise(vec2(uv.x*30.0, floor(tt*1.5))), 3.0);
|
||||
uv.y -= u_melt * drip * uv.y * 0.9;
|
||||
|
||||
uv = fract(uv);
|
||||
|
||||
// chroma split
|
||||
float ca = u_chroma * 0.03;
|
||||
vec3 col;
|
||||
col.r = texture2D(tex, uv + vec2( ca, 0.)).r;
|
||||
col.g = texture2D(tex, uv).g;
|
||||
col.b = texture2D(tex, uv - vec2( ca, ca*0.6)).b;
|
||||
|
||||
// static
|
||||
float sn = hash(uv*vec2(431.0,917.0) + fract(tt)*13.0);
|
||||
col = mix(col, vec3(sn), u_noise * 0.7);
|
||||
|
||||
// freeze dropout: grey flash lines
|
||||
float dropo = step(0.985 - u_freeze*0.12, hash(vec2(floor(uv.y*90.0), floor(tt*5.0))));
|
||||
col = mix(col, vec3(0.45), dropo * u_freeze);
|
||||
|
||||
// brownout dropout: heavy black tape-dropout bars with hot rims
|
||||
if(u_brownout > 0.001){
|
||||
float row = floor(vUv.y*70.0);
|
||||
float d = hash(vec2(row, floor(tt*8.0)));
|
||||
float bar = step(1.0 - u_brownout*0.38, d);
|
||||
col = mix(col, vec3(0.03), bar*u_brownout);
|
||||
float rim = step(1.0 - u_brownout*0.06, hash(vec2(row+31.0, floor(tt*8.0))));
|
||||
col = mix(col, vec3(0.85,0.9,0.85), rim*u_brownout*0.8);
|
||||
}
|
||||
|
||||
// film burn: fbm blotch eats the frame, hot rim then black
|
||||
float b = fbm(uv*3.0 + vec2(0., -tt*0.4));
|
||||
float edge = smoothstep(1.0 - u_burn*1.1, 1.0 - u_burn*1.1 + 0.12, b);
|
||||
vec3 rim2 = vec3(1.0, 0.55, 0.15);
|
||||
col = mix(col, rim2, edge * (1.0 - smoothstep(0.05, 0.35, edge)) * 2.0 * u_burn);
|
||||
col = mix(col, vec3(0.02), smoothstep(0.3, 1.0, edge) * step(0.01, u_burn));
|
||||
|
||||
// faint scanlines for vibe (constant, not scored)
|
||||
col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415);
|
||||
|
||||
// the awakening: one frame of full white on the first shipment ever
|
||||
col = mix(col, vec3(1.0), u_flash);
|
||||
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}`;
|
||||
|
||||
type UniformKey = Param | 't' | 'drift' | 'brownout' | 'flash';
|
||||
|
||||
export interface DrawState {
|
||||
params: ParamSet;
|
||||
timeSec: number;
|
||||
/** 0..1 amount of slow sinusoidal pan applied to the clean feed */
|
||||
drift: number;
|
||||
/** 0..1 brownout severity */
|
||||
brownout: number;
|
||||
/** 0..1 one-frame white awakening */
|
||||
flash: number;
|
||||
/** nothing shipped yet — the clean feed still carries its chyron */
|
||||
idle: boolean;
|
||||
}
|
||||
|
||||
export interface GlitchStack {
|
||||
draw(s: DrawState): void;
|
||||
}
|
||||
|
||||
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
|
||||
const s = gl.createShader(type)!;
|
||||
gl.shaderSource(s, src);
|
||||
gl.compileShader(s);
|
||||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
||||
throw new Error(`[screen] shader compile failed: ${gl.getShaderInfoLog(s)}`);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Returns null when WebGL is unavailable; caller degrades to a dead screen rather than crashing the game. */
|
||||
export function createGlitchStack(canvas: HTMLCanvasElement, base: BaseFeed): GlitchStack | null {
|
||||
const gl = canvas.getContext('webgl', { antialias: false, depth: false, alpha: false });
|
||||
if (!gl) {
|
||||
console.warn('[screen] no WebGL context — THE SCREEN stays dark');
|
||||
return null;
|
||||
}
|
||||
|
||||
const prog = gl.createProgram()!;
|
||||
gl.attachShader(prog, compile(gl, gl.VERTEX_SHADER, VS));
|
||||
gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, FS));
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||||
throw new Error(`[screen] program link failed: ${gl.getProgramInfoLog(prog)}`);
|
||||
}
|
||||
gl.useProgram(prog);
|
||||
|
||||
const buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
||||
const loc = gl.getAttribLocation(prog, 'p');
|
||||
gl.enableVertexAttribArray(loc);
|
||||
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
// 640x360 is non-power-of-two: CLAMP_TO_EDGE + LINEAR + no mips is mandatory in WebGL1.
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
base.update(0, true);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas);
|
||||
|
||||
const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
|
||||
uni.t = gl.getUniformLocation(prog, 't');
|
||||
uni.drift = gl.getUniformLocation(prog, 'u_drift');
|
||||
uni.brownout = gl.getUniformLocation(prog, 'u_brownout');
|
||||
uni.flash = gl.getUniformLocation(prog, 'u_flash');
|
||||
for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`);
|
||||
|
||||
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||
gl.uniform1f(uni.drift, 1);
|
||||
|
||||
return {
|
||||
draw({ params, timeSec, drift, brownout, flash, idle }) {
|
||||
// Re-upload the clean feed only when it actually changed (timecode tick / chyron).
|
||||
if (base.update(timeSec * 1000, idle)) {
|
||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas);
|
||||
}
|
||||
gl.uniform1f(uni.t, timeSec);
|
||||
gl.uniform1f(uni.drift, drift);
|
||||
gl.uniform1f(uni.brownout, brownout);
|
||||
gl.uniform1f(uni.flash, flash);
|
||||
for (const p of PARAMS) gl.uniform1f(uni[p], params[p]);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { BASE_W, BASE_H };
|
||||
@ -1,21 +1,139 @@
|
||||
/** LANE-SCREEN territory. Stub — replace per lanes/LANE-SCREEN.md. */
|
||||
/**
|
||||
* LANE-SCREEN — THE SCREEN compositor.
|
||||
*
|
||||
* The sky-display everything ships to. Starts sterile, ends deranged. Shipped items
|
||||
* feed a corruption ledger (corruptionMap.ts) whose blend drives the 8 tuned glitch
|
||||
* params of the ported GLYTCH shader stack (glitchStack.ts).
|
||||
*
|
||||
* Design contract with the player: WHAT you ship decides WHICH artifact; HOW MUCH you
|
||||
* have ever shipped decides the intensity. Shipments arrive as waves, not switches —
|
||||
* every param eases toward its target.
|
||||
*/
|
||||
import type { GameData, ScreenFX, SimEvent } from '../contracts';
|
||||
import { createBaseFeed } from './baseTexture';
|
||||
import { createGlitchStack, type GlitchStack } from './glitchStack';
|
||||
import { resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
|
||||
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
|
||||
|
||||
/** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */
|
||||
const EASE_TAU = 1.2;
|
||||
/** Brownouts hit fast and lift fast — the factory's pain should be legible immediately. */
|
||||
const BROWNOUT_TAU = 0.12;
|
||||
/** Above this total corruption, params start cross-modulating so a mature SCREEN looks alive. */
|
||||
const WOBBLE_THRESHOLD = 0.8;
|
||||
const WOBBLE_DEPTH = 0.12;
|
||||
/** Distinct slow rates per param so the wobble never reads as one global pulse. */
|
||||
const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07];
|
||||
|
||||
export function createScreenFX(): ScreenFX {
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let stack: GlitchStack | null = null;
|
||||
const tally = new Map<string, number>();
|
||||
const eased: ParamSet = zeroParams();
|
||||
const out: ParamSet = zeroParams();
|
||||
|
||||
let targets: ParamSet = zeroParams();
|
||||
let total = 0;
|
||||
let units = 0;
|
||||
let ledgerDirty = false;
|
||||
|
||||
let brownout = 0;
|
||||
let brownoutTarget = 0;
|
||||
let pendingFlash = false;
|
||||
let hasShipped = false;
|
||||
|
||||
let lastMs = 0;
|
||||
let frameCost = 0; // rolling avg ms of this module's CPU work
|
||||
|
||||
function recompute() {
|
||||
const r = resolveLedger(tally);
|
||||
targets = r.targets;
|
||||
total = r.total;
|
||||
units = r.units;
|
||||
ledgerDirty = false;
|
||||
}
|
||||
|
||||
function ship(item: string, count: number) {
|
||||
if (count <= 0) return;
|
||||
tally.set(item, (tally.get(item) ?? 0) + count);
|
||||
ledgerDirty = true;
|
||||
if (!hasShipped) {
|
||||
hasShipped = true;
|
||||
pendingFlash = true; // the awakening
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init(canvas: HTMLCanvasElement, _data: GameData) {
|
||||
ctx = canvas.getContext('2d');
|
||||
init(canvas: HTMLCanvasElement, data: GameData) {
|
||||
const base = createBaseFeed();
|
||||
stack = createGlitchStack(canvas, base);
|
||||
|
||||
const unmapped = unmappedItems(data);
|
||||
if (unmapped.length) {
|
||||
console.info(
|
||||
`[screen] ${unmapped.length} item(s) have no corruption profile, using generic noise: ${unmapped.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Debug/authoring hook — LANE-SIM does not ship yet, so THE SCREEN must be
|
||||
// drivable by hand. Console: __fktryScreen.ship('melt', 40)
|
||||
(window as any).__fktryScreen = {
|
||||
ship,
|
||||
brownout: (on: boolean) => (brownoutTarget = on ? 1 : 0),
|
||||
reset: () => {
|
||||
tally.clear();
|
||||
ledgerDirty = true;
|
||||
hasShipped = false;
|
||||
for (const p of PARAMS) eased[p] = 0;
|
||||
},
|
||||
stats: () => ({
|
||||
units: +units.toFixed(2),
|
||||
total: +total.toFixed(3),
|
||||
idle: !hasShipped,
|
||||
brownout: +brownout.toFixed(2),
|
||||
msPerFrame: +frameCost.toFixed(3),
|
||||
tally: Object.fromEntries(tally),
|
||||
params: Object.fromEntries(PARAMS.map((p) => [p, +out[p].toFixed(3)])),
|
||||
}),
|
||||
escalationItems: ESCALATION_ITEMS,
|
||||
};
|
||||
},
|
||||
onEvents(_events: SimEvent[]) {},
|
||||
|
||||
onEvents(events: SimEvent[]) {
|
||||
for (const e of events) {
|
||||
if (e.kind === 'shipped') ship(e.item, e.count);
|
||||
else if (e.kind === 'brownout') brownoutTarget = e.on ? 1 : 0;
|
||||
}
|
||||
},
|
||||
|
||||
frame(timeMs: number) {
|
||||
if (!ctx) return;
|
||||
// placeholder: sterile clean playback awaiting corruption
|
||||
ctx.fillStyle = '#101018';
|
||||
ctx.fillRect(0, 0, 640, 360);
|
||||
ctx.fillStyle = '#2a2440';
|
||||
ctx.font = '20px monospace';
|
||||
ctx.fillText('THE SCREEN · 0 corruption', 140, 180 + Math.sin(timeMs / 800) * 4);
|
||||
if (!stack) return;
|
||||
const t0 = performance.now();
|
||||
const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, (timeMs - lastMs) / 1000);
|
||||
lastMs = timeMs;
|
||||
|
||||
if (ledgerDirty) recompute();
|
||||
brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU);
|
||||
|
||||
const timeSec = timeMs / 1000;
|
||||
const wobble =
|
||||
total > WOBBLE_THRESHOLD ? (total - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD) : 0;
|
||||
|
||||
for (let i = 0; i < PARAMS.length; i++) {
|
||||
const p = PARAMS[i];
|
||||
eased[p] = ease(eased[p], targets[p], dt, EASE_TAU);
|
||||
// Wobble rides on top of the eased value; it never feeds back into the ledger.
|
||||
const w =
|
||||
wobble > 0
|
||||
? Math.sin(timeSec * WOBBLE_RATE[i] * Math.PI * 2 + i) * WOBBLE_DEPTH * wobble
|
||||
: 0;
|
||||
out[p] = Math.min(1, Math.max(0, eased[p] + w));
|
||||
}
|
||||
|
||||
const flash = pendingFlash ? 1 : 0;
|
||||
stack.draw({ params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped });
|
||||
pendingFlash = false;
|
||||
|
||||
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
28
fktry/src/screen/params.ts
Normal file
28
fktry/src/screen/params.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The eight glitch parameters, ported verbatim from the WIMVEE GLYTCH prototype.
|
||||
* Names and semantics are load-bearing: the fragment shader reads `u_<key>` and the
|
||||
* prototype's tuning assumes these exact ranges (0..1 each).
|
||||
*/
|
||||
|
||||
export const PARAMS = [
|
||||
'mosh', // macroblock displacement
|
||||
'melt', // vertical pixel drip
|
||||
'chroma', // RGB channel split
|
||||
'tear', // scanline horizontal tears
|
||||
'roll', // vertical hold roll
|
||||
'burn', // film burn blotches
|
||||
'noise', // analog snow
|
||||
'freeze', // time quantize + dropout
|
||||
] as const;
|
||||
|
||||
export type Param = (typeof PARAMS)[number];
|
||||
export type ParamSet = Record<Param, number>;
|
||||
|
||||
export function zeroParams(): ParamSet {
|
||||
return { mosh: 0, melt: 0, chroma: 0, tear: 0, roll: 0, burn: 0, noise: 0, freeze: 0 };
|
||||
}
|
||||
|
||||
/** Frame-rate independent approach toward a target. `tau` = seconds to ~63% of the way. */
|
||||
export function ease(current: number, target: number, dt: number, tau: number): number {
|
||||
return current + (target - current) * (1 - Math.exp(-dt / Math.max(1e-4, tau)));
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user