Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,13 +460,20 @@ no TypeScript build needed, and plain-JS users get it too through their editor.
src/ tinywebgpu.js — the library — and tinywebgpu.d.ts
dist/ the built artifacts, committed so the demo pages can load them
tools/ build-min.mjs, its two configs, and find-repeats.mjs
docs/ the tutorial, API.md, CHANGELOG.md
docs/ the tutorial, webgpu-check.html, API.md, CHANGELOG.md
examples/ the eight demo pages
test/ the test suite
index.html the demo index — the site's landing page
libselect.js the ?lib=full|min|tiny picker the pages share
diag.js puts an uncaught page error on the screen — see "Browser support" below
```

`libselect.js` resolves the build it imports against its own `import.meta.url`, not against the
page's — that is what a dynamic `import()` specifier is resolved against, and this file sits at
the repo root beside `src/` and `dist/`. So the demo pages work whether the site is served from
the root of an origin or from a subdirectory like `/tinywebgpu/`. `npm test` checks that by
resolving every path on every demo page against a subdirectory mount.

To vendor the library, take `src/tinywebgpu.js` (or a file from `dist/`) and drop it next to your
HTML — there is nothing else to fetch. The site is served straight from the repo root by GitHub
Pages, which is why `index.html` and `.nojekyll` live there.
Expand All @@ -476,6 +483,21 @@ Pages, which is why `index.html` and `.nojekyll` live there.
WebGPU requires a current browser (Chrome/Edge 113+, Firefox 141+ on Windows, Safari 26+) and
a secure context (https or localhost). No WebGL fallback — this is a WebGPU tool.

On **Android**, WebGPU means Chrome 121+ on Android 12 or newer. Samsung Internet, Firefox for
Android, and the in-app browsers that chat and mail apps open links in have no WebGPU at all, so
a demo opened from a message will not run — open it in Chrome. Where Chrome has WebGPU but the
driver is blocklisted, `requestAdapter()` returns null; `chrome://flags/#enable-unsafe-webgpu`
usually gets past that.

