/** * The GLYTCH shader stack, ported from the WIMVEE GLYTCH prototype (../index.html). * * 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. 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 = ` 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_block, u_ring, u_moire; uniform float u_drift, u_brownout, u_flash; uniform float u_strain, u_fever; uniform vec2 u_moireSeed; 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){ 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); } float lu(vec3 c){ return dot(c, vec3(0.299,0.587,0.114)); } // REUSABLE SPATIAL-ARTIFACT TEMPLATE (round 2 proposal, round 3 build). // An irregular growth front creeping outward from the seed point. grow=0 is nothing, grow=1 has // swallowed the frame. The fbm term makes the front ragged and organic instead of a // tidy circle, so the artifact spreads like the codex's maze walls rather than a spotlight. // Any future artifact that should GROW FROM A PLACE rather than fade in everywhere should // multiply its amount by this. float growthMask(vec2 p, vec2 seed, float grow){ vec2 d = p - seed; d.x *= 1.78; // 16:9 correction: grow circles, not ellipses float r = length(d); float ragged = (fbm(d*3.2 + seed*7.0) - 0.5) * 0.38; float front = grow * 1.35; // reach past the corners at grow=1 return smoothstep(front + 0.10, front - 0.10, r + ragged); } 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; // signal strain: the reserve is draining. A tremor, not a seizure — deliberately about a // quarter of the brownout's amplitude, so dread reads as dread and the brownout still lands. if(u_strain > 0.001){ float js = floor(tt*9.0); uv.x += (hash(vec2(js, 17.0))-0.5) * u_strain * 0.017; uv.y += (hash(vec2(js, 23.0))-0.5) * u_strain * 0.011; } // fever: the decoders are running hot. Slow heat-haze shimmer, no judder. if(u_fever > 0.001){ uv.x += sin(uv.y*38.0 + tt*1.7) * u_fever * 0.0045; uv.y += sin(uv.x*26.0 + tt*1.1) * u_fever * 0.0030; } // 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; // ---- 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. // REGIONAL: the crystal GROWS from a seeded place and creeps outward with the tally, rather // than fading in across the whole frame. Intensity saturates early while AREA keeps growing, // so a big shipment reads as "the maze has spread", not "everything got slightly shimmery". if(u_moire > 0.001){ float mo = clamp(u_moire*2.2, 0.0, 1.0) * growthMask(vUv, u_moireSeed, u_moire); if(mo > 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, mo*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); // 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)); // fever runs warm: the picture itself develops a temperature col = mix(col, col*vec3(1.12,0.98,0.88), u_fever*0.55); // 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' | 'strain' | 'fever' | 'moireSeed' | 'stampTex' | 'scramTex' | 'stampAmt' | 'stampScale' | 'scram' | 'scramY'; 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; /** 0..1 tremor from the draining bandwidth reserve (weather, not climate) */ strain: number; /** 0..1 feverish shimmer from aggregate machine heat */ fever: number; /** where the moire crystal grows from, in screen uv */ moireSeed: [number, number]; /** 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 { 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, 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'); 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); // 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); 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.strain = gl.getUniformLocation(prog, 'u_strain'); uni.fever = gl.getUniformLocation(prog, 'u_fever'); uni.moireSeed = gl.getUniformLocation(prog, 'u_moireSeed'); 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, strain, fever, moireSeed, 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.strain, strain); gl.uniform1f(uni.fever, fever); gl.uniform2f(uni.moireSeed, moireSeed[0], moireSeed[1]); 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); }, }; } export { BASE_W, BASE_H };