Skip to content

Commit ad22340

Browse files
committed
fix(webgl): static shaders, Playwright guards, PWA cache hints
- Use static GLSL strings and shared fullscreen quad base; mediump rain vert for uniform precision. - Playwright: matrix-playwright-helpers, fail on [Matrix][WebGL] / invalid program; regression suite (npm run test:regression). - Bump VERSION + service-worker header for cache bust; console shows real SW cache name (scope + VERSION + VER from SW script). - Remove stray marker in config comment; Prettier on touched paths. Made-with: Cursor
1 parent 287d608 commit ad22340

22 files changed

Lines changed: 659 additions & 274 deletions

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0.0
1+
1.0.1

js/config.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
*/
1414

1515
/*
16-
=======
1716
* Random Version Selection Utility
1817
*
1918
* Sometimes the best way to experience the Matrix is to let the system

js/main.js

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,33 @@ function enforceHoloplayRenderer(config) {
3737
*/
3838
let appVersion = "unknown";
3939

40+
/** Matches `const VER` in `service-worker.js` (read at runtime so CI-stamped deploys stay accurate). */
41+
let serviceWorkerVerStamp = "local";
42+
43+
/**
44+
* Same scope key algorithm as `service-worker.js` (BASE_PATH → SCOPE_KEY).
45+
* Uses the registered script URL so subpaths (e.g. GitHub Pages project sites) match the SW.
46+
* @returns {string}
47+
*/
48+
function getPwaCacheScopeKey() {
49+
const swPath = new URL("service-worker.js", location.href).pathname;
50+
const basePath = swPath.replace(/service-worker\.js$/, "");
51+
return basePath.replace(/^\/|\/$/g, "").replace(/\//g, "-") || "root";
52+
}
53+
54+
/**
55+
* Offline cache bucket name the service worker uses after a successful install
56+
* (`CACHE_NAME` in `service-worker.js`). If VERSION could not be read, the SW falls back to `v1`.
57+
* @param {string} versionTrimmed
58+
* @param {string} verStamp
59+
* @returns {string}
60+
*/
61+
function getExpectedPwaCacheName(versionTrimmed, verStamp) {
62+
const prefix = `matrix-sw-${getPwaCacheScopeKey()}-`;
63+
const versionSeg = versionTrimmed && versionTrimmed !== "unknown" ? versionTrimmed : "1";
64+
return `${prefix}v${versionSeg}-${verStamp}`;
65+
}
66+
4067
/**
4168
* Load application version from VERSION file
4269
* @returns {Promise<string>} The version string (e.g., "1.0.0")
@@ -53,15 +80,33 @@ async function loadVersion() {
5380
}
5481
}
5582

83+
/**
84+
* Read `VER` from the deployed service worker script (same source CI rewrites on gh-pages).
85+
* @returns {Promise<void>}
86+
*/
87+
async function loadServiceWorkerVerStamp() {
88+
try {
89+
const url = new URL("service-worker.js", location.href).href;
90+
const response = await fetch(url, { cache: "no-cache" });
91+
const text = await response.text();
92+
const match = text.match(/const\s+VER\s*=\s*"([^"]*)"/);
93+
if (match) {
94+
serviceWorkerVerStamp = match[1];
95+
}
96+
} catch (error) {
97+
console.warn("[Matrix] Could not read service worker VER for cache name hint:", error);
98+
}
99+
}
100+
56101
/**
57102
* Display version information to console
58103
* Includes Matrix-themed messaging and helpful cache information
59104
*/
60105
function displayVersionInfo() {
61-
const cacheName = `matrix-v${appVersion}`;
106+
const cacheName = getExpectedPwaCacheName(appVersion, serviceWorkerVerStamp);
62107
console.log("%c⎡ MATRIX DIGITAL RAIN ⎦", "color: #0F0; font-size: 16px; font-weight: bold; text-shadow: 0 0 10px #0F0");
63108
console.log(`%cVersion: ${appVersion}`, "color: #0F0; font-size: 12px");
64-
console.log(`%cCache: ${cacheName}`, "color: #0F0; font-size: 12px");
109+
console.log(`%cPWA offline cache: ${cacheName}`, "color: #0F0; font-size: 12px");
65110
console.log(`%c"Wake up, Neo... The Matrix has you."`, "color: #0F0; font-style: italic; font-size: 10px");
66111
console.log("");
67112
console.log("%cPWA Cache Debug Commands:", "color: #0F0; font-size: 11px; font-weight: bold");
@@ -216,7 +261,7 @@ document.body.onload = async () => {
216261
* Load and Display Version Information
217262
* Shows version and cache debugging info in console for PWA management
218263
*/
219-
await loadVersion();
264+
await Promise.all([loadVersion(), loadServiceWorkerVerStamp()]);
220265
displayVersionInfo();
221266

222267
/*
@@ -292,7 +337,7 @@ document.body.onload = async () => {
292337
urlParams.set("suppressWarnings", true);
293338
history.replaceState({}, "", "?" + unescape(urlParams.toString()));
294339
currentMatrixRenderer = await solution;
295-
startMatrix(currentMatrixRenderer, canvas, matrixConfig);
340+
await startMatrix(currentMatrixRenderer, canvas, matrixConfig);
296341
canvas.style.display = "unset";
297342
document.body.removeChild(notice);
298343
});
@@ -303,7 +348,7 @@ document.body.onload = async () => {
303348
* Initialize the chosen rendering solution immediately.
304349
*/
305350
currentMatrixRenderer = await solution;
306-
startMatrix(currentMatrixRenderer, canvas, matrixConfig);
351+
await startMatrix(currentMatrixRenderer, canvas, matrixConfig);
307352
}
308353
};
309354

