diff --git a/fktry/src/screen/baseTexture.ts b/fktry/src/screen/baseTexture.ts new file mode 100644 index 0000000..4fe77fe --- /dev/null +++ b/fktry/src/screen/baseTexture.ts @@ -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; + }, + }; +} diff --git a/fktry/src/screen/glitchStack.ts b/fktry/src/screen/glitchStack.ts new file mode 100644 index 0000000..976caca --- /dev/null +++ b/fktry/src/screen/glitchStack.ts @@ -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; + 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 }; diff --git a/fktry/src/screen/params.ts b/fktry/src/screen/params.ts new file mode 100644 index 0000000..e778b58 --- /dev/null +++ b/fktry/src/screen/params.ts @@ -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_` 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; + +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))); +}