Repository navigation
Expand file tree
/
Copy pathdeterministic.js
More file actions
395 lines (369 loc) · 17.8 KB
/
Copy pathdeterministic.js
File metadata and controls
395 lines (369 loc) · 17.8 KB
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
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
import seedrandom from "seedrandom";
import { DefaultLoadingManager } from "three";
const sayCheeseParam = new URLSearchParams(window.location.search).has(
"saycheese",
);
if (sayCheeseParam) {
seedrandom("hello.", { global: true });
//
// `networkidle` says the bytes arrived; it says nothing about the decode
// that follows -- a Draco mesh unpacks in a worker pool, an HDR parses off
// the main thread, and under load the settle after idle can lose that
// race. Then the second take re-suspends and mounts in arrival order
// again, quietly reviving everything the remount exists to fix
// (`iridescent-decals` flipped once in ten shots exactly this way, only
// under full-sweep load). The loading manager is the signal with the right
// shape: `itemEnd` fires after the parse, not after the bytes. Patched at
// the method rather than the `onLoad` slot, which drei's `useProgress`
// overwrites for its own loader UI.
//
// Counted per URL, and never below zero, because the pairing is a loader's
// to honour and one of them does not: `postprocessing` 6.39's
// `LUTCubeLoader.load()` calls `itemEnd` on a URL it never called
// `itemStart` for (6.36 did both, and the six examples loading a `.cube`
// are exactly the ones that felt it). A single counter turns that into
// `inflight - 1` for the rest of the page's life -- so it reads 0 at
// whatever moment exactly one real item is in flight, which is the shot
// going off *during* a decode, and reads -1 forever otherwise, which is the
// wait never ending: `glass-flower` and `nextjs-prism` sat there for the
// full 300s budget. An `itemEnd` for a URL nobody started is ignored.
const inflight = new Map();
const itemStart = DefaultLoadingManager.itemStart.bind(DefaultLoadingManager);
const itemEnd = DefaultLoadingManager.itemEnd.bind(DefaultLoadingManager);
DefaultLoadingManager.itemStart = (url) => {
inflight.set(url, (inflight.get(url) ?? 0) + 1);
itemStart(url);
};
DefaultLoadingManager.itemEnd = (url) => {
const pending = inflight.get(url) ?? 0;
if (pending > 0) inflight.set(url, pending - 1);
itemEnd(url);
};
window.__cheeseInflight = () =>
[...inflight.values()].reduce((total, pending) => total + pending, 0);
//
// Physics lives in a worker, and a worker keeps its own time.
//
// `@react-three/cannon` steps by ping-pong: each frame the provider posts
// `step` and *transfers* the position buffers to the worker; until the
// worker's `frame` reply hands them back, every further step is silently
// skipped (`byteLength === 0`) and its 1/60th of simulation is dropped, not
// deferred. So the picture is spawn + (completed round trips) x 1/60 -- and
// how many round trips fit into thirty pumped frames is the scheduler's
// call, not ours. Measured on `basic-ballpit`: 30/30 round trips at full
// speed, 28 under an 8x CPU throttle, each with its own perfectly
// reproducible canvas -- two stable pictures, chosen by machine speed.
// Chromatic's runner sits between the two and picks per run; twelve
// examples ride `@react-three/cannon`.
//
// So the round trips are counted -- `step` out, `frame` back, per worker --
// and the pump (see `SayCheese`) holds the next frame until the count
// settles. Every machine then completes exactly one step per frame.
// `terminate()` forgives what a dying worker still owes: the second take
// unmounts the whole physics provider, and a reply that will never come
// must not hold the pump forever.
//
const owed = new Map();
const RealWorker = window.Worker;
window.Worker = class extends RealWorker {
postMessage(message, ...rest) {
if (message?.op === "step") owed.set(this, (owed.get(this) ?? 0) + 1);
super.postMessage(message, ...rest);
}
get onmessage() {
return super.onmessage;
}
set onmessage(handler) {
super.onmessage = (event) => {
if (event.data?.op === "frame" && owed.get(this))
owed.set(this, owed.get(this) - 1);
handler(event);
};
}
terminate() {
owed.delete(this);
super.terminate();
}
};
window.__cheesePhysicsSettled = () => {
for (const count of owed.values()) if (count > 0) return false;
return true;
};
//
// Seeding fixes the *sequence*; it does not fix who draws from it in what
// order. Assets resolve in whatever order the network hands them back, the
// components waiting on them mount in that order, and every draw they make
// -- a spread position, a phase, the four values three.js burns per object
// for a UUID -- lands on a different point of the same fixed sequence.
//
// The harness deals with that by mounting the scene twice (see
// `CheesyCanvas`), and the second mount only helps if it starts from the top
// of the sequence. This is the rewind.
//
window.__cheeseReseed = () => seedrandom("hello.", { global: true });
//
// Chromatic archives the frozen page by serialising the DOM, and a <canvas>
// only survives that as whatever `toDataURL()` returns. WebGL clears its
// drawing buffer right after compositing, so by the time the archive is
// taken the read comes back blank -- unless the context was created with
// `preserveDrawingBuffer`.
//
// Patched onto `getContext` rather than merged into the `<Canvas gl>` prop,
// because this module is imported from `src/index.[jt]sx` -- which every
// example has -- and runs before anything creates a context. So it catches
// every canvas whatever shape `gl` takes (`svg-renderer` passes a callback
// and builds its own renderer, leaving nothing to merge into) and wherever
// the `<Canvas>` lives, which is not always where the plugin looks.
//
// Only under `?saycheese`: it costs an extra buffer copy per frame, which no
// visitor should pay for.
//
const getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, attributes) {
return getContext.call(
this,
type,
/^(webgl2?|experimental-webgl)$/.test(type)
? // `antialias: false` too: which surface wins each MSAA sample on a
// knife edge is resolved inside the driver, below anything a page
// can patch. `room-with-soft-shadows` flipped two pixels of its
// window pane by one sample's worth, run to run, with every
// JS-visible state byte-identical -- and held 8/8 without MSAA.
// Aliased edges are the price of a comparison with zero tolerance.
{ ...attributes, preserveDrawingBuffer: true, antialias: false }
: attributes,
);
};
//
// Draw order must not depend on which asset finished decoding first.
//
// three's default opaque sort (`painterSortStable`) orders by `material.id`
// before depth -- and that id is a global counter, burned in whatever order
// GLTFLoader's async pipeline happened to construct materials: Draco decodes
// in a four-worker pool, textures land when they land, and
// `assignFinalMaterial` mints its clones as each mesh's Promise.all
// resolves. The loader cache then hands those same instances to the second
// mount, so the remount cannot renumber them and the reseed cannot reach a
// module-private counter. Wherever two of those materials meet on a
// coplanar surface -- or anywhere at all inside a depthTest=false overdraw
// pass like ContactShadows' depth bake -- the draw order IS the picture.
//
// Measured as the entire remaining drift of `bloom-hdr-workflow-gltf` (one
// MSAA-contested pixel, 10/10 identical with this in place) and
// `bruno-simons-20k-challenge` (9/9), and the same mechanism was traced in
// `bounds-and-makedefault`, `clones` and `html-markers`.
//
// Same keys the defaults use, minus `material.id`, and with the final
// tiebreak on the object's *name* before its id. `object.id` is only safe
// for meshes the second take minted itself, in JSX order; a mesh drawn
// straight out of a cached GLTF scene graph carries an id burned at parse
// time, in the same racy order as the materials'. Its name, though, comes
// from the file. The transparent sort needs the same treatment for its
// z-ties -- `sport-hall`'s backboard glass sits exactly on its trim, and
// three's default falls back to `object.id` right there.
//
// Installed through the `__THREE_DEVTOOLS__` hook because every renderer
// announces itself there at construction, whichever version of three and
// whoever created it -- this module just has to exist first, and it is the
// entry's first import.
//
const byName = (a, b) =>
a.object.name < b.object.name
? -1
: a.object.name > b.object.name
? 1
: a.id - b.id;
const opaqueSort = (a, b) =>
a.groupOrder - b.groupOrder ||
a.renderOrder - b.renderOrder ||
a.z - b.z ||
byName(a, b);
const transparentSort = (a, b) =>
a.groupOrder - b.groupOrder ||
a.renderOrder - b.renderOrder ||
b.z - a.z ||
byName(a, b);
window.__THREE_DEVTOOLS__ = {
dispatchEvent(event) {
const it = event.detail;
if (it && it.isWebGLRenderer && it.setOpaqueSort) {
it.setOpaqueSort(opaqueSort);
it.setTransparentSort(transparentSort);
}
},
};
//
// The clock the whole page reads, replaced by one that only moves when we
// move it -- the same thing three.js does in its own screenshot harness.
//
// `frameloop="never"` freezes r3f's loop. It does not freeze the *page*, and
// that turned out to be most of what was left: `@react-spring/three` animates
// on its own `rafz` loop reading `performance.now()`, never through
// `useFrame`, so it kept advancing by however long our thirty frames happened
// to take -- which varies with shader compilation, run to run, by seconds.
// That is why postprocessing and physics never correlated with drift (they
// live inside `useFrame`) while springs and easings did.
//
// So: time starts frozen, stays frozen through loading, and advances exactly
// `1/60` per frame we pump. `rAF` callbacks get the same virtual timestamp,
// for anything that reads the argument rather than the clock.
//
// Frozen at one frame's worth, not at zero. Zero is the one value a clock
// must never idle on: react-spring's `rafz` computes its delta as
// `prevTs ? ts - prevTs : 16.667`, so a clock stuck at 0 keeps `prevTs`
// falsy forever and every *real* pre-shot rAF tick advances every spring by
// a full frame -- how many, the machine decides. Stuck at 16.667 instead,
// the fallback fires once and every later tick computes `dt = 0`: springs
// hold perfectly still until the pump moves the clock. Measured on
// `springy-boxes`, which entered the shot a machine-dependent number of
// frames into flight.
//
// `Date.now` moves with it off a fixed epoch. `new Date()` is deliberately
// left alone: patching the constructor breaks `instanceof` for very little,
// since animation libraries read `performance.now` or `Date.now`.
//
const EPOCH = 1767225600000; // 2026-01-01T00:00:00Z, an arbitrary fixed point
let virtual = 1000 / 60;
// Kept for the one thing that still wants wall clock: reporting how long the
// shot really took, which is what tells us whether the budget still fits.
window.__cheeseRealNow = performance.now.bind(performance);
performance.now = () => virtual;
Date.now = () => EPOCH + virtual;
// three.js patches this one too, and it is not redundant: `new Date().getTime()`
// never goes through `Date.now`.
Date.prototype.getTime = () => EPOCH + virtual;
const rAF = window.requestAnimationFrame.bind(window);
window.requestAnimationFrame = (callback) => rAF(() => callback(virtual));
// The User Timing API is its own clock: `performance.mark()` stamps the
// browser's internal monotonic time, and `measure().duration` is computed
// from those stamps -- none of it goes through `performance.now`, so it is
// the one channel wall time still had into the page. And into the pixels:
// stats-gl profiles each frame's CPU cost with mark/measure pairs, and the
// max of that range is printed onto the `clouds` StatsGl panel, one machine
// measurement away from flipping a digit. Marks land on the virtual clock
// instead; anything measured inside one pumped frame is exactly 0. Nothing
// is forwarded to the native calls on purpose -- a real entry in the buffer
// would hand `getEntriesByName` the same wall time back.
const marks = new Map();
performance.mark = (name) => {
marks.set(name, virtual);
return { name, entryType: "mark", startTime: virtual, duration: 0 };
};
performance.measure = (name, start, end) => {
// Marks we never saw (or the options-object form) resolve to `virtual`:
// every unknown duration is 0, which is the deterministic answer.
const from = marks.get(start) ?? virtual;
const to = marks.get(end) ?? virtual;
return { name, entryType: "measure", startTime: from, duration: to - from };
};
//
// Timers move onto the virtual clock too -- but only once the shot starts.
//
// `setTimeout` is how a scene schedules anything it does not want to do right
// now, and it was still keeping wall time: `threejs-journey-lv-1-fisheye`
// re-aims its subject on `setTimeout(wander, (1 + Math.random() * 2) * 800)`,
// so how many of our frames elapse before it fires depended entirely on how
// fast the machine drew them.
//
// Only once the shot starts, and that boundary was measured rather than
// guessed. Virtualising from page load instead -- to catch `clones`, which
// recolours its light on `setInterval(..., 3000)` a machine-dependent number
// of times *while loading* -- deadlocked the load: the clock does not move
// until we pump, so anything waiting on a long timer waits forever.
// `bruno-simons-20k-challenge` never finished loading and spent all three of
// its shots hitting the 300s budget. Exempting short delays was not enough.
//
// So a scene that does something time-based before the shot is beyond this,
// and has to stop doing it -- see `clones`.
//
const realSetTimeout = window.setTimeout.bind(window);
const pending = new Map();
let nextId = 1;
let shooting = false;
const realSetInterval = window.setInterval.bind(window);
function schedule(callback, delay, args, every) {
const id = -nextId++; // negative, so it cannot collide with a real handle
pending.set(id, { at: virtual + delay, callback, args, every });
return id;
}
window.setTimeout = (callback, delay = 0, ...args) =>
shooting
? schedule(callback, delay, args, 0)
: realSetTimeout(callback, delay, ...args);
// `clones` recolours its light on `setInterval(..., 3000)`, drawing from the
// shared sequence each time it fires -- so what it had drawn by the shot
// depended on how long the shot took to reach.
window.setInterval = (callback, delay = 0, ...args) =>
shooting
? schedule(callback, delay, args, delay)
: realSetInterval(callback, delay, ...args);
const realClearTimeout = window.clearTimeout.bind(window);
const realClearInterval = window.clearInterval.bind(window);
window.clearTimeout = (id) =>
id < 0 ? pending.delete(id) : realClearTimeout(id);
window.clearInterval = (id) =>
id < 0 ? pending.delete(id) : realClearInterval(id);
// Called by the pump in CheesyCanvas, once per frame, before it advances r3f.
window.__cheeseAdvanceClock = (ms) => {
shooting = true;
virtual += ms;
// Due callbacks, oldest first, and taken out of the map before they run so
// that one rescheduling itself cannot loop inside this frame.
for (const [id, timer] of [...pending].sort((a, b) => a[1].at - b[1].at)) {
if (timer.at > virtual) break;
if (timer.every) timer.at += timer.every;
else pending.delete(id);
timer.callback(...timer.args);
}
};
//
// A `<video>` answers to none of the above: it plays off the media clock, in
// its own thread, and a texture sampled from it lands on whatever frame the
// decoder had reached. It is the single strongest predictor of drift we
// measured -- ten times over-represented among the examples that will not
// hold still.
//
// three.js pins it by letting playback start and pausing on the first
// `timeupdate`, and calls the result "semi-deterministic" for good reason:
// `timeupdate` fires about four times a second, so where playback stops still
// depends on how quickly the machine got there. We measured that as still
// drifting.
//
// So it never plays. It seeks to a fixed position instead, and waits for the
// decoder to say it landed -- `seeked` is the guarantee that this exact frame
// is the one a texture will sample. Half a second in, because the first frame
// of a video is often black or a fade.
//
const SEEK = 0.5;
HTMLVideoElement.prototype.play = function () {
const seek = () => {
this.pause();
this.currentTime = SEEK;
};
// `currentTime` before metadata is a no-op, so wait for the duration to
// exist when it does not yet.
if (this.readyState >= 1) seek();
else this.addEventListener("loadedmetadata", seek, { once: true });
return Promise.resolve();
};
//
// CSS animations run on the compositor's timeline, which none of the clock
// patching above can reach -- and they do not have to touch the canvas to
// move pixels in it: `view-tracking` animates plain divs whose bounding
// rects drei's <View> reads every frame to place its scissor, so the
// picture followed real seconds since page load. Killed everywhere, not
// just on the canvas: the hash is canvas-only, so a frozen page costs
// nothing, and no example gates its loading on an animation ever ending.
//
var style = document.createElement("style");
style.innerHTML = `
*, *::before, *::after {
animation: none !important;
transition: none !important;
}
canvas[data-engine] {
opacity: 1!important;
}
`;
document.head.appendChild(style);
}