[screen] port GLYTCH shader stack + procedural clean feed
Raw WebGL fullscreen triangle, not three.js: one texture, one pass. The 8 prototype params keep their tuning verbatim; u_drift, u_brownout and u_flash are additions. Base feed repaints only when a visible glyph changes rather than per frame, so the texture upload stays off the hot path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f015a66283
commit
3dd423fe2b
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
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 };
|
||||
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