glytch/fktry/src/screen/baseTexture.ts
type-two 3dd423fe2b [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>
2026-07-17 16:11:35 +10:00

95 lines
3.3 KiB
TypeScript

/**
* 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;
},
};
}