@driftengine/core 3.61.1 → 3.61.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ /**
2
+ * A Chrome DevTools Protocol client, small enough to read in one sitting.
3
+ *
4
+ * **Why this is in the engine rather than in whatever needed it this week.** A renderer cannot be
5
+ * judged from a unit test. Every claim this repository makes about a picture rests on
6
+ * photographing that picture, and every consumer of a WebGL engine has the same problem the
7
+ * moment it wants to know whether a change moved a pixel. This had been written from scratch
8
+ * three times across two repositories, with a different set of traps found the hard way in each,
9
+ * so it is now one thing that everybody gets.
10
+ *
11
+ * **No dependency, deliberately.** Node has had a global `WebSocket` since 21, which is the only
12
+ * thing a CDP client actually needs, and the engine's rule about dependencies applies to its
13
+ * tooling as much as to its runtime. Nothing here is imported by anything under `src/`.
14
+ *
15
+ * Pair it with `browser.mjs`, which owns launching a browser that is actually using the GPU.
16
+ *
17
+ * import { launch } from '@driftengine/core/scripts/browser.mjs';
18
+ * import { connect } from '@driftengine/core/scripts/cdp.mjs';
19
+ *
20
+ * const browser = await launch();
21
+ * const client = await connect(browser.port);
22
+ * const page = await client.page('http://localhost:5173/?scene=0&hold=420', 1280, 720);
23
+ * await page.settled();
24
+ * await page.screenshot('before.png');
25
+ * await page.close();
26
+ * client.close();
27
+ * await browser.close();
28
+ */
29
+ import { writeFile } from 'node:fs/promises';
30
+
31
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
32
+
33
+ /**
34
+ * A phone, for `page()`'s `device` option.
35
+ *
36
+ * **A narrow `--width` is not a phone, and the difference is not cosmetic.** Real pages branch on
37
+ * being touch-driven in more places than they branch on being narrow, and the two are separate
38
+ * questions: `(pointer: coarse)` is what decides render scale, control sizes, whether a hover
39
+ * affordance exists at all, and — in at least one consumer — which of two entrances to a scene is
40
+ * built. A 390 px window driven by a mouse satisfies every `max-width` rule and none of those, so
41
+ * it photographs a layout no phone will ever draw and reports it as the mobile view. This preset
42
+ * exists so that trap is opted out of by name rather than rediscovered per consumer.
43
+ *
44
+ * Every field is overridable — `{ ...MOBILE_DEVICE, deviceScaleFactor: 3 }` — and the shape is
45
+ * flat rather than a device catalogue on purpose: a catalogue is a maintenance surface, and the
46
+ * three things that actually change a picture are the scale factor, whether touch is on, and
47
+ * whether the user-agent says mobile.
48
+ */
49
+ export const MOBILE_DEVICE = Object.freeze({
50
+ mobile: true,
51
+ /* 2 rather than a real phone's 3: it doubles the screenshot's pixels for the same layout, and
52
+ the extra row of detail at 3 has never been what a capture was read for. Override for a
53
+ genuine text-rendering question. */
54
+ deviceScaleFactor: 2,
55
+ touch: true,
56
+ maxTouchPoints: 5,
57
+ userAgent:
58
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 ' +
59
+ '(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
60
+ platform: 'iOS',
61
+ platformVersion: '17.5',
62
+ model: 'iPhone',
63
+ });
64
+
65
+ /** The desktop default, and the behaviour every existing caller already gets. */
66
+ const DESKTOP_DEVICE = Object.freeze({
67
+ mobile: false,
68
+ deviceScaleFactor: 1,
69
+ touch: false,
70
+ maxTouchPoints: 0,
71
+ userAgent: null,
72
+ platform: '',
73
+ platformVersion: '',
74
+ model: '',
75
+ });
76
+
77
+ /**
78
+ * Open a session against a browser already listening on `port`.
79
+ *
80
+ * Returns `{ send, on, page, close }`. `send` is the raw protocol, for anything this file does
81
+ * not wrap; `page` is what callers normally want.
82
+ */
83
+ export async function connect(port, { timeoutMs = 10_000 } = {}) {
84
+ const info = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
85
+ const socket = new WebSocket(info.webSocketDebuggerUrl);
86
+ await new Promise((resolve, reject) => {
87
+ socket.addEventListener('open', resolve, { once: true });
88
+ socket.addEventListener('error', () => reject(new Error(`no CDP socket on port ${port}`)), {
89
+ once: true,
90
+ });
91
+ });
92
+
93
+ let nextId = 1;
94
+ const pending = new Map();
95
+ const listeners = [];
96
+ socket.addEventListener('message', (event) => {
97
+ const message = JSON.parse(event.data);
98
+ if (message.id !== undefined) {
99
+ const entry = pending.get(message.id);
100
+ if (entry === undefined) return;
101
+ pending.delete(message.id);
102
+ if (message.error)
103
+ entry.reject(new Error(`${message.method}: ${JSON.stringify(message.error)}`));
104
+ else entry.resolve(message.result);
105
+ return;
106
+ }
107
+ for (const listener of listeners) listener(message);
108
+ });
109
+
110
+ const send = (method, params = {}, sessionId) =>
111
+ new Promise((resolve, reject) => {
112
+ const id = nextId++;
113
+ const timer = setTimeout(() => {
114
+ pending.delete(id);
115
+ reject(new Error(`${method} did not answer within ${timeoutMs} ms`));
116
+ }, timeoutMs);
117
+ pending.set(id, {
118
+ resolve: (value) => {
119
+ clearTimeout(timer);
120
+ resolve(value);
121
+ },
122
+ reject: (error) => {
123
+ clearTimeout(timer);
124
+ reject(error);
125
+ },
126
+ });
127
+ socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
128
+ });
129
+
130
+ const on = (listener) => listeners.push(listener);
131
+
132
+ /**
133
+ * A tab at an exact size, with its console captured from before the page loads.
134
+ *
135
+ * **The size is set twice and both are load-bearing.** `Target.createTarget` only honours
136
+ * `width` and `height` when `newWindow` is true, and a target left at its default can come up
137
+ * zero by zero — which is not an error anywhere, it is a run of
138
+ * `GL_INVALID_FRAMEBUFFER_OPERATION` and a black screenshot, so it reads as a renderer drawing
139
+ * nothing. `Emulation.setDeviceMetricsOverride` then fixes the *viewport* the page lays out
140
+ * against, which is a separate question from the window's own bounds.
141
+ *
142
+ * The related trap is on the page's side rather than here: a canvas sized from its layout
143
+ * collapses to zero if a harness hides the surrounding interface with `display: none`. Use
144
+ * `visibility: hidden`. Same black frame, different direction.
145
+ *
146
+ * `device` takes `MOBILE_DEVICE` (or any partial override of it) to photograph the page as a
147
+ * phone rather than as a narrow desktop window — see that constant for why those are not the
148
+ * same capture. Every override below goes on **before the first navigation**, which is the
149
+ * whole reason they are here rather than something a caller can do afterwards with the `call`
150
+ * it is handed back: `(pointer: coarse)` is read by framework state initialisers during the
151
+ * first render, so touch emulation switched on after load is a phone the page has already
152
+ * decided was a desktop.
153
+ */
154
+ const page = async (url, width = 1280, height = 720, { beforeLoad, device } = {}) => {
155
+ const { targetId } = await send('Target.createTarget', {
156
+ url: 'about:blank',
157
+ width,
158
+ height,
159
+ newWindow: true,
160
+ });
161
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
162
+ const call = (method, params) => send(method, params, sessionId);
163
+ await call('Page.enable');
164
+ await call('Runtime.enable');
165
+ await call('Log.enable');
166
+
167
+ const logs = [];
168
+ on((message) => {
169
+ if (message.sessionId !== sessionId) return;
170
+ if (message.method === 'Log.entryAdded') {
171
+ logs.push(`${message.params.entry.level}: ${message.params.entry.text}`);
172
+ }
173
+ if (message.method === 'Runtime.consoleAPICalled') {
174
+ const text = message.params.args
175
+ .map((arg) => arg.value ?? arg.description ?? arg.type)
176
+ .join(' ');
177
+ logs.push(`${message.params.type}: ${text}`);
178
+ }
179
+ if (message.method === 'Runtime.exceptionThrown') {
180
+ const details = message.params.exceptionDetails;
181
+ logs.push(`exception: ${details.text} ${details.exception?.description ?? ''}`);
182
+ }
183
+ });
184
+
185
+ const metrics = { ...DESKTOP_DEVICE, ...(device ?? null) };
186
+
187
+ await call('Emulation.setDeviceMetricsOverride', {
188
+ width,
189
+ height,
190
+ deviceScaleFactor: metrics.deviceScaleFactor,
191
+ mobile: metrics.mobile,
192
+ /* The viewport is the layout question; `screen.width`/`screen.height` are a separate one
193
+ that pages read for their own reasons, and leaving them at the host monitor's size while
194
+ claiming to be a phone is an inconsistency worth not shipping into a capture. */
195
+ screenWidth: width,
196
+ screenHeight: height,
197
+ screenOrientation: metrics.mobile
198
+ ? {
199
+ type: width > height ? 'landscapePrimary' : 'portraitPrimary',
200
+ angle: width > height ? 90 : 0,
201
+ }
202
+ : undefined,
203
+ });
204
+
205
+ /*
206
+ * Touch and the user agent, both only when asked for. `setTouchEmulationEnabled` is what
207
+ * moves `(pointer: coarse)` and `(hover: none)`; the user-agent override is what moves
208
+ * `navigator.userAgent` and the client hints beside it. Neither implies the other, and a
209
+ * page can read either, so `device` sets both together rather than leaving a capture that
210
+ * feels like a phone to CSS and looks like a desktop to script.
211
+ */
212
+ if (metrics.touch) {
213
+ await call('Emulation.setTouchEmulationEnabled', {
214
+ enabled: true,
215
+ maxTouchPoints: metrics.maxTouchPoints,
216
+ });
217
+ }
218
+ if (metrics.userAgent !== null) {
219
+ await call('Emulation.setUserAgentOverride', {
220
+ userAgent: metrics.userAgent,
221
+ userAgentMetadata: {
222
+ brands: [],
223
+ fullVersion: '',
224
+ platform: metrics.platform,
225
+ platformVersion: metrics.platformVersion,
226
+ architecture: '',
227
+ model: metrics.model,
228
+ mobile: metrics.mobile,
229
+ },
230
+ });
231
+ }
232
+
233
+ /*
234
+ * Anything that has to run before the page's own scripts do, and this is the seam that lets
235
+ * the two-capture gate work against a page the harness does not own. A held clock is the case
236
+ * it was added for: patching `requestAnimationFrame` from here needs no cooperation from the
237
+ * page, so a consumer does not grow a dev-only hook on the path to production, and the
238
+ * same call works against a deployed build. It re-runs on every navigation within the target,
239
+ * which is what makes it survive a page that redirects itself.
240
+ */
241
+ if (beforeLoad !== undefined) {
242
+ await call('Page.addScriptToEvaluateOnNewDocument', { source: beforeLoad });
243
+ }
244
+ if (url) await call('Page.navigate', { url });
245
+
246
+ const evaluate = async (expression, options = {}) => {
247
+ const result = await call('Runtime.evaluate', {
248
+ expression,
249
+ awaitPromise: true,
250
+ returnByValue: true,
251
+ ...options,
252
+ });
253
+ if (result.exceptionDetails) {
254
+ throw new Error(
255
+ `${result.exceptionDetails.text} ${result.exceptionDetails.exception?.description ?? ''}`,
256
+ );
257
+ }
258
+ return result.result.value;
259
+ };
260
+
261
+ return {
262
+ call,
263
+ logs,
264
+ eval: evaluate,
265
+ /**
266
+ * Wait until the page says it is ready, by polling an expression *in the page*.
267
+ *
268
+ * Polling rather than a fixed sleep, because the two things worth waiting for take
269
+ * wildly different times: a held clock reaches its frame in a second, and a scene that
270
+ * streams a model can take twenty. A timeout that covers the second is dead time on
271
+ * every run of the first.
272
+ *
273
+ * `settleMs` is the deliberate pause *after* the condition holds, for the frames a scene
274
+ * draws between announcing itself and being finished.
275
+ */
276
+ async settled(expression = 'true', { timeoutMs: limit = 120_000, settleMs = 1500 } = {}) {
277
+ const started = Date.now();
278
+ for (;;) {
279
+ if (await evaluate(`Boolean(${expression})`)) break;
280
+ if (Date.now() - started > limit) throw new Error(`page never satisfied: ${expression}`);
281
+ await sleep(250);
282
+ }
283
+ await sleep(settleMs);
284
+ },
285
+ /**
286
+ * Wait `count` animation frames, which is what a picture needs rather than milliseconds.
287
+ *
288
+ * **A setting reaches a drawn pixel on the frame after the one that set it**, so anything
289
+ * that changes what is on screen and then photographs it has to let frames pass. A sleep is
290
+ * the wrong instrument for that: it is dead time when the card is quick and a race when it
291
+ * is not, and which one you get depends on the machine. Asked for because a consumer had
292
+ * written the same four lines and had got it wrong once first, which is the argument for it
293
+ * living here rather than in each harness.
294
+ */
295
+ async frames(count = 2) {
296
+ await evaluate(
297
+ `new Promise((resolve) => {
298
+ let left = ${Math.max(1, Math.floor(count))};
299
+ const step = () => (left-- <= 0 ? resolve(true) : requestAnimationFrame(step));
300
+ requestAnimationFrame(step);
301
+ })`,
302
+ );
303
+ },
304
+ /** Whatever the console holds that looks like trouble. Empty is the passing case. */
305
+ complaints() {
306
+ return logs.filter((line) => /error|exception|warning/i.test(line));
307
+ },
308
+ async screenshot(path) {
309
+ const shot = await call('Page.captureScreenshot', { format: 'png' });
310
+ await writeFile(path, Buffer.from(shot.data, 'base64'));
311
+ return path;
312
+ },
313
+ async close() {
314
+ await send('Target.closeTarget', { targetId });
315
+ },
316
+ };
317
+ };
318
+
319
+ return { send, on, page, close: () => socket.close() };
320
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * What two frames differ by, and how speckled one is. Pure functions over decoded pixels.
3
+ *
4
+ * **These two measurements are the ones that have paid for themselves here**, and they are worth
5
+ * having in one place because both have a wrong version that looks right.
6
+ *
7
+ * `compare` reports by *brightness band* rather than as one number, which is what makes a result
8
+ * readable: "19% of the near-white pixels moved by 3, everything else by 0.03" is a description
9
+ * of an effect, and the single mean it collapses into is not. It was written the flat way first
10
+ * and the flat way could not tell a tone curve from rounding between eight bits and a half float.
11
+ *
12
+ * `speckle` counts a pixel brighter than the median of its neighbours, which is how a shading
13
+ * artefact made of single stray pixels gets counted at all: it survives a change of camera, it
14
+ * ignores a legitimate highlight's interior, and it is the metric two consumers arrived at
15
+ * independently while chasing the same fault.
16
+ */
17
+
18
+ /** Rec. 709 luminance of one pixel, in 0..255. */
19
+ export function luminance(pixels, at) {
20
+ return 0.2126 * pixels[at] + 0.7152 * pixels[at + 1] + 0.0722 * pixels[at + 2];
21
+ }
22
+
23
+ /** The bands `compare` splits a frame into, by the luminance of the *first* image's pixel. */
24
+ export const BANDS = [
25
+ { name: 'dark', from: 0, to: 32 },
26
+ { name: 'mid', from: 32, to: 128 },
27
+ { name: 'bright', from: 128, to: 224 },
28
+ { name: 'near white', from: 224, to: 256 },
29
+ ];
30
+
31
+ /**
32
+ * Two decoded images, band by band.
33
+ *
34
+ * `region` is `{ x0, y0, x1, y1 }` and defaults to the whole frame. It earns its place: a harness
35
+ * draws its own readout over the picture, and a frame rate printed in a corner differs between
36
+ * any two runs, so a comparison that includes it always reports a change.
37
+ *
38
+ * `tolerance` is the difference in luminance below which a pixel is called unchanged. 1 is the
39
+ * useful default, being the smallest step eight bits can express.
40
+ */
41
+ export function compare(one, two, { region, tolerance = 1 } = {}) {
42
+ if (one.width !== two.width || one.height !== two.height) {
43
+ throw new Error(
44
+ `frames are ${one.width}x${one.height} and ${two.width}x${two.height}; nothing to compare`,
45
+ );
46
+ }
47
+ const box = {
48
+ x0: region?.x0 ?? 0,
49
+ y0: region?.y0 ?? 0,
50
+ x1: region?.x1 ?? one.width,
51
+ y1: region?.y1 ?? one.height,
52
+ };
53
+ const bands = BANDS.map((band) => ({ ...band, pixels: 0, changed: 0, delta: 0 }));
54
+
55
+ let pixels = 0;
56
+ let changed = 0;
57
+ let delta = 0;
58
+ let worst = 0;
59
+ let worstAt = null;
60
+ let sumOne = 0;
61
+ let sumTwo = 0;
62
+
63
+ for (let y = box.y0; y < box.y1; y++) {
64
+ for (let x = box.x0; x < box.x1; x++) {
65
+ const at = (y * one.width + x) * one.channels;
66
+ const a = luminance(one.pixels, at);
67
+ const b = luminance(two.pixels, at);
68
+ const d = Math.abs(a - b);
69
+ pixels++;
70
+ sumOne += a;
71
+ sumTwo += b;
72
+ const band =
73
+ bands.find((entry) => a >= entry.from && a < entry.to) ?? bands[bands.length - 1];
74
+ band.pixels++;
75
+ if (d > tolerance) {
76
+ changed++;
77
+ delta += d;
78
+ band.changed++;
79
+ band.delta += d;
80
+ if (d > worst) {
81
+ worst = d;
82
+ worstAt = { x, y };
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ const mean = (sum, count) => (count === 0 ? 0 : sum / count);
89
+ return {
90
+ pixels,
91
+ changed,
92
+ meanDelta: mean(delta, changed),
93
+ worst,
94
+ worstAt,
95
+ meanLuminance: [mean(sumOne, pixels), mean(sumTwo, pixels)],
96
+ bands: bands.map((band) => ({
97
+ name: band.name,
98
+ pixels: band.pixels,
99
+ changed: band.changed,
100
+ meanDelta: mean(band.delta, band.changed),
101
+ })),
102
+ };
103
+ }
104
+
105
+ /**
106
+ * How many pixels stand brighter than the median of their eight neighbours by more than
107
+ * `threshold`.
108
+ *
109
+ * The 3x3 median rather than the mean, and this is the part that matters: a mean is dragged up by
110
+ * the very pixel being tested, so a bright speck partly hides itself and a run of them reads as a
111
+ * gentle gradient. A median ignores it entirely, so the comparison is against what the
112
+ * neighbourhood would have been.
113
+ *
114
+ * Two thresholds are usually worth reporting together. The lower one moves with legitimate
115
+ * silhouette detail and the higher one is the artefact, so a change that improves one and not the
116
+ * other has said something specific.
117
+ */
118
+ export function speckle(image, { threshold = 12, region } = {}) {
119
+ const box = {
120
+ x0: Math.max(1, region?.x0 ?? 1),
121
+ y0: Math.max(1, region?.y0 ?? 1),
122
+ x1: Math.min(image.width - 1, region?.x1 ?? image.width - 1),
123
+ y1: Math.min(image.height - 1, region?.y1 ?? image.height - 1),
124
+ };
125
+ const neighbours = new Array(9);
126
+ let count = 0;
127
+ for (let y = box.y0; y < box.y1; y++) {
128
+ for (let x = box.x0; x < box.x1; x++) {
129
+ let n = 0;
130
+ for (let dy = -1; dy <= 1; dy++) {
131
+ for (let dx = -1; dx <= 1; dx++) {
132
+ neighbours[n++] = luminance(
133
+ image.pixels,
134
+ ((y + dy) * image.width + (x + dx)) * image.channels,
135
+ );
136
+ }
137
+ }
138
+ const centre = neighbours[4];
139
+ const sorted = neighbours.slice().sort((a, b) => a - b);
140
+ if (centre - sorted[4] > threshold) count++;
141
+ }
142
+ }
143
+ return count;
144
+ }
145
+
146
+ /** One line per band, for printing a `compare` result without every caller writing the format. */
147
+ export function formatComparison(result) {
148
+ const lines = [
149
+ `${result.changed} of ${result.pixels} pixels changed, mean delta ${result.meanDelta.toFixed(2)}`,
150
+ `mean luminance ${result.meanLuminance[0].toFixed(1)} to ${result.meanLuminance[1].toFixed(1)}` +
151
+ (result.worstAt === null
152
+ ? ''
153
+ : `, worst ${result.worst.toFixed(0)} at ${result.worstAt.x},${result.worstAt.y}`),
154
+ ];
155
+ for (const band of result.bands) {
156
+ const share = band.pixels === 0 ? 0 : (band.changed / band.pixels) * 100;
157
+ lines.push(
158
+ ` ${band.name.padEnd(11)}${String(band.pixels).padStart(9)} px ` +
159
+ `${share.toFixed(1).padStart(6)}% changed, mean delta ${band.meanDelta.toFixed(2)}`,
160
+ );
161
+ }
162
+ return lines.join('\n');
163
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * A held clock, as a script to inject into a page the harness does not own.
3
+ *
4
+ * **Why this exists next to `demo/dev/heldFrame.ts` rather than instead of it.** Two runs of one
5
+ * build differ in zero pixels only if the frame being photographed is the same frame, and a
6
+ * scene's state is a function of elapsed time: a load that stalls a few frames puts the camera
7
+ * somewhere else. Measured while chasing a shading artefact, two captures taken at the same
8
+ * wall-clock offset from one page differed in **736,190 pixels of 921,600**.
9
+ *
10
+ * The engine's own harness takes `?hold=N` and freezes itself, which is right for a person typing
11
+ * a URL: they get a badge saying the clock has stopped, and a frame rate read off a held page is
12
+ * otherwise a lie. **A consumer has no such hook and should not have to grow one.** Adding
13
+ * a dev-only clock patch to a game's entry point puts harness code on the path to production and
14
+ * makes the gate something each consumer implements. Injected before the page's own scripts run,
15
+ * it needs no cooperation at all and works against a deployed build as readily as a dev server.
16
+ *
17
+ * So the two exist for different callers and the duplication is the price: this one is injected
18
+ * from outside and stays silent, that one is asked for from inside and announces itself. If the
19
+ * stepping ever changes, change both, and the fixed 1/60 below is the engine's own step rather
20
+ * than a number chosen here.
21
+ */
22
+
23
+ /** What a frame is worth, matching the engine's fixed simulation step. */
24
+ const STEP_MS = 1000 / 60;
25
+
26
+ /**
27
+ * Source for `beforeLoad`, freezing the page after exactly `frames` steps.
28
+ *
29
+ * The shape is the same as the in-page version and each part of it is load-bearing. Callbacks are
30
+ * queued rather than called through, because a scene that re-requests inside its own callback
31
+ * would recurse to the stack limit. The pump is a task rather than a microtask, so fetch, worker
32
+ * and decode callbacks get their turn: without that, a page that streams a model holds at zero
33
+ * parts for ever and photographs an empty stage.
34
+ */
35
+ export function heldClockScript(frames, { startWhen = 'true' } = {}) {
36
+ const wanted = Math.max(1, Math.floor(frames));
37
+ return `(() => {
38
+ const realRaf = window.requestAnimationFrame.bind(window);
39
+ const realNow = performance.now.bind(performance);
40
+ let virtualMs = 0;
41
+ let advanced = 0;
42
+ let taken = false;
43
+ let pending = [];
44
+
45
+ /*
46
+ * Nothing is taken over until the page says it is ready, and that is the part that makes this
47
+ * work on a game rather than only on a scene that mounts synchronously.
48
+ *
49
+ * Measured on one: holding from page load left two runs of one build differing in 12,023 pixels
50
+ * of 921,600, because the clock advances a frame per turn of the task queue whether or not the
51
+ * load has finished, so the number of frames the game had actually drawn by frame N depended on
52
+ * when a worker and an audio decode happened to resolve. Counting from a stated state instead
53
+ * makes the frame that gets photographed the same frame.
54
+ */
55
+ const ready = () => { try { return Boolean(${startWhen}); } catch (error) { return false; } };
56
+
57
+ const take = () => {
58
+ taken = true;
59
+ /* Continue from the real clock rather than from zero: a page that has already read the time
60
+ and stored an origin would otherwise see it run backwards. */
61
+ virtualMs = realNow();
62
+ performance.now = () => virtualMs;
63
+ window.requestAnimationFrame = (callback) => { pending.push(callback); return pending.length; };
64
+ window.cancelAnimationFrame = () => {};
65
+ const pump = () => {
66
+ const due = pending;
67
+ pending = [];
68
+ /*
69
+ * The clock moves for a frame that is actually drawn, and not for a turn of the task queue
70
+ * that happens to find nothing waiting.
71
+ *
72
+ * Measured on a game whose load is asynchronous: advancing per turn left two runs of one
73
+ * build differing in 895 pixels, because the turns that elapse while a worker and an audio
74
+ * decode resolve are real turns and there are a different number of them each time. Tying
75
+ * the step to a callback makes the page see exactly N frames of 1/60 however long its load
76
+ * took. The cost is that a page which stops asking for frames stops the clock, which is
77
+ * correct here and is why a caller states what to wait for.
78
+ */
79
+ if (due.length > 0 && advanced < ${wanted}) { virtualMs += ${STEP_MS}; advanced++; }
80
+ for (const callback of due) callback(virtualMs);
81
+ /* A task rather than a microtask, so fetch, worker and decode callbacks get their turn. */
82
+ setTimeout(pump, 0);
83
+ };
84
+ setTimeout(pump, 0);
85
+ };
86
+
87
+ const watch = () => {
88
+ if (ready()) take();
89
+ else realRaf(watch);
90
+ };
91
+ realRaf(watch);
92
+
93
+ /* What a caller waits on, since there is no badge to look for when nobody is looking. */
94
+ Object.defineProperty(window, '__heldFrame', {
95
+ get: () => (taken && advanced >= ${wanted} ? advanced : 0),
96
+ });
97
+ })();`;
98
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What `MeshBuilder.build()` costs, and what it used to.
3
+ *
4
+ * **A benchmark rather than a test**, in `island-bench.ts`'s shape: nothing here asserts, because a
5
+ * wall-clock number on one machine is evidence and not a contract. `npm run bench:mesh`.
6
+ *
7
+ * **Why the number matters to somebody other than this repository.** A consumer streaming a world
8
+ * builds its geometry between frames, and `build()` was one uninterruptible call: measured at
9
+ * 41.9 ms here for 110,628 triangles, two and a half frames at 16.7 ms. That is what made them cap
10
+ * a batch at a triangle count tuned to one laptop — 32,000, which is seven milliseconds there and
11
+ * unknown on a phone — and overshoot it by whatever the last object was, up to 47,040 against a cap
12
+ * of 32,000, because a batch is told what went into it after it went in.
13
+ *
14
+ * Two things fixed it and both are visible below: the builder writes into growable typed arrays as
15
+ * geometry arrives, so `build()` is a slice rather than ten conversions of boxed doubles; and the
16
+ * five "did anything differ from the default" questions it asked by scanning every vertex are now
17
+ * answered by a flag the write sets. **41.9 ms to 1.6 ms.**
18
+ *
19
+ * `triangleCount` is printed beside them because it is the other half of the same report: a caller
20
+ * can now ask how big a builder has become instead of tallying it, which is what lets a cap be
21
+ * honest rather than approximate.
22
+ */
23
+ import { MeshBuilder } from '../src/geometry/meshBuilder';
24
+
25
+ /** The consumer's own figure for one region's street-plate lettering, at twelve triangles a box. */
26
+ const TRIANGLES = 110628;
27
+ const BOXES = Math.round(TRIANGLES / 12);
28
+ const RUNS = 7;
29
+
30
+ function fill(): MeshBuilder {
31
+ const builder = new MeshBuilder();
32
+ for (let i = 0; i < BOXES; i++) {
33
+ builder.addBox(i * 0.01, 0, (i % 37) * 0.01, 0.004, 0.004, 0.001, [0.9, 0.9, 0.9]);
34
+ }
35
+ return builder;
36
+ }
37
+
38
+ const median = (values: number[]): number =>
39
+ values.slice().sort((a, b) => a - b)[Math.floor(values.length / 2)] as number;
40
+
41
+ /* Warm up: the first run of anything in a fresh isolate measures the compiler. */
42
+ for (let i = 0; i < 2; i++) fill().build();
43
+
44
+ const fills: number[] = [];
45
+ const builds: number[] = [];
46
+ let triangles = 0;
47
+ let vertices = 0;
48
+ for (let run = 0; run < RUNS; run++) {
49
+ let started = performance.now();
50
+ const builder = fill();
51
+ fills.push(performance.now() - started);
52
+
53
+ /* Asked before building, which is the point of it: no mesh exists yet. */
54
+ triangles = builder.triangleCount;
55
+ vertices = builder.vertexCountSoFar;
56
+
57
+ started = performance.now();
58
+ const mesh = builder.build();
59
+ builds.push(performance.now() - started);
60
+ if (mesh.indices.length / 3 !== triangles) {
61
+ throw new Error(
62
+ `triangleCount said ${triangles} and build() produced ${mesh.indices.length / 3}. ` +
63
+ 'Those are the same question and a caller batching by size is trusting the first one.',
64
+ );
65
+ }
66
+ }
67
+
68
+ console.log(`\n ${triangles} triangles, ${vertices} vertices, median of ${RUNS}`);
69
+ console.log(` fill ${median(fills).toFixed(1)} ms the caller's own loop`);
70
+ console.log(` build ${median(builds).toFixed(1)} ms the stop, against 41.9 ms before`);
71
+ console.log(` total ${(median(fills) + median(builds)).toFixed(1)} ms\n`);
@@ -0,0 +1,17 @@
1
+ /** What a decoded PNG is: row-major 8-bit samples, three or four to a pixel. */
2
+ export interface DecodedPng {
3
+ readonly width: number;
4
+ readonly height: number;
5
+ readonly channels: number;
6
+ readonly pixels: Buffer;
7
+ }
8
+
9
+ export function decodePng(bytes: Buffer): DecodedPng;
10
+ export function readPng(path: string): DecodedPng;
11
+
12
+ /**
13
+ * Write 8-bit RGBA as a PNG. `rgba` must be exactly `width * height * 4` bytes.
14
+ *
15
+ * Node-side only — `node:zlib` is the whole of it, and nothing a browser loads links this.
16
+ */
17
+ export function encodePng(width: number, height: number, rgba: Buffer | Uint8Array): Buffer;