diff --git a/fktry/src/screen/corruptionMap.test.ts b/fktry/src/screen/corruptionMap.test.ts index cafc561..b72cbbd 100644 --- a/fktry/src/screen/corruptionMap.test.ts +++ b/fktry/src/screen/corruptionMap.test.ts @@ -64,18 +64,39 @@ describe('item -> artifact legibility', () => { 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 + ['static-canister', 'noise'], // bottled analog snow + ['judder-cams', 'freeze'], // 3:2 pulldown, machined + ['macroblock-bricks', 'block'], // the quantizer's confession + ['coefficient-pack', 'block'], // coeff overdrive reads as the grid + ['ringing-halos', 'ring'], // Gibbs phenomenon, canned + ['gibbs-wraith', 'ring'], + ['moire-crystal', 'moire'], // interference made solid ['false-color-speckle', 'chroma'], - ['mosquito-swarm', 'noise'], // literal mosquito noise + ['mosquito-swarm', 'noise'], // literal mosquito noise ['v-hold-roll', 'roll'], - ['silver-frames', 'burn'], // celluloid - ['bloom-concentrate', 'mosh'], // motion compounded on itself + ['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('40 bricks look UNMISTAKABLY unlike 40 slurry (round 2 legibility bar)', () => { + const bricks = resolveLedger(tally({ 'macroblock-bricks': 40 })).targets; + const slurry = resolveLedger(tally({ 'chroma-slurry': 40 })).targets; + // Each must dominate its own signature and carry ~none of the other's. + expect(bricks.block).toBeGreaterThan(0.5); + expect(slurry.block).toBe(0); + expect(slurry.chroma).toBeGreaterThan(0.5); + expect(bricks.chroma).toBe(0); + }); + + it('keeps the three new artifact classes distinct from each other', () => { + const b = resolveLedger(tally({ 'macroblock-bricks': 40 })).targets; + const r = resolveLedger(tally({ 'ringing-halos': 40 })).targets; + const m = resolveLedger(tally({ 'moire-crystal': 40 })).targets; + expect([b.ring, b.moire, r.block, r.moire, m.block, m.ring]).toEqual([0, 0, 0, 0, 0, 0]); + }); + 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); diff --git a/fktry/src/screen/corruptionMap.ts b/fktry/src/screen/corruptionMap.ts index 0c3c83e..7a80ff5 100644 --- a/fktry/src/screen/corruptionMap.ts +++ b/fktry/src/screen/corruptionMap.ts @@ -36,7 +36,8 @@ const CORRUPTION: Record = { '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 } }, + // DCT coefficients overdriven: quantization, and the grid it confesses to. + 'coefficient-pack': { potency: 0.7, weights: { block: 0.7, freeze: 0.4, noise: 0.2 } }, '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 } }, @@ -52,10 +53,12 @@ const CORRUPTION: Record = { // ---- 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 } }, + // Round 2: these three now have artifact passes of their own. A pile of bricks must + // read as a blocky grid, not as displacement; halos as ringing, not as static. + 'macroblock-bricks': { potency: 1.1, weights: { block: 1.0, mosh: 0.2 } }, + 'ringing-halos': { potency: 0.9, weights: { ring: 1.0, noise: 0.15 } }, 'static-canister': { potency: 1.0, weights: { noise: 1.0 } }, - 'moire-crystal': { potency: 1.1, weights: { chroma: 0.6, noise: 0.5, tear: 0.35 } }, + 'moire-crystal': { potency: 1.1, weights: { moire: 1.0, chroma: 0.2 } }, '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 } }, @@ -66,7 +69,7 @@ const CORRUPTION: Record = { // ---- 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 } }, + 'gibbs-wraith': { potency: 1.0, weights: { ring: 0.85, noise: 0.25, freeze: 0.25 } }, }; const FALLBACK: ItemCorruption = { potency: 0.5, weights: { noise: 0.6 } }; diff --git a/fktry/src/screen/glitchStack.ts b/fktry/src/screen/glitchStack.ts index 976caca..1042cc9 100644 --- a/fktry/src/screen/glitchStack.ts +++ b/fktry/src/screen/glitchStack.ts @@ -1,16 +1,19 @@ /** * 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: + * The 8 original glitch params and their maths are TUNED — the prototype is the reference + * and the magic numbers are respected verbatim. Everything else here is additive: * 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 + * u_block/u_ring/u_moire (round 2) - artifact classes the original 8 cannot express + * u_stamp + u_scram uniforms (r2) - transient overlays composited in screen space * - * 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. + * Raw WebGL, not three.js: this is one fullscreen triangle. 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 type { Overlays } from './overlays'; import { PARAMS, type Param, type ParamSet } from './params'; const VS = ` @@ -24,7 +27,10 @@ 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_block, u_ring, u_moire; uniform float u_drift, u_brownout, u_flash; +uniform sampler2D u_stampTex, u_scramTex; +uniform float u_stampAmt, u_stampScale, u_scram, u_scramY; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); } float vnoise(vec2 p){ @@ -33,6 +39,7 @@ float vnoise(vec2 p){ 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); } +float lu(vec3 c){ return dot(c, vec3(0.299,0.587,0.114)); } void main(){ // freeze quantizes the timebase everything else animates on. @@ -82,6 +89,53 @@ void main(){ col.g = texture2D(tex, uv).g; col.b = texture2D(tex, uv - vec2( ca, ca*0.6)).b; + // ---- macroblock quantization: the quantizer's confession, 8x8 and LEGIBLE. + // The lattice is locked to screen space (vUv) so it stays a rigid grid while melt and + // mosh drag the picture underneath it — that rigidity is what makes it read as blocks. + if(u_block > 0.001){ + vec2 g = vec2(80.0, 45.0); // 8x8 source pixels at 640x360 + vec2 cell = (floor(vUv*g)+0.5)/g; + vec3 bcol = texture2D(tex, uv + (cell - vUv)).rgb; // one flat sample per cell + float levels = mix(24.0, 3.0, u_block); // flat areas starve as it bites + bcol = floor(bcol*levels + 0.5)/levels; + col = mix(col, bcol, u_block); + vec2 f = fract(vUv*g); + float seam = max(step(f.x, 0.07), step(f.y, 0.07)); + col = mix(col, col*0.55, seam*u_block*0.85); + } + + // ---- ringing halos: Gibbs oscillation ringing OUT from every contrast edge. + // Narrow differences only ever light up the 1px boundary, which reads as "slightly + // sharper" rather than as haloing — so the gradient is sampled WIDE (out to 7px) to give + // the halo room to ring, and the oscillation runs parallel to the edge it came from. + if(u_ring > 0.001){ + vec2 px = vec2(1.0/640.0, 1.0/360.0); + vec3 blur = 0.25*(texture2D(tex, uv+vec2(1.6*px.x,0.)).rgb + texture2D(tex, uv-vec2(1.6*px.x,0.)).rgb + + texture2D(tex, uv+vec2(0.,1.6*px.y)).rgb + texture2D(tex, uv-vec2(0.,1.6*px.y)).rgb); + float n1 = lu(texture2D(tex, uv+vec2(3.0*px.x,0.)).rgb) - lu(texture2D(tex, uv-vec2(3.0*px.x,0.)).rgb); + float n2 = lu(texture2D(tex, uv+vec2(0.,3.0*px.y)).rgb) - lu(texture2D(tex, uv-vec2(0.,3.0*px.y)).rgb); + float n3 = lu(texture2D(tex, uv+vec2(7.0*px.x,0.)).rgb) - lu(texture2D(tex, uv-vec2(7.0*px.x,0.)).rgb); + float n4 = lu(texture2D(tex, uv+vec2(0.,7.0*px.y)).rgb) - lu(texture2D(tex, uv-vec2(0.,7.0*px.y)).rgb); + vec2 grad = vec2(n1 + n3*0.75, n2 + n4*0.75); + float edge = clamp(length(grad)*2.2, 0., 1.); + vec2 dir = normalize(grad + vec2(1e-4)); + float proj = dot(dir, vec2(vUv.x*640.0, vUv.y*360.0)); + float rings = sin(proj*1.15 - tt*5.0); // ethereal, faintly vibrating + col += vec3(0.82,0.90,1.0) * rings * edge * u_ring * 0.85; + col += (col - blur) * u_ring * 1.5; // overshoot at the edge itself + } + + // ---- moire: two near-identical lattices beating against the sensor grid + if(u_moire > 0.001){ + float ang = 0.06 + 0.02*sin(tt*0.07); + vec2 r = vec2(vUv.x*cos(ang)-vUv.y*sin(ang), vUv.x*sin(ang)+vUv.y*cos(ang)); + float a = sin(vUv.x*640.0*0.55 + vUv.y*3.0); + float b = sin(r.x*640.0*0.575 + r.y*2.0 + tt*0.25); + float m = a*b; // the beat: swirling maze lattice + vec3 sheen = 0.5+0.5*cos(6.2831*(vec3(0.0,0.33,0.67) + m*1.2 + tt*0.03)); + col = mix(col, col*0.4 + sheen*0.7, u_moire*clamp(abs(m)*1.5, 0., 1.)); + } + // static float sn = hash(uv*vec2(431.0,917.0) + fract(tt)*13.0); col = mix(col, vec3(sn), u_noise * 0.7); @@ -110,13 +164,35 @@ void main(){ // faint scanlines for vibe (constant, not scored) col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415); + // ---- shipment stamp: screen space (vUv), so a melting feed never drags the placard + if(u_stampAmt > 0.001){ + vec2 halfSize = vec2(0.30, 0.15)*u_stampScale; + vec2 suv = (vUv - vec2(0.5, 0.44))/(2.0*halfSize) + 0.5; + if(suv.x > 0.0 && suv.x < 1.0 && suv.y > 0.0 && suv.y < 1.0){ + vec4 s = texture2D(u_stampTex, suv); + col = mix(col, s.rgb, s.a*u_stampAmt); + } + } + + // ---- scram: the tape loses tracking for exactly one frame + if(u_scram > 0.5){ + float halfH = 0.085; + float d = vUv.y - u_scramY; + if(abs(d) < halfH){ + vec4 sb = texture2D(u_scramTex, vec2(vUv.x, (d + halfH)/(2.0*halfH))); + col = mix(col, sb.rgb, sb.a); + } + } + // 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'; +type UniformKey = + | Param | 't' | 'drift' | 'brownout' | 'flash' + | 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY'; export interface DrawState { params: ParamSet; @@ -129,6 +205,16 @@ export interface DrawState { flash: number; /** nothing shipped yet — the clean feed still carries its chyron */ idle: boolean; + /** 0..1 opacity of the shipment stamp */ + stampAmt: number; + /** stamp size multiplier — bigger shipments stamp harder */ + stampScale: number; + /** true for exactly one frame per scram event */ + scram: boolean; + /** 0..1 vertical position of the scram bar */ + scramY: number; + /** set when the stamp canvas has been repainted and needs re-upload */ + stampDirty: boolean; } export interface GlitchStack { @@ -146,7 +232,11 @@ function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLSha } /** 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 { +export function createGlitchStack( + canvas: HTMLCanvasElement, + base: BaseFeed, + overlays: Overlays, +): 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'); @@ -169,36 +259,66 @@ export function createGlitchStack(canvas: HTMLCanvasElement, base: BaseFeed): Gl 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); + // All three sources are non-power-of-two: CLAMP_TO_EDGE + LINEAR + no mips is mandatory + // in WebGL1. Each texture keeps its own unit for the lifetime of the stack, so the hot + // path never rebinds — it only re-uploads on the frames where a canvas actually changed. + function makeTex(unit: number, src: TexImageSource): WebGLTexture { + const t = gl!.createTexture()!; + gl!.activeTexture(gl!.TEXTURE0 + unit); + gl!.bindTexture(gl!.TEXTURE_2D, t); + 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); + gl!.texImage2D(gl!.TEXTURE_2D, 0, gl!.RGBA, gl!.RGBA, gl!.UNSIGNED_BYTE, src); + return t; + } + base.update(0, true); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas); + overlays.paintStamp('', 0, '#000'); // allocate the stamp texture at full size up front + const baseTex = makeTex(0, base.canvas); + const stampTex = makeTex(1, overlays.stampCanvas); + makeTex(2, overlays.scramCanvas); const uni = {} as Record; 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'); + uni.stampAmt = gl.getUniformLocation(prog, 'u_stampAmt'); + uni.stampScale = gl.getUniformLocation(prog, 'u_stampScale'); + uni.scram = gl.getUniformLocation(prog, 'u_scram'); + uni.scramY = gl.getUniformLocation(prog, 'u_scramY'); for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`); + gl.uniform1i(gl.getUniformLocation(prog, 'tex'), 0); + gl.uniform1i(gl.getUniformLocation(prog, 'u_stampTex'), 1); + gl.uniform1i(gl.getUniformLocation(prog, 'u_scramTex'), 2); + gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform1f(uni.drift, 1); return { - draw({ params, timeSec, drift, brownout, flash, idle }) { + draw({ params, timeSec, drift, brownout, flash, idle, stampAmt, stampScale, scram, scramY, stampDirty }) { // Re-upload the clean feed only when it actually changed (timecode tick / chyron). if (base.update(timeSec * 1000, idle)) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, baseTex); gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas); } + if (stampDirty) { + gl.activeTexture(gl.TEXTURE0 + 1); + gl.bindTexture(gl.TEXTURE_2D, stampTex); + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, overlays.stampCanvas); + } gl.uniform1f(uni.t, timeSec); gl.uniform1f(uni.drift, drift); gl.uniform1f(uni.brownout, brownout); gl.uniform1f(uni.flash, flash); + gl.uniform1f(uni.stampAmt, stampAmt); + gl.uniform1f(uni.stampScale, stampScale); + gl.uniform1f(uni.scram, scram ? 1 : 0); + gl.uniform1f(uni.scramY, scramY); for (const p of PARAMS) gl.uniform1f(uni[p], params[p]); gl.drawArrays(gl.TRIANGLES, 0, 3); }, diff --git a/fktry/src/screen/params.ts b/fktry/src/screen/params.ts index e778b58..f96a8ef 100644 --- a/fktry/src/screen/params.ts +++ b/fktry/src/screen/params.ts @@ -1,7 +1,11 @@ /** - * The eight glitch parameters, ported verbatim from the WIMVEE GLYTCH prototype. - * Names and semantics are load-bearing: the fragment shader reads `u_` and the - * prototype's tuning assumes these exact ranges (0..1 each). + * The glitch parameters. The first eight are ported verbatim from the WIMVEE GLYTCH + * prototype — names and semantics are load-bearing: the fragment shader reads `u_` + * and the prototype's tuning assumes these exact ranges (0..1 each). Their maths is + * untouched. + * + * The last three (round 2) are artifact classes the original eight could not express: + * they are new shader passes, not new blends of old ones. */ export const PARAMS = [ @@ -13,13 +17,19 @@ export const PARAMS = [ 'burn', // film burn blotches 'noise', // analog snow 'freeze', // time quantize + dropout + 'block', // 8x8 quantization: flat blocks, hard seams, posterized colour + 'ring', // Gibbs ringing: oscillating halos hugging contrast edges + 'moire', // interference lattice: beat pattern + rainbow sheen ] as const; export type Param = (typeof PARAMS)[number]; export type ParamSet = Record; export function zeroParams(): ParamSet { - return { mosh: 0, melt: 0, chroma: 0, tear: 0, roll: 0, burn: 0, noise: 0, freeze: 0 }; + return { + mosh: 0, melt: 0, chroma: 0, tear: 0, roll: 0, burn: 0, noise: 0, freeze: 0, + block: 0, ring: 0, moire: 0, + }; } /** Frame-rate independent approach toward a target. `tau` = seconds to ~63% of the way. */