Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3ece387
PRD-752: run web games in a worker so the page cannot stall them
jonit-dev Oct 27, 2026
6cb7191
profile:wasm-page --load runs a page under a stated CPU load (PRD-752)
jonit-dev Oct 27, 2026
c4f4644
Core reads window and document only through platform.ts; loaded-host …
jonit-dev Oct 27, 2026
3795190
UI bridge loopback over a MessageChannel behind ?tnUiLoopback=1 (PRD-…
jonit-dev Oct 27, 2026
69166e5
PRD-752 phase 2 done: native desktop gate and starter loopback proof
jonit-dev Oct 27, 2026
8f8fcbc
Engine side of the worker boot: platform hook, worker engine bundle, …
jonit-dev Oct 27, 2026
929d0d9
Worker game host: @threenative/core/worker runs a game in a worker on…
jonit-dev Oct 27, 2026
e8f494f
Worker game audio: the page's AudioContext plays a worker game's soun…
jonit-dev Oct 27, 2026
94a0f7a
Worker game HUD: the UI bridge works when the game connects while its…
jonit-dev Oct 27, 2026
e575927
Starter: worker.html runs the same game in a web worker (PRD-752)
jonit-dev Oct 27, 2026
150a391
profile:wasm-page times frames on the game's own thread (PRD-752)
jonit-dev Oct 27, 2026
2013a6f
Worker game pointer lock: mouse look works for a game in a worker (PR…
jonit-dev Oct 27, 2026
4970303
profile:wasm-page times the frames the game presents (PRD-752)
jonit-dev Oct 27, 2026
25518c3
Shooter: worker.html runs the same game in a web worker; PRD-752 deci…
jonit-dev Oct 27, 2026
ea25ac5
Worker game playtest bridge: mirror it while start is held for the ru…
jonit-dev Oct 27, 2026
21f8a38
Worker is the default page for the starter and shooter kits (PRD-752)
jonit-dev Oct 27, 2026
7bc7600
Engine raycast: a cached triangle BVH per geometry (PRD-752)
jonit-dev Oct 27, 2026
105f492
Worker mode: fix the review findings in the worker host, remote audio…
jonit-dev Oct 27, 2026
32a62eb
profile:wasm-page --vsync keeps the browser's frame pacing for worker…
jonit-dev Oct 27, 2026
ca2bbf0
Playtest: a worker game relaunches without the unlimited frame rate (…
jonit-dev Oct 27, 2026
d600e7b
PRD-752: the worker KPI box, measured with vsync on both kits
jonit-dev Oct 27, 2026
3a07599
profile:wasm-page spec: record the launch args without a comma expres…
jonit-dev Oct 27, 2026
7fe753b
Playtest: a live-clock hold counts the ticks the game ran, not the re…
jonit-dev Oct 27, 2026
e4d9081
PRD-752: starter and harness boxes ticked; worker-in-Xvfb and live-cl…
jonit-dev Oct 27, 2026
4bc3055
PRD-752: visual judge NEUTRAL ticked; shooter performance on the real…
jonit-dev Oct 27, 2026
298a806
PRD-752: no worker-only frame rate under Xvfb (scheduler probe, draw-…
jonit-dev Oct 27, 2026
4bc3150
PRD-752: phase 4 ceiling measured (shooter about 6%, Midway about 25%…
jonit-dev Oct 27, 2026
6639da5
PRD-752: phase 4 threaded-build probe (1-5% single-thread cost); job …
jonit-dev Oct 27, 2026
c4fe5ef
PRD-752: threaded-build cost re-measured on one source tree (about 1.…
jonit-dev Oct 27, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ shares: animation about 13%, cull and project about 11%, `Renderer::render` 21%,
when its web and desktop rows both pass on the engine ([PRD-617](PRD-617-every-template-runs-on-the-native-engine.md)).
The engine exposes the upstream Three.js API, and a game uses the same source on every back end.
This refines the previous decision: the default is not reverted while templates remain pinned.
- 2026-10-26 (owner): the worker HUD contract is confirmed (one state snapshot per frame out, intents back): "Yes, and v5.2 threads too". Cycle v5 moves to [PRD-752](./PRD-752-web-games-run-in-a-worker-so-the-page-cannot-stall-them.md).
- 2026-10-15 (owner): housekeeping. Delete junk and reduce the file count; keep every behaviour.
- Deferred, filed again only on scorecard evidence: transform compose skip (if
`updateMatrixWorldSelf` is above 2% of the frame), projection reuse and SIMD world matrices (if
Expand Down Expand Up @@ -311,6 +312,8 @@ TN today: HZB declined ([PRD-489](../done/PRD-489-gpu-scene-occlusion-culling.md

## Cycle v5 — web worker

**Owned by [PRD-752](./PRD-752-web-games-run-in-a-worker-so-the-page-cannot-stall-them.md)** (owner, 2026-10-26: worker HUD contract confirmed, "Yes, and v5.2 threads too"). The boxes below stay as the summary; PRD-752 carries the proof.

The game side reads the DOM directly (23 core files read `document.` or `window.`; audio, pointer lock, gamepads, `localStorage`). A worker keeps frame p50; only Wasm threads can cut it.

#### Phase v5.1: Worker mode
Expand All @@ -328,7 +331,6 @@ The game side reads the DOM directly (23 core files read `document.` or `window.
- The v3 margin over Godot is an owner number, set before v3 starts.
- Whether engine-only commits may skip `native-platforms` is an owner call (slice 0b).
- `AnimationMixer.updateRate` adds a non-three property; João confirms the name before v2.3.
- The worker HUD contract (state out once per frame) needs João's confirmation before v5.1.
- Android timing rows need the Pixel 8 attached; the emulator holds counter and memory rows.
- Safari rows need a macOS machine.

Expand Down

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions packages/core/__tests__/game.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3519,6 +3519,58 @@ describe("IGame", () => {
}
});

it("takes the platform source a worker host installs when the game names none (PRD-752)", async () => {
// Worker mode: game.ts is the unchanged portable entry, so the worker host cannot pass
// `platform` to defineGame. It installs the source on the global scope before start().
const canvas = new EventTarget() as HTMLCanvasElement;
let mounted = 0;
let unmounted = 0;
const platform: IGamePlatformSource = {
input: () => [],
inputTarget: new EventTarget(),
mountCanvas: () => {
mounted += 1;
},
renderer: {
createCanvas: () => canvas,
hasWebGPU: () => false,
observeResize: () => () => undefined,
readSize: () => [320, 180],
},
unmountCanvas: () => {
unmounted += 1;
},
viewport: {
observeResize: () => () => undefined,
readSize: () => ({ aspect: 16 / 9, height: 180, width: 320 }),
},
};
vi.stubGlobal("__tnGamePlatform", platform);
const game = defineGame({
renderer: {
preferWebGPU: false,
webgl2Factory: () => ({
dispose: () => undefined,
domElement: canvas,
render: () => undefined,
setSize: () => undefined,
}),
},
scenes: { test: EmptyScene },
start: "test",
});
try {
await game.start();
expect(mounted).toBe(1);
expect(game.ctx?.viewport.size).toEqual({ aspect: 16 / 9, height: 180, width: 320 });
game.stop();
expect(unmounted).toBe(1);
} finally {
game.stop();
vi.unstubAllGlobals();
}
});

it.each([
{ opaque: false, worldDrawn: false },
{ opaque: true, worldDrawn: true },
Expand Down
11 changes: 11 additions & 0 deletions packages/core/__tests__/input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,17 @@ describe("InputMap", () => {
input.dispose();
});

it("takes the lock state a worker host forwards with pointerlockchange, where no document exists (PRD-752)", () => {
const canvas = Object.assign(new EventTarget(), { requestPointerLock: () => undefined });
const input = new InputMap({ look: { pointerRelative: true } }, canvas, canvas);
input.captureMouse();
expect(input.raw.pointer.captured).toBe(true);
// Escape on the page: the host forwards the change with the page's verdict.
canvas.dispatchEvent(Object.assign(new Event("pointerlockchange"), { captured: false }));
expect(input.raw.pointer.captured).toBe(false);
input.dispose();
});

it("releases a held mouse button on a button-less move while the pointer is free", () => {
// Released outside the window, no pointerup arrives: the next move is what clears it.
const target = new EventTarget();
Expand Down
21 changes: 20 additions & 1 deletion packages/core/__tests__/platform.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
detectPlatform,
getPlatform,
hostGestureTarget,
isMobile,
isNative,
isTouchscreenAvailable,
Expand Down Expand Up @@ -133,3 +134,21 @@ describe("platform detection", () => {
expect(isWeb()).not.toBe(isNative());
});
});

describe("host gesture target (PRD-752)", () => {
it("is the page window, else the input target a worker host installed", () => {
// AudioBus waits for a gesture before it plays. A worker has no window, so without the
// platform's input target every cue would stay queued.
const inputTarget = new EventTarget();
const view = new EventTarget();
vi.stubGlobal("__tnGamePlatform", { inputTarget });
try {
vi.stubGlobal("window", undefined);
expect(hostGestureTarget()).toBe(inputTarget);
vi.stubGlobal("window", view);
expect(hostGestureTarget()).toBe(view);
} finally {
vi.unstubAllGlobals();
}
});
});
26 changes: 26 additions & 0 deletions packages/core/__tests__/renderer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,32 @@ describe("createRenderer", () => {
}
});

it("names the WebGPU failure in TN_NO_RENDERER instead of swallowing it (PRD-752)", async () => {
// A worker boot fell back because the Wasm renderer called a DOM-only canvas method; the
// boot error said only "WebGPU is not available", which hid the fix.
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { gpu: {} } });
try {
await expect(
createRenderer({
canvas: testCanvas(),
preferWebGPU: true,
webgl2Factory: () => {
throw new Error("no WebGL");
},
webgpuFactory: () => {
throw new TypeError("this.domElement.setAttribute is not a function");
},
}),
).rejects.toThrow(
/^TN_NO_RENDERER: .* WebGPU failed: this\.domElement\.setAttribute is not a function$/u,
);
} finally {
if (descriptor === undefined) Reflect.deleteProperty(globalThis, "navigator");
else Object.defineProperty(globalThis, "navigator", descriptor);
}
});

it("reports a facade and Wasm engine of two web host ABIs as that, not as a device without WebGPU", async () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { gpu: {} } });
Expand Down
33 changes: 33 additions & 0 deletions packages/core/__tests__/ui-bridge-inbound.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,36 @@ describe("ui bridge inbound global", () => {
expect(scope[UI_BRIDGE_GLOBALS.gameReceive]).toBeUndefined();
});
});

describe("ui bridge loopback over a message port (PRD-752)", () => {
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));

it("carries state and intents over an async port when the page asks for ?tnUiLoopback=1", async () => {
const scope: Record<string, unknown> = { location: { search: "?tnUiLoopback=1" } };
const ui = connectUiBridge({ end: "ui", scope });
const game = connectUiBridge({ end: "game", scope });
const seenByUi: unknown[] = [];
const seenByGame: unknown[] = [];
ui.onMessage((message) => seenByUi.push(message));
game.onMessage((message) => seenByGame.push(message));

expect([ui.transport, game.transport]).toEqual(["host", "host"]);
expect(game.hasPeer()).toBe(true);
game.post({ type: "tn:state", state: { score: 3 } });
ui.post({ type: "tn:intent", name: "pause" });
// A worker delivers a task later, never inside the post: the loopback must too.
expect([seenByUi, seenByGame]).toEqual([[], []]);
await tick();
expect(seenByUi).toEqual([{ type: "tn:state", state: { score: 3 } }]);
expect(seenByGame).toEqual([{ type: "tn:intent", name: "pause" }]);

ui.close();
game.close();
(scope.__tnUiLoopback as { close(): void }).close();
});

it("keeps the in-process broker without the flag", () => {
const scope: Record<string, unknown> = { location: { search: "" } };
expect(connectUiBridge({ end: "ui", scope }).transport).toBe("in-process");
});
});
Loading