check-examples.mjs

scripts/check-examples.mjs · raw · all examples · site home

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
// Loads every ported Spark example (examples/spark/js/examples.ts)
// and the other example pages (examples/{splats,scenes,xr,...}),
// as built into build/examples/, in headless Chrome on both backends
// and checks that each one draws: the page says it is ready (Spark ports:
// window.example.ready or .error, js/page.ts; the others: their console
// handle once the splats are loaded), throws nothing, and its canvas is not
// one flat color. Pages that need WebGPU run on it alone. Every page reads
// its data from GCS (scripts/data.mjs). Needs the library and pages
// built and the repo served:
//
//   npm run build && npm run serve
//   node scripts/check-examples.mjs [--base http://localhost:8000]
//     [--site]        (the static site, its pages in examples/ on
//                     http://localhost:8002: npm run build:site && npm run serve:site)
//     [--gpu webgpu | webgpu-gl] [--only hello-world,glsl,splats] [--timeout 180]
//     [--shots dir]   (a PNG of each page's canvas)
//     [--single]      (the other pages' single-file builds, <name>/<name>.html:
//                     npm run build:single; no Spark ports)
//     [--lcc url]     (the LCC pages' samples from elsewhere; LCC2 ones
//                     from its lcc2/ sibling)
//     [--spz url]     (the SPZ page's samples from elsewhere)
/* global document, window, Image, requestAnimationFrame -- functions run in the page */
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { chromium } from "playwright-core";
import { EXAMPLES, pagesOf } from "../build/examples/spark/js/examples.js";

const args = process.argv.slice(2);
const option = (name, fallback) => {
  const index = args.indexOf(name);
  return index >= 0 ? args[index + 1] : fallback;
};
const REPO = "http://localhost:8000";
const site = args.includes("--site");
const base = option("--base", site ? "http://localhost:8002" : REPO);
const examples = site ? "examples" : "build/examples";
const backends = option("--gpu") ? [option("--gpu")] : ["webgpu", "webgpu-gl"];
const only = option("--only")?.split(",");
const timeout = Number(option("--timeout", 180)) * 1000;
const shots = option("--shots");
if (shots) {
  mkdirSync(shots, { recursive: true });
}

const lccRoot = option("--lcc");
const spzRoot = option("--spz");

// The LCC walk page, once its scene shows: the walker stands on the ground
// with the eye at its height, and driven 2 m/s toward the nearest wall
// around it stops in front of it (its knee and chest spheres never sink
// into the mesh)
async function checkWalk() {
  const v = window.radixLcc;
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  for (let i = 0; i < 300 && v.walker.state !== "grounded"; i += 1) {
    await wait(100);
  }
  const w = v.walker;
  const c = v.collision();
  if (w.state !== "grounded" || !c) {
    return { ok: false, reason: `walker ${w.state}, collision ${c ? "loaded" : "none"}` };
  }
  const Vector3 = v.camera.position.constructor;
  const scale = v.model.scale.x;
  const toFile = v.model.matrixWorld.clone().invert();
  const eye = v.camera.position.clone().applyMatrix4(toFile);
  const ground = c.set.raycast(eye, w.up.clone().negate(), 100 / scale);
  const height = ground ? ground.t * scale : Infinity;
  if (Math.abs(height - w.settings.eyeHeight) > 0.05) {
    return { ok: false, reason: `eye ${height.toFixed(3)} above the ground, not ${w.settings.eyeHeight}` };
  }
  const radius = w.settings.radius / scale;
  const knee = (w.settings.stepHeight + w.settings.radius) / scale;
  let wall;
  for (let k = 0; k < 16; k += 1) {
    const a = (k / 16) * Math.PI * 2;
    const side = Math.abs(w.up.z) > 0.9 ? new Vector3(1, 0, 0) : new Vector3(0, 0, 1);
    const s = side.clone().cross(w.up).normalize();
    const t = w.up.clone().cross(s).normalize();
    const dir = s.multiplyScalar(Math.cos(a)).add(t.multiplyScalar(Math.sin(a)));
    const hit = c.set.raycast(w.feet.clone().addScaledVector(w.up, knee), dir, 6 / scale);
    if (hit && hit.t > radius * 2 && (!wall || hit.t < wall.t)) {
      wall = { dir, t: hit.t };
    }
  }
  if (!wall) {
    return { ok: true, reason: `eye ${height.toFixed(3)} above the ground (no wall within 6 m)` };
  }
  const start = w.feet.clone();
  let closest = Infinity;
  w.driveFor(wall.dir.clone().transformDirection(v.model.matrixWorld).multiplyScalar(2), (wall.t * scale) / 2 + 1);
  const end = performance.now() + ((wall.t * scale) / 2 + 1.3) * 1000;
  while (performance.now() < end) {
    await new Promise((resolve) => requestAnimationFrame(resolve));
    for (const h of [knee, w.settings.eyeHeight / scale - radius]) {
      const center = w.feet.clone().addScaledVector(w.up, h);
      c.set.sphere(center, radius * 2, (p) => (closest = Math.min(closest, Math.hypot(p.x - center.x, p.y - center.y, p.z - center.z))));
    }
  }
  const moved = w.feet.clone().sub(start).dot(wall.dir);
  const ok = closest >= radius * 0.95;
  return { ok, reason: `eye ${height.toFixed(3)} m up; toward a wall ${(wall.t * scale).toFixed(2)} m away, moved ${(moved * scale).toFixed(2)} m, closest ${(closest * scale).toFixed(3)} m (radius ${w.settings.radius})` };
}

