an npmx inspired game for PICO-8 npicomx.vinnymac.dev
games pico-8 adventure npmx
0

Configure Feed

Select the types of activity you want to include in your feed.

npicomx / crt.js
13 kB 341 lines
1// ── CRT shader overlay for the npicomx game ────────────────────────────────── 2// 3// Renders the live PICO-8 canvas (#canvas) through the "Serenity" WebGL CRT 4// shader from gingerbeardman 5// sample canvas as a texture every frame to draw to secondary on top. 6 7import { CRTShader } from './vendor/CRTShader.js'; 8 9const PIXEL_RATIO_CAP = 2; // cap devicePixelRatio so mobile GPUs aren't overdrawn 10const STORAGE_KEY = 'npicomx-crt'; // localStorage: 'off' disables; anything else (incl. unset) = on 11 12// Exposed as window.npicomxCRT.params for live tuning from the devtools console. 13const PRESET = { 14 scanlineIntensity: 0.5, 15 scanlineCount: 0, 16 adaptiveIntensity: 0.3, 17 brightness: 1.45, // lift to offset the darkening from scanlines + vignette 18 contrast: 1.1, 19 saturation: 1.2, 20 bloomIntensity: 0.5, 21 bloomThreshold: 0.5, 22 rgbShift: 0.5, 23 vignetteStrength: 0.35, 24 curvature: 0.12, 25 flickerStrength: 0.015, 26}; 27 28// ── DOM handles ────────────────────────────────────────────────────────────── 29const gameCanvas = document.getElementById('canvas'); 30const stage = document.getElementById('stage'); 31const crtCanvas = document.getElementById('crt-canvas'); 32const btn = document.getElementById('btn-tv'); 33 34function webgl2Supported() { 35 try { 36 return !!document.createElement('canvas').getContext('webgl2'); 37 } catch (_) { 38 return false; 39 } 40} 41 42// ── Make the game canvas readable as a texture ─────────────────────────────── 43// A WebGL drawing buffer is cleared right after the browser composites it, so by 44// the time our RAF runs, texImage2D(gameCanvas) would read black/garbage. Setting 45// preserveDrawingBuffer:true at context-creation time keeps the buffer around to 46// be sampled. 47function patchGameCanvasForCapture() { 48 if (!gameCanvas || gameCanvas.__crtPatched) return; 49 gameCanvas.__crtPatched = true; 50 const original = gameCanvas.getContext.bind(gameCanvas); 51 gameCanvas.getContext = function (type, attrs) { 52 if (type === 'webgl' || type === 'experimental-webgl' || type === 'webgl2') { 53 attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true }); 54 } 55 return original(type, attrs); 56 }; 57} 58 59// ── Renderer state (lazily initialised on first enable) ────────────────────── 60let gl = null; 61let program = null; 62let texture = null; 63let uniforms = null; 64let texW = 0; 65let texH = 0; 66let displayH = 0; 67let rafId = null; 68let resizeObserver = null; 69let on = false; 70 71function ensureInit() { 72 if (gl) return; 73 74 gl = crtCanvas.getContext('webgl2', { alpha: false }); 75 if (!gl) throw new Error('WebGL2 context creation failed'); 76 77 program = createProgram(gl, buildVertexSource(), buildFragmentSource()); 78 // Empty VAO: the vertex shader synthesises the quad from gl_VertexID, no buffers. 79 gl.bindVertexArray(gl.createVertexArray()); 80 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // canvas Y is top-down, GL is bottom-up 81 82 uniforms = getUniformLocations(gl, program, [ 83 'uTexture', 'uEnabled', 'scanlineIntensity', 'scanlineCount', 'time', 'yOffset', 84 'brightness', 'contrast', 'saturation', 'bloomIntensity', 'bloomThreshold', 85 'rgbShift', 'adaptiveIntensity', 'vignetteStrength', 'curvature', 'flickerStrength', 86 ]); 87 88 texture = gl.createTexture(); 89 gl.bindTexture(gl.TEXTURE_2D, texture); 90 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 91 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); 92 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); 93 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); 94} 95 96// ── Geometry: lay #crt-canvas exactly over #canvas ─────────────────────────── 97// #crt-canvas is absolutely positioned inside #stage; the game canvas is centred 98// in #stage at a size that varies by breakpoint, so mirror its on-screen box and 99// size the backing store to that box × dpr (capped). 100function syncGeometry() { 101 const g = gameCanvas.getBoundingClientRect(); 102 const s = stage.getBoundingClientRect(); 103 crtCanvas.style.left = `${g.left - s.left}px`; 104 crtCanvas.style.top = `${g.top - s.top}px`; 105 crtCanvas.style.width = `${g.width}px`; 106 crtCanvas.style.height = `${g.height}px`; 107 displayH = g.height; 108 109 const dpr = Math.min(window.devicePixelRatio || 1, PIXEL_RATIO_CAP); 110 const bw = Math.max(1, Math.round(g.width * dpr)); 111 const bh = Math.max(1, Math.round(g.height * dpr)); 112 if (crtCanvas.width !== bw || crtCanvas.height !== bh) { 113 crtCanvas.width = bw; 114 crtCanvas.height = bh; 115 } 116} 117 118// ── Per-frame draw ─────────────────────────────────────────────────────────── 119function render(ts) { 120 // Upload the current game frame. Reallocate (texImage2D) when the source size 121 // changes, otherwise the cheaper in-place texSubImage2D. 122 const sw = gameCanvas.width; 123 const sh = gameCanvas.height; 124 gl.bindTexture(gl.TEXTURE_2D, texture); 125 if (sw !== texW || sh !== texH) { 126 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, gameCanvas); 127 texW = sw; 128 texH = sh; 129 } else { 130 gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, gameCanvas); 131 } 132 133 gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); 134 gl.useProgram(program); 135 136 // scanlineCount: pinned if PRESET sets one, else auto from the ON-SCREEN height 137 // (~one dark line pair per 2 CSS px) so density is DPI-independent and the lines 138 // stay chunky/visible on Retina rather than dissolving. 139 const scanlineCount = PRESET.scanlineCount > 0 140 ? PRESET.scanlineCount 141 : Math.max(120, Math.min(600, Math.round(displayH / 2))); 142 143 gl.activeTexture(gl.TEXTURE0); 144 gl.uniform1i(uniforms.uTexture, 0); 145 gl.uniform1i(uniforms.uEnabled, 1); 146 gl.uniform1f(uniforms.time, ts * 0.001); 147 gl.uniform1f(uniforms.yOffset, 0.0); 148 gl.uniform1f(uniforms.scanlineCount, scanlineCount); 149 gl.uniform1f(uniforms.scanlineIntensity, PRESET.scanlineIntensity); 150 gl.uniform1f(uniforms.adaptiveIntensity, PRESET.adaptiveIntensity); 151 gl.uniform1f(uniforms.brightness, PRESET.brightness); 152 gl.uniform1f(uniforms.contrast, PRESET.contrast); 153 gl.uniform1f(uniforms.saturation, PRESET.saturation); 154 gl.uniform1f(uniforms.bloomIntensity, PRESET.bloomIntensity); 155 gl.uniform1f(uniforms.bloomThreshold, PRESET.bloomThreshold); 156 gl.uniform1f(uniforms.rgbShift, PRESET.rgbShift); 157 gl.uniform1f(uniforms.vignetteStrength, PRESET.vignetteStrength); 158 gl.uniform1f(uniforms.curvature, PRESET.curvature); 159 gl.uniform1f(uniforms.flickerStrength, PRESET.flickerStrength); 160 161 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); 162 163 rafId = requestAnimationFrame(render); 164} 165 166// ── Enable / disable ───────────────────────────────────────────────────────── 167function enable() { 168 try { 169 ensureInit(); 170 } catch (err) { 171 // Loud failure, but keep the game playable: revert the toggle and bail. 172 console.error('[crt] init failed, leaving CRT off:', err); 173 setToggle(false); 174 return; 175 } 176 stage.classList.add('crt-on'); // CSS hides the raw canvas + reveals #crt-canvas 177 syncGeometry(); 178 startObservers(); 179 if (rafId == null) rafId = requestAnimationFrame(render); 180} 181 182function disable() { 183 if (rafId != null) { 184 cancelAnimationFrame(rafId); 185 rafId = null; 186 } 187 stopObservers(); 188 stage.classList.remove('crt-on'); 189} 190 191function startObservers() { 192 if (!resizeObserver) { 193 resizeObserver = new ResizeObserver(syncGeometry); 194 resizeObserver.observe(gameCanvas); 195 } 196 window.addEventListener('resize', syncGeometry, { passive: true }); 197 window.addEventListener('orientationchange', syncGeometry, { passive: true }); 198} 199 200function stopObservers() { 201 if (resizeObserver) { 202 resizeObserver.disconnect(); 203 resizeObserver = null; 204 } 205 window.removeEventListener('resize', syncGeometry); 206 window.removeEventListener('orientationchange', syncGeometry); 207} 208 209// ── Toggle button ──────────────────────────────────────────────────────────── 210// Grey (off) ↔ white (on) is driven by the `.on` class (see index.html CSS). 211function setToggle(next) { 212 on = next; 213 btn.classList.toggle('on', on); 214 btn.setAttribute('aria-pressed', String(on)); 215} 216 217// Persisted preference. Default ON: only an explicit 'off' disables it, so 218// first-time visitors get the CRT look out of the box. 219function readStored() { 220 try { return localStorage.getItem(STORAGE_KEY) !== 'off'; } catch (_) { return true; } 221} 222function writeStored(value) { 223 try { localStorage.setItem(STORAGE_KEY, value ? 'on' : 'off'); } catch (_) {} 224} 225 226function wireButton() { 227 if (!btn) return; 228 btn.addEventListener('click', () => { 229 setToggle(!on); 230 writeStored(on); 231 if (on) enable(); 232 else disable(); 233 }); 234 // Hook for testing 235 window.npicomxCRT = { 236 enable: () => { setToggle(true); enable(); }, 237 disable: () => { setToggle(false); disable(); }, 238 get on() { return on; }, 239 params: PRESET, 240 }; 241 242 // Apply the saved preference, defaulting to ON for first-time visitors. The CRT 243 // canvas sits behind the play cover until the cart boots, so it's not seen blank. 244 if (readStored()) { setToggle(true); enable(); } 245} 246 247// ── WebGL2 program build (ported from ../webgl-crt-shader/crt-webgl.js) ─────── 248function buildVertexSource() { 249 return `#version 300 es 250 precision highp float; 251 const vec2 pos[4] = vec2[4]( 252 vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(-1.0, 1.0), vec2(1.0, 1.0) 253 ); 254 const vec2 uvData[4] = vec2[4]( 255 vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(1.0, 1.0) 256 ); 257 out vec2 vUv; 258 void main() { 259 vUv = uvData[gl_VertexID]; 260 gl_Position = vec4(pos[gl_VertexID], 0.0, 1.0); 261 } 262 `; 263} 264 265function buildFragmentSource() { 266 // Rewrite the three.js-style GLSL into GLSL ES 3.00: drop its own uniform/varying 267 // decls, swap legacy names/builtins, and rename main→mainImage so we can gate it 268 // behind uEnabled. 269 let body = CRTShader.fragmentShader; 270 body = body.replace(/uniform\s+[^;]+;\s*/g, ''); 271 body = body.replace(/varying\s+[^;]+;\s*/g, ''); 272 body = body.replace(/\btDiffuse\b/g, 'uTexture'); 273 body = body.replace(/texture2D\s*\(/g, 'texture('); 274 body = body.replace(/gl_FragColor/g, 'fragColor'); 275 body = body.replace(/void main\s*\(/, 'void mainImage('); 276 return `#version 300 es 277 precision highp float; 278 uniform sampler2D uTexture; 279 uniform bool uEnabled; 280 uniform float scanlineIntensity; 281 uniform float scanlineCount; 282 uniform float time; 283 uniform float yOffset; 284 uniform float brightness; 285 uniform float contrast; 286 uniform float saturation; 287 uniform float bloomIntensity; 288 uniform float bloomThreshold; 289 uniform float rgbShift; 290 uniform float adaptiveIntensity; 291 uniform float vignetteStrength; 292 uniform float curvature; 293 uniform float flickerStrength; 294 in vec2 vUv; 295 out vec4 fragColor; 296 void bypass() { fragColor = texture(uTexture, vUv); } 297 ${body} 298 void main() { 299 if (!uEnabled) { bypass(); return; } 300 mainImage(); 301 } 302 `; 303} 304 305function createProgram(glContext, vsSource, fsSource) { 306 const vs = compileShader(glContext, glContext.VERTEX_SHADER, vsSource); 307 const fs = compileShader(glContext, glContext.FRAGMENT_SHADER, fsSource); 308 const prog = glContext.createProgram(); 309 glContext.attachShader(prog, vs); 310 glContext.attachShader(prog, fs); 311 glContext.linkProgram(prog); 312 if (!glContext.getProgramParameter(prog, glContext.LINK_STATUS)) { 313 throw new Error(glContext.getProgramInfoLog(prog) || 'Program link error'); 314 } 315 return prog; 316} 317 318function compileShader(glContext, type, source) { 319 const shader = glContext.createShader(type); 320 glContext.shaderSource(shader, source); 321 glContext.compileShader(shader); 322 if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) { 323 throw new Error(glContext.getShaderInfoLog(shader) || 'Shader compile error'); 324 } 325 return shader; 326} 327 328function getUniformLocations(glContext, prog, names) { 329 const map = {}; 330 for (const name of names) map[name] = glContext.getUniformLocation(prog, name); 331 return map; 332} 333 334// ── WebGL2 capability gate (runs last, once all state above is initialised) ── 335// No WebGL2 ⇒ the effect can't run. Hide the toggle 336if (!webgl2Supported()) { 337 if (btn) btn.style.display = 'none'; 338} else { 339 patchGameCanvasForCapture(); 340 wireButton(); 341}