@@ -598,11 +643,11 @@ function setupSpotifyEventListeners() {
598643
/**
599644
* Start the Matrix renderer
600645
*/
601-
function startMatrix(matrixRenderer, canvas, config) {
646+
async function startMatrix(matrixRenderer, canvas, config) {
602647
// Start the Matrix renderer
603648
// Note: setupFullscreenToggle is called within the renderer implementations
604649
// (webgl/main.js and webgpu/main.js) to avoid duplicate event listeners
605-
matrixRenderer.default(canvas, config);
650+
await matrixRenderer.default(canvas, config);
606651
}
607652

608653
/**
@@ -632,7 +677,7 @@ async function initializeGalleryMode() {
632677
const useWebGPU = (await supportsWebGPU()) && newConfig.renderer?.toLowerCase() === "webgpu";
633678
const solution = await import(`./${useWebGPU ? "webgpu" : "webgl"}/main.js`);
634679
currentMatrixRenderer = solution;
635-
startMatrix(currentMatrixRenderer, canvas, newConfig);
680+
await startMatrix(currentMatrixRenderer, canvas, newConfig);
636681
} else {
637682
await restartMatrixWithNewConfig(newConfig);
638683
}

js/webgl/bloomPass.js

Lines changed: 40 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { loadText, makePassFBO, makePass } from "./utils.js";
1+
import { fullscreenQuadReglBase, loadText, makePassFBO, makePass, requireShaderString } from "./utils.js";
22

33
// The bloom pass is basically an added high-pass blur.
44
// The blur approximation is the sum of a pyramid of downscaled, blurred textures.
@@ -35,49 +35,50 @@ export default ({ regl, config }, inputs) => {
3535

3636
// The high pass restricts the blur to bright things in our input texture.
3737
const highPassFrag = loadText("shaders/glsl/bloomPass.highPass.frag.glsl");
38-
const highPass = regl({
39-
frag: regl.prop("frag"),
40-
uniforms: {
41-
highPassThreshold,
42-
tex: regl.prop("tex"),
43-
},
44-
framebuffer: regl.prop("fbo"),
45-
});
46-
47-
// A 2D gaussian blur is just a 1D blur done horizontally, then done vertically.
48-
// The FBO pyramid's levels represent separate levels of detail;
49-
// by blurring them all, this basic blur approximates a more complex gaussian:
50-
// https://web.archive.org/web/20191124072602/https://software.intel.com/en-us/articles/compute-shader-hdr-and-bloom
51-
5238
const blurFrag = loadText("shaders/glsl/bloomPass.blur.frag.glsl");
53-
const blur = regl({
54-
frag: regl.prop("frag"),
55-
uniforms: {
56-
tex: regl.prop("tex"),
57-
direction: regl.prop("direction"),
58-
height: regl.context("viewportWidth"),
59-
width: regl.context("viewportHeight"),
60-
},
61-
framebuffer: regl.prop("fbo"),
62-
});
63-
64-
// The pyramid of textures gets flattened (summed) into a final blurry "bloom" texture
6539
const combineFrag = loadText("shaders/glsl/bloomPass.combine.frag.glsl");
66-
const combine = regl({
67-
frag: regl.prop("frag"),
68-
uniforms: {
69-
bloomStrength,
70-
...Object.fromEntries(vBlurPyramid.map((fbo, index) => [`pyr_${index}`, fbo])),
71-
},
72-
framebuffer: output,
40+
41+
let highPass;
42+
let blur;
43+
let combine;
44+
const programsReady = Promise.all([highPassFrag.loaded, blurFrag.loaded, combineFrag.loaded]).then(() => {
45+
highPass = regl({
46+
...fullscreenQuadReglBase,
47+
frag: requireShaderString("bloomPass.highPass.frag", () => highPassFrag.text()),
48+
uniforms: {
49+
highPassThreshold,
50+
tex: regl.prop("tex"),
51+
},
52+
framebuffer: regl.prop("fbo"),
53+
});
54+
blur = regl({
55+
...fullscreenQuadReglBase,
56+
frag: requireShaderString("bloomPass.blur.frag", () => blurFrag.text()),
57+
uniforms: {
58+
tex: regl.prop("tex"),
59+
direction: regl.prop("direction"),
60+
height: regl.context("viewportWidth"),
61+
width: regl.context("viewportHeight"),
62+
},
63+
framebuffer: regl.prop("fbo"),
64+
});
65+
combine = regl({
66+
...fullscreenQuadReglBase,
67+
frag: requireShaderString("bloomPass.combine.frag", () => combineFrag.text()),
68+
uniforms: {
69+
bloomStrength,
70+
...Object.fromEntries(vBlurPyramid.map((fbo, index) => [`pyr_${index}`, fbo])),
71+
},
72+
framebuffer: output,
73+
});
7374
});
7475

7576
return makePass(
7677
{
7778
primary: inputs.primary,
7879
bloom: output,
7980
},
80-
Promise.all([highPassFrag.loaded, blurFrag.loaded]),
81+
programsReady,
8182
(w, h) => {
8283
// The blur pyramids can be lower resolution than the screen.
8384
resizePyramid(highPassPyramid, w, h, bloomSize);
@@ -94,12 +95,12 @@ export default ({ regl, config }, inputs) => {
9495
const highPassFBO = highPassPyramid[i];
9596
const hBlurFBO = hBlurPyramid[i];
9697
const vBlurFBO = vBlurPyramid[i];
97-
highPass({ fbo: highPassFBO, frag: highPassFrag.text(), tex: i === 0 ? inputs.primary : highPassPyramid[i - 1] });
98-
blur({ fbo: hBlurFBO, frag: blurFrag.text(), tex: highPassFBO, direction: [1, 0] });
99-
blur({ fbo: vBlurFBO, frag: blurFrag.text(), tex: hBlurFBO, direction: [0, 1] });
98+
highPass({ fbo: highPassFBO, tex: i === 0 ? inputs.primary : highPassPyramid[i - 1] });
99+
blur({ fbo: hBlurFBO, tex: highPassFBO, direction: [1, 0] });
100+
blur({ fbo: vBlurFBO, tex: hBlurFBO, direction: [0, 1] });
100101
}
101102

102-
combine({ frag: combineFrag.text() });
103+
combine({});
103104
},
104105
);
105106
};

js/webgl/imagePass.js

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { loadImage, loadText, makePassFBO, makePass } from "./utils.js";
1+
import { fullscreenQuadReglBase, loadImage, loadText, makePassFBO, makePass, requireShaderString } from "./utils.js";
22

33
// Multiplies the rendered rain and bloom by a loaded in image
44

@@ -9,24 +9,28 @@ export default ({ regl, config }, inputs) => {
99
const bgURL = "bgURL" in config ? config.bgURL : defaultBGURL;
1010
const background = loadImage(regl, bgURL);
1111
const imagePassFrag = loadText("shaders/glsl/imagePass.frag.glsl");
12-
const render = regl({
13-
frag: regl.prop("frag"),
14-
uniforms: {
15-
backgroundTex: background.texture,
16-
tex: inputs.primary,
17-
bloomTex: inputs.bloom,
18-
},
19-
framebuffer: output,
12+
let render;
13+
const programsReady = Promise.all([background.loaded, imagePassFrag.loaded]).then(() => {
14+
render = regl({
15+
...fullscreenQuadReglBase,
16+
frag: requireShaderString("imagePass.frag", () => imagePassFrag.text()),
17+
uniforms: {
18+
backgroundTex: background.texture,
19+
tex: inputs.primary,
20+
bloomTex: inputs.bloom,
21+
},
22+
framebuffer: output,
23+
});
2024
});
2125
return makePass(
2226
{
2327
primary: output,
2428
},
25-
Promise.all([background.loaded, imagePassFrag.loaded]),
29+
programsReady,
2630
(w, h) => output.resize(w, h),
2731
(shouldRender) => {
2832
if (shouldRender) {
29-
render({ frag: imagePassFrag.text() });
33+
render({});
3034
}
3135
},
3236
);

js/webgl/main.js

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,35 @@ import { createEffectsMapping, getEffectPass } from "../effects.js";
1414

1515
const dimensions = { width: 1, height: 1 };
1616

17+
/**
18+
* Surface the first shader compile / program link failure (otherwise the browser only shows
19+
* INVALID_OPERATION: useProgram: program not valid and then suppresses further errors).
20+
*/
21+
function installWebGLShaderDebugHooks() {
22+
if (typeof WebGLRenderingContext === "undefined") {
23+
return;
24+
}
25+
const proto = WebGLRenderingContext.prototype;
26+
if (proto.__matrixShaderDebugHooked) {
27+
return;
28+
}
29+
proto.__matrixShaderDebugHooked = true;
30+
const compileShader = proto.compileShader;
31+
proto.compileShader = function (shader) {
32+
compileShader.call(this, shader);
33+
if (!this.getShaderParameter(shader, this.COMPILE_STATUS)) {
34+
console.error("[Matrix][WebGL] shader compile failed:\n", this.getShaderInfoLog(shader));
35+
}
36+
};
37+
const linkProgram = proto.linkProgram;
38+
proto.linkProgram = function (program) {
39+
linkProgram.call(this, program);
40+
if (!this.getProgramParameter(program, this.LINK_STATUS)) {
41+
console.error("[Matrix][WebGL] program link failed:\n", this.getProgramInfoLog(program));
42+
}
43+
};
44+
}
45+
1746
const loadJS = (src) =>
1847
new Promise((resolve, reject) => {
1948
const tag = document.createElement("script");
@@ -25,6 +54,7 @@ const loadJS = (src) =>
2554

2655
export default async (canvas, config) => {
2756
await Promise.all([loadJS("lib/regl.min.js"), loadJS("lib/gl-matrix.js")]);
57+
installWebGLShaderDebugHooks();
2858

2959
const resize = () => {
3060
const devicePixelRatio = window.devicePixelRatio ?? 1;
@@ -42,9 +72,16 @@ export default async (canvas, config) => {
4272
await setupCamera();
4373
}
4474

45-
const extensions = ["OES_texture_half_float", "OES_texture_half_float_linear"];
46-
// These extensions are also needed, but Safari misreports that they are missing
47-
const optionalExtensions = ["EXT_color_buffer_half_float", "WEBGL_color_buffer_float", "OES_standard_derivatives"];
75+
const extensions = [
76+
"OES_texture_half_float",
77+
"OES_texture_half_float_linear",
78+
"OES_standard_derivatives",
79+
// Required to render into half-float FBOs (compute passes + rain); without it, framebuffer attachments can be incomplete.
80+
"EXT_color_buffer_half_float",
81+
];
82+
// rainPass.frag.glsl uses fwidth() for MSDF anti-aliasing (OES_standard_derivatives).
83+
// Some older stacks only expose float renderbuffers under a different name — regl may still enable it.
84+
const optionalExtensions = ["WEBGL_color_buffer_float"];
4885

4986
switch (config.testFix) {
5087
case "fwidth_10_1_2022_A":
@@ -56,14 +93,16 @@ export default async (canvas, config) => {
5693
break;
5794
}
5895

59-
const regl = createREGL({ canvas, pixelRatio: 1, extensions, optionalExtensions });
96+
const regl = createREGL({
97+
canvas,
98+
pixelRatio: 1,
99+
extensions,
100+
optionalExtensions,
101+
});
60102

61103
const cameraTex = regl.texture(cameraCanvas);
62104
const lkg = await getLKG(config.useHoloplay, true);
63105

64-
// All this takes place in a full screen quad.
65-
const fullScreenQuad = makeFullScreenQuad(regl);
66-
67106
// Create dynamic effects mapping
68107
const passModules = {
69108
makePalettePass,
@@ -76,7 +115,9 @@ export default async (canvas, config) => {
76115
const context = { regl, canvas, config, lkg, cameraTex, cameraAspectRatio };
77116
const pipeline = makePipeline(context, [makeRain, makeBloomPass, effectPass, makeQuiltPass]);
78117
const screenUniforms = { tex: pipeline[pipeline.length - 1].outputs.primary };
79-
const drawToScreen = regl({ uniforms: screenUniforms });
118+
// Blit the final texture to the canvas. A nested `regl({ uniforms })` inside another draw can
119+
// inherit the last pass's framebuffer binding, so the screen never gets the composed image.
120+
const blitToCanvas = makeFullScreenQuad(regl, screenUniforms);
80121
await Promise.all(pipeline.map((step) => step.ready));
81122

82123
const targetFrameTimeMilliseconds = 1000 / config.fps;
@@ -111,11 +152,9 @@ export default async (canvas, config) => {
111152
step.setSize(viewportWidth, viewportHeight);
112153
}
113154
}
114-
fullScreenQuad(() => {
115-
for (const step of pipeline) {
116-
step.execute(shouldRender);
117-
}
118-
drawToScreen();
119-
});
155+
for (const step of pipeline) {
156+
step.execute(shouldRender);
157+
}
158+
blitToCanvas();
120159
});
121160
};

0 commit comments

Comments
 (0)