const SPARK_READY = "window.example?.ready || window.example?.error";
const sparkPages = EXAMPLES
  .filter((example) => (example.status === "ported" || example.status === "partial") && (!only || only.includes(example.name)))
  .flatMap((example) => pagesOf(example).map((page) => ({ path: `spark/${page}`, query: example.smoke?.replace(/^\?/, "") ?? "", ready: SPARK_READY, wait: example.smokeWait })));
const radixPages = [
  { name: "splats", path: "splats/index.html", query: "", ready: "window.radixSplats?.arrays()" },
  ...["seashell-v4", "sparkling-v4", "backpack-v2", "scarf-v3"].map((sample) => ({
    name: "spz", path: "spz/index.html", query: `sample=${sample}${spzRoot ? `&spz=${spzRoot}` : ""}`, ready: "window.radixSpz?.arrays()",
  })),
  ...["multi", "views", "modifiers", "subset", "offline"].map((scene) => ({ name: "scenes", path: "scenes/index.html", query: `scene=${scene}`, ready: "window.radixScenes" })),
  { name: "xr", path: "xr/index.html", query: "", ready: "window.radixXr?.build" },
  { name: "mpm", path: "mpm/index.html", query: "", ready: "window.radixMpm?.ready", backends: ["webgpu"] },
  ...["golden-hour-bulb", "ember-flame", "halvorsen-bloom", "sakura"].map((preset) => ({
    name: "fractals", path: "fractals/index.html", query: `preset=${preset}&splats=500000`, ready: "window.radixFractals?.ready",
  })),
  { name: "fxedit", path: "fxedit/index.html", query: "", ready: "window.radixFxedit?.ready" },
  { name: "doom", path: "doom/index.html", query: "warp=1,1&nosound", ready: "window.radixDoom?.ready || window.radixDoom?.error" },
  { name: "quake", path: "quake/index.html", query: "map=start&nosound", ready: "window.radixQuake?.ready || window.radixQuake?.error" },
  // LCC scenes (hosted samples): a Portable one with 3 cells and a Quality one
  // (SH); XGRIDS' LCC2 street (.sog), a converted LCC2 scene (.spz, SH); and
  // walking on the temple's collision mesh
  ...["unreal", "sutro", "street", "sutro-spz"].map((scene) => ({
    name: "lcc", path: "lcc/index.html", query: `scene=${scene}${lccRoot ? `&lcc=${lccRoot}` : ""}`, ready: "window.radixLcc?.ready || window.radixLcc?.error",
  })),
  {
    name: "lcc", path: "lcc/index.html", query: `scene=unreal&camera=walk${lccRoot ? `&lcc=${lccRoot}` : ""}`, ready: "window.radixLcc?.ready || window.radixLcc?.error",
    check: checkWalk,
  },
].filter((page) => !only || only.includes(page.name));
const pages = args.includes("--single")
  ? radixPages.map((page) => ({ ...page, path: page.path.replace(/^([^/]+)\/index\.html$/, "$1/$1.html") }))
  : [...sparkPages, ...radixPages];

