an npmx inspired game for PICO-8
npicomx.vinnymac.dev
games
pico-8
adventure
npmx
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}