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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/e2e/lib/shoot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,25 @@ export async function shoot(page, host) {
// fixed frame, so that is what gets waited on -- then the same settle the
// first wait uses.
//
//
// The remount's `flushSync` returns when the *DOM* side has committed; the
// scene lives behind the `<Canvas>` bridge in r3f's own root, whose render
// is scheduled, not flushed. Under load that render -- and the effects
// where a physics world is built, worker and all -- landed *inside* the
// shot, assembling the scene across pumped frames at whichever frame the
// machine chose (`trails`, measured under CPU throttle). So the page says
// when the take has finished mounting -- committed and effects run, see
// `Probe` in `CheesyCanvas` -- and the shot waits for it to say so.
//
const take = await page.evaluate(() => window.__cheeseTake ?? 0);
await page.evaluate(() => window.__cheeseRemount?.());
await page
.waitForFunction((before) => window.__cheeseTake > before, take, {
timeout: 60_000,
})
.catch(() => {
console.log("The second take never finished mounting, shooting anyway");
});
await waitForDecodes(page); // in case the second take re-suspended anything
await page
.waitForFunction(
Expand Down
41 changes: 40 additions & 1 deletion packages/e2e/src/CheesyCanvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ function SayCheese() {
// 158 examples still to be covered will.
//
if (window.__cheeseShoot === true && frame < FRAMES) {
//
// Not while a physics worker still owes a reply. Cannon steps by
// transferring its buffers to a worker and skips every step until
// they come back, so how many steps land inside our thirty frames
// would otherwise be the machine's choice -- see `deterministic.js`,
// which keeps the count this reads. Skipping the advance (rather
// than blocking) keeps the compositor pixel moving above.
//
if (window.__cheesePhysicsSettled?.() === false) {
raf = requestAnimationFrame(tick);
return;
}

if (frame === 0) started = window.__cheeseRealNow?.() ?? 0;

//
Expand Down Expand Up @@ -197,7 +210,33 @@ export default function CheesyCanvas({ children, frameloop, ...props }) {
<Canvas {...props} frameloop={cheesyFrameloop}>
{sayCheeseParam && <SayCheese />}

<React.Fragment key={take}>{children}</React.Fragment>
<React.Fragment key={take}>
{children}
{sayCheeseParam && <Probe take={take} />}
</React.Fragment>
</Canvas>
);
}

//
// Announces that a take has *finished* mounting -- render committed, effects
// run -- which `flushSync` in `__cheeseRemount` cannot promise: the children
// live behind the `<Canvas>` bridge, in r3f's own root, whose render the DOM
// side schedules rather than flushes. Measured on `trails` under CPU
// throttle: both flushSyncs long returned, and the second take still
// assembled itself at frames 1-2 of the pump -- its physics worker created,
// connected and populated across running frames, at whichever frame the
// machine chose (and the first take's, not yet unmounted, stepping in the
// meantime). The harness waits for this signal before it starts the shot --
// see `shoot.mjs`.
//
// Last in the fragment, because effects flush in tree order: by the time this
// one runs, every sibling before it has run its own -- for physics, that is
// where the worker is created and every body added.
//
function Probe({ take }) {
useEffect(() => {
window.__cheeseTake = take;
}, [take]);
return null;
}
76 changes: 72 additions & 4 deletions packages/e2e/src/deterministic.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,86 @@ if (sayCheeseParam) {
// the method rather than the `onLoad` slot, which drei's `useProgress`
// overwrites for its own loader UI.
//
let inflight = 0;
// 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 += 1;
inflight.set(url, (inflight.get(url) ?? 0) + 1);
itemStart(url);
};
DefaultLoadingManager.itemEnd = (url) => {
inflight -= 1;
const pending = inflight.get(url) ?? 0;
if (pending > 0) inflight.set(url, pending - 1);
itemEnd(url);
};
window.__cheeseInflight = () => inflight;
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 dead = new WeakSet();
const RealWorker = window.Worker;
window.Worker = class extends RealWorker {
postMessage(message, ...rest) {
// A step posted to a terminated worker is a debt nobody will honour:
// posting is a silent void, so counting it would hold the pump for the
// full 300s budget. It can happen -- `terminate()` runs in one effect's
// cleanup and the `useFrame` unsubscribe in another's.
if (message?.op === "step" && !dead.has(this))
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() {
dead.add(this);
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
Expand Down
Loading