// link.js — bandwidth is resolution. // Your connection speed is wired directly to the renderer. This is the whole thesis // and it is one render target, one shader and one uniform. import * as THREE from '../vendor/three.module.js'; // rows is the height of the render target. In ascii mode it is literally the number // of text rows on screen, so it has to stay in terminal territory — 88 rows gives you // five-pixel glyphs and a screen full of grey noise. export const BAUDS = [ // rate label rows ascii quant scan { rate: 300, label: '300 baud', rows: 36, ascii: 1, quant: 3, scan: 1.0 }, { rate: 1200, label: '1200 baud', rows: 48, ascii: 1, quant: 4, scan: 0.9 }, { rate: 2400, label: '2400 baud', rows: 120, ascii: 0, quant: 4, scan: 0.8 }, { rate: 9600, label: '9600 baud', rows: 220, ascii: 0, quant: 6, scan: 0.55 }, { rate: 14400, label: 'v.32bis', rows: 360, ascii: 0, quant: 10, scan: 0.35 }, { rate: 33600, label: 'v.34', rows: 560, ascii: 0, quant: 24, scan: 0.18 }, { rate: 56000, label: 'v.90', rows: 0, ascii: 0, quant: 0, scan: 0.06 }, // rows 0 = native ]; export const BAUD_MAX = BAUDS.length - 1; // The 16-step ramp. Reads as a picture at a distance and as a terminal up close. const RAMP = " .'`^\":;~+=*coaOX#%@"; function glyphAtlas(cell = 32) { const n = RAMP.length; const cv = document.createElement('canvas'); cv.width = cell * n; cv.height = cell; const g = cv.getContext('2d'); g.fillStyle = '#000'; g.fillRect(0, 0, cv.width, cv.height); g.fillStyle = '#fff'; g.font = `bold ${Math.floor(cell * 0.82)}px ui-monospace, Menlo, Consolas, monospace`; g.textAlign = 'center'; g.textBaseline = 'middle'; for (let i = 0; i < n; i++) g.fillText(RAMP[i], i * cell + cell / 2, cell * 0.54); const tex = new THREE.CanvasTexture(cv); tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false; tex.colorSpace = THREE.NoColorSpace; return tex; } const VERT = /* glsl */` varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); } `; const FRAG = /* glsl */` precision highp float; varying vec2 vUv; uniform sampler2D tScene; uniform sampler2D tGlyph; uniform vec2 uRes; // render-target size in pixels uniform vec2 uOut; // output size in pixels uniform float uAscii; // 0/1 uniform float uGlyphs; // glyph count in atlas uniform float uQuant; // colour levels, 0 = off uniform float uScan; // scanline / CRT amount uniform vec3 uTint; // phosphor tint, (1,1,1) = neutral uniform float uTintMix; uniform float uTime; uniform float uFade; // 1 = normal, 0 = black uniform float uGlitch; // packet corruption uniform float uSplit; // 1 = two viewports, draw the seam // three renders into a linear target; nothing downstream of a raw ShaderMaterial // will encode it for us, so we do it here and work in display space from then on. vec3 toSRGB(vec3 c){ c = max(c, vec3(0.0)); return mix(c * 12.92, 1.055 * pow(c, vec3(0.41666)) - 0.055, step(vec3(0.0031308), c)); } // 4x4 ordered Bayer. Dither is not a filter, it is a commitment. float bayer(vec2 p){ int x = int(mod(p.x,4.0)); int y = int(mod(p.y,4.0)); int i = x + y*4; float m[16]; m[0]=0.0; m[1]=8.0; m[2]=2.0; m[3]=10.0; m[4]=12.0; m[5]=4.0; m[6]=14.0; m[7]=6.0; m[8]=3.0; m[9]=11.0; m[10]=1.0; m[11]=9.0; m[12]=15.0;m[13]=7.0; m[14]=13.0;m[15]=5.0; float v = 0.0; for(int k=0;k<16;k++){ if(k==i) v = m[k]; } return (v + 0.5)/16.0; } void main(){ vec2 uv = vUv; // corrupted packets tear the line horizontally if(uGlitch > 0.001){ float band = floor(uv.y * 48.0); float r = fract(sin(band*91.7 + floor(uTime*14.0)*13.1)*43758.5453); if(r < uGlitch) uv.x = fract(uv.x + (r-0.5)*0.28*uGlitch); } vec3 col; if(uAscii > 0.5){ // one glyph per cell of the low-res target vec2 cell = floor(uv * uRes); vec2 inCell= fract(uv * uRes); vec3 s = toSRGB(texture2D(tScene, (cell + 0.5)/uRes).rgb); float lum = dot(s, vec3(0.299,0.587,0.114)); lum = clamp(pow(lum, 0.85), 0.0, 1.0); float gi = floor(lum * (uGlyphs - 1.0) + 0.5); vec2 guv = vec2((gi + inCell.x)/uGlyphs, 1.0 - inCell.y); float ink = texture2D(tGlyph, guv).r; // colour survives, but it is carried entirely by the glyph vec3 base = s / max(lum, 0.04); col = base * ink * clamp(lum*1.35 + 0.12, 0.0, 1.6); } else { col = toSRGB(texture2D(tScene, uv).rgb); } if(uQuant > 0.5){ vec2 px = uv * uRes; float d = (bayer(px) - 0.5) / uQuant; col = floor(clamp(col + d, 0.0, 1.0) * uQuant + 0.5) / uQuant; } col = mix(col, col * uTint, uTintMix); if(uSplit > 0.5){ float d = abs(uv.x - 0.5); if(d < 0.0016) col = vec3(0.0); else if(d < 0.004) col *= 0.35; } if(uScan > 0.01){ float line = sin(uv.y * uOut.y * 1.0) * 0.5 + 0.5; col *= 1.0 - uScan * 0.28 * line; // vignette. every CRT had one and pretending otherwise is a lie vec2 q = uv - 0.5; col *= 1.0 - uScan * 0.9 * dot(q,q); } gl_FragColor = vec4(col * uFade, 1.0); } `; export class Link { constructor(renderer) { this.renderer = renderer; this.index = BAUD_MAX; // start on fibre. you will not stay there. this.target = null; this.fade = 1; this.glitch = 0; this.scene = new THREE.Scene(); this.cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); this.uni = { tScene: { value: null }, tGlyph: { value: glyphAtlas() }, uRes: { value: new THREE.Vector2(1, 1) }, uOut: { value: new THREE.Vector2(1, 1) }, uAscii: { value: 0 }, uGlyphs: { value: RAMP.length }, uQuant: { value: 0 }, uScan: { value: 0.06 }, uTint: { value: new THREE.Color(1, 1, 1) }, uTintMix:{ value: 0 }, uTime: { value: 0 }, uFade: { value: 1 }, uGlitch: { value: 0 }, uSplit: { value: 0 }, }; const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([-1,-1,0, 3,-1,0, -1,3,0]), 3)); geo.setAttribute('uv', new THREE.BufferAttribute(new Float32Array([0,0, 2,0, 0,2]), 2)); this.quad = new THREE.Mesh(geo, new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms: this.uni, depthTest: false, depthWrite: false, })); this.quad.frustumCulled = false; this.scene.add(this.quad); this.resize(); } get spec() { return BAUDS[this.index]; } get label() { return this.spec.label; } get rate() { return this.spec.rate; } setBaud(i) { this.index = Math.max(0, Math.min(BAUD_MAX, i | 0)); this.resize(); return this.spec; } setRate(rate) { let best = 0; BAUDS.forEach((b, i) => { if (b.rate <= rate) best = i; }); return this.setBaud(best); } tint(hex, mix = 1) { this.uni.uTint.value.set(hex); this.uni.uTintMix.value = mix; } noTint() { this.uni.uTintMix.value = 0; } resize() { const r = this.renderer, dpr = Math.min(window.devicePixelRatio || 1, 2); const w = window.innerWidth, h = window.innerHeight; r.setPixelRatio(dpr); r.setSize(w, h, false); const spec = this.spec; let rw, rh; if (spec.rows === 0) { rw = Math.floor(w * dpr); rh = Math.floor(h * dpr); } else { rh = spec.rows; rw = Math.max(2, Math.round(rh * (w / h))); } if (!this.target) { this.target = new THREE.WebGLRenderTarget(rw, rh, { minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, type: THREE.UnsignedByteType, depthBuffer: true, }); // Leave the target linear. The post pass is a raw ShaderMaterial, so three // never appends its output colour-space conversion — the shader does the // sRGB encode itself, first thing, and every effect after that runs in // display space where dithering and glyph ramps actually belong. this.target.texture.colorSpace = THREE.NoColorSpace; } else this.target.setSize(rw, rh); this.uni.tScene.value = this.target.texture; this.uni.uRes.value.set(rw, rh); this.uni.uOut.value.set(w * dpr, h * dpr); this.uni.uAscii.value = spec.ascii; this.uni.uQuant.value = spec.quant; this.uni.uScan.value = spec.scan; this.aspect = w / h; } render(scene, camera, t) { this.uni.uTime.value = t; this.uni.uFade.value = this.fade; this.uni.uGlitch.value = this.glitch; const r = this.renderer; r.setRenderTarget(this.target); r.clear(); r.render(scene, camera); r.setRenderTarget(null); r.render(this.scene, this.cam); } // Two viewports into the same target, scissored, then one post pass over both. // For the branch you run down twice: left is what happens, right is what does not. renderSplit(scene, camL, camR, t) { this.uni.uTime.value = t; this.uni.uFade.value = this.fade; this.uni.uGlitch.value = this.glitch; const r = this.renderer, tgt = this.target; const w = tgt.width, h = tgt.height, hw = Math.floor(w / 2); const aspect = hw / h; for (const c of [camL, camR]) { if (c.isPerspectiveCamera && c.aspect !== aspect) { c.aspect = aspect; c.updateProjectionMatrix(); } } r.setRenderTarget(tgt); r.setScissorTest(true); r.setViewport(0, 0, hw, h); r.setScissor(0, 0, hw, h); r.render(scene, camL); r.setViewport(hw, 0, w - hw, h); r.setScissor(hw, 0, w - hw, h); r.render(scene, camR); r.setScissorTest(false); r.setViewport(0, 0, w, h); r.setScissor(0, 0, w, h); r.setRenderTarget(null); this.uni.uSplit.value = 1; r.render(this.scene, this.cam); this.uni.uSplit.value = 0; } // put a camera back the way the rest of the game expects it unsplit(camera) { if (camera && camera.isPerspectiveCamera) { camera.aspect = this.aspect; camera.updateProjectionMatrix(); } } }