const browser = await chromium.launch({ channel: "chrome", headless: true, args: ["--enable-unsafe-webgpu", "--autoplay-policy=no-user-gesture-required"] });
const results = [];
try {
  for (const gpu of backends) {
    for (const { path, query: search, ready, wait, check } of pages.filter((page) => (!page.backends || page.backends.includes(gpu)))) {
      const query = new URLSearchParams(search);
      query.set("gpu", gpu);
      const url = `${base}/${examples}/${path}?${query}`;
      const label = `${path}${search ? `?${search}` : ""}`;
      const page = await browser.newPage({ viewport: { width: 960, height: 600 } });
      const errors = [];
      page.on("pageerror", (error) => errors.push(`page error: ${error.message}`));
      page.on("console", (message) => message.type() === "error" && errors.push(`console: ${message.text().slice(0, 300)}`));
      const start = Date.now();
      let outcome;
      try {
        await page.goto(url);
        await page.waitForFunction(ready, null, { timeout });
        const error = await page.evaluate(() => window.example?.error);
        if (error) {
          outcome = { ok: false, reason: error.split("\n")[0] };
        } else {
          // Let a few more frames land (CPU sorts on WebGL2; reveals that
          // start blank), then look at the canvas alone
          await page.waitForTimeout(wait ?? 1500);
          await page.evaluate(() => document.querySelectorAll("body > :not(canvas)").forEach((element) => (element.style.visibility = "hidden")));
          const png = await page.locator("canvas").first().screenshot();
          if (shots) {
            writeFileSync(join(shots, `${label.replace(/^spark\//, "").replace(/\/$|\/\?/g, "").replace(/[/.?=]/g, "_")}-${gpu}.png`), png);
          }
          const spread = await page.evaluate(async (b64) => {
            const image = new Image();
            image.src = `data:image/png;base64,${b64}`;
            await image.decode();
            const canvas = Object.assign(document.createElement("canvas"), { width: image.width, height: image.height });
            const context = canvas.getContext("2d");
            context.drawImage(image, 0, 0);
            const { data } = context.getImageData(0, 0, image.width, image.height);
            let sum = 0;
            let sumSquares = 0;
            const n = data.length / 4;
            for (let i = 0; i < data.length; i += 4) {
              const y = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
              sum += y;
              sumSquares += y * y;
            }
            return Math.sqrt(Math.max(0, sumSquares / n - (sum / n) ** 2));
          }, png.toString("base64"));
          outcome = spread > 2 ? { ok: true, reason: `luma spread ${spread.toFixed(1)}` } : { ok: false, reason: `blank canvas (luma spread ${spread.toFixed(2)})` };
          if (outcome.ok && check) {
            const checked = await page.evaluate(check);
            outcome = { ok: checked.ok, reason: `${outcome.reason}; ${checked.reason}` };
          }
        }
      } catch (error) {
        outcome = { ok: false, reason: error.message.split("\n")[0] };
      }
      const seconds = ((Date.now() - start) / 1000).toFixed(1);
      results.push({ gpu, path: label, ...outcome, errors });
      console.log(`${outcome.ok ? "ok  " : "FAIL"} ${gpu.padEnd(9)} ${label.padEnd(38)} ${seconds}s  ${outcome.reason}`);
      errors.slice(0, 5).forEach((line) => console.log(`       ${line}`));
      await page.close();
    }
  }
} finally {
  await browser.close();
}

const failed = results.filter((result) => !result.ok);
console.log(`\n${results.length - failed.length}/${results.length} pages drew`);
process.exit(failed.length > 0 ? 1 : 0);