@driftengine/core 3.61.0 → 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.
- package/CHANGELOG.json +3527 -0
- package/package.json +5 -3
- package/scripts/browser.d.mts +17 -0
- package/scripts/browser.mjs +353 -0
- package/scripts/cdp.d.mts +28 -0
- package/scripts/cdp.mjs +320 -0
- package/scripts/frames.mjs +163 -0
- package/scripts/heldClock.mjs +98 -0
- package/scripts/mesh-bench.ts +71 -0
- package/scripts/png.d.mts +17 -0
- package/scripts/png.mjs +201 -0
- package/scripts/releases.mjs +109 -0
package/scripts/cdp.mjs
ADDED
|
@@ -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;
|