diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b4785630e..ce33e243c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -764,11 +764,14 @@ jobs: timeout: 60_000, }); await page.keyboard.down("ArrowRight"); - await page.waitForTimeout(3_000); + const moved = await page.waitForFunction(() => { + const position = Number(document.body.innerText.match(/POSITION\n(-?\d+)/)?.[1]); + return Number.isFinite(position) && Math.abs(position) >= 1; + }, undefined, { timeout: 60_000 }).then(() => true, () => false); await page.keyboard.up("ArrowRight"); const text = await page.locator("body").innerText(); const position = Number(text.match(/POSITION\n(-?\d+)/)?.[1]); - if (!Number.isFinite(position) || Math.abs(position) < 1 || errors.length > 0) { + if (!moved || !Number.isFinite(position) || Math.abs(position) < 1 || errors.length > 0) { throw new Error(`Scaffold smoke failed: ${JSON.stringify({ text, errors })}`); } await browser.close(); @@ -804,7 +807,11 @@ jobs: # Hosted on purpose: template legs use hosted while the balancer routes builds and tests # to the local pool, avoiding one routing variable sending the whole burst to a single pool. runs-on: ubuntu-24.04 - timeout-minutes: 30 + # rain relaunches the page for each of its five GPU-free scenarios. On the hosted SwiftShader + # adapter each launch now takes about 7 minutes, against about 1 minute on develop (runs + # 38818830914 and 38824021621), so five launches exceed the 30-minute cap. Only rain gets the + # larger cap until that software launch cost is reduced; every other leg keeps 30 minutes. + timeout-minutes: ${{ matrix.template == 'rain' && 60 || 30 }} env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} TN_PLAYTEST_ALLOW_SOFTWARE: "1" diff --git a/.github/workflows/native-platforms.yml b/.github/workflows/native-platforms.yml index c182dc57c5..a21d1f0a38 100644 --- a/.github/workflows/native-platforms.yml +++ b/.github/workflows/native-platforms.yml @@ -1536,15 +1536,36 @@ jobs: # The release container is a ZIP on macOS and Windows and desktop-distribution.mjs shells # out to `zip -r -q`. A missing archiver must be a red job, never a silent pass. if [ "${{ matrix.platform }}" = "Windows" ]; then - # The community feed drops requests now and then ("package was not found" for a listed - # version, run 37079046851). Retry twice; makensis -VERSION below still fails closed. - for attempt in 1 2 3; do - choco install zip -y --no-progress && choco install nsis --version=3.11.0 -y --no-progress && break - [ "$attempt" = 3 ] || sleep 30 - done - nsis='/c/Program Files (x86)/NSIS' - "$nsis/makensis.exe" -VERSION - cygpath -w "$nsis" >> "$GITHUB_PATH" + # Pinned upstream archives keep the Windows packaging proof independent of Chocolatey's feed. + root=$(cygpath -u "$RUNNER_TEMP") + tools="$root/tn-packaging-tools" + mkdir -p "$tools/nsis" "$tools/zip" + curl -fsSL --retry 2 --retry-all-errors --retry-delay 3 --connect-timeout 15 --max-time 120 \ + -o "$tools/nsis.zip" \ + 'https://downloads.sourceforge.net/project/nsis/NSIS%203/3.11/nsis-3.11.zip' + curl -fsSL --retry 2 --retry-all-errors --retry-delay 3 --connect-timeout 15 --max-time 120 \ + -o "$tools/zip.zip" \ + 'https://fossies.org/windows/misc/zip300xn-x64.zip' + printf '%s %s\n' \ + 'c7d27f780ddb6cffb4730138cd1591e841f4b7edb155856901cdf5f214394fa1' "$tools/nsis.zip" \ + '63f32f1f797680d9c68379d36cff4373674d405527d7fa2b4bbd34edcc3370d2' "$tools/zip.zip" \ + | sha256sum --check + export TN_NSIS_ARCHIVE="$(cygpath -w "$tools/nsis.zip")" + export TN_NSIS_DEST="$(cygpath -w "$tools/nsis")" + export TN_ZIP_ARCHIVE="$(cygpath -w "$tools/zip.zip")" + export TN_ZIP_DEST="$(cygpath -w "$tools/zip")" + powershell.exe -NoLogo -NoProfile -NonInteractive -Command ' + $ErrorActionPreference = "Stop" + Expand-Archive -LiteralPath $env:TN_NSIS_ARCHIVE -DestinationPath $env:TN_NSIS_DEST -Force + Expand-Archive -LiteralPath $env:TN_ZIP_ARCHIVE -DestinationPath $env:TN_ZIP_DEST -Force + ' + nsis="$tools/nsis/nsis-3.11" + export NSISDIR="$(cygpath -w "$nsis")" + "$nsis/Bin/makensis.exe" -VERSION + printf 'NSISDIR=%s\n' "$NSISDIR" >> "$GITHUB_ENV" + cygpath -w "$nsis/Bin" >> "$GITHUB_PATH" + cygpath -w "$tools/zip" >> "$GITHUB_PATH" + PATH="$tools/zip:$PATH" # The starter sets app.icon, so desktop-distribution.mjs shells out to rcedit to embed # the icon and version strings in the PE resources. rcedit is not on the runner image. # Pin the release: `latest` would make the packaged identity move under us. diff --git a/docs/PRDs/done/PRD-616-the-startup-warm-up-builds-what-the-first-frame-draws.md b/docs/PRDs/done/PRD-616-the-startup-warm-up-builds-what-the-first-frame-draws.md new file mode 100644 index 0000000000..0cb3d5177e --- /dev/null +++ b/docs/PRDs/done/PRD-616-the-startup-warm-up-builds-what-the-first-frame-draws.md @@ -0,0 +1,137 @@ +# PRD-616 — The startup warm-up builds what the first frame draws + +**Status:** COMPLETE +**Priority:** P1 — all five acceptance criteria verified. +**Complexity:** 2 (LOW) — 6–10 implementation files (`warmup.ts`, `renderer.ts`, `game.ts`, `world-cells.ts`, `pipeline-census.ts`, the playtest census evaluator) (+2); risk override: none +**Owner:** Codex — draft PR [#509](https://github.com/ThreeNativeHQ/threenative-engine/pull/509) +**Depends on:** None. [PRD-611](../Unreal/vfx/PRD-611-a-gpu-effect-that-starts-hidden-draws-its-first-frame-with-its-pipeline-built.md) depends on this PRD: its warm render of a hidden emitter has the same call-depth miss. + +## Context + +`warmUpScene` (`packages/core/src/warmup.ts:823`) compiles the scene with three's `compileAsync`. The chunk and cell warm-ups in `packages/core/src/world-cells.ts:9530` and `:9877` use the same call. All three run at renderer call depth 0 and target the canvas. + +A game that sets an output node draws its world through `renderFrame` (`packages/core/src/renderer.ts:632`): `outputPipeline.render()` renders the authored world `PassNode` at call depth 1, into the pass's own render target. + +Three r185 keys node builds by render context: `RenderObject.getMaterialCacheKey()` adds `this.context.id` (`three/src/renderers/common/RenderObject.js:855`), and there is one render context per call depth. The pipeline cache key also carries the output color space of the target. So nothing the warm-up builds is reused by the real frame. + +The extra warm render (`renderWarmPasses`, `warmup.ts:717`) does not cover this. It calls `raw.render` at depth 0, and it is skipped when the scene has no shadow caster and no reflector (`warmup.ts:742`). + +**Measured 2026-10-14** (abyss-framework with `warmUp: {}`, its TSL scene pass and bloom; headed Chromium, NVIDIA Turing; two identical runs; instrumentation wraps three's `_createNodeBuilder`, `_createNodeBuilderState` and `_pipelines.getForRender`): + +| | Run 1 | Run 2 | +|---|---:|---:| +| Node graphs built in warm-up | 3 | 3 | +| Warmed graphs built again in frame 1 (context 0 → 2, depth 1) | 3 of 3 | 3 of 3 | +| New post-processing graphs in frame 1 | 8 | 8 | +| Node builds in frames 6–120 | 0 | 0 | +| Vertex / fragment programs / pipelines after warm-up → after frame 120 | 3/3/6 → 7/11/17 | 3/3/6 → 7/11/17 | +| First frame / median frames 60–120 (CPU ms) | 29.9 / 1.1 | 29.2 / 1.1 | + +The repeated graphs produced identical WGSL, so shader programs were reused. All 3 pipeline cache keys differed, including the output color space, so the GPU pipeline compiles were repeated too. Instrumentation (uncommitted): `.claude/worktrees/measure-35014/examples/abyss-framework/src/main.tsx`. + +Upstream [mrdoob/three.js#35014](https://github.com/mrdoob/three.js/pull/35014) (open, r187) removes `context.id` from the node-build key. It fixes the node-build half only: the pipelines still differ by render target. It also changes four files the three patch already edits. This PRD fixes the cause in the engine and leaves `packages/core/patches/three@0.185.1.patch` unchanged. + +Layer: engine. The warm-up and `renderFrame` are core mechanism. No game can reach the output pass call depth from its own code. + +## Solution + +When an output pipeline is set, the warm-up compiles against the real world pass: the same render target (color space, MRT, samples) and the same call depth as `renderFrame`. Without an output pipeline, the current path stays as it is. + +Phase 1 picks the mechanism by measurement, on stock three APIs only: + +1. **Preferred:** a `compileAsync` issued while the world pass's render target and call depth are current, so pipeline creation stays asynchronous. +2. **Fallback:** one covered `renderFrame` through the output pipeline, behind the startup cover. It is synchronous, but it moves the cost behind the cover. + +`renderWarmPasses` stops skipping the covered render for a scene with an output pipeline. The chunk and cell warm-ups in `world-cells.ts` call the same core entry point. + +Risk: a covered render through the real pipeline can advance temporal history (TRAA, motion vectors). The first visible frame must not change (AC-4). + +## Acceptance Criteria + +- [x] AC-1 [local]: On Abyss with `warmUp: {}`, warmed objects build zero nodes and render pipelines through the first five actual visible world draws. proof: verified final core `acc3affd3`: default headed NVIDIA `artifacts/prd616/ac1-visible-final` passes both complete zero ceilings and diagnostics, with positive warm census 12 nodes/12 render pipelines. The independent first-visible matrix records five actual world draws per run; source-tagged receipts are `first-visible/matrix.json`. +- [x] AC-2 [local]: First-world render CPU improves. proof: verified final core `acc3affd3`: 3.0, 3.2, 3.8 ms; median **3.2 ms**. Same-harness frozen baseline is 21.3/22.3 ms (the original brief measured 29–30 ms). This is `runtimeDiagnosticsSeries.phases.render` for the first post-warm-settlement GameLoop world callback, before sustained READY; first visible post-READY CPU is recorded separately. Auxiliary paint waits assert unchanged held frame/tick/pose/seed/controller/CPU/DOM state. Metadata: `artifacts/prd616/first-visible/matrix.json`. +- [x] AC-3 [local]: A streamed chunk creates zero nodes/render pipelines on its first draw after warm-up. proof: verified final core `acc3affd3`: two headed NVIDIA runs `world-scope-after-{1,2}` and desktop `world-scope-native-visible-final` pass complete scoped zero ceilings and diagnostics for `chunk:0:2:0`. The stock authored output pass is installed before real WorldCells attachment; warm rendering preserves the complete target scene offscreen. Positive warm counts and actual `firstDraw: 0/0` are in consumed `result.json` receipts. The final scenario waits for three resident cells, nine terrain tiles and zero live load/prewarm/admission queues before assessment. +- [x] AC-4 [local]: The first visible frame is unchanged. proof: a fresh read-only visual judge inspected all 24 canonical frames and ten supporting pixel-preserving UI zoom pairs from two headed NVIDIA `--browser-recipe webgpu` runs per arm. Final amended verdict: **NEUTRAL (PASS)** for every startup frame pair, both fully resident scoped pairs and overall; the unsupported animation rationale was withdrawn. All 24 images are [posted on PR #509](https://github.com/ThreeNativeHQ/threenative-engine/pull/509#issuecomment-6194394348). Immutable proof commit: `a8be7166d7e9525f161ad4e0cd736e7f392a8ad9` on `prd616-screenshots`. +- [x] AC-5 [local]: The desktop host passes the actual Abyss AC-1 scenario. proof: verified final core `acc3affd3`: `artifacts/prd616/native-ac5-visible-final`, rebuilt `warm-up-native.js`, complete zero ceilings and diagnostics passed, with 12 positive warm nodes/12 render pipelines. Use absolute scenario/executable/bundle/artifact paths and `--project ` for the staged manifest. + +## Unverified external follow-up + +- Physical Pixel 8 / Bayview launch-time gain belongs to PRD-360's 96-program lane and remains unmeasured; shared-device access was not granted in this run. This is outside the five written PRD-616 acceptance criteria, all of which are complete. No acceptance criterion was waived and no physical-mobile gain is claimed. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +|---|---|---|---| +| Startup warm-up targets the world pass | `defineGame` boot → `warmUpScene` (`game.ts:1229`, `game.ts:2133`) → output pipeline path | depth-0 canvas compile kept only when no output pipeline is set | AC-1 | +| Streamed chunk warm-up targets the world pass | `WorldCells` chunk attach → `#warmChunk` (`world-cells.ts:9877`), cell proxy (`world-cells.ts:9530`) | calls the same core entry point | AC-3 | +| Census boundary at warm-up settled | playtest observations `pipelineCensus` → new assertion | extends `pipeline-summary.ts` (today it splits only at first present) | AC-1, AC-3 | + +## Execution Phases + +#### Phase 1: The census sees the rebuild (red) + +**Status:** COMPLETE +**Files:** `packages/core/src/pipeline-census.ts` (warm-up-settled boundary on each event), `packages/playtest/src/runner/pipeline-summary.ts` and its scenario schema/evaluator (assertion on builds and creations after warm-up), `examples/abyss-framework/playtests/warm-up-reuse.playtest.json`. +**Implementation:** Today's census flags only `beforeFirstPresent`, and frame-1 rebuilds land before the first present. Add a warm-up-settled boundary and count node builds and pipeline creations after it, per material. The assertion fails closed when the census is absent or incomplete. + +- [x] The new scenario runs red on develop and reports 3 rebuilt graphs and 3 new pipelines. proof: the AC-1 command, plus `pnpm exec vitest run packages/playtest/__tests__/pipeline-timing.spec.ts packages/core/__tests__/pipeline-census.spec.ts` — Verified 2026-10-15 against frozen develop `1d056cec3` with the diagnostic patch retained in `artifacts/prd616/baseline-source.patch`: headed NVIDIA Turing scenario exited 1 with both complete census assertions observing exactly 3 against 0; diagnostics passed. Two first-visible capture runs independently distinguish these 3 warmed world graphs/pipelines from 8 new post graphs. Parent reran the focused census/timing suites on the repaired source: 81/81 passed. Raw command results: `baseline-scenario-red-3.log` and `phase1-current-focused.log`; capture artifacts: `artifacts/prd616/baseline-scenario-red` and `artifacts/prd616/first-visible/before-{run1,run2}`. First-world CPU measurement remains a Phase 2 task. + +#### Phase 2: The warm-up compiles against the world pass (green) + +**Status:** COMPLETE +**Files:** `packages/core/src/warmup.ts`, `packages/core/src/renderer.ts` (expose the world-pass compile entry point beside `renderFrame`), `packages/core/src/game.ts`, `packages/core/src/world-cells.ts`, `packages/core/__tests__/warmup.spec.ts`, `examples/abyss-framework/playtests/world-warm-up-reuse.playtest.json`. +**Implementation:** Measure option 1 against option 2 from Solution on the AC-1 scenario and record the choice under `## Decisions`. Route all three warm-up callers through it. Keep the no-output-pipeline path unchanged. + +- [x] AC-1 and AC-2 are green. proof: final headed startup passes complete zero ceilings and positive warm 12/12; canonical matrix AFTER 3.0/3.2/3.8 ms, median 3.2 ms. See acceptance receipts above. +- [x] AC-3 is green. proof: dedicated `world-warm-up-reuse.playtest.json`, scope `chunk:0:2:0`, browser `?world&warmup` and portable `world-warm-up-native.js`. The fixture installs the authored output pass before attaching real WorldCells. Synthetic streaming and broad world-flythrough fixtures are restored to frozen develop; the old scope `chunk:0:2:1` was absent. No ceiling or diagnostic assertion is relaxed. Correct headed NVIDIA control and native control both expose the same-frame failure before the fix; final two headed NVIDIA browser and desktop runs pass zero ceilings and diagnostics. +- [x] A renderer without an output pipeline warms exactly as before. proof: `pnpm exec vitest run packages/core/__tests__/warmup.spec.ts` — 35 tests passed locally on 2026-10-14, including the scoped output-world-pass regression. + +#### Phase 3: Prove the look and the native host + +**Status:** COMPLETE +**Files:** none expected; fixes go back to Phase 2 files. + +- [x] AC-4 judge verdict is recorded, with the images on the PR. proof: amended independent NEUTRAL (PASS) for all 24 canonical frames; [PR image comment](https://github.com/ThreeNativeHQ/threenative-engine/pull/509#issuecomment-6194394348), immutable proof commit `a8be7166d7e9525f161ad4e0cd736e7f392a8ad9`. +- [x] AC-5 is green on the desktop host. proof: actual Abyss `native-ac5-visible-final` passes complete zero ceilings, diagnostics and positive warm 12/12 on final core `acc3affd3`. + +## Decisions + +- Measured the preferred stock `PassNode.compileAsync` path on headed NVIDIA WebGPU: its completion marker ran, but complete census still observed 3 warmed node rebuilds / 3 render-pipeline creations. The real output-render fallback produced 0/0, so startup and scoped callers share that path. No Three patch change. +- Scoped warm-up renders the complete real target scene offscreen with the requested chunk attached. Stock `RendererUtils` and explicit pass state restore renderer, camera, target and authored state on success/throw. Removing visibility masking preserves dynamic terrain; the installed `PassNode` / `NodeFrame` regression requires temporary public `NodeUpdateType.RENDER`, restored afterwards, for same-frame reuse. +- Census attributes node builds independently of pipeline creation, binds scoped observations to the first completed main draw, and fails closed on absent/incomplete observations. Failed, skipped, over-deadline or GPU-drain-timeout warm work cannot publish false completion or cache success. +- Final canonical capture uses standard headed WebGPU, verified NVIDIA adapter, actual first five post-READY world renders and unchanged held-world pose/seed/tick/CPU/DOM during auxiliary paint waits. CPU is the first post-warm-settlement GameLoop render phase before READY, reported separately from visible-frame timing. Final `first-visible/matrix.json`: BEFORE 21.3/22.3 ms; AFTER 3.0/3.2/3.8 ms, median 3.2 ms. Independent paired-frame and pixel-preserving UI-zoom review confirms static UI and nine-tile terrain remain complete; no UI/CSS change was needed. +- Scope remains the five written acceptance criteria: browser startup CPU/reuse, streamed reuse, visual parity and actual Abyss desktop reuse. Physical-mobile launch-time measurement remains an explicit external PRD-360 follow-up. The user subsequently authorized repair, push and the guarded merge queue; the earlier draft-only restriction is superseded. + +## Verification notes + +- Final executable source `acc3affd3`: root `pnpm test` exited 0, **776 files passed / 3 skipped; 10,605 tests passed / 20 skipped**. Root `pnpm typecheck && pnpm lint`, `pnpm check:docs`, `pnpm budgets` and `git diff --check` exited 0. Existing lint/LOC warnings are nonfatal. Raw receipts: `full-unit-canonical-final.log`, `canonical-type-lint-local.log`, `canonical-docs-budgets.log`; focused renderer/constraints/world-capture/quality coverage passed 65/65. +- Final browser startup (`ac1-visible-final`) and actual Abyss desktop (`native-ac5-visible-final`) pass complete zero ceilings and diagnostics with positive 12-node/12-pipeline warm census. Strict scoped browser runs (`world-canonical-after-{1,2}.log`) and desktop (`world-native-ring-final.log`) pass `chunk:0:2:0` first-draw 0/0, with all three cells / nine tiles resident and live queues clear. Rebuilt native bundles use the same final core; native claims cover desktop only. +- Reproduction scripts, exact diagnostic baseline/scenario patches, canonical matrix, consumed runtime receipts and images are [published at the immutable proof commit](https://github.com/ThreeNativeHQ/threenative-engine/tree/a8be7166d7e9525f161ad4e0cd736e7f392a8ad9/prd616). Earlier matrix files and partial-residency captures are not final evidence. Independent amended verdict is recorded in the PR image comment. +- Laptop capacity refused two final offload attempts at 98/97°C (exit 75); these were capacity refusals, not failed tests. Final typecheck/lint ran locally and passed. Remote CI and physical Android/iOS remain unverified; no remote-CI success is inferred from pre-push drift checks. + +### Phase 4: Repair independent review findings + +**Status:** COMPLETE +**Scope:** shared renderer refresh lifetime and repeated-label playtest census aggregation; existing bloom world fixture, focused regressions and browser/desktop proof. No new API, dependency or Three patch. + +- [x] Nested output nodes render the world once per outer scoped/main draw, including same-frame and throwing paths. proof: four real installed PassNode/NodeFrame variants failed before the repair and pass after a one-shot public updateBefore guard; renderer restores authored method ownership/update mode in finally. Focused renderer/warmup/constraints/timing suites pass 131/131. Bloom world fixture passes two headed NVIDIA browser runs and desktop with complete 0/0 scoped ceilings, three cells/nine terrain tiles and no diagnostics. Fresh independent visual judge: pair 1 NEUTRAL, pair 2 NEUTRAL, overall NEUTRAL (PASS). [Immutable repair proof](https://github.com/ThreeNativeHQ/threenative-engine/tree/d68a9b20e85ba0161f115cab536409f7b6c0e762/prd616); Opus 5.5/high independently confirms FIXED, no concrete repair regression. +- [x] Every repeated scope admission contributes to the ceiling, and any missing first draw fails closed. proof: three repeated-label regressions failed before repair; timing/cache/evaluator suites now pass 64/64 and playtest typecheck passes. Each metric retains its maximum across admissions; observed requires every firstDraw. Opus 5.5/high independently confirms FIXED. Missing draws remain unobservable even if another admission passes. +- [x] Repair verification is complete. proof: root typecheck/lint/docs/budgets pass; package-test phase passes after rebuilding six native test prerequisites removed during cleanup. Root unit run: 775 files passed / 3 skipped, 10,565 tests passed / 20 skipped, but one worker exited unexpectedly in assets/compile.spec.ts; isolated recovery passes that complete file, 73/73. Every configured test file is covered across these runs; **a clean final full-wrapper exit is not claimed**. Both focused regression sets, browser/desktop bloom scenarios, independent visual judge and Opus 5.5/high repair review pass. The pre-push drift hook passes after package builds finish. Delivery: push this repair and re-enable the guarded merge queue under the user's explicit instruction; remote CI remains authoritative and unverified here. + +Repair gate notes: the first full attempt stopped on missing native test executables removed during authorized cleanup; four V8/two QuickJS targets were rebuilt and the native/package checks then passed. A concurrent drift-hook attempt raced the assets package rebuild and could not resolve its entry; its sequential retry passed. Laptop full-wrapper execution could not read HEAD in its empty Git lane, and the later unit attempt was refused at 100°C; local execution supplied the checks above. No product assertion failed in the broad unit run. The unexplained worker exit is disclosed, rather than treated as a clean whole-suite pass. + +### Phase 5: Repair the browser CI replay failure + +**Scope:** existing replay proof hook and Playwright project only. Complexity: 1 → LOW; risk override: none. Await the initial startup before restarting for recording, and run the replay browser headed under CI's existing Xvfb wrapper. Preserve every golden movement assertion and all 1,800 ticks. + +- [x] The failed golden replay test passes locally. proof: CI run `38776074371` failed all three attempts allocating the 90,000-particle storage buffer. Local headless reproduction exposed SwiftShader's `SharedImageBackingFactory` failure during the newly enabled output warm render, destroying the device; a headed control also exposed stop during pending startup. With both repairs, both unchanged browser replay tests pass under `sh scripts/xvfb.sh` (2/2, 1.9 minutes), and headed NVIDIA records/replays all 1,800 ticks identically. Root typecheck/lint and docs pass; documentation/CI contracts pass 238/238. Existing lint warnings remain nonfatal. No assertion, particle count or warm-up coverage is reduced. +- [x] Fresh Opus inspection covers the canonical real Abyss startup frames. proof: fresh read-only Opus/high inspected all 20 canonical startup PNGs from immutable proof `a8be7166d7e9525f161ad4e0cd736e7f392a8ad9`: all ten paired frames and overall **NEUTRAL (PASS)** for appearance preservation, including glow, UI, particles, lighting, camera and framing. It independently inspected both repair terrain pairs from `d68a9b20e85ba0161f115cab536409f7b6c0e762`, also NEUTRAL. The intense terrain glow is authored normal-material terrain plus fixture bloom, not a BEFORE/AFTER change; saturated probe pixels alone cannot establish real Abyss parity. Real Abyss glows are authored additive plankton, bright lamp/pearls and bloom. Startup images prove the attract screen, not later combat or input responsiveness. + +#### Phase 6: Build-exit regression lock for the audio worker pool + +**Status:** COMPLETE +**Scope:** `packages/create-threenative/__tests__/build.spec.ts` only. Keeps the unchanged 60s exit assertion and locks the audio worker pool reference with a persistent output assertion. + +- [x] The audio-only build-exit regression is locked. proof: minimal audio-only fixture accepted (only `pickup.wav` retained); unchanged 60s exit assertion remains, followed by a persistent assertion for `cost pass audio: ran on 1 input`; original build took 28.56s locally and timed out at 60s remotely; focused build-exit test passed in 1.94s (case 853ms); the previous negative control disabling `pool?.dispose()` in the ignored assets dist timed out and dist byte restoration was verified; its output log alone could not distinguish contention from a leak. Browser remote 4/4 passed in run 38812720656; remaining remote CI is pending. + +Delivery pending: the repaired candidate must pass remote CI and merge through the required squash merge queue. Required `ci-required` remains authoritative; no bypass. diff --git a/docs/benchmark/LOC.md b/docs/benchmark/LOC.md index bcd1a3fffe..b65993c344 100644 --- a/docs/benchmark/LOC.md +++ b/docs/benchmark/LOC.md @@ -4,17 +4,17 @@ This file is generated by `pnpm tsx scripts/count-loc.ts`. CI checks the generated table, so do not edit its numbers by hand. -**LOC measurement:** Raw LOC is the physical source count. Normalised LOC is counted after both arms are formatted in memory with this repository's Biome configuration. Framework plumbing is **53.6%** of the control's — the plumbing column is the addressable surface named in `CHARTER.md` §3, because the game column is the model's work in both arms. The total ratio is **93.2%**. This table is a **regression ratchet** against frozen hand-written source, not the win condition; the win condition is `pnpm sweep:pair`, agent against agent. See `docs/benchmark/PROTOCOL.md`. +**LOC measurement:** Raw LOC is the physical source count. Normalised LOC is counted after both arms are formatted in memory with this repository's Biome configuration. Framework plumbing is **52.9%** of the control's — the plumbing column is the addressable surface named in `CHARTER.md` §3, because the game column is the model's work in both arms. The total ratio is **93.2%**. This table is a **regression ratchet** against frozen hand-written source, not the win condition; the win condition is `pnpm sweep:pair`, agent against agent. See `docs/benchmark/PROTOCOL.md`. | Arm | File | Raw LOC | Normalised LOC | Plumbing LOC | Game LOC | Vanilla wins? | |---|---|---:|---:|---:|---:|---| | Vanilla | `examples/abyss-vanilla/src/main.js` | 410 | 473 | 138 | 335 | — | -| ThreeNative | `examples/abyss-framework/src/main.tsx` | 54 | 54 | 15 | 39 | — | +| ThreeNative | `examples/abyss-framework/src/main.tsx` | 54 | 54 | 14 | 40 | — | | ThreeNative | `examples/abyss-framework/src/render/lighting.ts` | 10 | 10 | 2 | 8 | — | | ThreeNative | `examples/abyss-framework/src/render/postprocessing.ts` | 9 | 9 | 4 | 5 | — | | ThreeNative | `examples/abyss-framework/src/scenes/Abyss.ts` | 368 | 368 | 53 | 315 | — | | **Vanilla** | **control total** | **410** | **473** | **138** | **335** | — | -| **ThreeNative** | **framework total** | **441** | **441** | **74** | **367** | — | +| **ThreeNative** | **framework total** | **441** | **441** | **73** | **368** | — | **Static result:** framework wins. Vanilla wins are shown per summary row; the framework example remains the independently playable proof scene until the manual Abyss parity run is completed. diff --git a/examples/abyss-framework/playtests/warm-up-reuse.playtest.json b/examples/abyss-framework/playtests/warm-up-reuse.playtest.json new file mode 100644 index 0000000000..3e8a87046c --- /dev/null +++ b/examples/abyss-framework/playtests/warm-up-reuse.playtest.json @@ -0,0 +1,28 @@ +{ + "name": "abyss-framework-warm-up-reuse", + "schemaVersion": 1, + "target": "web", + "viewport": { "width": 1280, "height": 720 }, + "warmupFrames": 0, + "subject": "player", + "artifacts": { "screenshots": "before-after" }, + "steps": [ + { "label": "frame-1", "screenshot": "frame-1", "waitTicks": 1 }, + { "label": "frame-2", "screenshot": "frame-2", "waitTicks": 1 }, + { "label": "frame-3", "screenshot": "frame-3", "waitTicks": 1 }, + { "label": "frame-4", "screenshot": "frame-4", "waitTicks": 1 }, + { "label": "frame-5", "screenshot": "frame-5", "waitTicks": 1 } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "pipelineCensus": { + "maxNodeBuildsAfterWarmUpSettled": 0, + "maxRenderPipelineCreationsAfterWarmUpSettled": 0 + } + } +} diff --git a/examples/abyss-framework/playtests/world-warm-up-reuse.playtest.json b/examples/abyss-framework/playtests/world-warm-up-reuse.playtest.json new file mode 100644 index 0000000000..f1d9f63ccb --- /dev/null +++ b/examples/abyss-framework/playtests/world-warm-up-reuse.playtest.json @@ -0,0 +1,78 @@ +{ + "schemaVersion": 1, + "name": "authored-world-pass-streamed-chunk-warm-up-reuse", + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 10, + "steps": [ + { + "label": "settled-loads", + "timeoutMs": 20000, + "waitForResource": { + "id": "state", + "path": "stats.loadsInFlight", + "equals": 0 + } + }, + { + "label": "no-queued-loads", + "timeoutMs": 20000, + "waitForResource": { + "id": "state", + "path": "stats.loadsQueued", + "equals": 0 + } + }, + { + "label": "settled-prewarm", + "timeoutMs": 20000, + "waitForResource": { + "id": "state", + "path": "stats.pendingPrewarm", + "equals": 0 + } + }, + { + "label": "complete-terrain-ring", + "timeoutMs": 20000, + "waitForResource": { + "id": "state", + "path": "stats.residentTiles", + "equals": 9 + } + }, + { + "label": "admitted-world", + "timeoutMs": 20000, + "waitForResource": { + "id": "state", + "path": "stats.admission.backlog", + "equals": 0 + } + } + ], + "assert": { + "components": [ + { + "entity": "world", + "component": "residentCells", + "gte": 3, + "allowTrivial": "All three authored cells must be resident before comparing the fixed-camera world." + } + ], + "pipelineCensus": { + "warmUpScope": "chunk:0:2:0", + "maxNodeBuildsAfterWarmUpScope": 0, + "maxRenderPipelineCreationsAfterWarmUpScope": 0 + }, + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + } + } +} diff --git a/examples/abyss-framework/src/main.tsx b/examples/abyss-framework/src/main.tsx index 206946b4da..9a32d91281 100644 --- a/examples/abyss-framework/src/main.tsx +++ b/examples/abyss-framework/src/main.tsx @@ -4,7 +4,7 @@ import { acceptHotUpdate } from "@threenative/core/hot"; import { playtest } from "@threenative/core/playtest"; import "./style.css"; import { createRoot } from "react-dom/client"; -import { installReplayProof } from "./replay-proof.js"; +import { installReplayProof, installStartupProof } from "./replay-proof.js"; import { Abyss, type AbyssState } from "./scenes/Abyss.js"; import { ViewportProbe } from "./scenes/ViewportProbe.js"; import { App } from "./ui/App.js"; @@ -13,12 +13,11 @@ const viewportProbe = new URLSearchParams(globalThis.location.search).has("viewp const replayPlugin = replay(); let gameRuntime: IGamePluginRuntime | undefined; const replayRuntime: IGamePluginHooks = { - setup: (_ctx, runtime) => { + setup: (ctx, runtime) => { gameRuntime = runtime; - return undefined; + return installStartupProof(ctx, runtime); }, }; - const game = defineGame({ camera: { far: 7_000, near: 5_000, projection: "orthogonal", size: 520 }, inputTarget: window, @@ -38,6 +37,7 @@ const game = defineGame({ scenes: { play: viewportProbe ? ViewportProbe : Abyss }, seed: 90210, start: "play", + warmUp: {}, }); if (import.meta.env.DEV) { diff --git a/examples/abyss-framework/src/replay-proof.ts b/examples/abyss-framework/src/replay-proof.ts index ae4a8ecb1c..eefee61b52 100644 --- a/examples/abyss-framework/src/replay-proof.ts +++ b/examples/abyss-framework/src/replay-proof.ts @@ -1,8 +1,14 @@ import { createReplayDriver } from "@threenative/core"; -import type { IGame, IGamePluginRuntime, Recording } from "@threenative/core"; +import type { ICtx, IGame, IGamePluginRuntime, Recording } from "@threenative/core"; import type { AbyssState } from "./scenes/Abyss.js"; type ReplayPlugin = { readonly recording: Recording | undefined }; +type StartupProofArm = "before" | "after"; +type StartupProofController = { + holdCurrentCallback(): void; + paint(callback: () => void): void; +}; +type StartupPipelineCensus = ReturnType>; type ReplayProofStep = { readonly holdTicks?: number; @@ -13,6 +19,148 @@ type ReplayProofStep = { type ReplayTrace = { position: [number, number, number]; score: number }[]; +function parseStartupProof(value: string | null): StartupProofArm | undefined { + return value === "before" || value === "after" ? value : undefined; +} + +function isPositiveFinite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function publishFirstWorldCpu( + proofHost: Record, + startupProof: StartupProofArm, + runtime: IGamePluginRuntime, + previousPhases: unknown, + firstWorldReady: boolean, + firstWorldTick: number, + censusBeforeDraw: StartupPipelineCensus | undefined, +): void { + const phases = runtime.runtimeDiagnosticsSeries?.().at(-1)?.phases; + const renderMs = phases?.render; + const census = runtime.pipelineCensus?.(); + const beforeCensusError = + censusBeforeDraw === undefined + ? "TN_STARTUP_FIRST_WORLD_CPU_CENSUS_BEFORE_DRAW_MISSING" + : censusBeforeDraw.warmUpSettled === undefined + ? "TN_STARTUP_FIRST_WORLD_CPU_WARM_BOUNDARY_MISSING" + : undefined; + const afterCensusError = + census === undefined + ? "TN_STARTUP_FIRST_WORLD_CPU_CENSUS_AFTER_DRAW_MISSING" + : census.warmUpSettled === undefined + ? "TN_STARTUP_FIRST_WORLD_CPU_WARM_BOUNDARY_AFTER_DRAW_MISSING" + : undefined; + const errors = [ + runtime.enableRuntimeDiagnostics === undefined + ? "TN_STARTUP_FIRST_WORLD_CPU_DIAGNOSTICS_ENABLE_MISSING" + : undefined, + runtime.runtimeDiagnosticsSeries === undefined + ? "TN_STARTUP_FIRST_WORLD_CPU_DIAGNOSTICS_SERIES_MISSING" + : undefined, + phases === undefined || phases === previousPhases + ? "TN_STARTUP_FIRST_WORLD_CPU_PHASE_STALE" + : undefined, + isPositiveFinite(renderMs) + ? undefined + : `TN_STARTUP_FIRST_WORLD_CPU_RENDER_INVALID:${String(renderMs)}`, + beforeCensusError, + afterCensusError, + ].filter((error): error is string => error !== undefined); + const record = { + arm: startupProof, + census, + ready: firstWorldReady, + tick: firstWorldTick, + ...(isPositiveFinite(renderMs) ? { renderMs } : {}), + ...(errors.length === 0 ? {} : { error: errors.join(";") }), + }; + Object.assign(proofHost, { __TN_STARTUP_FIRST_WORLD_CPU__: record }); + const marker = + errors.length === 0 ? "TN_STARTUP_FIRST_WORLD_CPU" : "TN_STARTUP_FIRST_WORLD_CPU_ERROR"; + console.log(`${marker}:${JSON.stringify(record)}`); +} + +export function installStartupProof( + ctx: Pick, "beforeRender">, + runtime: IGamePluginRuntime | undefined, +): (() => void) | undefined { + const startupProof = parseStartupProof( + new URLSearchParams(globalThis.location.search).get("startupProof"), + ); + if (startupProof === undefined || runtime === undefined) return undefined; + const label = document.createElement("div"); + label.dataset.tnStartupProof = startupProof; + label.style.cssText = + "position:fixed;z-index:2147483647;left:0;bottom:0;padding:8px 16px;background:#000;color:#fff;font:700 32px monospace;line-height:1;pointer-events:none"; + document.body.append(label); + runtime.enableRuntimeDiagnostics?.(); + let firstWorldDrawObserved = false; + let visibleWorldFrame = 0; + const unregister = ctx.beforeRender(() => { + const proofHost = globalThis as Record; + const census = runtime.pipelineCensus?.(); + + if (!firstWorldDrawObserved && census?.warmUpSettled !== undefined) { + firstWorldDrawObserved = true; + const previousFrameBudget = runtime.runtimeDiagnosticsSeries?.().at(-1); + const previousPhases = previousFrameBudget?.phases; + const firstWorldReady = proofHost.__TN_STARTUP_READY__ === true; + const firstWorldTick = runtime.tick(); + const censusBeforeDraw = census; + queueMicrotask(() => + publishFirstWorldCpu( + proofHost, + startupProof, + runtime, + previousPhases, + firstWorldReady, + firstWorldTick, + censusBeforeDraw, + ), + ); + } + + if (proofHost.__TN_STARTUP_READY__ !== true || visibleWorldFrame >= 5) return; + const controller = proofHost.__TN_STARTUP_PROOF_CONTROLLER__ as + | StartupProofController + | undefined; + if (controller === undefined) throw new Error("TN_STARTUP_PROOF_CONTROLLER_MISSING"); + const previousFrameBudget = runtime.runtimeDiagnosticsSeries?.().at(-1); + const previousPhases = previousFrameBudget?.phases; + visibleWorldFrame += 1; + controller.holdCurrentCallback(); + label.textContent = `${startupProof.toUpperCase()} FIRST-VISIBLE-WORLD FRAME ${String(visibleWorldFrame)}`; + const frame = visibleWorldFrame; + queueMicrotask(() => { + const series = runtime.runtimeDiagnosticsSeries?.() ?? []; + const frameBudget = series.at(-1); + const census = runtime.pipelineCensus?.(); + if (frameBudget === undefined) + throw new Error(`TN_STARTUP_PROOF_FRAME_BUDGET_MISSING:${String(frame)}`); + if (frameBudget.phases === undefined || frameBudget.phases === previousPhases) + throw new Error(`TN_STARTUP_PROOF_FRAME_BUDGET_STALE:${String(frame)}`); + if (census === undefined) throw new Error(`TN_STARTUP_PROOF_CENSUS_MISSING:${String(frame)}`); + const record = { + arm: startupProof, + census, + frame, + frameBudget, + readyMs: runtime.startupTimeline?.().readyMs, + tick: runtime.tick(), + }; + controller.paint(() => { + Object.assign(proofHost, { __TN_STARTUP_PROOF_RECORD__: { ...record, painted: true } }); + console.log(`TN_STARTUP_VISIBLE_WORLD:${JSON.stringify({ ...record, painted: true })}`); + }); + }); + }); + return () => { + unregister(); + label.remove(); + }; +} + function dispatchKey(target: EventTarget, type: "keydown" | "keyup", code: string): void { target.dispatchEvent(Object.assign(new Event(type), { code })); } @@ -74,6 +222,7 @@ export function installReplayProof( return driver(runtime); }, recordAndReplay: async (steps: readonly ReplayProofStep[]) => { + await game.start(); game.stop(); await game.start(); const runtime = getRuntime(); diff --git a/examples/abyss-framework/src/scenes/WorldProbe.ts b/examples/abyss-framework/src/scenes/WorldProbe.ts index f8891f6758..8fd46e197b 100644 --- a/examples/abyss-framework/src/scenes/WorldProbe.ts +++ b/examples/abyss-framework/src/scenes/WorldProbe.ts @@ -8,6 +8,8 @@ import { MeshStandardMaterial, SphereGeometry, } from "three"; +import { bloom } from "three/addons/tsl/display/BloomNode.js"; +import { pass } from "three/tsl"; import { ShadowPartialProbe } from "../probes/shadowPartial.js"; import { terrainMaterial } from "../render/terrain.js"; import { worldFog } from "../render/worldFog.js"; @@ -340,3 +342,33 @@ export class WorldProbe extends Scene { }; } } + +export class WorldWarmUpProbe extends WorldProbe { + #worldPass: ReturnType | undefined; + #outputInstallation: { dispose(): void } | undefined; + + override enter(ctx: WorldCtx): void { + const worldPass = pass(ctx.scene, ctx.camera); + const colour = worldPass.getTextureNode(); + const installation = ctx.renderer.setOutputNode( + colour.add(bloom(colour, 0.9, 0.5, 0.25)), + worldPass, + ); + if (ctx.renderer.warmUpWorldPass === undefined) + throw new Error("WorldWarmUpProbe requires renderer.warmUpWorldPass."); + this.#worldPass = worldPass; + if (installation !== undefined) this.#outputInstallation = installation; + console.info("[WorldWarmUpProbe] installed world output pass"); + super.enter(ctx); + } + + override exit(ctx: WorldCtx): void { + super.exit(ctx); + this.#outputInstallation?.dispose(); + this.#outputInstallation = undefined; + const worldPass = this.#worldPass; + this.#worldPass = undefined; + ctx.renderer.clearOutputNode?.(worldPass); + worldPass?.dispose(); + } +} diff --git a/examples/abyss-framework/src/warm-up-native.ts b/examples/abyss-framework/src/warm-up-native.ts new file mode 100644 index 0000000000..c3e650218c --- /dev/null +++ b/examples/abyss-framework/src/warm-up-native.ts @@ -0,0 +1,26 @@ +import { defineGame } from "@threenative/core"; +import { playtest } from "@threenative/core/playtest"; +import { Abyss, type AbyssState } from "./scenes/Abyss.js"; + +const game = defineGame({ + camera: { far: 7_000, near: 5_000, projection: "orthogonal", size: 520 }, + input: { + move: { + down: ["KeyS", "ArrowDown"], + left: ["KeyA", "ArrowLeft"], + right: ["KeyD", "ArrowRight"], + up: ["KeyW", "ArrowUp"], + }, + pulse: { keys: ["Space"], pointer: true }, + start: { keys: ["Enter"] }, + zoom: { gamepadAxes: [3], pinch: true, scroll: true }, + }, + plugins: [playtest()], + renderer: { preferWebGPU: true }, + scenes: { play: Abyss }, + seed: 90210, + start: "play", + warmUp: {}, +}); + +export default game; diff --git a/examples/abyss-framework/src/world-main.ts b/examples/abyss-framework/src/world-main.ts index ddff876d7c..e6ee8947bd 100644 --- a/examples/abyss-framework/src/world-main.ts +++ b/examples/abyss-framework/src/world-main.ts @@ -1,7 +1,11 @@ import { defineGame } from "@threenative/core"; import { playtest } from "@threenative/core/playtest"; import "./style.css"; -import { WorldProbe, type WorldState } from "./scenes/WorldProbe.js"; +import { WorldProbe, type WorldState, WorldWarmUpProbe } from "./scenes/WorldProbe.js"; + +const worldScene = new URLSearchParams(globalThis.location.search).has("warmup") + ? WorldWarmUpProbe + : WorldProbe; const game = defineGame({ camera: { far: 1_000, fov: 60, near: 0.1, projection: "perspective" }, @@ -23,7 +27,7 @@ const game = defineGame({ }, plugins: [playtest()], renderer: { preferWebGPU: true }, - scenes: { world: WorldProbe }, + scenes: { world: worldScene }, seed: 20260925, start: "world", }); diff --git a/examples/abyss-framework/src/world-warm-up-native.ts b/examples/abyss-framework/src/world-warm-up-native.ts new file mode 100644 index 0000000000..6ba2f29adc --- /dev/null +++ b/examples/abyss-framework/src/world-warm-up-native.ts @@ -0,0 +1,29 @@ +import { defineGame } from "@threenative/core"; +import { playtest } from "@threenative/core/playtest"; +import { type WorldState, WorldWarmUpProbe } from "./scenes/WorldProbe.js"; + +const game = defineGame({ + camera: { far: 1_000, fov: 60, near: 0.1, projection: "perspective" }, + input: { fly: { keys: ["KeyF"] } }, + initialState: { + evictions: 0, + failures: 0, + instances: 0, + loadsInFlight: 0, + maxResidentCells: 0, + residenceChanges: 0, + residentCells: 0, + shadowDeferrals: 0, + shadowFrame: 0, + shadowLevels: 0, + shadowPartialDone: 0, + shadowRendered: 0, + }, + plugins: [playtest()], + renderer: { preferWebGPU: true }, + scenes: { world: WorldWarmUpProbe }, + seed: 20260925, + start: "world", +}); + +export default game; diff --git a/examples/avbd-rigging/__tests__/benchmark.spec.ts b/examples/avbd-rigging/__tests__/benchmark.spec.ts index 8af666ca8f..c04ab18792 100644 --- a/examples/avbd-rigging/__tests__/benchmark.spec.ts +++ b/examples/avbd-rigging/__tests__/benchmark.spec.ts @@ -14,6 +14,7 @@ const census = { uniquePipelines: 2, recordedEvents: 2, droppedEvents: 0, + nodeBuilds: 0, deviceCreations: 2, directCreations: 0, }, diff --git a/examples/avbd-rigging/__tests__/comparison.spec.ts b/examples/avbd-rigging/__tests__/comparison.spec.ts index 961c3238f8..6d6d16b74f 100644 --- a/examples/avbd-rigging/__tests__/comparison.spec.ts +++ b/examples/avbd-rigging/__tests__/comparison.spec.ts @@ -16,6 +16,7 @@ function fixture(cpu = 0.2, springLower = 2.8) { uniquePipelines: 2, recordedEvents: 2, droppedEvents: 0, + nodeBuilds: 0, deviceCreations: 2, directCreations: 0, }, diff --git a/packages/core/__tests__/game.spec.ts b/packages/core/__tests__/game.spec.ts index 32f115011c..b53a2eba88 100644 --- a/packages/core/__tests__/game.spec.ts +++ b/packages/core/__tests__/game.spec.ts @@ -854,7 +854,7 @@ describe("IGame", () => { expect(ctx.startup.progress).toBeLessThan(1); // still not ready after one frame expect(ctx.canvasLayer.opaque).toBe(true); - await Promise.resolve(); + for (let index = 0; index < 4; index += 1) await Promise.resolve(); const compileAsync = (game.ctx?.renderer.raw as { compileAsync: ReturnType }) .compileAsync; expect(compileAsync).toHaveBeenCalledWith(ctx.scene, ctx.camera); diff --git a/packages/core/__tests__/pipeline-census.spec.ts b/packages/core/__tests__/pipeline-census.spec.ts index 0d86138236..0ed689276d 100644 --- a/packages/core/__tests__/pipeline-census.spec.ts +++ b/packages/core/__tests__/pipeline-census.spec.ts @@ -1,3 +1,4 @@ +import { BoxGeometry, Mesh, MeshBasicMaterial } from "three"; import { describe, expect, it } from "vitest"; import { type IPipelineCensus, @@ -31,7 +32,7 @@ interface IStubDevice { interface IStubBackend { device: IStubDevice; createProgram: (program: Record) => void; - createRenderPipeline: (renderObject: Record, promises: unknown) => void; + createRenderPipeline: (renderObject?: Record, promises?: unknown) => void; createComputePipeline: (pipeline: Record) => void; get: (key: object) => Record; } @@ -84,7 +85,8 @@ function webgpuStub(): { backend: IStubBackend; device: IStubDevice; pipelines: device, get, createProgram, - createRenderPipeline: (renderObject: Record, promises: unknown) => { + createRenderPipeline: (renderObject, promises) => { + if (renderObject === undefined) return; const pipeline = renderObject.pipeline as Record; const descriptor = { label: "renderPipeline", @@ -488,6 +490,7 @@ describe("pipeline census", () => { }; const raw = { backend, + _nodes: { getForRender: (_renderObject: unknown) => ({}) }, renderObject: (...args: unknown[]) => { backend.createRenderPipeline?.( args[0] as Record, @@ -563,6 +566,298 @@ describe("pipeline census", () => { ).toBe(true); }); + it("counts distinct node-builder states on each side of the warm-up boundary", () => { + let now = 0; + const { backend, pipelines } = webgpuStub(); + const nodes = { + getForRender: (renderObject: Record) => renderObject.nodeBuilderState, + }; + const raw = { + backend, + _nodes: nodes, + renderObject: (...args: unknown[]) => { + const object = args[0] as Record; + nodes.getForRender(object); + pipelines.getForRender(object, args[1]); + }, + }; + const census = createPipelineCensus({ kind: "webgpu", now: () => now }); + census.installRenderer(raw); + + const firstState = {}; + const first = { + ...renderObject( + { + vertexProgram: { code: source("first vertex") }, + fragmentProgram: { code: source("first fragment") }, + }, + { id: 1 }, + { id: 2 }, + ), + nodeBuilderState: firstState, + }; + raw.renderObject(first, null); + census.warmUpSettled(); + now += 1; + + const secondState = {}; + const second = { + ...renderObject( + { + vertexProgram: { code: source("second vertex") }, + fragmentProgram: { code: source("second fragment") }, + }, + { id: 3 }, + { id: 4 }, + ), + nodeBuilderState: secondState, + }; + raw.renderObject(second, null); + census.warmUpScopeSettled("chunk:0"); + now += 1; + raw.renderObject(second, null); + + const report = census.snapshot(); + expect(report.counts.nodeBuilds).toBe(2); + expect(report.warmUpSettled).toMatchObject({ + eventsSettled: 1, + nodeBuilds: 1, + renderPipelineCreations: 1, + }); + expect(report.warmUpScopes).toMatchObject([ + { label: "chunk:0", eventsSettled: 2, nodeBuilds: 2, renderPipelineCreations: 2 }, + ]); + expect( + report.events.map(({ nodeBuilds, beforeWarmUpSettled, afterWarmUpScope }) => ({ + nodeBuilds, + beforeWarmUpSettled, + ...(afterWarmUpScope === undefined ? {} : { afterWarmUpScope }), + })), + ).toEqual([ + { nodeBuilds: 1, beforeWarmUpSettled: true }, + { nodeBuilds: 1, beforeWarmUpSettled: false }, + { nodeBuilds: 0, beforeWarmUpSettled: false, afterWarmUpScope: "chunk:0" }, + ]); + expect(report.nodeBuilds?.map(({ beforeWarmUpSettled }) => beforeWarmUpSettled)).toEqual([ + true, + false, + ]); + }); + + it("observes the renderer-owned node seam for a real Mesh instead of patching the Mesh", () => { + const { backend } = webgpuStub(); + const geometry = new BoxGeometry(); + const material = new MeshBasicMaterial(); + const mesh = new Mesh(geometry, material); + const state = {}; + const internalRenderObject = { + object: mesh, + material, + pipeline: { + vertexProgram: { code: source("mesh vertex") }, + fragmentProgram: { code: source("mesh fragment") }, + }, + }; + const originalGetForRender = (_renderObject: unknown): object => state; + const nodes = { getForRender: originalGetForRender }; + const originalRenderObject = (..._args: unknown[]): void => { + nodes.getForRender(internalRenderObject); + backend.createRenderPipeline?.(internalRenderObject, null); + }; + const raw = { backend, _nodes: nodes, renderObject: originalRenderObject }; + const census = createPipelineCensus({ kind: "webgpu" }); + census.installRenderer(raw); + + raw.renderObject(mesh, {}, {}, geometry, material, null, null, null, "main"); + + const report = census.snapshot(); + expect(report.complete).toBe(true); + expect(report.counts.nodeBuilds).toBe(1); + expect(report.events[0]?.nodeBuilds).toBe(1); + expect("getNodeBuilderState" in mesh).toBe(false); + + census.dispose(); + expect(nodes.getForRender).toBe(originalGetForRender); + expect(raw.renderObject).toBe(originalRenderObject); + geometry.dispose(); + material.dispose(); + }); + + it("binds a scope to its target's completed main frame, including node rebuilds without a pipeline event", () => { + const { backend } = webgpuStub(); + const target = {}; + const otherTarget = {}; + const world = {}; + const material = { id: 7, name: "chunk-material" }; + const states = [{}, {}]; + let stateIndex = 0; + let createPipeline = true; + const nodes = { + getForRender: (_renderObject: unknown) => states[stateIndex], + }; + const pipeline = { + vertexProgram: { code: source("scope vertex") }, + fragmentProgram: { code: source("scope fragment") }, + }; + const raw = { + backend, + _nodes: nodes, + renderObject: (object: unknown, scene: unknown, _camera: unknown, ...rest: unknown[]) => { + const internal = { object, material, pipeline }; + nodes.getForRender(internal); + if (createPipeline) backend.createRenderPipeline?.(internal, null); + void scene; + void rest; + }, + }; + const census = createPipelineCensus({ kind: "webgpu" }); + census.installRenderer(raw); + + raw.renderObject(target, world, {}, {}, material, null, null, null, "main"); + census.warmUpScopeSettled("scope-a", target); + census.warmUpScopeSettled("scope-b", otherTarget); + stateIndex = 1; + createPipeline = false; + census.beginWorldFrame(world); + raw.renderObject(target, world, {}, {}, material, null, null, null, "main"); + census.endWorldFrame(world, true); + census.firstPresent(); + + const report = census.snapshot(); + expect(report.counts.nodeBuilds).toBe(2); + expect(report.warmUpScopes).toEqual([ + expect.objectContaining({ + label: "scope-a", + firstDraw: { + nodeBuilds: 1, + renderPipelineCreations: 0, + }, + }), + expect.objectContaining({ label: "scope-b" }), + ]); + expect(report.warmUpScopes?.[1]?.firstDraw).toBeUndefined(); + expect(report.events).toHaveLength(1); + expect(report.events[0]?.afterWarmUpScope).toBeUndefined(); + expect(report.nodeBuilds).toHaveLength(2); + expect(report.nodeBuilds?.[1]?.provenance.material?.name).toBe("chunk-material"); + }); + + it("records a completed zero-work target frame instead of passing a missing draw", () => { + const { backend } = webgpuStub(); + const target = {}; + const world = {}; + const state = {}; + const pipeline = { + vertexProgram: { code: source("zero vertex") }, + fragmentProgram: { code: source("zero fragment") }, + }; + const nodes = { getForRender: (_renderObject: unknown) => state }; + const internal = { object: target, material: { name: "warm" }, pipeline }; + const raw = { + backend, + _nodes: nodes, + renderObject: () => { + nodes.getForRender(internal); + backend.createRenderPipeline?.(internal, null); + }, + }; + const census = createPipelineCensus({ kind: "webgpu" }); + census.installRenderer(raw); + raw.renderObject(); + census.warmUpScopeSettled("zero", target); + census.beginWorldFrame(world); + // An unrelated UI frame must not close the target scope. + raw.renderObject(); + census.endWorldFrame({}, true); + expect(census.snapshot().warmUpScopes?.[0]?.firstDraw).toBeUndefined(); + }); + + it("closes a target scope on a completed cached main draw with zero new node work", () => { + const target = {}; + const world = {}; + const state = {}; + const nodes = { getForRender: (_renderObject: unknown) => state }; + const raw = { + _nodes: nodes, + backend: webgpuStub().backend, + renderObject: (object: unknown, scene: unknown, _camera: unknown, ...rest: unknown[]) => { + nodes.getForRender({ object }); + void scene; + void rest; + }, + }; + const census = createPipelineCensus({ kind: "webgpu" }); + census.installRenderer(raw); + raw.renderObject(target, world, {}, {}, {}, null, null, null, "main"); + census.warmUpScopeSettled("cached", target); + census.beginWorldFrame(world); + raw.renderObject(target, world, {}, {}, {}, null, null, null, "main"); + census.endWorldFrame(world, true); + + expect(census.snapshot().warmUpScopes?.[0]?.firstDraw).toEqual({ + nodeBuilds: 0, + renderPipelineCreations: 0, + }); + }); + + it("keeps draw-time scope matching flat as streamed targets accumulate", () => { + const { backend } = webgpuStub(); + const world = {}; + const state = {}; + const nodes = { getForRender: (_renderObject: unknown) => state }; + const raw = { + _nodes: nodes, + backend, + renderObject: (object: unknown, scene: unknown, _camera: unknown, ...rest: unknown[]) => { + nodes.getForRender({ object }); + void scene; + void rest; + }, + }; + const census = createPipelineCensus({ kind: "webgpu" }); + census.installRenderer(raw); + const streamed = { parent: world }; + const drawn = { parent: streamed }; + const fastestDrawsMs = (): number => { + let fastest = Number.POSITIVE_INFINITY; + for (let round = 0; round < 3; round += 1) { + census.beginWorldFrame(world); + const startedMs = performance.now(); + for (let draw = 0; draw < 100; draw += 1) { + raw.renderObject(drawn, world, {}, {}, {}, null, null, null, "main"); + } + fastest = Math.min(fastest, performance.now() - startedMs); + census.endWorldFrame(world, true); + } + return fastest; + }; + census.warmUpScopeSettled("streamed:0", streamed); + const soloMs = fastestDrawsMs(); + for (let index = 1; index < 10_000; index += 1) { + census.warmUpScopeSettled(`streamed:${index}`, { parent: world }); + } + const crowdedMs = fastestDrawsMs(); + + expect(census.snapshot().warmUpScopes?.[0]?.firstDraw).toBeDefined(); + expect(crowdedMs / Math.max(soloMs, 1), "draw cost with 10,000 scopes vs one").toBeLessThan(50); + }); + + it("fails closed when the renderer-owned node seam cannot be installed", () => { + const { backend } = webgpuStub(); + const raw = { + backend, + _nodes: {}, + renderObject: () => backend.createRenderPipeline?.(), + }; + const census = createPipelineCensus({ kind: "webgpu" }); + census.installRenderer(raw); + raw.renderObject(); + + const report = census.snapshot(); + expect(report.complete).toBe(false); + expect(report.incompleteReasons).toContain("node builder observation unavailable"); + }); + it("rejects completeness when creation observation is missing or bounded out", () => { const census = createPipelineCensus({ kind: "webgpu", limit: 1 }); const backend = { diff --git a/packages/core/__tests__/renderer.spec.ts b/packages/core/__tests__/renderer.spec.ts index 3697d5b943..bafba79083 100644 --- a/packages/core/__tests__/renderer.spec.ts +++ b/packages/core/__tests__/renderer.spec.ts @@ -1,6 +1,6 @@ -import { Object3D, PerspectiveCamera, Scene } from "three"; +import { Color, Object3D, PerspectiveCamera, Scene } from "three"; import { pass } from "three/tsl"; -import { RenderPipeline } from "three/webgpu"; +import { NodeFrame, NodeUpdateType, RenderPipeline } from "three/webgpu"; import { describe, expect, it, vi } from "vitest"; import { createRenderer, prewarm } from "../src/renderer.js"; @@ -1290,6 +1290,292 @@ describe("createRenderer", () => { } }); + it.each([ + { throws: false, nextFrame: false }, + { throws: false, nextFrame: true }, + { throws: true, nextFrame: false }, + { throws: true, nextFrame: true }, + ])( + "refreshes a scoped world warm-up and its following main render in one outer render with nested post passes: %j", + async ({ throws, nextFrame }) => { + const canvas = testCanvas(); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { gpu: {} }, + }); + const nodeFrame = new NodeFrame(); + nodeFrame.frameId = 1; + const draws: { + scene: Object3D; + visibleObjects: string[]; + target: unknown; + warm: boolean; + }[] = []; + const canvasSubmissions: Object3D[] = []; + let boundTarget: unknown = null; + let boundMrt: unknown = null; + let warm = false; + let targetScene: Scene | undefined; + let worldPass: ReturnType | undefined; + const renderPipeline = vi.spyOn(RenderPipeline.prototype, "render").mockImplementation(() => { + nodeFrame.renderId += 1; + if (worldPass === undefined) throw new Error("Missing world pass"); + nodeFrame.updateBeforeNode(worldPass); + // Bloom/temporal quads sample the same pass under a new renderId. + nodeFrame.renderId += 1; + nodeFrame.updateBeforeNode(worldPass); + if (!warm) canvasSubmissions.push(worldPass.scene as Object3D); + }); + const rawRender = (scene: Object3D): void => { + draws.push({ + scene, + visibleObjects: scene.children + .filter((child) => child.visible) + .map((child) => child.name), + target: boundTarget, + warm, + }); + if (throws && warm && scene === targetScene) throw new Error("scoped warm failed"); + }; + const raw = { + _renderTarget: boundTarget, + autoClear: true, + contextNode: null, + dispose: () => undefined, + domElement: canvas, + getActiveCubeFace: () => 0, + getActiveMipmapLevel: () => 0, + getClearAlpha: () => 1, + getClearColor: (target: Color) => target.set(0), + getDrawingBufferSize: (size: { set: (width: number, height: number) => unknown }) => + size.set(320, 180), + getMRT: () => boundMrt, + getOutputRenderTarget: () => null, + getPixelRatio: () => 1, + getRenderObjectFunction: () => null, + getRenderTarget: () => boundTarget, + getScissorTest: () => false, + init: async () => undefined, + opaque: false, + outputColorSpace: "srgb", + render: rawRender, + setClearColor: () => undefined, + setMRT: (value: unknown) => { + boundMrt = value; + }, + setPixelRatio: () => undefined, + setRenderObjectFunction: () => undefined, + setRenderTarget: (value: unknown) => { + boundTarget = value; + raw._renderTarget = value; + }, + setScissorTest: () => undefined, + setSize: () => undefined, + toneMapping: 0, + toneMappingExposure: 1, + transparent: false, + xr: { enabled: false }, + }; + nodeFrame.renderer = raw as never; + + try { + const renderer = await createRenderer({ canvas, webgpuFactory: () => raw }); + const authoredScene = new Scene(); + const authoredCamera = new PerspectiveCamera(); + worldPass = pass(authoredScene, authoredCamera); + const outputNode = worldPass.add(pass(new Scene(), new PerspectiveCamera())); + renderer.setOutputNode(outputNode, worldPass); + const authoredUpdateBeforeType = worldPass.updateBeforeType; + const authoredUpdateBefore = worldPass.updateBefore; + const hadOwnUpdateBefore = Object.hasOwn(worldPass, "updateBefore"); + expect(authoredUpdateBeforeType).toBe(NodeUpdateType.FRAME); + targetScene = new Scene(); + const terrain = new Object3D(); + terrain.name = "terrain"; + targetScene.add(terrain); + const worldObject = new Object3D(); + worldObject.name = "world-object"; + targetScene.add(worldObject); + const chunk = new Object3D(); + chunk.name = "requested-chunk"; + + renderer.render(authoredScene, authoredCamera); + const warmStart = canvasSubmissions.length; + warm = true; + const warmUp = () => + renderer.warmUpWorldPass?.(chunk, new PerspectiveCamera(), targetScene); + if (throws) expect(warmUp).toThrow("scoped warm failed"); + else warmUp(); + warm = false; + + expect(worldPass.updateBeforeType).toBe(authoredUpdateBeforeType); + expect(canvasSubmissions).toHaveLength(warmStart); + expect(draws[1]?.scene).toBe(targetScene); + expect(draws[1]?.visibleObjects).toEqual(["terrain", "world-object", "requested-chunk"]); + expect(draws[1]?.target).not.toBeNull(); + expect(draws[1]?.warm).toBe(true); + + if (nextFrame) nodeFrame.frameId += 1; + renderer.render(authoredScene, authoredCamera); + expect(worldPass.updateBefore).toBe(authoredUpdateBefore); + expect(Object.hasOwn(worldPass, "updateBefore")).toBe(hadOwnUpdateBefore); + expect(draws.map((draw) => draw.scene)).toEqual([ + authoredScene, + targetScene, + authoredScene, + ]); + expect(draws[2]?.visibleObjects).toEqual([]); + expect(canvasSubmissions).toEqual([authoredScene, authoredScene]); + expect(worldPass.updateBeforeType).toBe(authoredUpdateBeforeType); + renderer.dispose(); + } finally { + renderPipeline.mockRestore(); + if (descriptor === undefined) Reflect.deleteProperty(globalThis, "navigator"); + else Object.defineProperty(globalThis, "navigator", descriptor); + } + }, + ); + + it.each([false, true])( + "warms a scoped output pass offscreen and restores state after %s failure", + async (throws) => { + const canvas = testCanvas(); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { gpu: {} }, + }); + let boundTarget: unknown = "previous-target"; + let boundMrt: unknown = "previous-mrt"; + let renderObjectFunction: ((...args: unknown[]) => void) | null = null; + let pixelRatio = 1; + let clearAlpha = 0.25; + let scissorTest = true; + const clearColor = new Color(0x123456); + const targetCalls: unknown[] = []; + let chunk: Object3D | undefined; + let worldPass: ReturnType | undefined; + let invokePassUpdate: (() => void) | undefined; + let seenScene: unknown; + let seenChunkParent: unknown; + const originalContextNode = { getFlowContextData: () => ({ original: true }) }; + const rawRender = vi.fn(() => { + seenScene = worldPass?.scene; + seenChunkParent = chunk?.parent; + const update = invokePassUpdate; + invokePassUpdate = undefined; + update?.(); + if (throws) throw new Error("covered output render failed"); + }); + const raw = { + _renderTarget: boundTarget, + autoClear: false, + compute: () => undefined, + contextNode: originalContextNode, + dispose: () => undefined, + domElement: canvas, + getActiveCubeFace: () => 3, + getActiveMipmapLevel: () => 2, + getClearAlpha: () => clearAlpha, + getClearColor: (target: Color) => target.copy(clearColor), + getMRT: () => boundMrt, + getPixelRatio: () => pixelRatio, + getDrawingBufferSize: (size: { set: (width: number, height: number) => unknown }) => + size.set(320, 180), + getOutputRenderTarget: () => null, + getRenderObjectFunction: () => renderObjectFunction, + // Match compileAsync's temporary getter without changing the bound target. + getRenderTarget: () => "compiler-target", + getScissorTest: () => scissorTest, + init: async () => undefined, + opaque: false, + outputColorSpace: "original-output-color-space", + render: rawRender, + setMRT: (value: unknown) => { + boundMrt = value; + }, + setClearColor: (value: Color, alpha?: number) => { + clearColor.copy(value); + if (alpha !== undefined) clearAlpha = alpha; + }, + setPixelRatio: (value: number) => { + pixelRatio = value; + }, + setRenderObjectFunction: (value: ((...args: unknown[]) => void) | null) => { + renderObjectFunction = value; + }, + setRenderTarget: (value: unknown) => { + targetCalls.push(value); + boundTarget = value; + raw._renderTarget = value; + }, + setScissorTest: (value: boolean) => { + scissorTest = value; + }, + setSize: () => undefined, + toneMapping: 17, + transparent: false, + xr: { enabled: true }, + }; + + try { + const renderer = await createRenderer({ canvas, webgpuFactory: () => raw }); + const authoredScene = new Scene(); + const authoredCamera = new PerspectiveCamera(); + worldPass = pass(authoredScene, authoredCamera); + const outputNode = worldPass.add(pass(new Scene(), new PerspectiveCamera())); + worldPass.name = "warm-world-pass"; + worldPass.contextNode = { getFlowContextData: () => ({ pass: true }) } as never; + worldPass.overrideMaterial = {} as never; + worldPass.setLayers({ mask: 2 } as never); + renderer.setOutputNode(outputNode, worldPass); + + const targetScene = new Scene(); + chunk = new Object3D(); + chunk.position.set(3, 4, 5); + const originalMatrix = chunk.matrix.clone(); + const camera = new PerspectiveCamera(); + const originalCameraMask = camera.layers.mask; + targetScene.name = "target-scene"; + targetScene.overrideMaterial = {} as never; + const originalTargetName = targetScene.name; + const targetOverrideMaterial = targetScene.overrideMaterial; + invokePassUpdate = () => worldPass?.updateBefore({ renderer: raw } as never); + + const warm = () => renderer.warmUpWorldPass?.(chunk as Object3D, camera, targetScene); + if (throws) expect(warm).toThrow("covered output render failed"); + else warm(); + + expect(seenScene).toBe(targetScene); + expect(seenChunkParent).toBe(targetScene); + expect(worldPass.scene).toBe(authoredScene); + expect(worldPass.camera).toBe(authoredCamera); + expect(chunk.parent).toBeNull(); + expect(chunk.matrix.equals(originalMatrix)).toBe(true); + expect(boundTarget).toBe("previous-target"); + expect(boundMrt).toBe("previous-mrt"); + expect(raw.autoClear).toBe(false); + expect(raw.transparent).toBe(false); + expect(raw.opaque).toBe(false); + expect(raw.contextNode).toBe(originalContextNode); + expect(raw.toneMapping).toBe(17); + expect(raw.outputColorSpace).toBe("original-output-color-space"); + expect(raw.xr.enabled).toBe(true); + expect(camera.layers.mask).toBe(originalCameraMask); + expect(targetScene.name).toBe(originalTargetName); + expect(targetScene.overrideMaterial).toBe(targetOverrideMaterial); + expect(targetCalls.length).toBeGreaterThan(0); + expect(targetCalls).not.toContain(null); + expect(rawRender).toHaveBeenCalled(); + renderer.dispose(); + } finally { + if (descriptor === undefined) Reflect.deleteProperty(globalThis, "navigator"); + else Object.defineProperty(globalThis, "navigator", descriptor); + } + }, + ); + it("rejects an output graph with multiple passes when no world pass is named", async () => { const canvas = testCanvas(); const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); diff --git a/packages/core/__tests__/warmup.spec.ts b/packages/core/__tests__/warmup.spec.ts index d35b12571b..79dcfecad6 100644 --- a/packages/core/__tests__/warmup.spec.ts +++ b/packages/core/__tests__/warmup.spec.ts @@ -245,6 +245,21 @@ describe("scene warm-up", () => { }); }); + test("should use the covered output pass when stock compile cannot match its render depth", async () => { + const compileAsync = vi.fn(async () => undefined); + const warmUpWorldPass = vi.fn(); + const report = await warmUpScene( + { compileAsync, warmUpWorldPass }, + sceneOf(1) as never, + {} as never, + { yieldFrame: () => Promise.resolve() }, + ); + + expect(compileAsync).not.toHaveBeenCalled(); + expect(warmUpWorldPass).toHaveBeenCalledOnce(); + expect(report).toMatchObject({ attempted: 0, compiled: 1, passes: ["main"] }); + }); + test("should not call abandoned work complete when the census itself is complete", async () => { const snapshot = { version: 1 as const, @@ -497,6 +512,16 @@ describe("scene warm-up", () => { }); test("should stop waiting for the GPU when the budget runs out", async () => { + const storage = new Map(); + const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => storage.get(key) ?? null, + removeItem: (key: string) => storage.delete(key), + setItem: (key: string, value: string) => storage.set(key, value), + }, + }); const renderer = { compileAsync: () => Promise.resolve(), render: () => undefined, @@ -511,11 +536,20 @@ describe("scene warm-up", () => { mesh("ground"), { children: [], isLight: true, castShadow: true, name: "sun" }, ]); - const report = await warmUpScene(renderer as never, scene as never, {} as never, { - budgetMs: 60, - yieldFrame: () => Promise.resolve(), - }); - expect(report.passes).toEqual(["main", "shadow"]); + try { + const report = await warmUpScene(renderer as never, scene as never, {} as never, { + budgetMs: 60, + cache: { key: "stalled-gpu" }, + yieldFrame: () => Promise.resolve(), + }); + expect(report.passes).toEqual(["main", "shadow"]); + expect(report).toMatchObject({ compiled: 1, abandoned: 0, timedOut: true }); + expect(report.cache).not.toBe("stored"); + expect(storage.size).toBe(0); + } finally { + if (previousStorage === undefined) Reflect.deleteProperty(globalThis, "localStorage"); + else Object.defineProperty(globalThis, "localStorage", previousStorage); + } }, 10_000); test("should name a reflection pass when a material carries a reflector node", async () => { @@ -556,6 +590,52 @@ describe("scene warm-up", () => { expect(report.passPipelines).toBe(0); }); + test("should keep the covered output world pass when renderPasses is false", async () => { + const warmUpWorldPass = vi.fn(); + const renderer = { + compileAsync: vi.fn(async () => undefined), + warmUpWorldPass, + }; + const report = await warmUpScene(renderer as never, sceneOf(1) as never, {} as never, { + renderPasses: false, + yieldFrame: () => Promise.resolve(), + }); + + expect(renderer.compileAsync).not.toHaveBeenCalled(); + expect(warmUpWorldPass).toHaveBeenCalledOnce(); + expect(report.passes).toEqual(["main"]); + }); + + test.each(["throw", "overrun"] as const)( + "should not publish a covered world warm render as settled when it %s", + async (failure) => { + let clock = 0; + const clockSpy = vi.spyOn(globalThis.performance, "now").mockImplementation(() => clock); + const warmUpWorldPass = vi.fn(() => { + if (failure === "throw") throw new Error("covered warm render failed"); + clock = 20; + }); + try { + const report = await warmUpScene( + { compileAsync: vi.fn(async () => undefined), warmUpWorldPass }, + sceneOf(1) as never, + {} as never, + { budgetMs: 10, yieldFrame: () => Promise.resolve() }, + ); + + expect(warmUpWorldPass).toHaveBeenCalledOnce(); + expect(report).toMatchObject({ + compiled: 0, + abandoned: 1, + timedOut: failure === "overrun", + }); + expect(report.cache).not.toBe("stored"); + } finally { + clockSpy.mockRestore(); + } + }, + ); + test("should submit an off-screen caster by turning frustum culling off for the warm render", async () => { // Frustum culling is on by default, so a mesh outside the startup camera's view is never // submitted to the shadow or reflection pass and its pipelines stay unbuilt. Midway's mid-flight diff --git a/packages/core/__tests__/world-capture-fixture.spec.ts b/packages/core/__tests__/world-capture-fixture.spec.ts index aa4ee054fd..9892c0ce63 100644 --- a/packages/core/__tests__/world-capture-fixture.spec.ts +++ b/packages/core/__tests__/world-capture-fixture.spec.ts @@ -8,6 +8,8 @@ import { PerspectiveCamera, Scene as ThreeScene, } from "three"; +import { bloom } from "three/addons/tsl/display/BloomNode.js"; +import { pass } from "three/tsl"; import ts from "typescript"; import { describe, expect, it } from "vitest"; import { evaluateRichPlaytestAssertions } from "../../playtest/src/assertion-evaluators.js"; @@ -49,6 +51,8 @@ async function probe() { if (name === "@threenative/core") return { Scene, VirtualShadowNode: class {} }; if (name === "@threenative/core/world") return { WorldCells: { load: async () => world } }; if (name === "three") return { Color, DirectionalLight, HemisphereLight }; + if (name === "three/tsl") return { pass }; + if (name === "three/addons/tsl/display/BloomNode.js") return { bloom }; if (name === "../render/terrain.js") return { terrainMaterial: () => ({}) }; // Only the `?world&viewDistance` flight applies it, and this context has no location. if (name === "../render/worldFog.js") return { worldFog: () => undefined }; diff --git a/packages/core/__tests__/world-cells-streamed-warmup.spec.ts b/packages/core/__tests__/world-cells-streamed-warmup.spec.ts index 50494b97b4..2e54685910 100644 --- a/packages/core/__tests__/world-cells-streamed-warmup.spec.ts +++ b/packages/core/__tests__/world-cells-streamed-warmup.spec.ts @@ -12,6 +12,7 @@ import { MeshBasicMaterial, type Object3D, PerspectiveCamera, + Scene, } from "three"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { IRendererLike } from "../src/renderer.js"; @@ -277,7 +278,7 @@ describe("a chunk streamed into a walking world", () => { url: "/world/world.json", }); // In a scene, because a world nothing projects is a world whose first frame is the first draw. - new Group().add(world); + new Scene().add(world); const { calls, renderer } = stubRenderer(); const camera = cameraAt(follow.position.x, follow.position.z); for (let pass = 0; pass < 200; pass += 1) { diff --git a/packages/core/src/game.ts b/packages/core/src/game.ts index a907be8bf9..1975bcf7f2 100644 --- a/packages/core/src/game.ts +++ b/packages/core/src/game.ts @@ -1235,6 +1235,10 @@ class GameImpl, TPhysics> } catch (error) { failure = error instanceof Error ? error.message : String(error); } + // With an explicit warm-up, this held pass is only the first half of startup. Stamp after + // the explicit pass so its own pipelines remain inside the settled boundary; without one, + // this is the only warm-up completion. + if (stamp || !this.#warmUpConfiguredExplicitly()) renderer.warmUpSettled?.(); warmUpStatus = report === undefined ? { status: "unavailable" } @@ -2141,6 +2145,7 @@ class GameImpl, TPhysics> } catch (error) { failure = error instanceof Error ? error.message : String(error); } + this.#renderer.warmUpSettled?.(); warmUpStatus = report === undefined ? { status: "unavailable" } diff --git a/packages/core/src/pipeline-census.ts b/packages/core/src/pipeline-census.ts index 670121f2ab..af87ab6e71 100644 --- a/packages/core/src/pipeline-census.ts +++ b/packages/core/src/pipeline-census.ts @@ -72,8 +72,23 @@ export interface IPipelineCensusEvent { /** Promise latency is retained as latency, never labelled native compiler time. */ readonly promiseMs?: number; readonly error?: string; + /** Number of distinct Three node-builder states first observed during this render-object call. */ + readonly nodeBuilds?: number; /** Whether this event settled before the first present boundary. */ readonly beforeFirstPresent?: boolean; + /** Whether this event settled before the explicit warm-up completion boundary. */ + readonly beforeWarmUpSettled?: boolean; + /** The most recent streamed warm-up scope that preceded this event. */ + readonly afterWarmUpScope?: string; +} + +/** A node-builder state observed even when Three reused an existing GPU pipeline. */ +export interface IPipelineNodeBuild { + readonly provenance: IPipelineProvenance; + readonly startedMs: number; + readonly beforeFirstPresent?: boolean; + readonly beforeWarmUpSettled?: boolean; + readonly afterWarmUpScope?: string; } export interface IPipelineCensusCounts { @@ -85,6 +100,7 @@ export interface IPipelineCensusCounts { readonly uniquePipelines: number; readonly recordedEvents: number; readonly droppedEvents: number; + readonly nodeBuilds: number; /** * Every creation seen at the device, including those attributed to a backend event. Absent * when the device could not be observed at all, so no reader mistakes silence for zero. @@ -119,8 +135,26 @@ export interface IPipelineCensus { readonly boundaryMs: number; readonly eventsSettled: number; }; + readonly warmUpSettled?: { + readonly boundaryMs: number; + readonly eventsSettled: number; + readonly nodeBuilds: number; + readonly renderPipelineCreations: number; + }; + readonly warmUpScopes?: readonly { + readonly label: string; + readonly boundaryMs: number; + readonly eventsSettled: number; + readonly nodeBuilds: number; + readonly renderPipelineCreations: number; + readonly firstDraw?: { + readonly nodeBuilds: number; + readonly renderPipelineCreations: number; + }; + }[]; readonly counts: IPipelineCensusCounts; readonly events: readonly IPipelineCensusEvent[]; + readonly nodeBuilds?: readonly IPipelineNodeBuild[]; readonly incompleteReasons: readonly string[]; } @@ -141,7 +175,6 @@ interface IRenderObjectLike { readonly context?: unknown; readonly scene?: unknown; readonly passId?: unknown; - readonly getNodeBuilderState?: () => unknown; } interface IProgramLike { @@ -184,7 +217,9 @@ interface IActiveRenderObject { readonly clippingContext: unknown; readonly object: unknown; readonly material: unknown; + readonly scene: unknown; readonly passId: unknown; + nodeBuilds: number; } interface IMutableCensusEvent { @@ -208,7 +243,10 @@ interface IMutableCensusEvent { serviceMs?: number; promiseMs?: number; error?: string; + nodeBuilds?: number; beforeFirstPresent?: boolean; + beforeWarmUpSettled?: boolean; + afterWarmUpScope?: string; /** Three's logical pipeline object, retained privately for backend data lookup. */ backendPipeline?: unknown; /** Observed at the device rather than through a backend method, so it carries its own handle. */ @@ -228,12 +266,36 @@ interface IInstalledBackend { interface IRendererHookTarget { backend?: unknown; + /** Three's renderer-owned node manager; absent on the WebGL2 renderer. */ + _nodes?: unknown; renderObject?: (...args: unknown[]) => unknown; } +interface INodeBuilderObservation { + readonly state: object; + builds: number; + reported: boolean; +} + +interface IMutableWarmUpScope { + label: string; + boundaryMs: number; + eventsSettled: number; + nodeBuilds: number; + renderPipelineCreations: number; + firstDraw?: { + nodeBuilds: number; + renderPipelineCreations: number; + }; + frameObserved: boolean; + frameNodeBuilds: number; + frameRenderPipelineCreations: number; +} + /** * Create and install a bounded census. The returned object is intentionally small: renderers own - * lifecycle and callers only need `snapshot()`, `withRenderObject()`, and `firstPresent()`. + * lifecycle and callers only need `snapshot()`, `withRenderObject()`, and the two lifecycle + * boundaries. */ export function createPipelineCensus(options: IPipelineCensusOptions): PipelineCensus { return new PipelineCensus(options); @@ -250,6 +312,7 @@ export class PipelineCensus { readonly #adapterIdentity: string; readonly #thermalIdentity: string; readonly #events: IMutableCensusEvent[] = []; + readonly #nodeBuildEvents: IPipelineNodeBuild[] = []; readonly #programs = new Set(); readonly #pipelines = new Set(); readonly #installed: IInstalledBackend[] = []; @@ -262,6 +325,10 @@ export class PipelineCensus { * hash per source instead of one per pipeline, and no capture ever retains a shader. */ readonly #observations = new WeakMap(); + /** Distinct node-builder states, so a repeated render-object lookup is not a graph rebuild. */ + readonly #nodeBuilderStates = new WeakSet(); + /** Pending state-build counts keyed by Three's internal RenderObject for async compilation. */ + readonly #nodeBuilderObservations = new WeakMap(); /** Device methods that exist but refused their hook, so their creations were never seen. */ readonly #deviceRefusals = new Set(); #activeRenderObject: IActiveRenderObject | undefined; @@ -274,8 +341,10 @@ export class PipelineCensus { #failures = 0; #pending = 0; #dropped = 0; + #droppedNodeBuilds = 0; #deviceCreations = 0; #directCreations = 0; + #nodeBuilds = 0; #nestedDeviceCreations = 0; #backendDepth = 0; #backendDeviceCalls = 0; @@ -284,6 +353,18 @@ export class PipelineCensus { #unsupported = false; #firstPresentMs: number | undefined; #eventsSettledAtFirstPresent = 0; + #warmUpSettledMs: number | undefined; + #eventsSettledAtWarmUpSettled = 0; + #nodeBuildsAtWarmUpSettled = 0; + #renderPipelineCreationsAtWarmUpSettled = 0; + readonly #warmUpScopes: IMutableWarmUpScope[] = []; + /** Scope lists keyed weakly by streamed root, so an evicted chunk graph is not kept alive here. */ + readonly #warmUpTargets = new WeakMap(); + /** Scopes a draw touched in the open world frame; only these reset when it closes. */ + #frameWarmUpScopes: IMutableWarmUpScope[] = []; + #activeWarmUpScope: { label: string; boundaryMs: number } | undefined; + #activeWorldFrame: { root: object } | undefined; + #nodeBuilderObservationUnavailable = false; #cleanup: (() => void) | undefined; constructor(options: IPipelineCensusOptions) { @@ -461,6 +542,7 @@ export class PipelineCensus { installRenderer(renderer: IRendererHookTarget): void { if (this.#cleanup !== undefined) return; const restoreBackend = this.install(renderer.backend); + const restoreNodeBuilder = this.#installNodeBuilderObservation(renderer); const originalRenderObject = renderer.renderObject; let wrappedRenderObject: ((...args: unknown[]) => unknown) | undefined; if (originalRenderObject !== undefined) { @@ -471,6 +553,7 @@ export class PipelineCensus { renderer.renderObject = wrappedRenderObject; } this.#cleanup = () => { + restoreNodeBuilder(); if (wrappedRenderObject !== undefined && renderer.renderObject === wrappedRenderObject) renderer.renderObject = originalRenderObject; restoreBackend(); @@ -486,12 +569,16 @@ export class PipelineCensus { /** Wrap Three's public render-object method so compileAsync retains the authored pass id. */ withRenderObject(args: readonly unknown[], callback: () => T): T { const previous = this.#activeRenderObject; - this.#activeRenderObject = { + const active: IActiveRenderObject = { clippingContext: args[7], object: args[0], material: args[4], + scene: args[1], passId: args[8], + nodeBuilds: 0, }; + this.#activeRenderObject = active; + this.#observeWarmUpScopes(this.#matchingWarmUpScopes()); try { return callback(); } finally { @@ -499,6 +586,146 @@ export class PipelineCensus { } } + #matchingWarmUpScopes(active = this.#activeRenderObject): readonly IMutableWarmUpScope[] { + const activeObject = active?.object; + if ( + active === undefined || + this.#activeWorldFrame?.root !== active.scene || + !isObject(activeObject) || + !isMainWorldPass(active.passId) + ) + return NO_WARM_UP_SCOPES; + return warmUpScopesOf(this.#warmUpTargets, activeObject); + } + + #observeWarmUpScopes(scopes: readonly IMutableWarmUpScope[]): void { + for (const scope of scopes) { + if (scope.frameObserved) continue; + scope.frameObserved = true; + this.#frameWarmUpScopes.push(scope); + } + } + + /** Begin the outer world render that can close target-bound streamed warm-up scopes. */ + beginWorldFrame(root: object): void { + this.#activeWorldFrame = { root }; + } + + /** Close target-bound scopes only after the complete visible world frame has returned. */ + endWorldFrame(root: object, completed: boolean): void { + const frame = this.#activeWorldFrame; + this.#activeWorldFrame = undefined; + const matches = completed && frame !== undefined && frame.root === root; + for (const scope of this.#frameWarmUpScopes) { + if (matches && scope.firstDraw === undefined) + scope.firstDraw = { + nodeBuilds: scope.frameNodeBuilds, + renderPipelineCreations: scope.frameRenderPipelineCreations, + }; + scope.frameObserved = false; + scope.frameNodeBuilds = 0; + scope.frameRenderPipelineCreations = 0; + } + this.#frameWarmUpScopes = []; + } + + /** Observe state creation at Three's renderer-owned NodeManager seam, never on the game Mesh. */ + #installNodeBuilderObservation(renderer: IRendererHookTarget): () => void { + if (this.#kind !== "webgpu" || renderer.renderObject === undefined) return () => undefined; + if (!isObject(renderer._nodes)) { + this.#nodeBuilderObservationUnavailable = true; + return () => undefined; + } + const nodes = renderer._nodes; + const original = nodes.getForRender; + if (typeof original !== "function") { + this.#nodeBuilderObservationUnavailable = true; + return () => undefined; + } + const census = this; + const restore: (() => void)[] = []; + const installed = this.#patchMethod( + nodes, + "getForRender", + (method) => + function (this: unknown, ...args: unknown[]): unknown { + const result = method.apply(this, args); + return census.#observeNodeBuilderResult(args[0], result); + }, + restore, + ); + if (!installed) { + this.#nodeBuilderObservationUnavailable = true; + return () => undefined; + } + return () => { + for (const undo of restore.splice(0).reverse()) undo(); + }; + } + + #observeNodeBuilderResult(renderObjectValue: unknown, result: unknown): unknown { + if (isPromiseLike(result)) { + return result.then((state: unknown) => { + this.#recordNodeBuilderState(renderObjectValue, state); + return state; + }); + } + this.#recordNodeBuilderState(renderObjectValue, result); + return result; + } + + #recordNodeBuilderState(renderObjectValue: unknown, state: unknown): void { + if (!isObject(renderObjectValue) || !isObject(state)) { + this.#nodeBuilderObservationUnavailable = true; + return; + } + if (this.#nodeBuilderStates.has(state)) return; + this.#nodeBuilderStates.add(state); + this.#nodeBuilds += 1; + const previous = this.#nodeBuilderObservations.get(renderObjectValue); + const observation: INodeBuilderObservation = + previous === undefined || previous.state !== state + ? { + state, + builds: (previous?.builds ?? 0) + 1, + reported: false, + } + : previous; + this.#nodeBuilderObservations.set(renderObjectValue, observation); + if (this.#activeRenderObject !== undefined) this.#activeRenderObject.nodeBuilds += 1; + const startedMs = this.#clock(); + const matching = this.#matchingWarmUpScopes(); + this.#observeWarmUpScopes(matching); + for (const scope of matching) scope.frameNodeBuilds += 1; + const legacyScope = + this.#activeWarmUpScope !== undefined && startedMs > this.#activeWarmUpScope.boundaryMs + ? this.#activeWarmUpScope.label + : undefined; + const scopedLabel = matching.length === 1 ? matching[0]?.label : legacyScope; + const record: IPipelineNodeBuild = { + provenance: provenance( + { object: this.#activeRenderObject?.object, material: this.#activeRenderObject?.material }, + this.#activeRenderObject, + ), + startedMs, + beforeFirstPresent: this.#firstPresentMs === undefined || startedMs <= this.#firstPresentMs, + ...(this.#warmUpSettledMs === undefined + ? {} + : { beforeWarmUpSettled: startedMs <= this.#warmUpSettledMs }), + ...(scopedLabel === undefined ? {} : { afterWarmUpScope: scopedLabel }), + }; + if (this.#nodeBuildEvents.length < this.#limit) this.#nodeBuildEvents.push(record); + else this.#droppedNodeBuilds += 1; + } + + #nodeBuildsFor(renderObjectValue: IRenderObjectLike | undefined): number { + if (!isObject(renderObjectValue)) return this.#activeRenderObject?.nodeBuilds ?? 0; + const observation = this.#nodeBuilderObservations.get(renderObjectValue); + if (observation === undefined || observation.reported) return 0; + observation.reported = true; + return observation.builds; + } + /** Mark the first renderer render call as the first-present boundary. */ firstPresent(): void { if (this.#firstPresentMs !== undefined) return; @@ -509,13 +736,70 @@ export class PipelineCensus { ).length; } + /** Mark the explicit shader warm-up as settled before the first measured world pass. */ + warmUpSettled(): void { + if (this.#warmUpSettledMs !== undefined) return; + const boundaryMs = this.#clock(); + this.#warmUpSettledMs = boundaryMs; + this.#eventsSettledAtWarmUpSettled = this.#events.filter( + (event) => event.settledMs !== undefined && event.settledMs <= boundaryMs, + ).length; + this.#nodeBuildsAtWarmUpSettled = this.#nodeBuilds; + this.#renderPipelineCreationsAtWarmUpSettled = this.#events.filter( + (event) => + event.kind === "render" && + event.status === "created" && + event.settledMs !== undefined && + event.settledMs <= boundaryMs, + ).length; + for (const event of this.#events) { + if (event.settledMs !== undefined) event.beforeWarmUpSettled = event.settledMs <= boundaryMs; + } + for (const nodeBuild of this.#nodeBuildEvents) + Object.assign(nodeBuild, { beforeWarmUpSettled: nodeBuild.startedMs <= boundaryMs }); + } + + /** Mark one repeatable streamed warm-up boundary; target-bound scopes close on their first world frame. */ + warmUpScopeSettled(label: string, target?: object): void { + if (label.trim() === "") throw new Error("TN_PIPELINE_CENSUS_SCOPE_INVALID: empty label"); + const boundaryMs = this.#clock(); + const scope: IMutableWarmUpScope = { + label, + boundaryMs, + eventsSettled: this.#events.filter( + (event) => event.settledMs !== undefined && event.settledMs <= boundaryMs, + ).length, + nodeBuilds: this.#nodeBuilds, + renderPipelineCreations: this.#events.filter( + (event) => + event.kind === "render" && + event.status === "created" && + event.settledMs !== undefined && + event.settledMs <= boundaryMs, + ).length, + frameObserved: false, + frameNodeBuilds: 0, + frameRenderPipelineCreations: 0, + }; + this.#warmUpScopes.push(scope); + if (target !== undefined) { + const scopes = this.#warmUpTargets.get(target); + if (scopes === undefined) this.#warmUpTargets.set(target, [scope]); + else scopes.push(scope); + } + if (target === undefined) this.#activeWarmUpScope = { label, boundaryMs }; + } + snapshot(): IPipelineCensus { const incompleteReasons: string[] = []; if (this.#unsupported) incompleteReasons.push("backend creation observation unavailable"); + if (this.#nodeBuilderObservationUnavailable) + incompleteReasons.push("node builder observation unavailable"); incompleteReasons.push(...this.#deviceReasons()); if (this.#adapterIdentity === "unavailable") incompleteReasons.push("adapter identity unavailable"); if (this.#overflowed) incompleteReasons.push("bounded event buffer overflowed"); + if (this.#droppedNodeBuilds > 0) incompleteReasons.push("bounded node-build buffer overflowed"); if (this.#pending > 0) incompleteReasons.push(`${this.#pending} pipeline event(s) are pending`); if (this.#failures > 0) incompleteReasons.push(`${this.#failures} pipeline creation(s) failed`); if (this.#creations === 0) incompleteReasons.push("no pipeline creations observed"); @@ -552,6 +836,28 @@ export class PipelineCensus { eventsSettled: this.#eventsSettledAtFirstPresent, }, }), + ...(this.#warmUpSettledMs === undefined + ? {} + : { + warmUpSettled: { + boundaryMs: this.#warmUpSettledMs, + eventsSettled: this.#eventsSettledAtWarmUpSettled, + nodeBuilds: this.#nodeBuildsAtWarmUpSettled, + renderPipelineCreations: this.#renderPipelineCreationsAtWarmUpSettled, + }, + }), + ...(this.#warmUpScopes.length === 0 + ? {} + : { + warmUpScopes: this.#warmUpScopes.map((scope) => ({ + label: scope.label, + boundaryMs: scope.boundaryMs, + eventsSettled: scope.eventsSettled, + nodeBuilds: scope.nodeBuilds, + renderPipelineCreations: scope.renderPipelineCreations, + ...(scope.firstDraw === undefined ? {} : { firstDraw: { ...scope.firstDraw } }), + })), + }), counts: { lookups: this.#lookups, creations: this.#creations, @@ -561,9 +867,13 @@ export class PipelineCensus { uniquePipelines: this.#pipelines.size, recordedEvents: this.#events.length, droppedEvents: this.#dropped, + nodeBuilds: this.#nodeBuilds, ...this.#deviceCounts(), }, events, + ...(this.#nodeBuildEvents.length === 0 && this.#nodeBuilds === 0 + ? {} + : { nodeBuilds: this.#nodeBuildEvents.map((entry) => freezeNodeBuild(entry)) }), incompleteReasons, }; } @@ -779,6 +1089,10 @@ export class PipelineCensus { readonly label?: string; }): IMutableCensusEvent { this.#creations += 1; + const matching = + input.kind === "render" && input.direct !== true ? this.#matchingWarmUpScopes() : []; + this.#observeWarmUpScopes(matching); + for (const scope of matching) scope.frameRenderPipelineCreations += 1; // A direct creation has no logical Three pipeline to be identified by, and one shared // placeholder would make two of them look like one. Each takes its own until its handle lands. const pipelineIdentity = @@ -806,6 +1120,12 @@ export class PipelineCensus { : { fragmentStagePresent: input.fragmentStagePresent }), ...(input.compute === undefined ? {} : { compute: input.compute }), ...(input.label === undefined ? {} : { label: input.label }), + ...(input.kind === "render" && input.direct !== true + ? { nodeBuilds: this.#nodeBuildsFor(input.renderObject) } + : {}), + ...(matching.length === 1 && matching[0] !== undefined + ? { afterWarmUpScope: matching[0].label } + : {}), startedMs: this.#clock(), backendPipeline: input.pipeline, ...(input.direct === true ? { direct: true } : {}), @@ -860,6 +1180,14 @@ export class PipelineCensus { event.settledMs = settledAt; event.beforeFirstPresent = this.#firstPresentMs === undefined || settledAt <= this.#firstPresentMs; + if (this.#warmUpSettledMs !== undefined) + event.beforeWarmUpSettled = settledAt <= this.#warmUpSettledMs; + if ( + event.afterWarmUpScope === undefined && + this.#activeWarmUpScope !== undefined && + settledAt > this.#activeWarmUpScope.boundaryMs + ) + event.afterWarmUpScope = this.#activeWarmUpScope.label; if (event.pending === true) { event.pending = false; this.#pending = Math.max(0, this.#pending - 1); @@ -1038,6 +1366,40 @@ function isPromiseLike(value: unknown): value is PromiseLike { return isObject(value) && typeof value.then === "function"; } +function isMainWorldPass(passId: unknown): boolean { + return passId === undefined || passId === null || passId === "main"; +} + +const NO_WARM_UP_SCOPES: readonly IMutableWarmUpScope[] = []; + +/** Scopes bound to the value, its `tnChunkRoot`, or any ancestor: O(depth) lookups per draw. */ +function warmUpScopesOf( + targets: WeakMap, + value: object, +): readonly IMutableWarmUpScope[] { + const userData = (value as { userData?: unknown }).userData; + const chunkRoot = isObject(userData) ? userData.tnChunkRoot : undefined; + let found = addWarmUpScopes(undefined, targets.get(value)); + if (isObject(chunkRoot)) found = addWarmUpScopes(found, targets.get(chunkRoot)); + for ( + let current = (value as { parent?: unknown }).parent; + isObject(current); + current = (current as { parent?: unknown }).parent + ) + found = addWarmUpScopes(found, targets.get(current)); + return found ?? NO_WARM_UP_SCOPES; +} + +function addWarmUpScopes( + found: IMutableWarmUpScope[] | undefined, + scopes: readonly IMutableWarmUpScope[] | undefined, +): IMutableWarmUpScope[] | undefined { + if (scopes === undefined) return found; + const next = found ?? []; + for (const scope of scopes) if (!next.includes(scope)) next.push(scope); + return next; +} + function asyncPromises(value: unknown): Promise[] { if (!Array.isArray(value)) return []; return value @@ -1172,8 +1534,33 @@ function freezeEvent(event: IMutableCensusEvent): IPipelineCensusEvent { ...(event.serviceMs === undefined ? {} : { serviceMs: event.serviceMs }), ...(event.promiseMs === undefined ? {} : { promiseMs: event.promiseMs }), ...(event.error === undefined ? {} : { error: event.error }), + ...(event.nodeBuilds === undefined ? {} : { nodeBuilds: event.nodeBuilds }), ...(event.beforeFirstPresent === undefined ? {} : { beforeFirstPresent: event.beforeFirstPresent }), + ...(event.beforeWarmUpSettled === undefined + ? {} + : { beforeWarmUpSettled: event.beforeWarmUpSettled }), + ...(event.afterWarmUpScope === undefined ? {} : { afterWarmUpScope: event.afterWarmUpScope }), + }; +} + +function freezeNodeBuild(entry: IPipelineNodeBuild): IPipelineNodeBuild { + return { + provenance: { + ...(entry.provenance.material === undefined + ? {} + : { material: { ...entry.provenance.material } }), + ...(entry.provenance.object === undefined ? {} : { object: { ...entry.provenance.object } }), + unknown: entry.provenance.unknown, + }, + startedMs: entry.startedMs, + ...(entry.beforeFirstPresent === undefined + ? {} + : { beforeFirstPresent: entry.beforeFirstPresent }), + ...(entry.beforeWarmUpSettled === undefined + ? {} + : { beforeWarmUpSettled: entry.beforeWarmUpSettled }), + ...(entry.afterWarmUpScope === undefined ? {} : { afterWarmUpScope: entry.afterWarmUpScope }), }; } diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 4b97750575..0b29e29f94 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -1,9 +1,13 @@ +import { RenderTarget } from "three"; import type { BufferGeometry, Camera, DepthTexture, Object3D, Texture } from "three"; import { + NodeUpdateType, type PassNode, ReadbackBuffer, RenderPipeline, + RendererUtils, type StorageBufferAttribute, + type Renderer as ThreeRenderer, } from "three/webgpu"; import { ComputeTimingScopes, type IComputeTimingReceipt } from "./compute-timing.js"; import type { IFrameSurfaceState } from "./frame-budget.js"; @@ -170,6 +174,12 @@ export interface IRendererLike { readonly compileCount?: number; /** The bounded, fail-closed pipeline observation for this renderer, when enabled. */ readonly pipelineCensus?: () => IPipelineCensus; + /** Marks the completion of the renderer's current scene warm-up attempt. */ + warmUpSettled?(): void; + /** Marks a repeatable streamed warm-up boundary for the next first draw. */ + warmUpScopeSettled?(label: string, target?: Object3D): void; + /** Performs one covered output-pipeline render without marking first present. */ + warmUpWorldPass?(scene: Object3D, camera: Camera, targetScene?: Object3D): void; /** * What alpha antialiasing did with the multisampled surface, and why, when it did nothing. * @@ -486,6 +496,8 @@ type RendererInstance = IStorageBufferSource & { needsFrameBufferTarget?: boolean; getRenderTarget?: () => unknown; setRenderTarget?: (target: unknown) => void; + getMRT?: () => unknown; + setMRT?: (mrt: unknown) => void; _getFrameBufferTarget?: () => unknown; /** What `getRenderTarget()` answers, and the only honest read while that answer is overridden. */ _renderTarget?: unknown; @@ -554,6 +566,27 @@ function wrapRenderer( // Keep caller identity separately: RenderPipeline may wrap its public output node. let outputInput: unknown; let outputPass: PassNode | undefined; + let pendingWorldPassRefresh = false; + // Nested bloom/temporal quads sample this pass under distinct renderIds. + const refreshWorldPassOnce = (pass: PassNode | undefined): (() => void) | undefined => { + if (pass === undefined) return undefined; + const updateBefore = pass.updateBefore; + const updateBeforeType = pass.updateBeforeType; + const hadOwnUpdateBefore = Object.hasOwn(pass, "updateBefore"); + let updated = false; + pass.updateBeforeType = NodeUpdateType.RENDER; + pass.updateBefore = function (frame) { + if (updated) return; + updated = true; + updateBefore.call(this, frame); + }; + return () => { + pass.updateBeforeType = updateBeforeType; + if (hadOwnUpdateBefore) pass.updateBefore = updateBefore; + else Reflect.deleteProperty(pass, "updateBefore"); + }; + }; + let warmOutputTarget: RenderTarget | undefined; const renderChains = new Set(); let renderChainUsesPerObjectVelocity = false; let activeCompiles = 0; @@ -630,16 +663,27 @@ function wrapRenderer( let renderingFrame = 0; const renderFrame = (scene: Object3D, camera: Camera): void => { - bindSurface(); - if (outputPipeline === undefined) raw.render(scene, camera); - else { - // RenderPipeline.render() has no scene argument and PassNode keeps the scene it captured - // when the graph was built. Retarget only the authored world pass at the root the wrapper - // is rendering so a projection mirror and its velocity history remain the same input. - setOutputPipelineRoot(outputPass, scene, camera); - outputPipeline.render(); + pipelineCensus?.beginWorldFrame(scene); + const refreshPass = pendingWorldPassRefresh ? outputPass : undefined; + const restoreWorldPass = refreshWorldPassOnce(refreshPass); + let completed = false; + try { + bindSurface(); + if (outputPipeline === undefined) raw.render(scene, camera); + else { + // RenderPipeline.render() has no scene argument and PassNode keeps the scene it captured + // when the graph was built. Retarget only the authored world pass at the root the wrapper + // is rendering so a projection mirror and its velocity history remain the same input. + setOutputPipelineRoot(outputPass, scene, camera); + outputPipeline.render(); + } + if (refreshPass !== undefined) pendingWorldPassRefresh = false; + pipelineCensus?.firstPresent(); + completed = true; + } finally { + restoreWorldPass?.(); + pipelineCensus?.endWorldFrame(scene, completed); } - pipelineCensus?.firstPresent(); }; const renderOverlayFrame = (scene: Object3D, camera: Camera): void => { bindSurface(); @@ -689,6 +733,120 @@ function wrapRenderer( return activeCompiles > 0; }, ...(pipelineCensus === undefined ? {} : { pipelineCensus: () => pipelineCensus.snapshot() }), + ...(pipelineCensus === undefined + ? {} + : { warmUpSettled: () => pipelineCensus.warmUpSettled() }), + ...(pipelineCensus === undefined + ? {} + : { + warmUpScopeSettled: (label: string, target?: Object3D) => + pipelineCensus.warmUpScopeSettled(label, target), + }), + get warmUpWorldPass() { + if (outputPipeline === undefined) return undefined; + return (scene: Object3D, camera: Camera, targetScene?: Object3D): void => { + const outputRoot = targetScene ?? scene; + const scoped = targetScene !== undefined && targetScene !== scene; + const previousPassScene = outputPass?.scene; + const previousPassCamera = outputPass?.camera; + let restoreWorldPass: (() => void) | undefined; + const warmRenderer = raw as RendererInstance & + ThreeRenderer & { + contextNode?: unknown; + opaque?: boolean; + transparent?: boolean; + }; + const rendererState = RendererUtils.saveRendererState(warmRenderer); + // An in-flight compile exposes a virtual target through the getter; restore the bound one. + if ("_renderTarget" in raw) + rendererState.renderTarget = Reflect.get( + raw, + "_renderTarget", + ) as typeof rendererState.renderTarget; + const hadOwnTransparent = Object.hasOwn(raw, "transparent"); + const hadOwnOpaque = Object.hasOwn(raw, "opaque"); + const hadOwnContextNode = Object.hasOwn(raw, "contextNode"); + const previousTransparent = Reflect.get(raw, "transparent"); + const previousOpaque = Reflect.get(raw, "opaque"); + const previousContextNode = Reflect.get(raw, "contextNode"); + const previousXr = warmRenderer.xr; + const hadOwnXrEnabled = previousXr !== undefined && Object.hasOwn(previousXr, "enabled"); + const previousXrEnabled = + previousXr === undefined ? undefined : Reflect.get(previousXr, "enabled"); + const hadOwnSceneOverrideMaterial = Object.hasOwn(scene, "overrideMaterial"); + const hadOwnOutputRootOverrideMaterial = Object.hasOwn(outputRoot, "overrideMaterial"); + const previousSceneName = scene.name; + const previousOutputRootName = outputRoot.name; + const previousSceneOverrideMaterial = Reflect.get(scene, "overrideMaterial"); + const previousOutputRootOverrideMaterial = Reflect.get(outputRoot, "overrideMaterial"); + const previousCameraMask = camera.layers.mask; + const restoreField = ( + object: object, + key: string, + hadOwn: boolean, + value: unknown, + ): void => { + if (hadOwn) Reflect.set(object, key, value); + else Reflect.deleteProperty(object, key); + }; + const originalParent = scene.parent; + const originalMatrix = scene.matrix.clone(); + const originalMatrixWorld = scene.matrixWorld.clone(); + const attached = scoped && !isInObject3DSubtree(outputRoot, scene); + if (attached) outputRoot.add(scene); + renderingFrame += 1; + try { + if (scoped && outputPass !== undefined) { + restoreWorldPass = refreshWorldPassOnce(outputPass); + pendingWorldPassRefresh = true; + } + if (scoped) { + if (raw.setRenderTarget === undefined) + throw new Error( + "TN_WARMUP_OFFSCREEN_TARGET_UNAVAILABLE: scoped world warm-up needs a render target seam.", + ); + warmOutputTarget ??= new RenderTarget(1, 1); + raw.setRenderTarget(warmOutputTarget); + } else bindSurface(); + setOutputPipelineRoot(outputPass, outputRoot, camera); + outputPipeline?.render(); + } finally { + renderingFrame -= 1; + if (outputPass !== undefined) { + outputPass.scene = previousPassScene as Object3D; + outputPass.camera = previousPassCamera as Camera; + restoreWorldPass?.(); + } + RendererUtils.restoreRendererState(warmRenderer, rendererState); + restoreField(raw, "transparent", hadOwnTransparent, previousTransparent); + restoreField(raw, "opaque", hadOwnOpaque, previousOpaque); + restoreField(raw, "contextNode", hadOwnContextNode, previousContextNode); + if (previousXr !== undefined) + restoreField(previousXr, "enabled", hadOwnXrEnabled, previousXrEnabled); + restoreField( + scene, + "overrideMaterial", + hadOwnSceneOverrideMaterial, + previousSceneOverrideMaterial, + ); + restoreField( + outputRoot, + "overrideMaterial", + hadOwnOutputRootOverrideMaterial, + previousOutputRootOverrideMaterial, + ); + scene.name = previousSceneName; + outputRoot.name = previousOutputRootName; + camera.layers.mask = previousCameraMask; + if (attached) { + outputRoot.remove(scene); + if (originalParent !== null) originalParent.add(scene); + } + scene.matrix.copy(originalMatrix); + scene.matrixWorld.copy(originalMatrixWorld); + } + }; + }, gpuFrameMs: () => gpuFrameSample()?.ms, gpuFrameAge: () => { const sample = gpuFrameSample(); @@ -1095,7 +1253,10 @@ function wrapRenderer( renderChains.clear(); renderChainUsesPerObjectVelocity = false; outputPipeline?.dispose(); + pendingWorldPassRefresh = false; outputPipeline = undefined; + warmOutputTarget?.dispose(); + warmOutputTarget = undefined; outputInput = undefined; outputPass = undefined; pipelineCensus?.dispose(); @@ -1137,6 +1298,7 @@ function wrapRenderer( nextPipeline.outputColorTransform = options.outputColorTransform; } outputPipeline?.dispose(); + pendingWorldPassRefresh = false; outputPass = nextOutputPass; outputPipeline = nextPipeline; outputInput = node; @@ -1144,6 +1306,7 @@ function wrapRenderer( isCurrent: () => outputPipeline === nextPipeline, dispose: () => { if (outputPipeline !== nextPipeline) return; + pendingWorldPassRefresh = false; outputPipeline = undefined; outputInput = undefined; outputPass = undefined; @@ -1154,6 +1317,7 @@ function wrapRenderer( clearOutputNode: (expectedNode) => { if (expectedNode !== undefined && expectedNode !== outputInput) return; outputPipeline?.dispose(); + pendingWorldPassRefresh = false; outputPipeline = undefined; outputInput = undefined; outputPass = undefined; @@ -1245,6 +1409,15 @@ function setOutputPipelineRoot(pass: PassNode | undefined, scene: Object3D, came pass.camera = camera; } +function isInObject3DSubtree(root: Object3D, object: Object3D): boolean { + let current: Object3D | null = object; + while (current !== null) { + if (current === root) return true; + current = current.parent; + } + return false; +} + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/packages/core/src/warmup.ts b/packages/core/src/warmup.ts index 7117b58d90..e8c931ab7b 100644 --- a/packages/core/src/warmup.ts +++ b/packages/core/src/warmup.ts @@ -124,6 +124,8 @@ export interface IWarmUpOptions { * `visibilityForced`, so a game can see how much of its scene the render had to un-hide. */ readonly includeHidden?: boolean; + /** The scene that supplies lights and environment when `scene` is an unattached object. */ + readonly targetScene?: Object3D; } /** What the warm-up did, so a caller can report it rather than assume it. */ @@ -213,6 +215,8 @@ export interface IWarmUpRenderer { compileAsync?: (scene: Object3D, camera: Camera, targetScene?: Object3D) => Promise; computeAsync?: (node: unknown) => Promise; pipelineCensus?: () => IPipelineCensus; + /** Performs one covered output-pipeline render without marking first present. */ + warmUpWorldPass?: (scene: Object3D, camera: Camera, targetScene?: Object3D) => void; /** The renderer's own render path, used to build every pass's pipelines once. */ render?: (scene: Object3D, camera: Camera) => void; raw?: unknown; @@ -725,9 +729,16 @@ function renderWarmPasses( camera: Camera, enabled: boolean, includeHidden: boolean, + targetScene?: Object3D, ): IWarmPassRender | undefined { - const render = renderer.render; - if (typeof render !== "function") return undefined; + const coveredWorldPass = renderer.warmUpWorldPass; + const render = + coveredWorldPass === undefined + ? renderer.render === undefined + ? undefined + : () => renderer.render?.(scene, camera) + : () => coveredWorldPass(scene, camera, targetScene); + if (render === undefined) return undefined; const skipped: IWarmPassRender = { passes: [], created: 0, @@ -735,19 +746,21 @@ function renderWarmPasses( cullingForced: 0, visibilityForced: 0, }; - if (!enabled) return skipped; + // `renderPasses: false` disables optional shadow/reflection work, but an output pipeline's world + // pass is the shared fallback for a chunk/cell compile and must still be covered once. + if (!enabled && coveredWorldPass === undefined) return skipped; const raw = typeof renderer.raw === "object" && renderer.raw !== null ? (renderer.raw as IWarmRenderHost) : undefined; const shadowMap = raw?.shadowMap; const passes: string[] = ["main"]; - if (shadowMap?.enabled !== false && hasShadowCaster(scene)) passes.push("shadow"); - if (hasReflector(scene)) passes.push("reflection"); + if (enabled && shadowMap?.enabled !== false && hasShadowCaster(scene)) passes.push("shadow"); + if (enabled && hasReflector(scene)) passes.push("reflection"); // `compileAsync` already built the main pass. A scene with neither a shadow map nor a reflection // has no second pass to build, so rendering it again would only draw a frame the cover hides for // nothing. Skip the draw and report the one pass that was compiled. - if (passes.length === 1) return { ...skipped, passes }; + if (passes.length === 1 && renderer.warmUpWorldPass === undefined) return { ...skipped, passes }; const censusBefore = readPipelineCensus(renderer); const sceneVisible = (scene as IWarmVisibilityHost).visible; @@ -788,7 +801,7 @@ function renderWarmPasses( if (sceneVisible === false) (scene as IWarmVisibilityHost).visible = true; if (raw !== undefined) raw.autoClear = false; if (shadowMap !== undefined) shadowMap.needsUpdate = true; - render(scene, camera); + render(); } finally { if (raw !== undefined) { if (hadOwnAutoClear) raw.autoClear = autoClear; @@ -932,20 +945,48 @@ export async function warmUpScene( return Promise.reject(error); } }; + const invokeSceneCompile = (): Promise => { + // Stock Three compileAsync has no render-depth input, so an output pipeline's covered render + // is the only path that can build the world graph against the target the first frame draws. + if (renderer.warmUpWorldPass !== undefined) { + const pass = renderWarmPassesOnce(); + return pass === undefined + ? Promise.reject(passError ?? new Error("covered world warm render did not settle")) + : Promise.resolve(); + } + return invokeCompile(scene, options.targetScene); + }; + let gpuDrainSettled = true; const finish = (report: IWarmUpReport): IWarmUpReport => { + const coveredPassIncomplete = + renderer.warmUpWorldPass !== undefined && passResult === undefined; + const drainNormalized = gpuDrainSettled + ? report + : { + ...report, + timedOut: true, + }; + const normalized = coveredPassIncomplete + ? { + ...drainNormalized, + abandoned: Math.max(1, drainNormalized.abandoned), + timedOut: drainNormalized.timedOut || now() >= startedAt + budgetMs, + } + : drainNormalized; const complete = - !report.unsupported && - !report.timedOut && - report.abandoned === 0 && - report.computeUnsupported !== true && - report.computeTimedOut !== true && - (report.computeAbandoned ?? 0) === 0; + gpuDrainSettled && + !normalized.unsupported && + !normalized.timedOut && + normalized.abandoned === 0 && + normalized.computeUnsupported !== true && + normalized.computeTimedOut !== true && + (normalized.computeAbandoned ?? 0) === 0; const finalCache = complete && cacheIdentity !== undefined ? storeWarmUpCache(cacheIdentity, candidates, computeNodes.length) : cacheStatus; - return reconcileWarmUpObservation({ ...report, cache: finalCache }); + return reconcileWarmUpObservation({ ...normalized, cache: finalCache }); }; // The hidden pass render runs once, after the compile and before the ending census snapshot, so @@ -954,6 +995,7 @@ export async function warmUpScene( // be worse than the stall it exists to prevent. let passResult: IWarmPassRender | undefined; let passRendered = false; + let passError: unknown; const renderWarmPassesOnce = (): IWarmPassRender | undefined => { if (passRendered) return passResult; passRendered = true; @@ -965,8 +1007,10 @@ export async function warmUpScene( camera, options.renderPasses !== false, options.includeHidden === true, + options.targetScene, ); - } catch { + } catch (error) { + passError = error; passResult = undefined; } return passResult; @@ -983,7 +1027,7 @@ export async function warmUpScene( )?.backend?.device?.queue; if (typeof queue?.onSubmittedWorkDone !== "function") return; const drained = Promise.resolve().then(() => queue.onSubmittedWorkDone?.()); - await withinDeadline(startedAt + budgetMs, drained, yieldFrame, now); + gpuDrainSettled = await withinDeadline(startedAt + budgetMs, drained, yieldFrame, now); }; const withPasses = (report: IWarmUpReport): IWarmUpReport => passResult === undefined @@ -1006,7 +1050,7 @@ export async function warmUpScene( const deadline = startedAt + budgetMs; const finished = now() < deadline - ? await withinDeadline(deadline, invokeCompile(scene), yieldFrame, now) + ? await withinDeadline(deadline, invokeSceneCompile(), yieldFrame, now) : false; options.onProgress?.({ done: finished ? 1 : 0, total: 1 }); renderWarmPassesOnce(); @@ -1022,14 +1066,14 @@ export async function warmUpScene( elapsedMs: now() - startedAt, unsupported: false, abandoned: finished ? 0 : 1, - timedOut: !finished, + timedOut: !finished && (passError === undefined || now() >= deadline), }), ); } const remaining = Math.max(0, startedAt + budgetMs - now()); const finished = remaining > 0 - ? await withinDeadline(startedAt + budgetMs, invokeCompile(scene), yieldFrame, now) + ? await withinDeadline(startedAt + budgetMs, invokeSceneCompile(), yieldFrame, now) : false; options.onProgress?.({ done: finished ? 1 : 0, total: 1 }); renderWarmPassesOnce(); @@ -1094,7 +1138,7 @@ export async function warmUpScene( // Bounded, because an unbounded await here is what held a real launch open forever. const finished = await withinDeadline( Math.min(deadline, now() + compileTimeoutMs), - invokeCompile(renderables[index] as Object3D, scene), + invokeCompile(renderables[index] as Object3D, options.targetScene ?? scene), yieldFrame, now, ); diff --git a/packages/core/src/world-cells.ts b/packages/core/src/world-cells.ts index 212e42cee4..add717de90 100644 --- a/packages/core/src/world-cells.ts +++ b/packages/core/src/world-cells.ts @@ -63,7 +63,7 @@ import type { IRendererLike } from "./renderer.js"; import { isStatic, markStatic } from "./static-transform.js"; import { addInSlices, loadAll } from "./streaming.js"; import { DEFAULT_TARGET_FPS } from "./target-fps.js"; -import { within, yieldToHost } from "./warmup.js"; +import { warmUpScene, yieldToHost } from "./warmup.js"; import { CellProxySelection, type IPreparedCellProxy, @@ -141,8 +141,6 @@ const CHUNK_MERGE_MAX_TRIANGLES = Math.floor( * compile that is not coming back — which is a slow frame rather than a chunk that never appears. */ const CHUNK_WARM_TIMEOUT_MS = 2000; -/** The engine frame clock, for the warm-up's bound. */ -const warmClock = (): number => globalThis.performance?.now() ?? Date.now(); /** Milliseconds one `update` may spend admitting streamed content, unless the game says otherwise. */ const DEFAULT_ADMISSION_BUDGET_MS = 2; /** @@ -9742,9 +9740,26 @@ export class WorldCells extends Group implements IComputeDriven { // No timeout counts as cancellation. This original promise owns every staged resource until it // settles, even if the cell/world has already left. Refusal keeps source and releases afterward. void Promise.resolve() - .then(() => renderer.compileAsync(prepared.root, camera, scene)) + .then(() => + warmUpScene(renderer, prepared.root, camera, { + budgetMs: CHUNK_WARM_TIMEOUT_MS, + renderPasses: false, + targetScene: scene, + }), + ) .then( - () => { + (report) => { + if ( + report.unsupported || + report.timedOut || + report.abandoned > 0 || + (renderer.warmUpWorldPass !== undefined && report.passes?.includes("main") !== true) + ) + throw new Error("cell proxy warm-up incomplete"); + renderer.warmUpScopeSettled?.( + `cell:${state.cell.key}:${String(state.cell.generation)}`, + prepared.root, + ); state.compiling = false; if (state.cancelled || !this.#cellLive(state.cell, state.cell.generation)) { state.cancelled = true; @@ -10108,17 +10123,32 @@ export class WorldCells extends Group implements IComputeDriven { * in no scene is skipped outright — nothing projects it, so a compile would build nothing, which is * the same answer `load()` gets, where the loading screen is about to draw it anyway. */ - async #warmChunk(chunk: Object3D): Promise { + async #warmChunk(chunk: Object3D, scopeLabel: string): Promise { const renderer = this.#renderer; const camera = this.#camera; if (renderer === undefined || camera === undefined || this.parent === null) return false; + let targetScene: Object3D = this; + while (targetScene.parent !== null) targetScene = targetScene.parent; + if (Reflect.get(targetScene, "isScene") !== true) return false; // The rejected compile is a pipeline this warm-up could not build, not a reason to fail the // admission: the frame that needs it will try again and fail there, where the error belongs. - return within( - renderer.compileAsync(chunk, camera, this), - CHUNK_WARM_TIMEOUT_MS, - yieldToHost, - warmClock, + return warmUpScene(renderer, chunk, camera, { + budgetMs: CHUNK_WARM_TIMEOUT_MS, + renderPasses: false, + targetScene, + yieldFrame: yieldToHost, + }).then( + (report) => { + const settled = + !report.unsupported && + report.observed.status === "complete" && + !report.timedOut && + report.abandoned === 0 && + (renderer.warmUpWorldPass === undefined || report.passes?.includes("main") === true); + if (settled) renderer.warmUpScopeSettled?.(scopeLabel, chunk); + return settled; + }, + () => false, ); } @@ -10200,7 +10230,8 @@ export class WorldCells extends Group implements IComputeDriven { // merge and the attach. Everything the world does not own is released when the cell that // asked for the chunk is gone, so a chunk that arrives after its cell left is torn down here // rather than compiled and thrown at a world that is not looking. - if (live()) await this.#warmChunk(object); + if (live()) + await this.#warmChunk(object, `chunk:${cell.key}:${String(cell.chunks.length)}`); if (!live()) { this.#failures += this.#disposeLoaded(object); continue; diff --git a/packages/create-threenative/__tests__/build.spec.ts b/packages/create-threenative/__tests__/build.spec.ts index 61b5a69365..06b03c93b9 100644 --- a/packages/create-threenative/__tests__/build.spec.ts +++ b/packages/create-threenative/__tests__/build.spec.ts @@ -169,6 +169,11 @@ describe("threenative build", () => { { install: false, target: "game", template: "starter" }, root, ); + // Keep pickup.wav to exercise the audio worker pool without basis-heavy fixtures. + for (const name of await readdir(path.join(target, "assets"))) { + if (name !== "pickup.wav") + await rm(path.join(target, "assets", name), { force: true, recursive: true }); + } await installDeterministicVite(target); const cli = path.resolve("packages/create-threenative/dist/threenative.js"); @@ -213,6 +218,7 @@ describe("threenative build", () => { code: 0, timedOut: false, }); + expect(result.output).toContain("cost pass audio: ran on 1 input"); }, 90_000); it("resolves every declared brand input through the live packaging-config caller", async () => { diff --git a/packages/create-threenative/agent-docs/references/assertion-reference.md b/packages/create-threenative/agent-docs/references/assertion-reference.md index 65dd55bac1..d7d0be1751 100644 --- a/packages/create-threenative/agent-docs/references/assertion-reference.md +++ b/packages/create-threenative/agent-docs/references/assertion-reference.md @@ -8,6 +8,42 @@ validator itself reads, so this page cannot disagree with the runtime. Assertion closed: a malformed assertion throws at load, and a scenario whose assertions all hold trivially fails `TN_PLAYTEST_SCENARIO_ASSERTS_NOTHING`. +### `tone` + +Bounds display-referred luminance from a captured PNG frame, using a shared 256-bin histogram. **Use when** that is the thing the scenario must prove. + +- **Supported on:** web, desktop, bevy · **Requires:** browser.screenshot + +| Field | Type | Required | +| --- | --- | --- | +| `atStep` | non-empty string | no | +| `region` | { x: number, y: number, width: number, height: number } | no | +| `compare` | { region: object, metric: string, minDelta: number } | no | +| `mean` | { min?: number, max?: number } | no | +| `p1` | { min?: number, max?: number } | no | +| `p50` | { min?: number, max?: number } | no | +| `p99` | { min?: number, max?: number } | no | +| `clipFraction` | { min?: number, max?: number } | no | +| `blackFraction` | { min?: number, max?: number } | no | + + +```json +{ + "tone": [ + { + "atStep": "landed", + "mean": { + "min": 60, + "max": 140 + }, + "p99": { + "min": 150 + } + } + ] +} +``` + ### `deviceMetrics` Reports the device's thermal, power and battery state around the run and judges whether the run is comparable with a cool one. **Use when** that is the thing the scenario must prove. @@ -426,7 +462,7 @@ Proves DOM state inside a same-origin webview overlay iframe. **Use when** that Proves console, network, runtime, and readiness diagnostics stayed clean. **Use when** that is the thing the scenario must prove. -- **Supported on:** web · **Requires:** browser.console, browser.network, runtime.diagnostics +- **Supported on:** web, desktop · **Requires:** browser.console, browser.network, runtime.diagnostics | Field | Type | Required | | --- | --- | --- | @@ -533,6 +569,31 @@ Proves projected entity visibility in the viewport. **Use when** that is the thi } ``` +### `audio` + +Proves which labelled audio cues the game actually played, and how often. **Use when** that is the thing the scenario must prove. + +- **Supported on:** web, desktop · **Requires:** runtime.audio + +| Field | Type | Required | +| --- | --- | --- | +| `cue` | string | yes | +| `minPlays` | non-negative integer | no | +| `maxPlays` | non-negative integer | no | +| `minGapMs` | non-negative integer | no | + + +```json +{ + "audio": [ + { + "cue": "speech:p01", + "maxPlays": 1 + } + ] +} +``` + ### `world` Proves runtime world metadata exposed by the application bridge. **Use when** that is the thing the scenario must prove. @@ -667,40 +728,6 @@ Proves animation evidence appeared in the effect log or runtime observation. **U } ``` -### `audio` - -Proves which labelled audio cues the game actually played, and how often. Every other audio check -is about the *file* — it exists, it decodes, it is inside its budget — and all of them stay green -while a one-shot line sounds a second time mid-match. **Use when** that is the thing the scenario -must prove. - -Label a cue by passing a cue name to the audio bus when the game plays it; unlabelled sounds are never counted. -When it fails, `observations.json/runtimeObservations/gameplay/audio/recentCues` says when each -play happened. - -- **Supported on:** web, desktop · **Requires:** runtime.audio - -| Field | Type | Required | -| --- | --- | --- | -| `cue` | string | yes | -| `minPlays` | non-negative integer (defaults to 1) | no | -| `maxPlays` | non-negative integer; 0 proves silence | no | -| `minGapMs` | non-negative integer; fewest ms since the line before it | no | - - -```json -{ - "audio": [ - { - "cue": "speech:p01", - "minPlays": 1, - "maxPlays": 1, - "minGapMs": 500 - } - ] -} -``` - ### `scene` Bounds the room the game is played in — the lights, materials, fog and camera framing the renderer was handed. Fails closed on a bridge that does not report the scene. **Use when** that is the thing the scenario must prove. @@ -816,6 +843,30 @@ Bounds when the application's startup milestones happened: the world entered, fi } ``` +### `pipelineCensus` + +Bounds node-builder state builds and render-pipeline creations after a complete runtime capture proves that explicit shader warm-up settled; missing, incomplete, or malformed census evidence fails closed. **Use when** that is the thing the scenario must prove. + +- **Supported on:** web, desktop, bevy · **Requires:** runtime.pipelineCensus + +| Field | Type | Required | +| --- | --- | --- | +| `maxNodeBuildsAfterWarmUpSettled` | non-negative integer | no | +| `maxRenderPipelineCreationsAfterWarmUpSettled` | non-negative integer | no | +| `warmUpScope` | string | no | +| `maxNodeBuildsAfterWarmUpScope` | non-negative integer | no | +| `maxRenderPipelineCreationsAfterWarmUpScope` | non-negative integer | no | + + +```json +{ + "pipelineCensus": { + "maxNodeBuildsAfterWarmUpSettled": 0, + "maxRenderPipelineCreationsAfterWarmUpSettled": 0 + } +} +``` + ### `renderChain` Proves the render chain reports its quality tier, authored stage ids/order, graph-output changes for named stages, and, when asserted, a bounded temporal-history rejection fraction. **Use when** that is the thing the scenario must prove. @@ -828,6 +879,7 @@ Proves the render chain reports its quality tier, authored stage ids/order, grap | `stages` | object | no | | `contributions` | object | no | | `velocity` | object | no | +| `perAdapter` | object | no | ```json @@ -856,20 +908,3 @@ Proves the render chain reports its quality tier, authored stage ids/order, grap } } ``` - - -## Captured tone - -Use `tone` to gate exposure from decoded frame pixels. Each row takes an optional `atStep` -(named step; omitted means final frame) and at least one metric bound. `mean`, `p1`, `p50`, `p99` -use display luminance 0..255; `clipFraction` and `blackFraction` use 0..1. Bounds take inclusive -min/max numbers. Empty, invalid and contradictory bounds throw at load; a missing capture fails. - -```json -{ "tone": [{ "atStep": "landed", "mean": { "min": 60, "max": 140 }, "p99": { "min": 150 }, "clipFraction": { "max": 0.005 } }] } -``` - -The host uses one rounded 256-bin Rec.709 luminance histogram; fully transparent pixels are -excluded. The CLI command `threenative-playtest tone shot.png other.png` reports the same six -metrics plus an unweighted frame-average row without starting a browser. Tone is an exposure -gate, not an aesthetic verdict. Retain and inspect the actual runtime screenshots. diff --git a/packages/playtest/__tests__/evaluator-semantics.spec.ts b/packages/playtest/__tests__/evaluator-semantics.spec.ts index d2d47fbfdd..b3feeab7fa 100644 --- a/packages/playtest/__tests__/evaluator-semantics.spec.ts +++ b/packages/playtest/__tests__/evaluator-semantics.spec.ts @@ -28,6 +28,88 @@ function evaluate(assert_: IPlaytestScenario["assert"], extra: object = {}) { } describe("evaluator semantics (characterization)", () => { + test("fails the pipeline census ceiling on work observed after warm-up settled", () => { + const event = (sequence: number, settledMs: number, beforeWarmUpSettled: boolean, nodeBuilds: number) => ({ + sequence, + programIdentity: `program-${sequence}`, + pipelineIdentity: `pipeline-${sequence}`, + kind: "render", + pass: "main", + mode: "sync", + status: "created", + vertex: { hash: `vertex-${sequence}`, bytes: 10 }, + fragment: { hash: `fragment-${sequence}`, bytes: 10 }, + provenance: { material: { name: "material" }, unknown: false }, + reasons: ["map"], + startedMs: settledMs - 1, + settledMs, + serviceMs: 1, + beforeFirstPresent: sequence === 1, + beforeWarmUpSettled, + ...(sequence === 2 ? { afterWarmUpScope: "chunk:0" } : {}), + nodeBuilds, + }); + const result = evaluate( + { + pipelineCensus: { + maxNodeBuildsAfterWarmUpSettled: 0, + maxRenderPipelineCreationsAfterWarmUpSettled: 0, + warmUpScope: "chunk:0", + maxRenderPipelineCreationsAfterWarmUpScope: 0, + }, + } as never, + { + observations: { + ...base.observations, + pipelineCensus: { + version: 1, + complete: true, + overflowed: false, + backend: { kind: "webgpu", identity: "webgpu:test" }, + clock: { source: "performance", originMs: 0 }, + build: { identity: "build:test" }, + adapter: { identity: "adapter:test", thermal: "unavailable" }, + firstPresent: { boundaryMs: 1, eventsSettled: 1 }, + warmUpSettled: { boundaryMs: 1, eventsSettled: 1, nodeBuilds: 1, renderPipelineCreations: 1 }, + warmUpScopes: [{ + label: "chunk:0", + boundaryMs: 1.5, + eventsSettled: 1, + nodeBuilds: 1, + renderPipelineCreations: 1, + firstDraw: { nodeBuilds: 1, renderPipelineCreations: 1 }, + }], + counts: { + lookups: 2, + creations: 2, + failures: 0, + pending: 0, + uniquePrograms: 2, + uniquePipelines: 2, + recordedEvents: 2, + droppedEvents: 0, + nodeBuilds: 2, + }, + events: [event(1, 1, true, 1), event(2, 2, false, 1)], + incompleteReasons: [], + }, + }, + }, + ); + + expect(result.assertions.map(({ id, pass }) => ({ id, pass }))).toEqual([ + { id: "pipelineCensus.maxRenderPipelineCreationsAfterWarmUpScope", pass: false }, + { id: "pipelineCensus.maxNodeBuildsAfterWarmUpSettled", pass: false }, + { id: "pipelineCensus.maxRenderPipelineCreationsAfterWarmUpSettled", pass: false }, + { id: "diagnostics", pass: true }, + ]); + expect(result.diagnostics.map(({ code }) => code)).toEqual([ + "TN_PLAYTEST_PIPELINE_CENSUS_CEILING_EXCEEDED", + "TN_PLAYTEST_PIPELINE_CENSUS_CEILING_EXCEEDED", + "TN_PLAYTEST_PIPELINE_CENSUS_CEILING_EXCEEDED", + ]); + }); + test("movement pins its verdict id and minimum-distance details", () => { const result = evaluate( { movement: { entity: "player", minDistance: 1 } } as never, diff --git a/packages/playtest/__tests__/pipeline-timing.spec.ts b/packages/playtest/__tests__/pipeline-timing.spec.ts index 8cc4adeddd..e8d5b5c8ec 100644 --- a/packages/playtest/__tests__/pipeline-timing.spec.ts +++ b/packages/playtest/__tests__/pipeline-timing.spec.ts @@ -445,6 +445,186 @@ describe("pipeline timing capture", () => { expect(summary.warmup.beforeFirstPresent).toBe(1); }); + it("reports node builds and render creations after the warm-up boundary", () => { + const events = [ + browserEvent({ nodeBuilds: 3, beforeWarmUpSettled: true }), + browserEvent({ + beforeFirstPresent: false, + beforeWarmUpSettled: false, + pipelineIdentity: "backend-pipeline-2", + sequence: 2, + settledMs: 5, + nodeBuilds: 1, + }), + browserEvent({ + beforeFirstPresent: false, + beforeWarmUpSettled: false, + provenance: { material: { name: "bloom" }, unknown: false }, + pipelineIdentity: "backend-pipeline-3", + sequence: 3, + settledMs: 6, + }), + ]; + const capture = parsePipelineCapture(browserCapture(events, { + counts: { + lookups: 3, + creations: 3, + failures: 0, + pending: 0, + uniquePrograms: 1, + uniquePipelines: 3, + recordedEvents: 3, + droppedEvents: 0, + nodeBuilds: 4, + }, + firstPresent: { boundaryMs: 4, eventsSettled: 1 }, + warmUpSettled: { + boundaryMs: 4, + eventsSettled: 1, + nodeBuilds: 3, + renderPipelineCreations: 1, + }, + })); + const summary = summarizePipelineCapture(capture); + + expect(summary.warmup).toMatchObject({ + afterWarmUpSettled: 2, + nodeBuildsAfterWarmUpSettled: 1, + renderPipelineCreationsAfterWarmUpSettled: 2, + warmedNodeBuildsAfterWarmUpSettled: 1, + warmedRenderPipelineCreationsAfterWarmUpSettled: 1, + }); + }); + + it("summarizes repeatable streamed warm-up scopes", () => { + const capture = parsePipelineCapture(browserCapture([ + browserEvent({ nodeBuilds: 1, beforeWarmUpSettled: true }), + browserEvent({ + beforeFirstPresent: false, + beforeWarmUpSettled: false, + afterWarmUpScope: "chunk:0", + sequence: 2, + settledMs: 5, + nodeBuilds: 0, + }), + ], { + counts: { + lookups: 2, + creations: 2, + failures: 0, + pending: 0, + uniquePrograms: 1, + uniquePipelines: 2, + recordedEvents: 2, + droppedEvents: 0, + nodeBuilds: 1, + }, + firstPresent: { boundaryMs: 4, eventsSettled: 1 }, + warmUpSettled: { boundaryMs: 1, eventsSettled: 1, nodeBuilds: 1, renderPipelineCreations: 1 }, + warmUpScopes: [{ label: "chunk:0", boundaryMs: 4, eventsSettled: 1, nodeBuilds: 1, renderPipelineCreations: 1 }], + })); + expect(summarizePipelineCapture(capture).warmup.scopes).toEqual([ + { label: "chunk:0", nodeBuilds: 0, renderPipelineCreations: 1 }, + ]); + }); + + it.each([ + ["keeps the maximum for each metric", [ + { nodeBuilds: 2, renderPipelineCreations: 0 }, + { nodeBuilds: 0, renderPipelineCreations: 3 }, + { nodeBuilds: 0, renderPipelineCreations: 0 }, + ], { nodeBuilds: 2, renderPipelineCreations: 3, observed: true }], + ["does not hide a missing later draw", [ + { nodeBuilds: 2, renderPipelineCreations: 1 }, undefined, + ], { nodeBuilds: 2, renderPipelineCreations: 1 }], + ["does not hide a missing earlier draw", [ + undefined, { nodeBuilds: 2, renderPipelineCreations: 1 }, + ], { nodeBuilds: 2, renderPipelineCreations: 1 }], + ])("repeated warm-up label %s", (_name, draws, expected) => { + const capture = parsePipelineCapture(browserCapture([browserEvent()], { + warmUpScopes: draws.map((firstDraw, index) => ({ + label: "chunk:0", + boundaryMs: 4 + index, + eventsSettled: 1, + nodeBuilds: 0, + renderPipelineCreations: 1, + ...(firstDraw === undefined ? {} : { firstDraw }), + })), + })); + expect(capture.complete).toBe(true); + expect(summarizePipelineCapture(capture).warmup.scopes).toEqual([ + { label: "chunk:0", ...expected }, + ]); + }); + + it("attributes node-build records when the later render reuses its cached pipeline", () => { + const capture = parsePipelineCapture( + browserCapture( + [ + browserEvent({ + beforeFirstPresent: false, + beforeWarmUpSettled: false, + afterWarmUpScope: "chunk:0", + settledMs: 5, + nodeBuilds: 0, + }), + ], + { + counts: { + lookups: 1, + creations: 1, + failures: 0, + pending: 0, + uniquePrograms: 1, + uniquePipelines: 1, + recordedEvents: 1, + droppedEvents: 0, + nodeBuilds: 2, + }, + warmUpSettled: { + boundaryMs: 1, + eventsSettled: 0, + nodeBuilds: 1, + renderPipelineCreations: 0, + }, + warmUpScopes: [ + { + label: "chunk:0", + boundaryMs: 2, + eventsSettled: 0, + nodeBuilds: 1, + renderPipelineCreations: 0, + firstDraw: { nodeBuilds: 1, renderPipelineCreations: 0 }, + }, + ], + nodeBuilds: [ + { + provenance: { material: { name: "brick" }, unknown: false }, + startedMs: 0, + beforeFirstPresent: true, + beforeWarmUpSettled: true, + }, + { + provenance: { material: { name: "brick" }, unknown: false }, + startedMs: 2, + beforeFirstPresent: true, + beforeWarmUpSettled: false, + afterWarmUpScope: "chunk:0", + }, + ], + }, + ), + ); + const summary = summarizePipelineCapture(capture); + expect(summary.warmup.warmedNodeBuildsAfterWarmUpSettled).toBe(1); + expect(summary.warmup.scopes).toEqual([ + { label: "chunk:0", nodeBuilds: 1, renderPipelineCreations: 0, observed: true }, + ]); + expect(summary.contributors.find(({ label }) => label === "material:brick")).toMatchObject({ + nodeBuilds: 2, + }); + }); + it("requires a version before treating an events object as a browser capture", () => { expect(() => parsePipelineCapture({ events: [browserEvent()] })).toThrow(/missing its version/u); }); diff --git a/packages/playtest/__tests__/scenario.spec.ts b/packages/playtest/__tests__/scenario.spec.ts index c1e340307b..bf504e2140 100644 --- a/packages/playtest/__tests__/scenario.spec.ts +++ b/packages/playtest/__tests__/scenario.spec.ts @@ -605,6 +605,10 @@ const FAMILY_SCENARIO_ASSERTS = { reachability: { artifact: "artifacts/envelope.json", entities: ["platform.a", "platform.b"] }, resources: [{ changed: true, gte: 1, id: "GameState", path: "coins" }], renderChain: { tier: "high", velocity: { maxRejectionFraction: 0.2 } }, + pipelineCensus: { + maxNodeBuildsAfterWarmUpSettled: 0, + maxRenderPipelineCreationsAfterWarmUpSettled: 0, + }, scene: { cameraClearsScene: true, fogClearsScene: true, litMaterialsAreLit: true, minVisibleLights: 1 }, sceneNodes: [{ select: { nameContains: "crate" }, visible: true }], causedBy: [ @@ -654,6 +658,8 @@ const FAMILY_PASS_IDS = [ "states.player", "renderChain.tier", "renderChain.velocity.rejectionFraction", + "pipelineCensus.maxNodeBuildsAfterWarmUpSettled", + "pipelineCensus.maxRenderPipelineCreationsAfterWarmUpSettled", "scene.minVisibleLights", "scene.litMaterialsAreLit", "scene.fogClearsScene", @@ -776,6 +782,46 @@ function familyReportObservations(fulfilled: boolean) { rejectionFraction: 0.1, }, }, + pipelineCensus: { + version: 1, + complete: true, + overflowed: false, + backend: { kind: "webgpu", identity: "fixture-backend" }, + clock: { source: "performance", originMs: 0 }, + build: { identity: "fixture-build" }, + adapter: { identity: "fixture-adapter", thermal: "unavailable" }, + firstPresent: { boundaryMs: 2, eventsSettled: 1 }, + warmUpSettled: { boundaryMs: 1, eventsSettled: 1, nodeBuilds: 1, renderPipelineCreations: 1 }, + counts: { + lookups: 1, + creations: 1, + failures: 0, + pending: 0, + uniquePrograms: 1, + uniquePipelines: 1, + recordedEvents: 1, + droppedEvents: 0, + nodeBuilds: 1, + }, + events: [{ + sequence: 1, + programIdentity: "fixture-program", + pipelineIdentity: "fixture-pipeline", + kind: "render", + pass: "main", + mode: "sync", + status: "created", + vertex: { bytes: 1, hash: "fixture-vertex" }, + provenance: { material: { name: "fixture" }, unknown: false }, + reasons: ["fixture"], + startedMs: 0, + settledMs: 1, + nodeBuilds: 1, + beforeFirstPresent: true, + beforeWarmUpSettled: true, + }], + incompleteReasons: [], + }, resources: { GameState: { after: { coins: 2 }, before: { coins: 0 } } }, sceneNodes: [ { @@ -898,6 +944,8 @@ test("should preserve every assertion family's result contract", async () => { "states.player", "renderChain.tier", "renderChain.velocity.rejectionFraction", + "pipelineCensus.maxNodeBuildsAfterWarmUpSettled", + "pipelineCensus.maxRenderPipelineCreationsAfterWarmUpSettled", // One result, not four: with no scene observed at all there is nothing to bound, and the // family says so once rather than failing each bound against nothing. "scene.observed", @@ -942,6 +990,8 @@ test("should preserve every assertion family's result contract", async () => { "TN_PLAYTEST_STATE_ASSERTION_FAILED", "TN_PLAYTEST_RENDER_CHAIN_UNOBSERVABLE", "TN_PLAYTEST_RENDER_CHAIN_UNOBSERVABLE", + "TN_PLAYTEST_PIPELINE_CENSUS_UNOBSERVABLE", + "TN_PLAYTEST_PIPELINE_CENSUS_UNOBSERVABLE", "TN_PLAYTEST_SCENE_UNOBSERVED", "TN_PLAYTEST_SCENE_NODES_UNOBSERVED", "TN_PLAYTEST_TRANSITIONS_UNOBSERVED", diff --git a/packages/playtest/__tests__/schema-boundaries.spec.ts b/packages/playtest/__tests__/schema-boundaries.spec.ts index fd2524780a..b8aea96eb7 100644 --- a/packages/playtest/__tests__/schema-boundaries.spec.ts +++ b/packages/playtest/__tests__/schema-boundaries.spec.ts @@ -468,6 +468,11 @@ describe("scenario schema boundaries", () => { expect(() => validateAssertions({ world: { seed: "random" } }, "invalid.json")).toThrow(/world\.seed/u); expect(() => validateAssertions({ reachability: { artifact: "", entities: ["a"] } }, "invalid.json")).toThrow(/reachability/u); expect(() => validateAssertions({ renderChain: "high" }, "invalid.json")).toThrow(/renderChain.*object/u); + expect(() => validateAssertions({ pipelineCensus: "zero" }, "invalid.json")).toThrow(/pipelineCensus.*object/u); + expect(() => validateAssertions({ pipelineCensus: { maxNodeBuildsAfterWarmUpSettled: "zero" } }, "invalid.json")).toThrow(/maxNodeBuildsAfterWarmUpSettled.*non-negative integer/u); + expect(() => validateAssertions({ pipelineCensus: {} }, "invalid.json")).toThrow(/pipeline census assertion observes nothing/u); + expect(() => validateAssertions({ pipelineCensus: { warmUpScope: "chunk:0" } }, "invalid.json")).toThrow(/pipeline census assertion observes nothing/u); + expect(() => validateAssertions({ pipelineCensus: { maxRenderPipelineCreationsAfterWarmUpScope: 0 } }, "invalid.json")).toThrow(/warmUpScope.*required/u); expect(() => validateAssertions({ performance: "fast" }, "invalid.json")).toThrow(/performance/u); expect(() => validateAssertions({ visual: [{}] }, "invalid.json")).toThrow(/visual.*declare/u); expect(() => validateAssertions({ aerodynamics: [{ entity: "plane", controls: [{ sign: "sideways", surface: "elevator" }] }] }, "invalid.json")).toThrow(/negative.*positive/u); diff --git a/packages/playtest/__tests__/vacuous-assertion.spec.ts b/packages/playtest/__tests__/vacuous-assertion.spec.ts index 986b344e0f..1b1fa0f4e6 100644 --- a/packages/playtest/__tests__/vacuous-assertion.spec.ts +++ b/packages/playtest/__tests__/vacuous-assertion.spec.ts @@ -180,7 +180,7 @@ test("the six held-value assertion kinds reject boolean and short triviality opt }); test("every registry entry carries rationale and the audit reclassifies the six held-value kinds", () => { - expect(PLAYTEST_ASSERTION_REGISTRY).toHaveLength(30); + expect(PLAYTEST_ASSERTION_REGISTRY).toHaveLength(31); expect(PLAYTEST_ASSERTION_REGISTRY.every(({ trivialityRationale }) => trivialityRationale.trim().length > 0)).toBe(true); expect(PLAYTEST_ASSERTION_REGISTRY.filter(({ triviality }) => triviality === "reject-initial-value").map(({ kind }) => kind)).toEqual([ "components", diff --git a/packages/playtest/src/assertion-evaluators.ts b/packages/playtest/src/assertion-evaluators.ts index 33afc26a73..4050123a82 100644 --- a/packages/playtest/src/assertion-evaluators.ts +++ b/packages/playtest/src/assertion-evaluators.ts @@ -17,6 +17,7 @@ import { emitCausedBy } from "./evaluators/caused-by.js"; import { emitSceneNodes } from "./evaluators/scene-nodes.js"; import { emitScene } from "./evaluators/scene.js"; import { emitStartup } from "./evaluators/startup.js"; +import { emitPipelineCensus } from "./evaluators/pipeline-census.js"; export { overlayNodeObservationKey } from "./evaluators/helpers.js"; @@ -36,6 +37,7 @@ export function evaluateRichPlaytestAssertions(input: { emitPerfSignalsWorld(ctx); emitWorldGameplay(ctx); emitRenderChain(ctx); + emitPipelineCensus(ctx); emitScene(ctx); emitSceneNodes(ctx); // A geometry request carries no bounds of its own, so its one result says the capture actually diff --git a/packages/playtest/src/assertion-schema.ts b/packages/playtest/src/assertion-schema.ts index 8fe5323a2c..9c8e8a5a15 100644 --- a/packages/playtest/src/assertion-schema.ts +++ b/packages/playtest/src/assertion-schema.ts @@ -611,6 +611,25 @@ export const PLAYTEST_ASSERTION_REGISTRY: readonly IPlaytestAssertionSchemaEntry triviality: "not-applicable", trivialityRationale: "The timeline is stamped by the runtime as milestones happen; a missing milestone fails the ceiling rather than passing it.", }, + { + description: "Bounds node-builder state builds and render-pipeline creations after a complete runtime capture proves that explicit shader warm-up settled; a named streamed scope must also prove its target-bound first visible world frame, and missing, incomplete, or malformed census evidence fails closed.", + example: { pipelineCensus: { maxNodeBuildsAfterWarmUpSettled: 0, maxRenderPipelineCreationsAfterWarmUpSettled: 0 } }, + fields: [ + { description: "Ceiling on distinct node-builder states first observed after warm-up settled.", name: "maxNodeBuildsAfterWarmUpSettled", type: "non-negative integer" }, + { description: "Ceiling on render-pipeline creations observed after warm-up settled.", name: "maxRenderPipelineCreationsAfterWarmUpSettled", type: "non-negative integer" }, + { description: "Label of a repeatable streamed warm-up scope.", name: "warmUpScope", type: "string" }, + { description: "Ceiling on node-builder state builds after the named warm-up scope.", name: "maxNodeBuildsAfterWarmUpScope", type: "non-negative integer" }, + { description: "Ceiling on render-pipeline creations after the named warm-up scope.", name: "maxRenderPipelineCreationsAfterWarmUpScope", type: "non-negative integer" }, + ], + cardinality: "object", + kind: "pipelineCensus", + observationPath: "pipelineCensus", + requiredCapabilities: ["runtime.pipelineCensus"], + resultIdPrefix: "pipelineCensus.", + supportedOn: ["web", "desktop", "bevy"], + triviality: "not-applicable", + trivialityRationale: "The runtime stamps a separate warm-up boundary and counts later observations; an initial scene value cannot satisfy a post-boundary ceiling.", + }, { description: "Proves the render chain reports its quality tier, authored stage ids/order, graph-output changes for named stages, and, when asserted, a bounded temporal-history rejection fraction.", example: { renderChain: { tier: "high", stages: { includes: ["outline", "kuwahara", "watercolor"], order: ["outline", "kuwahara", "watercolor"] }, contributions: { graphOutputChanged: ["outline", "kuwahara", "watercolor"] } } }, diff --git a/packages/playtest/src/evaluators/pipeline-census.ts b/packages/playtest/src/evaluators/pipeline-census.ts new file mode 100644 index 0000000000..2583fddadc --- /dev/null +++ b/packages/playtest/src/evaluators/pipeline-census.ts @@ -0,0 +1,98 @@ +import { parsePipelineCapture, summarizePipelineCapture } from "../runner/pipeline-summary.js"; +import type { IEvaluationContext } from "./context.js"; + +/** Evaluate post-warm-up pipeline work only when the capture proves its boundary and completeness. */ +export function emitPipelineCensus(ctx: IEvaluationContext): void { + const assertion = ctx.scenarioAssertions.pipelineCensus; + if (assertion === undefined) return; + + let capture: ReturnType | undefined; + let summary: ReturnType | undefined; + let parseError: string | undefined; + try { + capture = parsePipelineCapture({ + observations: { pipelineCensus: ctx.input.report.observations?.pipelineCensus }, + }); + summary = summarizePipelineCapture(capture); + } catch (error) { + parseError = error instanceof Error ? error.message : String(error); + } + + const boundary = capture?.warmUpSettled; + const nodeBuilds = summary?.warmup.warmedNodeBuildsAfterWarmUpSettled; + const renderPipelineCreations = summary?.warmup.warmedRenderPipelineCreationsAfterWarmUpSettled; + const scopeLabel = assertion.warmUpScope; + const scope = scopeLabel === undefined + ? undefined + : summary?.warmup.scopes.find(({ label }) => label === scopeLabel); + const boundaryUsable = capture?.complete === true && boundary !== undefined; + const usable = boundaryUsable + && nodeBuilds !== undefined + && renderPipelineCreations !== undefined; + const unobservable = parseError + ?? (boundary === undefined + ? "the capture has no warm-up-settled boundary" + : capture?.complete !== true + ? `the capture is incomplete: ${capture?.incompleteReasons.join("; ") ?? "unknown reason"}` + : nodeBuilds === undefined + ? "the capture has no per-material warm-up node-build count" + : "the capture has no per-material post-warm-up render-pipeline count"); + + const checks = [ + ["maxNodeBuildsAfterWarmUpSettled", nodeBuilds, "node builds"] as const, + ["maxRenderPipelineCreationsAfterWarmUpSettled", renderPipelineCreations, "render-pipeline creations"] as const, + ]; + if (assertion.maxNodeBuildsAfterWarmUpScope !== undefined || assertion.maxRenderPipelineCreationsAfterWarmUpScope !== undefined) { + const scopedUsable = capture?.complete === true && scope?.observed === true; + const scopedChecks = [ + ["maxNodeBuildsAfterWarmUpScope", scope?.nodeBuilds, "node builds"] as const, + ["maxRenderPipelineCreationsAfterWarmUpScope", scope?.renderPipelineCreations, "render-pipeline creations"] as const, + ]; + for (const [key, observed, label] of scopedChecks) { + const expected = assertion[key]; + if (expected === undefined) continue; + const pass = scopedUsable && observed !== undefined && observed <= expected; + const id = `pipelineCensus.${key}`; + ctx.assertions.push({ details: { boundary, complete: capture?.complete, expected, incompleteReasons: capture?.incompleteReasons, observed, scope: scopeLabel }, id, pass }); + if (!pass) + ctx.diagnostics.push({ + code: scopedUsable ? "TN_PLAYTEST_PIPELINE_CENSUS_CEILING_EXCEEDED" : "TN_PLAYTEST_PIPELINE_CENSUS_UNOBSERVABLE", + message: scopedUsable + ? `The pipeline census observed ${observed} ${label} after warm-up scope '${scopeLabel}', above the asserted ceiling of ${expected}.` + : `The pipeline census cannot evaluate '${id}': ${scopeLabel === undefined ? "the assertion has no warm-up scope label" : `scope '${scopeLabel}' is absent or incomplete`}.`, + observedRuntimePath: "observations.json/pipelineCensus", + severity: "error", + suggestion: scopedUsable ? "Move the affected first-use pipeline work into the streamed warm-up and verify the scoped census again." : "Run with runtime.pipelineCensus enabled and keep the named warm-up scope in the report.", + }); + } + } + for (const [key, observed, label] of checks) { + const expected = assertion[key]; + if (expected === undefined) continue; + const pass = usable && observed !== undefined && observed <= expected; + const id = `pipelineCensus.${key}`; + ctx.assertions.push({ + details: { + boundary, + complete: capture?.complete, + expected, + incompleteReasons: capture?.incompleteReasons, + observed, + }, + id, + pass, + }); + if (pass) continue; + ctx.diagnostics.push({ + code: usable ? "TN_PLAYTEST_PIPELINE_CENSUS_CEILING_EXCEEDED" : "TN_PLAYTEST_PIPELINE_CENSUS_UNOBSERVABLE", + message: usable + ? `The pipeline census observed ${observed} ${label} after warm-up settled, above the asserted ceiling of ${expected}.` + : `The pipeline census cannot evaluate '${id}': ${unobservable}.`, + observedRuntimePath: "observations.json/pipelineCensus", + severity: "error", + suggestion: usable + ? "Move the affected first-use pipeline work into the explicit warm-up and verify the post-warm-up census again." + : "Run with runtime.pipelineCensus enabled and keep the warm-up-settled boundary and complete capture in the report.", + }); + } +} diff --git a/packages/playtest/src/runner/pipeline-summary.ts b/packages/playtest/src/runner/pipeline-summary.ts index 75820479ea..62ef3a0fd2 100644 --- a/packages/playtest/src/runner/pipeline-summary.ts +++ b/packages/playtest/src/runner/pipeline-summary.ts @@ -44,7 +44,18 @@ export interface IPipelineCaptureEvent { readonly wallMs?: number; readonly label?: string; readonly error?: string; + readonly nodeBuilds?: number; readonly beforeFirstPresent?: boolean; + readonly beforeWarmUpSettled?: boolean; + readonly afterWarmUpScope?: string; +} + +export interface IPipelineCaptureNodeBuild { + readonly provenance: IPipelineCaptureProvenance; + readonly startedMs: number; + readonly beforeFirstPresent?: boolean; + readonly beforeWarmUpSettled?: boolean; + readonly afterWarmUpScope?: string; } export interface IPipelineCaptureCounts { @@ -56,6 +67,7 @@ export interface IPipelineCaptureCounts { readonly uniquePipelines: number; readonly recordedEvents: number; readonly droppedEvents: number; + readonly nodeBuilds?: number; } /** @@ -84,8 +96,26 @@ export interface IPipelineCapture { readonly build?: Readonly>; readonly adapter?: Readonly>; readonly firstPresent?: { readonly boundaryMs: number; readonly eventsSettled: number }; + readonly warmUpSettled?: { + readonly boundaryMs: number; + readonly eventsSettled: number; + readonly nodeBuilds: number; + readonly renderPipelineCreations: number; + }; + readonly warmUpScopes?: readonly { + readonly label: string; + readonly boundaryMs: number; + readonly eventsSettled: number; + readonly nodeBuilds: number; + readonly renderPipelineCreations: number; + readonly firstDraw?: { + readonly nodeBuilds: number; + readonly renderPipelineCreations: number; + }; + }[]; readonly counts: IPipelineCaptureCounts; readonly events: readonly IPipelineCaptureEvent[]; + readonly nodeBuilds?: readonly IPipelineCaptureNodeBuild[]; readonly incompleteReasons: readonly string[]; } @@ -100,9 +130,12 @@ export interface IPipelinePassSummary { } export interface IPipelineContributorSummary { + readonly afterWarmUpSettledCreations: number; + readonly afterWarmUpSettledNodeBuilds: number; readonly creations: number; readonly failures: number; readonly label: string; + readonly nodeBuilds: number; readonly sampleCount: number; /** Sum of observed device-call service durations; absent when the driver did not expose them. */ readonly serviceMs?: number; @@ -129,10 +162,23 @@ export interface IPipelineSizeTimeSummary { export interface IPipelineWarmupSummary { readonly afterFirstPresent: number; + readonly afterWarmUpSettled: number; readonly beforeFirstPresent: number; readonly eventsSettledAtFirstPresent?: number; + readonly eventsSettledAtWarmUpSettled?: number; + readonly nodeBuildsAfterWarmUpSettled?: number; + readonly renderPipelineCreationsAfterWarmUpSettled?: number; + /** Post-boundary work attributed to material contributors already observed during warm-up. */ + readonly warmedNodeBuildsAfterWarmUpSettled?: number; + readonly warmedRenderPipelineCreationsAfterWarmUpSettled?: number; readonly total: number; readonly unreported: number; + readonly scopes: readonly { + readonly label: string; + readonly nodeBuilds: number; + readonly renderPipelineCreations: number; + readonly observed?: boolean; + }[]; } export interface IPipelineSummary { @@ -453,8 +499,43 @@ export function summarizePipelineCapture(capture: IPipelineCapture): IPipelineSu const sizeMap = new Map(); let beforeFirstPresent = 0; let afterFirstPresent = 0; + let afterWarmUpSettled = 0; + let renderPipelineCreationsAfterWarmUpSettled = 0; + let warmedNodeBuildsAfterWarmUpSettled = 0; + let warmedRenderPipelineCreationsAfterWarmUpSettled = 0; + const scopeMap = new Map< + string, + { nodeBuilds: number; renderPipelineCreations: number; observed?: boolean } + >(); + const measuredScopeLabels = new Set(); + for (const scope of capture.warmUpScopes ?? []) { + const previous = scopeMap.get(scope.label); + if (scope.firstDraw !== undefined) measuredScopeLabels.add(scope.label); + scopeMap.set(scope.label, { + nodeBuilds: Math.max(previous?.nodeBuilds ?? 0, scope.firstDraw?.nodeBuilds ?? 0), + renderPipelineCreations: Math.max( + previous?.renderPipelineCreations ?? 0, + scope.firstDraw?.renderPipelineCreations ?? 0, + ), + ...(scope.firstDraw !== undefined && (previous === undefined || previous.observed === true) + ? { observed: true } + : {}), + }); + } let unreported = 0; let sizeSamples = 0; + const hasNodeBuildRecords = capture.nodeBuilds !== undefined; + const warmedMaterialLabels = + capture.warmUpSettled === undefined + ? undefined + : new Set([ + capture.events + .filter(({ beforeWarmUpSettled, provenance }) => beforeWarmUpSettled === true && provenance.material !== undefined) + .map(contributorLabel), + ...(capture.nodeBuilds ?? []) + .filter(({ beforeWarmUpSettled, provenance }) => beforeWarmUpSettled === true && provenance.material !== undefined) + .map(({ provenance }) => contributorLabelOfProvenance(provenance)), + ].flat()); for (const event of capture.events) { const serviceMs = event.serviceMs; const previousPass = passMap.get(event.pass); @@ -468,9 +549,17 @@ export function summarizePipelineCapture(capture: IPipelineCapture): IPipelineSu const label = contributorLabel(event); const previousContributor = contributorMap.get(label); contributorMap.set(label, { + afterWarmUpSettledCreations: + (previousContributor?.afterWarmUpSettledCreations ?? 0) + + (event.beforeWarmUpSettled === false && event.status === "created" ? 1 : 0), + afterWarmUpSettledNodeBuilds: + (previousContributor?.afterWarmUpSettledNodeBuilds ?? 0) + + (hasNodeBuildRecords || event.beforeWarmUpSettled !== false ? 0 : event.nodeBuilds ?? 0), label, creations: (previousContributor?.creations ?? 0) + 1, failures: (previousContributor?.failures ?? 0) + (event.status === "failed" ? 1 : 0), + nodeBuilds: (previousContributor?.nodeBuilds ?? 0) + + (hasNodeBuildRecords ? 0 : event.nodeBuilds ?? 0), sampleCount: (previousContributor?.sampleCount ?? 0) + (event.serviceMs === undefined ? 0 : 1), ...(serviceMs === undefined && previousContributor?.serviceMs === undefined ? {} @@ -497,8 +586,62 @@ export function summarizePipelineCapture(capture: IPipelineCapture): IPipelineSu if (event.beforeFirstPresent === true) beforeFirstPresent += 1; else if (event.beforeFirstPresent === false) afterFirstPresent += 1; else unreported += 1; + if (event.beforeWarmUpSettled === false) { + afterWarmUpSettled += 1; + if (event.kind === "render" && event.status === "created") + renderPipelineCreationsAfterWarmUpSettled += 1; + if (warmedMaterialLabels?.has(label) === true) { + if (!hasNodeBuildRecords) warmedNodeBuildsAfterWarmUpSettled += event.nodeBuilds ?? 0; + if (event.kind === "render" && event.status === "created") + warmedRenderPipelineCreationsAfterWarmUpSettled += 1; + } + } + if (event.afterWarmUpScope !== undefined) { + const scope = scopeMap.get(event.afterWarmUpScope) ?? { + nodeBuilds: 0, + renderPipelineCreations: 0, + }; + if (!measuredScopeLabels.has(event.afterWarmUpScope)) { + if (!hasNodeBuildRecords) scope.nodeBuilds += event.nodeBuilds ?? 0; + if (event.kind === "render" && event.status === "created") + scope.renderPipelineCreations += 1; + } + scopeMap.set(event.afterWarmUpScope, scope); + } + } + for (const nodeBuild of capture.nodeBuilds ?? []) { + const label = contributorLabelOfProvenance(nodeBuild.provenance); + const previousContributor = contributorMap.get(label); + contributorMap.set(label, { + afterWarmUpSettledCreations: previousContributor?.afterWarmUpSettledCreations ?? 0, + afterWarmUpSettledNodeBuilds: + (previousContributor?.afterWarmUpSettledNodeBuilds ?? 0) + + (nodeBuild.beforeWarmUpSettled === false ? 1 : 0), + label, + creations: previousContributor?.creations ?? 0, + failures: previousContributor?.failures ?? 0, + nodeBuilds: (previousContributor?.nodeBuilds ?? 0) + 1, + sampleCount: previousContributor?.sampleCount ?? 0, + ...(previousContributor?.serviceMs === undefined + ? {} + : { serviceMs: previousContributor.serviceMs }), + }); + if (nodeBuild.beforeWarmUpSettled === false && warmedMaterialLabels?.has(label) === true) + warmedNodeBuildsAfterWarmUpSettled += 1; + if (nodeBuild.afterWarmUpScope !== undefined) { + const scope = scopeMap.get(nodeBuild.afterWarmUpScope) ?? { + nodeBuilds: 0, + renderPipelineCreations: 0, + }; + if (!measuredScopeLabels.has(nodeBuild.afterWarmUpScope)) scope.nodeBuilds += 1; + scopeMap.set(nodeBuild.afterWarmUpScope, scope); + } } const source = capture.source; + const nodeBuildsAfterWarmUpSettled = + capture.warmUpSettled === undefined || capture.counts.nodeBuilds === undefined + ? undefined + : capture.counts.nodeBuilds - capture.warmUpSettled.nodeBuilds; return { adapter: capture.adapter, ...(capture.boundary === undefined ? {} : { boundary: capture.boundary }), @@ -537,8 +680,25 @@ export function summarizePipelineCapture(capture: IPipelineCapture): IPipelineSu total: capture.events.length, beforeFirstPresent, afterFirstPresent, + afterWarmUpSettled, unreported, ...(capture.firstPresent === undefined ? {} : { eventsSettledAtFirstPresent: capture.firstPresent.eventsSettled }), + ...(capture.warmUpSettled === undefined + ? {} + : { + eventsSettledAtWarmUpSettled: capture.warmUpSettled.eventsSettled, + renderPipelineCreationsAfterWarmUpSettled, + }), + ...(nodeBuildsAfterWarmUpSettled === undefined ? {} : { nodeBuildsAfterWarmUpSettled }), + ...(warmedMaterialLabels === undefined || warmedMaterialLabels.size === 0 + ? {} + : { + warmedNodeBuildsAfterWarmUpSettled, + warmedRenderPipelineCreationsAfterWarmUpSettled, + }), + scopes: [...scopeMap.entries()] + .map(([label, value]) => ({ label, ...value })) + .sort((left, right) => left.label.localeCompare(right.label)), }, }; } @@ -569,7 +729,10 @@ export function formatPipelineSummary(summary: IPipelineSummary): string { if (summary.contributors.length > 0) { lines.push("top material/object contributors:"); for (const contributor of summary.contributors.slice(0, 5)) { - lines.push(` ${contributor.label.padEnd(28)} ${contributor.creations} creation(s), ${formatService(contributor.serviceMs)} service`); + lines.push( + ` ${contributor.label.padEnd(28)} ${contributor.creations} creation(s), ` + + `${contributor.nodeBuilds} node build(s), ${formatService(contributor.serviceMs)} service`, + ); } } if (summary.programReasons.length > 0) { @@ -582,6 +745,18 @@ export function formatPipelineSummary(summary: IPipelineSummary): string { `warm-up: ${summary.warmup.beforeFirstPresent} before first present, ` + `${summary.warmup.afterFirstPresent} after, ${summary.warmup.unreported} unreported`, ); + if (summary.warmup.eventsSettledAtWarmUpSettled !== undefined) { + lines.push( + `warm-up settled: ${summary.warmup.afterWarmUpSettled} event(s) after, ` + + `${summary.warmup.nodeBuildsAfterWarmUpSettled ?? "unreported"} node build(s), ` + + `${summary.warmup.renderPipelineCreationsAfterWarmUpSettled} render pipeline creation(s)`, + ); + if (summary.warmup.warmedNodeBuildsAfterWarmUpSettled !== undefined) + lines.push( + `warmed materials after warm-up: ${summary.warmup.warmedNodeBuildsAfterWarmUpSettled} node build(s), ` + + `${summary.warmup.warmedRenderPipelineCreationsAfterWarmUpSettled} render pipeline creation(s)`, + ); + } if (summary.sizeTime.byPass.length > 0) { lines.push("shader size/time by pass:"); for (const pass of summary.sizeTime.byPass) { @@ -600,6 +775,7 @@ function parseBrowserCapture(value: JsonRecord): IPipelineCapture { const eventsValue = value.events; if (!Array.isArray(eventsValue)) throw malformed("browser capture is missing an events array"); const events = eventsValue.map((event, index) => normaliseBrowserEvent(event, index)); + const nodeBuilds = parseNodeBuilds(value.nodeBuilds); validateEventSequence(events, "browser capture", true); const declaredCounts = parseCounts(value.counts); const reasons: string[] = []; @@ -659,6 +835,71 @@ function parseBrowserCapture(value: JsonRecord): IPipelineCapture { reasons.push("an event is marked after first present but settled before its boundary"); } } + const warmUpSettled = parseWarmUpSettled(value.warmUpSettled); + const warmUpScopes = parseWarmUpScopes(value.warmUpScopes); + if (warmUpSettled !== undefined) { + const settledAtWarmUp = events.filter( + ({ settledMs }) => settledMs !== undefined && settledMs <= warmUpSettled.boundaryMs, + ).length; + if (warmUpSettled.eventsSettled !== settledAtWarmUp) + reasons.push("warm-up settled event count does not match event timings"); + const renderCreationsAtWarmUp = events.filter( + ({ kind, status, settledMs }) => + kind === "render" && + status === "created" && + settledMs !== undefined && + settledMs <= warmUpSettled.boundaryMs, + ).length; + if (warmUpSettled.renderPipelineCreations !== renderCreationsAtWarmUp) + reasons.push("warm-up settled render creation count does not match event timings"); + for (const event of events) { + if (event.beforeWarmUpSettled === true && event.settledMs !== undefined && event.settledMs > warmUpSettled.boundaryMs) + reasons.push("an event is marked before warm-up settled but settled after its boundary"); + if (event.beforeWarmUpSettled === false && event.settledMs !== undefined && event.settledMs <= warmUpSettled.boundaryMs) + reasons.push("an event is marked after warm-up settled but settled before its boundary"); + if (event.beforeWarmUpSettled === undefined) + reasons.push("an event is missing its warm-up settled boundary tag"); + if (event.beforeWarmUpSettled === false && event.nodeBuilds === undefined) + reasons.push("an event after warm-up settled is missing its node-build count"); + } + if (declaredCounts.nodeBuilds !== undefined && declaredCounts.nodeBuilds < warmUpSettled.nodeBuilds) + reasons.push("warm-up settled node-build count exceeds the total node-build count"); + for (const [index, nodeBuild] of (nodeBuilds ?? []).entries()) { + if (nodeBuild.beforeWarmUpSettled === undefined) + reasons.push(`node build ${index + 1} is missing its warm-up settled boundary tag`); + else if ( + (nodeBuild.beforeWarmUpSettled && nodeBuild.startedMs > warmUpSettled.boundaryMs) || + (!nodeBuild.beforeWarmUpSettled && nodeBuild.startedMs <= warmUpSettled.boundaryMs) + ) + reasons.push(`node build ${index + 1} has an invalid warm-up settled boundary tag`); + } + } + if (warmUpScopes !== undefined) { + const labels = new Set(warmUpScopes.map(({ label }) => label)); + for (const event of events) { + if (event.afterWarmUpScope !== undefined && !labels.has(event.afterWarmUpScope)) + reasons.push(`event ${event.sequence} names an unknown warm-up scope`); + } + for (const scope of warmUpScopes) { + const settled = events.filter( + ({ settledMs }) => settledMs !== undefined && settledMs <= scope.boundaryMs, + ).length; + if (settled !== scope.eventsSettled) + reasons.push(`warm-up scope '${scope.label}' event count does not match event timings`); + const creations = events.filter( + ({ kind, status, settledMs }) => + kind === "render" && status === "created" && settledMs !== undefined && settledMs <= scope.boundaryMs, + ).length; + if (creations !== scope.renderPipelineCreations) + reasons.push(`warm-up scope '${scope.label}' render creation count does not match event timings`); + } + for (const [index, nodeBuild] of (nodeBuilds ?? []).entries()) { + if (nodeBuild.afterWarmUpScope !== undefined && !labels.has(nodeBuild.afterWarmUpScope)) + reasons.push(`node build ${index + 1} names an unknown warm-up scope`); + } + } + if (nodeBuilds !== undefined && declaredCounts.nodeBuilds !== undefined && nodeBuilds.length !== declaredCounts.nodeBuilds) + reasons.push("node-build count does not match the nodeBuilds array"); return { version: PIPELINE_CAPTURE_VERSION, source: "browser", @@ -669,8 +910,11 @@ function parseBrowserCapture(value: JsonRecord): IPipelineCapture { ...(build === undefined ? {} : { build }), ...(adapter === undefined ? {} : { adapter }), ...(firstPresent === undefined ? {} : { firstPresent }), + ...(warmUpSettled === undefined ? {} : { warmUpSettled }), + ...(warmUpScopes === undefined ? {} : { warmUpScopes }), counts: declaredCounts, events, + ...(nodeBuilds === undefined ? {} : { nodeBuilds }), incompleteReasons: reasons, }; } @@ -922,7 +1166,10 @@ function normaliseBrowserEvent(value: unknown, index: number): IPipelineCaptureE ...optionalNumber(event.queueMs, "queueMs", index), ...optionalNumber(event.wallMs, "wallMs", index), ...(event.error === undefined ? {} : { error: stringValue(event.error, `event ${index + 1} error`) }), + ...(event.nodeBuilds === undefined ? {} : { nodeBuilds: nonNegativeInteger(event.nodeBuilds, `event ${index + 1} nodeBuilds`) }), ...(event.beforeFirstPresent === undefined ? {} : { beforeFirstPresent: booleanValue(event.beforeFirstPresent, `event ${index + 1} beforeFirstPresent`) }), + ...(event.beforeWarmUpSettled === undefined ? {} : { beforeWarmUpSettled: booleanValue(event.beforeWarmUpSettled, `event ${index + 1} beforeWarmUpSettled`) }), + ...(event.afterWarmUpScope === undefined ? {} : { afterWarmUpScope: stringValue(event.afterWarmUpScope, `event ${index + 1} afterWarmUpScope`) }), }; validateEventTiming(result, `event ${index + 1}`); if (status === "failed" && result.error === undefined) @@ -1003,6 +1250,7 @@ function parseCounts(value: unknown): IPipelineCaptureCounts { uniquePipelines: nonNegativeInteger(source.uniquePipelines, "counts uniquePipelines"), recordedEvents: nonNegativeInteger(source.recordedEvents, "counts recordedEvents"), droppedEvents: nonNegativeInteger(source.droppedEvents, "counts droppedEvents"), + ...(source.nodeBuilds === undefined ? {} : { nodeBuilds: nonNegativeInteger(source.nodeBuilds, "counts nodeBuilds") }), }; } @@ -1052,6 +1300,78 @@ function parseFirstPresent(value: unknown): IPipelineCapture["firstPresent"] { }; } +function parseWarmUpSettled(value: unknown): IPipelineCapture["warmUpSettled"] { + if (value === undefined) return undefined; + const source = asRecord(value); + if (source === undefined) throw malformed("warmUpSettled is not an object"); + return { + boundaryMs: nonNegativeNumber(source.boundaryMs, "warmUpSettled boundaryMs"), + eventsSettled: nonNegativeInteger(source.eventsSettled, "warmUpSettled eventsSettled"), + nodeBuilds: nonNegativeInteger(source.nodeBuilds, "warmUpSettled nodeBuilds"), + renderPipelineCreations: nonNegativeInteger( + source.renderPipelineCreations, + "warmUpSettled renderPipelineCreations", + ), + }; +} + +function parseWarmUpScopes(value: unknown): IPipelineCapture["warmUpScopes"] { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw malformed("warmUpScopes is not an array"); + return value.map((entry, index) => { + const source = asRecord(entry); + if (source === undefined) throw malformed(`warmUpScopes[${index}] is not an object`); + const label = stringValue(source.label, `warmUpScopes[${index}] label`); + if (label.trim() === "") throw malformed(`warmUpScopes[${index}] label is empty`); + const firstDraw = source.firstDraw === undefined + ? undefined + : (() => { + const draw = asRecord(source.firstDraw); + if (draw === undefined) throw malformed(`warmUpScopes[${index}] firstDraw is not an object`); + return { + nodeBuilds: nonNegativeInteger(draw.nodeBuilds, `warmUpScopes[${index}] firstDraw nodeBuilds`), + renderPipelineCreations: nonNegativeInteger( + draw.renderPipelineCreations, + `warmUpScopes[${index}] firstDraw renderPipelineCreations`, + ), + }; + })(); + return { + label, + boundaryMs: nonNegativeNumber(source.boundaryMs, `warmUpScopes[${index}] boundaryMs`), + eventsSettled: nonNegativeInteger(source.eventsSettled, `warmUpScopes[${index}] eventsSettled`), + nodeBuilds: nonNegativeInteger(source.nodeBuilds, `warmUpScopes[${index}] nodeBuilds`), + renderPipelineCreations: nonNegativeInteger( + source.renderPipelineCreations, + `warmUpScopes[${index}] renderPipelineCreations`, + ), + ...(firstDraw === undefined ? {} : { firstDraw }), + }; + }); +} + +function parseNodeBuilds(value: unknown): IPipelineCapture["nodeBuilds"] { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw malformed("nodeBuilds is not an array"); + return value.map((entry, index) => { + const source = asRecord(entry); + if (source === undefined) throw malformed(`nodeBuilds[${index}] is not an object`); + return { + provenance: normaliseProvenance(source.provenance, `nodeBuilds[${index}]`), + startedMs: nonNegativeNumber(source.startedMs, `nodeBuilds[${index}] startedMs`), + ...(source.beforeFirstPresent === undefined + ? {} + : { beforeFirstPresent: booleanValue(source.beforeFirstPresent, `nodeBuilds[${index}] beforeFirstPresent`) }), + ...(source.beforeWarmUpSettled === undefined + ? {} + : { beforeWarmUpSettled: booleanValue(source.beforeWarmUpSettled, `nodeBuilds[${index}] beforeWarmUpSettled`) }), + ...(source.afterWarmUpScope === undefined + ? {} + : { afterWarmUpScope: stringValue(source.afterWarmUpScope, `nodeBuilds[${index}] afterWarmUpScope`) }), + }; + }); +} + function validateCaptureVersion(value: unknown, label: string): void { const event = asRecord(value); if (event === undefined) throw malformed(`${label} is not an object`); @@ -1114,11 +1434,15 @@ function nativePass(pass: string, labelValue: unknown): string { } function contributorLabel(event: IPipelineCaptureEvent): string { - const material = event.provenance.material; + return contributorLabelOfProvenance(event.provenance); +} + +function contributorLabelOfProvenance(provenance: IPipelineCaptureProvenance): string { + const material = provenance.material; if (material?.name !== undefined) return `material:${material.name}`; if (material?.id !== undefined) return `material#${material.id}`; if (material?.type !== undefined) return `material:${material.type}`; - const object = event.provenance.object; + const object = provenance.object; if (object?.name !== undefined) return `object:${object.name}`; if (object?.id !== undefined) return `object#${object.id}`; return "unknown provenance"; diff --git a/packages/playtest/src/scenario/generated-assertion-validators.ts b/packages/playtest/src/scenario/generated-assertion-validators.ts index d059cf8d71..3051919ca4 100644 --- a/packages/playtest/src/scenario/generated-assertion-validators.ts +++ b/packages/playtest/src/scenario/generated-assertion-validators.ts @@ -236,6 +236,13 @@ export const GENERATED_ASSERTION_FIELD_VALIDATORS: Readonly typeof value === "number" && Number.isFinite(value), "maxReadyMs": (value: unknown) => typeof value === "number" && Number.isFinite(value), }), + "pipelineCensus": Object.freeze({ + "maxNodeBuildsAfterWarmUpSettled": (value: unknown) => typeof value === "number" && Number.isInteger(value) && value >= 0, + "maxRenderPipelineCreationsAfterWarmUpSettled": (value: unknown) => typeof value === "number" && Number.isInteger(value) && value >= 0, + "warmUpScope": (value: unknown) => typeof value === "string" && value.trim() !== "", + "maxNodeBuildsAfterWarmUpScope": (value: unknown) => typeof value === "number" && Number.isInteger(value) && value >= 0, + "maxRenderPipelineCreationsAfterWarmUpScope": (value: unknown) => typeof value === "number" && Number.isInteger(value) && value >= 0, + }), "renderChain": Object.freeze({ "tier": (value: unknown) => typeof value === "string" && value.trim() !== "", "stages": (value: unknown) => isRecord(value), diff --git a/packages/playtest/src/scenario/schema-accessors.ts b/packages/playtest/src/scenario/schema-accessors.ts index 3501051139..c8cb3c699a 100644 --- a/packages/playtest/src/scenario/schema-accessors.ts +++ b/packages/playtest/src/scenario/schema-accessors.ts @@ -4,7 +4,7 @@ import { PlaytestScenarioError, invalidScenario, rejectUnknownKeys } from "./err import { MIN_TRIVIALITY_REASON_LENGTH, NUMERIC_COMPARISON_KEYS, PLAYTEST_PER_ADAPTER_KEYS } from "./schema-base.js"; import { GENERATED_ASSERTION_FIELD_VALIDATORS } from "./generated-assertion-validators.js"; import type { IPlaytestVisualAssertion, PlaytestTarget, IPlaytestPerformanceAssertion, IPlaytestFramebufferCoverageAssertion, IPlaytestStateAssertion, IPlaytestTagCountAssertion, IPlaytestContactAssertion, IPlaytestSignalAssertion, IPlaytestAnimationAssertion, - IPlaytestSceneAssertion, IPlaytestSceneNodesAssertion, IPlaytestSceneNodeSelectorSpec, IPlaytestCausedByAssertion, IPlaytestCauseSpec, IPlaytestEffectSpec, IPlaytestVisibilityAssertion, IPlaytestPathAssertion, IPlaytestResourceAssertion, IPlaytestResourcePathAlternative, IPlaytestViewport, IPlaytestScenarioAssertions, IPlaytestDeviceMetricsAssertion, IPlaytestParityAssertion, IPlaytestRenderChainAssertion, IPlaytestRenderChainExpectation, IPlaytestStartupAssertion, IPlaytestVisualRegionTarget } from "./schema-base.js"; + IPlaytestSceneAssertion, IPlaytestSceneNodesAssertion, IPlaytestSceneNodeSelectorSpec, IPlaytestCausedByAssertion, IPlaytestCauseSpec, IPlaytestEffectSpec, IPlaytestVisibilityAssertion, IPlaytestPathAssertion, IPlaytestResourceAssertion, IPlaytestResourcePathAlternative, IPlaytestViewport, IPlaytestScenarioAssertions, IPlaytestDeviceMetricsAssertion, IPlaytestParityAssertion, IPlaytestRenderChainAssertion, IPlaytestRenderChainExpectation, IPlaytestStartupAssertion, IPlaytestPipelineCensusAssertion, IPlaytestVisualRegionTarget } from "./schema-base.js"; export function validateVisualAssertion(value: unknown, scenarioPath: string, objectPath: string): IPlaytestVisualAssertion { const record = requireRecord(value, scenarioPath, objectPath); // A non-record entry used to be dropped from the array, and a mistyped key @@ -531,6 +531,44 @@ export function validateStartupAssertion( return result; } +const PIPELINE_CENSUS_CEILINGS = [ + "maxNodeBuildsAfterWarmUpSettled", + "maxRenderPipelineCreationsAfterWarmUpSettled", + "maxNodeBuildsAfterWarmUpScope", + "maxRenderPipelineCreationsAfterWarmUpScope", +] as const; + +export function validatePipelineCensusAssertion( + value: unknown, + scenarioPath: string, + objectPath: string, +): IPlaytestPipelineCensusAssertion { + const record = requireRecord(value, scenarioPath, objectPath); + rejectUnknownKeys(record, [...PIPELINE_CENSUS_CEILINGS, "warmUpScope"], scenarioPath, objectPath); + const result: Partial = {}; + if (record.warmUpScope !== undefined) { + if (typeof record.warmUpScope !== "string" || record.warmUpScope.trim() === "") + throw invalidScenario(scenarioPath, `'${objectPath}.warmUpScope' must be a non-empty string`); + result.warmUpScope = record.warmUpScope; + } + for (const key of PIPELINE_CENSUS_CEILINGS) { + const ceiling = optionalNonNegativeInteger(record, key, scenarioPath, objectPath); + if (ceiling !== undefined) result[key] = ceiling; + } + const hasScopeCeiling = + result.maxNodeBuildsAfterWarmUpScope !== undefined || + result.maxRenderPipelineCreationsAfterWarmUpScope !== undefined; + if (hasScopeCeiling && result.warmUpScope === undefined) + throw invalidScenario(scenarioPath, `'${objectPath}.warmUpScope' is required for a scoped pipeline census ceiling`); + if (!PIPELINE_CENSUS_CEILINGS.some((key) => result[key] !== undefined)) { + throw invalidScenario( + scenarioPath, + `'${objectPath}' must set at least one of ${PIPELINE_CENSUS_CEILINGS.join(", ")}; an empty pipeline census assertion observes nothing.`, + ); + } + return result; +} + /** Re-exported so a scenario author and the protocol never disagree about the phase names. */ export { PLAYTEST_FRAME_BUDGET_PHASES, PLAYTEST_FRAME_PASS_KINDS }; diff --git a/packages/playtest/src/scenario/schema-base.ts b/packages/playtest/src/scenario/schema-base.ts index 4f617236f0..a4e95c48c4 100644 --- a/packages/playtest/src/scenario/schema-base.ts +++ b/packages/playtest/src/scenario/schema-base.ts @@ -475,6 +475,15 @@ export interface IPlaytestStartupAssertion { maxReadyMs?: number; } +/** Ceilings on work observed after the runtime's explicit shader warm-up settled. */ +export interface IPlaytestPipelineCensusAssertion { + maxNodeBuildsAfterWarmUpSettled?: number; + maxRenderPipelineCreationsAfterWarmUpSettled?: number; + warmUpScope?: string; + maxNodeBuildsAfterWarmUpScope?: number; + maxRenderPipelineCreationsAfterWarmUpScope?: number; +} + export interface IPlaytestFramebufferCoverageAssertion { backdrop: [number, number, number]; grid?: { @@ -628,6 +637,7 @@ export interface IPlaytestScenarioAssertions { overlayNodes?: IPlaytestOverlayNodeAssertion[]; parity?: IPlaytestParityAssertion; performance?: IPlaytestPerformanceAssertion; + pipelineCensus?: IPlaytestPipelineCensusAssertion; renderChain?: IPlaytestRenderChainAssertion; reachability?: IPlaytestReachabilityAssertion; resources?: IPlaytestResourceAssertion[]; diff --git a/packages/playtest/src/scenario/schema-validate.ts b/packages/playtest/src/scenario/schema-validate.ts index 5f57cecd78..90f0d12b82 100644 --- a/packages/playtest/src/scenario/schema-validate.ts +++ b/packages/playtest/src/scenario/schema-validate.ts @@ -3,7 +3,7 @@ import type { IToneRegion } from "../tone.js"; import type { IPlaytestToneAssertion } from "./schema-base.js"; import { PLAYTEST_ASSERTION_REGISTRY } from "../assertions.js"; import { invalidScenario, invalidStep, rejectUnknownKeys } from "./errors.js"; -import { isRecord, validateViewport, positiveInteger, hasKey, validateOptionalNumberTuple, validateAssertionKeys, validateDeviceMetricsAssertion, validateParityAssertion, validatePerformanceAssertion, validateFramebufferCoverageAssertion, validateRenderChainAssertion, validateStartupAssertion, validateSceneAssertion, validateSceneNodesAssertion, validateCausedByAssertion, validateAnimationAssertion, validateContactAssertion, validatePathAssertion, validateNumberTuple, validateResourcePathAssertion, validateSignalAssertion, validateStateAssertion, validateTagCountAssertion, validateVisibilityAssertion, validateVisualAssertion, requireRecord, optionalNumber, requireString, optionalPositiveNumber, present, optionalTrivialityReason, optionalString, optionalPositiveInteger, optionalTargetArray, optionalBoolean, requireArray, describeValue, optionalNonNegativeNumber, optionalNonNegativeInteger } from "./schema-accessors.js"; +import { isRecord, validateViewport, positiveInteger, hasKey, validateOptionalNumberTuple, validateAssertionKeys, validateDeviceMetricsAssertion, validateParityAssertion, validatePerformanceAssertion, validateFramebufferCoverageAssertion, validateRenderChainAssertion, validateStartupAssertion, validatePipelineCensusAssertion, validateSceneAssertion, validateSceneNodesAssertion, validateCausedByAssertion, validateAnimationAssertion, validateContactAssertion, validatePathAssertion, validateNumberTuple, validateResourcePathAssertion, validateSignalAssertion, validateStateAssertion, validateTagCountAssertion, validateVisibilityAssertion, validateVisualAssertion, requireRecord, optionalNumber, requireString, optionalPositiveNumber, present, optionalTrivialityReason, optionalString, optionalPositiveInteger, optionalTargetArray, optionalBoolean, requireArray, describeValue, optionalNonNegativeNumber, optionalNonNegativeInteger } from "./schema-accessors.js"; import { NUMERIC_COMPARISON_KEYS } from "./schema-base.js"; import type { IPlaytestGeometryCaptureRequest } from "../protocol.js"; import type { IPlaytestAimRequest, IPlaytestAimTarget, IPlaytestPlaceRequest, IPlaytestSpawnRequest, IPlaytestScenario, IPlaytestArtifactRequest, IPlaytestParityConfig, PlaytestTarget, IPlaytestScenarioSetup, IPlaytestSetupResource, IPlaytestSetupEntityTransform, IPlaytestStep, IPlaytestPointer, IPlaytestScenarioAssertions, IPlaytestAudioAssertion, IPlaytestWorldAssertion, IPlaytestReachabilityAssertion, IPlaytestSettledAssertion, IPlaytestOverlayNodeAssertion, IPlaytestComponentAssertion, IPlaytestAerodynamicsAssertion, IPlaytestOccludedAssertion, IPlaytestResourceWait } from "./schema-base.js"; @@ -1016,6 +1016,9 @@ export function validateAssertions(value: Record, scenarioPath: const startup = value.startup === undefined ? undefined : validateStartupAssertion(value.startup, scenarioPath, "assert.startup"); + const pipelineCensus = value.pipelineCensus === undefined + ? undefined + : validatePipelineCensusAssertion(value.pipelineCensus, scenarioPath, "assert.pipelineCensus"); const scene = value.scene === undefined ? undefined : validateSceneAssertion(value.scene, scenarioPath, "assert.scene"); @@ -1081,6 +1084,7 @@ export function validateAssertions(value: Record, scenarioPath: ...(performance === undefined ? {} : { performance }), ...(renderChain === undefined ? {} : { renderChain }), ...(startup === undefined ? {} : { startup }), + ...(pipelineCensus === undefined ? {} : { pipelineCensus }), ...(scene === undefined ? {} : { scene }), ...(geometry === undefined ? {} : { geometry }), ...(hasKey(value, "causedBy") diff --git a/playwright.config.ts b/playwright.config.ts index a09ec371f8..cfa4adebf2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -522,7 +522,7 @@ export default defineConfig({ { name: "abyss-framework-replay", testDir: "./tests/browser-replay", - use: { baseURL: `http://127.0.0.1:${replayPort}` }, + use: { baseURL: `http://127.0.0.1:${replayPort}`, headless: false }, }, ], });