Because a phone has no console, the demo pages do not rely on one. Every page loads `diag.js`
first — a plain, non-module script that turns an uncaught error into a banner on the page:
a missing `navigator.gpu`, a null adapter, a module that failed to load or parse. The banner
carries an environment report (user agent, secure context, adapter, limits) and links to
**[docs/webgpu-check.html](https://lampmaker.github.io/tinywebgpu/docs/webgpu-check.html)**,
a library-free page that compiles and draws one triangle and reports exactly where the chain
broke. That page is the right thing to open — and the right report to paste — when a demo shows
nothing on a device.

## License

MIT.
180 changes: 180 additions & 0 deletions diag.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Failure reporting for the demo pages. A classic script, deliberately ES5, deliberately not a
// module: it is loaded from <head> before anything else, so it is already listening when the
// module scripts are parsed and run.
//
// Why it exists: a WebGPU demo that cannot start has nowhere to say so. An uncaught throw in a
// `<script type="module">` — a missing navigator.gpu, a null adapter, a module that failed to
// parse — goes to the console and nothing else, and a phone has no console. The page just sits
// there looking loaded, which reads as "the JavaScript never ran". This turns every one of those
// into a banner on the page, with the environment details needed to tell the cases apart.
//
// The global is TWG_DIAG:
// TWG_DIAG.fail(title, detail) show the banner (first call wins; later ones are ignored)
// TWG_DIAG.report() Promise<string> of the environment report
// TWG_DIAG.shown whether a banner is up

(function () {
'use strict';

var W = window;
if (W.TWG_DIAG) return;

var CHECK = 'webgpu-check.html'; // resolved against this script's own URL below
var checkHref = (function () {
var s = document.currentScript;
try { return new URL('docs/' + CHECK, s ? s.src : location.href).href; } catch (e) { return ''; }
})();

var api = { shown: false };
W.TWG_DIAG = api;

// ---- the environment report ---------------------------------------------------------------
// Everything that decides whether a WebGPU page can run, in the order you would check it by
// hand. The adapter probe is async and best-effort: a browser without navigator.gpu never
// reaches it, and one that hangs on requestAdapter is cut off rather than left pending.
var lines = function (o) {
var out = [], k;
for (k = 0; k < o.length; k++) out.push(o[k][0] + ': ' + o[k][1]);
return out.join('\n');
};

api.report = function () {
var base = [
['page', location.href],
['userAgent', navigator.userAgent],
['secure context', String(W.isSecureContext)],
['navigator.gpu', navigator.gpu ? 'present' : 'MISSING'],
['viewport', W.innerWidth + '×' + W.innerHeight + ' @ dpr ' + (W.devicePixelRatio || 1)],
];
if (!navigator.gpu) return Promise.resolve(lines(base));

var timeout = new Promise(function (res) {
setTimeout(function () { res(null); }, 4000);
});
var probe = Promise.resolve()
.then(function () { return navigator.gpu.requestAdapter(); })
.then(function (a) {
if (!a) { base.push(['adapter', 'NULL — the browser has WebGPU but this device/driver gave no adapter']); return; }
var info = a.info || {};
base.push(['adapter', [info.vendor, info.architecture, info.device, info.description]
.filter(Boolean).join(' / ') || '(no info exposed)']);
base.push(['features', a.features ? a.features.size + ' available' : '?']);
var L = a.limits || {};
base.push(['maxBufferSize', String(L.maxBufferSize)]);
base.push(['maxStorageBufferBindingSize', String(L.maxStorageBufferBindingSize)]);
base.push(['maxComputeInvocationsPerWorkgroup', String(L.maxComputeInvocationsPerWorkgroup)]);
base.push(['maxComputeWorkgroupStorageSize', String(L.maxComputeWorkgroupStorageSize)]);
base.push(['maxStorageBuffersPerShaderStage', String(L.maxStorageBuffersPerShaderStage)]);
})
.catch(function (e) { base.push(['adapter', 'requestAdapter threw: ' + (e && e.message || e)]); });

return Promise.race([probe, timeout]).then(function (r) {
if (r === null) base.push(['adapter', 'requestAdapter did not answer within 4s']);
return lines(base);
});
};

// ---- the banner ---------------------------------------------------------------------------
var CSS =
'#twg-diag{position:fixed;left:0;right:0;top:0;z-index:2147483647;box-sizing:border-box;' +
'max-height:80vh;overflow:auto;padding:.8rem 2.5rem .8rem 1rem;background:#2c2413;color:#f0d79a;' +
'border-bottom:1px solid rgba(240,215,154,.4);' +
'font:13px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;' +
'-webkit-text-size-adjust:100%;text-align:left}' +
'#twg-diag b{color:#ffe9b8}' +
'#twg-diag a{color:#9fc0ff}' +
'#twg-diag pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:.5rem 0 0;' +
'font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#d8c79c}' +
'#twg-diag summary{cursor:pointer;margin-top:.5rem;color:#e8c983}' +
'#twg-diag .x{position:absolute;top:.35rem;right:.5rem;background:none;border:0;' +
'color:#f0d79a;font-size:20px;line-height:1;padding:.2rem .45rem;cursor:pointer}';

var mount = function (node) {
var to = document.body || document.documentElement;
to.appendChild(node);
};

api.fail = function (title, detail) {
if (api.shown) return; // the first failure is the interesting one
api.shown = true;

var run = function () {
var style = document.createElement('style');
style.textContent = CSS;
(document.head || document.documentElement).appendChild(style);

var box = document.createElement('div');
box.id = 'twg-diag';
box.setAttribute('role', 'alert');

var close = document.createElement('button');
close.className = 'x';
close.setAttribute('aria-label', 'dismiss');
close.textContent = '×';
close.onclick = function () { box.parentNode.removeChild(box); };

var head = document.createElement('div');
var b = document.createElement('b');
b.textContent = title;
head.appendChild(b);
if (detail) {
head.appendChild(document.createTextNode(' '));
head.appendChild(document.createTextNode(detail));
}
if (checkHref) {
head.appendChild(document.createTextNode(' '));
var a = document.createElement('a');
a.href = checkHref;
a.textContent = 'Run the WebGPU check →';
head.appendChild(a);
}

var det = document.createElement('details');
var sum = document.createElement('summary');
sum.textContent = 'Environment details';
var pre = document.createElement('pre');
pre.textContent = 'collecting…';
det.appendChild(sum);
det.appendChild(pre);
api.report().then(function (t) { pre.textContent = t; });

box.appendChild(close);
box.appendChild(head);
box.appendChild(det);
mount(box);
};

if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
else run();
};

// ---- the traps ----------------------------------------------------------------------------
// A module that fails to parse, a module that throws while evaluating, and a rejected promise
// nobody caught all end up here. Without these three, each of them is a silent blank page.
var describe = function (e) {
if (!e) return 'Unknown error.';
if (typeof e === 'string') return e;
return (e.name ? e.name + ': ' : '') + (e.message || String(e));
};

W.addEventListener('error', function (ev) {
// Resource errors (a 404 on <img>/<script src>) do not bubble as ErrorEvent with .error, but
// they do arrive here in the capture phase; only the script-level ones matter for the banner.
if (ev.target && ev.target !== W && ev.target.tagName) {
var tag = ev.target.tagName;
if (tag === 'SCRIPT' || tag === 'LINK') {
var url = ev.target.src || ev.target.href;
api.fail('This page could not load one of its scripts.', url
? 'The browser failed to fetch ' + url + '.'
: 'A module script failed to load or parse — check that the library files under ' +
'src/ and dist/ are being served, and as JavaScript.');
}
return;
}
api.fail('This page stopped with an error.', describe(ev.error || ev.message));
}, true);

W.addEventListener('unhandledrejection', function (ev) {
api.fail('This page stopped with an error.', describe(ev.reason));
});
})();
37 changes: 37 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,43 @@ All notable changes to TinyWebGPU. Semver; pre-1.0, minor versions may break API

## Unreleased

**Fixed — the demo pages load their library again when the site is not at the root of its origin**

Every example and the tutorial were broken on <https://lampmaker.github.io/tinywebgpu/>: the page
rendered, the build picker appeared, and nothing else ran. `libselect.js` imported the library
with a specifier built from a `base` argument the page passed in — `'../src/tinywebgpu.js'` from
a page one level down — but a dynamic `import()` resolves its specifier against the *importing
module's* URL, not the document's, and `libselect.js` sits at the repo root. Served from the root
of an origin the leading `..` has nowhere to go and is clamped away, so a local
`python3 -m http.server` resolved it to the right file and never showed the bug. Under GitHub
Pages the site lives at `/tinywebgpu/`, so the same `..` walked out to the domain root and each
page 404'd on `https://lampmaker.github.io/src/tinywebgpu.js`.

`loadLib` now resolves the build against `import.meta.url`, which is correct wherever the site is
mounted and wherever the page sits, and the `base` argument is gone from `loadLib`, `boot` and
every call site. `test/paths.test.mjs` resolves every `src`, `href` and import specifier on the
demo pages the way a browser will — against a mount at `/tinywebgpu/`, which is the case that
fails — and checks each one against the files on disk. It found two more dead links while it was
at it: examples 6 and 7 pointed at `../tutorial.html` rather than `../docs/tutorial.html`.

**Added — a failed start-up says so on the page**

Six of the eight examples called `init()` with no `catch`, so a missing `navigator.gpu`, a null
adapter or the import failure above threw out of the module script into a console — which a phone
does not have, and which the reader of a demo has no reason to open. The page just sat there
looking loaded. `diag.js` is a classic, non-module script every demo page now loads first: it
traps `error` and `unhandledrejection` and puts the failure on screen with an environment report
(user agent, secure context, adapter, limits). Being classic and ahead of the modules, it also
catches a module that fails to load or parse — the case above.

`libselect.js` gained `gpuAdvice()`, which separates "this browser has no WebGPU" from "this
device gave no adapter" and says what to do about each, `reportFailure()`, and `boot()` — picker,
build import and `init()` in one call, with failures reported. Examples 1–5 and 8 use it; 6 and 7
keep their own error strip, filled from the shared reporter. The tutorial reports an adapter
failure once at page level rather than only inside whichever box scrolled into view first.
`docs/webgpu-check.html` is a library-free page that walks `navigator.gpu` → adapter → device →
one compiled and drawn triangle and reports where the chain broke, with a copyable report.

**Added — tutorial step 16, "Your own vertex stage, and a depth buffer"**

The tutorial stopped at compute and fullscreen passes; `makeDraw` lived only in a "where to go
Expand Down
18 changes: 14 additions & 4 deletions docs/tutorial.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
<title>TinyWebGPU — tutorial</title>
<meta name="description" content="A step-by-step tutorial for TinyWebGPU: from one fullscreen
shader to compute, atomics and a particle system. Every code box on the page really runs.">
<!-- Loaded first and on purpose not a module: it turns an uncaught error in the
module scripts below — no WebGPU, no adapter, a file that failed to load — into
a banner on the page, because a phone has no console to print it to. -->
<script src="../diag.js"></script>
<style>
:root {
color-scheme: light dark;
Expand Down Expand Up @@ -187,7 +191,9 @@ <h1>TinyWebGPU, step by step</h1>
<div id="unsupported">
<strong>WebGPU isn’t available in this browser.</strong> The text still reads fine, but no
box will run. You need Chrome/Edge 113+, Firefox 141+ (Windows) or Safari 26+, over https
or localhost.
or localhost. On Android that means Chrome 121+ on Android 12+ — Samsung Internet, Firefox
for Android and the in-app browsers inside chat and mail apps have no WebGPU at all.
<a href="webgpu-check.html">Run the WebGPU check</a> to see what this device reports.
</div>

<div class="note">
Expand Down Expand Up @@ -1436,8 +1442,8 @@ <h3>Where to go next</h3>
// The tutorial itself needs nothing beyond the core, but many boxes read results back,
// resize their canvas or upload textures — features the stock tiny build drops — so under
// tiny those boxes are skipped with a note instead of failing on a missing function.
import { loadLib, explain, missing, TINY_DROPS } from '../libselect.js';
const { WEBGPU, lib } = await loadLib(['read', 'resize', 'texio', 'depth'], '..');
import { loadLib, explain, missing, reportFailure, TINY_DROPS } from '../libselect.js';
const { WEBGPU, lib } = await loadLib(['read', 'resize', 'texio', 'depth']);

// What a box's code can use that the tiny build lacks. Checked against the live editor
// content on every run, so deleting the offending line makes the box runnable again.
Expand Down Expand Up @@ -1476,7 +1482,11 @@ <h3>Where to go next</h3>
const TYPE_NAMES = Object.keys(TYPES);
const DEFINES = Object.entries({ ...TYPES, ...CONSTS }).map(([t, r]) => `${t} ${r}`).join('\n');

const getG = () => (initPromise ||= WEBGPU().init().then(g => (g.defines = DEFINES, g)));
// A failure here is about the device, not about the box that happened to ask first, so it
// is reported once at page level as well as in that box's output panel.
const getG = () => (initPromise ||= WEBGPU().init()
.then(g => (g.defines = DEFINES, g))
.catch(e => { reportFailure(e, lib); throw e; }));

const useCanvas = canvas => {
if (!canvas) return;
Expand Down
Loading
Loading