diff --git a/.github/workflows/native-platforms.yml b/.github/workflows/native-platforms.yml index 2a0a2a3f90..8a7b91a10d 100644 --- a/.github/workflows/native-platforms.yml +++ b/.github/workflows/native-platforms.yml @@ -988,6 +988,38 @@ jobs: run: ccache --zero-stats - name: Build the native host run: pnpm --filter @threenative/runtime-native native:build + - name: Verify the shipped Crowd rate scenario on desktop + # Software adapters prove the native scenario contract, not physical GPU performance. + env: + TN_PLAYTEST_ALLOW_SOFTWARE: "1" + TN_FRAME_SPANS: "1" + shell: bash + run: | + set -euo pipefail + project="$GITHUB_WORKSPACE/packages/runtime-native/artifacts/conformance/crowd-rate-project" + mkdir -p "$project" + pnpm --filter skinned-crowd exec node ../../packages/runtime-native/scripts/bundle.mjs \ + --project . --entry src/game.ts --target desktop --output "$project/skinned-crowd-native.js" + cp examples/skinned-crowd/public/assets.manifest.json "$project/assets.manifest.json" + timeout --kill-after=15s 315s node --input-type=module - \ + "$GITHUB_WORKSPACE/examples/skinned-crowd/playtests/crowd-rate.playtest.json" \ + --project "$project" --target desktop \ + --executable "$GITHUB_WORKSPACE/packages/runtime-native/build/tn-linux/mystral" \ + --host-arg run --host-arg "$project/skinned-crowd-native.js" \ + --artifacts "$GITHUB_WORKSPACE/packages/runtime-native/artifacts/conformance/crowd-rate" <<'NODE' + import { main, flushStdout } from './packages/playtest/dist/runner/cli.js'; + const keepalive = setInterval(() => {}, 1000); + const bound = setTimeout(() => process.exit(124), 300_000); + let code = 3; + try { + code = await main(process.argv.slice(2)); + } finally { + clearTimeout(bound); + clearInterval(keepalive); + } + await flushStdout(); + process.exit(code); + NODE - name: Report the compiler cache hit rate if: always() && env.CCACHE_UNAVAILABLE != '1' shell: bash diff --git a/AGENTS.md b/AGENTS.md index 5545a897f7..e4ef14cb9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ Results go in the existing PRD, PR or response; a separate evidence file only wh **Fail closed**: malformed input throws, a missing observation fails, an empty assertion set fails. `pnpm test` proves units; **a playtest scenario proves the game** — every runtime-behaviour change gets one, rerun on each later change to that behaviour. **Nearest lane first, CI last.** A CI round trip costs over an hour; prove it locally (unit test, playtest, emulator, attached device) and push once green. While `TN_RUNNER` is set, run only the focused checks for what you changed and push: the full board runs once, on `tn-local`. **Prefer an emulator over hardware** when it can hold the claim; a stopped emulator means start it, not "blocked". Never call `xvfb-run` (its exit status is its own cleanup kill; `sh scripts/xvfb.sh ` is the wrapper). An unnamed WebGPU adapter may be SwiftShader: pass `--browser-recipe webgpu` and check `adapter.info`. Free a port with `lsof -ti tcp: | xargs -r kill`; `pkill -f vite` kills your own shell. -**Visual judge subagent (owner rule, 2026-10-09).** Any change that alters what reaches the screen — materials, lights, shadows, exposure, fog, post, terrain, particles, LOD, resolution scale, and any perf change that touches them — needs a visual judge. Capture the same pose before and after (headed, `--browser-recipe webgpu`, `adapter.info` checked, at least two runs per arm, BEFORE and AFTER burned in large). Give both sets and the intended effect to a fresh read-only subagent that did not write the change. It returns IMPROVEMENT, NEUTRAL or REGRESSION per frame and overall. NEUTRAL or REGRESSION means fix the cause and judge again; do not explain it away. Record the verdict in the PRD box that claims the outcome and post the images on the PR (orphan `-screenshots` branch). A pixel diff or a green gate is not a verdict, and a perf win counts only when the judge passes. +**Visual judge subagent (owner rule, 2026-10-09).** Any change that alters what reaches the screen — materials, lights, shadows, exposure, fog, post, terrain, particles, LOD, resolution scale, and any perf change that touches them — needs a visual judge. Capture the same pose before and after (headed, `--browser-recipe webgpu`, `adapter.info` checked, at least two runs per arm, BEFORE and AFTER burned in large). Give both sets and the intended effect to a fresh read-only subagent that did not write the change. Set visual acceptance criteria from the task before judging. The judge returns IMPROVEMENT, NEUTRAL or REGRESSION against those criteria per frame and overall. Performance-only and appearance-preserving tasks pass with NEUTRAL or IMPROVEMENT when there is no visible regression; tasks promising visual improvement require IMPROVEMENT. Intentional appearance changes must meet their stated visual target. REGRESSION, or NEUTRAL when improvement is required, means fix the cause and judge again; do not explain it away. Record the verdict in the PRD box that claims the outcome and post the images on the PR (orphan `-screenshots` branch). A pixel diff or a green gate is not a verdict, and a perf win counts only when the judge passes. Long gates write `artifacts/gates/status.json`: read it with `pnpm gate:status`, probe a stale phase with `pnpm gate:doctor`; `pnpm gate:resume` continues only while worktree, branch, HEAD, lease and artifact still match. None of them repair or remove a worktree. ## Working outside the repo diff --git a/CLAUDE.md b/CLAUDE.md index cfc4212ef4..824f2be062 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,7 @@ Results go in the existing PRD, PR or response; a separate evidence file only wh **Fail closed**: malformed input throws, a missing observation fails, an empty assertion set fails. `pnpm test` proves units; **a playtest scenario proves the game** — every runtime-behaviour change gets one, rerun on each later change to that behaviour. **Nearest lane first, CI last.** A CI round trip costs over an hour; prove it locally (unit test, playtest, emulator, attached device) and push once green. While `TN_RUNNER` is set, run only the focused checks for what you changed and push: the full board runs once, on `tn-local`. **Prefer an emulator over hardware** when it can hold the claim; a stopped emulator means start it, not "blocked". Never call `xvfb-run` (its exit status is its own cleanup kill; `sh scripts/xvfb.sh ` is the wrapper). An unnamed WebGPU adapter may be SwiftShader: pass `--browser-recipe webgpu` and check `adapter.info`. Free a port with `lsof -ti tcp: | xargs -r kill`; `pkill -f vite` kills your own shell. -**Visual judge subagent (owner rule, 2026-10-09).** Any change that alters what reaches the screen — materials, lights, shadows, exposure, fog, post, terrain, particles, LOD, resolution scale, and any perf change that touches them — needs a visual judge. Capture the same pose before and after (headed, `--browser-recipe webgpu`, `adapter.info` checked, at least two runs per arm, BEFORE and AFTER burned in large). Give both sets and the intended effect to a fresh read-only subagent that did not write the change. It returns IMPROVEMENT, NEUTRAL or REGRESSION per frame and overall. NEUTRAL or REGRESSION means fix the cause and judge again; do not explain it away. Record the verdict in the PRD box that claims the outcome and post the images on the PR (orphan `-screenshots` branch). A pixel diff or a green gate is not a verdict, and a perf win counts only when the judge passes. +**Visual judge subagent (owner rule, 2026-10-09).** Any change that alters what reaches the screen — materials, lights, shadows, exposure, fog, post, terrain, particles, LOD, resolution scale, and any perf change that touches them — needs a visual judge. Capture the same pose before and after (headed, `--browser-recipe webgpu`, `adapter.info` checked, at least two runs per arm, BEFORE and AFTER burned in large). Give both sets and the intended effect to a fresh read-only subagent that did not write the change. Set visual acceptance criteria from the task before judging. The judge returns IMPROVEMENT, NEUTRAL or REGRESSION against those criteria per frame and overall. Performance-only and appearance-preserving tasks pass with NEUTRAL or IMPROVEMENT when there is no visible regression; tasks promising visual improvement require IMPROVEMENT. Intentional appearance changes must meet their stated visual target. REGRESSION, or NEUTRAL when improvement is required, means fix the cause and judge again; do not explain it away. Record the verdict in the PRD box that claims the outcome and post the images on the PR (orphan `-screenshots` branch). A pixel diff or a green gate is not a verdict, and a perf win counts only when the judge passes. Long gates write `artifacts/gates/status.json`: read it with `pnpm gate:status`, probe a stale phase with `pnpm gate:doctor`; `pnpm gate:resume` continues only while worktree, branch, HEAD, lease and artifact still match. None of them repair or remove a worktree. ## Working outside the repo diff --git a/docs/PRDs/Unreal/animation/PRD-570-a-character-costs-what-the-camera-sees-of-it.md b/docs/PRDs/Unreal/animation/PRD-570-a-character-costs-what-the-camera-sees-of-it.md index 9404ffea93..5b97e66d09 100644 --- a/docs/PRDs/Unreal/animation/PRD-570-a-character-costs-what-the-camera-sees-of-it.md +++ b/docs/PRDs/Unreal/animation/PRD-570-a-character-costs-what-the-camera-sees-of-it.md @@ -4,9 +4,9 @@ prd_contract: v1 # PRD-570 — A character costs what the camera sees of it -**Status:** NOT STARTED -**Priority:** P2 — AC-1 is open: every rig evaluates its full pose every frame, near or far, on screen or off. Phase 1 has not yet measured how much that costs. -**Complexity:** 5 (MEDIUM) — 1–5 engine files (`animation.ts`, `projection-skinned.ts`, `render-camera-cull.ts`) (+1), skipped-frame accumulation and interpolation state carries across frames (+2), a skin-once compute pass is a new mechanism, gated by Phase 1 (+2); risk override: none +**Status:** PARTIAL — original CPU savings and temporal motion preservation remain unmet. The rejected current-time lookup production prototype remains reverted; the raw-keyframe candidate is artifact-only and unqualified. Full CI run 38772095802 passed on published source 437e0f602; original CPU/motion acceptance remains open. +**Priority:** P2 — resolve CPU/motion acceptance before final qualification. +**Complexity:** 7 (HIGH) — internal animation/projection and pose-table mechanism (+3), mutable clip/action/rig invalidation (+2), GPU indexing, device limits and velocity lifetime (+2); risk override: HIGH. Phase3 compute remains declined. **Owner:** João **Depends on:** None. Coordinate with [PRD-486](../../open-world/PRD-486-characters-get-a-lod-chain.md): its posed-bounds rule (Phase 2) is the bound this PRD reads when that lands, and its mannequin crowd is a shared subject. @@ -98,10 +98,12 @@ raw `AnimationMixer` (`examples/skinned-crowd/src/scenes/Crowd.ts:92-105`), so i reported. - A skipped frame leaves the palette slot unwritten, so `SkinnedBatch.write` is skipped too. Interpolation is added only if the Phase 2 judge rejects the plain trail at rate 2. - - The velocity history follows the drawn pose. `SkinnedBatch.begin` copies the whole current palette into - the previous palette every frame (`projection-skinned.ts:291-293`). A skipped rig then has zero - velocity, and its next evaluation frame has the full multi-frame step as velocity. The - temporal anti-aliasing stage reads that step. This matches the pose that is drawn, and Phase 2 asserts it. + - The velocity history follows the drawn pose. The plain-hold baseline copied the target palette + each frame, giving zero velocity on a hold and the full step on evaluation. The triggered + interpolation fallback also snapshots its start palette, root compensation and blend factor; + previous position reads the complete previous displayed blend. Target palettes still skip + unchanged held writes. Forced/exact poses, restarts and slot reuse reset interpolation, and + Phase 2 asserts the drawn-pose invariant on both paths. 3. **Skin once (Phase 3), only if Phase 1 shows skinning is at least 0.5 ms of GPU across passes on the crowd.** A compute pass writes skinned position and previous position into storage once per frame. The batch's `positionNode` and the velocity history read it in every pass. The pass skips a rig whose palette was not written this frame. Otherwise the @@ -115,7 +117,7 @@ which is compatibility mode, not the core WebGPU that this engine requests. ## Acceptance Criteria - [ ] AC-1 [local]: A 128-rig `SkeletalMesh3D` crowd receding to 120 m spends at least 40% less CPU in `AnimationPlayer.update` plus `SkinnedBatch.write` than the rate-1 arm, from one build. proof: new `examples/skinned-crowd/playtests/crowd-rate.playtest.json` through `node packages/playtest/dist/runner/cli.js ... --browser-recipe webgpu` with `playtest perf`, adapter named. -- [ ] AC-2 [local]: The shooter's soldiers keep their gameplay behavior: the existing shooter playtests stay green with the policy on. proof: `pnpm test:templates` for shooter. +- [ ] AC-2 [local]: The shooter's soldiers keep their gameplay behavior: the existing shooter playtests stay green with the policy on. proof: `pnpm test:templates` for shooter. 2026-10-15 repaired runner `aaed0fb88`, current runtime source guarded at `3fdac2f3a`: fresh installed34/34 scenarios and60-real-frame boot PASS, verifier/supervisor exit0, all646 source guards PASS and owned descendant cleanup remaining{}. Original performance assertions pass at1920x1080 on physical NVIDIA/Turing WebGPU, including p95<=33ms/FPS>=30/draws<=1300/triangles<=500000. No exact p95 value is inferred from the concise scenario summary. Original default clock selection is preserved; performance selects live clock automatically. Artifacts: `artifacts/prd570-shooter-runner-current/`; retained fresh fixture `/tmp/threenative-template-playtests-zObnwY/shooter`. Any later runtime change reopens this gate. Earlier current integrated failure remains preserved: original suite exits2 on bridge-operation timeouts with matching source hashes. Historical passing results follow. 2026-10-14 integrated75ef6afce runtime (docs-only eb1b09439 equivalent): complete fresh installed shooter suite34/34 plus real-frame boot PASS on physical NVIDIA/Turing, wall clock,1920x1080; performance p9526.3ms<=33, remaining FPS/draw/triangle assertions pass. Exact core+shooter source hashes match before/after. Artifacts: `artifacts/prd570-final-9e527b712/shooter-integrated-nvidia/`; retained fixture `/tmp/threenative-template-playtests-xrdLIZ/shooter`. Earlier Intel failures remain historical and unwaived. 2026-10-14 after rejecting ab1d interpolation: source restored exactly to46907651f across core, playtest, crowd and shooter; retained runtime proof applies to that restored source. Focused91/91 tests and core typecheck pass. Any new runtime candidate reopens this gate. 2026-10-14 restored929c1e149 executable source: all34 unchanged clean-installed scenarios report PASS, including original performance assertions; fresh60-real-frame boot reports PASS on NVIDIA/Turing with zero diagnostics. The harness emits its success marker only after both scenario and boot commands succeed. Source hashes match before and after. Outer execution shell terminates143 after the success marker, before its receipt writes; preserve wrapper.exit143 and do not claim a clean wrapper exit. Post-run source guard is completed manually and passes. Artifacts: `artifacts/prd570-restored-929c1e149/shooter/`; retained fixture `/tmp/threenative-template-playtests-dn6YLr/shooter`. Any new runtime candidate reopens this behavior gate. Detached unchanged-harness confirmation completes all34scenarios and60-real-frame boot again, then source guards and both suite/launcher exit receipts are0. This resolves the clean-termination uncertainty without changing product code. Artifacts: `artifacts/prd570-restored-929c1e149/shooter-clean-exit/`; retained fixture `/tmp/threenative-template-playtests-W2k0Ii/shooter`. The first wrapper143 remains retained. ## Blocked on @@ -123,6 +125,8 @@ which is compatibility mode, not the core WebGPU that this engine requests. ## Decisions +- 2026-10-14 (session ownership and direct-track entry result): João assigns PR #487 to session `01a13d5f-ac02-7282-96ec-965647df68b1`. Competing session `01a137e6-9271-79e1-89da-f038b0a5c393` is archived and its active turn confirmed interrupted. Reuse `.worktrees/prd570` at `9b3d46def`; preserve the lookup rollback, soldier opacity correction, other existing improvements and all rejected-candidate artifacts. The completed authoritative-seam direct-track diagnostic retains all20 rounds,128 independent rigs and exact-pose checks: mean direct/control ratio0.503177631 exceeds the predeclared0.48 entry cutoff; control/direct CV4.98%/6.76%. Live host observations additionally flag load, so the formal verdict is INCONCLUSIVE_LOADED, with raw oracle verdict STOP. Source hashes remain stable. This provides no entry approval or AC-1 evidence; no retry, production direct sampler or GPU qualification follows. Artifacts: `artifacts/prd570-direct-track-lower-bound/authoritative-seam-run/`. Acceptance and current-source runtime/CI gates remain open. + - 2026-10-12 (Unreal source review): the projected-size source was wrong. `render-camera-cull.ts` skips the skinned batch and exposes no size, so Phase 2 extracts the formula as a helper and tests each rig. Unreal interpolates skipped frames below rate 4, so its pose still moves each frame. A plain skip here freezes the pose, so velocity is zero on skipped frames and one large step on the evaluation frame. That is the true motion of the drawn pose, and one Phase 2 box now asserts it. - 2026-10-12 (Unreal source review): Unreal's skin cache reuses a rig's skinned buffers while its bone-buffer revision is unchanged. Phase 3, if it runs, keys the stored skin by palette write so a skipped rig is not skinned again. @@ -137,21 +141,687 @@ which is compatibility mode, not the core WebGPU that this engine requests. ## Execution Phases #### Phase 1: The cost is measured -**Status:** NOT STARTED -**Files:** `examples/skinned-crowd/src/scenes/Crowd.ts` (a `SkeletalMesh3D` crowd arm), `examples/skinned-crowd/playtests/crowd-rate.playtest.json` (new) -- [ ] [local] The crowd scenario reports CPU milliseconds for `AnimationPlayer.update` and `SkinnedBatch.write` at 32, 128 and 256 rigs, with the result recorded under `## Decisions`. proof: `crowd-rate.playtest.json` with `playtest perf` and span probes. -- [ ] [local] The same scenario reports GPU milliseconds for skinned draws in the main and shadow passes, against an arm where the crowd is static. The result decides Phase 3 under `## Decisions`. proof: the same playtest, both arms, `TN_FRAME_BUDGET` `main`/`shadow` buckets. +**Status:** COMPLETE — CPU costs are reported for the current runtime at all three crowd sizes and GPU attribution is verified; the unstable CPU savings comparison does not close AC-1. +**Files:** `examples/skinned-crowd/src/scenes/Crowd.ts`, `examples/skinned-crowd/src/game.ts`, `examples/skinned-crowd/playtests/crowd-rate.playtest.json` (new), `packages/core/src/profiling/Spans.ts`, `packages/core/src/projection-skinned.ts`, `packages/core/__tests__/projection-skinned.spec.ts` (exact write probe). +- [x] [local] The crowd scenario reports CPU milliseconds for `AnimationPlayer.update` and `SkinnedBatch.write` at 32, 128 and 256 rigs, with the result recorded under `## Decisions`. proof: `crowd-rate.playtest.json` with `playtest perf` and span probes. 2026-10-13: runtime `067b2fed2`, all six scenarios and three-window perf validations exit 0; 18 complete post-startup windows. Observed costs and variability are recorded below; AC-1 remains inconclusive. +- [x] [local] The same scenario reports GPU milliseconds for skinned draws in the main and shadow passes, against an arm where the crowd is static. The result decides Phase 3 under `## Decisions`. proof: the same playtest, both arms, `TN_FRAME_BUDGET` `main`/`shadow` buckets. #### Phase 2: Rigs evaluate at the rate their screen size earns -**Status:** NOT STARTED -**Files:** `packages/core/src/animation.ts`, `packages/core/src/projection-skinned.ts`, `packages/core/src/render-camera-cull.ts`, `packages/core/__tests__/animation-rate.spec.ts` (new) -- [ ] [local] Accumulated `dt` keeps clip time, fade progress and the `finished` event identical to rate-1 playback over 300 frames, and a forced evaluation follows any skip longer than the rate. proof: red-green cases in `pnpm exec vitest run packages/core/__tests__/animation-rate.spec.ts`. -- [ ] [local] Velocity history matches the pose that was drawn: zero on a skipped frame, the whole step on the evaluation frame, and no stale previous palette after a rate change or a first write. proof: a red-green case in `pnpm exec vitest run packages/core/__tests__/projection-skinned.spec.ts`. -- [ ] [local] Stagger offsets spread 128 rigs so that the evaluations in any one frame stay within 1.5× of the mean. Root-motion and `updateRate: 1` players never skip. proof: the same spec. -- [ ] [local] AC-1 passes. A fresh judge subagent compares 10 m, 40 m and 120 m captures against rate 1 and finds no visible loss. proof: `crowd-rate.playtest.json` plus `pnpm visuals:ab --before --after --out `. -- [ ] [local] The native desktop host runs the crowd scenario with the policy on. proof: `node packages/playtest/dist/runner/cli.js examples/skinned-crowd/playtests/crowd-rate.playtest.json --target desktop`. +**Status:** PARTIAL — rejected trailing interpolation and current-time lookup remain reverted. Integrated native crowd behavior is verified; installed shooter qualification is reopened after bridge-operation timeouts. Original CPU/motion AC-1 and current-head CI remain open. +**Files:** `packages/core/src/animation.ts`, `packages/core/src/projection-skinned.ts`, `packages/core/src/render-camera-cull.ts`, and focused animation/projection/cull tests. Rejected pose-table and device-limit plumbing are removed. +- [x] [local] Accumulated `dt` keeps clip time, fade progress and the `finished` event identical to rate-1 playback over 300 frames, and a forced evaluation follows any skip longer than the rate. proof: red-green cases in `pnpm exec vitest run packages/core/__tests__/animation-rate.spec.ts`. 2026-10-11 green: `pnpm exec vitest run packages/core/__tests__/animation-rate.spec.ts` 26/26, red first on `updateRateReport` missing; rates 2/4/8 match rate-1 clip time over 300 frames, crossfade weights, interruption, weighted blends, `finished` timing and replay, forced evaluation, stop/pause/stride-rate integration all exact. Surface: `AnimationPlayer.updateRate` (`"auto"` | positive integer), `updateRateReport`, `update(dt, force)`. 111 existing animation/composition/cull regressions pass. +- [ ] [local] Velocity history matches the pose that was drawn: zero on a skipped frame, the whole step on the evaluation frame, and no stale previous palette after a rate change or a first write. proof: a red-green case in `pnpm exec vitest run packages/core/__tests__/projection-skinned.spec.ts`. 2026-10-12 green: the focused animation/projection run passes 42/42, including skipped-frame zero-history, evaluation-frame whole-step, rate-1 history and projected-batch auto-rate cases; the implementation commits as `172b80bf3`. 2026-10-14 after rejecting ab1d interpolation: source restored exactly to46907651f across core, playtest, crowd and shooter; retained runtime proof applies to that restored source. Focused91/91 tests and core typecheck pass. Any new runtime candidate reopens this gate. +- [x] [local] Stagger offsets spread 128 rigs so that the evaluations in any one frame stay within 1.5× of the mean. Root-motion and `updateRate: 1` players never skip. proof: the same spec. 2026-10-11 green: same spec, 128 rigs at rates 2/4/8 over 240 frames, per-frame evaluations never exceed 1.5x the mean (mean = 128/rate); `updateRate: 1` and root-motion composers evaluate every tick. Auto-rate pixel bands (128/32/8 px) are provisional until AC-1's judge. +- [ ] [local] AC-1 passes. A fresh judge subagent compares10m,40m and120m captures against rate1 and finds no visible loss. proof: `crowd-rate.playtest.json` plus matched headed WebGPU captures. 2026-10-13 runtime `9e527b712`: all12 captures pass on physical NVIDIA/Turing, two independently launched runs per arm/depth, burned-in labels, matching source/bundle/pose provenance and zero diagnostics. Fresh independent verdict PASS/NEUTRAL for all six pairs under the owner's task-dependent policy; temporal smoothness is unverified and not claimed. The combined box stays open: frozen six-arm CPU comparison is INCONCLUSIVE, reductions39.05%/30.94%,128-rig CVs28.31%/29.75%/16.31%/7.35%, versus unchanged>=40% and<=15% gates. All windows2–4 retained. Artifacts: `artifacts/prd570-final-9e527b712/cpu-desktop-after-build/cpu-summary.json`. +- [ ] [local] The native desktop host runs the crowd scenario with the policy on. proof: shipped `crowd-rate` assertions through the desktop CLI. 2026-10-13 runtime `9e527b712`: all16 assertions pass with zero diagnostics in49seconds on the existing byte-verified Linux V8/Dawn host. Source, runtime-dist, native-project and host guards pass before/after. Artifacts: `artifacts/prd570-final-9e527b712/native/report.stdout.log`. This proves native behavior, not FPS or CPU savings. 2026-10-14 after rejecting ab1d interpolation: source restored exactly to46907651f across core, playtest, crowd and shooter; retained runtime proof applies to that restored source. Focused91/91 tests and core typecheck pass. Any new runtime candidate reopens this gate. 2026-10-14 restored929c1e149: fresh desktop bundle and unchanged original scenario PASS16/16, zero diagnostics, physical NVIDIA/Turing1280x720, source/dist/project/byte-verified-host guards PASS before/after. Artifacts: `artifacts/prd570-restored-929c1e149/native/`. This proves current native behavior only; CPU/motion acceptance remains open and any later runtime change reopens this box. #### Phase 3: Skinning runs once per frame, if Phase 1 earns it -**Status:** NOT STARTED +**Status:** PENDING for the new RAW vertex candidate — remeasure held-minus-static main-plus-shadow GPU overhead at 32, 128 and 256 rigs before retaining the historical <0.5 ms decline. **Files:** `packages/core/src/projection-skinned.ts`, `packages/core/src/render/velocity.ts`, `packages/core/__tests__/projection-skinned.spec.ts` - [ ] [local] With the skin-once pass, the crowd's main-plus-shadow GPU time falls against Phase 1's arm, and the existing velocity-history assertions stay green. If Phase 1 shows less than 0.5 ms, the decline is recorded under `## Decisions` instead. proof: `crowd-rate.playtest.json` perf arms and `pnpm exec vitest run packages/core/__tests__/projection-skinned.spec.ts`. + + +## Decisions + +- 2026-10-15 (separately predeclared loaded-host ORIGINAL CPU qualification; preparation only): AC-1 requires >=40% combined animation/palette CPU reduction within one build on a named adapter; the original protocol requires both ordered comparisons, all windows2–4, all four128-rig CVs<=15%, exact arms128-rate1-a/128-auto-a/128-auto-b/128-rate1-b/32-rate1/256-rate1, full observations and original1280×720/scale1/MSAA4/live clock. The earlier loaded-host owner authorization explicitly overrides only the laptop skill’s quiet-wait requirement, preserves other jobs and rejects unstable data; the historic Intel valid FAIL at94–100°C (load1 actually3.5–4.3; load5=16–20) is distinct from the later campaign-specific CPU<25%/load1<8/Tctl<80°C conditions. Those conditions are not universal AC-1 admission. D22's stronger continuous limits remain intact in the cancelled quiet9ca attempt; every stopped raw diagnostic and old procedure stays STOPPED UNCHANGED, with no retrospective acceptance. A separate procedure at `artifacts/prd570-loaded-original-9ca/` predeclares exactly one loaded-host flight, <=25minutes, ordinary shared offload `--gpu --no-sync` without `--quiet`, mandatory fresh offload status/care approval, continuous positive unambiguous Intel temperature/CPU/load observations without25/8/80 admission gating, and fail-closed missing/malformed observations. It reuses the frozen9ca source/build/dependency/bundle and original evaluator/plan/scenario, with fresh physical Intel isolation/positive clock quantum<=10µs, affinity0,2 for every arm, full immutable guards, retained failures and owned-child cleanup. Remote source9ca077c05471db431c492a7e2b443278ff4fe331 remains frozen; local26d38816a adds documentation, so local9ca source-manifest verification is EXPECTED FAIL after docs and must not trigger prepare-transfer or checkout resync. No retries, favorable subsets, foreign termination, CPU-fixture freezing, invented visual-rate receipt, rendering change, quiet/FPS claim or measurement execution is authorized by this preparation; incomplete arms/windows or missing host observations mean INCONCLUSIVE. + + +- 2026-10-15 (current ORIGINAL CPU qualification: valid FAIL): frozen9ca077c05471db431c492a7e2b443278ff4fe331 completes the single predeclared loaded-host flight in `artifacts/prd570-loaded-original-9ca/`. Both ordered reductions are A40.1315789474% and B31.7241379310%; all four128-rig CVs pass15% at9.93399268/5.03579747/10.92591296/10.20596454%. Mean animation-update/palette-write/combined ms are rate1-A0.3966667/0.11/0.5066667, auto-A0.2333333/0.07/0.3033333, auto-B0.2566667/0.0733333/0.33, rate1-B0.38/0.1033333/0.4833333,32-rig0.1133333/0.03/0.1433333 and256-rig0.70/0.25/0.95. All six scenario/perf exits are0; all18windows2–4 are present without problems, one build fingerprint matches before/after and final full guards PASS (`qualification.exit=0`, outer offload exit0, parent `lrt loaded-original-wait`). Fresh COOP/COEP isolation gives positive5µs clock quantum on physical Intel/gen12lp, fallback=false. All95monitor samples are valid: CPU0.808–12.562%, load1 0.56–1.06, aggregate positive Intel maximum69–102.05°C (not package-only; coretemp<=100°C), clocks0.396–5.003GHz, maximum gap1.028s. This is a complete stable FAIL because pair B misses40%, with no quiet/FPS claim or retry. AC-1 stays unchecked, AC-2 remains open at shooter32/34; PR stays draft/prd:75/0of2 acceptance. Historical175b2bae5 attribution is not current-source proof; diagnose current fixed work versus mixer evaluation before any optimization, with at least0.04ms auto reduction or comparable fixed-work cut needed and no promised win. + +- 2026-10-15 (current-source attribution diagnostic, not acceptance): the shipped headed WebGPU trace records60seconds after10seconds settle on the unchanged frozen9ca128-auto scene. Offload/diagnostic exits are0, final immutable guard passes, and structured output reports physical Intel/gen12lp, existing non-virtual display, no blockers and only the expected no-input warning; the pinned readiness control flow therefore observed startup. Fresh isolated clock proof passes. Artifacts: `artifacts/prd570-current-profile-9ca/`; its fetched frozen bundle matches every file hash of the completed original qualification. Generated function/caller ownership must be mapped before selecting a change; profiler-instrumented self times are not a CPU acceptance result. No qualification retry or production change follows from this diagnostic alone. + +- 2026-10-14 (direct launch diagnostic and blocked qualification audit): the shipped sculpt shim directly initializes as threenative-sculpt-mcp0.2.1 and lists all seven rig/animation gates required by the failing child-launch test;12tools total. Machine output: `artifacts/prd570-lookup-candidate/sculpt-direct.jsonl`. This proves direct protocol behavior, not a passing child-process test or full suite, and does not justify changing the server. Physical GPU devices remain absent, NVIDIA-SMI cannot reach a driver, and laptop admission fails on SSH configuration permissions for the third consecutive goal turn. The original40%/CV15% CPU criterion, temporal judges, changed-source shooter/native proof and required full CI remain unmet. CPU campaign has not started; PR is still draft/open. Further acceptance work requires a GPU-capable execution environment or restored laptop access; no thresholds, scope or gates weakened. + +- 2026-10-14 (whole-workspace type qualification): root `pnpm typecheck` genuinely fails on eight errors in the lookup tests that core-only typecheck excludes. Nullable palettes now have assertions, spy contexts narrow to StorageBufferAttribute, and the WGSL inspection test declares the missing Three declaration surface using the existing test pattern. Root `pnpm typecheck` now PASS across the workspace; root `pnpm lint` PASS, and both repaired specs60/60 PASS with Biome and diff checks. Runtime source is unchanged from db9330ebb. A broader core diagnostic run reports3488PASS,4FAIL,4SKIP across233files: debug child-node launch and two tracked-file checks fail EPERM; sculpt-server initialization times out without a reply. The latter is unclassified, not waived. These results do not claim a green core suite or full board; changed test specs are separately rerun after repair. Artifacts: `artifacts/prd570-lookup-candidate/{root-typecheck.log,root-typecheck-green.log,root-lint.log,core-suite.log,root-types-focused-green.log}`. Physical GPU and laptop SSH admission remain unavailable; CPU campaign has not started, runtime/visual acceptance stays open. + +- 2026-10-14 (independent review repair, post-evaluation bone edits): review reproduces a live bone at0.9 while lookup displays0.033333335 after an evaluated tick. Three genuine RED regressions cover first draw, forced evaluation and scheduled evaluation. AnimationPlayer now records evaluated local transforms before game edits; PoseBasis validates against that snapshot and retains a newly prepared basis through same-write exact fallback/restore. Initial skipped ticks capture the starting pose; explicit rate1 does not capture. Snapshot storage and traversal remain inside measured update/write costs, including exact fallback for base rates above1; CPU savings are unqualified. Independent read-only review accepts the repair. Final serial focused run218/218, core typecheck, ESM/DTS compilation, Biome error lane and diff check PASS. Actual WGSL/storage inputs pass15,424comparisons, maximum error0.0000145133m. The artifact fixture explicitly forces a zero-time player evaluation after its public mixer phase override; the earlier probe correctly declined lookup for that externally changed pose. Artifacts: `artifacts/prd570-lookup-candidate/`. No runtime/acceptance box ticked. +- 2026-10-14 (qualification environment): physical GPU device files are absent and NVIDIA-SMI cannot communicate with the driver; laptop status fails on SSH configuration permissions. The one allowed frozen CPU campaign has NOT started. Publint fails because its pack subprocess produces no tarball; documentation links cannot run because spawnSync git returns EPERM. The pnpm progress wrapper fails on its tsx IPC socket, but the same progress script through node --import tsx succeeds:2/3phases,0/2acceptance,prd:75%. A concurrent build/test invocation also failed while dist was cleaned; compilation completed before the final passing serial run. Shooter, native, temporal and current-head CI proof remain open for changed source. PR remains draft; skipped CI is not a pass. + +- 2026-10-10, execution lane `perf/prd570-skin-animation-gate` at + `.worktrees/prd570`, owned by the PRD-570 worker; retained for coordinator inspection. + Scope is the baseline gate only. No update-rate policy or skin-once optimization is implemented. + No phase or acceptance box is ticked, and Phase 3 is neither earned nor declined. +- The original no-query 64-rig fixture keeps its raw-mixer path. Measurement runs require + `?tnFrameSpans=1&crowdCount=32&crowdArm=animated` (repeat with counts 128 and 256). + `animated` uses actual `SkeletalMesh3D` instances and spans each inherited + `AnimationPlayer.update(1/60)` call; `skinnedWrite` spans the actual shared + `SkinnedBatch.write` body, including `skeleton.update`, excluding reconcile and palette upload. + `TN_CROWD_RATE` publishes their window means and calls per frame alongside GPU main/shadow + buckets, rendered-shadow sample count, main/shadow draws and triangles, and drawing surface. + Missing timestamps, spans, rendered shadows or expected call counts leave `ready` false. +- Two frozen controls isolate the GPU comparison: `crowdArm=held` keeps the skeletal rigs at + sway time 0; `crowdArm=static` bakes that same pose's positions and skin-transformed normals + once into shared unskinned geometry, drawn with stock `InstancedMesh`. Both retain the same + actor transforms, material, topology, cast/receive-shadow settings and 10/40/120 m depth bands. + The fixture forces a shadow redraw in every arm. Check actual main/shadow draws and triangles + for **every** count before comparing timings; a differing 256-rig batch count invalidates a + skinning-only attribution. Frozen-pose equality is unverified visually, and neither frozen + control is claimed pixel-identical to a later animated pose. `projectedHeldHeights` reports each + root's measured held-pose height projected at its camera-space depth in viewport pixels; it is + an initialization observation, not continuously updated animated bounds. +- The baseline includes instrumentation overhead: two clock/recorder crossings per player update + and palette write, plus existing span probes. No invented overhead has been subtracted. + GPU values are existing resolved whole-pass windows, not direct vertex-stage timestamps. + Only a matched held-minus-static main-plus-shadow comparison can inform the 0.5 ms gate. +- Local proof: the new exact-write assertion failed before instrumentation (`undefined` versus + 8 writes), then passed. Focused projection/span specs: **25 tests passed**. Crowd TypeScript + check, checkout-local core ESM/declaration build, playtest package build and scenario schema + loading passed. Vite build passed with existing chunk/dynamic-import warnings; Biome passed + with one non-fatal complexity warning in `Crowd.enter`. At first draft delivery, no browser, GPU, + native, shooter or visual-judge run had been performed. Baseline cost comparisons and all PRD + outcomes remain unverified pending the coordinator's exclusive cost window. + +- Coordinator review found a bridge serialization defect in the frozen controls: absent span or + GPU metrics were published as `undefined`. They now publish `null`, retaining missing-work + semantics and `ready: false` for missing mandatory measurements. A focused Node/tsx falsifier + using the bridge's actual `assertJsonSafe` rejected held/static snapshots before the fix; + after it, all four held/static × present/missing-GPU cases pass, including explicit null-value + and readiness assertions. Crowd TypeScript and Vite build pass; no GPU run was needed or claimed. + +- Draft PR #487 targets develop at `prd:0%`. The initial normal pre-push hook passed agents, + documentation, lint and drift checks; the exact seven-package build closure passed with source + hashes unchanged. A subsequent local scenario change waits on an observed window of at least + 2, then readiness, rather than treating 600 fixed ticks as 600 renders. Readiness requires 120 + actual render frames and 120 sampled frames for each active CPU span. Fresh GPU and rendered- + shadow sample counts remain explicit; they are not claimed to cover all 120 render frames. + Schema loading, Crowd TypeScript/Vite and six focused bridge/readiness cases pass: complete + animated/held/static windows are ready; an incomplete CPU window or missing GPU data is not. + These local changes remain unpushed; the bounded functional run below exercises them. + +- The local GPU coverage extension exposes `gpuMainSamples` from the same existing ring + summary that supplies `gpuMain`; absent measurements stay absent in FrameBudget and publish + `null` in the fixture. Readiness now requires an observed main sample as well as rendered-shadow + samples. The focused fresh/deduplicated-main assertion failed before the field existed, then + all **17 frame-budget GPU-series tests passed**, including measured zero and missing-main cases. + Core ESM/DTS/bundler build and publint pass. Crowd TypeScript, scenario schema and Vite build + pass. Six bridge/readiness cases pass: a global GPU sample count cannot substitute for missing + main samples. Main sample counts are observed independently of the 120 render frames. + +The coordinator reviewed `5e0cba5f8..cd48d5ae7` and granted one bounded 32-animated functional +preflight. The exact built fixture was served by an owned `vite preview` at port 57170. Doctor +passed before and after the run with the four approved extras; it supplies the shipped five args: + +```sh +node packages/playtest/dist/runner/cli.js doctor --url 'http://127.0.0.1:57170/?tnFrameSpans=1&crowdCount=32&crowdArm=animated' --text \ + --browser-arg --use-angle=vulkan --browser-arg --use-vulkan \ + --browser-arg --disable-vulkan-surface --browser-arg --no-sandbox +``` + +The following single 32-animated run uses an explicit manual override equivalent to the shipped +WebGPU recipe plus those four extras (the scenario CLI rejects combining recipe and arguments): + +```sh +node packages/playtest/dist/runner/cli.js examples/skinned-crowd/playtests/crowd-rate.playtest.json \ + --url 'http://127.0.0.1:57170/?tnFrameSpans=1&crowdCount=32&crowdArm=animated' \ + --headed --artifacts artifacts/prd570-functional-cd48d5ae7/run \ + --browser-arg --ozone-platform=x11 --browser-arg --enable-unsafe-webgpu \ + --browser-arg --disable-gpu-sandbox --browser-arg --ignore-gpu-blocklist \ + --browser-arg --enable-features=Vulkan --browser-arg --use-angle=vulkan \ + --browser-arg --use-vulkan --browser-arg --disable-vulkan-surface --browser-arg --no-sandbox +``` + +- Actual capture adapter: `vendor=nvidia`, `architecture=turing` (physical GPU RTX 2080). + At completed window 2, `ready=true`; render, animation-span and palette-span frame counts were + each 120. Main and rendered-shadow GPU sample counts were each **120 observed samples**. + Animation calls and palette writes were each 32 per frame. Main/shadow draws were 3/1 and + triangles 17,667/17,664, at 1280×720, scale 1, DPR 1, sample count 4. +- The single scenario exited **1**: 11 `TN_PLAYTEST_ASSERTION_TRIVIAL` diagnostics rejected held + invariants already satisfied in the startup observation. The observed window/readiness checks + passed. No assertion was weakened and no runtime retry was made; scenario success remains + unverified. Doctor before/after both exited 0, identified NVIDIA/Turing and reported no console + errors. The next scenario change must distinguish held invariants from transitions, using the + existing harness contract, before another runtime grant. +- Raw artifacts are retained in `artifacts/prd570-functional-cd48d5ae7/`: `before.json` and + `after.json` contain capacity/source and built-artifact hashes, `doctor.log`/`doctor-after.log`, + `run.log`, `receipt.json`, `state.json`, and `run/capture.json`/`run/console.json`. Source and + bundle hashes were unchanged during the run. Owned preview PID 1795686 was terminated after + doctor, and port 57170 was verified closed. Other GPU processes were present (26% utilization + before, 22% after); these observations establish functional coverage only, with no cost, + savings, visual-quality or native claim. + +A separate exclusive cost window is required before the nine count/control arms. Proposed minimum: +three complete post-startup 120-render windows per arm, each with at least 30 actual main and 30 +rendered-shadow samples; report both observed coverage counts and draws/triangles. Reserve 20 minutes +and record fresh CPU/RAM/GPU utilization/process metadata before and after. The coordinator has not +granted this series. AC-1's 40% reduction is a later Phase 2 gate. + +2026-10-10, game-fixture correction after the functional preflight: `crowdRate.measured` starts +at null and latches exactly one completed ready window numbered at least 2. Its explicit JSON-safe +snapshot contains CPU/GPU costs and counts, draws/triangles, surface and control metadata. Flat +startup metrics continue updating, including later windows; they are not hidden or overwritten. +The scenario retains both raw readiness/window wait steps and assertion rows, checks window metrics +through this receipt with the original limits, and adds the receipt's window-at-least-2 proof. +Its 14 resource rows contain 11 unwaived checks and exactly three +held invariants use the installed `allowTrivial`: measurement mode and count are fixed by the URL, +and projected-rig count is computed in `Crowd.enter` before startup sampling. Each written reason +points to the unwaived post-baseline window checks. No window metric has a waiver. + +Proof: `node --import tsx artifacts/prd570-measurement-check.mjs` first failed on the absent initial +null receipt, then passed six cases using actual Crowd initialization/callbacks, the bridge's +`assertJsonSafe`, and its installed resource evaluator. Cases cover window 1 remaining visible, +valid 120-frame window 2 receipts for animated/held/static, missing GPU/CPU and 119 CPU frames +preventing a receipt, later raw updates leaving the receipt immutable, and held/static optional +CPU costs remaining null. All 14 rows pass with the initial pre-window baseline; all 11 unwaived +rows reject an already-held late baseline with `TN_PLAYTEST_ASSERTION_TRIVIAL`. An already-ready +window-1 baseline also remains rejected by the raw readiness row. Crowd TypeScript, scenario +loading, Biome and diff checks pass; +Biome reports non-fatal complexity warnings in `crowdSpans` and `Crowd.enter`. The check script is +an ignored executable consumed by this focused check, not a verification report. + +If startup sampling already observes readiness or the latched window, the scenario must fail honestly; warmup +has not been adjusted to evade that guard. One receipt does not implement or earn the proposed +three-window-per-arm cost series. No new browser/GPU/build run, optimization claim or PRD checkbox +completion accompanies this local change. + +2026-10-10, bounded functional recheck at local commit `16f2173f9`: normal commit and +`timeout 240s pnpm --filter ./examples/skinned-crowd build` passed (Vite 2.04 s). One headed +32-animated NVIDIA/Turing run passed all 14 resource assertions with exactly three documented +held-invariant waivers, original warmup 120, 1280×720 and unchanged limits. Startup +`ready:false, measured:null` became immutable ready window 2: 120 render/animation/palette frames, +32 calls/writes per frame and 120 actual main/120 rendered-shadow GPU samples. Main/shadow +counts were 3/1 draws and 17,667/17,664 triangles, scale 1/MSAA 4. Doctor before/after passed; +no console errors or runtime diagnostics. Source/bundle hashes remained unchanged during capture. +Commands, exact nine manual browser arguments, hashes, adapter, state and host/process metadata +remain in `artifacts/prd570-functional-16f2173f9/` (`receipt.json`, `before.json`, `after.json`, +`state.json` and logs). Unowned GPU load was 14%/22%; this is functional proof only. The owned +preview stopped and port 57170 closed. No push, retry, cost series, savings, visual comparison or +native claim; late-baseline rejection still applies and every PRD gate remains open. +- **2026-10-11, owner delegated the cost-window grant; Phase 1 series run on a loaded host, NOT accepted as cost evidence:** four headed `--browser-recipe webgpu` runs on NVIDIA/Turing (`artifacts/prd570-cost-series-20261011T213750Z` held/static 128, first animated attempts; `artifacts/prd570-cost-series-20261011T213750Z-rerun` animated 128 and 256 reruns after an Xvfb 10 s start timeout and a window-2 wait timeout at 256 under load). All six reported runs exit 0 or are explained, 120 GPU samples each, draws 3 main / 1 shadow. GPU main/shadow ms: animated-128 1.3/0.2, held-128 1.3/0.1, static-128 0.1/0, animated-256 0.1/0. The 256 arm reading is implausibly below 128 and the host load average was about 44–50, so no box is ticked and no CPU `AnimationPlayer.update`/`SkinnedBatch.write` span values were extracted (the playtest assertions carry frame counts, not span milliseconds). Next: rerun on a quiet host and read spans with `playtest perf`; Phase 2 (policy + `animation-rate.spec.ts` red-green) does not depend on these numbers. + +- Shooter template gate (AC-2), 2026-10-12, headed `--browser-recipe webgpu` with the NVIDIA env + (`adapterClass: hardware`), policy on (`updateRate: 'auto'`): two full runs passed 33 of 34 + scenarios, all gameplay and behaviour scenarios included. The one failure each time is + `performance.maxFrameMsP95` (limit 33 ms; observed 178.9, 86.7 ms) at host load 11–17. A middle + run added two `Instance dropped in popErrorScope` device-loss reds (`crouch-lowers-the-eye`, + `enemy-body-is-a-body`) that passed in the other two runs. Three low-load replays of that scenario + gave 41.1–45.8 ms. The same scenario on a scaffold without the PRD-570 changes gave 51 ms, an + error exit and one pass, so the budget fails on this lane without the policy too. The first + gate run used the software adapter (swiftshader) and is void. AC-2 stays open: "stay green" is + not met while `performance` is red, and a quiet-host baseline is the missing evidence. + +- 2026-10-12, runtime implementation and environment check: `172b80bf3` adds camera-projected + auto-rate observations for source rigs behind a `SkinnedBatch`, skips duplicate palette writes + when an `AnimationPlayer` did not evaluate, preserves the existing full-step velocity history, + and keeps `crowdRate=1` as the named control. Focused animation/projection tests pass 42/42; + core and skinned-crowd builds pass; Biome reports only the existing non-fatal complexity + warnings. The headed auto fixture reaches `TN_CROWD_RATE ready:true` with 49.75 palette writes + against 128 animation calls, but its adapter is `vendor=google, architecture=swiftshader` and + is not cost or visual proof. The NVIDIA-forced replay also selects SwiftShader and times out + readiness after repeated `GPUBuffer.mapAsync` external-instance errors. Setup diagnosis: + `/proc/driver/nvidia/version` is `615.71.09`, installed Vulkan/NVML libraries are `615.78.08`, + `vulkaninfo` finds no driver, and `nvidia-smi` reports a driver/library mismatch. No Phase 1, + AC-1, Phase 3, visual, native or AC-2 box is ticked from these runs. + +- 2026-10-12, manager-reproduced moving-root defect fixed in `86d284513`: the red regression + failed with `batch.palette[12] = 0` after a skipped-rate-4 frame while the moved actor's + `matrixWorld.elements[12] = 10`. `SkinnedBatch.write` now compares the rig's current + `matrixWorld` with the last world transform written; a changed gameplay transform writes the + world-space palette even when animation evaluation is skipped, while an unchanged skipped pose + still omits `skinnedWrite`. The regression also proves velocity history is old-root to new-root. + `pnpm exec vitest run packages/core/__tests__/animation-rate.spec.ts packages/core/__tests__/projection-skinned.spec.ts` + passes 43/43, and touched-file Biome passes. + +- 2026-10-12, hardware doctor after sourcing + `/home/joao/.cache/nvidia-615.71.09/env.sh`: `nvidia-smi` reports NVIDIA GeForce RTX 2080, + driver `615.71.09`; `vulkaninfo --summary` reports Vulkan 1.4.363; the headed playtest doctor + exits 0 with `adapter nvidia turing`, a nonblank rendered scene and 0 console errors. The first + doctor attempt was intentionally invalid because the owned preview was stopped and returned + `ERR_CONNECTION_REFUSED`; the corrected doctor run used the preview on port 57170 and passed. + +- 2026-10-12, skinned-write measurement coverage corrected: the first focused regression run was + red because a stationary skipped `SkinnedBatch.write` call had no `skinnedWrite` span + (`undefined`, expected `perFrame=1`), although its `skeleton.update` spy was not called. The + existing span now brackets the whole `write()` function, including the animation-rate lookup, + world-matrix guard and early return; the focused animation/projection contracts pass 43/43. + `skinnedWrite.perFrame` is therefore function-call count, including skipped stationary calls, + while the regression still proves skipped calls do not update the skeleton or rewrite the + palette. The crowd receipt exposes this scope as `paletteCalls`, and readiness requires exactly + `count` calls per frame for animated, held and auto/rate-1 arms; no call-count reduction is + expected from auto-rate policy. Historical `paletteWrites` evidence above is retained as-is. + +- 2026-10-12, portable crowd-rate selection corrected: `Crowd.ts` now uses the shipped + `spansRequested()` launch helper when no `crowdCount` query is present, so native + `TN_FRAME_SPANS=1` selects the 128-rig animated auto-rate measuring fixture without a browser + location. The focused actual-module check was red before the change (`count=64`, + `measuring=false`) and is green after it for native flag selection, legacy no-flag mode and + explicit `crowdCount=32` precedence. Crowd typecheck and build pass; the native desktop box + remains open for the manager's post-build scenario. + +- 2026-10-12, the manager's native run at `c579311ed` passed all 14 resource assertions and + measured the 128-rig auto fixture, but exited only on `TN_PLAYTEST_NETWORK_ERROR` for the + missing `assets.manifest.json` (`artifacts/prd570-desktop-c579311ed/`). The geometry-only crowd + now ships the standard empty manifest in `public/`; Crowd build copies and parses it at + `dist/assets.manifest.json` as `{"version":1,"entries":{}}`. The native desktop box remains + open pending rerun with the built dist asset root. + +- 2026-10-12, manager verification: all 60 focused animation/projection/GPU-series tests pass (2.15 s), and `artifacts/prd570-functional-bfa55ee5f/manager-run-1/` passes the 14-resource 128-auto hardware scenario with 120 observed main/shadow samples. The subsequent native run at `artifacts/prd570-desktop-c579311ed/` passed every resource but failed its one missing asset-manifest request. The standard empty manifest was staged, the game bundle rebuilt, and `artifacts/prd570-desktop-210264de2/` passes the full desktop scenario from the dist asset root. These are functional/native proofs, not CPU or GPU savings measurements. + +- 2026-10-12, matched visual evidence prepared for the fresh judge: the fixture-only `crowdFocus=10|40|120` query selects the existing near/mid/far depth bands (10+row, 40+row, 120-row) without changing the default mixed-distance performance view. `crowd-rate-visual.playtest.json` captures the animated 128-rig arm at 1280×720 after the same fixed 120-tick step. Rate-1 and auto pairs used the same Vite build, camera position `[0,3,0]`, light/material setup, render tick 180, and NVIDIA/Turing WebGPU adapter; all six runs passed with zero console/network/runtime diagnostics and nonblank 1280×720 PNGs. Flat pairs are in `artifacts/prd570-visual-210264de2/rate1/` and `artifacts/prd570-visual-210264de2/auto/`; `pnpm visuals:ab --before artifacts/prd570-visual-210264de2/rate1 --after artifacts/prd570-visual-210264de2/auto --out artifacts/prd570-visual-210264de2/visual-ab` created the 3-pair blind bundle and exited 2 awaiting judge verdicts. The visual box remains open pending the manager's fresh judge. + +- 2026-10-12, camera-target policy red-green and final-policy visual recapture: the manager's unparented animated target at `z=-120`, observed through `camera.lookAt(target)`, first reported `rate=4` instead of the required full rate. `RenderCameraCull` now promotes each known world bound independently only when it intersects the camera's forward centre ray; camera-parented rigs retain their existing protection, retargeting recomputes the promotion, and off-centre/off-frustum rigs keep the normal screen-size policy. No closest-object choice or per-frame bounds recomputation was added. The direct target, retarget-away, and projected-`SkinnedBatch` regressions are green with `pnpm exec vitest run packages/core/__tests__/animation-rate.spec.ts packages/core/__tests__/projection-skinned.spec.ts packages/core/__tests__/render-camera-cull.spec.ts` (3 files, 64 tests); `pnpm --filter @threenative/core build` also passes. + + The visual fixture now freezes only after its normal `beforeRender` update at `crowdFreezeFrame=120`, without forcing a final evaluation, and exposes `visualRenderFrame`, `visualLogicalSeconds`, `visualEvaluationTimes`, `visualRates`, `visualEvaluated`, and `visualThrottledRigs`. Final rate-1 captures report frame 120, 2 seconds, 128 rate-1 rigs and zero throttled rigs. Final auto captures report frame 120, 2 seconds, 112 throttled rigs; the retained 40 m witness has rates `1×16, 2×49, 4×63`, and the 120 m witness has `1×16, 2×53, 4×59`. All six corrected-policy runs used the same 1280×720 NVIDIA/Turing WebGPU build, camera/light/material setup and matching focus bands (`10+row`, `40+row`, `120-row`), with zero diagnostics and nonblank PNGs. Final pairs are in `artifacts/prd570-visual-camera-target/rate1/` and `artifacts/prd570-visual-camera-target/auto/`; the rebuilt blind bundle is `artifacts/prd570-visual-camera-target/visual-ab/blind/`. `pnpm visuals:ab ...` exited 2 only because it awaits the manager's judge verdict; the visual box remains open. + +- 2026-10-12, public contract/discovery completion: `packages/core/src/index.ts` now documents + `updateRate: "auto"` as the default camera policy, `updateRate: 1` as full evaluation, another + positive safe integer as a requested frame interval, and `updateRateReport` as the effective-rate, + override-status and latest-evaluation report. It names the full-rate protections without changing + runtime behavior: one-shots/fades, root-motion `AnimationComposer`, automatic camera-ray targets + and camera-parented rigs. All 13 shipped template `AGENTS.md` files carry the same authoring + convention and their generated `CLAUDE.md` mirrors are synced. The draw-cull class comment no + longer misstates the animation-only camera-ray exemption. + + `pnpm sync:agents` passed (`25` mirrors checked, `13` written); `pnpm capabilities:sync` passed + (`439` entries, `6` not-owned rows, `427/427` package-backed scaffold imports, `0` unresolved), + regenerating both capability manifests and the capability reference. The focused capability, + mirror and scaffold checks passed 8 files/72 tests; `pnpm check:docs` passed 3,087 links across + 1,361 Markdown files; Biome passed with only the two existing camera-cull complexity warnings. + The visual, performance and shooter boxes remain open for the manager's actual gates and + independent judge/review. + + The 2026-10-13 follow-up compacted the shared updateRate paragraph to one physical line so all + templates remain under 100 lines; the scaffold constants were refreshed from all 13 actual + no-install trees after genuine 65-pass/1-hash-failure hash measurements. + +- 2026-10-12 (independent visual judgment, current camera-target build): a fresh read-only `gpt-5.6-sol` reviewer inspected all eight shuffled captures in `artifacts/prd570-visual-camera-target/visual-ab/blind/`, including duplicate controls, at actual render frame 120 and logical 2.0 seconds. Corrected scoped pose-integrity scores are 5/5 for every sample: no visible pose loss at 10/40/120 m against rate 1. The initial blank observation for sample-02 was rejected: it is byte-identical to sample-01 (SHA256 `e762bb465f89237bb88ac856eca2cda4055a6fe1bd10cc1d3af35e0905b5a908`); sequential image inspection corrected the verdict. Reviewer session `01a133c3-7425-77b0-ae9f-94956a9a0a3a`. These stills prove pose integrity, not temporal smoothness or a commercial-template art score. `visuals:ab` built the blind bundle and exited 2 pending its separate generic template-quality verdict schema; no generic visual-quality gate is claimed. The combined Phase 2 AC-1 box stays open until CPU reduction is verified. +- 2026-10-12 (manager verification after camera-target repair): the current animation-rate, projection-skinned and render-camera-cull specs pass 64/64 across three files; `pnpm api:surface:check` passes for 14 packages and 413 symbols. Full final verification remains pending. + +- 2026-10-12 (AC-1 measured): immutable production bundle, matching NVIDIA 615.71.09 userspace/kernel, RTX 2080 / NVIDIA Turing WebGPU. Four 128-rig runs in rate1 → auto → auto → rate1 order, each with startup window 1 discarded and three complete 120-render windows. Combined AnimationPlayer.update + full SkinnedBatch.write call CPU means: pair A 3.987 → 2.130 ms (46.57% lower); reversed pair B 2.667 → 1.420 ms (46.75% lower). Both pairs exceed 40%; aggregate 46.64%. Every window is ready, with 128 animation/palette calls per frame, 120 span/render frames, 120 main/shadow timestamp samples, 3/1 main/shadow draws, 70,659/70,656 triangles, pinned 1280×720 scale 1, 4× samples. Each scenario and `playtest perf --require-windows 3` passes. Artifacts and immutable bundle hashes: `artifacts/prd570-final-series-attempt1/`. Background CPU load varied (roughly 17–23), so absolute CPU/FPS figures are not quiet-machine throughput claims; both independently paired reduction ratios agree within 0.2 percentage points. The fresh matched visual judgment above completes the Phase 2 AC-1 box. + +- 2026-10-12 (Phase 1 CPU attribution): rate-1 animated controls from the same immutable production build and RTX 2080 series: 32 rigs: animation 0.563 ms + palette-call 0.287 ms = 0.850 ms; 128 rigs: animation 2.552 ms + palette-call 0.775 ms = 3.327 ms; 256 rigs: animation 4.703 ms + palette-call 2.673 ms = 7.377 ms. Per run, startup window 1 is discarded; three complete 120-render/span windows pass scenario readiness and `playtest perf --require-windows 3`. The 128-rig result combines both reversed-order control runs. These are measured call costs under the recorded shared-host CPU load, not quiet-machine FPS claims. GPU observations from this first series are retained but not used to decide Phase 3 because competing Chrome/native runs overlapped; the locked repeat is pending. +- 2026-10-12 (final static qualification): `pnpm typecheck` and `pnpm lint` both exit 0 after the camera fix and public convention documentation. Logs: `artifacts/prd570-final-typecheck.log` and `artifacts/prd570-final-lint.log`. + +- 2026-10-13 (qualification repair): the whole root unit phase completed with 10,180 passing, 28 failing and 20 skipped tests across 778 files. Four failing suites were a stale installed Three patch plus an AST timeout; `pnpm install --frozen-lockfile --force` restored the canonical dependency patch, the shipped patch helper now reports unchanged, and all four suites pass 59/59 with ordinary test timeouts. The two template suites exposed genuine instruction-length and stale scaffold-fingerprint regressions: `39b9a3de6` compacts the convention, regenerates mirrors and refreshes all 13 actual no-install tree hashes; focused scaffold 66/66 and template 38/38 pass. The whole unit phase has not been rerun after these repairs; its earlier failure is not reported as a whole-suite pass. +- 2026-10-13 (native package qualification): canonical V8 contract targets and the QuickJS host/timestamp/rg11b10 targets built locally using the existing CI recipe. `taskset -c 0-1 pnpm --filter @threenative/runtime-native test` exits 0: 137 files, 1,581 passing and 62 skipped tests, plus 29 physics-contract tests, 23 Rust tests and strict publint. The earlier eight missing-executable failures are resolved; the isolated SIGKILL startup check also passes without a source change. Current animation/projection/camera focused tests pass 64/64 after dependency repair. +- 2026-10-13 (measurement protocol refresh): the crowd fixture advances animation exactly once per actual draw, so the shipped runner `--live-clock` option is used for fresh cost measurements against the rebuilt canonical dependency candidate. The exact three-window scenario passes its original 15 assertions, with no new triviality waivers. A transient missing-bridge launch was diagnosed with runtime/scene doctor and passes unchanged against the owned persistent Vite preview server; failed launch artifacts are retained and excluded from cost results. Fresh paired CPU/GPU and repeated visual proof remain pending; no quiet-machine FPS claim is made under the recorded shared-host load. + +- 2026-10-13 (canonical live-clock GPU decision): all 14 arms in `artifacts/prd570-canonical-live-series-attempt2/` pass the original 15 scenario assertions. Startup window 1 is excluded; each arm supplies three complete 120-frame windows with 120 main and shadow timestamp samples. Held/static surface and full pass topology match at each count. The prespecified conservative bound (maximum held main-plus-shadow window minus minimum static window, including both reversed 256-rig pairs) is 0.0 ms at 32 rigs, 0.1 ms at 128, and 0.1 ms at 256. All bounds are below 0.5 ms, so Phase 3 declines skin-once on the named NVIDIA/Turing RTX 2080. The 256-held low-clock window remains included; it cannot improve this upper bound. Immutable bundle hashes are retained with the series. All 14 arms also pass `playtest perf --require-windows 3`. +- 2026-10-13 (AC-1 reopened): the rebuilt canonical candidate live-clock CPU pairs measure 3.020 to 2.167 ms (28.25% lower) and 3.757 to 2.270 ms (39.57% lower), below the required 40% in both pairs. Earlier scripted-clock success is historical and does not close current acceptance. Shared desktop scheduler contention is recorded; a quiet laptop comparison is requested before deciding whether implementation optimization is needed. The first quiet job exits 75 at the capacity guard, so it produced no measurement; `prd570-quiet-cpu-retry` is the replacement. Shooter qualification on the laptop also exits 1 and AC-2 remains open. +- 2026-10-13 (current native rerun): after the canonical dependency repair and candidate rebuild, the current V8 desktop crowd again passes all 15 assertions, including readiness, animation/palette spans, main/shadow observations and clean diagnostics. Artifacts: `artifacts/prd570-native-canonical-28c4/` and its CLI log. The initial launch used a relative scenario with an explicit project root and failed path resolution before execution; rerunning the same scenario by its required absolute path passes unchanged. +- 2026-10-13 (shooter current result): the existing laptop scaffold suite completes 33/34 scenarios successfully. Only `performance` fails: frame p95 127.8 ms against 33 ms, FPS 21.05 against 30; draw calls 1,177/1,300 and triangles 420,933/500,000 pass. All gameplay scenarios pass, but AC-2 still requires the complete suite. Source receipt is clean `39b9a3de6`, with identical runtime inputs to `28c4d8ff2`; the log and preserved scaffold are on the laptop under `prd570-final-gates` and `/tmp/threenative-template-playtests-ycy7z2`. No threshold is waived. +- 2026-10-13 (canonical repeated visual proof): all 12 fresh headed Chromium captures from the rebuilt candidate pass: two independent runs per rate1/auto arm at 10/40/120 m, burned-in BEFORE/AFTER labels, fixed render frame 120 and logical 2.0 seconds, complete per-run pose/rate/evaluation-time provenance, NVIDIA/Turing adapter and immutable bundle hashes. The new bundle hash is `6a167ef23aa64056916b49fbea2d9c69a19066f872af526fde24b5f4de225ea9`; all 12 PNG hashes exactly match the earlier independently judged set. A fresh pinned `gpt-5.6-sol` read-only reviewer opens all images sequentially at original resolution and returns PASS: no visible pose loss in any of the six matched pairs. Artifacts: `artifacts/prd570-visual-canonical-28c4/`; reviewer log: primary checkout `.linchpin/pr487-canonical-visual-judge.log`. João explicitly accepts unchanged visuals for this performance change. Stills establish pose integrity only; the combined Phase 2 box remains open because current CPU acceptance fails. +- 2026-10-13 (canonical root unit rerun): 10,204 tests pass; one scaffold-build subprocess exceeds its existing 60-second exit ceiling, and one asset-health worker exits unexpectedly. Comparing all 778 planned files with the 777 completed summaries identifies `packages/assets/__tests__/health.spec.ts` as the missing suite. Both affected specs are rerunning with one worker; no timeout or assertion is relaxed. The whole run exits 1, including one retained temporary directory, so it is not a whole-suite pass. Log: `artifacts/prd570-unit-canonical.log`. +- 2026-10-13 (canonical package board): the root package-test phase passes with prebuilt outputs and serial package scheduling, including the current native contracts, all package test/publint scripts and no temporary-directory growth. `TN_SUITE_PHASES=package-test TN_SUITE_PREBUILT=1 TN_SUITE_PACKAGE_CONCURRENCY=1 pnpm test` exits 0. Log/status: `artifacts/prd570-package-canonical.log` and `artifacts/gates/prd570-package-canonical-status.json`. +- 2026-10-13 (focused unit failures resolved without edits): the unchanged scaffold build and asset-health specs pass 85/85 with one worker, including the original 60-second build exit assertion. Log: `artifacts/prd570-unit-failure-focused.log`. This clears the two observed cases, while the failed full-run status remains recorded honestly. +- 2026-10-13 (hidden-slot cache repair): the new rate-4/8 regression was red before the fix: after `hide`, a skipped `AnimationPlayer.update`, `restart` and `write`, both palettes remained all zero. `SkinnedBatch` now indexes rigs by slot and invalidates only the returning/hidden rig's evaluation and world-transform cache before the existing write guard. `pnpm exec vitest run packages/core/__tests__/projection-skinned.spec.ts -t 'rewrites a hidden slot'` is green with 2/2 tests; full affected focused tests remain pending after the remaining review findings. +- 2026-10-13 (camera-policy ordering repair): the engine-order regression was red before the fix: `RenderCameraCull.apply` had run, but `cullsBeforeBeforeRender` was `0`, so a beforeRender animation update and the following palette pack could use the previous camera rate. The existing single cull now publishes metadata before beforeRender and projection reconciliation; no duplicate cull was added. `pnpm exec vitest run packages/core/__tests__/game.spec.ts -t 'applies camera animation policy before beforeRender projection work'` is green with 1/1 test. Native, CPU and visual proof remain reopened for the final repaired build. +- 2026-10-13 (loop-listener timing repair): the new short-clip red reproduced Three.js event coalescing: over 60 ticks, rate 1 delivered 20 `loop` callbacks with `loopDelta: 1`, while rate 8 delivered 7 callbacks with `loopDelta: 2` or `3` at later ticks. `AnimationPlayer` now uses an internal mixer subclass that tracks only official `addEventListener`/`removeEventListener` registrations for the public `"loop"` event; an active listener promotes that player alone to effective rate 1, preserving `{ action, loopDelta }` timing without forcing all repeating clips to rate 1. The focused regression is green, and the four affected files pass 124/124: `pnpm exec vitest run packages/core/__tests__/animation-rate.spec.ts packages/core/__tests__/projection-skinned.spec.ts packages/core/__tests__/render-camera-cull.spec.ts packages/core/__tests__/game.spec.ts`. `pnpm --filter @threenative/core typecheck` also exits 0. The public AnimationPlayer/SkeletalMesh3D JSDoc now names this protection; native, CPU, visual and shooter gates remain manager-owned and open. +- 2026-10-13 (loop-listener registration boundary repair): a real public-API red advanced both rate-1 and rate-8 players for six ticks on a 0.05-second loop with no listeners, then registered the first listeners; rate 8 replayed an unobserved `loopDelta: 2` at registration frame 6 while rate 1 had no event until frame 8. `pnpm exec vitest run packages/core/__tests__/animation-rate.spec.ts -t 'starts a newly registered loop listener after skipped time'` failed before the fix. The mixer now consumes pending player time before the first loop listener is installed, so registration observes only later loops with the same frame, delta and clip; duplicate listener adds remain set-like, the last removal restores rate 8, and listener additions from mixer callbacks do not reenter pending synchronization. The focused animation spec passes 31/31, including those boundary cases. Public JSDoc and generated capability manifests/reference now state that skipped pre-registration loops are not replayed; capability/doc checks pass 5 files/78 tests. Native, CPU, visual and shooter gates remain manager-owned and open. +- 2026-10-13 (draw-cull ordering repair): a real game-order red used a `0.1`-radius mesh at `z=-1000`, moved by `beforeRender` to `z=-10` with its world matrix updated; the old pre-callback `RenderCameraCull.apply` hid it before the callback, so `pnpm exec vitest run packages/core/__tests__/game.spec.ts -t 'culls after beforeRender moves geometry into camera space'` failed with `drawnVisibilities.at(-1) === false` instead of `true`. `RenderCameraCull.prepareAnimation` now publishes camera-parent and centre-ray animation metadata before `beforeRender`/projection packing without changing draw visibility, while the one actual `apply` runs after projection reconciliation, clustered meshes and LOD against the final render root. The same focused core command passes 125/125; `pnpm --filter @threenative/core typecheck` exits 0 and the touched-file Biome check exits 0 with eight existing complexity warnings. Native, CPU, visual and shooter evidence remains open for the corrected build. + +- 2026-10-13 (final regression candidate `498745bc0`): full typecheck and lint exit 0; four affected core suites pass 128/128. `pnpm check:docs` exits 0 and the six documented doc/CI contract suites pass 246/246. Twelve fresh, separately launched, labeled physical RTX captures at 10/40/120 m all pass and every PNG is byte-identical to the previously independently judged canonical set; immutable bundle `4de0de81c394915a6f6f4d84730fc34c095d9ba2fdbd81d8f77f88f66bd355cb`, artifacts `artifacts/prd570-visual-498745bc0/`. This preserves the accepted no-visible-pose-loss result and does not claim temporal smoothness or a new art win. +- 2026-10-13 (current native verification): rebuilt core, browser and desktop crowd bundles from clean `498745bc0`; Linux V8/Dawn with the matching NVIDIA 615.71.09 environment reports RTX 2080/Turing and passes all 15 assertions with zero diagnostics. The first CLI process ended without its final verdict and is not counted. A bounded 120-second invocation of the same exported CLI `main`, held alive until its promise settled, returns the complete passing report at `artifacts/prd570-native-498745bc0-keepalive.log` with raw observations in `artifacts/prd570-native-498745bc0-keepalive/`. No assertion, runtime or host configuration changed. +- 2026-10-13 (loaded-host protocol authorization): João explicitly authorizes preserving existing jobs while running the comparison with host load recorded, rejecting unstable data, and retaining all acceptance criteria. This overrides only the laptop skill's quiet-wait requirement. The clean final candidate is synchronized to the owned laptop lane; six bounded arms are ordered rate1/auto/auto/rate1 at 128 rigs, followed by 32/256 rate1 attribution. All three post-startup 120-frame windows are retained; both ordered pairs must reduce combined animation/palette CPU by at least 40%, and any 128-rig within-arm CV above 15% makes the comparison inconclusive. Results will be labeled loaded-host measurements, with no quiet-host or FPS claim. Current job `prd570-loaded-498745bc0` is waiting for another lane's GPU lock; all unrelated jobs remain active and untouched. + +- 2026-10-13 (review-round-2 runtime repairs): four production-order/public-API red cases were reproduced and fixed without changing policy thresholds. The game-order case proved an exact projection proxy could carry the new authored local matrix while final culling still read its stale world matrix; the existing `MatrixWorldPass` now runs before final culling, and the projection-enabled `beforeRender` regression is green. The public `AnimationAction.warp()`, `halt()` and warp-enabled `crossFadeFrom()` cases matched an unwrapped Three mixer at rate 1; grouped stride scaling remains intact, and a throttled warp remains active. A finished-callback listener-registration case now delivers a later same-update loop event instead of suppressing it. Exact projected mirrored/non-uniform skinned sources now carry the existing animation-source ownership metadata; off-centre sources throttle while centre-ray and camera-parented sources remain full-rate. Red/green proof: `pnpm exec vitest run packages/core/__tests__/game.spec.ts -t "refreshes exact projection proxies before the final camera cull"`, `pnpm exec vitest run packages/core/__tests__/animation-rate.spec.ts -t "preserves Three action time-scale interpolation|does not cancel a public warp|warp-enabled public crossfades|delivers later loop events"`, and `pnpm exec vitest run packages/core/__tests__/projection-skinned.spec.ts -t "observes animation roots behind exact projection proxies"`; the full affected focused set is green at 134/134 and `pnpm --filter @threenative/core typecheck` exits 0. Native and current CPU evidence are reopened for a final build; no unrun acceptance box is ticked. + +- 2026-10-13 (public time-scale interpolation repair): the source-hash-guarded diagnostic reproduced raw Three drift after a duration-10 clip's public `warp(1, 2, 0.5)` at rate 4/8 (`+0.023333332936`/`+0.055000011126` seconds) and `halt(0.5)` (`-0.023888885789`/`-0.050000002297`); rate 1 matched. The player now flushes pending time before public `warp`/`crossFadeFrom`, tracks only those official action calls, protects the effective rate at 1 until each interpolant settles, and avoids stride sync cancelling it. The red-green cases compare clip time, effective time scale and evaluated-frame rate against an unwrapped Three mixer at rates 1/4/8, include skipped time before warp, unequal-duration warp-enabled crossfades and a mixer-callback warp; the full animation-rate spec passes 36/36. All four affected focused suites pass 135/135, core typecheck exits 0, and focused Biome plus `git diff --check` pass with six existing complexity warnings. Native evidence is reopened because runtime changed after the manager's `498` proof; CPU, AC-1, visual and shooter gates remain open. No generated capability contract changed. + +- 2026-10-13 (final loaded-host execution plan): `067b2fed2` repairs the remaining public interpolation drift. The prior laptop job `prd570-loaded-498745bc0` was cancelled while queued, before execution, because final review required further repairs; it produced no measurements. Two later laptop capacity probes refuse launch at 97–98°C. The authorized bounded comparison therefore uses the existing physical RTX desktop display and matching NVIDIA environment, retaining every other job and the same preregistered six-arm order, three post-startup windows, 40% paired reduction threshold and 15% sample-CV stability limit. No capacity threshold, power setting, model or configuration is changed; results remain loaded-host measurements. + +- 2026-10-13 (final-runtime CPU attribution, acceptance inconclusive): runtime `067b2fed2` was unchanged before, after and throughout all six ordered physical-display arms; core bundle digest starts `63efed62`, crowd bundle digest `570c8271`. All six scenarios and `playtest perf --require-windows 3` exit 0; every one of the 18 retained post-startup windows contains 120 frames with zero overflow. Observed mean `AnimationPlayer.update / SkinnedBatch.write` milliseconds: 32 rate-1 rigs 1.050 / 0.360; 128 rate-1 pair A 4.467 / 1.743 and pair B 2.280 / 0.803; 256 rate-1 rigs 6.477 / 3.407. These are loaded-host observations (Ryzen 9 5900X, 24 threads, load 30–40, 81–85°C; physical RTX 2080, matching NVIDIA 615.71.09, 1280×720, timestamp-query), not quiet-host throughput. Paired total-cost reductions are 54.97% and 53.62%, but rate-1 A and auto A sample CVs are 25.77% and 75.14%, above the preregistered 15% ceiling. The entire comparison is INCONCLUSIVE, so AC-1 and the combined visual/CPU box remain open. All windows are retained; no retry or favorable-window selection occurred. Artifact: `artifacts/prd570-proof-067b2fed2/cpu-summary.json`. Visual and native gates were not run by this CPU-first lane. CPU cost reporting closes Phase 1; acceptance still needs a stable comparison. + +- 2026-10-13 (supplemental loaded-host attempt, rejected): the separate isolated-display attempt `artifacts/prd570-loaded-isolated-269ccc95b/` retained all three predefined windows for four paired 128-rig arms and 32-rig attribution. All five scenarios passed, but metadata-only PRD commits tripped its broader HEAD guard before the 256-rig arm; runtime code and measured bundle bytes remained unchanged. The partial comparison is not accepted: observed pair reductions 77.01%/54.10% coexist with sample CVs 17.61%/26.66%/95.14%/33.91% across the four 128-rig arms, all above the 15% ceiling. No arm is rerun or window discarded to seek a win. The already complete same-runtime six-arm artifact above supplies CPU attribution and remains INCONCLUSIVE for AC-1. All unrelated jobs/background tests were preserved; no models, repository configuration or power settings changed. Current native/visual, shooter and full workspace qualification remain open for the final runtime. + +- 2026-10-13 (current native and visual refresh): the same final runtime `067b2fed2` passes all 15 native crowd assertions on Linux V8/Dawn with the physical RTX 2080/Turing adapter and zero diagnostics. Rebuilt the native entry with `pnpm --filter skinned-crowd build:desktop`, then awaited the exported shipped CLI `main` with a keepalive and 120-second bound; the complete report is `artifacts/prd570-native-067b2fed2.log`. Twelve separately launched labeled physical WebGPU captures in `artifacts/prd570-behavior-067b2fed2/visual/` all pass: two per rate1/auto × 10/40/120 m, render frame 120, logical time 2 s, frozen state and 128 rate observations; auto throttles 112–118 rigs while rate1 throttles zero. Every PNG is byte-identical to the canonical set already opened and independently judged as no visible pose loss, preserving the user-accepted unchanged-visual result. Immutable visual bundle: `7f266d0c40d45606f7ef2954c084e6b0eb9a014cd2e38b0fcfe8924aa2d59374`. The combined CPU/visual box stays open because AC-1 is inconclusive. Full workspace typecheck passes; full lint found one test-formatting error, being corrected separately. +- 2026-10-13 (origin/develop integration): merge resolution for `5e2a9d3d3` retains both PRD-570 `updateRate` guidance and PRD-569 particle `onCull` guidance. The existing scaffold hash helper first observed 65/66 with only `action-rpg` stale, then passed 66/66 after recording measured hash `4b5b69696ed344b4306d71a1040ce86d34e5d815e17c7fdc61e5d86d38437262`; the other 12 values were unchanged. Template/generated contracts pass 51/51, affected core contracts pass 159/159, `pnpm capabilities:check`, `pnpm sync:agents --check`, core typecheck, focused Biome and `git diff --check` pass. The native box remains open for a fresh whole-build run after this merge. +- 2026-10-13 (native fixture startup red): the merged `3e4b9c805` skinned-crowd host was healthy and reported 128 rigs with `measuring:true`, but the first observation failed closed because `crowdRate.window` was absent before `crowdBudget` received its first callback (`TN_PLAYTEST_OBSERVATION_UNAVAILABLE`, `artifacts/prd570-native-3e4b9c805.log`). The fixture now initializes `window: 0` while keeping `ready:false` and `measured:null`; the manager's rerun remains pending, and no native or acceptance box is ticked. + +- 2026-10-13 (stable Intel comparison FAIL, not a performance pass): the predeclared laptop series for runtime `067b2fed2` completed six arms on physical Intel Iris Xe (`intel/gen-12lp`, timestamp-query, display `:1`), with an immutable source manifest and one unchanged core/crowd build. All six `playtest perf --require-windows 3` validations pass, retaining windows 2–4 after startup. Combined animation-update plus skinned-write means: 128 rate-1 A 0.577 ms, auto A 0.473 ms; auto B 0.470 ms, rate-1 B 0.763 ms; 32 rate-1 0.213 ms; 256 rate-1 1.613 ms. Both paired reductions miss the required 40% (17.92%, 38.43%). All four 128-rig sample CVs satisfy the 15% ceiling (12.54%, 6.45%, 7.67%, 5.45%), so this series is FAIL, not INCONCLUSIVE. Loaded-host limits remain explicit: load1 3.5–4.3, 5-minute load 16–20, package 94–100°C; rate-1 arm means drift 32%, while auto means differ 0.6%. No window selection, capacity override, threshold waiver or measurement retry occurred. Five capacity refusals produced zero samples before the one completed series. Artifacts: `artifacts/prd570-cpu-laptop-067b2fed2/remote-results/cpu-summary.json` and `RECEIPT.json`; job `prd570-cpu-final-067` is terminal exit 10. This predates develop integration; it does not verify the integrated candidate. AC-1 stays open and the skipped-frame common work is under read-only investigation before any optimization or further comparison. + +- 2026-10-13 (simulation-clock readiness repair): develop's FrameBudget reports after 120 simulation ticks, so native windows with 252–270 renders were correctly complete but the fixture's old exact-120-render checks rejected them. The fixture now requires the default 60 Hz loop's completed two-second duration, matching positive render/span frame counts, unchanged per-rig call expectations, finite CPU values, and observed total/main/shadow GPU samples. The scenario observes the simulation duration; the first valid post-startup receipt remains frozen. Real FrameBudget/SpanRecorder bridge tests first reproduced 8 failures, then passed 20/20, including 30/120/258-render windows and incomplete/missing/mismatched observations; the manager independently reran 20/20 on Node 20.19.6. Fixture typecheck and scenario schema pass; focused Biome exits 0 with complexity warnings. Four previously red contracts also pass on Node 20 (86 tests: scaffolder build, capability examples, negative fixtures, terrain starter). No native, visual or CPU acceptance box is checked by this fixture repair; those runtime gates remain pending. + +- 2026-10-13 (integrated candidate, predeclared current comparison): runtime source 175b2bae5 includes the readiness repair and current develop. Before collecting samples, retain the original six-arm order: 128 rate-1 A, 128 auto A, 128 auto B, 128 rate-1 B, 32 rate-1, 256 rate-1. One rebuilt immutable bundle, physical NVIDIA RTX 2080, live clock, 1280×720, scale 1, four samples. Exclude only startup window 1; use every complete window 2–4, now proved by matching render/span counts and at least two simulation seconds. Both ordered 128-rig pairs must reduce the full animation-update plus palette-write mean by at least 40%; any 128-rig within-arm CV over 15% or invalid/non-finite sample makes the series inconclusive. Stop on the first infrastructure/arm failure and preserve artifacts; no automatic retries, warmup adjustments or favorable-window selection. Host load is retained, not described as quiet. Workflow-consumed runners live in artifacts/prd570-proof-175b2bae5 and artifacts/prd570-behavior-175b2bae5; preparation builds pass, and the verifier rejects incomplete observations, historical clock contracts, undefined zero-cost variance and negative CPU cost. Native host provenance must be established before its refreshed run. All runtime outcome and acceptance boxes remain open. + +- 2026-10-13 (integrated CPU series INCONCLUSIVE): the first four arms at ebcf303c1 complete all three retained windows on physical NVIDIA/Turing, but the 32-rig process exits 75 waiting for another live capture holder; no 32-rig browser/samples start, and 256 is unrun. The stopped series is preserved without remeasurement or window selection. Ordered 128-rig reductions are 26.59% and 55.44%; auto-A CV is 17.86%, exceeding the predeclared 15% ceiling even before the missing arms. Baseline means also drift from 0.840 to 1.317 ms. This is not an AC-1 pass. Raw windows, load traces and the inconclusive verdict remain in artifacts/prd570-proof-175b2bae5. Next is CPU attribution of common update work before any further optimization or comparison; acceptance remains open. + +- 2026-10-13 (browser attribution, diagnostic only): unchanged runtime bundle 175b2bae5, current clean source envelope 334ec34cb, physical NVIDIA/Turing. The original 128-rig auto scenario passes with no diagnostics under `--cpu-prof`; the 6.44-second profile samples 224.534 ms beneath AnimationPlayer.update (146.066 ms evaluation, 31.763 ms stride, 14.872 ms rate selection) and 50.199 ms beneath SkinnedBatch.write. These samples include profiler overhead and prove attribution, not AC-1. The existing visual-freeze observation at the original camera (no focus override), render frame 120 and logical time 2 s reports rates 1×16, 2×49, 4×63. Span invocation counts must not be interpreted as actual palette writes. Diagnostic artifacts: `/tmp/pr487-browser-profile.WIzpsy/` and `/tmp/pr487-rate-observation.IYg3Jw/`; both immutable bundle hashes match. + +- 2026-10-13 (next comparison predeclared): retain the original six-arm order, windows 2–4, 1280×720 surface, live clock, same runtime and unchanged 40%/15% thresholds. To address measured host contention rather than change the crowd, pin every CLI/browser process to logical CPUs 2,11,14,23 (two physical cores and their siblings), and admit each arm only after 15 consecutive seconds with global CPU utilization below 25%, one-minute load below 8, and CPU Tctl below 80°C. Capacity waiting is bounded at 20 minutes per arm; refusal launches no browser and is not a performance result. Record host utilization, temperature and clocks throughout; preserve all samples and stop on the first arm or gate failure. This is one newly declared host condition, not a retry of the invalid loaded-host series. No runtime optimization or acceptance claim follows from the diagnostic profile alone. + +- 2026-10-13 (independent review regressions): reproduced two failures before editing: stopping after a skipped tick left the projected palette in the animated pose, and animation preparation followed by draw culling rescanned an every-frame dynamic position buffer 39 times instead of once. Stop now invalidates the evaluation revision; repeated bounds consultations preserve the same frame's version-change observation. The three focused suites pass 78/78, core typecheck and focused Biome pass (existing complexity warnings), and `git diff --check` passes. Native and final-runtime acceptance remain open. The capacity-controlled campaign was stopped for these correctness repairs after only rate-1 A completed; its partial baseline is retained, with no paired-saving claim. + +- 2026-10-13 (user-reported opaque soldier defect, game layer): the retained installed shooter reproduces a building-shaped stripe across a living helmet in the existing close-body scenario. Asset alpha is 1; the game permanently enabled transparent blending for death fades, while the deferred normal target blends alpha 0. The controlled game-only change to opaque alpha-hashed fading removes the stripe at the identical pose on physical NVIDIA/Turing WebGPU; the original scenario passes. The template now uses this material mode and the body scenario observes the actual body material flags. All four labeled physical WebGPU runs pass the body scenario, including the new live material assertion in both AFTER runs. The fresh read-only visual judge reports IMPROVEMENT for standing/running in both pairs, with no visible downside. The existing `enemy-material-is-not-shared` full death/respawn scenario passes (opacity floor 0 for the killed soldier, 1 for the untouched soldier), as do installed-project typecheck, focused Biome and the actual generated scaffold equality test after updating only the measured shooter hash. Doctor passes after restarting the expired preview; the first fade attempt was unreachable and the first doctor retry used an unsupported flag, neither is counted as runtime evidence. Mid-fade visual quality and native rendering have not been checked for this material change; full shooter and performance acceptance remain open. + +- 2026-10-13 (develop integration): integrated origin/develop `5f7143895`; the only merge conflict was the scaffold fingerprint map. Actual generation measured all thirteen trees and changed only action-rpg relative to this branch, retaining both update-rate and emitParticle/onCull guidance. Agent mirrors and capability contracts were regenerated. The merged scaffold suite and four affected core suites pass 203/203. Final-runtime native/CPU, full shooter and workspace qualification remain open; laptop admission refuses at 100°C, so local qualification runs before the capacity-controlled CPU comparison. + +- 2026-10-13 (public mixer callback compatibility): six new red cases proved callback mixer reads/writes and loop action-speed writes were overwritten, public finished listeners did not protect deadlines, and signed mixer speeds could cancel pending mixer elapsed while losing action elapsed. Observed rigs now take ordinary Three.js updates each tick, public finished listeners join the existing listener protection, and mixer-speed overrides flush prior skipped time before accumulating the current tick. The internal finished hook remains unobserved so default crowds still throttle. All 123 affected animation/projection/composition tests pass, core typecheck passes, and a read-only review finds no remaining scoped defect after five additional zero/negative/positive speed and first-listener checks. All thirteen actual scaffold hashes were measured after documenting the full-rate exceptions; both scaffold/template suites pass 104/104. The merged action-rpg guide was compacted without changing its words to meet the strict line budget. The broader qualification exposed the six pre-fix regressions plus that guide limit and one build-exit timeout; the unchanged 60-second build check passes alone in 19.7 seconds. A final full board, current native/CPU/visual and shooter acceptance remain open. + +- 2026-10-13 (browser performance-clock repair, harness layer): the unchanged installed shooter reproduces49.9ms p95 with counted ten-tick batches, versus15.4ms/114.94FPS with the existing live-clock option at the same1920x1080 surface and33ms/30FPS limits. Browser frame-time/FPS assertions now select the live host clock by default; explicitfalse, draw-count-only assertions and Android retain counted ticks. Valid red/green regressions cover the default and an independent-review Android scope finding; all206 focused runner/performance/desktop/profile tests pass, package typecheck/build/publint, docs and8 mirror tests pass. No acceptance threshold, scenario, quality or CPU comparison changed. The first broad test attempt timed out in the GPU-free mocked fixture behind another owner's capture; its lock policy is now mocked only within that browser-refusing test. Real captures retain serialization. Desktop default-invocation proof refused75 behind that owner; Debian suite r1 refused thermal admission75, and r2 runs after cooling on the physical Intel display. Full default-clock shooter acceptance and current-head complete qualification remain unclaimed until their terminal results. + +- 2026-10-13 (default-clock terminal qualification): runtime9e527b712 with clock repairf23e7ae76 is installed through the shipped template suite on physical Intel/gen-12lp display1; local/remote runner source,CLI and core-dist SHA256 manifests match. Complete unchanged shooter sweep passes33/34 scenarios plus real-frame boot. Its performance report confirmswall-clock and1024 valid samples:43ms p95>33ms,45.24FPS>=30,567draws<=1300,432864triangles<=500000. Existing automatic resolution adapts during the run; no authored quality/scenario/cap changes. AC-2 remains unchecked. The broader playtest package run passes111files/1558tests but5capture tests time out in2files behind an activeforeignGPUowner; its one desktop recheck endsSIGTERM without a verdict after another capture starts. On idle physical Debian, both exact files pass15/15 in4.2seconds, resolving those test failures without changing production locks. All package cases are therefore covered across these runs, not one new full workspace pass. Final reviewer finds no actionable defect; ci:fast passes agents/docs/lint/drift only. CPU campaign host monitoring peaks36.93% globalCPU and81.62C Tctl, consistent with the retained CV instability; both original acceptance gates and current-head full CI/native qualification remain open. Artifacts:`artifacts/prd570-final-9e527b712/prd570-shooter-clock-default-r2.log` and `prd570-clock-capture-units-debian-r1.log` in that directory. + +- 2026-10-14 (Opus steering diagnostic predeclared): actual topology confirms previous CPUs2,11,14,23 span L3 caches0/1; clocksource is TSC. Unchanged runtime9e and identical frozen crowd bundle, current harnessf225 explicitly live-clock. Four diagnostic-only arms rate1-A/auto-A/auto-B/rate1-B, every window2–4 retained; only affinity changes to8,11,20,23, selected by ten-second least-busy L3 then two least-busy physical cores and SMT. Original host admission and production capture locks remain. Record per-CPU counters and thread placement each second. Both G/R-normalized reductions>=42% support cache hypothesis; <=37% rejects it; intermediate/mixed results need per-call attribution. Normalization does not satisfy AC-1; original raw40%/CV15% gates unchanged. No retries; first failed arm stops and preserves all artifacts. Artifact:`artifacts/prd570-final-9e527b712/cpu-single-l3-diagnostic`. + +- 2026-10-14 (single-L3 diagnostic terminal result): unchanged9e completes all four arms with verified physical NVIDIA/Turing, immutable bundle and source/dist guards. Raw reductions22.18%/23.72%, all within-arm CVs10.42/8.82/11.77/4.79%; stable below-threshold result, not an acceptance pass. Opus-style normalization31.15%/36.38% rejects the cache hypothesis under its predeclared<=37% rule. That calculation mixes render median with animation mean; consistent-mean diagnostic29.43%/33.84% also offers no support. No result uses normalization for acceptance. Next predeclared attribution: example-only query-gated evaluated/skipped per-call timing using existing spanNow/addSpan, same two clock reads and complete AU span, no profiler, rate1/auto/auto/rate1 with original2,11,14,23 affinity and windows2–4; core unchanged. Publish per-window counts/costs and validate count/frame/span alignment. Keep expensive-skip hypothesis only if skip>=one-third rate1 per-call and evaluated-auto<=1.1x normalized rate1; reject if skip<=one-quarter or evaluated-auto>=1.2x. No engine candidate before attribution. Artifacts:`artifacts/prd570-final-9e527b712/cpu-single-l3-diagnostic/` and `cpu-call-split-diagnostic/`. + +- 2026-10-14 (Opus-guided attribution complete): query-gated example-only timing bundle uses installed spanNow/addSpan around the same AnimationPlayer.update calls; tracked game source is restored and core runtime9e is unchanged. First attempt stopped because a new evaluated-call count assertion was already true at startup; exact counts/frame/span checks pass its retained windows. After annotating only that held diagnostic invariant with shipped allowTrivial, the separately declared four-arm series completes every original scenario/perf check and all three windows per arm pass exact call-count/frame/span alignment. Auto evaluation share is43.95% in both arms; average rate1/evaluated-auto/skipped-auto costs are4.633/6.970/1.683 microseconds in pairA and5.464/6.188/1.479 in pairB. Consistent-mean R normalization gives evaluated-cost ratios1.323/1.165 and skipped-cost ratios0.321/0.277; independence of R remains unproven, so these are diagnostic only. Neither pair satisfies the predeclared expensive-skip keep condition (skip>=one-third rate1 AND evaluated-auto<=1.1x). Raw total-cost reductions19.01%/37.50% coexist with CV40.92/15.71/13.48/16.52%; no AC-1 result or engine optimization follows. All measurements/windows are retained in `artifacts/prd570-final-9e527b712/cpu-call-split-diagnostic-r2/`; no identical trial retry or favorable-window selection. + +- 2026-10-14 (full shooter qualification and baseline attribution): unchanged complete default-clock NVIDIA suite reports33/34 scenarios plus real-frame boot passed; performance fails165ms p95/24.87FPS against33ms/30FPS. Physical NVIDIA/Turing and wall-clock are verified; retained console shows CPU update/render spikes after compilation and adaptive resolution changes. Host capacity was not monitored throughout this ordinary suite; subsequent CPU admission observed48% utilization, load10.15 and81.25C, so no quiet-host attribution is claimed. Intel develop baseline5f7143895 with only verifiedf23 runner/config clock hunks builds and runs the complete unchanged suite:33/34 plus boot passed,40.8ms p95 fails33ms while other performance assertions pass. Its window3–5 p95 values38.6/40.9/37.8ms and adaptation pattern match the candidate's37.6/56/41.2ms transient; baseline also failing supports a pre-existing Intel issue, without waiving AC-2. First fresh-baseline attempt lacked built assets and stopped before scenarios; required build fixed that invocation. Baseline attempt/build/install logs, exact clock patch and physical Intel capture/console are preserved under `artifacts/prd570-final-9e527b712/intel-baseline/`. Owned411MB baseline checkout was inspected clean after restoring the backed-up patch and removed normally; its idle offload lane was removed, diagnostic branch retained. Main task checkout remains unmerged and retained. Both original acceptance boxes stay open; current-head full CI is unverified. + +- 2026-10-14 (bounded policy/stride feasibility): no runtime change. A CPU-free reconstruction of the unchanged128rig layout uses the shipped Three geometry/skeleton/clip and camera; every original predicted rate is asserted against actual `RenderCameraCull`. Original rates16x1/49x2/63x4 reproduce evaluated-call share0.439453125; the one proposed128/64/16 table gives16x1/37x2/33x4/42x8, share0.375. Using the earlier noisy normalized per-call costs predicts30.31%/39.02% savings, so this candidate is rejected before visual/native/CPU-gate spending. This prediction is not acceptance. Real Opus5.5high steering initially swapped evaluated and skipped shares; the correct cost is `evaluatedShare * evaluatedCostRatio + (1 - evaluatedShare) * skippedCostRatio`, and its claimed universal38.5% ceiling is unsupported. An isolated stock-root microbenchmark, original affinity2/11/14/23, four alternating-order rounds with warm-up and all20samples per method retained, measures `getWorldPosition`0.118us, rate1update2.222us, skipped bookkeeping0.303us. The world-position read is5.33% of rate1 cost, below Opus's predeclared8% stop threshold: reject the stride shortcut hypothesis without changing observable public matrices. Artifacts: `artifacts/prd570-final-9e527b712/policy-scope-diagnostic/`; long skipped batches isolate bookkeeping and are not the normal cadence or an acceptance run. Both acceptance boxes remain open; no new original-gate retry or runtime candidate is justified by these two diagnostics. + +- 2026-10-14 (profiled shooter contact-discovery candidate): paired stock crowd profiles on physical Intel/gen12lp each complete62.5seconds and all unchanged assertions pass, frozen bundle/served-index/before-after guards pass. After a common10second attribution cut, rate1/auto inclusive sampled milliseconds are animation1059/709, evaluation844/457, stride117/144 and palette448/290; these perturbed traces are not acceptance timings. A stock installed shooter CPU profile reproduces50.5ms p95 with all other performance assertions passing, and identifies recursive contact-adapter discovery as leading JS self-time. Artifact-only instrumentation confirms a65,536byte Uint8Array enumerated4,556times during that scenario. Engine `playtest.ts` now treats ArrayBuffer views as leaf storage after retaining the view's own identity; ordinary nested physics objects remain traversed. The existing nested-contact test augmented with a65,536byte buffer genuinely fails before the guard because `Object.values` enumerates it, and passes afterward with the original trigger/body/tag assertions intact. Full focused file28/28, core typecheck and touched-file Biome check pass (three existing complexity warnings). Fresh build, complete shooter suite, current candidate acceptance and independent review remain pending; no threshold or assertion was removed. Artifacts: `artifacts/prd570-final-9e527b712/paired-profile/intel/`, `shooter-cpu-profile-intel/`, `contact-graph-probe/`. A separate numeric-rate motion prototype is unshipped:256/128/64 bands raise nearest-frame vertex error from2.02 to6.04pixels, so no visual preservation or performance claim is made for it. + +- 2026-10-14 (contact-discovery review/qualification): real read-only Opus5.5high approves treating ArrayBuffer views as atomic storage, with view identity/direct adapters retained; numeric-storage expandos are excluded like the existing Three-graph/depth bounds. Its test-cleanup finding is fixed by restoring the global `Object.values` spy in `finally`; the focused case passes again. Candidate75daec2bd is synced clean to the laptop;35/35 tests across playtest/build/constraints and core typecheck pass there. The complete unchanged installed shooter suite is currently rebuilding/packing that candidate, not yet a pass. Its source hashes are recorded before/after; do not sync while the owned job runs. The first local commit attempt used Node18 and stopped at the capability hook; rerunning with required Node20 passed capability/API synchronization and committed the candidate without bypasses. + +- 2026-10-14 (bounded cadence candidate, unqualified): the approved provisional-table tuning clause permits testing256/128/64 pixel bands at rates1/2/4/8. Keep the frozen crowd workload, original40%/CV15% CPU limits, shooter33ms/30FPS and all camera-target/root-motion/one-shot/callback/warp/named-override protections. Three new projected-tier cases atz=-10/-20/-80 genuinely fail at the old table, expecting2/4/8 versus1/2/4. The candidate changes only three threshold literals; near full-rate fixtures now usez=-5, and two off-target expectations follow the new far tier. Native is reopened. Its prior all-vertex prototype has nearest error6.04px versus2.02px, so this is deliberately unqualified pending fresh same-pose headed WebGPU sequences, two launches per arm/depth, frames120–127, burned labels and independent read-only motion review BEFORE any candidate CPU acceptance series. A regression rejects and reverts the candidate; no visual or performance pass is claimed from unit tests or the numeric model. No second table will be chosen from a failed CPU result. The approved work remains completion of original AC-1/AC-2, not a reduced target. + +- 2026-10-14 (cadence candidate REJECTED before CPU acceptance): all96 fresh physical Intel/gen12lp headed WebGPU captures pass frame120–127, two independent launches per arm/depth/frame, frozen compiled-bundle/runner/pose guards and diagnostics. Three fresh read-only judges each view all32 images at their assigned depth.10m and120m return REGRESSION for visible temporal holds/catch-up jumps in both repeats (static pose quality NEUTRAL);40m returns NEUTRAL for static/spatial quality and leaves real-time temporal preservation unverified. The candidate is rejected, not explained away. The three threshold changes and all associated test changes are restored exactly to4b2f6716a; the native box again refers to its unchanged128/32/8 historical policy proof. No candidate CPU series ran. Captures/provenance and the recoverable rejected diff remain under `artifacts/prd570-cadence-256-128-64/`. Fresh source/dists are being rebuilt after reversion. The PRD's explicitly permitted interpolation fallback is the next design route; no new threshold table is selected from a CPU failure. +- 2026-10-14 (complete contact-buffer-fix shooter qualification): source75daec2bd, unchanged complete installed suite on physical Intel/gen12lp, default wall clock,35 focused tests and core typecheck pass. All34 shooter scenarios run:33 pass; performance alone fails46.4ms p95 against unchanged33ms, while FPS/draw/triangle assertions pass. Real-frame boot passes, source hashes match before/after, no current AC-2 tick. Logs and exact performance capture/console are retained in `artifacts/prd570-final-9e527b712/contact-fix-shooter-suite/`; installed fixture is `/tmp/threenative-template-playtests-JK2EfX/shooter`. The buffer guard is independently approved and removes enumerated numeric storage; no measured frame-time win is claimed from this suite. Upstream develop99aeb6e30 adds ASTC/asset-packaging changes, with no changes to animation/camera-cull/skinned-palette/playtest core sources; integrate before final qualification, not during frozen captures. + +- 2026-10-14 (post-buffer attribution and develop integration): committed rejection/result notes as15b817d0d, then integrated origin/develop99aeb6e30 cleanly as75ef6afce368798f35853b7d4f63da47aa930e54. Focused integration set initially316/317: the new mobile-shippable scaffold test imported stale assets/dist. Rebuilding assets passes; rerunning that exact case passes1/1,65 unrelated cases not selected. The other five files passed. Integrated core build/publint and core typecheck pass. No source/test workaround was made. +- 2026-10-14 (post-buffer shooter CPU profile): unchanged installed75daec2 runtime, physical Intel/gen12lp, default wall clock, original performance assertion, shipped CPU profiler, normal offload GPU lock; recorded non-quiet host admission48C/load0.15 under existing loaded-host authorization. Exact installed core hashes match before/after. Profile spans25.996seconds, includes all samples: contact graph traversal (actual minified d0, identified by inspecting its compiled definition) inclusive97.029ms, native WebGPU submit self9563.461ms. Earlier same-fixture-class pre-fix profile25.248seconds reports graph1038.181ms/submit6799.723ms. This verifies the graph is no longer the leading JS hotspot; different driver workload and profiling overhead prevent a frame-time win claim. Instrumented scenario p9542ms>33ms, remaining assertions pass; no acceptance box closes. Artifacts: artifacts/prd570-final-9e527b712/prd570-shooter-post-buffer-profile/. Remaining attribution points at renderer/driver submission, not another contact traversal shortcut. + +- 2026-10-14 (predeclared interpolation feasibility ceiling, real Opus5.5 high read-only steering): CPU-side interpolation would add per-frame palette work; first bound possible benefit with the already-rejected256/128/64 plain-hold bundle, diagnostic only. Reuse its immutable compiled build and recorded source provenance, adding only exact r2 per-call instrumentation; current integrated production source stays128/32/8. One four-arm rate1/auto/auto/rate1 run, physical NVIDIA, single-L3 affinity8,11,20,23, existing15sample CPU/load/temperature admission, live clock, all complete windows2–4, no retries or another table. Proceed to a GPU-blend prototype only if BOTH raw combined CPU reductions>=42%, ALL four CVs<=15%, and each auto-arm evaluated share.247+/- .01; any other result stops this interpolation route. This is a conservative experiment-entry rule, not a replacement or waiver of original>=40% acceptance. Even a passing ceiling cannot close AC-1: actual interpolation still needs red/green semantics, independent motion judging, native proof and a final binding six-arm series. Plan, bundle/source hashes, original build provenance and exact instrumentation under artifacts/prd570-final-9e527b712/cpu-cadence-ceiling/. + +- 2026-10-14 (interpolation ceiling terminal INCONCLUSIVE, route stopped): all four scenario/perf validations, exact adapter/source/dist/bundle guards, and r2 call/span alignment checks pass. Pair reductions46.02%/56.11%; within-arm CVs26.74%/19.99%/31.81%/7.54%. Both auto arms evaluated share0.2470703125. Three CVs fail the predeclared<=15% rule, so do NOT implement GPU interpolation, retry this ceiling, choose another table or claim AC-1. All windows2–4 retained; no instability normalization or window selection. Production thresholds remain128/32/8; native proof remains historical stock-policy proof. CPU-side artifact-only interpolation over240frames does reduce maximum per-frame vertex motion error6.04/3.49/1.22px to4.15/.99/.35px, while maximum pose lag remains6.05/3.50/1.22px; this is static-world geometric feasibility only, not rendered quality or speed. Original CPU acceptance remains unmet. Next unblocked gate is a freshly rebuilt complete shooter suite on physical NVIDIA after host admission; no Intel baseline waiver or CPU acceptance change. + +- 2026-10-14 (retained-policy motion acceptance reopened): fresh integrated stock128/32/8 compiled build eb1b09439, source/dist/CLI hashes exact before/after compilation;96/96 independent headed physicalIntel/gen12lp captures at frames120–127, two launches/frame/arm/depth, matched logical poses and burned labels, all runtime/bundle/pose/diagnostic guards PASS. Fresh independent read-only judges at10m,40m,120m each return staticNEUTRAL / temporalREGRESSION / overallREGRESSION: foreground tips show small holds followed by larger advances. This is new proof against the retained policy, not an attempt to revive the rejected256table. Stills do not establish subjective60Hzseverity; no temporal equivalence is claimed. The Phase2 combined AC-1 box stays open for both CPU and motion preservation. Artifacts: `artifacts/prd570-stock-motion-integrated/remote/`. +- 2026-10-14 (measured browser-clock limitation and bounded metrology repair): three interleaved repetitions in headlessCPU-only and headedphysicalIntel/gen12lp with the same WebGPUrecipe measure performance.now granularity100microseconds on plainHTTP versus5microseconds with COOPsame-origin/COEPrequire-corp, while observed per-call CPU costs are about2–10microseconds. This proves resolution mismatch, not that it caused the failed CV or that AC-1 passes. Planned smallest benchmark-layer fix: give the crowd Vite dev/preview server isolation headers; verify actual configured preview headers, crossOriginIsolated and <=10microsecond timer quantum, plus the existing crowd scenario without resource/diagnostic failures. Preserve all original CPU thresholds, workload, arms and windows; no new CPU campaign before metrology is verified. Existing rejected cadence ceiling remains stopped. Fresh actual Opus5.5high read-only architectural steering is running on the new stock-motion failure and measured clock limitation; no interpolation code written. Artifact probes: `artifacts/prd570-clock-resolution{,-headed}.json`. + +- 2026-10-14 (actual preview metrology red/green): the unmodified crowd Vite preview on physical headedIntel/gen12lp genuinely fails the isolation assertion, reporting missingCOOP/COEP, crossOriginIsolated=false and100microsecond timer steps. Added only examples/skinned-crowd/vite.config.ts to send COOPsame-origin/COEPrequire-corp on dev+preview. The identical actual-preview probe now passes: same physicaladapter, crossOriginIsolated=true and5microsecond steps. Existing shipped crowd capture scenario passes with zero diagnostics on that configured preview, and its nonblank frame120 capture is visually inspected. Focused Biome and crowd typecheck pass. Artifacts: artifacts/prd570-clock-preview/{red.json,green.json,crowd-run.log,crowd/}. This repairs the measurement environment; it does not close CPU savings or motion preservation, does not prove clock granularity caused the prior CV, and does not revive the stopped cadence ceiling. + +- 2026-10-14 (bounded stock-motion repair, actual Opus5.5 high read-only steering completed): the fresh retained128/32/8 motion regression triggers this PRD's existing interpolation fallback. This is a motion repair, not permission to revive the rejected256/128/64 performance route: that ceiling remains stopped, with no retry or table tuning. Keep original AC-1 >=40% combined CPU reduction and every128-rig CV<=15%, all windows and workload unchanged. Clock isolation is already verified on the actual Intel preview; prove it on the measurement adapter before any binding series. + - Scope: existing SkinnedBatch vertex mechanism and internal animation-rate state, no public appearance option or new render stage. Interpolate displayed pose between evaluated endpoints while keeping current root motion exact. Normals/tangents follow the same deformation; velocity must reference the previous displayed pose, including irregular/forced evaluations. Slot reuse, restart/hide and rate1 must remain exact. Bone attachments and incompatible materials need coherent full-rate protection with honest reporting, never a benchmark-specific exemption. Any bookkeeping and CPU endpoint blending count in the measured methods; moving work outside the spans cannot qualify. + - Red/green requirements before rendered acceptance: steady linear rate4 motion has quarter-sized steps rather than holds/jumps; moving rig and armature roots remain current; reused slots have no previous-owner pose; velocity matches previous displayed pose; rate1 rendered output stays within the independently measured two-launch floor. Tests must observe the actual shader inputs/result rather than a disconnected interpolation formula. + - One rendered experiment after those checks: repeat the unchanged96-capture protocol at10/40/120m, two independent launches per frame/arm, exact provenance and three fresh read-only judges. Accept only no static or temporal REGRESSION at every depth. Stop on a design regression from lag/chord; allow one correction only for a specific defect reproduced by a failing unit test. No alpha/lag/table search or extrapolation. The box remains open until actual rendered proof passes. + - Only after motion passes: one predeclared binding AC-1 series with original criteria, followed by complete unchanged shooter, native crowd/skinned conformance and required current-head CI. A CPU failure leaves AC-1 open; this plan does not establish achievable savings. Reviewer estimate:4–6hours code/tests plus2–3hours captures/judging, unverified. Raw steering: /tmp/pr487-motion-architecture-opus-response.txt (local advisory, not acceptance evidence). + +- 2026-10-14 (motion-repair prototype, not acceptance): genuine red/green shader-input test reproduces rate4 linear holds and verifies equal positive quarter steps after interpolation. New red/green cases catch stale endpoints after forced evaluation, external-armature movement omitted from the write guard, exact-proxy auto throttling, dynamic bone attachments and animated non-bone root compensation. The candidate keeps original128/32/8, stores evaluated/start endpoint palettes with per-rig root compensation and blend factors, and snapshots the previous displayed-pose inputs for velocity. Attachments, detached/independent armatures, incompatible exact draws and clips touching non-bone root transforms retain coherent exact poses with rate1 auto reporting. Explicit numeric overrides remain reported. New per-rig bookkeeping and any interrupted-endpoint blend stay inside SkinnedBatch.write's CPU span. Four focused suites98/98 PASS; core build/publint PASS. Earlier core typecheck passed before the final root-transform guard; its final run found Three PropertyBinding.findNode typed as object, corrected using the same Object3D|null type narrowing already used by clip-audit. Current typecheck result follows. These unit fixtures inspect the actual compiled vertex buffer bindings; they are not a GPU execution claim. +- 2026-10-14 (rendered motion experiment started): frozen candidate build/source provenance under artifacts/prd570-motion-interpolation/. First actual headed NVIDIA/Turing40m auto frame120 capture PASS,zero diagnostics; root inspected nonblank image. Full unchanged96capture protocol now running on desktop because laptop admission refused98C and subsequent status remained96C/load7.67. Keep normal CAPTURE_LOCK1 and physical display credentials; no virtual/SwiftShader substitution. Initial artifact guard omitted the CLI hash and a later invocation lacked Xauthority; both failed before any rendering and are retained, corrected without changing candidate code or gates. Fresh Opus5.5high read-only prototype review first hit max-turn1 without a verdict; one bounded max-turn2 retry is live. Native/shooter/history boxes reopened rather than carrying old-runtime proof into this candidate. CPU AC-1 remains unmet, and the rejected256ceiling remains stopped. + +- 2026-10-14 (prototype completion and bounded correctness correction): first frozen prototype96/96 headed NVIDIA/Turing captures PASS all guards, with zero diagnostics; no visual verdict is claimed. Additional genuine red tests found (a) removal of an attachment during a held tick could revive an old blend, (b) incomplete legacy internal state could throw, and (c) an outer player's animated ancestor was missed by the nearest-player guard. Corrected by resetting interpolation on every exact-pose tick, validating the complete internal metadata, and checking all relevant animation owners. No alpha/lag/rate tuning. The next capture attempt was cancelled explicitly at the owned driver/process tree when fresh review exposed a further root-basis defect; its partial captures remain unaccepted under artifacts/prd570-motion-interpolation-corrected/. +- 2026-10-14 (fresh read-only source review): actual Opus5.5high prototype review returned no verdict (max-turn1 failure,15minute retry cancelled, tools-disabled300second retry timed out). Those are unverified, not approvals. The AFK contract's fresh-review fallback used ephemeral read-only Codex with only brief+diff. It found one P2: a skeleton palette omitting an ancestor Bone leaves poseRoot unproven, so external motion can hold or interpolate in the wrong basis. The new partial-skeleton shader-input regression fails (0.125m versus current0.2m root movement). One-line exact-pose fallback for an unproven root basis makes the case pass, retaining honest rate1 auto protection. Final focused result102/102 across four suites; core typecheck PASS, Biome formatting completes with complexity warnings only, diff check PASS. Reviewer: /tmp/pr487-motion-codex-review-response.txt (local advisory, not runtime acceptance). The corrected source must be frozen and captured anew before any visual box is ticked; shooter/native/history remain reopened and original AC-1 remains unmet. + +- 2026-10-14 (final rendered motion design REJECTED): frozen reviewed candidate ab1d2b20e completes96/96 headed physical NVIDIA/Turing captures, all run guards valid, zero diagnostics, plus48/48 original-source rate1 controls; session71873 exits0. Fresh read-only judges independently inspect all32 original PNGs per depth. At40m and120m every matched frame120–127 in both repeats is REGRESSION from visible pose lag. At10m both temporal sequences are REGRESSION, with repeatable late-frame lag; early frames mostly NEUTRAL. Static geometry, materials, lights and grounding remain NEUTRAL. Successful capture execution and102focused unit tests do not override the visual rejection. The bounded plan's design stop applies: no alpha/lag/table tuning, extrapolation or candidateCPU qualification. Original AC-1 remains unmet; native/shooter/history remain unqualified for this rejected candidate. Recoverable implementation commit and artifact directories are retained. Artifacts: `artifacts/prd570-motion-interpolation-reviewed/`, original controls `artifacts/prd570-rate1-original-nvidia/`. + +- 2026-10-14 (rejected implementation restored): restore only the four animation/projection source/test files from ab1d's parent, retaining all earlier clock/contact-buffer fixes. `git diff46907651f -- packages/core/src packages/core/__tests__ packages/playtest/src examples/skinned-crowd templates/shooter` is empty. Current91/91 focused tests across four files and core typecheck PASS. Retained native16/16 and complete unchanged shooter34/34+boot proof apply to the same restored executable source; no new runtime pass is claimed. All48 matched original-vs-candidate rate1 PNGs are pixel-identical (maximum channel delta0), confirming the rejected defect belongs to the interpolated path. The interpolation commit remains recoverable; motion and CPU gates stay open. + +- 2026-10-14 (distinct-direction real Opus5.5high read-only steering): completed successfully in590.292seconds. NO-GO on exact pose sharing as this PRD's completion route: frozen crowd synchronization would supply its cache hits; it does not establish savings for independent phases, and public mixer/action/bind/caller-edit validation plus per-rig matrices remain. No sharing code or performance measurement authorized by that hypothesis. Installed Three AnimationObjectGroup also forbids simultaneous grouped and direct property control. A CPU-only diagnostic confirms clip/time/weight equality alone is insufficient: partial-weight grouped playback with different original joint poses diverges from independent mixers by0.100502radians. First diagnostic invocation failed import resolution at workspace root/Node18; rerun from core withNode20 produces the observation. This is not an acceptance case. Advisory: /tmp/pr487-distinct-direction-opus-response.txt. +- 2026-10-14 (bounded exact-overhead attribution plan): existing historical Intel control profile does not cleanly split unavoidable work from dispatch. All samples retained, combined AnimationPlayer.update/SkinnedBatch.write sampled1,773,953microseconds: explicit matrix/quaternion/palette math lower bound25.24%; property-assignment dispatch26.23%, track seek/apply26.36%, remainder22.16% unresolved. These are attribution categories, not demonstrated removable overhead or a proof that40% is impossible; do not call whole property setters unavoidable math. One CPU-profiler-only capture of the original immutable9e control bundle on physical NVIDIA, unchanged128rig workload/affinity, normal admission/capture lock, all samples, current CLI and frozen bundle guards. No auto arm, acceptance retry or source change. Cutoff: only a source-supported exact overhead hypothesis can justify implementation; ambiguous attribution remains inconclusive. One profiled control cannot prove infeasibility for both binding comparisons. Artifacts consumed by profiler: `artifacts/prd570-final-9e527b712/exact-control-profile/`. + +- 2026-10-14 (profiler capacity refusal, no browser launched): first60second admission ends124, last observation CPU14.62%, load14.22, Tctl80C, below neither required load<8 nor temperature<80. Foreign Chrome3416848 had consumed about9cores and has now exited; no foreign process was changed. One new300second capacity wait is permitted on that changed external state, retaining exactly15 consecutive observations CPU<25%, load<8, Tctl<80 and normal capture lock. This changes waiting budget only; no product run or acceptance series is retried. Stop if still unavailable. New raw profiler artifacts: `artifacts/prd570-final-9e527b712/exact-control-profile-admitted/`; first refusal retained separately. + +- 2026-10-14 (bounded profiler attempt terminal, no capture): revised300second admission also ends124 before any browser launch. Final observation CPU65.20%, load19.59, Tctl80.625C; unchanged CPU<25/load<8/temp<80 requirements unmet. Both refusal logs retained; no third capture attempt, foreign process changes or admission bypass. Existing Intel control profile attributes77.15% of combined sampled leaf self-time to Three's bundle,20.44% to engine bundle and2.41% to unattributed clock; even deleting all engine leaf work would not prove40% savings in this diagnostic. It is not a binding NVIDIA result or a proof that all Three work is irreducible. +- 2026-10-14 (exact full-rate direct-track lower-bound probe, before execution): source setter contains only property assignment plus dirty flag; existing profiles leave track seek/apply/dispatch costs unresolved. Falsify a distinct exact mechanism using installed Three `KeyframeTrack.createInterpolant` and `PropertyBinding` directly for each rig, with no pose sharing, held frame, future endpoint, bake, table/alpha tuning or GPU change. Use actual12-bone/11-Euler-track2second sway shape fromCrowd.ts, independent heterogeneous phases, stable unit-weight loop. The optimistic oracle omits public mixer/action bookkeeping and safety dispatch, so a pass does not authorize production or close AC-1. At unchanged palette cost the worse retained control requires animation-update cost<=0.4828 of baseline; predeclare entry only if all20 paired CPU-only rounds satisfy both mean ratio<=0.48 and each arm CV<=15%, plus exact poses through loops. Any other result rejects the direct-track route without retry or GPU spending; no favorable-window filtering. Observe host load and label loaded results inconclusive. Artifacts consumed by this probe under `artifacts/prd570-direct-track-lower-bound/`. Prior Opus direction prompt understated the rig as8bones/seventracks; the actual source and this probe use12/11. Its lockstep-sharing NO-GO reasoning does not depend on that count; no numerical estimate from that prompt qualifies a mechanism. + +- 2026-10-14 (direct-track oracle stopped before timing): local functional check catches default Float32 interpolant output versus stock mixer's Float64 buffer (first-frame quaternion delta7.44e-10). Matching Float64 exposes a separate clock mismatch atframe2/rig55: engine clip time0 versus naive modulo1.9999999999999991. The existing engine loop-seam rule remains authoritative; no replacement clock or production fast sampler is implemented. The owned laptop quiet job never reaches its timing command and is cancelled143 before correcting its frozen script; its entire process group665285 is verified absent, no foreign lock/process changed. Shared reader locks prevent exclusive timing even at low laptop load. No timing ratio or feasibility pass is claimed. +- 2026-10-14 (distinct current-time clip lookup feasibility plan, before execution): investigate temporary clip-pose data in the existing skinned vertex mechanism, preserving existing CPU mixer/clock/automatic128/32/8, original three phases and all acceptance gates. This differs from stopped trailing endpoints and synchronized donor sharing: sample an eligible game's clip asset once, then query at actual current logical time for arbitrary independent phases; no future gameplay-state prediction, extrapolation, new render stage or public format. Phase3 compute skin-once remains declined. No production code yet; real read-only Opus architecture assessment is pending. One cheap geometric-only falsifier reuses the existing actual12-bone crowd/geometry helper: sample interval is the observed1/60update tick, lookup Float32 matrices, official control clock, independent phases, moving roots, all vertices at10/40/120m camera poses and ten frames spanning loop seams. Entry requires maximum projected error<=0.1pixel in every observed case, with no parameter search. A pass only supports architecture evaluation, not a shader execution, visual verdict, safe cache lifecycle, memory budget or CPU result. Cache invalidation/public edits, eligibility/full-rate fallback, actual GPU memory limits, velocity and all CPU bookkeeping inside measured methods remain unresolved requirements. Advisory session36288 is bounded600s, read-only Opus5.5high; no verdict inferred while live. + +- 2026-10-14 (current-time lookup geometric entry PASS, not acceptance): the one predeclared CPU-only geometric run exits0. All446,720vertices across128independently phased rigs, ten loop-spanning frames and three10/40/120m camera poses pass the<=0.1pixel entry rule; maximum projected error0.00522602pixels. Moving roots and Float32 palette/root inputs are included. Lookup has120intervals at the observed1/60tick and92,928bytes for this clip. It queries the official rate1 mixer clock, introducing no trailing-time offset in this diagnostic. It is not actual GPU arithmetic, a visual verdict, runtime cache correctness, arbitrary-dt/mutation safety, memory-limit proof or CPU savings. Original AC-1 remains open. Real Opus architecture review36288 remains live; no production implementation yet. Raw harness inputs/output: `artifacts/prd570-current-time-lookup/{feasibility.ts,geometry.json,geometry-stderr.log}`. + +- 2026-10-14 (current-time lookup clock projection PASS, not new clock): artifact query reads existing action time plus the pending integration, retaining the source's loop-seam rule and preserving actual action time when pending time is zero. It never advances or replaces mixer/action state. Across600frames,128independently phased rigs, rates2/4/8 and repeating dt0,1/60,1/30,1/120,1/90, all76,800queries match the rate1 clock within4.10783e-15seconds (predeclared1e-12 tolerance). This supports a future internal draw-time query but does not prove public mutation fallback, shader execution or CPU cost. Raw script/output: `artifacts/prd570-current-time-lookup/{logical-time.ts,logical-time.log}`. Review36288 remains live; no production code changed. + +- 2026-10-14 (bounded current-time lookup prototype design): actual Opus5.5high architecture request36288 terminates124 without a verdict; no approval inferred. Fresh independent read-only Codex architecture critic returns GO for one bounded production prototype, NO-GO for qualification or merge. Scope remains internal Phase2 animation/projection mechanism; original128/32/8 table, authoritative mixer/action clock, >=40% savings in both comparisons, every128-rig CV<=15%, temporal preservation, unchanged shooter/native and current-head CI remain binding. Phase3 compute remains declined. Risk override HIGH: publicly mutable clip/action/rig state, GPU indexing and resource lifetime. Existing PRD is the design/plan; no additional planning or verification document. + 1. Start with a genuine mutation-during-pending regression using AnimationPlayer and the actual SkinnedBatch palette. Internal read-only draw-time query projects existing pending action integration and existing loop-seam rule; it never advances a second clock. Eligibility admits only demonstrably stable single unit-weight non-additive repeating actions and proven complete root-relative rigs. Camera target, root motion, attachments, blends, listeners, unsupported binds and animated ancestors retain exact full-rate behavior. Mutation invalidates cached data, flushes pending evaluation and writes the current exact pose in the same write; auto protection reports rate1 honestly. + 2. Isolated installed Three mixer bakes game clip samples once at the first observed positive tick interval; variable later dt changes lookup time rather than triggering density searches. Shared samples require matching clip contents, hierarchy, inverse binds and rest transforms. Validate in-place track edits, nonanimated/rest-bone edits and public action state. All bake, validation, allocation and bookkeeping stays within existing measured update/write spans; report startup bake honestly. No public format, appearance setting, new dependency or render/compute stage. + 3. Bound bytes and total storage bindings against actual device limits including velocity; unsupported/oversized rigs fall back without unused allocation. Refcount ownership releases data on last user. Existing position, normal, tangent and velocity shader paths read current/previous displayed lookup coordinates and root transforms. Test actual shader storage/indexing, independent phases, loop seams, variable/zero dt, mutations while pending, partial skeletons, moving roots, lookup/exact transitions and slot reuse. + 4. After focused correctness, run exactly one frozen original-protocol CPU comparison including all new validation cost. Continue only if BOTH reductions>=40% and ALL128-rig CVs<=15%; any other result ends this prototype without threshold, density, table or timing retries. This orders cheap CPU rejection before the full motion protocol for this distinct candidate, without weakening either gate. A passing CPU run cannot tick the combined box. Then fresh independent temporal judges at10/40/120m, unchanged installed shooter, native behavior and current-head required CI qualify the exact candidate. Any new runtime change reopens retained-source shooter/native/history proof. + +- 2026-10-14 (lookup safety first verified slice, not lookup completion): genuine new actual-palette regression fails before the fix: drawing after an in-place keyframe edit while time is pending produces0m instead of current0.033333335m. Internal prepareDraw metadata checks the current clip's mutable numeric times/values before the held-palette early return, flushes existing authoritative pending evaluation on a detected edit, refreshes root matrices and retains last displayed palette as velocity history. No new clock or lookup table is implemented. Value and time edits now both pass; all93focused animation/projection/cull/velocity tests, core typecheck, ESM/DTS build and publint PASS. Biome completes with six existing complexity warnings and git diff check passes. Snapshot allocation/validation remains inside measured SkinnedBatch.write; its cost has not been qualified. Public action/rest-bone/cache eligibility/resource checks remain unfinished. This slice is not a complete invalidation or performance claim. Changed-source AC-2 and native box reopened; prior restored-source passes remain historical. Laptop admission refuses98C, so the short native crowd check runs on desktop with fresh source/dist/project hashes and byte-verified host; outcome pending under artifacts/prd570-lookup-safety/native/. + +- 2026-10-14 (current lookup-safety slice native PASS): fresh current-source/current-dist/project guarded desktop crowd run exits0, all16assertions PASS, zero console/network/runtime diagnostics; byte-verified Linux V8/Dawn host unchanged. Native box restored for this exact clip-edit safety slice; this is fixed-step behavior proof, not CPU/FPS savings or temporal judge. Artifacts: artifacts/prd570-lookup-safety/native/. Documentation checks246/246 and3089relative links PASS. AC-2 stays open for changed-source installed shooter qualification. Any later lookup implementation must rerun native and the relevant history/shooter proofs. + +- 2026-10-14 (current-time query and isolated asset-bake component): genuine query RED is missing readPose at rates2/4/8; GREEN reads existing action.time plus existing pending integration with the same seam rule over400frames, four independent phases per rate, dt1/60,0,1/30,1/120,1/90. Public mixer/action time remains unchanged by every read; first observed positive tick selects density once. Stable unit-weight repeating nonadditive action eligibility declines other scheduled actions, paused/speed-interpolated/event/full-rate paths. Genuine public-fade RED finds lookup still eligible after fadeOut; public fadeIn/fadeOut now flush preceding pending time and protect exact playback until Three stops fading. This does not complete public-state/cache eligibility. + New internal animation-pose-table component uses an isolated installed Three mixer with LoopOnce endpoint sampling, explicit transform-only copies and rewritten bound-bone UUID paths; it neither clones userData nor touches live geometry/material/mixer state. Root-relative rest signatures ignore animated components while preserving nonanimated components, hierarchy, bone order and inverse binds. Genuine RED cases for reordered bones, clip-content/interpolation edits and an independently animated ancestor now reject. Attachments, omitted ancestor bones, unsupported paths, nonidentity binds and oversized tables fall back before bake. Actual12-bone/11-Euler-track crowd source is eligible:120intervals,92,928bytes and23,232sampled palette values match stock exactly (maximum difference0). Probe: artifacts/prd570-current-time-lookup/production-bake.ts. No GPU shader reads these tables yet; sharing/resource ownership, actual device-limit plumbing, exact same-write invalidation/fallback and previous displayed lookup inputs remain unfinished. Native/current installed shooter must qualify changed source; original CPU/temporal gates and the one-candidate CPU stop remain unchanged. + +- 2026-10-14 (component checkpoint verification):104/104focused tests across animation-rate, isolated pose table, projection, cull and bind/velocity suites PASS after the final guards; core typecheck, ESM/DTS build, publint, Biome error lane and3089relative documentation links PASS. New component complexity warnings remain nonfatal; no lint suppression added. Current-source/current-dist/current-project native crowd16/16 PASS with zero diagnostics, fixed-step clock, exact host/source guards before/after; artifacts: artifacts/prd570-lookup-components/native/. This verifies current policy runtime boot/behavior only; the isolated pose-table helper is not wired into the native or browser renderer, and its shader/GPU behavior is unverified. Native box restored for this checkpoint, to reopen on renderer integration. AC-2, CPU savings and temporal preservation remain open. + +- 2026-10-14 (concurrent implementation ownership): another active Claude session checkpoints the same unwired component source as9e4b6fa85, then launches an existing Codex worker for bounded prototype steps1–3 only (no commit/push/GPU/playtests). Root detects its live process3918252 and shared runtime edits, stops writing runtime/tests, and leaves that worker ownership while retaining read-only review and PRD accounting. The104focused/typecheck/build/native16 proof above covers the unwired component checkpoint, not the worker's later edits; native box reopens for that changed runtime. The original GPU shader-input, resource, CPU and temporal requirements remain unchanged. + +- 2026-10-14 (independent read-only WIP shader and metrology checks): while the existing worker retains source/test ownership, root adds artifact-only CPU checks of the actual WGSL builder and its bound StorageBufferAttribute arrays. Original valid9e4b6fa85 projection source fails the new shader check because current lookup coordinates are absent; initial control import resolution failure is corrected before this genuine RED. Worker WIP passes15,424matrix-component comparisons across241frames/two independent phases, variable/zero dt, loop seams and moving roots, including previous displayed poses; maximum error0.0000145133m within0.00002m. The compiled shader uses five storage inputs, current.xy/previous.zw coordinates, correct bone strides/root history offsets, and the same current matrix for position/normal/tangent. No GPU execution, visual verdict or CPU saving is claimed; later source changes require rerunning this snapshot check. Artifacts consumed by these checks: artifacts/prd570-lookup-shader-check/{assert-compiled.ts,before.log,current-all-frames.log}. + Genuine metrology RED remains in the inspected worker draft: one actual write produces3skinnedWrite span entries because new lookup begin/end bookkeeping creates additional span calls. The unchanged Crowd readiness requires palette.perFrame===count (Crowd.ts:129), so this would prevent qualification. Reproduction: artifacts/prd570-lookup-shader-check/span-count.ts, output span-count.log, actualWrites1/observedSpanCalls3. Fix by counting all new bookkeeping within the existing per-write measurement semantics; do not weaken readiness, hide work or alter the original CPU protocol. Review the worker's terminal source before any acceptance run; current observations are WIP, not a final review verdict. + +- 2026-10-14 (bounded current-time lookup steps 1–3, CPU correctness verification only): SkinnedBatch now queries AnimationPlayer.readPose and shares isolated bakePoseTable samples across compatible clip/rest/hierarchy/inverse-bind users. Existing vertex skinning reads current/previous sample coordinates and root transforms for position, normal, tangent and velocity; game.ts passes actual backend device limits through renderProjection.ts/projection-apply.ts. Lookup checks per-binding bytes, maxBufferSize and all five storage bindings with velocity (four without) before allocation; last-owner release disposes shared storage. This bounded prototype keeps one table per batch; incompatible clips/bases, unknown limits and unsupported material/interpolation/rig cases use exact evaluation. Pending clip/action/rest/animated-bone edits, blends/additive actions, listeners, root tracks, attachments and partial skeletons retain current exact fallback with rate 1 reporting. A public mixer action activation regression additionally proved and fixed pre-activation pending-time leakage. Twenty-two regression cases fail when restoring the unwired HEAD SkinnedBatch, then pass after restoring the implementation. CPU WGSL generation verifies five storage inputs and all deformation paths; independent phases, loop seams, variable/zero dt, moving/scaled roots, displayed velocity history, lookup/exact transitions, sharing/disposal and slot reuse pass. Genuine metrology RED (three span entries for one write) is fixed by moving lookup history/upload bookkeeping inside actual writes; its regression now reports one entry per write, without changing readiness or the CPU protocol. Final `pnpm exec vitest run packages/core/__tests__/animation packages/core/__tests__/projection-skinned.spec.ts packages/core/__tests__/render-camera-cull.spec.ts`: 9 files / 214 tests PASS, exit 0. `pnpm --filter @threenative/core typecheck`: PASS, exit 0. `pnpm exec biome check` on all nine changed TypeScript files: exit 0, 31 warnings, no fixes pending. No commit, push, Xvfb, GPU execution, playtest, build, CPU benchmark or capture was run by this worker. Step 4, actual GPU/native/shooter/temporal qualification and any performance claim remain unverified as requested; earlier component results do not qualify this renderer integration. No boxes ticked; existing PRD edits and concurrent review notes retained. + +- 2026-10-14 (independent handoff verification and named-control fairness correction): worker3918252 is terminal, source checkpoint9e6d64826. Independent final artifact checks confirm one span entry per actual write and15,424actual compiled-shader input comparisons PASS with source hashes retained in current-final.log. Genuine new baseline RED: five named rate1 gameplay updates followed by draws call mixer.update ten times, alternating dt1/60 and0, because exact fallback unconditionally reevaluated an already current pose. This would inflate the denominator and cannot qualify a CPU win. The minimal correction skips fallback reevaluation when no time is pending and the base effective rate is already1, retaining pending/ineligible repair. Both artifact probe and permanent regression now give exactly five1/60updates; all215focused tests across9files, core typecheck and changed-file Biome error lane PASS. Artifacts: artifacts/prd570-lookup-shader-check/rate1-evaluations-{red,green}.log. No CPU acceptance series has run. Current renderer/native/shooter/temporal/CI qualification remains open. Before source freeze, integrate origin/develop2a6be3ed1 (currently based on99a) and deliver requested task-dependent visual-policy commit217599ab1, which is still only on local primary develop. Actual Opus5.5high read-only review is requested through laptop jobpr487-lookup-opus-review; command start/verdict is not yet established because admission reaches100C and its output file is absent. No approval inferred. + +- 2026-10-14 (post-integration checkpoint): e5eafd421 integrates origin/develop2a6be3ed1. Requested task-dependent visual policy is already present and all25generated mirrors pass sync:agents --check; attempted policy cherry-pick217599ab1 was empty and safely skipped. Core ESM/DTS build/publint and crowd production build PASS. All215focused tests/9files PASS after build completion; an initial concurrent test/build attempt failed package resolution because the build cleaned dist, and is retained as an invocation-order failure, not a product regression. Current-source actual WGSL/storage-input check again passes15,424comparisons, maximum error0.0000145133m, five bindings and source-stability guards (integrated-shader.log). These are CPU-side compiler/input checks, not GPU execution or acceptance. Laptop review job terminates75 at100C before Claude starts; actual Opus5.5high read-only review runs locally with tools/MCP disabled, terminal verdict pending. CPU comparison, fresh temporal judges, changed-source shooter/native proof and required current-head CI remain open; no boxes ticked. + +- 2026-10-14 (resumed qualification and three reproduced lookup repairs): reused `.worktrees/prd570` at1f184bfc6; physical NVIDIA RTX2080 and laptop SSH are available again. All218focused tests and fresh core/playtest/crowd builds pass; the earlier restricted-environment debug/MCP failures rerun39/39PASS. The frozen CPU admission process444260 was cancelled143 before launching any browser or producing any arm capture/window after independent read-only audit identified three safety defects. This is not a CPU comparison or acceptance result; original40%/CV15% gates and no-tuning rule remain unchanged. Three new regressions genuinely fail, then pass after lookup retirement on player disposal, clip signatures captured with action creation, and preservation of simultaneous public clip/bone edits before draw-triggered reevaluation. All221focused tests, core and workspace typecheck, workspace lint and browser/native crowd bundle builds PASS. Independent repair review and full unit suite are pending. Current-source CPU/temporal, installed shooter, native playback and required CI remain open; no acceptance or runtime box ticked. Results are retained under `artifacts/prd570-lookup-candidate/`; no separate verification report. + +- 2026-10-14 (independent first-draw review repair): review reproduced another lost-public-pose case before the first batch draw. Expanded simultaneous clip/bone-edit regressions genuinely fail for first draw, unsupported detached rigs and named rate1 (0.025 instead of authored0.9); retained-basis control passes. Replaced basis-dependent preservation with evaluated-pose component deltas, restored after the final exact-fallback evaluation. Already-current rate1 avoids unnecessary reevaluation. All224focused tests/9files and core typecheck PASS; changed-file Biome has no errors (14 existing warnings), diff check PASS. Independent read-only review approves, reproducing authored0.9/live and0.899999976/displayed. The prior full-unit process was already running when this repair was applied and is not final-source qualification. Workspace typecheck and lint PASS (1449 existing warnings). No CPU timing, GPU/native playback, temporal judge or current-head CI claimed; acceptance boxes remain open. Artifacts: artifacts/prd570-lookup-candidate/compound-{first-red,first-green,focused,typecheck,root-typecheck,format-final}.log. + +- 2026-10-14 (current-time lookup bounded attempt REJECTED): frozen504399319 core/playtest/crowd builds,224post-build focused tests, core/workspace types and lint PASS; actual compiled-input probe passes15,424comparisons. Current-source core split qualification gives3496laptop passes plus2desktop Git-index checks,4skipped; laptop file hashes match desktop. Earlier full run10584pass/2fail/20skip changed source mid-run; its exact two scaffold/build failures pass serially on the clean laptop lane, not a full final-source claim. Two unmeasured preflights were retained: snapshot encountered directory symlinks (fixed with separate link checks), then FORCE_COLOR polluted numeric cohort/port arguments (plain stdout writer exact-byte check passes). Neither launched a browser. + The one actual CPU comparison starts only after original admission and obtains all three windows2–4 for128-rate1-a,128-auto-a and128-auto-b on NVIDIA/Turing, correct surface/count/GPU readiness and source/dist/bundle guards. Retained mean(AnimationPlayer.update+SkinnedBatch.write):1.250ms,5.467ms,6.707ms respectively; observed CVs3.49%,11.66%,12.63%. These partial observations do not constitute the required two ordered comparisons. Fresh independent source review additionally finds the named rate1 arm unnecessarily scans every clip sample in prepareDraw, inflating its denominator; both arms paying that scan does not establish a correct opt-out. This invalidates CPU qualification. Root stops the owned fourth-arm admission before browser launch; driver terminates143, no owned capture/admission process remains, every collected sample retained. Formal complete-series result INCONCLUSIVE (remaining arms missing), plus the independent metrology rejection. No threshold/window/density/table tuning or timing retry; no motion/shooter/native spending on this prototype. The route ends under the bounded plan. Recoverable source504399319 and all artifacts remain in artifacts/prd570-qualification-504399319/. + Revert only the ten lookup source/test files to their46907651f bytes; verify later upstream473/MSAA changes do not touch these paths and preserve all other files, including soldier opacity correction. All176focused tests/8files, core typecheck and workspace typecheck PASS after removing all three remaining device-limit caller hooks; initial typecheck caught those stale imports before correction. No acceptance/history/runtime box newly ticked. Next distinct available falsifier is the already-predeclared direct-track CPU-only oracle, which never reached timing because its artifact clock differed at loop seams. Artifact-only oracle now uses the existing exact loop-seam rule and Float64 interpolant output: all128independent phases over300frames match stock action time and every local pose, maximum quaternion-component delta1.7347e-18; no timing run yet. Original naive-clock probe/logs retained separately from probe-authoritative-seam.mjs. Retain the20round/0.48ratio/CV15% cutoff and no-GPU entry protocol. No production direct sampler or new acceptance comparison authorized by an oracle pass alone. + +- 2026-10-14 (distinct matrix-work route NO-GO): fresh independent read-only feasibility audit at929c1e149 finds hierarchy propagation is predominantly outside the scored update/write spans, while palette writing already deduplicates per-skeleton updates and uses contiguous identity-bind copies. The actual12-bone chain animates11bones, leaving little dependency sparsity. Existing attribution puts all palette writing plus all stride work at29.8% of the sampled auto total even if removed for free; that diagnostic is not a rate1 acceptance bound. No supported path to candidateAuto<=0.60*equallyOptimizedRate1 is established. Namedrate1 must benefit equally from applicable exact-work removal. No implementation, timing retry or gate change follows. Audit subprocess exits0; decision retained in `.linchpin/resume487-next-direction.log`. + +- 2026-10-14 (distinct raw-keyframe GPU mechanism, artifact-only screening): independent read-only audit supports a paper/operation falsifier only. Interpolate the game's original scalar keyframes at authoritative current action time and compose bone transforms inside the existing vertex deformation; never bake/sample pose tables, introduce animation opcodes/interpreter/format, predict gameplay state or add a render/compute stage. This differs from the stopped CPU direct sampler and baked GPU pose-table prototype; neither stopped route is reopened. Original CPU cadence,128/32/8 bands, public mutation/fallback semantics, normal/tangent/deformation and previous displayed history remain binding. Capability search/details confirm existing SkeletalMesh3D/AnimationPlayer and velocity plumbing remain authoritative; no installed raw-channel evaluator is supplied. + Before execution, predeclare maximum12ancestor steps per influence for this actual12bone fixture, no duplicate shared-ancestor evaluation, <=8million joint evaluations/frame for main+one shadow+previous-position envelope, and <=5total vertex storage bindings. Installed actual cylinder349vertices/1656indices and128rigs give4,423,680indexed joint evaluations, conservative7,630,848, five proposed bindings and minimum561,680bytes including current/previous raw-data/basis/state plus exact fallback palettes. Paper assertions PASS; this is not shader compilation, GPU execution, CPU validation cost or acceptance. Operation data consumed by the next artifact probe: `artifacts/prd570-raw-keyframe-screen/operation-count.json`. + Next cheap falsifier, before any production code: one artifact-only Float32 raw scalar-Z interpolation/forward-kinematics oracle against installed Three authoritative current-time poses, actual12bone/11track/349vertex fixture,128independent phases through loop seams and variable/zero dt, normal transforms and10/40/120m projections. Predeclare maximum projected error<=0.1pixel at every depth and normal angular error<=1e-5radians; retain all observations and stop on any non-pass without parameter search. A pass only supports further design review; it cannot authorize implementation, CPU timing, GPU qualification or tick AC-1. Public state validation, history across mutations, real shader execution/cost and device limits remain unresolved. + +- 2026-10-14 (raw-keyframe geometry screen PASS FOR DESIGN REVIEW ONLY): artifact-only Float32 scalar-Z key interpolation/forward kinematics passes against installed Three current-action-time poses over128independent phases*300frames, repeating1/60,0,1/30,1/120,1/90dt and266loop seams, with moving/rotated/nonuniformly-scaled roots. Corrected per-camera-aim maximum projected errors10/40/120m are0.000247780/0.000256587/0.000260487pixels<=0.1; maximum normal angular error5.97648e-7radians<=1e-5. All40,204,800projected comparisons and40,204,800normal threshold checks pass; normal checks repeat13,401,600unique normal evaluations across three cameras. Source hashes stable. Initial invalid-reference-normal preflights are retained. Review of the first result finds a maximum accumulator field mismatch: individual thresholds had no failures, but the reported zero maximum was invalid. Actual-helper regression reproduces it; field/provenance-only repair and self-check pass, then the same unchanged protocol yields the corrected maxima. Both versions/logs retained. Fresh independent read-only reviewer finds no remaining actionable defect and returns PASS FOR DESIGN REVIEW, explicitly no implementation approval. Artifacts: `artifacts/prd570-raw-keyframe-screen/`; quaternion/general action/mutation-history/device/shader/GPU/CPU/AC-1 claims remain unverified. + Next screening step, predeclared before timing: one optimistic CPU-floor diagnostic for this distinct mechanism, not an acceptance comparison. Use installed AnimationPlayer at original measured128rig cadence16x1/49x2/63x4 versus namedrate1; actual SkinnedBatch.write for control. Candidate minimum write validates raw clip contents/references/interpolation and each rig's hierarchy, inverse binds, rest/current-pose fields plus stable unit-loop action state, then prepares raw time/root/eligibility state. Capture evaluated local poses inside candidate update cost. Shared clip checks may run once per synchronous traversal; per-rig checks remain separate. The missing authoritative query, complete public/reentrant mutation/fallback, retained raw-data history/device/resource/real GPU upload costs are nonnegative and explicitly omitted: passing cannot authorize production or satisfy AC-1. No CPU direct-track sampler or baked pose-table revival. + Require untimed mutation-guard self-checks first; then freeze source and artifact,500warmup frames and all20alternating-order paired rounds of500frames,128independent phases, affinity2/11/14/23, original quiet-host admission/live monitoring. Entry only if combined candidate/control mean<=0.50 (retaining0.10baseline headroom before original0.60target) and each arm CV<=0.15; all rounds retained. Any non-pass stops this raw-keyframe route without parameter/window/table/timing retries or GPU spending. Loaded observations are INCONCLUSIVE, never an entry pass. Only root may start timing after readiness review; the worker prepares the diagnostic and untimed checks only. Original six-arm browser CPU protocol, visual judges, native/shooter and CI remain unchanged. + +- 2026-10-14 (ownership transfer and raw-keyframe CPU screen stopped): user directs stopping the other session and continuing here. Live repair worker1402159 terminates143 with edits/backups preserved; PR487 session434365 stops. Repaired artifact self-check exits0, exact128rig cadence16/49/63, preallocated evaluated-pose snapshots, diagnostic/source hash readiness and invalid-statistic checks pass. One admitted CPU-floor attempt stops at round1 live monitoring: CPU30.5142% exceeds unchanged25% limit (load4.41, observed thermal35C). Exit1; no complete20round result or entry pass. Diagnostic throws before writing partial observations, so round1 timings are unavailable and the old self-check result must not be treated as timing evidence. No retry, GPU implementation, acceptance tick or savings claim follows. Original gates remain open. + +- 2026-10-14 (develop integration): merge current origin/develop217599ab1, preserving PRD-565 native/transient-attachment changes. Resolve only scaffold fingerprint conflict by measuring all13 combined generated trees; complete scaffold suite66/66 passes. Animation/projection/cull/Three-upgrade focused tests116/116 pass after frozen-lockfile install; core typecheck, scaffold Biome and31doc-contract tests pass. Partial CPU diagnostics now persist completed arm observations before live-host rejection, but the lost first-round observations cannot be recovered and no timing retry occurred. Current combined runtime still needs full qualification, native/shooter and CPU/temporal acceptance; no box newly ticked. + +- 2026-10-14 (explicit CPU-screen protocol amendment before execution): fresh independent review identifies eager successful-path assertion formatting and duplicate finite scans as removable diagnostic costs, so the old checker was not a valid optimistic mechanism floor. Preserve its source and host-invalid exit; no complete performance result exists. Correct80checks to direct success predicates with failure-only diagnostics, preserving every clip/rig/action/reference/content/finite check and Object.is semantics. Remove only snapshot finite scans duplicated before payload use. Untimed mutation/snapshot/statistics checks PASS; independent corrected-source verdict PASS FOR ONE REVISED IMPLEMENTATION SCREEN. Amend the earlier blanket no-retry stop solely to admit one corrected implementation screen: unchanged128rig cadence16/49/63,500warmup/20alternating paired500frame rounds, affinity2/11/14/23, original25%CPU/load8/temp80 admission and live monitoring, ratio<=0.50 and eachCV<=0.15. Retain all observations, including incomplete rejected rounds. Any non-pass ends this corrected checked design without parameter/window/timing retries; FAIL does not prove every possible raw-channel implementation impossible. PASS still authorizes no production/acceptance claim without resolving omitted costs and remaining original gates. + +- 2026-10-14 (corrected CPU implementation screen stopped): the one revised admitted run exits1 at round1 live monitoring: CPU26.7966% exceeds unchanged25% limit, load7.27, reported thermal35C. Full20round distribution and accepted entry are absent. Partial-result retention now works: pending round1 control update/write means0.250213/0.126193ms and candidate0.140259/0.233796ms are retained with ordering/source hashes/admission samples, explicitly INCONCLUSIVE. These host-invalid observations cannot establish savings or a universal mechanism lower bound. No further timing of this checked unchanged-rig validation design; no production/GPU/AC approval. Artifacts: artifacts/prd570-raw-keyframe-screen/revised-screen.log and raw-keyframe-cpu-floor-result.partial.json. + +- 2026-10-14 (qualification repair): laptop current-source typecheck and lint PASS. Initial pnpm test cannot start in the offload mirror without Git HEAD; transfer an exact shallow Git snapshot f9bac7180, not a fabricated commit, and retain original failure. Fresh suite passes docs/build then detects one retained Corepack V8 cache directory in the orphan gate. Set V8_COMPILE_CACHE_CACHE_DIR to the run-owned infrastructure directory and create it before baseline counting, matching the existing capture-lock baseline. Existing orphan ownership tests6/6, bash syntax and actual laptop orphan browser/teardown gate exit0 PASS; no orphan remains. Browser/profile/lock leak checks and thresholds unchanged. Full suite remains pending. + +- 2026-10-14 (distinct live-input deformation paper screen, not implementation): independent read-only critic finds a bounded design plausible: upload actual finite local transforms/inverse binds each traversal instead of proving them unchanged. Keep clip/action/topology/reference checks, official current-time query, last-genuine-evaluation quaternion snapshots and displayed raw history. Detect public rotation edits at update entry and again at draw: before-update edits use genuine exact mixer fallback; after-update edits draw the current CPU pose without reevaluation. Unsupported edits can permanently protect that rig at exact rate1; automatic reentry is not assumed safe. This differs from the stopped unchanged-rig comparison design. No CPU/GPU implementation or savings claim. First untimed falsifier, predeclared: edit tracked Z before a zero-dt update on actual rate1 AnimationPlayer and observe whether authoritative PropertyMixer retains the direct edit. Then cover changing samples, pre-update untracked axes/quaternion, post-update edit through two later updates, simultaneous key/pose edits at loop seam and preserved-quaternion Euler-order changes with displayed history. A fallback must not unconditionally replace a direct edit with an authored sample when stock mixer leaves it alone. No timing before mutation geometry/history and revised binding/byte/operation screening. + +- 2026-10-14 (zero-dt mutation oracle): actual rate1 AnimationPlayer at time0.3 produces trackedZ=-0.03600000143. Direct editZ=0.5 survives update(0) exactly; update(1/60) then changesZ=-0.03300000131. Both assertions PASS using installed Three and engine. Therefore an unconditional raw-channel replacement after a public edit would be wrong even when an exact mixer update occurred. The proposed live-input design must keep that rig on genuine exact fallback after detected unsupported rotation edits; no automatic reentry or geometry/GPU qualification is inferred. + +- 2026-10-14 (live-input simple fallback COUNTEREXAMPLE): runnable artifact live-input-mutation-counterexample.mjs uses actual AnimationPlayer/installed Three at rates2/4. With one outstanding skipped1/60frame, both arms receive trackedZ=0.5 before dt0. Rate1 retains0.5; forced pending evaluation advances to the correct logicaltime0.3166666667 but overwrites the edit with-0.03300000131. Both cases reproduce; oracle exit0 records SIMPLE_FALLBACK_REJECTED. Matching clocks therefore do not prove matching public mutation semantics. No GPU, CPU screen or production candidate follows this broken fallback. Next feasibility question is whether observations can catch a public mutator before it edits, flush prior pending time first, and permanently protect exact rate1 without replacing callbacks or silently missing methods; simultaneous clip edits remain an unresolved falsifier. + +- 2026-10-14 (optional before-access seam, artifact-only plan): independent review approves one narrow clone falsifier, not production. Instance wrappers cannot catch borrowed/captured methods; instead instrument only an isolated copy of installed Three Euler/Quaternion public getters/setters/methods with an optional per-instance before-access hook. The actual Matrix4.compose uses private quaternion components, so standard world propagation can stay unobserved. At first public access, retire raw and flush pending before the operation; bypass genuine internal updates with exception-safe depth. Predeclare rates2/4, normal and loop-adjacent phases, zero dt, direct/borrowed/captured mutators, public component reads, Euler/quaternion synchronization, callback registration/nesting/throwing and ordinary world propagation. Existing custom callbacks decline activation; registration after activation retires before changing the callback. Require exact clock/component agreement within1e-12 in the bounded cases and restored bypass depth. Clone PASS proves ordering only: direct matrixWorld access, action/mixer fields, simultaneous clip edits, reentrant event ordering, installed-class identity and full mutation/history/GPU/cost acceptance remain unresolved. No timed run or production source change. + +- 2026-10-14 (portable qualification results and fixture repair): supported laptop split excludes native-host package tests and runs package-test/unit with original test ceilings. Terminal exit1:10,635passed,5failed,49skipped in620.92s. Three failures are test fixtures affected by the owned V8-cache baseline: infrastructure now expects two owned directories; search-path fixture follows the two added source lines. All17focused infrastructure/ownership/search tests PASS with existing negative leak/queue/symlink checks retained; Biome PASS. Two remaining60s timeout cases pass unchanged when run serially on desktop: texture-cap test16.69s wall and audio-worker lifetime test21.41s wall. Laptop retry admission refuses97C before tests; no timeout increase or full-suite PASS inferred. Earlier native-package21failures are missing compiled C++ test binaries; native matrix remains unqualified for the integrated source. + +- 2026-10-14 (isolated before-access seam PASS, bounded): clone probe exits0 with34cases and57inserted public seams. Rates2/4, ordinary and loop-adjacent phases, public reads/direct/borrowed/captured methods, callback registration/nesting and exception-safe bypass all meet exact clock/component checks; ordinary world propagation does not retire raw. Initial probe teardown failed because its deliberately throwing callback remained installed during dispose; reset that callback only after the throwing-update/depth assertions, rerun unchanged cases and PASS. Installed Three source is byte-unchanged. Result PASS_FOR_SEAM_ORDERING_ONLY is not production, performance, actual GPU or full mutation/history acceptance. Direct matrixWorld access, action/mixer fields, simultaneous clip edits, event ordering and installed-class identity remain unresolved. Artifact: artifacts/prd570-raw-keyframe-screen/before-access-clone{.mjs,-result.json}. + +- 2026-10-14 (before-access action/clip falsifiers): independent reviewer accepts only the34tested seam-ordering cases, observing that callback registration itself retires raw, so those cases do not establish reentrant edits during an active internal bypass. Suggested paused-action falsifier does not reproduce: existing pending-action integration preserves exact pose/time at rates2/4 before/after dt0; initial assertion expecting a mismatch fails and the actual-equality oracle then passes. Follow-up actual disabled-action, direct action.time assignment and clip-value edit each diverge from rate1 with pending time at both rates (six cases). Disabled candidate remains at0.3 instead of0.3166666667; setting action.time=0.7 incorrectly advances candidate to0.7166666667; clip editing changes candidate pose before the rate1 arm evaluates again. Exact observations retained in before-access-action-clip-result.json. Transform-only hooks are therefore insufficient; no production implementation or timing follows. Clip/action observation ordering, callback bypass, matrix access and full displayed-history semantics remain required, not waived. + +- 2026-10-14 (raw deferred-state route stopped): fresh independent design review returns NO-GO for production. Transform-only hook counterexamples are sufficient rejection. Action hooks would not close retained matrix references, callback-originated edits under bypass, or prior clip/binding state; temporarily substituting old clip data can expose substituted state to callbacks. Keeping authoritative mixer/pose state current every frame is compatible but removes this deferred-state saving mechanism. No further field-by-field observers, timing or GPU implementation for this route. Original CPU/temporal acceptance remains open; this is not a general impossibility claim. + +- 2026-10-14 (qualified fixture repair checkpoint854a9435c):17focused fixtures,246doc contracts,3,097relative links and Biome/diff checks PASS. Exact clean commit transferred to laptop Git/source mirror. Portable package-test/unit rerun prd570-854a-portable-fourworkers starts after admission at65C/load0.19, on CPUs0-4 (four Vitest workers) and package concurrency1, retaining original tests/assertions/timeouts; native-host package stays in its supported desktop lane. Terminal result pending; no green full-suite claim. Native prerequisites are present locally, but a foreign native player1635713 currently owns the desktop host lane; do not stop it. No current-head native/shooter/CI proof inferred from historical runs. + +- 2026-10-14 (requirement-audit correction): independent reviewer retracts the broad conclusion that complete compatibility requires current CPU poses on every frame. Original Solution deliberately defers mixer evaluation and holds CPU state; existing update/#evaluate implements that contract. Universal quaternion/matrix/action equality with rate1 at every public read was an extra requirement of the optional before-access experiment, not original AC-1/AC-2. The six counterexamples still reject that before-access recovery rule, but do not by themselves reject every live-input raw mechanism. Correct CPU mutation reference is the unchanged auto player at the same actual cadence; named rate1 remains the original performance comparison and exact/protected paths retain their existing guarantees. No original CPU, CV, temporal, history, shooter, native or CI gate changes. Next artifact only classifies same-cadence mutation/fallback without forced catchup; no production or timing authorization follows. + +- 2026-10-14 (desktop host-dependent fixture qualification): all24tests across crash-handler-policy, native-adapter-policy and pump-silence PASS,17.43s, on this integrated checkout with its existing compiled host/policy executable. This resolves the laptop missing-binary failures for those fixtures; Xvfb/ALSA diagnostic output is retained by the tool run. Not a full native package/matrix pass or crowd gameplay/visual qualification. + +- 2026-10-14 (integrated portable/native qualification PASS): portable package-test/unit split at854a9435c exits0:777test files pass,10,640tests pass,49existing optional tests skip,592.44s unit duration; orphan baseline stays2before/2after. Original assertions/timeouts retained; four workers/package concurrency1 remove prior resource-contention failures. Desktop @threenative/runtime-native complete package test exits0, including its Vitest command, shipping physics parity and publint. Current11266e0dc source typecheck/lint also exit0 on laptop (lint retains1,445warnings). These are qualification slices for integrated source, not original CPU/temporal acceptance or current-head CI. Full shooter wrapper12239 remains in unchanged quiet-host admission; latest observation CPU33.83%/load10.24/Tctl79.75C is WAIT, not execution or pass. + +- 2026-10-14 (same-cadence mutation reference PASS): actual engine/installed Three28cases across rates2/4 and phases0.3/1.99 preserve existing CPU pose/action/mixer semantics through metadata-only retirement and subsequent0,1/60,0 updates. Real stagger IDs align modulo rate via disposed intervening instances; actual reports/full dt histories match, no private writes. Two separate tracked-Z cases prove extra forced evaluation changes semantics. Parent rerun exits0. Detection/retirement are a CPU artifact model, not implemented raw eligibility/GPU/history/performance. + +- 2026-10-14 (live-input transition preliminary scope corrected): initial48case geometric/history model exits0,79,572vertex comparisons, max0.00006591pixels/3.12798e-7normal radians, fallback/previous error0. Fresh reviewer finds it samples held action.time, not pending logical time; therefore that pass cannot support advancing raw display or smooth motion. No production/timing follows. Artifact correction uses a genuine independently advanced rate1 oracle with identical dt history solely to supply logical display time, asserts raw time/visible output differ from held time and keeps same-cadence mutation fallback/history checks. Production authoritative-time query remains unimplemented/unmeasured; corrected outcome pending. + +- 2026-10-14 (current-time geometry/query artifact PASS): corrected transition model advances a genuine third rate1 player through identical dt history and takes first raw display time from that oracle. All48cases/79,572vertex comparisons pass:6.68024e-5pixels/2.75494e-7normal radians; fallback/previous error0. Every case distinguishes raw current-time geometry from held-time geometry by nonzero6.6–33.2mm vertex displacement. Separately, mechanically isolated AnimationPlayer copy adds one read-only query over existing pending-action integration, preserving original source/method bodies.128independent phases/600frames/rates2/4/8 with variable/zero dt yield76,800queries, max4.21885e-15seconds<=1e-12,49,747nonheld queries/985loop crossings; query does not change public time/rate state. Parent reruns exit0; fresh reviewer accepts bounded geometry/history claim only. No public API, shader/GPU, production, CPU or runtime-acceptance claim. + +- 2026-10-14 (actual batch-cache semantics): real exported SkinnedBatch public palette/AnimationPlayer probe exits0 in6/6cases (position,scale,inversebind at rates2/4). With actual skipped evaluation and unchanged root world, displayed palette stays byte-identical while fresh stock skinning differs0.314–0.553world units. The initial fixture copied skin weights with an incorrect integer type and missed evaluated-frame writes; both fixture defects are corrected and the full actual-batch probe reruns. Original raw-geometry helper already uses Float32weights and remains unchanged. Therefore live-input adoption on every skipped draw would change existing displayed behavior. New bounded cached-basis hypothesis captures inputs only when actual original writer refreshes, preserves held inputs by construction, and falls back from cached world/inverse/bind data without new mixer evaluation. Root-triggered refresh, skeleton dedup, first/returning slots, public actions/clips and previous raw inputs remain binding. Fresh reviewer finds concept coherent but authorizes only hold→root-refresh→retirement falsifier, not implementation or timing. + +- 2026-10-14 (qualified draft delivery and shooter admission terminal): normal push91a73a35a succeeds after fast drift/doc/lint/agent hooks; hosted PR487 head matches and is MERGEABLE/DRAFT75. No publication/deploy trigger applies to this branch under checked workflows. PR body reports current qualification and open original gates. Integrated shooter wrapper12239 terminates124 at its predeclared1,800second outer limit while still in quiet-host admission, with no scenario/browser launch or suite receipt; no integrated shooter PASS inferred. Existing playtest doctor exits0 and confirms Node20/Playwright/Chromium/Xvfb prerequisites; it does not prove quiet-load admission or GPU behavior. No admission bypass or foreign process change. Work continues on cached-basis falsifier while that runtime qualification remains open. + +- 2026-10-14 (integrated native crowd behavior PASS): fresh `pnpm --filter skinned-crowd build:desktop` at local86e33aeb6 succeeds; current playtest CLI and retained bounded native wrapper complete the original crowd-rate scenario with16/16assertions PASS, zero diagnostics, exit0 on physical NVIDIA RTX2080/Vulkan. `TN_FRAME_SPANS=1` selects128/animated/auto; no query shim, scenario or threshold edit. Copied bundle SHA256 b8ac763e826dbdc4eaf2f541ff032d962c9cd83d234801968b7190674206847b; retained native host SHA2565c39ec9b7e47d48d3a48892e4d80ee08bddfe2225614e12b9c523d67564359f1. Bundle/host/manifest/scenario hashes match after execution. Artifacts: `artifacts/prd570-integrated-86e33/native/`; raw log `native-crowd-integrated-86e-absolute.log`. Initial relative-scenario invocation exits2 before assertions; corrected absolute paths resolve it. This is current JavaScript behavior on the byte-identified retained host, not a fresh native-host build, CPU/FPS savings, visual acceptance or full platform qualification. Shooter admission remains open: a new15second observation reaches at most5healthyseconds, with CPU peak55.45%, so no suite retry launches. Cached-basis transition worker's reads-only exit0 is rejected because its assigned artifacts are absent; one corrective worker remains live, with no timing approval. + +- 2026-10-14 (current-source native host qualification): `pnpm native:build` exits0, rebuilding17C++ targets including runtime/WebGPU resources, pipelines and presentation. New host SHA2564233648b30604d7af951e72b960fa82eba30d4ac9418254f9d938a452af99191. The unchanged128/animated/auto crowd scenario reruns on this host:16/16assertions PASS, zero diagnostics, physical NVIDIA RTX2080/Vulkan, wrapper exit0. Bundle/host/manifest/scenario hashes remain unchanged after execution. Artifacts: `artifacts/prd570-integrated-3d5bd/native-built/`; raw logs `native-host-integrated-build.log` and `native-crowd-current-built.log`. This resolves the retained-host limitation for this crowd behavior result. `pnpm native:verify:desktop` is still running: its300-frame core and lifecycle/background-resume checks report PASS, but no overall command pass is claimed before terminal exit. CPU/motion AC-1, installed shooter and required current-head CI remain open. + +- 2026-10-14 (complete desktop gate PASS): current rebuilt host completes `pnpm native:verify:desktop`, exit0:300-frame desktop core, lifecycle/background-resume, all50native contract targets, playtest build/publint, in-frame UI attachment/hit regions and9final Vitest assertions PASS. UI phase p950.03ms is below its unchanged2ms ceiling. Raw log: `native-current-desktop-verify.log`; this qualifies the executed Linux desktop gate, not Android/iOS or original crowd CPU/visual acceptance. +- 2026-10-14 (cached-basis artifact corrected, parent PASS): reviewer finds missing scalar-Z/off-axis rejection, inaccurate retired availability, and absent independent current-time comparison in the initial4-case model. The new off-axis falsifier fails before correction, then4cases/6,980comparisons and16incompatible-pose retirements pass; parent rerun exits0. Max4.318814e-5pixels/2.754937e-7normal radians, cached fallback palette Float32 exact. Current-time geometry now compares against independently advanced rate1 before live edits. Final script SHA2569db2cd1ea9ab8d5fdc347cd30872a333cad35c54b7f2526584b38d3b9042a3e0; helpers/engine sources unchanged. Artifact `artifacts/prd570-raw-keyframe-screen/cached-basis-transition-result.json`. Shared skeletons, returning slots, other bind configurations and raw re-entry remain unresolved; fresh final review accepts the bounded geometry/history claim only. No writer readiness, GPU, production, timing or AC-1 claim. Independent contract audit permits owned key/topology snapshots to remain held until actual batch refresh, with cheap action/clip/duration compatibility guards; immediate mutable-key retirement was a proposed design safeguard, not an original requirement. Time must belong to the cached clip version, and refreshed capture must reflect the effective action interpolant inputs. No replacement clock or original acceptance change. + +- 2026-10-15 (writer behavior overlay PASS, CPU design still blocked): parent reruns36untimed checks across128real rigs with16/49/63cadence;91,089geometry vertices max0.000174165pixels/4.28955e-7normal radians, Float32 fallback exact, held-content probe reads0. Artifact `cached-basis-cpu-readiness-result.json`; script SHA256bcd397c8fc7848a6571cd1d2e424ce0bd466f927f41529a84832d9a7da94a620. Final reviewer classifies this as behavior-overlay evidence only: it retains actual stock palette writes; custom interpolant functions are not excluded; pose compatibility1e-6 lacks a worst-case bound. Proposed metadata including both arms' begin/end would alter the frozen control denominator and is not approved. Next isolated untimed component uses an independent candidate and independent stock oracle, omits stock skin/palette work on raw draws, lazily reconstructs fallback with its own dedup bookkeeping, charges candidate-only history, guards effective function identities, and tightens pose compatibility to1e-7 with adversarial11-bone checks. No timing, production, shader or acceptance claim. + +- 2026-10-15 (independent cached writer parent PASS):48untimed checks,537,809geometry vertices, max0.000174165pixels/1.372774e-6normal radians; exact cached fallback remains within original limits. Eligible raw writes make0stock writes/0Skeleton updates/0stock palette packs/0held-content scans;36candidate fallback packs are charged, verification-oracle reconstruction is outside the writer. Custom effective-interpolant rejection and simultaneous11-bone near1e-7eligibility perturbations pass. Final script SHA256690c9485e0fec85b05c2634008f3584459ce03a21aaf0ccaeda2407867dcb9d4, artifact `independent-cached-writer-result.json`. Fresh independent review accepts bounded untimed design and preparation of a separate frozen-protocol CPU driver. Candidate-only begin/end/history cost must be charged without inflating the stock control denominator. Parent CPU-driver self-check also passes48cases; fresh independent review permits one separately admitted frozen diagnostic series, now running. No timing result, GPU/upload or production claim. Original-contract audit establishes no automatic packed `Skeleton.boneMatrices` refresh requirement for raw draws; production packed-cache consumers and stock-rendering transitions remain unverified. Shooter actual suite completes with wrapper exit1 and unchanged source hashes after launcher143; bridge-operation timeouts reopen AC-2. Read-only failure triage is pending; no retry or assertion change. + +- 2026-10-15 (CPU diagnostic stopped; bounded v2 untimed correction): the single independently reviewed CPU series exits1 at round2 admission, CPU25% against strict<25%, load6.61. Preserved `independent-cached-cpu-screen-result.partial.json` has0completed rounds and one pending pair: control0.329264ms (update0.219730/write0.109534), candidate7.210090ms (update0.194981/write7.015109). These incomplete host-invalid observations are INCONCLUSIVE, not a threshold result or lower bound; the series is not retried. Thermal-zone37°C does not establish actual Tctl coverage. Static review finds charged duplicate copies, numeric-array recursive freezing, temporary matrices and topology serialization. Separate v2 removes only that representation overhead; parent49untimed checks pass,538,507vertices max0.000174165pixels/1.372774e-6normal radians,0stock writes/updates/packs/held scans. Prior-version geometry and exact fallback survive consecutive captures, same-frame dedup and later live/key/scratch mutations. Fresh independent review accepts the bounded untimed correction. Original writer/attempt remain preserved; no v2 timing, GPU/upload, production or acceptance claim. Stock already skips skeleton updates and palette construction after its held-pose/root guard; adding another skip would duplicate it. + +- 2026-10-15 (integrated shooter failure triaged):32/34unchanged scenarios PASS; `headshot-hits-head` sample15000ms and `input-control` advance20500ms time out with0frames after ready/warmup-complete, without console/network errors. Later cases including performance recover. Nearby07:28:19UTC hitch samples show CPU35.6–99.5%, load18.9–27.4 and Tctl82.5–85.25°C, but exact timeout timestamps are absent, so host saturation is a hypothesis rather than established cause. The one unchanged retained-fixture headshot diagnostic reaches the original1800-second outer limit, exit124, without a scenario result. Its captured NVIDIA/Turing WebGPU viewport is1280×720; source hashes match afterward and owned fixture/CLI processes are gone. Physical1920×1080display is distinct from scenario viewport. The actual underlying test exit is unavailable. Doctor prerequisites pass, without proving scene behavior or the stall cause. No full-suite retry, timeout increase or assertion change; AC-2 stays open. + +- 2026-10-15 (v2 CPU screen stopped; original qualification replanned): the separately reviewed v2 driver passes parent49untimed checks and mandatory positive Tctl boundary checks, then its single series stops before the first pair: CPU30.457863% exceeds strict25%, load6.56, Tctl74.875°C. Attempt marker is preserved; no measured pair, partial result or v2 ratio exists. Two desktop admission losses stop further equivalent desktop experiments. The doubtful assumption is that15quiet seconds predict a stable window while other owners' jobs run. No retries or threshold changes. Fresh idle laptop status permits preparation of one current-source original six-arm qualification with offload quiet exclusion, explicit Intel affinity/package-temperature coverage and immutable source/build guards. Independent original-contract review confirms Intel is permitted; this does not qualify raw-writer performance or change any acceptance criterion. Timing awaits procedure review. + +- 2026-10-15 (shooter scene diagnostic PASS; stall cause unresolved): the existing bounded `doctor --url` on the unchanged installed fixture exits0, with3,144distinct screen colours,44ticks between two bridge samples, two moving entities and zero console errors. Served bundle index hashes match afterward. This rejects a persistent startup/bridge-liveness failure in this diagnostic; it does not identify the earlier transient stall or close AC-2. Read-only runner review finds an unbounded live-clock RAF wait after capture setup, but no observation proves it was the stalled await. No scenario replay, timeout increase or gameplay change follows. + +- 2026-10-15 (shared-key-copy v3 untimed PASS): refreshing rigs previously copied22arrays/66key components each despite shared effective backing arrays. Separate v3 reuses one newest immutable owned payload per backing-array pair, while every actual refresh still compares every component with `Object.is`; held frames gain no scans. All original49checks plus a same-frame shared-key mutation/restoration/history/identity falsifier pass in the parent:538,507vertices, max0.000174165pixels/1.372774e-6normal radians, exact cached fallback, zero stock writes/skeleton updates/palette packs/held-content scans. Fresh independent review accepts the bounded untimed design only. Final script SHA2569f231c8a7cd3873ae9dbe5769016be5aead2b03d8f35b89730e234050f212bdc; artifact `independent-cached-writer-v3-result.json`. No timing driver, performance benefit, GPU/upload, production or acceptance claim. + +- 2026-10-15 (fresh original CPU build ready; capacity unavailable): frozen install and fresh crowd/playtest build from9ca077c05 pass on the laptop; source, installed-dependency, built-output and immutable-bundle guards pass. Built manifest SHA256e29f6cd5d884c098cc6c22e84681ee15cf694a02b9025dba808f1aae305dcdab; bundle manifest SHA2560011968b71cf696f346f36f0b298e751184c6d7ebc0828329ce9d870dbbb3382. Corrected procedure receives independent conditional approval, preserving original scenario/plan/evaluator bytes and requiring a fresh isolated-clock proof before timing. The job remains queued on the exclusive quiet lock for20minutes, then only that owned queued job is stopped (exit143): no admission receipt, series marker, browser or samples exist. Three idle CI containers, up32hours, hold shared locks; another owner's offload job also holds one. Shared services and foreign jobs are untouched. Latest laptop care check refuses98°C; desktop capacity probe is CPU49.09%/load9.15/Tctl75.25°C, outside strict CPU/load limits. Omarchy is unreachable. No current integrated original CPU result, fresh clock proof or acceptance pass exists; no gate or threshold is relaxed. + +Current-source attribution follow-up (2026-10-15): the canonical profile reader accounting matches 60,408.487 ms after clamping negative sample deltas. Visible matching call ancestry includes 474.833 ms under `AnimationPlayer.update` and 141.391 ms under `SkinnedBatch.write`; these are incomplete sampled subsets because inlined callers can be absent, not complete target costs or acceptance measurements. The 338.744 ms `ProjectionStability.holds` term lies outside those acceptance spans; quaternion Euler work belongs to the mixer, and recorder clocks are measurement overhead. The entire clip stride measurement is already cached; the proposed duplicate per-tick world-scale work does not exist. Fresh read-only review found no proven production optimization in the inspected paths. No runtime source changed and the original CPU FAIL remains. + +A separate one-shot shooter pending-await diagnostic used a byte-reversible, instrumented copy of the original installed CLI, preserving the original scenario hash, expressions, assertions and runner timeouts. Parent selfchecks, syntax checks and independent preparation review passed. The attempt ended with `TN_PLAYTEST_SERVER_FAILED` / managed server exit 0 before the scenario ran (outer exit 2, zero observed scenario frames); before/after original and prepared-file guards passed. The recorded preview process group and fixture preview inventory were empty afterward. This failed diagnostic establishes neither the historical stall's pending await nor a product regression. Its single-attempt budget is exhausted; no retry, runtime fix or acceptance credit is claimed. Artifacts: `artifacts/prd570-headshot-await-diagnostic/`. + +Bounded runner repair hypothesis (2026-10-15, before implementation): corrected instrumentation of the original installed headshot scenario reaches physical NVIDIA/Turing WebGPU at1280x720, completes its input step and bridge samples, then stalls in one `waitFrames(1)` for74,077 ms until the180-second outer interruption. Original and copied-file guards pass; exact owned process inventory is empty afterward. The diagnostic does not prove why RAF stopped or satisfy AC-2. The engine playtest runner owns the unbounded Node await in `steps.ts`, so repair that layer with its existing operation-timeout diagnostic and Node timer/race/finally-clear pattern. Preserve normal RAF ordering, original scenario assertions and budgets; thread existing remaining startup/step budgets where available, and derive a sufficient existing-policy budget for legitimate long waits. First reproduce with a never-settling `page.evaluate`, then verify successful and zero-frame waits, timer cleanup and existing deadline propagation in `live-clock.spec.ts`. No synthetic frame, timeout relaxation, source-animation change or gameplay acceptance credit. Focused live-clock/unit/type checks first; actual scene behavior remains a separate open gate. + +Bounded runner repair verification (2026-10-15): genuine RED against original `steps.ts` reproduces never-settling RAF waits; a separate original `startupReady.ts` RED confirms missing remaining-budget propagation. The Node-side wait now emits existing `TN_PLAYTEST_OPERATION_TIMEOUT`, clears its timer, defaults only when no budget is supplied and rejects expired/malformed supplied budgets before page work. Startup consumes its existing remaining deadline. Parent tests execute actual queued RAF callbacks through a36-second successful wait with a40-second supplied budget, reject an N-minus-one stall, verify timer cleanup and exhaust startup budgets200/150/100/50ms into `TN_PLAYTEST_STARTUP_NOT_READY`. Final focused results:42/42 across live-clock, startup-ready, device-startup-warmup and startup-assertion; playtest typecheck PASS, changed-file Biome has20warnings/noerrors, diff check PASS. Fresh independent review GO for this boundedness repair. No scenario assertion, original timeout, animation cadence, appearance or acceptance threshold changes. Actual changed-runner browser execution, full qualification and original AC-2 remain open; this is not a shooter gameplay pass. + +Changed-runner scene proof (2026-10-15, `aaed0fb88`): current playtest ESM/DTS build and publint PASS. The first guarded headshot replay forces wall clock and completes353frames with no console/network/runtime errors, but fails multiplier1/health36 versus4/0. A subsequent source audit establishes this is a diagnostic protocol mismatch: the original generated shooter `pnpm test` does not force `--live-clock`; only performance scenarios select wall clock automatically. Preserve that failed replay without calling it an original AC-2 regression or changing gameplay. One separate replay restores the original default clock against the repaired runner and unchanged installed game/scenario: fixed-step receipt,353frames, all5 original assertion results PASS (head multiplier4, health0), no diagnostics, physical NVIDIA/Turing WebGPU1280x720, wrapper exit0. All76 current compiled/source/fixture guards pass before/after, and exact owned preview/CLI inventory is empty. Artifacts: `artifacts/prd570-headshot-bounded-run/` and `artifacts/prd570-headshot-original-clock/`. This verifies the changed runner on the original headshot case; the complete34-scenario suite,60-real-frame boot and AC-2 remain open. + +Untimed compatibility result (2026-10-15): actual V3 Writer bypasses a public `Skeleton.update` override. Across eight rate4 draws and two actual evaluations, genuine stock calls the override three times and retains its finite palette markers; V3 calls it zero times and omits the markers. Parent rerun and fresh read-only review confirm this bounded failure; the helper prefix is byte-identical and58 protected original/source files remain unchanged. Palette/history mismatches also occur, but cannot all be attributed to the override because raw current-time poses already differ from held stock. The first worker comparison was rejected for never executing V3; its rejected artifacts remain separate. Corrected artifact: `artifacts/prd570-raw-skeleton-override/`. No production compatibility or performance approval. + +Next bounded design hypothesis (before implementation): the existing synchronous skinned-group loop can own callback-delimited key-validation epochs. Validate shared effective key inputs once per epoch, preserving immutable packets, per-rig eligibility and actual held/evaluation cadence. Author-controlled calls must invalidate that epoch; custom Skeleton methods use genuine stock behavior, not mathematical replacement. Named rate1 bypasses candidate scans; unsupported shared-memory/accessor/reentrant cases remain stock. First falsifier is raw A → genuine stock B whose overridden `Skeleton.update` mutates shared keys → eligible C: prove B calls/palette, renewed C validation, and unchanged A/history. Also prove external next-transaction mutation is observed. This is untimed design work only; stopped raw timing flights and original acceptance remain unchanged. + +Transaction design proof rejected (2026-10-15): the first worker repeats the old override test unchanged. The corrective artifact reports passing checks, but parent and fresh review find its callback case uses three separate transactions; normal begin resets therefore mask whether the callback barrier works. Genuine stock delegation also lacks matching batch lifecycle and owned palette/history representation. Its66 instrumented comparison components and zero second-rig comparisons exclude remaining per-rig finite/order scans; changed-payload cache reuse is also incomplete. No compatibility or CPU credit is granted. Stop equivalent worker retries and re-plan one actual A-raw/B-stock-callback/C-refresh transaction, with barrier-removal RED and actual stock/history observations. Artifacts: `artifacts/prd570-transaction-falsifier/`; original acceptance remains open. + +Replanned barrier-only proof (2026-10-15): one actual transaction now executes raw A → genuine stock B whose override mutates shared keys → C refresh, with no intervening begin/update. Removing the callback barrier fails on stale0.09 versus mutated0.12; enabled barrier rescans the changed first track (six components), retires C from raw, preserves A's immutable packet and matches B's palette/history to an independent genuine stock fixture with matching batch lifecycle. Fresh review GO for this bounded barrier proof only. The initial hardcoded-adjacent-rate assumption fails setup and is retained separately; corrected selection uses actual rate reports. Fresh history initialization does not prove displayed-frame transitions. Fallback packet integration, accessors/shared memory/reentrancy, full validation cost, GPU representation and original performance/visual acceptance remain open. Artifacts: `artifacts/prd570-transaction-barrier-only/`. + +Next bounded representation hypothesis (before implementation): own immutable raw(version,time) and stock(palette) packets, with previous displayed packet independent of the stock batch's private history. Installing an override while held must not force an extra Skeleton evaluation: only a proven pristine cached CPU basis may be materialized, while an actual refresh invokes genuine stock behavior within matching lifecycle and copies its palette. Retirement stays one-way for that slot lifetime. First untimed falsifier is raw display → override installed while held → genuine refresh → held stock, checking callbacks, owned palette and previous displayed history against an independent sequence. Generic fallback compatibility, reentry, renderer and performance remain unqualified. + +Scoped packet proof and composition (2026-10-15): the rejected predecessor invokes a custom Skeleton override early on a held frame (one call versus stock zero). The owning successor passes12frames against independent stock: one raw display,11 retired stock displays including9 held frames, one author call per arm at the actual scheduled refresh, frozen owned palettes matching stock on every fallback frame, and immutable previous displayed packet identity across frames. Removing the override does not resume raw within that lifetime. Combining this path with the single-transaction callback barrier also passes B's owned palette and actual stock history plus C's exercised pristine fallback; barrier-disabled RED still exposes stale keys. Fresh independent reviews GO for these scoped cases only. Generic fallback, named-rate1 packet handling, release/reclaim, accessors/shared memory/reentrancy, renderer consumption, validation cost and original performance/visual acceptance remain open. Artifacts: `artifacts/prd570-fallback-packets/` and `artifacts/prd570-composed-packet-barrier/`. + +Current repaired-runner qualification (2026-10-15, runtime source `915f79420`): a separate actual-Git laptop mirror preserves the frozen9ca CPU lane. Fresh frozen-lockfile install/build/typecheck/lint and original portable package-test/unit phases exit0; root units report777 files PASS,10,647 tests PASS and49 skipped (four skipped files),622.34seconds. Temporary suite entries remain2before/2after; final tracked diff is clean. Desktop native-package command exits0 with1,587 tests PASS/62 skipped,29 physics-parity assertions,23 Rust tests and publint PASS. A fresh original native crowd run through the rebuilt repaired CLI passes16/16 assertions with zero diagnostics, physical NVIDIA/Turing1280x720,19 compiled/native plus646 source guards unchanged, exit0 and no remaining owned descendants. This uses the byte-verified previously qualified host, not a new C++ build. Artifacts: `artifacts/prd570-runner-qualification/`. These results qualify the executed portable/native behavior lanes; original CPU/temporal AC-1 and required current-head hosted CI remain open. The offload follow-up status command is refused by the laptop temperature care gate; no status-command pass or thermal-control change is claimed. + +Next bounded geometry hypothesis (before implementation, 2026-10-15): one untimed screening attempt interpolates rigid palette endpoints from private pristine Three mixers, using translation lerp, shortest-arc quaternion slerp and uniform scale. Horizon is observed delta times original rate, clipped at authored knots/loop seams; an out-of-horizon query rebuilds at its exact current time. Independent genuine current-time Three evaluation is the oracle. Include hierarchical child-offset chord error, quaternion hemisphere, all intermediate samples, knots/seams and a horizon-crossing delta jump. Fixed screening limits remain0.1pixels/1e-5normal radians; they are not original acceptance criteria. No density/alpha tuning, timing, shader or production change is authorized by this proof. Fresh reviewer GO for this single falsifier only. + +Rigid-bracket screening result (2026-10-15): actual private Three endpoint mixers and an independent current-time Three oracle pass3,840queries/1,340,160vertices at the fixed limits. Maximum0.0728464831pixels/8.8608226e-7normal radians;128knot,128loop-seam and128exact jump-rebuild cases, with shortest-arc hemisphere checks. The original frozen tube/rig/clip/phase helpers are reused and five protected source guards remain unchanged. Node-only setup fails on the existing TypeScript import; the actual tsx execution exits0. Fresh independent review GO for bounded scalar-fixture geometry only. Assigned16/49/63rate counts are not actual cull qualification; knot spacing is fixture-specific, the jump checks its rebuilt endpoint, nonzero chord error is not general comparator sensitivity, and history/mutable state/GPU/performance remain unverified. Artifact: `artifacts/prd570-rigid-bracket-screen/`. Before further correctness work, audit a structural saving mechanism: private endpoint evaluations add work while original public CPU cadence remains mandatory. No performance flight follows this geometric pass. + +Future-endpoint architecture STOP (2026-10-15): fresh cost review finds no demonstrated structural saving. Reusing an actual evaluated CPU pose can avoid the private current-endpoint sample, but distinct original phases still need future mixer evaluation, hierarchy/palette construction, validation and upload while original public evaluation cadence remains unchanged. Shared future endpoints and mutable-input prediction reuse are unproved. Do not expand this endpoint design or launch CPU timing; retain its bounded geometry result. Next read-only question is whether the existing owned matrix walk can certify freshness cheaply enough to omit duplicate per-bone compose/world comparisons without losing late-callback/manual/frozen-node detection. No receipt or production optimization has been implemented. + +Next bounded stock-palette hypothesis (before implementation, 2026-10-15): specialize only default Skeleton updating with translation-only inverse binds; preserve original held/dedup and downstream root/bind packing. Untimed exact stock equivalence comes first, covering signed zero, mutable inverses, nontranslation/nonfinite fallback, authored update overrides, bone texture versions and actual batch held/dedup behavior. A naive column copy must be falsified by signed-zero arithmetic; finite zero entries need the original addition order. No original40percent, temporal improvement, timing or production approval follows. Accessor/proxy inputs and other public method overrides remain explicit compatibility obligations before implementation. + +Translation-palette screening (2026-10-15): naive column copying fails exact signed-zero comparison (stock+0 versus copied-0). The corrected ordinary-input helper passes540cases/794,848 exact Object.is component assertions, including signed-zero inverse entries, Float32 underflow,512 finite matrix cases, inverse mutation/restoration, nonfinite stock fallback, author callbacks and direct texture-version updates. The replanned batch proof reuses the frozen original128-rig fixture and genuine auto16/49/63cadence for16variable-delta frames:1,148 held/900evaluated observations,2,048 repeated writes without extra helper calls, root refreshes, callbacks6/6, and exact current/history buffers throughout. Cohort runs without bone textures; texture versions are separate direct tests. Initial tiny harness failures (constructor, literal state key and texture-padded stock copy) are retained, not candidate failures or padding compatibility claims. Fresh review GO only for ordinary-input equivalence, STOP production/timing at the current helper boundary: preflight plus repeated reads changes accessor/proxy observations and bypasses custom Matrix4 multiplication. Next architecture review targets the existing Matrix4 operator after its original single input read; no source optimization has been implemented. Artifact: `artifacts/prd570-translation-palette/`. + +Next bounded operator hypothesis (before implementation, 2026-10-15): insert a translation-inverse arithmetic branch only after Matrix4.multiplyMatrices has performed its original32 input reads. Preserve public method dispatch, the generic body, exact zero/fourth-column expressions and stock output-assignment order0/4/8/12/1/5/etc. Strict primitive-finite guards avoid coercion. One untimed operator proof compares exact values and observable read/write/coercion traces, including aliases, accessors/proxies, signed zeros, underflow and nonfinite inputs. Fresh architecture review GO for this proof only; global predicate overhead, production integration and original acceptance remain unqualified. + +Single-read operator screening (2026-10-15): the mechanically cloned installed Matrix4 source passes1,553cases/24,848 exact components, with776fast/777generic invocations. Observable input-read/output-write/coercion traces match stock for proxy/accessor inputs, input/output aliases, mutating getters, output setters, coercive values and a throwing valueOf. Signed-zero inverse entries, underflow and nonfinite inputs are explicit. Fresh review GO for this operator proof only; its alias-both case aliases the two inputs, not all three objects. No installed source changes. Next untimed integration hypothesis: original source Skeleton lifecycle and unchanged actual SkinnedBatch, with original128-rig cadence/history, separate texture-version tests, public Matrix4/Skeleton dispatch and reentrant input access. Qualify generic/global operator cost separately only after compatibility proof; no production or timing approval yet. Artifact: `artifacts/prd570-translation-operator/`. + +Scoped source integration PASS (2026-10-15): source Skeleton with only Matrix4 import redirected feeds unchanged installed SkinnedBatch; original128-rig auto cadence,16variable/zero-delta frames, held/repeated writes, current/history palettes, root/inverse changes, author callbacks and detached nonidentity bind match stock exactly. Separate direct texture versions, public Matrix4 multiply/toArray dispatch and nested accessor reentry also match. Fresh read-only review GO for scoped integration and preparation of one artifact-only cost rejection experiment, not compiled/global patch or padded texture cohort qualification. Artifact: `artifacts/prd570-translation-integration/`. + +Operator cost-rejection protocol (before execution, 2026-10-15): one bounded artifact-only experiment, no installed source changes. Compare frozen source Matrix4 against its proven branch clone, removing only artifact counters so both arms have no instrumentation; charge all translation/finite predicates. Original12-bone scalar-sway world/inverse operands form translation workload; nontranslation TRS operands form generic workload. Fixed20,000warmup calls per arm/workload and12alternating paired rounds of250,000calls, one fresh Node process pinned to one allowed CPU,45-second outer bound. Freeze/check operands and source fingerprints, retain checksums, means/ratios and arm CVs. Entry requires translation mean ratio below1, generic ratio at most1.02 and all arm CVs at most15percent; high variance is inconclusive, no unchanged retry. These are prospective research admission checks only: original40percent/both-pair/four-CV/visual requirements remain unchanged. No production admission follows this micro experiment alone. + +Operator cost experiment terminal0 (2026-10-15): source/operand fingerprints and checksums match, all new predicate costs included, no counter instrumentation in either arm. Translation candidate/stock mean ratio1.503555 with CV7.43percent/4.93percent; generic ratio1.154664 with CV39.48percent/70.50percent. Overall verdict INCONCLUSIVE because generic variance exceeds the predeclared15percent limit; research entry is not met and no production patch is admitted. The stable translation observation is evidence against this candidate in this artifact, not an original end-to-end saving or qualified general regression claim. Preserve the single experiment; no unchanged retry, warmup/window/threshold adjustment or old raw flight is authorized. Artifact: `artifacts/prd570-translation-cost/`. Next read-only architecture question is repeated identical scalar-Z Euler-to-quaternion math within one original rig; no memoization, source change or timing has been implemented. + +Next bounded quaternion hypothesis (before implementation, 2026-10-15): one last-input cache shares only the six trigonometric results for identical successive primitive finite Euler angles. Preserve original Euler and Math-function reads; signed-zero-sensitive keys, known-order eligibility and trusted sin/cos identity guards; replacements/nonfinite/nonprimitive/unknown orders execute stock. Cache results are copied into locals before the untouched quaternion switch, field writes and callback, preserving setter/callback reentry. Explicit unresolved limit: trust in Math functions present at module initialization does not prove pre-import replacements pristine. Fresh read-only review GO for one untimed value/trace falsifier only; no code, cost or original acceptance claim yet. + +Trig-cache screening (2026-10-15):1,166cases/4,692 exact components pass, with776cache hits/408misses; all orders, signed zeros, coercion, stateful post-import Math replacements, updatefalse and setter/callback reentry preserve observable traces. Fresh review GO scoped proof only, requiring no new import-time Math reads and fresh-import replacement/accessor evidence. One descriptor setup error is retained separately. Before any cost experiment, choose the smaller universal zero-angle identity operator: native cos(±0)=1 and sin(±0)=the same signed zero, preserving the original stock quaternion switch. Trust is established lazily from the existing two Math reads using conservative exact native-function source checks under an explicit intrinsic Function.prototype.toString assumption; unknown functions/formats remain stock. Fresh architecture review GO for one untimed fixed zero-identity/fresh-import proof only; no memoization timing or production patch. Artifacts: `artifacts/prd570-quaternion-trig-cache/` and `artifacts/prd570-quaternion-zero-identities/`. + +Zero-identity proof execution (2026-10-15):1,171cases/4,712 exact components pass. Source clone preserves stock field writes/callbacks and fresh-import observable traces; native Math accessors add zero import-time reads, stateful function and Proxy-wrapped-native replacements retain12calls per arm, and nonfunction/undefined replacements retain the stock cos-not-a-function error. Parent discovers and reproduces a real guard hole before correction: both unset trusted references equal undefined Math functions and bypass the stock exception. The new undefined test is RED; requiring a nonundefined established trusted function restores GREEN. Initial result is explicitly rejected and the RED is retained. Intrinsic Function.prototype.toString remains an explicit platform trust assumption; unfamiliar native source formats take stock execution. Fresh independent review GO for this scoped zero-identity proof; cost and compiled integration remain unverified. No production edit or original acceptance credit. + +Zero-identity cost protocol (before execution, 2026-10-15): one artifact-only experiment with two fresh sequential Node launches. Reuse original12-bone scalar-sway helpers and genuine Mixer-evaluated Euler operands for zero-heavy work; all-nonzero Euler operands span all six orders. Mechanically remove only artifact hit/miss counters; both arms remain uninstrumented, with all lazy-trust/identity/zero predicates charged. Fixed100,000warmup calls per arm/workload and12alternating paired rounds of1,000,000calls, updatefalse as the Bone quaternion synchronization uses. Choose one allowed CPU from the least-busy0.5-second read-only /proc/stat sample, pin both launches there,90-second outer bound per launch; record host/load, source/operand hashes and exact checksums. Research entry requires zero-heavy candidate/stock mean ratio below1 and ordinary ratio at most1.02 in BOTH launches, with every arm CV at most15percent. High variance is INCONCLUSIVE; no unchanged retry, window/warmup/threshold adjustment. These prospective micro checks neither replace nor relax original40percent/both-pair/four-CV/visual acceptance, and do not qualify compiled/global integration. + +Broad zero-operator cost REJECT (2026-10-15): both fresh launches complete with unchanged source/operand guards and exact checksums. Zero-heavy candidate/stock ratios0.634365/0.625686, ordinary1.159946/1.157440; all arm CVs0.23–1.62percent. The ordinary slowdown violates the fixed1.02 research limit. Fresh review rejects this candidate; no installed patch or unchanged retry. Artifact: `artifacts/prd570-quaternion-zero-cost/`. + +Re-derived XY-zero hypothesis (before implementation): preserve original Euler/two Math reads, then specialize only x===0 and y===0. On that shape, conservative lazy native trust allows c1=c2=1, s1=x, s2=y and original cos(z/2) followed by sin(z/2); replacements use the untouched six-call block. All other shapes execute the untouched block without trust checks or six ternaries. This removes the observed generic-path tax structurally; it does not alter behavior or original acceptance. Fresh architecture review GO for one untimed exact-value/trace proof including coercive z, signed zeros, reentry and pre-import replacements. A cost run is not yet approved or launched. + +XY-zero source proof PASS (2026-10-15): the fixed shape-gated source clone preserves exact values and traces across all six orders,1,536 additional signed-XY-zero poses, coercive-z and z-valueOf reentry, plus the original fresh-import/stateful/invalid-Math checks. Fresh read-only review GO for scoped equivalence and ONE separately predeclared cost comparison. Freeze `artifacts/prd570-quaternion-xy-cost/` driver hashes before launch. Reuse the rejected broad operator experiment's exact operand generator, assert its operand hash before timing, and preserve100,000warmup/12x1,000,000rounds/two fresh launches/CPU-selection policy and all entry/CV limits. Remove only artifact counters; charge the complete new branch/trust predicates. Preserve the broad operator REJECT; no unchanged retry or production admission. + +XY-zero cost entry PASS (2026-10-15): both fresh launches satisfy the frozen research gates. Zero-heavy ratios0.609341/0.603287, ordinary0.995985/1.011780; all CVs0.155–5.63percent. Operands match the prior rejected broad-operator hash, driver/source guards and exact checksums pass, counters are absent and every trust/predicate cost remains charged. Fresh independent review GO for bounded production-candidate preparation only; original40percent/motion acceptance remain open. Artifact: `artifacts/prd570-quaternion-xy-cost/`. + +Production hypothesis (before implementation): ship the proven shape-gated operator through the existing Three patch, consistently in src/math/Quaternion.js, build/three.core.js and build/three.cjs. Preserve the generic six-call block, native trust fallback, signed zero, public callbacks/overrides and input/write order. Add focused math/patch contracts and run existing installer stock/upgrade/idempotence/tamper/CRLF checks; refresh the normal pnpm patch hash and rebuild real consumers/native bundles before claims. First qualify actual runtime compatibility, then original CPU comparison and independent static/temporal visual judges. No micro result ticks AC-1, and any runtime change reopens AC-2 until its suite is rerun. + +Objective-alignment STOP before qualification (2026-10-15): AC-1 explicitly compares rate1 and auto from ONE build; the original driver changes only crowdRate on one frozen preview. The global quaternion speedup affects evaluated-pose work in both arms. With C=H+P and A=H+rP, reducing P decreases relative saving (1-r)P/(H+P) when H>0. Therefore the micro entry does not establish a structural route from31.72percent to40percent and provides no temporal repair. Fresh independent alignment review stops this implementation as the PRD-570 solution; neither old-baseline comparison nor auto-only activation is used to manufacture the threshold. + +Rejected preparation retained (2026-10-15): equivalent source/ESM/CJS Quaternion hunks, normal pnpm patch-hash update and a focused parity/upgrade test were prepared. Five actual new tests pass before the stop; the legacy installer suite is interrupted and unqualified. Worker verifies backups in `artifacts/prd570-quaternion-prepared-rejected/` (candidate patch SHA256ee67dc14210f4061df5ce9e7332fdbac403169c92e29ce326404fb9b6896067d), restores only its owned tracked patch/lock/test edits and completes normal frozen-lockfile offline install of the original patch. Parent PRD is preserved. No commit/push/build/CPU qualification of the candidate; current shipped runtime is unchanged, so existing AC-2 proof remains applicable. Next read-only question targets cadence-independent work: original public-velocity world-position reads and repeated identity Scene compose operations. No new shortcut has been implemented. + +Cadence-independent compose hypothesis (before implementation, 2026-10-15): actual AnimationPlayer.update calls syncStride every tick and reads root world position before delta/stride checks; Object3D world-position freshness recomposes automatic ancestors. First falsifier counts Matrix4.compose by Scene/root/bone identity ONLY within actual player-update regions of matching original128-rig rate1/auto fixtures,16variable/zero-delta frames, strideSyncfalse and identical root motion. Prove equal Scene/root counts across arms and continued calls on held ticks, with unchanged public velocity. Separate bone/other calls that may belong to evaluated-pose P. Source/fixture guards and genuine16/49/63auto cadence remain mandatory. Fresh read-only review GO for this single untimed call-count proof only; no identity-matrix shortcut, micro timing or production edit is approved yet. + +Cadence-independent count proof PASS (2026-10-15): actual player-update instrumentation observes2,048Scene and2,048root composes per arm over16frames, including every held tick; bone/other calls are zero inside these regions. Original auto cadence16/49/63 yields900evaluated/1,148held ticks. All2,048paired public stride snapshots match, groundSpeed follows identical root movement and retained snapshots stay immutable. Source/fixture fingerprints remain unchanged and the wrapper is restored. Fresh independent review GO for this scoped H proof and one untimed positive-primitive-zero identity Quaternion Matrix4.compose operator falsifier. Negative quaternion zero and generic inputs retain stock execution; scale coercion/nonfinite arithmetic, output order, position getter timing and reentry must be preserved. Actual shortcut eligibility must also be observed in the original fixture. Artifact: `artifacts/prd570-compose-h-counts/`. No cost, integration or original acceptance claim. + +Prospective compose cost admission (before execution): only after untimed equivalence and independent review pass, freeze one operator experiment with fixed Scene/root-style identity inputs and fixed nonidentity generic inputs, identical operands/output consumption and source/input/driver hashes. Remove counters equally and charge every eligibility predicate.100,000warmup calls and12alternating1,000,000-call rounds per workload, two fresh launches,90seconds per launch, one allowed CPU chosen once by the existing least-busy0.5-second read-only utilization rule. Entry requires identity candidate/stock ratio below1, generic ratio at most1.02 and every arm CV at most15percent in BOTH launches; retain all rounds. Missing provenance/guards, timeout or instability is INCONCLUSIVE; no unchanged retry or tuning of windows/warmup/thresholds. Micro entry alone cannot quantify enough H reduction for original both-pair40percent, qualify compiled/native integration or repair temporal regression. + +Compose operator screening PASS (2026-10-15):3,686cases/59,328exact components and22observable traces match stock, including signed quaternion zeros, nonfinite/coercive scales, getters/proxies/output setters, same-output reentry and throwing writes. Positive-zero eligibility uses primitive comparisons/division with no new ambient Object.is dependency; generic source is retained exactly. Actual fixture eligibility narrows H coverage: each arm has2,048eligible Scene calls, only16eligible root calls and2,032generic root calls; auto held ticks include1,148eligible Scene and8eligible root calls. Public stride matches; source fingerprints and wrapper restoration pass. Artifacts: `artifacts/prd570-compose-identity-operator/`. Fresh independent review GO for scoped equivalence/eligibility and ONE predeclared cost-admission experiment, with frozen driver review before execution; no timing, production or original acceptance credit. + +Compose cost harness stop before timing (2026-10-15): frozen driver/protocol preparation passes4,096exact preflight components and independent driver review, but actual run.py fails before its first subprocess launch: Python dict accessed as protocol.outerTimeoutSeconds instead of protocol['outerTimeoutSeconds']. One CPU selection is already retained; launch-1.log is opened empty, with no Node launch or timed samples. Preserve original driver/protocol hashes and failure receipts. Correct only this harness access and resume the verified pre-launch state using the SAME selected CPU and new log names; no reselection or cost-result retry. Corrected driver must pass fresh review and external protocol hash checks before timing. No performance result exists. + +Compose cost REJECT (2026-10-15): corrected pre-launch driver independently reviewed, original CPU5 retained, corrected protocol SHA2566451bd1239add6b0fab8395c39405624ecc6fb32e632b8c43dac8d970e7873a6 verified before/after. Both actual fresh launches complete with source/input guards and exact checksums. Identity candidate/stock ratios1.488044/1.555862; generic1.401353/1.453793; all CVs0.31–1.31percent. Both violate predeclared entry gates with stable samples. Reject the candidate; no installed/production edit or unchanged retry. Original CPU and temporal acceptance remain open. Next read-only source question is residual cadence-independent syncStride work when strideSync:false, preserving public velocity and world freshness; no new mechanism is approved. + +Residual stride audit STOP (2026-10-15): fresh read-only review finds no concrete safe strideSync:false shortcut. update still owes fresh world position/displacement/public velocity; measureOf already caches its complete result. Ground speed, hypothetical rate, in-place/override reporting and restoration of a previously synchronized action scale remain observable when synchronization is disabled. Existing code already excludes blend synchronization and action-rate writes on this path. No new stride optimization or timing is authorized without identifying a redundant operation. Next qualification question is why current hosted CI checks are skipped and whether an existing draft-compatible route can qualify the shipped runner gain. + +Current-head hosted qualification started (2026-10-15): read-only CI audit identifies draft gating of scope/ci-required as the reason32checks skip; skips are not qualification. Existing workflow_dispatch invokes exhaustive fresh qualification without changing draft or AC boxes, and native publication separately requires a push-originating workflow_run. Fresh branch/ref and no-active-run checks precede dispatch. Run [38756187566](https://github.com/ThreeNativeHQ/threenative-engine/actions/runs/38756187566) is verified workflow_dispatch at exact hosted SHA4ecce2d8b60e3da9eed1f9604516051ae8e3628a, queued at observation; completion remains unverified. One bounded background watcher retains logs. No source push, merge, ready transition, acceptance weakening or publication. + +Auto-specific scheduling audit STOP (2026-10-15): the projected-rate freshness check is constant-time and repeats neither projection nor reporting. Per-action pending integration exists in both arms and preserves each skipped tick's paused/timeScale contribution. Grouped evaluation checks/restoration own loop seams, paused actions, effective scale and warps; broadening the direct Mixer path lacks equivalence for those cases, fades/blends/events and evaluation identities. No demonstrated redundant operation or new timing candidate is found. While hosted CI runs, one new read-only architecture question asks whether render-only current-time sampling can use actual installed AnimationAction interpolants and their live arrays rather than cloned content requiring full-content scans; action time, public poses/events, interpolant state and custom behavior must stay honest. No existing guard is removed and no sampler implementation/timing is approved. + +Live installed-interpolant shortcut STOP (2026-10-15): independent source review shows AnimationMixer binds resultBuffer to PropertyMixer incoming state; evaluate mutates that buffer and interval state and invokes overridable methods. Direct calls are not read-only. Sampling also omits action time/loop/weight/accumulation ownership. Live arrays reflect current mutations rather than V3's cached held-pose/history version, and supply no hierarchy/bind/authored-pose certificate. An isolated shared-array sampler would be a separate design still requiring version/history/mutation policy and unproven savings. Reject this direct-evaluate shortcut; retain existing guards and stopped timing protocols. Hosted full qualification remains running, with selected unit/browser/playtest/template jobs visible in the retained watcher log; no terminal or green claim. + +Override compatibility repair hypothesis (before implementation, 2026-10-15): preserve the actual V3 public Skeleton.update override RED and original V3 bytes. One artifact-only successor adapts the independently proven OwnedFallbackWriter at writer entry before raw capture, permanently retiring raw eligibility when Skeleton.update differs from the captured default. Use the actual stock held/refresh predicate: genuine refresh dispatches through original SkinnedBatch.write and owns its complete palette; held retirement uses the owned stock palette or prior owned CPU basis without author callback or extra evaluation. Real batch begin/end remains active; returned packets and immutable previous-displayed history are compared against genuine stock, not oracle-filled candidate buffers. Cases cover override at first draw, held ticks, installation after raw eligibility, root-only refresh and removal. Fresh read-only review GO for this single actual RED/GREEN compatibility repair only; generic fallback, shared-skeleton transitions, renderer, cost and original acceptance remain unqualified. + +Override repair scoped PASS (2026-10-15): actual successor compares47returned current palettes and47cached-CPU history palettes with genuine stock,34held frames/two root-only refreshes/40frames after override removal; callback totals2/2per case and every per-frame delta match. Write-boundary mixer evaluations are0/0; previous packet identity/immutability and permanent retirement pass, excluded candidate-buffer oracle calls are zero, original RED/V3/protected source unchanged. Fresh independent review GO for custom-update compatibility only. Explicit history limitation: raw previous packets are reconstructed through cached CPU fallback, not their current-time displayed geometry. Before any broader claim, extend this same fixture to display several held raw packets before override installation, then sample previous geometry from its own immutable version/time through the existing proven packet sampler, preserving nonzero sensitivity against cached CPU reconstruction. Stock CPU history comparison applies only to stock previous packets. No renderer/GPU, generic fallback, shared skeleton, reentrancy, cost or original acceptance claim. Artifact: `artifacts/prd570-raw-skeleton-override-repaired/`. + +Override repair CPU geometry/history strengthened PASS (2026-10-15): six returned raw displays include four held frames before override installation. Existing raw-geometry sampler consumes each saved immutable packet version/time; expected positions/normals are independent newly allocated numeric snapshots.108retained checks include90after root refresh and84after override removal. Raw versus cached CPU basis differs0.0694175m and wrong CPU-time sampling differs0.0694122m, proving sensitivity.42current stock palettes and41previous stock palettes match genuine stock, callbacks2/2per case and zero extra mixer evaluations; initial CPU-only proof preserved, original RED/V3/source fingerprints unchanged. Fresh independent review GO for scoped CPU geometry/history ownership only; actual GPU/renderer and broader fallback/cost/visual acceptance remain unqualified. + +Next valid-bind retirement hypothesis (before implementation): one successor fixture extends the proven owned fallback with an explicit valid nonidentity-bind raw-eligibility miss at a genuine stock refresh, before publishing a raw version. Permanently dispatch through genuine stock thereafter, retaining held CPU cache and previous displayed geometry. Compare paired stock current/history packets after several raw held displays, then make the retired Skeleton.update throw on a later genuine refresh: the identical author error must escape without a new successful packet. Nonfinite/malformed bind inputs fail closed; no catch-all converts author errors into retirement success. Fresh read-only plan GO for this one eligibility/error-routing component only; no held content rescan, extra mixer evaluation, renderer/GPU, timing or production approval. + +Valid-bind retirement/error routing scoped PASS (2026-10-15): eight raw displays/six held precede a valid nonidentity-bind refresh;12current stock palettes/11stock histories/19previous-geometry/132retained raw-geometry checks pass. Retirement stays permanent after identity-bind restoration; eight retired held frames retain cache. Two malformed/nonfinite binds fail closed without changing the old packet/version. At frame20both genuine stock and retired candidate propagate the identical author sentinel without publishing a new successful packet; zero extra mixer evaluations. Callback comparison is retirement-relative: total candidate/control5/7includes two deliberately omitted pre-retirement stock updates; from baseline0/2 both execute5, with matching per-frame deltas. Initial mistaken total-count assertion is preserved and corrected honestly. Fresh independent review GO for this one bind/error-routing case only; other eligibility catches, lifecycle, renderer/GPU, cost and original acceptance remain unqualified. Artifact: `artifacts/prd570-valid-bind-retirement/`. + +Current-head CI failures and first repair (2026-10-15): still-running full dispatch reports five failed jobs: budgets and racing/action-rpg/runner/tower-defense nonvisual templates. Official completed-job logs are captured while the parent run remains active. Budgets fails solely at shooter applicability-table citations Enemy.ts848/854; actual calls are844/850. Local reproduction fails identically, correcting those two cells in existing `docs/verification/PRD-289-conventions-2026-08-31.md` restores the checker, its7contracts pass and complete pnpm budgets exits0 (existing nonfatal LOC/census notices remain). No template source/fingerprint/AGENTS mirror change. Four template logs show operation timeouts before game assertions: racing/action-rpg10frames/22,500ms, runner40frames/30,000ms, tower-defense20frames/25,000ms. Adapter/startup/admission and deadline-propagation cause audits are pending; no timeout relaxation, forced clock change, blind rerun or green CI claim. + +Warmup diagnosis plan (before implementation, 2026-10-15): all four failing frame counts match authored warmupFrames; source review falsifies fixed-step RAF interception, and ordinary warmup precedes creation of the readiness deadline, so no missing remaining-budget argument is established. Existing trace saves only on success, CPU profiling starts after warmup, and inspected bridge fields expose simulation ticks but no authoritative rendered/RAF counter. One ignored copied-runner observer will count completions inside the actual warmup RAF callback chain, then on failure attempt one Node-bounded page probe for that count and existing startup/clock fields; it will boundedly preserve an already-requested trace before teardown. Observation/trace failures remain secondary to the unchanged warmup error. Focused tests exercise responsive and unresponsive probes, incomplete RAF counts and trace failure; original source/bundles, scenario, clocks and operation bounds stay unchanged. Real racing diagnostic requires frozen-clone review before execution. A proposed combined earlier warmup/readiness startup allowance is explicitly a new policy, conditionally reviewed only, and will not be implemented without cold-start evidence or represented as a missing-argument fix. + +Terminal hosted qualification (2026-10-15): exhaustive workflow_dispatch run38756187566 finished FAILURE on exact hosted4ecce2d8b60e3da9eed1f9604516051ae8e3628a. Failed canonical jobs are budgets and racing/action-rpg/runner/shooter/tower-defense, with ci-required correctly failing. Native coverage passed; this is not whole-run qualification. Shooter first fails breakable-shatters warmup30RAF/27,500ms, before gameplay;14scenario summaries show20/30-frame warmup timeouts. Hosted adapter is Google SwiftShader; device-loss observations do not establish the cause of the first timeout. Its26selected nonvisual scenarios exclude8hardware cases and do not replace the retained local physical-GPU AC2 proof. + +One unchanged-bound diagnostic (2026-10-15): frozen OWN copied-runner observer passed60focused assertions/7receipt cases and independent review before execution. Fourteen retained tarball hashes verified;173relevant runtime/template files matched OWN build. Racing boost on desktop forced Vulkan SwiftShader via public browser arguments; actual adapter reports google/swiftshader. Original warmup10frames/22,500ms, scenario/clock/readiness unchanged; run exits0 with400scenario frames, all assertions passing and diagnostics empty. Startup compileSettled17878ms/ready21115ms are page-clock observations, not warmup duration. Failure-only RAF probe was not triggered, so this does not reproduce hosted failure or establish its cause. Requested trace retained; existing qualified300s supervisor cleanup reports remaining{}. Laptop care refused99C, so desktop fallback used. Artifact: artifacts/prd570-warmup-observer/. No policy change, production observer, unchanged retry or acceptance claim. + +Declared startup-policy repair plan (2026-10-15): independent trace review measures racing warmup14,613.093ms (call@24), confirming cold-start work occurs inside warmup without reproducing hosted failure. Apply the existing180,000ms startup allowance once, beginning before warmup, only when startup is requested and advertised. This deliberately relaxes the newly introduced per-operation warmup limit while bounding the historical unbounded-warmup-plus-fresh-startup phase. Keep all authored frames, readiness requirements, optouts, unsupported-page behavior, explicit budgets and steady-operation limits. Enforce one Node deadline even if readiness reads hang; no refreshed allowance after warmup. Implement only after focused red tests for consumed remainder, exhausted warmup, hung readiness and optout/steady-limit preservation; then focused units/typecheck/build, independent review and relevant real scenario. Hosted cause and original CPU/temporal acceptance remain unproven. + +Startup policy implemented and scoped verification passed (2026-10-15): supported, requested startup now shares one existing 180-second allowance across authored warmup and readiness. Unsupported pages and startup optouts retain their ordinary frame-operation limits. A Node deadline also bounds hung readiness reads; an authoritative settlement flag prevents late warmup/readiness/host checks from resuming startup work after expiry. Six initial regression failures and three late-continuation failures were reproduced before their repairs. Final focused verification passes 50 tests across four files; independent review approves the corrected flag. Package build/typecheck, root typecheck/lint, and final budgets exit 0. Rebuilt production CLI racing on observed Google SwiftShader passes all 5 assertions over 400 scenario frames, reaches ready, reports no diagnostics, and leaves no owned children. This is a declared policy change, not a proven hosted failure cause or a change to CPU/motion acceptance. + +Broader local verification remains partial (2026-10-15): the full `pnpm test` passes documentation/build and the playtest package's orphan/publint checks, then fails the separate native timestamp contract: 1 failed, 1,586 passed, 62 skipped. Its C++ fixture directly executes the native test binary; the changed Node startup runner is not on this path. The initial separate portable run incorrectly used the AC2 subreaper wrapper, which can retain adopted zombie processes until root exit; four descendant-disappearance tests failed. The standard launcher passes both affected files (149 passed, 5 skipped), and a complete standard `pnpm exec vitest run` then passes 10,687 tests with 20 skipped across 778 passed files and 3 skipped files, exit 0. Original failed logs remain preserved; the full gate is not reported green. + +Native timestamp diagnosis remains unresolved (2026-10-15): failing V8 test binary SHA256 f8165b40e2668563a597aa40c347f8827c78f4505caae0fbd12eaa11db3cf1ce contains the current fixture; inspected replay mappings preserve distinct timestamp slots and dispatch dimensions. One artifact-only copy adds seven compute-output checks against an independent affine-LCG oracle while retaining the exact shader, dispatch, polling and positive timestamp assertion. On NVIDIA RTX 2080 it passes all seven checks and reports a 65,536-nanosecond compute delta, with empty cleanup. This does not explain the earlier equal nonzero timestamps. [Dawn deliberately coarsens timestamp precision](https://dawn.googlesource.com/dawn/+/refs/heads/main/src/dawn/native/QueryHelper.cpp); the observed step is not established as a hardware clock period, and the fixture's actual executed iteration count remains unverified. No retained final shader assembly was found. No native source, required assertion, acceptance threshold or CMake file was changed. Artifacts: `artifacts/prd570-startup-budget/` and `artifacts/prd570-timestamp-diagnostic/`. + +### Bounded raw-keyframe renderer probe (2026-10-15; unqualified) + +The remaining motion gate needs GPU proof; CPU geometry equivalence is insufficient. Use an artifact-only clone of the existing `SkinnedBatch` vertex node, with per-slot current/previous raw-or-stock state. Raw packets sample original scalar-Z keys and compose the captured hierarchy; retired slots use the genuine stock palette. Keep normal, tangent, shadow and previous-position behavior in the existing material path. This adds no compute stage; Phase 3 remains declined. + +The first slice uses the existing frozen 12-bone fixture, current and previous logical times, and one raw-to-stock retirement. Reuse the installed Three.js TSL/storage interfaces and existing headed capture harness. Require actual GPU execution and current/previous geometry comparison before matched two-run captures and the fresh visual judge. Native execution, CPU cost and original full-crowd acceptance remain unqualified. No production source changes or acceptance ticks are authorized by a helper-only pass. + +The first actual headed probe rendered two curved chains and shadows at frame 122 on Intel Gen-12LP WebGPU, with zero console/network/runtime errors. Its run nevertheless failed: the added `framebufferCoverage` assertion checks an all-loading-backdrop image, not nonblank rendering, and also reported transparent readback. That failed receipt is retained; no GPU arithmetic, temporal parity or performance pass follows from the visible screenshot. The corrected protocol uses the installed screenshot nonblank check and named checkpoint captures; stock comparison distinguishes the never-retired current-time slot from the retired slot's genuine rate-4 held pose. + +The corrected probe subsequently passed six scoped assertions in each of four headed Intel launches (two raw/two stock). Fresh visual judging returned **REGRESSION at frame 119 in both pairs**, and NEUTRAL at 10/121/122; shadows vary within each arm while silhouettes and surface shading match. [All 16 failed-judge checkpoint captures](https://github.com/ThreeNativeHQ/threenative-engine/tree/78847c28340ec811ef1889bc6c928c57aa2b88bf/raw-renderer-20261015-regression) are preserved on the screenshot branch and posted on PR487. The artifact fixture batches fixed ticks within one renderer frame, while Three caches shadows by renderer frame ID. A real renderer-frame boundary per fixed tick is under correction; no visual acceptance box is ticked. + +The artifact-only renderer-frame correction then passed four fresh headed Intel launches (6/6 scoped assertions each). Actual NodeFrame IDs advanced from 2 to 238–242; each presentation rejects stale or missing IDs. A separate fresh visual judge inspected all 16 corrected captures and returned **NEUTRAL at all four checkpoints in both pairs**, with consistent repeated shadows. [Corrected captures](https://github.com/ThreeNativeHQ/threenative-engine/tree/dc2fda2e50b239e484111c5fb71ba59be334bb39/raw-renderer-20261015-frame-boundary) are posted on PR487; the failed-judge images remain preserved. This passes only the fixed two-rig visual probe, not original AC-1 or temporal motion. + +The next bounded artifact slice consumes the existing MRT velocity attachment through `ensureVelocityOutput`, `VelocityTracker` and a diagnostic texture view. On observed held-CPU frames, raw LEFT motion must remain nonzero while retired stock RIGHT and the static camera/floor remain zero. Normal colour rendering is retained. Require actual GPU attachment evidence before claiming previous-position execution; no compute stage, new public API or production change is planned for this probe. + +The first velocity GPU attempt used physical Intel/gen-12lp and correct bound storage modes, but failed because the right player evaluated at frame 122 and sparse diagnostic output tripped the existing global nonblank guard (0.00283 < 0.05). The correction preserves actual two-player staggering, uses frame 121 for transition evidence and frame 124 for both-held comparison, and prepares normal-colour primary presentation with separate MRT GPU readback under unchanged gain/ROI/pixel limits. No corrected GPU result is qualified yet; published `437e0f602fe77824e4626dca3feecd1204cdf9cc` full CI remains green, while original CPU and temporal acceptance remain open. + +Next bounded integration slice: connect guarded writer packets to the existing raw vertex consumer for the original 12-bone scalar-Z chain. Keep the genuine stock batch behind `OwnedFallbackWriter`; consume current and saved previous packets independently, including stock palette upload and dirty notification. Seed fresh-slot previous from current, preserve per-slot permanent retirement and root/bind history, and assert the frozen fixture layout before expanding storage to 128 slots. The falsifier retires one CPU-held slot after differing raw display times, then changes its root: uploaded current/previous ownership, callback counts and neighbouring raw slots must remain correct. This is an artifact integration prerequisite, not CPU, temporal or native acceptance. + +Corrected bounded velocity probe: laptop job `prd570-velocity437-jsonsafe` completed successfully on physical Intel/gen-12lp WebGPU at 1280×720, with 7/7 scenario assertions and zero runtime/console/network errors. Actual frame 121 (renderer 203) and frame 124 (renderer 242) were both CPU-held in raw/stock mode; frame 124 also proved the preceding right display held. Same-pass GPU diagnostic readback at frame 124 had 7,490 nonzero left-ROI pixels (maximum 111), zero right/floor pixels, and opaque pixels throughout, passing the unchanged gain-80/fixed-ROI protocol. This qualifies only the two-rig 8-bit velocity consumer, not exact float velocity, the guarded-writer integration, normal-colour visual neutrality on this build, native or original 128-rig CPU/temporal acceptance. Failed earlier attempts remain retained. + +Packet-consumer component preflight passes on 128 actual guarded-writer rigs: fresh history, differing raw times, held per-slot retirement, root refresh (one callback, matching control), neighbouring raw isolation, exact stock history and storage dirty notification. This is a CPU upload proof plus library build, not GPU or timing qualification; extra mixer evaluations were not directly counted. The bounded browser extraction will replace only the prototype’s callable Node assertion dependency with browser `TypeError` preserving explicit owned-validation messages; valid packets and author-error identity must remain unchanged. The Node assertion class is not claimed portable. + + +First guarded-producer/128-consumer GPU integration passes: laptop job `prd570-packets437-room` exited 0 on physical Intel/gen-12lp WebGPU, frozen browser bundle `d7f9ce2f1e006cdbc1581a1d5585d5ca6df0c95690eaf6afe768f96e9e4e5960`. All 17/17 scenario assertions passed with zero runtime/console/network errors. Actual consumer draw receipts advanced 2/3/4 across held retirement, root refresh and permanent retirement; each encoded exactly 128 instances in one draw. Current/previous storage, stock refresh, neighbouring raw ownership and nonblank captures passed their bounded protocol. Both earlier admissions exited 75 before the CLI started; the successful admission observed 70C/AC/load1 1.95 and free advisory locks. This proves browser compilation, storage and draw integration only: original CPU reductions, 10/40/120m motion quality, public live-array compatibility and native qualification remain open. + + +Production preparation keeps one genuine `SkinnedBatch`: the existing stock `write` body is extracted unchanged into a private fallback method, with the original profiling try/finally retained at the public entry. No raw path, shader, storage or public API is enabled by this extraction. Focused projection-skinned tests and core typecheck pass; this preparation does not advance runtime acceptance. + + +The private AnimationPlayer ownership reader is implemented with RAW rendering still disabled. Root/ancestor lookup returns genuine action/clip ownership and logical playback time including pending held updates; disposed/replaced owners, crossfade/unproved playback, custom query methods/accessors and evaluation reentry are rejected without invoking author code. Parent verification: 7 new packet-state tests plus 42 existing cadence tests pass (49 total), and core typecheck passes. Public palette compatibility must preserve both actual mutable backing arrays and reader-independent motion. Detached diagnostic arrays were rejected because edits did not reach renderer storage; one-way retirement merely on reading was also rejected because it stopped held motion. Production integration therefore mirrors displayed RAW packets into observed backing arrays without changing rendering, and detects actual per-slot array edits before overwriting them; only edited slots retire. Fresh history, first RAW promotion, mid-reconcile observation, reuse and immediate buffer invalidation remain explicit integration checks. The ownership reader alone does not enable or qualify the production raw path. + + +Current production candidate reopens shooter behavior, native crowd behavior, displayed-pose history and the conditional GPU skin-once decision. Historical results above remain scoped to their recorded source revisions. RAW vertex sampling changes GPU work, so the old stock-shader <0.5 ms decision cannot qualify this candidate. No new runtime qualification or CPU/motion acceptance is claimed from unit tests. + +Current production checkpoint (2026-10-15): 98 focused animation/cadence/packet/stock tests and core typecheck PASS; document checks and six document/CI contract files (246 tests) PASS. Fresh review approves the bounded GPU probe after late-sharing displayed-history, same-frame skeleton update deduplication and TSL matrix branch fixes. These results do not prove the original CPU, temporal, native, shooter or conditional GPU acceptance; their reopened boxes remain unchecked. Actual rate-1 controls bypass packet capture using the player's effective cadence, rather than the camera's requested cadence. + +CI repair checkpoint (2026-10-15, after full run 38809385774 on 3cca8470e): the test job reproduced a terrain document save lost by Vite's rapid-change coalescing. A deterministic adjacent-save regression failed with the same keepalive receipt, then passed with an independent owned-file stat watcher and exact close cleanup; terrain's 20 focused tests, build, publint and typecheck pass. Six errors exposed by the root test-inclusive compiler are fixed with null guards and explicit diagnostic types. The budget job's new double-cast finding is removed using a genuine Quaternion; its allocation remains inside the measured write path. Parent root compiler, 32 directly rerun packet/document tests and quality gate pass. The original performance and runtime acceptance boxes remain open; thermal refusals before the laptop CLI are not GPU evidence. + +The same run's unit shards also exposed the double-cast quality finding and a core visual-concern keyword collision in the private CPU pose evaluator names. The helpers now use `evaluateAnimationPosePacket` / `evaluateAnimationPoseFallback`; no constraint regex or waiver changed. Parent rerun: all 29 constraints/packet tests and the quality JSON command test pass; the root compiler also passes. Historical immutable artifact inputs are preserved; current production probes must rebuild against the renamed source before qualification. + +Native proof preparation (runtime unverified): row 52 retains its original two-bone subject and adds genuine 12-bone players/batch with installed MRT velocity readback and an independent rate-1 reference. Readiness waits for actual consumer draws and finite readback proof (24 frames / 30 seconds). Both error and history misregistration use the existing 0.05 px conformance bound; retirement must survive method restoration, a genuine refresh and consecutive held frames. Parent fixture tests: 6/6 PASS; Biome has no errors; fresh independent logic review GO. No registry gate, precision bound or acceptance criterion was weakened. This test preparation does not qualify native execution, the original crowd, visual quality or performance; those boxes remain open. + +### Production GPU and native fixture qualification checkpoint + +The immutable production128-slot probe passed23/23 checks on headed physical Intel/gen-12lp WebGPU at1280×720 with zero console/network/runtime errors. Actual128-instance draws, RAW112/STOCK16 routing, isolated retirement to111/17, current/previous history and nonblank checks passed. Two prior22/23 failures remain preserved; their sole cause was an artifact-server favicon404. The corrected server returns204 for that request while unrelated missing resources still return404; engine inputs and assertions were unchanged. This scoped result does not qualify original CPU, temporal, shooter or native acceptance. + +Full CI38815117314 at e3ed3367e executed native row52 in hosted SwiftShader and failed because MRT configuration had no physical velocity attachment. The fixture now calls the installed `velocityTexture(scenePass)` before rendering. A regression test checks both MRT configuration and the real velocity texture; focused native tests pass6/6, and independent review is GO. GPU readback assertions and precision limits remain unchanged. The corrected native runtime remains unverified pending fresh CI. Original acceptance remains0/2; the original six-arm CPU flight uses its independently frozen e3 build and is not yet qualified. + +The existing Linux desktop CI job now runs the original shipped `crowd-rate.playtest.json` through the desktop CLI, using the bundled actual game, its asset manifest, `TN_FRAME_SPANS=1` and the retained bounded keepalive launch. All scenario assertions remain unchanged and only exit0 passes. Existing conformance artifact upload retains the result. This explicitly permits software adapters for native correctness and makes no physical performance claim. Focused CI contracts pass207 tests and fresh review is GO; actual scenario execution remains pending. Project screenshot parity is not substituted for the shipped crowd-rate assertions. diff --git a/docs/verification/PRD-289-conventions-2026-08-31.md b/docs/verification/PRD-289-conventions-2026-08-31.md index 037b4fdf55..b19a1bc185 100644 --- a/docs/verification/PRD-289-conventions-2026-08-31.md +++ b/docs/verification/PRD-289-conventions-2026-08-31.md @@ -23,7 +23,7 @@ table, reads each template's `AGENTS.md`, and checks the TypeScript AST for the | rts | N/A — every unit is placed by the simulation at its own ground height, so there is no rendered body to keep on a floor | N/A — every model is procedural in metres already, so no authored asset needs normalising | N/A — the template has no held object or character hand | N/A — no skinned or animated asset is loaded | | runner | `src/conventions.ts:19` | `src/conventions.ts:18` | N/A — the runner carries nothing and has no character hand | N/A — the jump is an authored arc, not an AnimationClip | | sailing | N/A — the ship is waterborne and uses buoyancy, not floor grounding | `src/conventions.ts:9` | N/A — the template has no held object or character hand | N/A — no skinned or animated asset is loaded | -| shooter | N/A — the soldier's floor contact is measured against the town's own collider boxes, and a corpse is settled onto the deck it fell on | `src/entities/Enemy.ts:848` | `src/entities/Enemy.ts:854` | `src/entities/Rifle.ts:136` | +| shooter | N/A — the soldier's floor contact is measured against the town's own collider boxes, and a corpse is settled onto the deck it fell on | `src/entities/Enemy.ts:844` | `src/entities/Enemy.ts:850` | `src/entities/Rifle.ts:136` | | snow | N/A — the procedural explorer plants each boot on the canonical snow surface (`src/entities/Explorer.ts`), and no authored model is grounded | N/A — every body is built from primitives in metres; no authored model is loaded | N/A — the template has no held object | N/A — the gait is procedural IK, not an AnimationClip | | starter | `src/conventions.ts:15` | `src/conventions.ts:14` | N/A — the template has no held object or character hand | N/A — the native proof asset is static and has no AnimationClip | | tower-defense | N/A — every tower, walker and pad is placed by the simulation at ground level, so there is no rendered body to keep on a floor | N/A — every model is procedural in metres already, so no authored asset needs normalising | N/A — the template has no held object or character hand | N/A — no skinned or animated asset is loaded | diff --git a/examples/skinned-crowd/playtests/crowd-rate.playtest.json b/examples/skinned-crowd/playtests/crowd-rate.playtest.json new file mode 100644 index 0000000000..f672f597ce --- /dev/null +++ b/examples/skinned-crowd/playtests/crowd-rate.playtest.json @@ -0,0 +1,122 @@ +{ + "name": "crowd-rate-measured-baseline", + "schemaVersion": 1, + "subject": "Run with ?tnFrameSpans=1&crowdCount=32|128|256&crowdArm=animated|held|static&crowdRate=auto|1. CPU spans cover actual AnimationPlayer.update and SkinnedBatch.write calls; crowdRate=1 is the named control and crowdRate=auto must reduce palette writes; held versus baked static controls report main and shadow GPU buckets separately.", + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 120, + "artifacts": { + "screenshots": false + }, + "steps": [ + { + "label": "completed-post-warmup-window", + "timeoutMs": 30000, + "waitForResource": { + "id": "state", + "path": "crowdRate.window", + "gte": 2 + } + }, + { + "label": "complete-cpu-window-and-resolved-gpu-samples", + "timeoutMs": 30000, + "waitForResource": { + "id": "state", + "path": "crowdRate.ready", + "equals": true + } + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "state", + "path": "crowdRate.measuring", + "equals": true, + "allowTrivial": "The crowdCount URL selects measurement mode before the startup observation; the post-baseline measured.window and measurement checks remain unwaived." + }, + { + "id": "state", + "path": "crowdRate.count", + "gte": 32, + "lte": 256, + "allowTrivial": "The crowdCount URL fixes the cohort size at boot; the post-baseline measured.window and measurement checks remain unwaived." + }, + { + "id": "state", + "path": "crowdRate.projectedRigs", + "gte": 32, + "allowTrivial": "Crowd.enter computes a projected held-pose observation per rig before startup sampling; the post-baseline measured.window and measurement checks remain unwaived." + }, + { + "id": "state", + "path": "crowdRate.ready", + "equals": true + }, + { + "id": "state", + "path": "crowdRate.measured.gpuSamples", + "gte": 1 + }, + { + "id": "state", + "path": "crowdRate.measured.gpuMainSamples", + "gte": 1 + }, + { + "id": "state", + "path": "crowdRate.measured.gpuShadowSamples", + "gte": 1 + }, + { + "id": "state", + "path": "crowdRate.measured.gpuMainMs", + "gte": 0 + }, + { + "id": "state", + "path": "crowdRate.measured.gpuShadowMs", + "gte": 0 + }, + { + "id": "state", + "path": "crowdRate.measured.mainDraws", + "gte": 1 + }, + { + "id": "state", + "path": "crowdRate.measured.shadowDraws", + "gte": 1 + }, + { + "id": "state", + "path": "crowdRate.measured.window", + "gte": 2 + }, + { + "id": "state", + "path": "crowdRate.measured.renderFrames", + "gte": 1 + }, + { + "id": "state", + "path": "crowdRate.measured.simulatedSeconds", + "gte": 2 + }, + { + "id": "state", + "path": "crowdRate.window", + "gte": 2 + } + ] + } +} diff --git a/examples/skinned-crowd/playtests/crowd-visual.playtest.json b/examples/skinned-crowd/playtests/crowd-visual.playtest.json new file mode 100644 index 0000000000..e4a539ddf9 --- /dev/null +++ b/examples/skinned-crowd/playtests/crowd-visual.playtest.json @@ -0,0 +1,35 @@ +{ + "name": "crowd-rate-visual", + "schemaVersion": 1, + "target": "web", + "viewport": { + "width": 1280, + "height": 720 + }, + "artifacts": { + "screenshots": false + }, + "steps": [ + { + "label": "freeze-at-render-frame-120", + "timeoutMs": 30000, + "waitForResource": { + "id": "state", + "path": "crowdRate.visualFrozen", + "equals": true + } + }, + { + "label": "render-frame-120", + "waitTicks": 1, + "screenshot": "render-frame-120" + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "runtimeReady": true + } + } +} diff --git a/examples/skinned-crowd/public/assets.manifest.json b/examples/skinned-crowd/public/assets.manifest.json new file mode 100644 index 0000000000..54c91ef848 --- /dev/null +++ b/examples/skinned-crowd/public/assets.manifest.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "entries": {} +} diff --git a/examples/skinned-crowd/src/game.ts b/examples/skinned-crowd/src/game.ts index 90b9ddffed..8392d675b4 100644 --- a/examples/skinned-crowd/src/game.ts +++ b/examples/skinned-crowd/src/game.ts @@ -1,6 +1,6 @@ import { defineGame } from "@threenative/core"; import { playtest } from "@threenative/core/playtest"; -import { Crowd } from "./scenes/Crowd.js"; +import { Crowd, crowdBudget, crowdRate, crowdSpans } from "./scenes/Crowd.js"; /** * A crowd of animated rigs written the way any game writes one: ordinary `SkinnedMesh` objects @@ -9,7 +9,13 @@ import { Crowd } from "./scenes/Crowd.js"; */ const game = defineGame({ plugins: [playtest()], - initialState: {}, + initialState: { crowdRate: {} }, + frameBudget: crowdRate.measuring + ? { reportEvery: 120, onWindow: crowdBudget, report: crowdSpans } + : undefined, + renderer: crowdRate.measuring + ? { gpuTimestampFrameInterval: 1, resolutionScale: 1, pixelRatio: 1 } + : undefined, render: { preferWebGPU: true }, scenes: { crowd: Crowd }, start: "crowd", diff --git a/examples/skinned-crowd/src/scenes/Crowd.ts b/examples/skinned-crowd/src/scenes/Crowd.ts index 26a2dc6d06..db3f59540a 100644 --- a/examples/skinned-crowd/src/scenes/Crowd.ts +++ b/examples/skinned-crowd/src/scenes/Crowd.ts @@ -1,17 +1,35 @@ -import { type ICtx, Scene } from "@threenative/core"; +import { + type ICtx, + type IFrameBudgetWindow, + type ISpanWindow, + SPANS, + SPANS_MARKER, + Scene, + SkeletalMesh3D, + beginSpan, + endSpan, + lodPixelScale, + spansRequested, +} from "@threenative/core"; import { AnimationClip, AnimationMixer, Bone, CylinderGeometry, + DirectionalLight, Float32BufferAttribute, + InstancedMesh, + Matrix3, + Matrix4, Mesh, NumberKeyframeTrack, + Object3D, type PerspectiveCamera, PlaneGeometry, Skeleton, SkinnedMesh, Uint16BufferAttribute, + Vector3, } from "three"; import { crowdLook } from "../render/look.js"; @@ -21,6 +39,193 @@ const BONES = 12; const HEIGHT = 2; const SWAY_SECONDS = 2; +const query = new URLSearchParams(globalThis.location?.search ?? ""); +const requestedCount = query.get("crowdCount") ?? (spansRequested() ? "128" : null); +const count = requestedCount === null ? SIDE * SIDE : Number(requestedCount); +const arm = query.get("crowdArm") ?? "animated"; +const requestedRate = query.get("crowdRate") ?? "auto"; +const focusDepth = query.get("crowdFocus"); +const freezeFrameText = query.get("crowdFreezeFrame"); +const visualFreezeFrame = freezeFrameText === null ? undefined : Number(freezeFrameText); +const updateRate = requestedRate === "1" ? 1 : requestedRate === "auto" ? "auto" : undefined; +if (requestedCount !== null && ![32, 128, 256].includes(count)) + throw new Error("crowdCount must be 32, 128 or 256."); +if (!["animated", "held", "static"].includes(arm)) + throw new Error("crowdArm must be animated, held or static."); +if (updateRate === undefined) throw new Error("crowdRate must be auto or 1."); +if (focusDepth !== null && !["10", "40", "120"].includes(focusDepth)) + throw new Error("crowdFocus must be 10, 40 or 120."); +if ( + visualFreezeFrame !== undefined && + (!Number.isSafeInteger(visualFreezeFrame) || visualFreezeFrame < 1) +) + throw new Error("crowdFreezeFrame must be a positive safe integer."); +const measuring = requestedCount !== null; + +export const crowdRate: Record = { + count, + arm, + updateRate, + measuring, + window: 0, + ready: false, + measured: null, +}; +if (visualFreezeFrame !== undefined) { + crowdRate.visualFreezeFrame = visualFreezeFrame; + crowdRate.visualFrozen = false; +} + +const finiteOrNull = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +export function crowdBudget(window: IFrameBudgetWindow): void { + if (!measuring) return; + crowdRate.gpuMainMs = window.gpuMain ?? null; + crowdRate.gpuMainSamples = window.gpuMainSamples ?? null; + crowdRate.gpuShadowMs = window.gpuShadow ?? null; + crowdRate.gpuShadowSamples = window.gpuShadowRendered?.samples ?? null; + crowdRate.gpuSamples = window.gpu?.samples ?? null; + crowdRate.window = window.window; + crowdRate.renderFrames = window.frames; + crowdRate.simulatedSeconds = window.simulatedSeconds ?? null; + crowdRate.mainDraws = window.passes?.main?.draws.mean ?? null; + crowdRate.shadowDraws = window.passes?.shadow?.draws.mean ?? null; + crowdRate.mainTriangles = window.passes?.main?.triangles.mean ?? null; + crowdRate.shadowTriangles = window.passes?.shadow?.triangles.mean ?? null; + crowdRate.surface = window.surface ?? null; +} + +export function crowdSpans(line: string): void { + console.info(line); + if (!measuring || !line.startsWith(`${SPANS_MARKER}:`)) return; + const window = JSON.parse(line.slice(SPANS_MARKER.length + 1)) as ISpanWindow; + const animation = window.spans.animationUpdate; + const palette = window.spans.skinnedWrite; + crowdRate.animationUpdateMs = animation?.mean ?? null; + crowdRate.skinnedWriteMs = palette?.mean ?? null; + crowdRate.animationFrames = animation?.frames ?? null; + crowdRate.paletteFrames = palette?.frames ?? null; + crowdRate.animationCalls = animation?.perFrame ?? 0; + crowdRate.paletteCalls = palette?.perFrame ?? 0; + crowdRate.ready = + window.overflowed === 0 && + window.window === crowdRate.window && + Number.isSafeInteger(window.frames) && + window.frames > 0 && + window.frames === crowdRate.renderFrames && + // reportEvery is 120 simulation ticks at the fixture's default 60 Hz, not 120 renders. + Number.isFinite(crowdRate.simulatedSeconds) && + Number(crowdRate.simulatedSeconds) >= 120 / 60 && + (arm === "animated" + ? animation?.perFrame === count && + animation.frames === window.frames && + Number.isFinite(animation.mean) + : animation === undefined) && + (arm === "static" + ? palette === undefined + : palette !== undefined && + palette.frames === window.frames && + palette.perFrame === count && + Number.isFinite(palette.mean)) && + Number.isFinite(crowdRate.gpuMainMs) && + Number.isFinite(crowdRate.gpuShadowMs) && + Number(crowdRate.mainDraws) > 0 && + Number(crowdRate.shadowDraws) > 0 && + [crowdRate.gpuSamples, crowdRate.gpuShadowSamples, crowdRate.gpuMainSamples].every( + (samples) => Number.isSafeInteger(samples) && Number(samples) > 0, + ); + if (crowdRate.measured === null && crowdRate.ready === true && window.window >= 2) { + const surface = crowdRate.surface as IFrameBudgetWindow["surface"] | null; + crowdRate.measured = Object.freeze({ + window: finiteOrNull(window.window), + ready: true, + renderFrames: finiteOrNull(crowdRate.renderFrames), + simulatedSeconds: finiteOrNull(crowdRate.simulatedSeconds), + animationFrames: finiteOrNull(crowdRate.animationFrames), + paletteFrames: finiteOrNull(crowdRate.paletteFrames), + animationUpdateMs: finiteOrNull(crowdRate.animationUpdateMs), + skinnedWriteMs: finiteOrNull(crowdRate.skinnedWriteMs), + animationCalls: finiteOrNull(crowdRate.animationCalls), + paletteCalls: finiteOrNull(crowdRate.paletteCalls), + gpuSamples: finiteOrNull(crowdRate.gpuSamples), + gpuMainSamples: finiteOrNull(crowdRate.gpuMainSamples), + gpuShadowSamples: finiteOrNull(crowdRate.gpuShadowSamples), + gpuMainMs: finiteOrNull(crowdRate.gpuMainMs), + gpuShadowMs: finiteOrNull(crowdRate.gpuShadowMs), + mainDraws: finiteOrNull(crowdRate.mainDraws), + shadowDraws: finiteOrNull(crowdRate.shadowDraws), + mainTriangles: finiteOrNull(crowdRate.mainTriangles), + shadowTriangles: finiteOrNull(crowdRate.shadowTriangles), + surface: + surface == null + ? null + : Object.freeze({ + resolutionScale: finiteOrNull(surface.resolutionScale), + sampleCount: finiteOrNull(surface.sampleCount), + drawingBufferWidth: finiteOrNull(surface.drawingBufferWidth), + drawingBufferHeight: finiteOrNull(surface.drawingBufferHeight), + scaleSource: surface.scaleSource ?? null, + atFloor: surface.atFloor ?? null, + compiling: surface.compiling ?? null, + }), + count, + arm, + measuring, + projectedRigs: finiteOrNull(crowdRate.projectedRigs), + bonesPerRig: finiteOrNull(crowdRate.bonesPerRig), + verticesPerRig: finiteOrNull(crowdRate.verticesPerRig), + heldPoseSeconds: finiteOrNull(crowdRate.heldPoseSeconds), + nearDepth: finiteOrNull(crowdRate.nearDepth), + midDepth: finiteOrNull(crowdRate.midDepth), + farDepth: finiteOrNull(crowdRate.farDepth), + projectedHeldHeights: Array.isArray(crowdRate.projectedHeldHeights) + ? Object.freeze(crowdRate.projectedHeldHeights.map(finiteOrNull)) + : null, + }); + } + console.info(`TN_CROWD_RATE:${JSON.stringify(crowdRate)}`); +} + +/** Bake the held pose once; shared geometry keeps both controls at one draw per pass. */ +function staticPose(source: SkinnedMesh): CylinderGeometry { + source.updateMatrixWorld(true); + source.skeleton.update(); + const boneMatrices = source.skeleton.boneMatrices; + if (boneMatrices === null) throw new Error("Crowd held pose has no bone palette."); + const geometry = source.geometry.clone() as CylinderGeometry; + const positions = geometry.getAttribute("position"); + const normals = geometry.getAttribute("normal"); + const indices = geometry.getAttribute("skinIndex"); + const weights = geometry.getAttribute("skinWeight"); + const point = new Vector3(); + const normal = new Vector3(); + const transformed = new Vector3(); + const sum = new Vector3(); + const matrix = new Matrix4(); + const linear = new Matrix3(); + for (let vertex = 0; vertex < positions.count; vertex += 1) { + source.getVertexPosition(vertex, point); + positions.setXYZ(vertex, point.x, point.y, point.z); + normal.fromBufferAttribute(normals, vertex); + sum.set(0, 0, 0); + for (let influence = 0; influence < 4; influence += 1) { + matrix.fromArray(boneMatrices, indices.getComponent(vertex, influence) * 16); + matrix.premultiply(source.bindMatrixInverse).multiply(source.bindMatrix); + linear.setFromMatrix4(matrix); + transformed.copy(normal).applyMatrix3(linear); + sum.addScaledVector(transformed, weights.getComponent(vertex, influence)); + } + sum.normalize(); + normals.setXYZ(vertex, sum.x, sum.y, sum.z); + } + geometry.deleteAttribute("skinIndex"); + geometry.deleteAttribute("skinWeight"); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return geometry; +} + /** A tube bound to a chain of bones, so a bend of the chain bends the tube. */ function tube(): CylinderGeometry { const geometry = new CylinderGeometry(0.22, 0.3, HEIGHT, 12, 22); @@ -73,10 +278,16 @@ function rig(geometry: CylinderGeometry, material: SkinnedMesh["material"]): Ski export class Crowd extends Scene { override enter(ctx: ICtx) { - const extent = (SIDE * SPACING) / 2 + 1; + const extent = measuring ? 125 : (SIDE * SPACING) / 2 + 1; const camera = ctx.camera as PerspectiveCamera; camera.position.set(0, 7, 11); camera.lookAt(0, 0, 0); + if (measuring) { + camera.position.set(0, 3, 0); + camera.lookAt(0, 0, -Number(focusDepth ?? "40")); + camera.far = 250; + camera.updateProjectionMatrix(); + } ctx.add(camera); const look = crowdLook(ctx.scene, ctx.renderer.raw as never, extent); @@ -89,6 +300,107 @@ export class Crowd extends Scene { const geometry = tube(); const clip = swayClip(); + if (measuring) { + const source = rig(geometry, look.skin); + const heldMixer = new AnimationMixer(source); + heldMixer.clipAction(clip).play(); + heldMixer.setTime(0); + source.updateMatrixWorld(true); + source.computeBoundingBox(); + const posedHeight = source.boundingBox?.getSize(new Vector3()).y; + if (posedHeight === undefined || !Number.isFinite(posedHeight) || posedHeight <= 0) + throw new Error("Crowd held pose has no finite positive height."); + const baked = arm === "static" ? staticPose(source) : undefined; + const staticCrowd = + baked === undefined ? undefined : new InstancedMesh(baked, look.skin, count); + if (staticCrowd !== undefined) { + staticCrowd.name = "static-crowd"; + staticCrowd.castShadow = true; + staticCrowd.receiveShadow = true; + staticCrowd.frustumCulled = false; + ctx.add(staticCrowd); + } + const shadows = ctx.scene.children + .filter((node): node is DirectionalLight => node instanceof DirectionalLight) + .map((light) => light.shadow); + const players: SkeletalMesh3D[] = []; + const projectedHeights: number[] = []; + camera.updateMatrixWorld(true); + const cameraPoint = new Vector3(); + for (let index = 0; index < count; index += 1) { + const player = + arm === "static" + ? undefined + : new SkeletalMesh3D({ + source, + clips: [clip], + requiredClips: ["sway"], + strideSync: false, + updateRate, + }); + const mesh = player?.root ?? new Object3D(); + mesh.name = `walker-${index}`; + const band = index % 3; + const row = Math.floor(index / 24); + const depth = band === 0 ? 10 + row : band === 1 ? 40 + row : 120 - row; + mesh.position.set(((Math.floor(index / 3) % 8) - 3.5) * SPACING, 0, -depth); + mesh.rotation.y = index * 0.37; + if (staticCrowd !== undefined) { + mesh.updateMatrix(); + staticCrowd.setMatrixAt(index, mesh.matrix); + } else ctx.add(mesh); + cameraPoint.copy(mesh.position).applyMatrix4(camera.matrixWorldInverse); + projectedHeights.push( + posedHeight * lodPixelScale(camera, ctx.viewport.size.height, -cameraPoint.z), + ); + if (player !== undefined) { + player.play("sway"); + player.update(0); + players.push(player); + } + } + if (staticCrowd !== undefined) staticCrowd.instanceMatrix.needsUpdate = true; + crowdRate.projectedHeldHeights = projectedHeights; + crowdRate.projectedRigs = projectedHeights.length; + crowdRate.bonesPerRig = BONES; + crowdRate.verticesPerRig = geometry.getAttribute("position").count; + crowdRate.heldPoseSeconds = 0; + crowdRate.nearDepth = 10; + crowdRate.midDepth = 40; + crowdRate.farDepth = 120; + let visualRenderFrame = 0; + let visualFrozen = false; + ctx.beforeRender(() => { + if (!visualFrozen) { + visualRenderFrame += 1; + // Both frozen controls redraw shadows, so a cached shadow is never priced as skinning + // saved. + for (const shadow of shadows) shadow.needsUpdate = true; + if (arm === "animated") + for (const player of players) { + beginSpan(SPANS.animationUpdate); + try { + player.update(1 / 60); + } finally { + endSpan(SPANS.animationUpdate); + } + } + if (visualFreezeFrame !== undefined) { + const rates = players.map((player) => player.updateRateReport.rate); + crowdRate.visualRenderFrame = visualRenderFrame; + crowdRate.visualLogicalSeconds = visualRenderFrame / 60; + crowdRate.visualEvaluationTimes = players.map((player) => player.mixer.time); + crowdRate.visualRates = rates; + crowdRate.visualEvaluated = players.map((player) => player.updateRateReport.evaluated); + crowdRate.visualThrottledRigs = rates.filter((rate) => rate > 1).length; + visualFrozen = visualRenderFrame >= visualFreezeFrame; + crowdRate.visualFrozen = visualFrozen; + } + } + ctx.state.set({ crowdRate: { ...crowdRate } }); + }); + return; + } const mixers: { mixer: AnimationMixer; phase: number }[] = []; for (let index = 0; index < SIDE * SIDE; index += 1) { const mesh = rig(geometry, look.skin); diff --git a/examples/skinned-crowd/vite.config.ts b/examples/skinned-crowd/vite.config.ts new file mode 100644 index 0000000000..3b80c8a5c0 --- /dev/null +++ b/examples/skinned-crowd/vite.config.ts @@ -0,0 +1,7 @@ +// Microsecond CPU spans need the isolated browser clock. +const headers = { + "Cross-Origin-Embedder-Policy": "require-corp", + "Cross-Origin-Opener-Policy": "same-origin", +}; + +export default { preview: { headers }, server: { headers } }; diff --git a/packages/core/__tests__/animation-packet-state.spec.ts b/packages/core/__tests__/animation-packet-state.spec.ts new file mode 100644 index 0000000000..dac65698b8 --- /dev/null +++ b/packages/core/__tests__/animation-packet-state.spec.ts @@ -0,0 +1,128 @@ +import { AnimationClip, AnimationMixer, Group, NumberKeyframeTrack } from "three"; +import { describe, expect, it, vi } from "vitest"; +import { readAnimationPacketState } from "../src/animation-packet-state.js"; +import { AnimationPlayer } from "../src/animation.js"; + +function fixture(root = new Group()) { + const clip = new AnimationClip("sway", 1, [ + new NumberKeyframeTrack(".rotation[z]", [0, 0.5, 1], [-0.09, 0.09, -0.09]), + ]); + const player = new AnimationPlayer({ + root, + clips: [clip], + strideSync: false, + updateRate: 8, + }); + player.play("sway"); + return { player, root, clip, action: player.mixer.clipAction(clip) }; +} + +describe("internal animation packet ownership", () => { + it("reads held pending time and repeating seams without advancing the mixer", () => { + const f = fixture(); + const mixer = new AnimationMixer(new Group()); + const action = mixer.clipAction(f.clip).play(); + let held = 0; + for (let frame = 0; frame < 180; frame++) { + f.player.update(1 / 60); + mixer.update(1 / 60); + const before = f.action.time; + const snapshot = readAnimationPacketState(f.root); + expect(snapshot?.time).toBeCloseTo(action.time, 12); + expect(snapshot?.action).toBe(f.action); + expect(snapshot?.clip).toBe(f.clip); + expect(snapshot?.mixer).toBe(f.player.mixer); + expect(snapshot?.evaluated).toBe(f.player.updateRateReport.evaluated); + expect(f.action.time).toBe(before); + if (!snapshot?.evaluated) held++; + } + expect(held).toBeGreaterThan(100); + f.player.dispose(); + }); + + it("finds the nearest genuine ancestor and removes disposed ownership", () => { + const outer = fixture(); + const child = new Group(); + outer.root.add(child); + expect(readAnimationPacketState(child)?.root).toBe(outer.root); + const inner = fixture(child); + inner.action.paused = true; + expect(readAnimationPacketState(child)).toBeUndefined(); + inner.player.dispose(); + expect(readAnimationPacketState(child)?.root).toBe(outer.root); + outer.player.dispose(); + expect(readAnimationPacketState(child)).toBeUndefined(); + }); + + it("does not remove a replacement owner's reader", () => { + const first = fixture(); + const second = fixture(first.root); + first.player.dispose(); + expect(readAnimationPacketState(first.root)?.mixer).toBe(second.player.mixer); + second.player.dispose(); + }); + + it("rejects crossfade playback", () => { + const root = new Group(); + const clips = ["a", "b"].map( + (name) => + new AnimationClip(name, 1, [new NumberKeyframeTrack(".rotation[z]", [0, 1], [0, 0.1])]), + ); + const player = new AnimationPlayer({ root, clips, strideSync: false, updateRate: 8 }); + player.play("a"); + player.play("b", { fade: 0.5 }); + expect(readAnimationPacketState(root)).toBeUndefined(); + player.dispose(); + }); + + it("rejects custom methods and accessors without invoking author code", () => { + const f = fixture(); + const author = vi.fn(() => { + throw new Error("author sentinel"); + }); + const original = f.action.getClip; + f.action.getClip = author; + expect(readAnimationPacketState(f.root)).toBeUndefined(); + expect(author).not.toHaveBeenCalled(); + f.action.getClip = original; + Object.defineProperty(f.action, "time", { configurable: true, get: author }); + expect(readAnimationPacketState(f.root)).toBeUndefined(); + expect(author).not.toHaveBeenCalled(); + Object.defineProperty(f.action, "time", { configurable: true, writable: true, value: 0 }); + f.player.dispose(); + }); + + it("rejects paused, nonunit and nonfinite playback", () => { + const f = fixture(); + for (const [key, value] of [ + ["paused", true], + ["weight", 0.5], + ["timeScale", 2], + ["time", Number.NaN], + ] as const) { + const descriptor = Object.getOwnPropertyDescriptor(f.action, key); + if (descriptor === undefined) throw new Error(`missing native action field ${key}`); + Object.defineProperty(f.action, key, { ...descriptor, value }); + expect(readAnimationPacketState(f.root)).toBeUndefined(); + Object.defineProperty(f.action, key, descriptor); + } + f.player.dispose(); + }); + + it("rejects reads during evaluation and preserves thrown callback identity", () => { + const f = fixture(); + const sentinel = new Error("loop author"); + f.player.mixer.addEventListener("loop", () => { + expect(readAnimationPacketState(f.root)).toBeUndefined(); + throw sentinel; + }); + let thrown: unknown; + try { + f.player.update(1.1, true); + } catch (error) { + thrown = error; + } + expect(thrown).toBe(sentinel); + f.player.dispose(); + }); +}); diff --git a/packages/core/__tests__/animation-pose-packet.spec.ts b/packages/core/__tests__/animation-pose-packet.spec.ts new file mode 100644 index 0000000000..3d817d420d --- /dev/null +++ b/packages/core/__tests__/animation-pose-packet.spec.ts @@ -0,0 +1,258 @@ +import { + AnimationClip, + Bone, + BufferGeometry, + MeshBasicMaterial, + NumberKeyframeTrack, + Skeleton, + SkinnedMesh, +} from "three"; +import { describe, expect, it, vi } from "vitest"; +import { + evaluateAnimationPoseFallback, + evaluateAnimationPosePacket, + readAnimationPosePacket, + readAnimationPosePacketTime, +} from "../src/animation-pose-packet.js"; +import { AnimationPlayer } from "../src/animation.js"; + +function required(value: T | null | undefined): T { + if (value === null || value === undefined) throw new Error("missing fixture or eligible packet"); + return value; +} + +function fixture() { + const bones = Array.from({ length: 12 }, (_, i) => { + const bone = new Bone(); + bone.name = `bone${i}`; + bone.position.y = i ? 2 / 11 : -1; + return bone; + }); + bones.forEach((bone, i) => { + if (i) bones[i - 1]?.add(bone); + }); + const mesh = new SkinnedMesh(new BufferGeometry(), new MeshBasicMaterial()); + mesh.add(required(bones[0])); + mesh.bind(new Skeleton(bones)); + const clip = new AnimationClip( + "sway", + 2, + Array.from( + { length: 11 }, + (_, i) => + new NumberKeyframeTrack(`bone${i + 1}.rotation[z]`, [0, 1, 2], [-0.09, 0.09, -0.09]), + ), + ); + const player = new AnimationPlayer({ + root: mesh, + clips: [clip], + strideSync: false, + updateRate: 8, + }); + player.play("sway"); + player.update(0, true); + mesh.updateMatrixWorld(true); + return { mesh, bones, clip, player, action: player.mixer.clipAction(clip) }; +} + +describe("owned scalar-Z pose packets", () => { + it("captures the frozen chain and queries held logical time without author evaluation", () => { + const f = fixture(); + const first = readAnimationPosePacket(f.mesh); + expect(first?.version.parent).toEqual(Array.from({ length: 12 }, (_, i) => i - 1)); + expect(Object.keys(required(first).version.keys)).toHaveLength(11); + const evaluate = vi.spyOn(f.player.mixer, "update"); + let held = false; + for (let i = 0; i < 8; i++) { + f.player.update(1 / 60); + f.mesh.updateMatrixWorld(true); + evaluate.mockClear(); + const packet = readAnimationPosePacket(f.mesh); + expect(packet).toBeDefined(); + expect(evaluate).not.toHaveBeenCalled(); + if (!f.player.updateRateReport.evaluated) { + held = true; + expect(required(packet).time).toBeGreaterThan(f.action.time); + break; + } + } + expect(held).toBe(true); + f.player.dispose(); + }); + + it("owns immutable matrices/keys and rebuilds changed effective source data", () => { + const f = fixture(); + const first = required(readAnimationPosePacket(f.mesh)); + const saved = JSON.stringify(first.version); + expect(readAnimationPosePacket(f.mesh)?.version).toBe(first.version); + required(f.clip.tracks[0]).values[2] = 0.2; + const second = required(readAnimationPosePacket(f.mesh)); + expect(second.version).not.toBe(first.version); + expect(required(second.version.keys[1]).values[2]).toBeCloseTo(0.2); + f.mesh.position.x += 0.125; + f.mesh.updateMatrixWorld(true); + const third = required(readAnimationPosePacket(f.mesh)); + expect(third.version.rootWorld[12]).toBeCloseTo(0.125); + expect(JSON.stringify(first.version)).toBe(saved); + expect(Object.isFrozen(required(first.version.keys[1]).values)).toBe(true); + expect(Object.isFrozen(required(first.version.rest[0]).local)).toBe(true); + f.player.dispose(); + }); + + it("refuses replaced track storage and track identities until genuinely rebound", () => { + const f = fixture(); + expect(readAnimationPosePacket(f.mesh)).toBeDefined(); + required(f.clip.tracks[0]).values = required(f.clip.tracks[0]).values.slice(); + expect(readAnimationPosePacket(f.mesh)).toBeUndefined(); + f.player.dispose(); + }); + + it("refuses custom interpolants without invoking them", () => { + const f = fixture(); + const interpolants = Reflect.get(f.action, "_interpolants"); + const author = vi.fn(() => { + throw new Error("author"); + }); + interpolants[0].interpolate_ = author; + expect(readAnimationPosePacket(f.mesh)).toBeUndefined(); + expect(author).not.toHaveBeenCalled(); + f.player.dispose(); + }); + + it("rejects a held interpolant method replacement without author calls", () => { + const f = fixture(); + do { + f.player.update(1 / 60); + f.mesh.updateMatrixWorld(true); + } while (f.player.updateRateReport.evaluated); + const packet = required(readAnimationPosePacket(f.mesh)); + expect(readAnimationPosePacketTime(f.mesh, packet.version)).toBeDefined(); + const interpolants = Reflect.get(f.action, "_interpolants"); + const author = vi.fn(() => { + throw new Error("held interpolant author"); + }); + interpolants[0].interpolate_ = author; + expect(readAnimationPosePacketTime(f.mesh, packet.version) === undefined).toBe(true); + expect(author).not.toHaveBeenCalled(); + f.player.dispose(); + }); + + it("refuses nonidentity bind, unsupported root scale and changed topology", () => { + const f = fixture(); + expect(readAnimationPosePacket(f.mesh)).toBeDefined(); + f.mesh.bindMatrix.elements[12] = 0.25; + expect(readAnimationPosePacket(f.mesh)).toBeUndefined(); + f.mesh.bindMatrix.identity(); + f.mesh.scale.x = 2; + f.mesh.updateMatrixWorld(true); + expect(readAnimationPosePacket(f.mesh)).toBeUndefined(); + f.mesh.scale.x = 1; + f.mesh.position.x = Number.MAX_VALUE; + f.mesh.updateMatrixWorld(true); + expect(readAnimationPosePacket(f.mesh)).toBeUndefined(); + f.mesh.position.x = 0; + f.mesh.updateMatrixWorld(true); + required(f.bones[0]).add(required(f.bones[2])); + f.mesh.updateMatrixWorld(true); + expect(readAnimationPosePacket(f.mesh)).toBeUndefined(); + f.player.dispose(); + }); + + it("refuses authored matrix or pose edits and preserves old versions", () => { + const f = fixture(); + const old = required(readAnimationPosePacket(f.mesh)); + const saved = JSON.stringify(old); + required(f.bones[1]).rotation.z += 0.2; + f.mesh.updateMatrixWorld(true); + expect(readAnimationPosePacket(f.mesh)).toBeUndefined(); + expect(JSON.stringify(old)).toBe(saved); + f.player.update(0.1, true); + f.mesh.updateMatrixWorld(true); + expect(readAnimationPosePacket(f.mesh)).toBeDefined(); + required(f.bones[1]).matrix.elements[12] = 1; + expect(readAnimationPosePacket(f.mesh)).toBeUndefined(); + f.player.dispose(); + }); + it("defers held numeric edits while owning old data, then validates the genuine refresh", () => { + const f = fixture(); + const first = required(readAnimationPosePacket(f.mesh)); + f.player.update(1 / 60); + f.mesh.updateMatrixWorld(true); + while (f.player.updateRateReport.evaluated) { + f.player.update(1 / 60); + f.mesh.updateMatrixWorld(true); + } + required(f.clip.tracks[0]).values[2] = 0.2; + f.mesh.bindMatrix.elements[12] = 0.5; + const held = required(readAnimationPosePacketTime(f.mesh, first.version)); + expect(held.version).toBe(first.version); + expect(required(held.version.keys[1]).values[2]).toBeCloseTo(-0.09); + f.player.update(0.1, true); + f.mesh.updateMatrixWorld(true); + expect(readAnimationPosePacket(f.mesh)).toBeUndefined(); + f.mesh.bindMatrix.identity(); + const next = required(readAnimationPosePacket(f.mesh)); + expect(next.version).not.toBe(first.version); + expect(required(next.version.keys[1]).values[2]).toBeCloseTo(0.2); + f.player.dispose(); + }); + + it("rejects held owner/duration/custom-update changes without author callbacks", () => { + const f = fixture(); + const packet = required(readAnimationPosePacket(f.mesh)); + f.clip.duration = 3; + expect(readAnimationPosePacketTime(f.mesh, packet.version)).toBeUndefined(); + f.clip.duration = 2; + const author = vi.fn(() => { + throw new Error("author update"); + }); + const original = f.mesh.skeleton.update; + f.mesh.skeleton.update = author; + expect(readAnimationPosePacketTime(f.mesh, packet.version)).toBeUndefined(); + expect(readAnimationPosePacket(f.mesh)).toBeUndefined(); + expect(author).not.toHaveBeenCalled(); + f.mesh.skeleton.update = original; + f.player.dispose(); + }); + + it("materializes cached CPU and saved displayed RAW poses independently", () => { + const f = fixture(); + const reference = fixture(); + for (const fixture of [f, reference]) { + fixture.mesh.position.set(1, 0.25, 3); + fixture.mesh.rotation.y = 0.3; + fixture.mesh.scale.setScalar(1.5); + fixture.mesh.updateMatrixWorld(true); + } + const first = required(readAnimationPosePacket(f.mesh)); + f.mesh.skeleton.update(); + expect(Array.from(evaluateAnimationPoseFallback(first.version))).toEqual( + Array.from(required(f.mesh.skeleton.boneMatrices)), + ); + for (let i = 0; i < 8; i++) { + f.player.update(1 / 60); + f.mesh.updateMatrixWorld(true); + if (!f.player.updateRateReport.evaluated) break; + } + const packet = required(readAnimationPosePacket(f.mesh)); + reference.player.update(packet.time, true); + reference.mesh.updateMatrixWorld(true); + reference.mesh.skeleton.update(); + const referenceMatrices = required(reference.mesh.skeleton.boneMatrices); + const displayed = evaluateAnimationPosePacket(packet); + const cached = evaluateAnimationPoseFallback(packet.version); + expect( + Math.max(...displayed.map((v, i) => Math.abs(v - (referenceMatrices[i] as number)))), + ).toBeLessThan(2e-6); + expect( + Math.max(...displayed.map((v, i) => Math.abs(v - (cached[i] as number)))), + ).toBeGreaterThan(1e-4); + const saved = Array.from(displayed); + f.player.update(0.3, true); + f.mesh.updateMatrixWorld(true); + readAnimationPosePacket(f.mesh); + expect(Array.from(evaluateAnimationPosePacket(packet))).toEqual(saved); + f.player.dispose(); + reference.player.dispose(); + }); +}); diff --git a/packages/core/__tests__/animation-rate.spec.ts b/packages/core/__tests__/animation-rate.spec.ts new file mode 100644 index 0000000000..f44a41b722 --- /dev/null +++ b/packages/core/__tests__/animation-rate.spec.ts @@ -0,0 +1,830 @@ +import { + AnimationClip, + AnimationMixer, + Bone, + Group, + LoopOnce, + Mesh, + MeshBasicMaterial, + NumberKeyframeTrack, + PerspectiveCamera, + Scene, + SphereGeometry, + VectorKeyframeTrack, +} from "three"; +import { describe, expect, it, vi } from "vitest"; +import { AnimationComposer } from "../src/animation-composition.js"; +import { AnimationPlayer } from "../src/animation.js"; +import { RenderCameraCull, animationRateStates } from "../src/render-camera-cull.js"; +import { SkeletalMesh3D } from "../src/skeletal-mesh.js"; + +function player(updateRate: "auto" | number = "auto") { + const root = new Group(); + const clips = [1, 2, 3].map( + (value) => + new AnimationClip(`clip${value}`, 1, [ + new NumberKeyframeTrack(".rotation[x]", [0, 1], [0, value]), + ]), + ); + const result = new AnimationPlayer({ root, clips, strideSync: false, updateRate }); + result.play("clip1"); + return result; +} + +function rendered(result: AnimationPlayer, z = -100) { + const mesh = new Mesh(new SphereGeometry(1, 8, 6), new MeshBasicMaterial()); + result.root.add(mesh); + result.root.position.z = z; + const scene = new Scene(); + scene.add(result.root); + const camera = new PerspectiveCamera(60, 1, 0.1, 1000); + const cull = new RenderCameraCull(); + const render = () => { + scene.updateMatrixWorld(true); + cull.apply(scene, camera, 720); + cull.restore(); + }; + render(); + return { scene, camera, cull, render }; +} + +describe("AnimationPlayer update rate", () => { + it("records named rate1 independently of distant cull policy", () => { + const rig = player(1); + const view = rendered(rig, -120); + view.camera.lookAt(10, 0, -120); + view.render(); + rig.update(1 / 60); + expect(animationRateStates.get(rig.root)?.rate).toBeGreaterThan(1); + expect(animationRateStates.get(rig.root)?.effectiveRate).toBe(1); + view.cull.dispose(); + rig.dispose(); + }); + + it("records explicit cadence without cull and forces stock on forced evaluation", () => { + const rig = player(4); + rig.update(1 / 60); + expect(animationRateStates.get(rig.root)?.rate).toBe(1); + expect(animationRateStates.get(rig.root)?.effectiveRate).toBe(4); + rig.update(1 / 60, true); + expect(animationRateStates.get(rig.root)?.effectiveRate).toBe(1); + expect(rig.updateRateReport.rate).toBe(4); + rig.update(1 / 60); + expect(animationRateStates.get(rig.root)?.effectiveRate).toBe(4); + rig.dispose(); + }); + + it.each([1, 8])("preserves public callback mixer state at requested rate %i", (rate) => { + for (const event of ["loop", "finished"] as const) { + const candidate = player(rate); + const expected = new AnimationMixer(new Group()); + const expectedAction = expected.clipAction(candidate.clip("clip1")).play(); + const actualAction = candidate.mixer.clipAction(candidate.clip("clip1")); + if (event === "finished") { + expectedAction.setLoop(LoopOnce, 1); + actualAction.setLoop(LoopOnce, 1); + expectedAction.clampWhenFinished = actualAction.clampWhenFinished = true; + } + const reads: number[] = []; + for (const mixer of [expected, candidate.mixer]) { + mixer.timeScale = 2; + mixer.addEventListener(event, () => { + reads.push(mixer.timeScale); + mixer.timeScale = 0; + }); + } + expected.update(0.5); + candidate.update(0.5, true); + expect(reads).toEqual([2, 2]); + expect(candidate.mixer.timeScale).toBe(expected.timeScale); + candidate.dispose(); + } + }); + + it.each([1, 8])("retains loop callback action speed at requested rate %i", (rate) => { + const candidate = player(rate); + const action = candidate.mixer.clipAction(candidate.clip("clip1")); + candidate.mixer.addEventListener("loop", () => action.setEffectiveTimeScale(2)); + candidate.update(1, true); + expect(action.timeScale).toBe(2); + candidate.dispose(); + }); + + it("protects public finished listeners without pinning unobserved loops", () => { + const candidate = player(8); + const listener = vi.fn(); + expect(candidate.updateRateReport.rate).toBe(8); + candidate.mixer.addEventListener("finished", listener); + expect(candidate.updateRateReport.rate).toBe(1); + candidate.mixer.removeEventListener("finished", listener); + expect(candidate.updateRateReport.rate).toBe(8); + candidate.dispose(); + }); + + it("flushes prior skipped time before a signed mixer speed change", () => { + const candidate = player(8); + const expected = new AnimationMixer(new Group()); + const expectedAction = expected.clipAction(candidate.clip("clip1")).play(); + do { + expected.update(0.1); + candidate.update(0.1); + } while (candidate.updateRateReport.evaluated); + expected.timeScale = candidate.mixer.timeScale = -1; + expectedAction.setEffectiveTimeScale(2); + candidate.mixer.clipAction(candidate.clip("clip1")).setEffectiveTimeScale(2); + expected.update(0.1); + candidate.update(0.1, true); + expect(candidate.mixer.time).toBeCloseTo(expected.time, 10); + expect(candidate.mixer.clipAction(candidate.clip("clip1")).time).toBeCloseTo( + expectedAction.time, + 10, + ); + candidate.dispose(); + }); + + it.each([2, 4, 8])("accumulates clip time for 300 frames at rate %i", (rate) => { + const baseline = player(1); + const candidate = player(rate); + const updates = vi.spyOn(candidate.mixer, "update"); + for (let frame = 0; frame < 300; frame += 1) { + baseline.update(1 / 60); + candidate.update(1 / 60, frame === 299); + if (candidate.updateRateReport.evaluated) { + expect(candidate.mixer.time).toBeCloseTo(baseline.mixer.time, 10); + expect(candidate.mixer.clipAction(candidate.clip("clip1")).time).toBeCloseTo( + baseline.mixer.clipAction(baseline.clip("clip1")).time, + 10, + ); + expect(candidate.root.rotation.x).toBeCloseTo(baseline.root.rotation.x, 6); + } + } + expect(updates.mock.calls.length).toBeLessThan(300 / rate + 2); + expect(candidate.mixer.time).toBeCloseTo(5, 10); + expect(candidate.updateRateReport).toMatchObject({ rate, overridden: true }); + baseline.dispose(); + candidate.dispose(); + }); + + it("keeps crossfade weights and interrupted transitions exact over 300 frames", () => { + const baseline = player(1); + const candidate = player(8); + for (let frame = 0; frame < 300; frame += 1) { + if (frame === 37 || frame === 52) { + const clip = frame === 37 ? "clip2" : "clip3"; + baseline.play(clip, { fade: 0.5 }); + candidate.play(clip, { fade: 0.5 }); + } + baseline.update(1 / 60); + candidate.update(1 / 60, frame === 299); + for (const name of ["clip1", "clip2", "clip3"]) { + const actual = candidate.mixer.clipAction(candidate.clip(name)); + const expected = baseline.mixer.clipAction(baseline.clip(name)); + expect(actual.getEffectiveWeight()).toBeCloseTo(expected.getEffectiveWeight(), 10); + if (candidate.updateRateReport.evaluated) + expect(actual.time).toBeCloseTo(expected.time, 10); + } + } + baseline.dispose(); + candidate.dispose(); + }); + + it("delivers finished on the same frame as rate 1, including replay", () => { + const baseline = player(1); + const candidate = player(8); + const expected: number[] = []; + const actual: number[] = []; + let frame = 0; + baseline.mixer.addEventListener("finished", () => expected.push(frame)); + candidate.mixer.addEventListener("finished", () => actual.push(frame)); + for (; frame < 300; frame += 1) { + if (frame === 7 || frame === 177) { + baseline.play("clip1", { mode: "once" }); + candidate.play("clip1", { mode: "once" }); + } + baseline.update(1 / 60); + candidate.update(1 / 60); + expect(candidate.finished).toBe(baseline.finished); + if (frame >= 7) expect(candidate.advancedFrames).toBe(baseline.advancedFrames); + } + expect(actual).toEqual(expected); + expect(actual).toHaveLength(2); + baseline.dispose(); + candidate.dispose(); + }); + + it("preserves loop listener timing at a requested reduced rate", () => { + const clip = new AnimationClip("loop", 0.05, [ + new NumberKeyframeTrack(".rotation[x]", [0, 0.05], [0, 1]), + ]); + const baseline = new AnimationPlayer({ + root: new Group(), + clips: [clip], + strideSync: false, + updateRate: 1, + }); + const candidate = new AnimationPlayer({ + root: new Group(), + clips: [clip], + strideSync: false, + updateRate: 8, + }); + baseline.play("loop"); + candidate.play("loop"); + const expected: { frame: number; loopDelta: number; clip: string }[] = []; + const actual: { frame: number; loopDelta: number; clip: string }[] = []; + let frame = 0; + baseline.mixer.addEventListener("loop", ({ action, loopDelta }) => + expected.push({ frame, loopDelta, clip: action.getClip().name }), + ); + candidate.mixer.addEventListener("loop", ({ action, loopDelta }) => + actual.push({ frame, loopDelta, clip: action.getClip().name }), + ); + for (; frame < 60; frame += 1) { + baseline.update(1 / 60); + candidate.update(1 / 60); + } + expect(actual).toEqual(expected); + expect(actual.length).toBeGreaterThan(0); + baseline.dispose(); + candidate.dispose(); + }); + + it("starts a newly registered loop listener after skipped time", () => { + const clip = new AnimationClip("loop", 0.05, [ + new NumberKeyframeTrack(".rotation[x]", [0, 0.05], [0, 1]), + ]); + const baseline = new AnimationPlayer({ + root: new Group(), + clips: [clip], + strideSync: false, + updateRate: 1, + }); + const candidate = new AnimationPlayer({ + root: new Group(), + clips: [clip], + strideSync: false, + updateRate: 8, + }); + baseline.play("loop"); + candidate.play("loop"); + for (let frame = 0; frame < 6; frame += 1) { + baseline.update(1 / 60); + candidate.update(1 / 60); + } + const expected: { frame: number; loopDelta: number; clip: string }[] = []; + const actual: { frame: number; loopDelta: number; clip: string }[] = []; + let frame = 6; + baseline.mixer.addEventListener("loop", ({ action, loopDelta }) => + expected.push({ frame, loopDelta, clip: action.getClip().name }), + ); + candidate.mixer.addEventListener("loop", ({ action, loopDelta }) => + actual.push({ frame, loopDelta, clip: action.getClip().name }), + ); + for (; frame < 60; frame += 1) { + baseline.update(1 / 60); + candidate.update(1 / 60); + } + expect(actual).toEqual(expected); + expect(actual.length).toBeGreaterThan(0); + baseline.dispose(); + candidate.dispose(); + }); + + it("restores throttling after duplicate listener removal and later re-registration", () => { + const clip = new AnimationClip("loop", 0.05, [ + new NumberKeyframeTrack(".rotation[x]", [0, 0.05], [0, 1]), + ]); + const candidate = new AnimationPlayer({ + root: new Group(), + clips: [clip], + strideSync: false, + updateRate: 8, + }); + candidate.play("loop"); + const first = vi.fn(); + const second = vi.fn(); + expect(candidate.updateRateReport.rate).toBe(8); + candidate.mixer.addEventListener("loop", first); + candidate.mixer.addEventListener("loop", first); + expect(candidate.updateRateReport.rate).toBe(1); + candidate.mixer.removeEventListener("loop", first); + expect(candidate.updateRateReport.rate).toBe(8); + candidate.mixer.addEventListener("loop", first); + candidate.mixer.addEventListener("loop", second); + candidate.mixer.removeEventListener("loop", first); + expect(candidate.updateRateReport.rate).toBe(1); + candidate.mixer.removeEventListener("loop", first); + expect(candidate.updateRateReport.rate).toBe(1); + candidate.mixer.removeEventListener("loop", second); + expect(candidate.updateRateReport.rate).toBe(8); + for (let frame = 0; frame < 6; frame += 1) candidate.update(1 / 60); + candidate.mixer.addEventListener("loop", first); + expect(candidate.updateRateReport.rate).toBe(1); + expect(first).not.toHaveBeenCalled(); + candidate.update(1 / 60); + candidate.dispose(); + }); + + it("delivers later loop events after a listener registers in a mixer callback", () => { + const onceClip = new AnimationClip("once", 0.05, [ + new NumberKeyframeTrack(".rotation[x]", [0, 0.05], [0, 1]), + ]); + const loopClip = new AnimationClip("loop", 0.05, [ + new NumberKeyframeTrack(".rotation[y]", [0, 0.05], [0, 1]), + ]); + const candidate = new AnimationPlayer({ + root: new Group(), + clips: [onceClip, loopClip], + strideSync: false, + updateRate: 8, + }); + candidate.play("once", { mode: "once" }); + candidate.mixer.clipAction(loopClip).play(); + const loopListener = vi.fn(); + candidate.mixer.addEventListener("finished", () => { + candidate.mixer.addEventListener("loop", loopListener); + }); + expect(() => { + for (let frame = 0; frame < 4; frame += 1) candidate.update(1 / 60); + }).not.toThrow(); + expect(loopListener).toHaveBeenCalledTimes(1); + candidate.dispose(); + }); + + it("flushes elapsed time before weighted phase sync and keeps transition weights exact", () => { + const baseline = player(1); + const candidate = player(8); + for (let frame = 0; frame < 300; frame += 1) { + if (frame === 19 || frame === 41) { + const entries = [ + { clip: "clip2", weight: 0.6 }, + { clip: "clip3", weight: 0.4 }, + ]; + baseline.playWeighted(entries, { transition: 0.5 }); + candidate.playWeighted(entries, { transition: 0.5 }); + } + baseline.update(1 / 60); + candidate.update(1 / 60, frame === 299); + for (const name of ["clip1", "clip2", "clip3"]) { + const actual = candidate.mixer.clipAction(candidate.clip(name)); + const expected = baseline.mixer.clipAction(baseline.clip(name)); + expect(actual.getEffectiveWeight()).toBeCloseTo(expected.getEffectiveWeight(), 10); + if (candidate.updateRateReport.evaluated) + expect(actual.time).toBeCloseTo(expected.time, 10); + } + } + baseline.dispose(); + candidate.dispose(); + }); + + it.each([4, 8])("spreads 128 players within 1.5 times the mean at rate %i", (rate) => { + const crowd = Array.from({ length: 128 }, () => player(rate)); + const counts: number[] = []; + for (let frame = 0; frame < 300; frame += 1) { + let count = 0; + for (const rig of crowd) { + rig.update(1 / 60); + if (rig.updateRateReport.evaluated) count += 1; + } + counts.push(count); + } + const mean = counts.reduce((sum, value) => sum + value, 0) / counts.length; + expect(mean).toBe(128 / rate); + expect(Math.max(...counts)).toBeLessThanOrEqual(mean * 1.5); + for (const rig of crowd) rig.dispose(); + }); + + it("forces evaluation before a changing stagger can exceed its rate", () => { + const rig = player(8); + let skipped = 0; + for (let frame = 0; frame < 100; frame += 1) { + rig.updateRate = frame % 3 === 0 ? 2 : 8; + rig.update(1 / 60); + skipped = rig.updateRateReport.evaluated ? 0 : skipped + 1; + expect(skipped).toBeLessThan(rig.updateRateReport.rate); + } + rig.update(0, true); + expect(rig.updateRateReport.evaluated).toBe(true); + expect(rig.mixer.time).toBeCloseTo(100 / 60, 10); + rig.dispose(); + }); + + it("never skips updateRate 1 and reports the explicit override", () => { + const rig = player(1); + for (let frame = 0; frame < 300; frame += 1) { + rig.update(1 / 60); + expect(rig.mixer.time).toBeCloseTo((frame + 1) / 60, 10); + expect(rig.updateRateReport).toEqual({ rate: 1, overridden: true, evaluated: true }); + } + rig.dispose(); + }); + + it.each(["warp", "halt"] as const)( + "preserves Three action time-scale interpolation at rates 1, 4 and 8 (%s)", + (operation) => { + for (const rate of [1, 4, 8]) { + const clip = new AnimationClip("warp", 10, [ + new NumberKeyframeTrack(".rotation[x]", [0, 10], [0, 1]), + ]); + const expectedRoot = new Group(); + const expectedMixer = new AnimationMixer(expectedRoot); + const expectedAction = expectedMixer.clipAction(clip).play(); + const actual = new AnimationPlayer({ + root: new Group(), + clips: [clip], + strideSync: false, + updateRate: rate, + }); + actual.play("warp"); + const actualAction = actual.mixer.clipAction(actual.clip("warp")); + let skippedBeforeWarp = false; + for (let frame = 0; frame < Math.max(3, rate); frame += 1) { + expectedMixer.update(1 / 60); + actual.update(1 / 60); + skippedBeforeWarp ||= !actual.updateRateReport.evaluated; + } + if (rate > 1) expect(skippedBeforeWarp).toBe(true); + if (operation === "warp") { + expectedAction.warp(1, 2, 0.5); + actualAction.warp(1, 2, 0.5); + } else { + expectedAction.halt(0.5); + actualAction.halt(0.5); + } + let protectedFrames = 0; + for (let frame = 0; frame < 300; frame += 1) { + expectedMixer.update(1 / 60); + actual.update(1 / 60); + expect(actualAction.getEffectiveTimeScale()).toBeCloseTo( + expectedAction.getEffectiveTimeScale(), + 10, + ); + if (actual.updateRateReport.evaluated) + expect(actualAction.time).toBeCloseTo(expectedAction.time, 10); + if ( + !expectedAction.paused && + expectedAction.getEffectiveTimeScale() !== expectedAction.timeScale + ) { + protectedFrames += 1; + expect(actual.updateRateReport).toMatchObject({ rate: 1, evaluated: true }); + } + } + actual.update(0, true); + expect(actualAction.time).toBeCloseTo(expectedAction.time, 10); + expect(actualAction.getEffectiveTimeScale()).toBeCloseTo( + expectedAction.getEffectiveTimeScale(), + 10, + ); + expect(protectedFrames).toBeGreaterThan(0); + actual.dispose(); + expectedMixer.uncacheRoot(expectedRoot); + } + }, + ); + + it("preserves unequal-duration warp-enabled crossfades at every requested rate", () => { + const clips = [ + new AnimationClip("from", 4, [new NumberKeyframeTrack(".rotation[x]", [0, 4], [0, 1])]), + new AnimationClip("to", 10, [new NumberKeyframeTrack(".rotation[y]", [0, 10], [0, 1])]), + ]; + for (const rate of [1, 4, 8]) { + const expectedRoot = new Group(); + const expectedMixer = new AnimationMixer(expectedRoot); + const expectedFrom = expectedMixer.clipAction(clips[0] as AnimationClip).play(); + const expectedTo = expectedMixer + .clipAction(clips[1] as AnimationClip) + .setEffectiveWeight(1) + .play(); + const actual = new AnimationPlayer({ + root: new Group(), + clips, + strideSync: false, + updateRate: rate, + }); + actual.play("from"); + const actualFrom = actual.mixer.clipAction(actual.clip("from")).play(); + const actualTo = actual.mixer.clipAction(actual.clip("to")).setEffectiveWeight(1).play(); + let skippedBeforeCrossfade = false; + for (let frame = 0; frame < Math.max(3, rate); frame += 1) { + expectedMixer.update(1 / 60); + actual.update(1 / 60); + skippedBeforeCrossfade ||= !actual.updateRateReport.evaluated; + } + if (rate > 1) expect(skippedBeforeCrossfade).toBe(true); + expectedTo.crossFadeFrom(expectedFrom, 0.5, true); + actualTo.crossFadeFrom(actualFrom, 0.5, true); + let protectedFrames = 0; + for (let frame = 0; frame < 120; frame += 1) { + expectedMixer.update(1 / 60); + actual.update(1 / 60); + const evaluated = actual.updateRateReport.evaluated; + let protectedThisFrame = false; + for (const [actualAction, expectedAction] of [ + [actualFrom, expectedFrom], + [actualTo, expectedTo], + ] as const) { + expect(actualAction.getEffectiveTimeScale()).toBeCloseTo( + expectedAction.getEffectiveTimeScale(), + 10, + ); + if (evaluated) { + expect(actualAction.time).toBeCloseTo(expectedAction.time, 10); + expect(actualAction.getEffectiveWeight()).toBeCloseTo( + expectedAction.getEffectiveWeight(), + 10, + ); + } + if (expectedAction.getEffectiveTimeScale() !== expectedAction.timeScale) + protectedThisFrame = true; + } + if (protectedThisFrame) { + protectedFrames += 1; + expect(actual.updateRateReport).toMatchObject({ rate: 1, evaluated: true }); + } + } + actual.update(0, true); + expect(actualFrom.time).toBeCloseTo(expectedFrom.time, 10); + expect(actualTo.time).toBeCloseTo(expectedTo.time, 10); + expect(protectedFrames).toBeGreaterThan(0); + actual.dispose(); + expectedMixer.uncacheRoot(expectedRoot); + } + }); + + it("does not recursively flush when a public warp starts in a mixer callback", () => { + const clips = [ + new AnimationClip("loop", 1, [new NumberKeyframeTrack(".rotation[x]", [0, 1], [0, 1])]), + new AnimationClip("once", 0.05, [new NumberKeyframeTrack(".rotation[y]", [0, 0.05], [0, 1])]), + ]; + const actual = new AnimationPlayer({ + root: new Group(), + clips, + strideSync: false, + updateRate: 8, + }); + actual.play("loop"); + const loopAction = actual.mixer.clipAction(actual.clip("loop")); + const onceAction = actual.mixer.clipAction(actual.clip("once")).setLoop(LoopOnce, 1).play(); + let warped = false; + let callbackRate = 0; + actual.mixer.addEventListener("finished", ({ action }) => { + if (action === onceAction) { + warped = true; + loopAction.warp(1, 2, 0.1); + callbackRate = actual.updateRateReport.rate; + } + }); + expect(() => { + for (let frame = 0; frame < 30; frame += 1) actual.update(1 / 60); + }).not.toThrow(); + expect(warped).toBe(true); + expect(callbackRate).toBe(1); + actual.dispose(); + }); + + it("preserves warp-enabled public crossfades at rate 1", () => { + const clips = [ + new AnimationClip("from", 1, [new NumberKeyframeTrack(".rotation[x]", [0, 1], [0, 1])]), + new AnimationClip("to", 1, [new NumberKeyframeTrack(".rotation[y]", [0, 1], [0, 1])]), + ]; + const expectedRoot = new Group(); + const expectedMixer = new AnimationMixer(expectedRoot); + const expectedFrom = expectedMixer.clipAction(clips[0] as AnimationClip).play(); + const expectedTo = expectedMixer.clipAction(clips[1] as AnimationClip).play(); + const actual = new AnimationPlayer({ + root: new Group(), + clips, + strideSync: false, + updateRate: 1, + }); + actual.play("from"); + const actualFrom = actual.mixer.clipAction(actual.clip("from")).play(); + const actualTo = actual.mixer.clipAction(actual.clip("to")).setEffectiveWeight(1).play(); + expectedTo.crossFadeFrom(expectedFrom, 0.5, true); + actualTo.crossFadeFrom(actualFrom, 0.5, true); + for (let frame = 0; frame < 30; frame += 1) { + expectedMixer.update(1 / 60); + actual.update(1 / 60); + expect(actualFrom.getEffectiveWeight()).toBeCloseTo(expectedFrom.getEffectiveWeight(), 10); + expect(actualTo.getEffectiveWeight()).toBeCloseTo(expectedTo.getEffectiveWeight(), 10); + expect(actualFrom.getEffectiveTimeScale()).toBeCloseTo( + expectedFrom.getEffectiveTimeScale(), + 10, + ); + expect(actualTo.getEffectiveTimeScale()).toBeCloseTo(expectedTo.getEffectiveTimeScale(), 10); + } + actual.dispose(); + expectedMixer.uncacheRoot(expectedRoot); + }); + + it("measures auto rate from the render camera and keeps hidden rigs advancing", () => { + const rig = player(); + expect(rig.updateRate).toBe("auto"); + const view = rendered(rig, -10); + // Keep this policy subject off the centre ray: this test measures projected size, not camera + // target recognition. + rig.root.position.x = 2; + view.render(); + rig.update(1 / 60); + expect(rig.updateRateReport).toMatchObject({ rate: 1, overridden: false }); + rig.root.position.z = -100; + view.render(); + const updates = vi.spyOn(rig.mixer, "update"); + for (let frame = 0; frame < 32; frame += 1) rig.update(1 / 60); + expect(rig.updateRateReport.rate).toBeGreaterThan(1); + expect(updates.mock.calls.length).toBeLessThan(32); + rig.root.visible = false; + view.render(); + for (let frame = 0; frame < 16; frame += 1) rig.update(1 / 60); + expect(rig.updateRateReport.rate).toBeGreaterThanOrEqual(4); + rig.update(0, true); + expect(rig.mixer.time).toBeCloseTo(49 / 60, 10); + rig.root.visible = true; + rig.root.position.z = -10; + view.render(); + rig.update(1 / 60); + expect(rig.updateRateReport.rate).toBe(1); + view.cull.dispose(); + rig.dispose(); + }); + + it("keeps a distant unparented camera target at full rate and retargeting drops promotion", () => { + const rig = player(); + const view = rendered(rig, -120); + view.camera.lookAt(rig.root.position); + view.render(); + rig.update(1 / 60); + expect(rig.updateRateReport).toMatchObject({ rate: 1, overridden: false }); + + view.camera.lookAt(10, 0, -120); + view.render(); + rig.update(1 / 60); + expect(rig.updateRateReport).toMatchObject({ rate: 4, overridden: false }); + + view.cull.dispose(); + rig.dispose(); + }); + + it("keeps root-motion composers evaluating and moving on every tick", () => { + const body = new Group(); + const root = new Group(); + const hips = new Bone(); + hips.name = "Hips"; + body.add(root); + root.add(hips); + const clip = new AnimationClip("walk", 1, [ + new VectorKeyframeTrack("Hips.position", [0, 1], [0, 0, 0, 0, 0, 1]), + ]); + const composer = new AnimationComposer({ + root, + clips: [clip], + samples: ["walk"], + rootMotion: { bone: "Hips", body }, + }); + const updates = vi.spyOn(composer.mixer, "update"); + for (let frame = 0; frame < 300; frame += 1) { + const proposal = composer.advance(1 / 60); + expect(proposal.translation.z).toBeCloseTo(1 / 60, 6); + body.position.add(proposal.translation); + composer.finish(proposal); + expect(updates.mock.calls.length).toBe(frame + 1); + expect(hips.position.z).toBe(0); + } + expect(body.position.z).toBeCloseTo(5, 6); + composer.dispose(); + }); + + it("returns a previously distant rig to full rate when attached to the render camera", () => { + const rig = player(); + const view = rendered(rig); + rig.root.position.x = 10; + view.render(); + rig.update(1 / 60); + expect(rig.updateRateReport.rate).toBeGreaterThan(1); + view.scene.add(view.camera); + view.camera.add(rig.root); + view.render(); + rig.update(1 / 60); + expect(rig.updateRateReport).toMatchObject({ rate: 1, evaluated: true }); + expect(animationRateStates.get(rig.root)?.effectiveRate).toBe(1); + view.cull.dispose(); + rig.dispose(); + }); + + it("passes the named override through SkeletalMesh3D", () => { + const rig = new SkeletalMesh3D({ source: new Group(), updateRate: 4 }); + expect(rig.updateRate).toBe(4); + rig.dispose(); + }); + + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects invalid updateRate %s at construction and assignment", + (rate) => { + expect(() => player(rate)).toThrow(/updateRate/); + const rig = player(1); + expect(() => { + rig.updateRate = rate; + }).toThrow(/updateRate/); + expect(rig.updateRate).toBe(1); + rig.dispose(); + }, + ); + + it("discards pending time on stop so a new clip receives only its own elapsed time", () => { + const rig = player(8); + rig.update(0.01); + rig.stop(); + rig.play("clip2"); + rig.update(0.1, true); + expect(rig.mixer.clipAction(rig.clip("clip2")).time).toBeCloseTo(0.1, 10); + rig.dispose(); + }); + + it("integrates changing stride rates without applying the last rate to skipped time", () => { + const create = (updateRate: number) => { + const body = new Group(); + const root = new Group(); + body.add(root); + const clip = new AnimationClip("walk", 2, [ + new VectorKeyframeTrack(".position", [0, 2], [0, 0, 0, 0, 0, 2]), + ]); + const rig = new AnimationPlayer({ root, clips: [clip], strideRoot: body, updateRate }); + rig.play("walk"); + return { rig, body, clip }; + }; + const baseline = create(1); + const candidate = create(8); + for (let frame = 0; frame < 300; frame += 1) { + const distance = ((frame % 3) + 1) / 60; + baseline.body.position.z += distance; + candidate.body.position.z += distance; + baseline.rig.update(1 / 60); + candidate.rig.update(1 / 60, frame === 299); + expect(candidate.rig.stride.rate).toBeCloseTo(baseline.rig.stride.rate, 8); + if (candidate.rig.updateRateReport.evaluated) + expect(candidate.rig.mixer.clipAction(candidate.clip).time).toBeCloseTo( + baseline.rig.mixer.clipAction(baseline.clip).time, + 8, + ); + } + baseline.rig.dispose(); + candidate.rig.dispose(); + }); + + it("retains elapsed playback when an action pauses during a skipped interval", () => { + const baseline = player(1); + const candidate = player(8); + const expected = baseline.mixer.clipAction(baseline.clip("clip1")); + const actual = candidate.mixer.clipAction(candidate.clip("clip1")); + for (let frame = 0; frame < 300; frame += 1) { + expected.paused = actual.paused = frame % 8 >= 3; + baseline.update(1 / 60); + candidate.update(1 / 60, frame === 299); + if (candidate.updateRateReport.evaluated) expect(actual.time).toBeCloseTo(expected.time, 8); + } + baseline.dispose(); + candidate.dispose(); + }); + + it("flushes a weighted loop before handing its current clip back to single playback", () => { + const baseline = player(1); + const candidate = player(1024); + const entries = [ + { clip: "clip1", weight: 0.6 }, + { clip: "clip2", weight: 0.4 }, + ]; + baseline.playWeighted(entries); + candidate.playWeighted(entries); + for (let frame = 0; frame < 3; frame += 1) { + baseline.update(1 / 60); + candidate.update(1 / 60); + } + baseline.play("clip1", { fade: 0.2 }); + candidate.play("clip1", { fade: 0.2 }); + baseline.update(1 / 60); + candidate.update(1 / 60); + expect(candidate.mixer.clipAction(candidate.clip("clip1")).time).toBeCloseTo( + baseline.mixer.clipAction(baseline.clip("clip1")).time, + 10, + ); + baseline.dispose(); + candidate.dispose(); + }); + + it.each([2, 4, 8])("spreads 128 rigs at rate %i so no frame exceeds 1.5x the mean", (rate) => { + const rigs = Array.from({ length: 128 }, () => player(rate)); + const counts: number[] = []; + for (let frame = 0; frame < 240; frame += 1) { + let evaluated = 0; + for (const rig of rigs) { + rig.update(1 / 60); + if (rig.updateRateReport.evaluated) evaluated += 1; + } + counts.push(evaluated); + } + const mean = counts.reduce((sum, count) => sum + count, 0) / counts.length; + expect(mean).toBeCloseTo(128 / rate, 5); + expect(Math.max(...counts)).toBeLessThanOrEqual(1.5 * mean); + for (const rig of rigs) rig.dispose(); + }); +}); diff --git a/packages/core/__tests__/frame-budget-gpu-series.spec.ts b/packages/core/__tests__/frame-budget-gpu-series.spec.ts index 01e91501a3..fc9cbb8a73 100644 --- a/packages/core/__tests__/frame-budget-gpu-series.spec.ts +++ b/packages/core/__tests__/frame-budget-gpu-series.spec.ts @@ -38,6 +38,26 @@ function driveGpu( } describe("frame-budget GPU series", () => { + it("counts only fresh main-bucket samples, retaining measured zero and absent semantics", () => { + const budget = gpuBudget(); + budget.beginFrame(0, 0); + budget.addGpuMs(9, 1); + budget.addGpuBucketMs({ shadow: 9 }); + budget.endFrame(1); + expect(budget.window().gpuMainSamples).toBeUndefined(); + for (const [index, frame] of [2, 2, 3].entries()) { + budget.beginFrame((index + 1) * 16, (index + 1) * 16); + budget.addGpuMs(8, frame); + budget.addGpuBucketMs(frame === 2 ? { main: index === 0 ? 0 : 99 } : { shadow: 8 }); + budget.endFrame((index + 1) * 16 + 1); + } + const window = budget.window(); + expect(window.frames).toBe(4); + expect(window.gpu?.samples).toBe(3); + expect(window.gpuMain).toBe(0); + expect(window.gpuMainSamples).toBe(1); + }); + it("keeps stale bucket samples out of a new window's resolved frame range", () => { const windows: IFrameBudgetWindow[] = []; const budget = gpuBudget({ reportEvery: 4, onWindow: (window) => windows.push(window) }); @@ -207,17 +227,19 @@ describe("frame-budget GPU series", () => { (marker as string).slice(`${FRAME_BUDGET_MARKER}:`.length), ) as Record; expect(payload.gpuMain).toBe(6.1); + expect(payload.gpuMainSamples).toBe(2); expect(payload.gpuShadow).toBe(2); expect(payload.gpuOther).toBe(1); expect(payload.gpuCompute).toBe(0.4); // Appended after every existing field, so a reader that parsed the older line keeps its keys. - expect(Object.keys(payload).slice(-6)).toEqual([ + expect(Object.keys(payload).slice(-7)).toEqual([ "gpuMain", "gpuShadow", "gpuOther", "gpuCompute", "gpuShadowP95", "gpuShadowSamples", + "gpuMainSamples", ]); }); diff --git a/packages/core/__tests__/game.spec.ts b/packages/core/__tests__/game.spec.ts index acb047fe25..cf4f9a7643 100644 --- a/packages/core/__tests__/game.spec.ts +++ b/packages/core/__tests__/game.spec.ts @@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest"; import { type IGamePlatformSource, defineGame } from "../src/game.js"; import { InputMap } from "../src/input.js"; import type { IRenderPerformanceSample } from "../src/loop.js"; +import { RenderCameraCull } from "../src/render-camera-cull.js"; import { type ICtx, Scene } from "../src/scene.js"; import { UI_INTENT_MESSAGE, UI_READY_INTENT, connectUiBridge } from "../src/ui-bridge.js"; import { subscribeUiState } from "../src/ui-state.js"; @@ -494,6 +495,197 @@ describe("IGame", () => { } }); + it("applies camera animation policy before beforeRender projection work", async () => { + const canvas = testCanvas(); + let frame: ((time: number) => void) | undefined; + let preparationCalls = 0; + let preparationsBeforeBeforeRender = 0; + const cull = vi.spyOn(RenderCameraCull.prototype, "prepareAnimation").mockImplementation(() => { + preparationCalls += 1; + }); + class PolicyOrderScene extends Scene { + static override readonly initialState = {}; + + override enter(ctx: ICtx): void { + ctx.beforeRender(() => { + preparationsBeforeBeforeRender = preparationCalls; + }); + } + } + const game = defineGame({ + renderer: { + canvas, + preferWebGPU: false, + webgl2Factory: () => ({ + domElement: canvas, + render: () => undefined, + setSize: () => undefined, + }), + }, + scenes: { test: PolicyOrderScene }, + start: "test", + }); + const requestFrame = globalThis.requestAnimationFrame; + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: (callback: (time: number) => void) => { + frame = callback; + return 1; + }, + }); + + try { + await game.start(); + if (frame === undefined) throw new Error("Game did not start its loop."); + frame(16); + expect(preparationCalls).toBeGreaterThan(0); + expect(preparationsBeforeBeforeRender).toBeGreaterThan(0); + } finally { + game.stop(); + cull.mockRestore(); + if (requestFrame === undefined) Reflect.deleteProperty(globalThis, "requestAnimationFrame"); + else Object.defineProperty(globalThis, "requestAnimationFrame", { value: requestFrame }); + } + }); + + it("culls after beforeRender moves geometry into camera space", async () => { + const canvas = testCanvas(); + let frame: ((time: number) => void) | undefined; + let mesh: Mesh | undefined; + const drawnVisibilities: boolean[] = []; + const cull = vi.spyOn(RenderCameraCull.prototype, "apply"); + class CullOrderScene extends Scene { + static override readonly initialState = {}; + + override enter(ctx: ICtx): void { + mesh = ctx.add(new Mesh(new SphereGeometry(0.1, 8, 6), new MeshBasicMaterial())); + mesh.position.z = -1_000; + mesh.updateMatrixWorld(true); + ctx.beforeRender(() => { + mesh?.position.set(0, 0, -10); + mesh?.updateMatrixWorld(true); + }); + } + } + const game = defineGame({ + camera: { projection: "perspective", fov: 60, near: 0.1, far: 2_000 }, + render: { projection: false, minimumProjectedPixels: 2 }, + renderer: { + canvas, + preferWebGPU: false, + webgl2Factory: () => ({ + domElement: canvas, + render: () => { + drawnVisibilities.push(mesh?.visible === true); + }, + setSize: () => undefined, + }), + }, + scenes: { test: CullOrderScene }, + start: "test", + }); + const requestFrame = globalThis.requestAnimationFrame; + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: (callback: (time: number) => void) => { + frame = callback; + return 1; + }, + }); + + try { + await game.start(); + if (frame === undefined || mesh === undefined) + throw new Error("Game did not start its loop."); + frame(16); + expect(mesh.position.z).toBe(-10); + expect(cull).toHaveBeenCalledOnce(); + expect(drawnVisibilities.at(-1)).toBe(true); + } finally { + game.stop(); + cull.mockRestore(); + if (requestFrame === undefined) Reflect.deleteProperty(globalThis, "requestAnimationFrame"); + else Object.defineProperty(globalThis, "requestAnimationFrame", { value: requestFrame }); + } + }); + + it("refreshes exact projection proxies before the final camera cull", async () => { + const canvas = testCanvas(); + let frame: ((time: number) => void) | undefined; + let moveIntoView = false; + const exactDraws: { visible: boolean; z: number }[] = []; + const geometry = new SphereGeometry(0.1, 8, 6); + const material = new MeshBasicMaterial(); + let exactSource: Mesh | undefined; + class ProjectedCullScene extends Scene { + static override readonly initialState = {}; + + override enter(ctx: ICtx): void { + exactSource = new Mesh(geometry, material); + exactSource.renderOrder = 1; + exactSource.position.z = -1_000; + ctx.add(exactSource); + for (let index = 0; index < 200; index += 1) { + const mesh = new Mesh(geometry, material); + mesh.position.z = -1_000; + ctx.add(mesh); + } + ctx.beforeRender(() => { + if (moveIntoView) exactSource?.position.set(0, 0, -10); + }); + } + } + const game = defineGame({ + camera: { projection: "perspective", fov: 60, near: 0.1, far: 2_000 }, + render: { minimumProjectedPixels: 2 }, + renderer: { + canvas, + preferWebGPU: false, + webgl2Factory: () => ({ + domElement: canvas, + render: (scene: { traverse(callback: (object: Mesh) => void): void }) => { + scene.traverse((object) => { + if (object.isMesh === true && object.renderOrder === 1) { + exactDraws.push({ + visible: object.visible, + z: object.matrixWorld.elements[14] ?? 0, + }); + } + }); + }, + setSize: () => undefined, + }), + }, + scenes: { test: ProjectedCullScene }, + start: "test", + }); + const requestFrame = globalThis.requestAnimationFrame; + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: (callback: (time: number) => void) => { + frame = callback; + return 1; + }, + }); + + try { + await game.start(); + if (frame === undefined || exactSource === undefined) + throw new Error("Game did not start its loop."); + frame(16); + moveIntoView = true; + frame(32); + const latest = exactDraws.at(-1); + expect(latest).toEqual({ visible: true, z: -10 }); + } finally { + game.stop(); + if (requestFrame === undefined) Reflect.deleteProperty(globalThis, "requestAnimationFrame"); + else Object.defineProperty(globalThis, "requestAnimationFrame", { value: requestFrame }); + geometry.dispose(); + material.dispose(); + } + }); + it("snapshots ctx.beforeRender so additions wait a frame and removals still run", async () => { const canvas = testCanvas(); const events: string[] = []; diff --git a/packages/core/__tests__/playtest.spec.ts b/packages/core/__tests__/playtest.spec.ts index 70a65601e3..6cd717ea0b 100644 --- a/packages/core/__tests__/playtest.spec.ts +++ b/packages/core/__tests__/playtest.spec.ts @@ -403,6 +403,7 @@ describe("playtest plugin", () => { it("publishes registry tags and drained contacts through gameplay channels", async () => { const canvas = testCanvas(); const body = {}; + const storage = new Uint8Array(65_536); const area = { drainContacts: () => [{ body, entity: "coin.3", started: true }], }; @@ -412,7 +413,7 @@ describe("playtest plugin", () => { const coin = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial()); ctx.add(fox); ctx.add(coin); - ctx.entities.add("fox", { mesh: fox, physics: { rigidBody: body } }); + ctx.entities.add("fox", { mesh: fox, physics: { rigidBody: body, storage } }); ctx.entities.add("coin.3", { mesh: coin, physics: { area }, tags: ["coin"] }); } } @@ -435,11 +436,15 @@ describe("playtest plugin", () => { }); await game.start(); + const values = vi.spyOn(Object, "values"); try { const gameplay = (await bridge().sample({})).gameplay; + const enumeratedStorage = values.mock.calls.some(([value]) => value === storage); + expect(enumeratedStorage).toBe(false); expect(gameplay?.contacts).toEqual([{ entity: "fox", kind: "trigger", with: "coin.3" }]); expect(gameplay?.tags).toEqual({ coin: { count: 1 } }); } finally { + values.mockRestore(); game.stop(); } }); diff --git a/packages/core/__tests__/projection-skinned-packets.spec.ts b/packages/core/__tests__/projection-skinned-packets.spec.ts new file mode 100644 index 0000000000..5d91a53ccb --- /dev/null +++ b/packages/core/__tests__/projection-skinned-packets.spec.ts @@ -0,0 +1,541 @@ +import { + AnimationClip, + Bone, + BufferAttribute, + BufferGeometry, + Float32BufferAttribute, + MeshStandardMaterial, + NumberKeyframeTrack, + PerspectiveCamera, + Scene, + Skeleton, + SkinnedMesh, + Uint16BufferAttribute, +} from "three"; +import { describe, expect, it, vi } from "vitest"; +import * as pose from "../src/animation-pose-packet.js"; +import { AnimationPlayer } from "../src/animation.js"; +import { SkinnedBatch } from "../src/projection-skinned.js"; +import { RenderCameraCull, animationRateStates } from "../src/render-camera-cull.js"; + +function present(value: T | null | undefined): T { + if (value === null || value === undefined) throw new Error("missing fixture value"); + return value; +} + +function fixture(velocity = true, rate: 1 | 4 = 4) { + const scene = new Scene(); + const bones = Array.from({ length: 12 }, (_, i) => { + const b = new Bone(); + b.name = `bone${i}`; + b.position.y = i ? 2 / 11 : -1; + return b; + }); + bones.forEach((b, i) => { + if (i) bones[i - 1]?.add(b); + }); + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute([0, 0, 0, 0, 1, 0, 0.1, 1, 0], 3)); + geometry.setAttribute("normal", new Float32BufferAttribute([1, 0, 0, 1, 0, 0, 1, 0, 0], 3)); + geometry.setAttribute( + "skinIndex", + new Uint16BufferAttribute([0, 0, 0, 0, 5, 0, 0, 0, 11, 0, 0, 0], 4), + ); + geometry.setAttribute( + "skinWeight", + new Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0], 4), + ); + const mesh = new SkinnedMesh(geometry, new MeshStandardMaterial()); + mesh.add(present(bones[0])); + mesh.bind(new Skeleton(bones)); + scene.add(mesh); + const clip = new AnimationClip( + "sway", + 2, + Array.from( + { length: 11 }, + (_, i) => + new NumberKeyframeTrack(`bone${i + 1}.rotation[z]`, [0, 1, 2], [-0.09, 0.09, -0.09]), + ), + ); + const player = new AnimationPlayer({ + root: mesh, + clips: [clip], + strideSync: false, + updateRate: rate, + }); + player.play("sway"); + player.update(0, true); + scene.updateMatrixWorld(true); + player.update(0); + const batch = new SkinnedBatch({ first: mesh, capacity: 2, velocity }); + batch.claim(mesh); + const tick = () => { + player.update(1 / 60); + scene.updateMatrixWorld(true); + }; + const draw = () => { + batch.begin(); + batch.write(0, mesh); + batch.end(); + }; + const receipt = () => Reflect.get(batch.mesh.userData, "threenative.animationPosePackets")(); + const held = () => { + for (let i = 0; i < 8; i++) { + tick(); + draw(); + if (!present(animationRateStates.get(mesh)).evaluated) return; + } + throw Error("no held tick"); + }; + const close = () => { + batch.dispose(); + player.dispose(); + geometry.dispose(); + mesh.material.dispose(); + }; + return { scene, mesh, bones, clip, player, batch, tick, draw, receipt, held, close }; +} + +describe("SkinnedBatch owned scalar display packets", () => { + it("submits independently saved current/previous RAW payloads on held ticks", () => { + const f = fixture(); + try { + f.draw(); + const first = f.receipt(); + expect(first.slots[0].currentRaw).toBe(true); + f.held(); + const r = f.receipt(); + expect(r.slots[0].currentRaw).toBe(true); + expect(r.slots[0].previousRaw).toBe(true); + expect(r.slots[0].currentTime).toBeGreaterThan(r.slots[0].previousTime); + expect(r.storage.meta).not.toBe(f.receipt().storage.meta); + expect(r.instanceCount).toBe(1); + } finally { + f.close(); + } + }); + it("matches stock first-update dedup across a second root refresh in one reconcile", () => { + for (const [firstHeld, custom] of [ + [false, false], + [false, true], + [true, false], + [true, true], + ]) { + const f = fixture(); + const stock = fixture(); + const stockUpdate = vi.spyOn(stock.mesh.skeleton, "update"); + try { + if (firstHeld) { + f.draw(); + stock.draw(); + for (let tick = 0; tick < 8; tick++) { + f.player.update(0); + stock.player.update(0); + if ( + !present(animationRateStates.get(f.mesh)).evaluated && + !present(animationRateStates.get(stock.mesh)).evaluated + ) + break; + f.draw(); + stock.draw(); + } + } + const previousDisplay = firstHeld + ? pose.evaluateAnimationPosePacket( + present( + pose.readAnimationPosePacketTime( + f.mesh, + present(pose.readAnimationPosePacket(f.mesh)).version, + ), + ), + ) + : undefined; + const stockCalls = stockUpdate.mock.calls.length; + f.batch.begin(); + stock.batch.begin(); + f.batch.write(0, f.mesh); + stock.batch.write(0, stock.mesh); + expect(f.receipt().slots[0].currentRaw).toBe(true); + expect(stockUpdate.mock.calls.length - stockCalls).toBe(firstHeld ? 0 : 1); + const candidateUpdate = custom ? vi.spyOn(f.mesh.skeleton, "update") : undefined; + f.mesh.position.x = stock.mesh.position.x = 1; + f.scene.updateMatrixWorld(true); + stock.scene.updateMatrixWorld(true); + f.batch.write(0, f.mesh); + stock.batch.write(0, stock.mesh); + f.batch.end(); + stock.batch.end(); + if (candidateUpdate) expect(candidateUpdate).toHaveBeenCalledTimes(firstHeld ? 1 : 0); + expect(stockUpdate.mock.calls.length - stockCalls).toBe(1); + expect(f.mesh.skeleton.boneMatrices, `held=${firstHeld} custom=${custom}`).toEqual( + stock.mesh.skeleton.boneMatrices, + ); + expect(f.batch.palette).toEqual(stock.batch.palette); + if (previousDisplay) + expect(present(f.batch.history).subarray(0, 192)).toEqual(previousDisplay); + else expect(f.batch.history).toEqual(stock.batch.history); + expect(f.receipt().slots[0].currentRaw).toBe(false); + candidateUpdate?.mockRestore(); + } finally { + stockUpdate.mockRestore(); + f.close(); + stock.close(); + } + } + }); + it("keeps rate1 controls on genuine stock without capture/query", () => { + const f = fixture(true, 1); + const cull = new RenderCameraCull(); + const camera = new PerspectiveCamera(60, 1, 0.1, 1000); + camera.position.z = 40; + camera.updateMatrixWorld(true); + cull.apply(f.scene, camera, 720); + cull.restore(); + expect(present(animationRateStates.get(f.mesh)).rate).toBeGreaterThan(1); + const capture = vi.spyOn(pose, "readAnimationPosePacket"); + const query = vi.spyOn(pose, "readAnimationPosePacketTime"); + const update = vi.spyOn(f.mesh.skeleton, "update"); + try { + f.tick(); + f.draw(); + expect(capture).not.toHaveBeenCalled(); + expect(query).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledTimes(1); + expect(f.receipt().slots[0].currentRaw).toBe(false); + } finally { + capture.mockRestore(); + query.mockRestore(); + update.mockRestore(); + cull.dispose(); + f.close(); + } + }); + it("retires only the overridden owner while retaining saved previous RAW and held CPU cache", () => { + const f = fixture(); + try { + f.draw(); + f.held(); + const before = f.receipt(); + const saved = present(pose.readAnimationPosePacket(f.mesh)); + const update = vi.spyOn(f.mesh.skeleton, "update"); + f.batch.begin(); + f.batch.write(0, f.mesh); + f.batch.end(); + const retired = f.receipt(); + expect(retired.slots[0].currentRaw).toBe(false); + expect(retired.slots[0].previousRaw).toBe(true); + expect(update).not.toHaveBeenCalled(); + expect(retired.slots[0].previousTime).toBe(before.slots[0].currentTime); + expect(f.batch.palette.subarray(0, 192)).toEqual( + pose.evaluateAnimationPoseFallback(saved.version), + ); + expect(present(f.batch.history).subarray(0, 192)).toEqual( + pose.evaluateAnimationPosePacket(saved), + ); + f.mesh.position.x += 0.1; + f.scene.updateMatrixWorld(true); + f.draw(); + expect(update).toHaveBeenCalledTimes(1); + update.mockRestore(); + f.draw(); + expect(f.receipt().slots[0].retired).toBe(true); + } finally { + f.close(); + } + }); + it("first public observation preserves actual mutable arrays and stock fresh/end semantics", () => { + const f = fixture(); + try { + f.batch.begin(); + f.batch.write(0, f.mesh); + const update = vi.spyOn(f.mesh.skeleton, "update"); + const current = f.batch.palette; + const previous = present(f.batch.history); + expect(update).not.toHaveBeenCalled(); + expect(previous.every((v) => v === 0)).toBe(true); + f.batch.end(); + expect(previous).toEqual(current); + update.mockRestore(); + f.held(); + current[0] = 123; + previous[0] = -42; + expect(f.batch.palette[0]).toBe(123); + expect(present(f.batch.history)[0]).toBe(-42); + f.batch.begin(); + expect(previous[0]).toBe(123); + f.batch.write(0, f.mesh); + f.batch.end(); + expect(current[0]).toBe(123); + expect(f.batch.palette).toBe(current); + expect(f.batch.history).toBe(previous); + f.batch.restart(0); + f.batch.end(); + expect(previous).toEqual(current); + f.batch.hide(0); + expect(current.every((v) => v === 0)).toBe(true); + expect(previous.every((v) => v === 0)).toBe(true); + } finally { + f.close(); + } + }); + it("supports observation before any packet and optional absent history", () => { + const f = fixture(false); + try { + expect(f.batch.history).toBeUndefined(); + const p = f.batch.palette; + expect(p.every((v) => v === 0)).toBe(true); + f.tick(); + f.draw(); + expect(f.batch.palette).toBe(p); + expect(f.receipt().slots[0].currentRaw).toBe(true); + expect(f.batch.history).toBeUndefined(); + } finally { + f.close(); + } + }); + it("late shared ownership retires the earlier RAW display and retains stock skeleton dedup", () => { + const f = fixture(); + try { + f.draw(); + f.held(); + const saved = present(pose.readAnimationPosePacket(f.mesh)); + const displayed = pose.evaluateAnimationPosePacket(saved); + expect(displayed).not.toEqual(pose.evaluateAnimationPoseFallback(saved.version)); + const other = new SkinnedMesh(f.mesh.geometry, f.mesh.material); + other.bind(f.mesh.skeleton); + f.scene.add(other); + f.scene.updateMatrixWorld(true); + const update = vi.spyOn(f.mesh.skeleton, "update"); + expect(f.batch.claim(other)).toBe(1); + expect(update).not.toHaveBeenCalled(); + f.batch.begin(); + f.batch.write(0, f.mesh); + f.batch.write(1, other); + f.batch.end(); + expect(f.receipt().slots.every((s: { currentRaw: boolean }) => !s.currentRaw)).toBe(true); + expect(update).toHaveBeenCalledTimes(1); + expect(f.receipt().slots[0].previousRaw).toBe(true); + expect(present(f.batch.history).subarray(0, 192)).toEqual(displayed); + expect(update).toHaveBeenCalledTimes(1); + update.mockRestore(); + f.batch.release(other); + expect(f.batch.claim(other)).toBe(1); + f.draw(); + expect(f.receipt().slots[0].retired).toBe(true); + } finally { + f.close(); + } + }); + it("revokes an already-written RAW owner when sharing is claimed during reconcile", () => { + const f = fixture(); + try { + f.draw(); + f.held(); + const saved = present(pose.readAnimationPosePacket(f.mesh)); + const previousDisplay = pose.evaluateAnimationPosePacket(saved); + f.batch.begin(); + f.batch.write(0, f.mesh); + expect(f.receipt().slots[0].currentRaw).toBe(true); + const other = new SkinnedMesh(f.mesh.geometry, f.mesh.material); + other.bind(f.mesh.skeleton); + expect(f.batch.claim(other)).toBe(1); + expect(f.receipt().slots[0].currentRaw).toBe(false); + f.batch.write(1, other); + f.batch.end(); + expect(f.receipt().slots.every((slot: { currentRaw: boolean }) => !slot.currentRaw)).toBe( + true, + ); + expect(present(f.batch.history).subarray(0, 192)).toEqual(previousDisplay); + } finally { + f.close(); + } + }); + it("keeps a neighboring unique skeleton RAW when one owner retires", () => { + const f = fixture(); + const neighbor = fixture(); + try { + f.scene.add(neighbor.mesh); + expect(f.batch.claim(neighbor.mesh)).toBe(1); + f.scene.updateMatrixWorld(true); + f.batch.begin(); + f.batch.write(0, f.mesh); + f.batch.write(1, neighbor.mesh); + f.batch.end(); + f.held(); + const update = vi.spyOn(f.mesh.skeleton, "update"); + f.batch.begin(); + f.batch.write(0, f.mesh); + f.batch.write(1, neighbor.mesh); + f.batch.end(); + expect(f.receipt().slots.map((s: { currentRaw: boolean }) => s.currentRaw)).toEqual([ + false, + true, + ]); + expect(update).not.toHaveBeenCalled(); + update.mockRestore(); + f.batch.hide(0); + f.draw(); + expect(f.receipt().slots[0].retired).toBe(true); + expect(f.receipt().slots[1].currentRaw).toBe(true); + } finally { + f.close(); + neighbor.close(); + } + }); + it("observes between begin/write and after restart without losing stock history boundaries", () => { + for (const restart of [false, true]) { + const f = fixture(); + try { + f.draw(); + f.held(); + const saved = present(pose.readAnimationPosePacket(f.mesh)); + if (restart) f.batch.restart(0); + else f.batch.begin(); + const update = vi.spyOn(f.mesh.skeleton, "update"); + const current = f.batch.palette; + const previous = present(f.batch.history); + expect(update).not.toHaveBeenCalled(); + expect(current.subarray(0, 192)).toEqual(pose.evaluateAnimationPosePacket(saved)); + if (!restart) expect(previous.subarray(0, 192)).toEqual(current.subarray(0, 192)); + f.batch.end(); + if (restart) expect(previous.subarray(0, 192)).toEqual(current.subarray(0, 192)); + expect(f.batch.palette).toBe(current); + expect(f.batch.history).toBe(previous); + update.mockRestore(); + } finally { + f.close(); + } + } + }); + it("preserves previous STOCK storage on first nonfresh RAW promotion and observation", () => { + const f = fixture(); + try { + f.player.update(0, true); + f.scene.updateMatrixWorld(true); + f.draw(); + expect(f.receipt().slots[0].currentRaw).toBe(false); + const stock = new Float32Array(present(f.mesh.skeleton.boneMatrices)); + for (let i = 0; i < 8; i++) { + f.tick(); + if (present(animationRateStates.get(f.mesh)).evaluated) break; + } + f.draw(); + expect(f.receipt().slots[0].currentRaw).toBe(true); + expect(f.receipt().slots[0].previousRaw).toBe(false); + const raw = present(pose.readAnimationPosePacket(f.mesh)); + const update = vi.spyOn(f.mesh.skeleton, "update"); + const current = f.batch.palette; + const previous = present(f.batch.history); + expect(update).not.toHaveBeenCalled(); + expect(current.subarray(0, 192)).toEqual(pose.evaluateAnimationPosePacket(raw)); + expect(previous.subarray(0, 192)).toEqual(stock); + update.mockRestore(); + } finally { + f.close(); + } + }); + it("marks actual materialized palette storage immediately without changing RAW modes after end", () => { + const f = fixture(); + f.draw(); + const uploaded: BufferAttribute[] = []; + const setter = present( + present(Object.getOwnPropertyDescriptor(BufferAttribute.prototype, "needsUpdate")).set, + ); + const mark = vi + .spyOn(BufferAttribute.prototype, "needsUpdate", "set") + .mockImplementation(function (this: BufferAttribute, value: boolean) { + if (value) uploaded.push(this); + setter.call(this, value); + }); + try { + const current = f.batch.palette; + const previous = present(f.batch.history); + expect(uploaded.filter((a) => a.array === current)).toHaveLength(1); + expect(uploaded.filter((a) => a.array === previous)).toHaveLength(1); + expect(uploaded).toHaveLength(2); + expect(f.receipt().slots[0].currentRaw).toBe(true); + } finally { + mark.mockRestore(); + f.close(); + } + }); + it("keeps RAW motion advancing after a read on a held CPU tick", () => { + const f = fixture(); + try { + f.draw(); + const current = f.batch.palette; + const first = current.slice(0, 192); + const time = f.receipt().slots[0].currentTime; + f.held(); + expect(f.receipt().slots[0].currentRaw).toBe(true); + expect(f.receipt().slots[0].currentTime).toBeGreaterThan(time); + expect(f.batch.palette).toBe(current); + expect(current.subarray(0, 192)).not.toEqual(first); + } finally { + f.close(); + } + }); + it("detects observed edits at end and on a second getter without retiring the neighbor", () => { + for (const boundary of ["end", "getter"]) { + const f = fixture(); + const neighbor = fixture(); + try { + f.scene.add(neighbor.mesh); + f.batch.claim(neighbor.mesh); + f.scene.updateMatrixWorld(true); + f.batch.begin(); + f.batch.write(0, f.mesh); + f.batch.write(1, neighbor.mesh); + f.batch.end(); + f.held(); + f.batch.begin(); + f.batch.write(0, f.mesh); + f.batch.write(1, neighbor.mesh); + const current = f.batch.palette; + const history = present(f.batch.history); + current[0] = 77; + if (boundary === "getter") expect(f.batch.palette[0]).toBe(77); + f.batch.end(); + expect(f.receipt().slots.map((s: { currentRaw: boolean }) => s.currentRaw)).toEqual([ + false, + true, + ]); + expect(current[0]).toBe(77); + f.batch.begin(); + expect(history[0]).toBe(77); + f.batch.write(0, f.mesh); + f.batch.end(); + expect(current[0]).toBe(77); + } finally { + f.close(); + neighbor.close(); + } + } + }); + it("resumes RAW after a temporary forced full-rate stock frame", () => { + const f = fixture(); + try { + f.draw(); + expect(f.receipt().slots[0].currentRaw).toBe(true); + f.player.update(0, true); + f.scene.updateMatrixWorld(true); + f.draw(); + expect(f.receipt().slots[0].currentRaw).toBe(false); + expect(f.receipt().slots[0].previousRaw).toBe(true); + expect(f.receipt().slots[0].retired).toBe(false); + let resumed = false; + for (let i = 0; i < 8; i++) { + f.tick(); + f.draw(); + if (f.receipt().slots[0].currentRaw) { + resumed = true; + break; + } + } + expect(resumed).toBe(true); + } finally { + f.close(); + } + }); +}); diff --git a/packages/core/__tests__/projection-skinned.spec.ts b/packages/core/__tests__/projection-skinned.spec.ts index 66d86dffe1..e32765a099 100644 --- a/packages/core/__tests__/projection-skinned.spec.ts +++ b/packages/core/__tests__/projection-skinned.spec.ts @@ -1,12 +1,16 @@ import { + AnimationClip, Bone, BufferGeometry, Float32BufferAttribute, + Group, type InstancedBufferGeometry, Matrix4, Mesh, MeshStandardMaterial, + NumberKeyframeTrack, type Object3D, + PerspectiveCamera, Scene, Skeleton, SkinnedMesh, @@ -14,8 +18,11 @@ import { } from "three"; import { positionLocal } from "three/tsl"; import { MeshStandardNodeMaterial } from "three/webgpu"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { AnimationPlayer } from "../src/animation.js"; +import { SpanRecorder, setSpanRecorder } from "../src/profiling/Spans.js"; import { SkinnedBatch, isSimilarityTransform } from "../src/projection-skinned.js"; +import { RenderCameraCull } from "../src/render-camera-cull.js"; import { SceneRenderProjection } from "../src/renderProjection.js"; /** @@ -109,6 +116,41 @@ function expectPalette(batch: SkinnedBatch, slot: number, mesh: SkinnedMesh): vo } describe("skinned lane of the render projection", () => { + it("attributes each actual palette write, excluding reconcile overhead", () => { + const { scene } = crowd(8); + const projection = new SceneRenderProjection(scene); + const recorder = new SpanRecorder(); + setSpanRecorder(recorder); + try { + projection.reconcile(); + recorder.endFrame(10); + expect(recorder.window()?.spans.skinnedWrite?.perFrame).toBe(8); + } finally { + setSpanRecorder(undefined); + projection.dispose(); + } + }); + + it("closes the write span if skeleton evaluation throws", () => { + const mesh = rig(rigGeometry(), new MeshStandardMaterial()); + const batch = new SkinnedBatch({ first: mesh, capacity: 2, velocity: false }); + const slot = batch.claim(mesh) as number; + const recorder = new SpanRecorder(); + const update = vi.spyOn(mesh.skeleton, "update").mockImplementation(() => { + throw new Error("failed skeleton update"); + }); + setSpanRecorder(recorder); + try { + expect(() => batch.write(slot, mesh)).toThrow("failed skeleton update"); + recorder.endFrame(10); + expect(recorder.window()?.spans.skinnedWrite?.perFrame).toBe(1); + } finally { + setSpanRecorder(undefined); + update.mockRestore(); + batch.dispose(); + } + }); + it("draws a crowd sharing geometry and material as one palette draw, with no game code", () => { const { scene, rigs } = crowd(8); const projection = new SceneRenderProjection(scene); @@ -275,6 +317,312 @@ describe("SkinnedBatch palette", () => { expect([...(batch.history as Float32Array)]).toEqual([...batch.palette]); }); + it.each([4, 8])("rewrites a hidden slot after a skipped update at rate %i", (updateRate) => { + const root = new Group(); + const mesh = rig(rigGeometry(), new MeshStandardMaterial()); + root.add(mesh); + const player = new AnimationPlayer({ root, clips: [], strideSync: false, updateRate }); + const batch = new SkinnedBatch({ first: mesh, capacity: 1, velocity: true }); + const slot = batch.claim(mesh) as number; + + player.update(0, true); + root.updateMatrixWorld(true); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + expect(batch.palette.some((value) => value !== 0)).toBe(true); + + batch.begin(); + batch.hide(slot); + batch.end(); + do player.update(1 / 60); + while (player.updateRateReport.evaluated); + + root.updateMatrixWorld(true); + batch.begin(); + batch.restart(slot); + batch.write(slot, mesh); + batch.end(); + expect(batch.palette.some((value) => value !== 0)).toBe(true); + expect([...(batch.history as Float32Array)]).toEqual([...batch.palette]); + + player.dispose(); + batch.dispose(); + }); + + it("writes the restored pose and velocity history when stopped after a skipped tick", () => { + const root = new Group(); + const mesh = rig(rigGeometry(), new MeshStandardMaterial()); + (mesh.skeleton.bones[0] as Bone).name = "joint"; + root.add(mesh); + const player = new AnimationPlayer({ + root, + clips: [ + new AnimationClip("move", 1, [ + new NumberKeyframeTrack("joint.position[x]", [0, 1], [0, 1]), + ]), + ], + strideSync: false, + updateRate: 8, + }); + const batch = new SkinnedBatch({ first: mesh, capacity: 1, velocity: true }); + const slot = batch.claim(mesh) as number; + player.play("move"); + player.update(0, true); + root.updateMatrixWorld(true); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + const initial = batch.palette.slice(); + player.update(0.5, true); + root.updateMatrixWorld(true); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + const animated = batch.palette.slice(); + do player.update(1 / 60); + while (player.updateRateReport.evaluated); + player.stop(); + root.updateMatrixWorld(true); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + expect([...batch.palette]).toEqual([...initial]); + expect([...(batch.history as Float32Array)]).toEqual([...animated]); + player.dispose(); + batch.dispose(); + }); + + it("skips a palette write on a skipped animation frame and carries the whole step on evaluation", () => { + const root = new Group(); + const mesh = rig(rigGeometry(), new MeshStandardMaterial()); + root.add(mesh); + const player = new AnimationPlayer({ + root, + clips: [ + new AnimationClip("turn", 1, [ + new NumberKeyframeTrack(".rotation[x]", [0, 1], [0, Math.PI]), + ]), + ], + strideSync: false, + updateRate: 4, + }); + player.play("turn"); + const batch = new SkinnedBatch({ first: mesh, capacity: 1, velocity: true }); + const slot = batch.claim(mesh) as number; + player.update(0, true); + root.updateMatrixWorld(true); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + const first = batch.palette.slice(); + + let skipped = false; + while (!skipped) { + player.update(1 / 60); + if (player.updateRateReport.evaluated) { + root.updateMatrixWorld(true); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + first.set(batch.palette); + } + skipped = !player.updateRateReport.evaluated; + } + root.updateMatrixWorld(true); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + expect([...(batch.history as Float32Array)]).toEqual([...batch.palette]); + + while (!player.updateRateReport.evaluated) player.update(1 / 60); + root.updateMatrixWorld(true); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + expect([...(batch.history as Float32Array)]).toEqual([...first]); + expect([...batch.palette]).not.toEqual([...first]); + + player.updateRate = 1; + player.update(1 / 60); + root.updateMatrixWorld(true); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + expect([...(batch.history as Float32Array)]).not.toEqual([...batch.palette]); + player.dispose(); + batch.dispose(); + }); + + it("keeps an unparented camera target at full rate behind a projected skinned batch", () => { + const scene = new Scene(); + const geometry = rigGeometry(); + const material = new MeshStandardMaterial(); + const entries = Array.from({ length: 8 }, (_, index) => { + const root = new Group(); + root.position.set(index * 5, 0, -120); + const mesh = rig(geometry, material); + root.add(mesh); + scene.add(root); + const player = new AnimationPlayer({ + root, + clips: [ + new AnimationClip("turn", 1, [ + new NumberKeyframeTrack(".rotation[z]", [0, 1], [0, Math.PI]), + ]), + ], + strideSync: false, + }); + player.play("turn"); + return { player, root }; + }); + const projection = new SceneRenderProjection(scene); + const camera = new PerspectiveCamera(60, 1, 0.1, 1_000); + camera.lookAt(0, 0, -120); + scene.updateMatrixWorld(true); + projection.reconcile(); + expect(projection.report.skinnedBatches).toBe(1); + + const cull = new RenderCameraCull(); + cull.apply(projection.root, camera, 720); + cull.restore(); + for (const entry of entries) entry.player.update(1 / 60); + + expect(entries[0]?.player.updateRateReport).toMatchObject({ rate: 1, overridden: false }); + expect(entries[1]?.player.updateRateReport.rate).toBe(4); + + cull.dispose(); + projection.dispose(); + for (const entry of entries) entry.player.dispose(); + }); + + it("observes animation roots behind exact projection proxies", () => { + const scene = new Scene(); + const geometry = rigGeometry(); + const material = new MeshStandardMaterial(); + const camera = new PerspectiveCamera(60, 1, 0.1, 1_000); + scene.add(camera); + const entries: { player: AnimationPlayer; root: Group }[] = []; + for (let index = 0; index < 9; index += 1) { + const root = new Group(); + root.position.set(index === 0 ? 60 : 0, 0, -120); + const mesh = rig(geometry, material); + if (index === 0) mesh.scale.set(1, 2, 1); + else if (index === 1 || index === 2) mesh.scale.set(-1, 1, 1); + root.add(mesh); + (index === 2 ? camera : scene).add(root); + const player = new AnimationPlayer({ + root, + clips: [ + new AnimationClip("turn", 1, [ + new NumberKeyframeTrack(".rotation[z]", [0, 1], [0, Math.PI]), + ]), + ], + strideSync: false, + }); + player.play("turn"); + entries.push({ player, root }); + } + camera.lookAt(0, 0, -120); + scene.updateMatrixWorld(true); + const projection = new SceneRenderProjection(scene, { minMeshes: 1 }); + const cull = new RenderCameraCull({ minimumPixels: 2 }); + try { + projection.reconcile(); + expect(projection.report.exact.nonUniformScale).toBe(1); + expect(projection.report.exact.negativeScale).toBe(2); + cull.apply(projection.root, camera, 720); + cull.restore(); + for (const entry of entries) entry.player.update(1 / 60); + expect(entries[0]?.player.updateRateReport.rate).toBeGreaterThan(1); + expect(entries[1]?.player.updateRateReport).toMatchObject({ rate: 1, overridden: false }); + expect(entries[2]?.player.updateRateReport).toMatchObject({ rate: 1, overridden: false }); + } finally { + cull.dispose(); + projection.dispose(); + for (const entry of entries) entry.player.dispose(); + geometry.dispose(); + material.dispose(); + } + }); + + it("writes gameplay root movement on a skipped animation frame", () => { + const root = new Group(); + const mesh = rig(rigGeometry(), new MeshStandardMaterial()); + root.add(mesh); + const player = new AnimationPlayer({ root, clips: [], strideSync: false, updateRate: 4 }); + const batch = new SkinnedBatch({ first: mesh, capacity: 1, velocity: true }); + const slot = batch.claim(mesh) as number; + const skeletonUpdate = vi.spyOn(mesh.skeleton, "update"); + const recorder = new SpanRecorder(); + setSpanRecorder(recorder); + try { + player.update(0, true); + root.updateMatrixWorld(true); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + recorder.endFrame(1); + recorder.window(); + + while (player.updateRateReport.evaluated) player.update(1 / 60); + root.position.x = 10; + root.updateMatrixWorld(true); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + recorder.endFrame(1); + expect(batch.palette[12]).toBeCloseTo(10); + expect(batch.history?.[12]).toBeCloseTo(0); + expect(recorder.window()?.spans.skinnedWrite?.perFrame).toBe(1); + + skeletonUpdate.mockClear(); + batch.begin(); + batch.write(slot, mesh); + batch.end(); + recorder.endFrame(1); + expect(skeletonUpdate).not.toHaveBeenCalled(); + expect(batch.history?.[12]).toBeCloseTo(batch.palette[12] as number); + expect(recorder.window()?.spans.skinnedWrite?.perFrame).toBe(1); + } finally { + setSpanRecorder(undefined); + skeletonUpdate.mockRestore(); + player.dispose(); + batch.dispose(); + } + }); + + it("updates auto-rate state for source rigs behind the projected skinned batch", () => { + const root = new Group(); + const mesh = rig(rigGeometry(), new MeshStandardMaterial()); + root.add(mesh); + // Keep this policy subject off the centre ray: this test measures projected size behind the + // batch, not camera target recognition. + root.position.set(10, 0, -100); + root.updateMatrixWorld(true); + const player = new AnimationPlayer({ + root, + clips: [], + strideSync: false, + }); + const batch = new SkinnedBatch({ first: mesh, capacity: 1, velocity: false }); + batch.claim(mesh); + batch.begin(); + batch.write(0, mesh); + batch.end(); + const scene = new Scene(); + scene.add(batch.mesh); + const camera = new PerspectiveCamera(60, 1, 0.1, 1000); + scene.updateMatrixWorld(true); + const cull = new RenderCameraCull(); + cull.apply(scene, camera, 720); + cull.restore(); + expect(player.updateRateReport.rate).toBeGreaterThan(1); + cull.dispose(); + player.dispose(); + batch.dispose(); + }); + it("collapses a released slot so it draws nothing", () => { const mesh = rig(rigGeometry(), new MeshStandardMaterial()); const batch = new SkinnedBatch({ first: mesh, capacity: 2, velocity: false }); diff --git a/packages/core/__tests__/render-camera-cull.spec.ts b/packages/core/__tests__/render-camera-cull.spec.ts index a2cea4b59d..7dfd5169b2 100644 --- a/packages/core/__tests__/render-camera-cull.spec.ts +++ b/packages/core/__tests__/render-camera-cull.spec.ts @@ -252,28 +252,32 @@ describe("render camera cull", () => { expect(cull.report.culled).toBe(0); }); - it("does not rescan a buffer that is rewritten every frame", () => { - // A per-frame rewrite used to scan every vertex every frame. The gate cannot know the new - // extents without that scan, so once it sees two changes in a row it treats the bound like - // `frustumCulled = false` and keeps drawing, rather than paying the scan forever. - const dynamic = dynamicMesh([0, 0, 0, 0.001, 0, 0, 0, 0.001, 0], -1_000); - const scene = sceneWith(dynamic.mesh); - const cull = new RenderCameraCull(); - const compute = vi.spyOn(dynamic.mesh.geometry, "computeBoundingSphere"); - - for (let frame = 0; frame < 40; frame += 1) { - dynamic.position[3] = frame + 1; - dynamic.attribute.needsUpdate = true; - cull.apply(scene, camera(), 720); - } + it.each([false, true])( + "does not rescan a buffer rewritten every frame (prepare=%s)", + (prepare) => { + // A per-frame rewrite used to scan every vertex every frame. The gate cannot know the new + // extents without that scan, so once it sees two changes in a row it treats the bound like + // `frustumCulled = false` and keeps drawing, rather than paying the scan forever. + const dynamic = dynamicMesh([0, 0, 0, 0.001, 0, 0, 0, 0.001, 0], -1_000); + const scene = sceneWith(dynamic.mesh); + const cull = new RenderCameraCull(); + const compute = vi.spyOn(dynamic.mesh.geometry, "computeBoundingSphere"); + + for (let frame = 0; frame < 40; frame += 1) { + dynamic.position[3] = frame + 1; + dynamic.attribute.needsUpdate = true; + if (prepare) cull.prepareAnimation(scene, camera(), 720); + cull.apply(scene, camera(), 720); + } - // One recompute for the first change, then the per-frame rewrite is recognised. - expect(compute.mock.calls.length).toBe(1); - expect(dynamic.mesh.visible).toBe(true); - expect(cull.report.culled).toBe(0); - expect(cull.report.exemptDynamicBounds).toBe(1); - compute.mockRestore(); - }); + // One recompute for the first change, then the per-frame rewrite is recognised. + expect(compute.mock.calls.length).toBe(1); + expect(dynamic.mesh.visible).toBe(true); + expect(cull.report.culled).toBe(0); + expect(cull.report.exemptDynamicBounds).toBe(1); + compute.mockRestore(); + }, + ); it("culls a rewritten buffer again once it stops changing", () => { // The dynamic exemption is for a buffer that *is* rewritten every frame. One that settles has a diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index d84efdd9c0..365ac6e14c 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -719,7 +719,8 @@ "importPath": "@threenative/core", "kind": "class", "overrides": [ - "strideSync controls whether the matched rate is applied while stride is still measured" + "strideSync controls whether the matched rate is applied while stride is still measured", + "updateRate: \"auto\" chooses the camera-based interval; 1 is full evaluation, and another positive safe integer requests that interval; a public `mixer` `\"loop\"` listener promotes effective evaluation to 1 to preserve event timing" ], "package": "@threenative/core", "signature": "export class AnimationPlayer { … }", @@ -729,9 +730,11 @@ "stop a walking character's feet from sliding or spinning", "match a walk or run cycle to how fast a character is moving", "match an in-place walk cycle with no root motion to the body's speed", - "find out what speed an animation clip was authored for" + "find out what speed an animation clip was authored for", + "reduce animation CPU for distant characters", + "inspect the effective animation evaluation rate" ], - "summary": "Play a skinned or sprite animation from game code. A locomotion clip's playback rate is matched to the ground the body actually covers, so feet do not skate or spin — on by default, `strideSync: false` to keep the authored rate, and `player.stride` reports the measurement either way. Name the body a game moves as `strideRoot` when the rig is a child of it. Clips authored **in place** — every ActorX and Unreal export, every Mixamo \"in place\" clip, every stock animal pack — are matched too: their stride is read off the ground a planted foot sweeps, and `stride.inPlace` says so.", + "summary": "Play a skinned or sprite animation from game code. A locomotion clip's playback rate is matched to the ground the body actually covers, so feet do not skate or spin — on by default, `strideSync: false` to keep the authored rate, and `player.stride` reports the measurement either way. Name the body a game moves as `strideRoot` when the rig is a child of it. Clips authored **in place** — every ActorX and Unreal export, every Mixamo \"in place\" clip, every stock animal pack — are matched too: their stride is read off the ground a planted foot sweeps, and `stride.inPlace` says so. Evaluation is automatic by default: `updateRate: \"auto\"` follows measured camera visibility and projected size. `updateRate: 1` evaluates every frame; another positive safe integer names that frame interval. `updateRateReport` reports the effective rate, whether a named override is active, and whether the latest update evaluated. One-shots and fades stay full-rate; root-motion `AnimationComposer` advances every tick, and auto camera-ray targets and camera-parented rigs stay full-rate. A public `mixer` `\"loop\"` listener promotes the player to full-rate and observes only loop events after registration, so skipped pre-registration cycles are not replayed; `updateRateReport` reports that effective rate.", "supersedes": [], "symbol": "AnimationPlayer" }, @@ -2909,7 +2912,8 @@ "kind": "class", "overrides": [ "strideSync controls whether locomotion playback rate matches ground covered", - "size normalises the instance to real-world metres with skin-aware measurement" + "size normalises the instance to real-world metres with skin-aware measurement", + "updateRate: \"auto\" chooses the camera-based interval; 1 is full evaluation, and another positive safe integer requests that interval; a public `mixer` `\"loop\"` listener promotes effective evaluation to 1 to preserve event timing" ], "package": "@threenative/core", "signature": "export class SkeletalMesh3D extends AnimationPlayer { … }", @@ -2918,9 +2922,11 @@ "my imported character renders deformed", "instance an imported rigged character", "validate animation clips on a character rig at load time", - "prepare a skinned character with safe skeleton cloning and stride sync" + "prepare a skinned character with safe skeleton cloning and stride sync", + "reduce animation CPU for a crowd of imported rigs", + "inspect an imported rig's effective animation evaluation rate" ], - "summary": "Shared preparation for an imported rigged character. Instances the rig with a skeleton-safe clone, normalises size with skin-aware measurement, validates requested clips against the file and rig at load time, and sets up AnimationPlayer with honest stride-root accounting.", + "summary": "Shared preparation for an imported rigged character. Instances the rig with a skeleton-safe clone, normalises size with skin-aware measurement, validates requested clips against the file and rig at load time, and sets up AnimationPlayer with honest stride-root accounting. Animation evaluation inherits AnimationPlayer's automatic camera-based `updateRate: \"auto\"` default; `updateRate: 1` evaluates every frame, another positive safe integer requests that interval, and `updateRateReport` exposes the effective rate, override status and latest result. One-shots and fades stay full-rate; root-motion `AnimationComposer` advances every tick, and auto camera-ray targets and camera-parented rigs stay full-rate. A public `mixer` `\"loop\"` listener promotes effective evaluation to 1 and observes only loop events after registration, so skipped pre-registration cycles are not replayed.", "supersedes": [], "symbol": "SkeletalMesh3D" }, diff --git a/packages/core/src/animation-packet-state.ts b/packages/core/src/animation-packet-state.ts new file mode 100644 index 0000000000..dfd7510ab8 --- /dev/null +++ b/packages/core/src/animation-packet-state.ts @@ -0,0 +1,85 @@ +import { AnimationAction, type AnimationClip, AnimationMixer, type Object3D } from "three"; + +/** Internal ownership/time observation; clip and rig eligibility remain the batch's responsibility. */ +export interface IAnimationPacketState { + readonly root: Object3D; + readonly mixer: AnimationMixer; + readonly action: AnimationAction; + readonly clip: AnimationClip; + readonly time: number; + readonly evaluated: boolean; + readonly evaluation: number; +} + +export const animationPacketReaders = new WeakMap< + Object3D, + () => IAnimationPacketState | undefined +>(); + +export function readAnimationPacketState(object: Object3D): IAnimationPacketState | undefined { + for (let node: Object3D | null = object; node !== null; node = node.parent) { + const reader = animationPacketReaders.get(node); + if (reader !== undefined) return reader(); + } + return undefined; +} + +/** Accessors and replaced methods are ineligible; observing must not invoke author code. */ +export function animationPacketField(object: object, name: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(object, name); + return descriptor !== undefined && Object.hasOwn(descriptor, "value") + ? descriptor.value + : undefined; +} + +const actionMethods = [ + "getClip", + "getEffectiveWeight", + "getEffectiveTimeScale", + "isRunning", + "isScheduled", +] as const; +const defaults = actionMethods.map( + (name) => + [name, Object.getOwnPropertyDescriptor(AnimationAction.prototype, name)?.value] as const, +); +const mixerMethods = ["getRoot", "_isActiveAction"] as const; +const mixerDefaults = mixerMethods.map( + (name) => [name, Object.getOwnPropertyDescriptor(AnimationMixer.prototype, name)?.value] as const, +); + +function unchangedMethod(object: object, name: string, expected: unknown): boolean { + if (typeof expected !== "function") return false; + for (let owner: object | null = object; owner !== null; owner = Object.getPrototypeOf(owner)) { + const descriptor = Object.getOwnPropertyDescriptor(owner, name); + if (descriptor !== undefined) + return Object.hasOwn(descriptor, "value") && descriptor.value === expected; + } + return false; +} + +export function animationPacketMethodsPristine( + action: AnimationAction, + mixer: AnimationMixer, +): boolean { + return ( + defaults.every(([name, method]) => unchangedMethod(action, name, method)) && + mixerDefaults.every(([name, method]) => unchangedMethod(mixer, name, method)) + ); +} + +export function animationPacketTime( + held: number, + pending: number, + duration: number, +): number | undefined { + if (!Number.isFinite(pending) || pending < 0) return undefined; + if (pending === 0) return held; + let time = held + pending; + if (!Number.isFinite(time)) return undefined; + const seam = Math.round(time / duration) * duration; + if (Math.abs(time - seam) <= Number.EPSILON * 16 * Math.max(duration, Math.abs(time))) + time = seam; + // Same repeating wrap as the proven read-only query and Three's AnimationAction._updateTime. + return time >= duration || time < 0 ? time - duration * Math.floor(time / duration) : time; +} diff --git a/packages/core/src/animation-pose-packet.ts b/packages/core/src/animation-pose-packet.ts new file mode 100644 index 0000000000..b6b99a7f82 --- /dev/null +++ b/packages/core/src/animation-pose-packet.ts @@ -0,0 +1,466 @@ +import { + LinearInterpolant, + Matrix4, + Quaternion, + Skeleton, + type SkinnedMesh, + type Vector3, +} from "three"; +import { + animationPacketField as field, + readAnimationPacketState, +} from "./animation-packet-state.js"; +import { isSimilarityTransform } from "./projection-skinned.js"; + +export interface IAnimationPoseKey { + readonly times: readonly number[]; + readonly values: readonly number[]; +} +export interface IAnimationPoseRest { + readonly local: readonly number[]; + readonly position: readonly number[]; + readonly scale: readonly number[]; +} +export interface IAnimationPoseVersion { + readonly parent: readonly number[]; + readonly rest: readonly IAnimationPoseRest[]; + readonly keys: Readonly>; + readonly duration: number; + readonly time: number; + readonly evaluation: number; + readonly bind: readonly number[]; + readonly bindInverse: readonly number[]; + readonly rootWorld: readonly number[]; + readonly bindInverses: readonly (readonly number[])[]; + readonly world64: readonly (readonly number[])[]; + readonly inverse64: readonly (readonly number[])[]; + readonly root64: readonly number[]; + readonly bind64: readonly number[]; + readonly bindInverse64: readonly number[]; + readonly bindMode: "attached"; +} +export interface IAnimationPosePacket { + readonly raw: true; + readonly version: IAnimationPoseVersion; + readonly time: number; +} + +const cache = new WeakMap< + SkinnedMesh, + { + readonly sources: readonly object[]; + readonly version: IAnimationPoseVersion; + readonly interpolants: readonly object[]; + } +>(); +const prototype = Object.getPrototypeOf(Float32Array.prototype); +const typedLength = Object.getOwnPropertyDescriptor(prototype, "length")?.get; +const typedBuffer = Object.getOwnPropertyDescriptor(prototype, "buffer")?.get; +const compose = Matrix4.prototype.compose; +const product = Matrix4.prototype.multiplyMatrices; +const methods = ["evaluate", "interpolate_", "copySampleValue_", "intervalChanged_"] as const; +const defaults = methods.map( + (name) => [name, installedMethod(LinearInterpolant.prototype, name)] as const, +); +const F = Math.fround; +const skeletonUpdate = Object.getOwnPropertyDescriptor(Skeleton.prototype, "update")?.value; + +function installedMethod(value: object, name: string): unknown { + for (let owner: object | null = value; owner !== null; owner = Object.getPrototypeOf(owner)) { + const descriptor = Object.getOwnPropertyDescriptor(owner, name); + if (descriptor !== undefined) + return Object.hasOwn(descriptor, "value") ? descriptor.value : undefined; + } + return undefined; +} + +function object(value: unknown): value is object { + return value !== null && typeof value === "object"; +} +function member(value: unknown, name: string): unknown { + return object(value) ? field(value, name) : undefined; +} +function numbers(value: unknown, length: number): readonly number[] | undefined { + const array = Array.isArray(value); + if (!array && !(value instanceof Float32Array)) return undefined; + if (!array && !(typedBuffer?.call(value) instanceof ArrayBuffer)) return undefined; + if ((array ? field(value, "length") : typedLength?.call(value)) !== length) return undefined; + const result: number[] = []; + for (let i = 0; i < length; i++) { + const n = member(value, String(i)); + if (typeof n !== "number" || !Number.isFinite(n)) return undefined; + result.push(n); + } + return Object.freeze(result); +} +function components(value: unknown, names: readonly string[]): readonly number[] | undefined { + const result = names.map((name) => member(value, name)); + return result.every((n) => typeof n === "number" && Number.isFinite(n)) + ? Object.freeze(result as number[]) + : undefined; +} +function matrix(value: unknown): readonly number[] | undefined { + return numbers(member(value, "elements"), 16); +} +function rounded(values: readonly number[]): readonly number[] { + return Object.freeze(values.map(F)); +} +function same(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (!object(a) || !object(b)) return false; + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && keys.every((key) => same(field(a, key), field(b, key))) + ); +} +function finiteRaw(value: unknown): boolean { + if (typeof value === "number") return Number.isFinite(F(value)); + return !object(value) || Object.keys(value).every((key) => finiteRaw(field(value, key))); +} +function identity(values: readonly number[]): boolean { + return values.every((v, i) => v === (i % 5 === 0 ? 1 : 0)); +} +function pristine(value: unknown): boolean { + if (!object(value) || Object.getPrototypeOf(value) !== LinearInterpolant.prototype) return false; + return defaults.every(([name, expected]) => { + for (let owner: object | null = value; owner !== null; owner = Object.getPrototypeOf(owner)) { + const d = Object.getOwnPropertyDescriptor(owner, name); + if (d !== undefined) return Object.hasOwn(d, "value") && d.value === expected; + } + return false; + }); +} +function sample(key: IAnimationPoseKey, time: number): number { + if (time <= (key.times[0] as number)) return key.values[0] as number; + for (let i = 1; i < 3; i++) { + const end = key.times[i] as number; + if (time <= end) { + const alpha = F( + (time - (key.times[i - 1] as number)) / F(end - (key.times[i - 1] as number)), + ); + return F( + (key.values[i - 1] as number) + + F(F((key.values[i] as number) - (key.values[i - 1] as number)) * alpha), + ); + } + } + return key.values[2] as number; +} +function validTransform( + node: object, + parentWorld: readonly number[], +): (IAnimationPoseRest & { readonly world: readonly number[] }) | undefined { + const local = matrix(member(node, "matrix")); + const world = matrix(member(node, "matrixWorld")); + const position = components(member(node, "position"), ["x", "y", "z"]); + const scale = components(member(node, "scale"), ["x", "y", "z"]); + const q = components(member(node, "quaternion"), ["_x", "_y", "_z", "_w"]); + if (!local || !world || !position || !scale || !q || member(node, "matrixAutoUpdate") !== true) + return undefined; + const scratch = new Matrix4(); + compose.call( + scratch, + { x: position[0], y: position[1], z: position[2] } as Vector3, + new Quaternion(q[0], q[1], q[2], q[3]), + { x: scale[0], y: scale[1], z: scale[2] } as Vector3, + ); + if (!same(local, scratch.elements)) return undefined; + product.call(scratch, { elements: parentWorld } as Matrix4, { elements: local } as Matrix4); + return same(world, scratch.elements) ? { local, world, position, scale } : undefined; +} + +/** Pure read/capture only. Every source component is revalidated, including on held ticks. + * Caller captures on genuine evaluation/world refresh. Held numeric edits stay deferred until + * that refresh, as in stock; readAnimationPosePacketTime retains the owned version between them. + * Undefined means genuine stock fallback; no retirement, callbacks or batch lifecycle live here. */ +export function readAnimationPosePacket(rig: SkinnedMesh): IAnimationPosePacket | undefined { + const state = readAnimationPacketState(rig); + if (!state || state.root !== rig) return undefined; + const skeleton = member(rig, "skeleton"); + const bones = member(skeleton, "bones"); + const inverses = member(skeleton, "boneInverses"); + const tracks = member(state.clip, "tracks"); + const interpolants = member(state.action, "_interpolants"); + const bindings = member(state.action, "_propertyBindings"); + if ( + !Array.isArray(bones) || + bones.length !== 12 || + !Array.isArray(inverses) || + inverses.length !== 12 || + !Array.isArray(tracks) || + tracks.length !== 11 || + !Array.isArray(interpolants) || + interpolants.length !== 11 || + !Array.isArray(bindings) || + bindings.length !== 11 || + !object(skeleton) + ) + return undefined; + if (installedMethod(skeleton, "update") !== skeletonUpdate) return undefined; + const bind = matrix(member(rig, "bindMatrix")); + const bindInverse = matrix(member(rig, "bindMatrixInverse")); + const rootWorld = matrix(member(rig, "matrixWorld")); + if ( + !bind || + !identity(bind) || + !bindInverse || + !rootWorld || + member(rig, "bindMode") !== "attached" + ) + return undefined; + const parent = member(rig, "parent"); + const parentWorld = + parent === null ? new Matrix4().elements : matrix(member(parent, "matrixWorld")); + if (!parentWorld || !validTransform(rig, parentWorld)) return undefined; + if (!isSimilarityTransform(rootWorld)) return undefined; + const scratch = new Matrix4(); + product.call(scratch, { elements: rootWorld } as Matrix4, { elements: bindInverse } as Matrix4); + if (scratch.elements.some((v, i) => Math.abs(v - (i % 5 === 0 ? 1 : 0)) > 1e-10)) + return undefined; + const held = member(state.action, "time"); + const duration = member(state.clip, "duration"); + if (typeof held !== "number" || typeof duration !== "number") return undefined; + const sources: object[] = [ + state.action, + state.clip, + skeleton, + bones, + inverses, + tracks, + interpolants, + bindings, + ]; + sources.push( + ...[ + member(rig, "matrix"), + member(rig, "matrixWorld"), + member(rig, "bindMatrix"), + member(rig, "bindMatrixInverse"), + parent, + ].filter(object), + ); + const rest: IAnimationPoseRest[] = []; + const worlds: (readonly number[])[] = []; + const inverse64: (readonly number[])[] = []; + const keys: Record = {}; + const capturedInterpolants: object[] = []; + for (let i = 0; i < 12; i++) { + const bone = member(bones, String(i)); + const inverse = matrix(member(inverses, String(i))); + if ( + !object(bone) || + !inverse || + member(bone, "parent") !== (i === 0 ? rig : member(bones, String(i - 1))) + ) + return undefined; + const pose = validTransform(bone, i === 0 ? rootWorld : (worlds[i - 1] as readonly number[])); + if (!pose) return undefined; + rest.push( + Object.freeze({ + local: rounded(pose.local), + position: rounded(pose.position), + scale: rounded(pose.scale), + }), + ); + worlds.push(pose.world); + inverse64.push(inverse); + sources.push( + bone, + ...[ + member(inverses, String(i)), + member(bone, "matrix"), + member(bone, "matrixWorld"), + member(bone, "position"), + member(bone, "scale"), + member(bone, "quaternion"), + member(bone, "rotation"), + ].filter(object), + ); + } + for (let i = 0; i < 11; i++) { + const track = member(tracks, String(i)); + const interpolation = member(interpolants, String(i)); + const binding = member(member(bindings, String(i)), "binding"); + const path = member(binding, "parsedPath"); + const bone = member(binding, "node"); + const index = bones.indexOf(bone); + const positions = member(interpolation, "parameterPositions"); + const values = member(interpolation, "sampleValues"); + if ( + !object(track) || + !object(interpolation) || + !object(binding) || + index < 1 || + keys[index] || + !pristine(interpolation) || + member(interpolation, "valueSize") !== 1 || + member(path, "propertyName") !== "rotation" || + member(path, "propertyIndex") !== "z" || + member(track, "name") !== member(binding, "path") || + member(track, "times") !== positions || + member(track, "values") !== values + ) + return undefined; + const times = numbers(positions, 3); + const samples = numbers(values, 3); + if ( + !times || + !samples || + times[0] !== 0 || + times[2] !== duration || + !(times[0] < (times[1] as number) && (times[1] as number) < (times[2] as number)) + ) + return undefined; + const key = Object.freeze({ times, values: samples }); + const z = sample(key, held); + const b = bones[index]; + const rotation = components(member(b, "rotation"), ["_x", "_y", "_z"]); + const q = components(member(b, "quaternion"), ["_x", "_y", "_z", "_w"]); + const expected = [0, 0, Math.sin(z / 2), Math.cos(z / 2)]; + if ( + !rotation || + !q || + member(member(b, "rotation"), "_order") !== "XYZ" || + Math.abs(rotation[0] as number) > 1e-12 || + Math.abs(rotation[1] as number) > 1e-12 || + Math.abs((rotation[2] as number) - z) > 1e-7 || + ![1, -1].some((sign) => + q.every((v, j) => Math.abs(v - sign * (expected[j] as number)) < 1e-7), + ) + ) + return undefined; + keys[index] = key; + capturedInterpolants.push(interpolation); + sources.push(track, interpolation, binding, positions as object, values as object); + } + const version: IAnimationPoseVersion = Object.freeze({ + parent: Object.freeze(Array.from({ length: 12 }, (_, i) => i - 1)), + rest: Object.freeze(rest), + keys: Object.freeze(keys), + duration, + time: held, + evaluation: state.evaluation, + bind: rounded(bind), + bindInverse: rounded(bindInverse), + rootWorld: rounded(rootWorld), + bindInverses: Object.freeze(inverse64.map(rounded)), + world64: Object.freeze(worlds), + inverse64: Object.freeze(inverse64), + root64: rootWorld, + bind64: bind, + bindInverse64: bindInverse, + bindMode: "attached", + }); + if (!finiteRaw(version)) return undefined; + const previous = cache.get(rig); + const owned = + previous && + previous.sources.length === sources.length && + sources.every((value, i) => value === previous.sources[i]) && + same(previous.version, version) + ? previous.version + : version; + cache.set(rig, { sources, version: owned, interpolants: capturedInterpolants }); + return Object.freeze({ raw: true, version: owned, time: state.time }); +} + +/** Cheap held query: numeric keys/rest/bind remain owned until the next genuine refresh. */ +export function readAnimationPosePacketTime( + rig: SkinnedMesh, + version: IAnimationPoseVersion, +): IAnimationPosePacket | undefined { + const saved = cache.get(rig); + const state = readAnimationPacketState(rig); + const skeleton = member(rig, "skeleton"); + if ( + !saved || + saved.version !== version || + !state || + state.root !== rig || + state.action !== saved.sources[0] || + state.clip !== saved.sources[1] || + member(state.clip, "duration") !== version.duration || + skeleton !== saved.sources[2] || + member(skeleton, "bones") !== saved.sources[3] || + !object(skeleton) || + installedMethod(skeleton, "update") !== skeletonUpdate + ) + return undefined; + const interpolants = member(state.action, "_interpolants"); + if ( + interpolants !== saved.sources[6] || + member(interpolants, "length") !== 11 || + saved.interpolants.some( + (value, i) => member(interpolants, String(i)) !== value || !pristine(value), + ) + ) + return undefined; + return Object.freeze({ raw: true, version, time: state.time }); +} + +/** Cached genuine CPU basis for held retirement; no current-time sampling or author calls. */ +export function evaluateAnimationPoseFallback(version: IAnimationPoseVersion): Float32Array { + const out = new Float32Array(192); + const scratch = new Matrix4(); + for (let b = 0; b < 12; b++) { + product.call( + scratch, + { elements: version.world64[b] } as Matrix4, + { elements: version.inverse64[b] } as Matrix4, + ); + out.set(scratch.elements, b * 16); + } + return out; +} + +function rawProduct(a: ArrayLike, b: ArrayLike): Float32Array { + const out = new Float32Array(16); + for (let c = 0; c < 4; c++) + for (let r = 0; r < 4; r++) { + let value = F(F(a[r] as number) * F(b[c * 4] as number)); + value = F(value + F(F(a[4 + r] as number) * F(b[c * 4 + 1] as number))); + value = F(value + F(F(a[8 + r] as number) * F(b[c * 4 + 2] as number))); + out[c * 4 + r] = F(value + F(F(a[12 + r] as number) * F(b[c * 4 + 3] as number))); + } + return out; +} + +/** Owned displayed RAW matrices, including the shader's root/bindInverse prefix. */ +export function evaluateAnimationPosePacket(packet: IAnimationPosePacket): Float32Array { + const v = packet.version; + const out = new Float32Array(192); + const worlds: Float32Array[] = []; + const prefix = rawProduct(v.rootWorld, v.bindInverse); + for (let b = 0; b < 12; b++) { + const rest = v.rest[b] as IAnimationPoseRest; + const key = v.keys[b]; + let local = Float32Array.from(rest.local); + if (key) { + const z = sample(key, packet.time); + const c = F(Math.cos(z)); + const sn = F(Math.sin(z)); + local = new Float32Array([ + F(c * F(rest.scale[0] as number)), + F(sn * F(rest.scale[0] as number)), + 0, + 0, + F(-sn * F(rest.scale[1] as number)), + F(c * F(rest.scale[1] as number)), + 0, + 0, + 0, + 0, + F(rest.scale[2] as number), + 0, + F(rest.position[0] as number), + F(rest.position[1] as number), + F(rest.position[2] as number), + 1, + ]); + } + const world = rawProduct(b === 0 ? v.rootWorld : (worlds[b - 1] as Float32Array), local); + worlds.push(world); + out.set(rawProduct(prefix, rawProduct(world, v.bindInverses[b] as readonly number[])), b * 16); + } + return out; +} diff --git a/packages/core/src/animation.ts b/packages/core/src/animation.ts index d53ede1c0b..9de03da759 100644 --- a/packages/core/src/animation.ts +++ b/packages/core/src/animation.ts @@ -2,13 +2,23 @@ import { type AnimationAction, type AnimationClip, AnimationMixer, + type AnimationMixerEventMap, + type EventListener, LoopOnce, LoopRepeat, type Object3D, Vector3, type VectorKeyframeTrack, } from "three"; +import { + type IAnimationPacketState, + animationPacketField, + animationPacketMethodsPristine, + animationPacketReaders, + animationPacketTime, +} from "./animation-packet-state.js"; import { clipTrackBindings } from "./clip-audit.js"; +import { type IAnimationRateState, animationRateStates } from "./render-camera-cull.js"; import { type ISharedStride, type RigPreparation, uniformYawScale } from "./rig-preparation.js"; export interface IAnimationPlayerOptions { @@ -38,6 +48,17 @@ export interface IAnimationPlayerOptions { * the clip's motion back as if it were the body's. */ readonly strideRoot?: Object3D; + /** + * Evaluate by measured screen size by default; a positive integer names a frame interval. + * Public mixer listeners/speed overrides stay full-rate; action speed interpolations do until settled. + */ + readonly updateRate?: "auto" | number; +} + +export interface IAnimationUpdateRateReport { + readonly rate: number; + readonly overridden: boolean; + readonly evaluated: boolean; } /** @@ -225,10 +246,114 @@ export interface IAnimationPlayOptions { } type AnimationMode = "loop" | "once"; +let nextAnimationOffset = 0; +const ANIMATION_RATE_STATE_KEY = "threenative.animationRateState"; + +type LoopListener = EventListener; +type FinishedListener = EventListener< + AnimationMixerEventMap["finished"], + "finished", + LoopAwareAnimationMixer +>; +type BeforeFirstLoopListener = (duringUpdate: boolean) => void; + +class LoopAwareAnimationMixer extends AnimationMixer { + readonly #loopListeners = new Set(); + readonly #finishedListeners = new Set(); + readonly #beforeFirstLoopListener: BeforeFirstLoopListener; + #updateDepth = 0; + + constructor( + root: Object3D, + beforeFirstLoopListener: BeforeFirstLoopListener, + internalFinishedListener: FinishedListener, + ) { + super(root); + this.#beforeFirstLoopListener = beforeFirstLoopListener; + super.addEventListener("finished", internalFinishedListener); + } + + get hasPublicListeners(): boolean { + return this.#loopListeners.size > 0 || this.#finishedListeners.size > 0; + } + + override addEventListener(type: "loop", listener: LoopListener): void; + override addEventListener(type: "finished", listener: FinishedListener): void; + override addEventListener( + type: "loop" | "finished", + listener: LoopListener | FinishedListener, + ): void { + if (type === "loop") { + const loopListener = listener as LoopListener; + if (!this.hasEventListener(type, loopListener)) { + if (!this.hasPublicListeners) { + const duringUpdate = this.#updateDepth > 0; + this.#beforeFirstLoopListener(duringUpdate); + } + this.#loopListeners.add(loopListener); + } + super.addEventListener(type, loopListener); + return; + } + const finishedListener = listener as FinishedListener; + if (!this.hasEventListener(type, finishedListener)) { + if (!this.hasPublicListeners) this.#beforeFirstLoopListener(this.#updateDepth > 0); + this.#finishedListeners.add(finishedListener); + } + super.addEventListener(type, finishedListener); + } + + override removeEventListener(type: "loop", listener: LoopListener): void; + override removeEventListener(type: "finished", listener: FinishedListener): void; + override removeEventListener( + type: "loop" | "finished", + listener: LoopListener | FinishedListener, + ): void { + if (type === "loop") { + const loopListener = listener as LoopListener; + const wasRegistered = this.hasEventListener(type, loopListener); + super.removeEventListener(type, loopListener); + if (wasRegistered) this.#loopListeners.delete(loopListener); + return; + } + const finishedListener = listener as FinishedListener; + super.removeEventListener(type, finishedListener); + this.#finishedListeners.delete(finishedListener); + } + + override update(deltaTime: number): AnimationMixer { + this.#updateDepth += 1; + try { + return super.update(deltaTime); + } finally { + this.#updateDepth -= 1; + } + } +} export class AnimationPlayer { + readonly #packetReader = () => this.#readPacketState(); readonly mixer: AnimationMixer; readonly root: Object3D; + readonly #rateState: IAnimationRateState = { + rate: 1, + effectiveRate: 1, + frame: 0, + source: undefined, + evaluated: true, + evaluation: 0, + }; + readonly #staggerOffset = nextAnimationOffset++; + #updateRate: "auto" | number = "auto"; + #updateFrame = 0; + #skippedFrames = 0; + #pendingDt = 0; + readonly #pendingActionTimes = new Map(); + readonly #evaluationScales = new Map(); + readonly #evaluationPaused = new Set(); + readonly #timeScaleInterpolations = new Set(); + #evaluating = false; + #evaluated = false; #actions = new Map(); #current: string | undefined; #mode: AnimationMode = "loop"; @@ -264,7 +389,14 @@ export class AnimationPlayer { constructor(options: IAnimationPlayerOptions) { const owner = new.target.name || "AnimationPlayer"; this.root = options.root; - this.mixer = new AnimationMixer(options.root); + this.updateRate = options.updateRate ?? "auto"; + this.mixer = new LoopAwareAnimationMixer( + options.root, + (duringUpdate) => { + if (!duringUpdate) this.#flushPending(); + }, + this.#onFinished, + ); this.#preparation = options.preparation; this.#strideSync = options.strideSync ?? true; this.#strideRoot = options.strideRoot ?? options.root; @@ -280,6 +412,7 @@ export class AnimationPlayer { const action = this.mixer.clipAction(clip); if (action === null) throw new Error(`Animation clip '${clip.name}' could not create an action.`); + this.#observeTimeScaleInterpolation(action); this.#actions.set(clip.name, action); this.#clips.set(clip.name, clip); } @@ -299,7 +432,65 @@ export class AnimationPlayer { `${owner}: clip '${name}' binds 0 tracks to '${options.root.name || options.root.type}'.`, ); } - this.mixer.addEventListener("finished", this.#onFinished); + animationPacketReaders.set(this.root, this.#packetReader); + animationRateStates.set(this.root, this.#rateState); + this.root.userData[ANIMATION_RATE_STATE_KEY] = this.#rateState; + } + + get updateRate(): "auto" | number { + return this.#updateRate; + } + + set updateRate(value: "auto" | number) { + if (value !== "auto" && (!Number.isSafeInteger(value) || value < 1)) + throw new Error("AnimationPlayer.updateRate requires 'auto' or a positive safe integer."); + this.#updateRate = value; + } + + get updateRateReport(): IAnimationUpdateRateReport { + return { + rate: this.#effectiveUpdateRate(), + overridden: this.#updateRate !== "auto", + evaluated: this.#evaluated, + }; + } + + #effectiveUpdateRate(): number { + // Public mixer speed overrides and observed events retain ordinary Three.js tick semantics. + if (this.mixer.timeScale !== 1) return 1; + // Event deadlines and changing blend weights remain exactly on the gameplay tick. + if ( + this.#mode === "once" || + this.#fadeOut.length > 0 || + this.#blendElapsed < this.#blendDuration + ) + return 1; + if (this.mixer instanceof LoopAwareAnimationMixer && this.mixer.hasPublicListeners) return 1; + if (this.#timeScaleInterpolations.size > 0) return 1; + if (this.#updateRate !== "auto") return this.#updateRate; + const state = this.#rateState; + if (state.source === undefined) return 1; + return state.frame === state.source.animationFrame ? state.rate : 8; + } + + #observeTimeScaleInterpolation(action: AnimationAction): void { + const warp = action.warp.bind(action); + const stopWarping = action.stopWarping.bind(action); + const crossFadeFrom = action.crossFadeFrom.bind(action); + action.warp = (startTimeScale, endTimeScale, duration) => { + if (!this.#evaluating) this.#flushPending(); + this.#timeScaleInterpolations.add(action); + return warp(startTimeScale, endTimeScale, duration); + }; + action.crossFadeFrom = (fadeOutAction, duration, warp) => { + if (!this.#evaluating) this.#flushPending(); + return crossFadeFrom(fadeOutAction, duration, warp); + }; + action.stopWarping = () => { + const result = stopWarping(); + this.#timeScaleInterpolations.delete(action); + return result; + }; } get current(): string | undefined { @@ -454,7 +645,12 @@ export class AnimationPlayer { if (clipGroundSpeed < CLIP_GROUND_FLOOR) { // Not locomotion. An idle, a reload or a death is authored at the rate it is authored at, // and warping it by how fast the body happens to be sliding is a bug, not a convention. - if (action.getEffectiveTimeScale() !== 1) action.setEffectiveTimeScale(1); + if ( + this.#strideSynced && + !this.#timeScaleInterpolations.has(action) && + action.getEffectiveTimeScale() === this.#strideRate + ) + action.setEffectiveTimeScale(1); this.#strideGroundSpeed = groundSpeed; this.#strideRate = 1; this.#strideSynced = false; @@ -469,7 +665,8 @@ export class AnimationPlayer { // event happens at, however still the body stands: its own root motion is the movement, so // re-timing it against a stationary body only slows the event down. A travelling death clip // clamped to the rate floor held a corpse upright through its whole death in the sandbox. - const applies = this.#strideSync && this.#mode === "loop"; + const applies = + this.#strideSync && this.#mode === "loop" && !this.#timeScaleInterpolations.has(action); if (applies) action.setEffectiveTimeScale(rate); this.#strideGroundSpeed = groundSpeed; this.#strideRate = rate; @@ -489,7 +686,7 @@ export class AnimationPlayer { for (const [name, action] of this.#actions) { if (!action.isScheduled() || action.getEffectiveWeight() <= 0) continue; const ground = this.#measureOf(name).groundSpeed; - if (ground < CLIP_GROUND_FLOOR) continue; + if (ground < CLIP_GROUND_FLOOR || this.#timeScaleInterpolations.has(action)) continue; const wanted = groundSpeed < STRIDE_SPEED_FLOOR ? STRIDE_RATE_MIN : groundSpeed / ground; action.setEffectiveTimeScale(Math.min(STRIDE_RATE_MAX, Math.max(STRIDE_RATE_MIN, wanted))); } @@ -565,6 +762,8 @@ export class AnimationPlayer { play(name: string, options: IAnimationPlayOptions = {}): void { const next = this.#actions.get(name); if (next === undefined) throw new Error(`Unknown animation clip '${name}'.`); + if (this.#current !== name || this.#finished || (options.mode ?? "loop") !== this.#mode) + this.#flushPending(); // One owner at a time. A single-clip request takes the rig back from a weighted blend, exactly // as it took it back from a crossfade. const weighted = this.#blendTargets !== undefined; @@ -623,6 +822,7 @@ export class AnimationPlayer { [...targets].every(([name, weight]) => this.#blendTargets?.get(name) === weight) ) return; + this.#flushPending(); // Any crossfade in flight stops owning the rig; its outgoing actions join the blend ramp below // as participants with a target of zero, so they leave the same bounded way they arrived. this.#fadeOut = []; @@ -731,14 +931,165 @@ export class AnimationPlayer { } } - update(dt: number): void { + /** `force` evaluates accumulated time now, even outside this player's stagger slot. */ + update(dt: number, force = false): void { if (!Number.isFinite(dt) || dt < 0) throw new Error("AnimationPlayer.update requires a finite non-negative dt."); + if (this.mixer.timeScale !== 1) this.#flushPending(); + const elapsed = dt * this.mixer.timeScale; + this.#pendingDt += elapsed; + for (const action of this.#actions.values()) { + if (!action.isScheduled()) continue; + const time = action.paused ? 0 : action.timeScale * elapsed; + this.#pendingActionTimes.set(action, (this.#pendingActionTimes.get(action) ?? 0) + time); + } + const rate = this.#effectiveUpdateRate(); + this.#rateState.effectiveRate = force ? 1 : rate; + this.#updateFrame += 1; + this.#skippedFrames += 1; + this.#rateState.evaluated = false; + this.#evaluated = + force || + (this.#updateFrame + this.#staggerOffset) % rate === 0 || + this.#skippedFrames >= rate; + if (this.#evaluated) this.#evaluate(dt); + // Measure the body's movement on every tick, including skipped pose evaluations. + this.#syncStride(dt); + } + + #readPacketState(): IAnimationPacketState | undefined { + if ( + this.#evaluating || + this.#actions.size !== 1 || + this.#mode !== "loop" || + this.#finished || + this.#fadeOut.length !== 0 || + this.#blendTargets !== undefined || + this.#timeScaleInterpolations.size !== 0 + ) + return undefined; + const action = this.#actions.get(this.#current ?? ""); + const clip = this.#clips.get(this.#current ?? ""); + if ( + action === undefined || + clip === undefined || + !animationPacketMethodsPristine(action, this.mixer) + ) + return undefined; + const field = animationPacketField; + const duration = field(clip, "duration"); + const held = field(action, "time"); + const index = field(action, "_cacheIndex"); + const active = field(this.mixer, "_nActiveActions"); + const actions = field(this.mixer, "_actions"); + if ( + field(action, "_mixer") !== this.mixer || + field(action, "_clip") !== clip || + field(this.mixer, "_root") !== this.root || + field(this.mixer, "timeScale") !== 1 || + field(action, "loop") !== LoopRepeat || + field(action, "repetitions") !== Number.POSITIVE_INFINITY || + field(action, "clampWhenFinished") !== false || + field(action, "paused") !== false || + field(action, "enabled") !== true || + field(action, "weight") !== 1 || + field(action, "_effectiveWeight") !== 1 || + field(action, "timeScale") !== 1 || + field(action, "_effectiveTimeScale") !== 1 || + field(action, "_startTime") !== null || + field(action, "_weightInterpolant") !== null || + field(action, "_timeScaleInterpolant") !== null || + active !== 1 || + typeof index !== "number" || + index !== 0 || + !Array.isArray(actions) || + field(actions, String(index)) !== action || + typeof duration !== "number" || + !Number.isFinite(duration) || + duration <= 0 || + typeof held !== "number" || + !Number.isFinite(held) || + held < 0 || + held > duration + ) + return undefined; + const time = animationPacketTime(held, this.#pendingActionTimes.get(action) ?? 0, duration); + if (time === undefined) return undefined; + return Object.freeze({ + root: this.root, + mixer: this.mixer, + action, + clip, + time, + evaluated: this.#rateState.evaluated, + evaluation: this.#rateState.evaluation, + }); + } + + #flushPending(): void { + if (this.#pendingDt !== 0) this.#evaluate(this.#pendingDt); + } + + #evaluate(dt: number): void { // Read before mixer.update(): the "finished" listener fires inside it for a completing // once-clip, and that final frame still counts as advancement. const wasFinished = this.#finished; const before = this.mixer.time; - this.mixer.update(dt); + const elapsed = this.#pendingDt; + const mixerScale = this.mixer.timeScale; + const direct = + this.#skippedFrames === 1 && + elapsed === dt * mixerScale && + (mixerScale !== 1 || + (this.mixer instanceof LoopAwareAnimationMixer && this.mixer.hasPublicListeners)); + if (!direct) { + this.mixer.timeScale = 1; + for (const [action, pending] of this.#pendingActionTimes) { + const wasPaused = action.paused; + if (wasPaused && pending !== 0) { + this.#evaluationPaused.add(action); + action.paused = false; + } + let time = pending; + let seamAdjusted = false; + const duration = action.getClip().duration; + if (action.loop === LoopRepeat && duration > 0) { + const end = action.time + time; + const seam = Math.round(end / duration) * duration; + // Grouped addition must not hold the last pose when rate 1 already crossed a loop seam. + if (Math.abs(end - seam) <= Number.EPSILON * 16 * Math.max(duration, Math.abs(end))) { + time = seam - action.time; + seamAdjusted = true; + } + } + // Integrate changing stride rates; the last tick's rate must not own earlier skipped time. + if (elapsed !== 0) { + const scale = time / elapsed; + const equalToActionScale = + Math.abs(scale - action.timeScale) <= + Number.EPSILON * 16 * Math.max(1, Math.abs(scale), Math.abs(action.timeScale)); + const warpActive = !wasPaused && action.getEffectiveTimeScale() !== action.timeScale; + if (!warpActive && (seamAdjusted || !equalToActionScale)) { + this.#evaluationScales.set(action, action.timeScale); + action.setEffectiveTimeScale(scale); + } + } + } + } + this.#evaluating = true; + try { + this.mixer.update(direct ? dt : elapsed); + } finally { + this.#evaluating = false; + if (!direct) this.mixer.timeScale = mixerScale; + for (const action of this.#evaluationPaused) action.paused = true; + this.#evaluationPaused.clear(); + for (const [action, scale] of this.#evaluationScales) action.setEffectiveTimeScale(scale); + this.#evaluationScales.clear(); + this.#pendingDt = 0; + this.#pendingActionTimes.clear(); + this.#skippedFrames = 0; + } if (this.#fadeOut.length > 0) { this.#fadeElapsed = Math.min(this.#fadeDuration, this.#fadeElapsed + dt); const linear = this.#fadeDuration === 0 ? 1 : this.#fadeElapsed / this.#fadeDuration; @@ -762,11 +1113,17 @@ export class AnimationPlayer { if (this.#current !== undefined && !wasFinished && this.mixer.time !== before) this.#advancedFrames += 1; this.#advanceBlend(dt); - this.#syncStride(dt); + this.#rateState.evaluation += 1; + this.#rateState.evaluated = true; } stop(): void { + this.#pendingDt = 0; + this.#pendingActionTimes.clear(); + this.#skippedFrames = 0; + this.#evaluated = false; this.mixer.stopAllAction(); + this.#rateState.evaluation += 1; this.#current = undefined; this.#mode = "loop"; this.#finished = false; @@ -776,6 +1133,7 @@ export class AnimationPlayer { this.#blendDuration = 0; this.#blendElapsed = 0; this.#blendStarts.clear(); + this.#timeScaleInterpolations.clear(); this.#resetStride(); } @@ -796,6 +1154,12 @@ export class AnimationPlayer { this.#clips.clear(); this.#clipGroundSpeed.clear(); this.#preparation = undefined; + if (animationPacketReaders.get(this.root) === this.#packetReader) + animationPacketReaders.delete(this.root); + if (animationRateStates.get(this.root) === this.#rateState) + animationRateStates.delete(this.root); + if (this.root.userData[ANIMATION_RATE_STATE_KEY] === this.#rateState) + delete this.root.userData[ANIMATION_RATE_STATE_KEY]; } } diff --git a/packages/core/src/frame-budget.ts b/packages/core/src/frame-budget.ts index fb4527da7d..83d0106d77 100644 --- a/packages/core/src/frame-budget.ts +++ b/packages/core/src/frame-budget.ts @@ -377,6 +377,8 @@ export interface IFrameBudgetWindow { * `gpuMain + gpuShadow + gpuOther` reconciles with `gpuMs` only when all three are present. */ readonly gpuMain?: number; + /** Fresh frames in the same main-bucket ring as gpuMain; absent when unmeasured. */ + readonly gpuMainSamples?: number; readonly gpuShadow?: number; readonly gpuOther?: number; readonly gpuCompute?: number; @@ -1161,7 +1163,8 @@ export class FrameBudget { const summary = this.#gpuBucketRings[bucket].summarize(this.#scratch); return summary.samples === 0 ? undefined : round1(summary.p50); }; - const gpuMain = gpuBucket("main"); + const mainBucket = this.#gpuBucketRings.main.summarize(this.#scratch); + const gpuMain = mainBucket.samples === 0 ? undefined : round1(mainBucket.p50); const gpuShadow = gpuBucket("shadow"); const gpuOther = gpuBucket("other"); const gpuCompute = gpuBucket("compute"); @@ -1252,6 +1255,7 @@ export class FrameBudget { : { gpuShadowP95: round1(shadowBucket.p95), gpuShadowSamples: shadowBucket.samples }), ...(shadowRendered.samples === 0 ? {} : { gpuShadowRendered: shadowRendered }), ...(gpuPyramid.samples === 0 ? {} : { gpuPyramid }), + ...(mainBucket.samples === 0 ? {} : { gpuMainSamples: mainBucket.samples }), }; } diff --git a/packages/core/src/game.ts b/packages/core/src/game.ts index 4749176949..732393138c 100644 --- a/packages/core/src/game.ts +++ b/packages/core/src/game.ts @@ -1713,6 +1713,17 @@ class GameImpl, TPhysics> // Before anything walks: a frozen subtree whose author moved its root thaws here, so the // walk that follows sees a transform nobody had to remember to announce. refreshStaticTransforms(); + // Camera-rate metadata is an input to scene-owned beforeRender work and the projection + // palette write, not a post-draw observation. Publish it before either can skip a rig; + // this pass observes animation only and does not decide draw visibility. + const drawingBufferHeight = renderer.surface().drawingBufferHeight; + const cullPrepareStart = spans === undefined ? 0 : spanNow(); + this.#cameraCull?.prepareAnimation( + this.#projection?.root ?? threeScene, + camera, + drawingBufferHeight, + ); + if (spans !== undefined) addSpan(SPANS.cull, spanNow() - cullPrepareStart); // Scene prep that must read the frame's last solved state before the projection packs and // the renderer draws. Inside the world-render block it cannot land on a held loader frame. if (this.#beforeRenderCallbacks.size > 0) { @@ -1775,20 +1786,20 @@ class GameImpl, TPhysics> } finally { endSpan(SPANS.lod); } - // Projected-size cull, per render camera, default on. It writes only `object.visible` — - // the one per-frame flag the projection's batch key ignores — and restores what it hid - // immediately after the draw, so the authored scene is untouched between frames. The - // renderer walks the same root the projection is about to draw, so a declined projection - // and an active one both get the gate. - const drawingBufferHeight = renderer.surface().drawingBufferHeight; + const renderRoot = this.#projection?.root ?? threeScene; + // The cull reads the matrices the renderer will draw. Projection exact proxies carry the + // authored world matrix in their local matrix, so their own world walk must precede both + // the cull and the render rather than following the cull. + renderRoot.matrixWorldAutoUpdate = false; + this.#matrixWorld?.apply(renderRoot); + // Visibility is decided against the transforms and LOD selection that will actually be + // drawn. The earlier animation-only pass has already published the rate for this frame. const cullApplyStart = spans === undefined ? 0 : spanNow(); this.#cameraCull?.apply( this.#projection?.root ?? threeScene, camera, drawingBufferHeight, ); - // Added, not bracketed: the render call sits between the apply and the restore, and a - // span that contained it would report the world pass as cull work. if (spans !== undefined) addSpan(SPANS.cull, spanNow() - cullApplyStart); const capturing = geometryCapture.armed(); if (capturing) { @@ -1812,12 +1823,6 @@ class GameImpl, TPhysics> : { backend: rendererBackendIdentity(renderer.raw) as string }), }); } - const renderRoot = this.#projection?.root ?? threeScene; - // three's renderer walks whatever scene it is handed, so it is told not to: the pass above - // is the walk. Handing it the mirror sets the mirror scene's flag; the authored scene was - // marked at construction. - renderRoot.matrixWorldAutoUpdate = false; - this.#matrixWorld?.apply(renderRoot); renderer.render(renderRoot, camera); // *After* the render, because that is where the draw happened. The matrices read here are // the ones drawn: the walk above refreshed them and `render()` did not touch them. The diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b24d8981b..3532bcc4bc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,22 +17,34 @@ export const version = "0.3.4"; * `strideRoot` when the rig is a child of it. Clips authored **in place** — every ActorX and * Unreal export, every Mixamo "in place" clip, every stock animal pack — are matched too: their * stride is read off the ground a planted foot sweeps, and `stride.inPlace` says so. + * Evaluation is automatic by default: `updateRate: "auto"` follows measured camera visibility + * and projected size. `updateRate: 1` evaluates every frame; another positive safe integer names + * that frame interval. `updateRateReport` reports the effective rate, whether a named override is + * active, and whether the latest update evaluated. One-shots and fades stay full-rate; root-motion + * `AnimationComposer` advances every tick, and auto camera-ray targets and camera-parented rigs + * stay full-rate. A public `mixer` `"loop"` listener promotes the player to full-rate and observes + * only loop events after registration, so skipped pre-registration cycles are not replayed; + * `updateRateReport` reports that effective rate. * @situation play an animation on a character * @situation switch a character between idle and attack clips * @situation stop a walking character's feet from sliding or spinning * @situation match a walk or run cycle to how fast a character is moving * @situation match an in-place walk cycle with no root motion to the body's speed * @situation find out what speed an animation clip was authored for + * @situation reduce animation CPU for distant characters + * @situation inspect the effective animation evaluation rate * @constraint name the body a game moves as strideRoot when the animated rig is a child of it * @constraint a one-shot clip always plays at its authored rate; the matched rate re-times loops only * @constraint the matched rate is held inside 0.15x-3x; a speed outside what the clip's own stride supports is clamped, and `stride.rate` says so * @override strideSync controls whether the matched rate is applied while stride is still measured + * @override updateRate: "auto" chooses the camera-based interval; 1 is full evaluation, and another positive safe integer requests that interval; a public `mixer` `"loop"` listener promotes effective evaluation to 1 to preserve event timing * @example const player = new AnimationPlayer({ clips, root: rig, strideRoot: body }); */ export { AnimationPlayer } from "./animation.js"; export type { IAnimationPlayerOptions, IAnimationPlayOptions, + IAnimationUpdateRateReport, IStrideReport, } from "./animation.js"; /** @@ -70,15 +82,25 @@ export { BlendSpace1D, BlendSpace2D } from "./animation-blend.js"; * Instances the rig with a skeleton-safe clone, normalises size with skin-aware measurement, * validates requested clips against the file and rig at load time, and sets up AnimationPlayer * with honest stride-root accounting. + * Animation evaluation inherits AnimationPlayer's automatic camera-based `updateRate: "auto"` + * default; `updateRate: 1` evaluates every frame, another positive safe integer requests that + * interval, and `updateRateReport` exposes the effective rate, override status and latest result. + * One-shots and fades stay full-rate; root-motion `AnimationComposer` advances every tick, and + * auto camera-ray targets and camera-parented rigs stay full-rate. A public `mixer` `"loop"` + * listener promotes effective evaluation to 1 and observes only loop events after registration, + * so skipped pre-registration cycles are not replayed. * @situation put an animated character in the scene * @situation my imported character renders deformed * @situation instance an imported rigged character * @situation validate animation clips on a character rig at load time * @situation prepare a skinned character with safe skeleton cloning and stride sync + * @situation reduce animation CPU for a crowd of imported rigs + * @situation inspect an imported rig's effective animation evaluation rate * @constraint use strideRoot to name the body moved by game code when the rig is parented under it * @constraint requiredClips fails closed at load time if any requested clip is missing or binds 0 tracks * @override strideSync controls whether locomotion playback rate matches ground covered * @override size normalises the instance to real-world metres with skin-aware measurement + * @override updateRate: "auto" chooses the camera-based interval; 1 is full evaluation, and another positive safe integer requests that interval; a public `mixer` `"loop"` listener promotes effective evaluation to 1 to preserve event timing * @example import { SkeletalMesh3D } from "@threenative/core"; * const character = new SkeletalMesh3D({ * source: gltf.scene, clips: gltf.animations, requiredClips: ["idle", "walk"], diff --git a/packages/core/src/playtest.ts b/packages/core/src/playtest.ts index a0432099f4..c3ee913fd8 100644 --- a/packages/core/src/playtest.ts +++ b/packages/core/src/playtest.ts @@ -710,7 +710,8 @@ function objectGraphValues(root: object | undefined): object[] { if (value === null || typeof value !== "object" || visited.has(value)) return; visited.add(value); values.push(value); - if (depth >= 4 || value instanceof Object3D) return; + // Numeric storage is leaf data, not a container of contact adapters. + if (depth >= 4 || value instanceof Object3D || ArrayBuffer.isView(value)) return; for (const child of Object.values(value)) visit(child, depth + 1); }; visit(root, 0); diff --git a/packages/core/src/profiling/Spans.ts b/packages/core/src/profiling/Spans.ts index 7d63cdae39..fc8c102142 100644 --- a/packages/core/src/profiling/Spans.ts +++ b/packages/core/src/profiling/Spans.ts @@ -74,6 +74,10 @@ export const SPANS = { terrainBlock: 14, /** Terrain seam reconciliation and bridge-attribute swap on the calling thread. */ terrainSeam: 15, + /** One actual AnimationPlayer.update call, measured by the crowd fixture. */ + animationUpdate: 16, + /** One actual SkinnedBatch.write call, including its guard and any skeleton/palette work. */ + skinnedWrite: 17, } as const; export type SpanId = (typeof SPANS)[keyof typeof SPANS]; @@ -96,6 +100,8 @@ export const SPAN_NAMES: readonly string[] = [ "draw", "terrainBlock", "terrainSeam", + "animationUpdate", + "skinnedWrite", ]; export const SPAN_COUNT = SPAN_NAMES.length; diff --git a/packages/core/src/projection-apply.ts b/packages/core/src/projection-apply.ts index 11beb6e9d7..4aecc2384f 100644 --- a/packages/core/src/projection-apply.ts +++ b/packages/core/src/projection-apply.ts @@ -55,6 +55,7 @@ import type { ProjectionExactReason, ProjectionMaterialChecks } from "./renderPr /** A transform with no volume. Every triangle drawn through it is degenerate and discarded. */ const ZERO_MATRIX = /* @__PURE__ */ new Matrix4().multiplyScalar(0); +const ANIMATION_RATE_SOURCES_KEY = "threenative.animationRateSources"; /** * The material a uniform batch draws with: the group's representative with its base colour removed. @@ -1340,6 +1341,7 @@ export class ProjectionMirror { proxy.matrixAutoUpdate = false; this.#proxies.set(object, proxy); fresh = true; + proxy.userData[ANIMATION_RATE_SOURCES_KEY] = new Map([[object, 0]]); // An `LOD` picks one of its levels by distance every frame, and it does that on itself. Its // levels therefore have to hang off the stand-in, or nothing selects one and the mirror // draws whichever rung happened to be visible when it was built. diff --git a/packages/core/src/projection-skinned.ts b/packages/core/src/projection-skinned.ts index 17d24150ad..e84dc8b3f7 100644 --- a/packages/core/src/projection-skinned.ts +++ b/packages/core/src/projection-skinned.ts @@ -1,19 +1,25 @@ import { InstancedBufferGeometry, type Material, + Matrix4, Mesh, type Object3D, - type Skeleton, + Skeleton, type SkinnedMesh, } from "three"; import { Fn, + If, attribute, + cos, + float, instanceIndex, + mat4, normalGeometry, normalLocal, positionGeometry, positionPrevious, + sin, storage, tangentGeometry, tangentLocal, @@ -33,6 +39,36 @@ import { StorageBufferAttribute, } from "three/webgpu"; +import { animationPacketField } from "./animation-packet-state.js"; +import { + type IAnimationPosePacket, + type IAnimationPoseVersion, + evaluateAnimationPoseFallback, + evaluateAnimationPosePacket, + readAnimationPosePacket, + readAnimationPosePacketTime, +} from "./animation-pose-packet.js"; +import { SPANS, beginSpan, endSpan } from "./profiling/Spans.js"; + +const skeletonUpdate = Skeleton.prototype.update; +interface IRawDisplaySlot { + current?: IAnimationPosePacket; + previous?: IAnimationPosePacket; + retired: boolean; + writeFrame?: number; + expectedCurrent?: Float32Array; + expectedPrevious?: Float32Array; +} + +const ANIMATION_RATE_SOURCES_KEY = "threenative.animationRateSources"; +const ANIMATION_RATE_STATE_KEY = "threenative.animationRateState"; + +interface IAnimationRateStateLike { + readonly evaluated: boolean; + readonly effectiveRate?: number; + readonly evaluation: number; +} + /** * The skinned lane of the render projection: every rig that shares a geometry and material is one * instanced draw per pass instead of one draw per rig per pass. @@ -196,6 +232,7 @@ export class SkinnedBatch { readonly bones: number; readonly capacity: number; readonly instances = new Map(); + readonly #slotRigs = new Map(); readonly free: number[] = []; used = 0; readonly #current: Float32Array; @@ -206,9 +243,27 @@ export class SkinnedBatch { readonly #fresh: number[] = []; readonly #bindScratch = new Float64Array(16); readonly #updated = new WeakMap(); + // A RAW refresh substitutes for stock's first update. Materialize its CPU basis only + // if a later same-frame stock write actually needs the shared skeleton cache. + readonly #rawUpdates = new WeakMap(); + readonly #writtenEvaluations = new WeakMap(); + readonly #writtenWorldMatrices = new WeakMap(); #frame = 0; #dirty = false; #sourceVersion: number; + readonly #rawSlots = new Map(); + readonly #ownedSkeletons = new Map(); + readonly #skeletonOwners = new Map>(); + #observed = false; + #reconciling = false; + #rawFrame = -1; + #rawDirtyFrame = -1; + #rawMeta = new Float32Array(0); + #rawData = new Float32Array(0); + #rawPrevious = new Float32Array(0); + #rawRoots = new Float32Array(0); + #rawPreviousRoots = new Float32Array(0); + readonly #rawAttributes: StorageBufferAttribute[] = []; constructor(options: ISkinnedBatchOptions) { const { first, capacity, velocity } = options; @@ -255,21 +310,148 @@ export class SkinnedBatch { .add(palette.element(base.add(boneIndex.y)).mul(weight.y)) .add(palette.element(base.add(boneIndex.z)).mul(weight.z)) .add(palette.element(base.add(boneIndex.w)).mul(weight.w)); + // The admitted packet layout is fixed; every other rig retains the stock node. + let rawMatrix: ((previousFrame: boolean) => ReturnType) | undefined; + let rawMeta: ReturnType> | undefined; + if (this.bones === 12) { + this.#rawMeta = new Float32Array(capacity * 4); + this.#rawData = new Float32Array(capacity * 12 * 48); + this.#rawPrevious = new Float32Array(this.#rawData.length); + this.#rawRoots = new Float32Array(capacity * 32); + this.#rawPreviousRoots = new Float32Array(this.#rawRoots.length); + const input = (array: Float32Array, size: number) => { + const a = new StorageBufferAttribute(array, size); + this.#rawAttributes.push(a); + return a; + }; + const metadata = storage(input(this.#rawMeta, 4), "vec4", capacity).toReadOnly(); + rawMeta = metadata; + const raw = storage(input(this.#rawData, 4), "vec4", capacity * 144).toReadOnly(); + const oldRaw = storage(input(this.#rawPrevious, 4), "vec4", capacity * 144).toReadOnly(); + const roots = storage(input(this.#rawRoots, 16), "mat4", capacity * 2).toReadOnly(); + const oldRoots = storage( + input(this.#rawPreviousRoots, 16), + "mat4", + capacity * 2, + ).toReadOnly(); + rawMatrix = (old) => { + const data = old ? oldRaw : raw; + const root = old ? oldRoots : roots; + const time = old ? metadata.element(instanceIndex).w : metadata.element(instanceIndex).y; + const start = instanceIndex.mul(uint(144)); + let world: ReturnType = mat4(); + let blended: ReturnType = mat4(); + for (let b = 0; b < 12; b++) { + const offset = start.add(uint(b * 12)); + const at = (n: number) => data.element(offset.add(uint(n))); + const position = at(8); + const scale = at(9); + const times = at(10); + const values = at(11); + const second = time.greaterThan(times.y); + const t0 = second.select(times.y, times.x); + const t1 = second.select(times.z, times.y); + const v0 = second.select(values.y, values.x); + const v1 = second.select(values.z, values.y); + const z = v0.add(v1.sub(v0).mul(time.sub(t0).div(t1.sub(t0)))); + const c = cos(z); + const sn = sin(z); + const local = + b === 0 + ? mat4(at(0), at(1), at(2), at(3)) + : mat4( + c.mul(scale.x), + sn.mul(scale.x), + 0, + 0, + sn.negate().mul(scale.y), + c.mul(scale.y), + 0, + 0, + 0, + 0, + scale.z, + 0, + position.x, + position.y, + position.z, + 1, + ); + world = ( + b === 0 ? root.element(instanceIndex.mul(uint(2))).mul(local) : world.mul(local) + ).toVar(); + const palette = world.mul(mat4(at(4), at(5), at(6), at(7))); + const w = boneIndex.x + .equal(uint(b)) + .select(weight.x, float(0)) + .add(boneIndex.y.equal(uint(b)).select(weight.y, float(0))) + .add(boneIndex.z.equal(uint(b)).select(weight.z, float(0))) + .add(boneIndex.w.equal(uint(b)).select(weight.w, float(0))); + blended = b === 0 ? palette.mul(w) : blended.add(palette.mul(w)); + } + return root + .element(instanceIndex.mul(uint(2))) + .mul(root.element(instanceIndex.mul(uint(2)).add(uint(1)))) + .mul(blended); + }; + } const hasTangent = source.getAttribute("tangent") !== undefined; const material = new NodeClass(); this.#syncMaterial(material); material.positionNode = Fn(() => { - const matrix = blend(current); + const matrix = mat4().toVar(); + if (rawMatrix && rawMeta) { + const sample = rawMatrix; + If(rawMeta.element(instanceIndex).x.greaterThan(0), () => { + matrix.assign(sample(false)); + }).Else(() => { + matrix.assign(blend(current)); + }); + } else matrix.assign(blend(current)); normalLocal.assign(matrix.mul(vec4(normalGeometry, 0)).xyz); if (hasTangent) tangentLocal.assign(matrix.mul(vec4(tangentGeometry.xyz, 0)).xyz); - if (previous !== undefined) - positionPrevious.assign(blend(previous).mul(vec4(positionGeometry, 1)).xyz); + if (previous !== undefined) { + const oldMatrix = mat4().toVar(); + if (rawMatrix && rawMeta) { + const sample = rawMatrix; + If(rawMeta.element(instanceIndex).z.greaterThan(0), () => { + oldMatrix.assign(sample(true)); + }).Else(() => { + oldMatrix.assign(blend(previous)); + }); + } else oldMatrix.assign(blend(previous)); + positionPrevious.assign(oldMatrix.mul(vec4(positionGeometry, 1)).xyz); + } return matrix.mul(vec4(positionGeometry, 1)).xyz; })(); material.needsUpdate = true; this.mesh = new Mesh(geometry, material); this.mesh.matrixAutoUpdate = false; + this.mesh.userData[ANIMATION_RATE_SOURCES_KEY] = this.instances; + this.mesh.userData["threenative.animationPosePackets"] = () => ({ + capacity: this.capacity, + bones: this.bones, + instanceCount: this.mesh.geometry.instanceCount, + previousEnabled: this.#previous !== undefined, + slots: [...this.instances].map(([rig, slot]) => { + const r = this.#rawSlots.get(slot); + const m = slot * 4; + return { + slot, + currentRaw: this.#rawMeta[m] === 1, + previousRaw: this.#rawMeta[m + 2] === 1, + currentTime: this.#rawMeta[m + 1] ?? 0, + previousTime: this.#rawMeta[m + 3] ?? 0, + retired: r?.retired === true, + ...(this.#writtenEvaluations.has(rig) + ? { currentEvaluation: this.#writtenEvaluations.get(rig) } + : {}), + ...(r?.previous ? { previousEvaluation: r.previous.version.evaluation } : {}), + }; + }), + storage: { meta: Array.from(this.#rawMeta) }, + }); // The palette spans every rig; only the per-rig sources have meaningful bounds. this.mesh.frustumCulled = false; this.mesh.castShadow = first.castShadow; @@ -282,16 +464,25 @@ export class SkinnedBatch { * before any rig writes, so every pass of the frame reads the same pair. */ begin(): void { + this.#reconciling = false; this.#frame += 1; this.#syncMaterial(this.mesh.material); if (this.sourceMaterial.version !== this.#sourceVersion) { this.#sourceVersion = this.sourceMaterial.version; this.mesh.material.needsUpdate = true; } + this.#checkObservedEdits(); if (this.#previous !== undefined) { this.#previous.set(this.#current); this.#dirty = true; + if (this.#observed) + for (const [slot, display] of this.#rawSlots) + display.expectedPrevious = this.#previous.slice( + slot * this.bones * 16, + (slot + 1) * this.bones * 16, + ); } + this.#reconciling = true; } /** Copies the game's current material values onto the draw's twin, as three's own conversion does. */ @@ -313,7 +504,9 @@ export class SkinnedBatch { this.used += 1; } this.instances.set(rig, slot); + this.#slotRigs.set(slot, rig); this.#fresh.push(slot); + this.#trackSkeleton(slot, rig); return slot; } @@ -321,43 +514,321 @@ export class SkinnedBatch { release(rig: Object3D): void { const slot = this.instances.get(rig); if (slot === undefined) return; + this.#untrackSkeleton(slot); this.instances.delete(rig); + this.#slotRigs.delete(slot); + this.#writtenEvaluations.delete(rig); + this.#writtenWorldMatrices.delete(rig); this.hide(slot); + this.#rawSlots.delete(slot); this.free.push(slot); } /** This frame's world-space bone matrices, `capacity × bones` of them in slot order. */ get palette(): Float32Array { + this.#observe(); return this.#current; } /** Last frame's palette, present only while a temporal stage asks for per-object history. */ get history(): Float32Array | undefined { + if (this.#previous !== undefined) this.#observe(); return this.#previous; } /** Marks a slot whose history must restart from its next pose. */ restart(slot: number): void { + this.#invalidate(slot); this.#fresh.push(slot); } /** Collapses one slot for this frame; a hidden rig keeps its slot. */ hide(slot: number): void { + this.#invalidate(slot); const stride = this.bones * 16; this.#current.fill(0, slot * stride, (slot + 1) * stride); this.#previous?.fill(0, slot * stride, (slot + 1) * stride); + const display = this.#rawSlots.get(slot); + if (display) { + display.current = undefined; + display.previous = undefined; + } + if (this.#rawMeta.length) { + this.#rawMeta.fill(0, slot * 4, slot * 4 + 4); + this.#markRawDirty(); + } this.#dirty = true; } + #invalidate(slot: number): void { + const rig = this.#slotRigs.get(slot); + if (rig === undefined) return; + this.#writtenEvaluations.delete(rig); + this.#writtenWorldMatrices.delete(rig); + } + /** * Writes one rig's world-space bone palette. Equals stock skinning's * `matrixWorld · bindMatrixInverse · boneMatrix · bindMatrix` per bone; in the default attached * bind mode `matrixWorld · bindMatrixInverse` is the identity and is skipped. */ write(slot: number, rig: SkinnedMesh): void { + beginSpan(SPANS.skinnedWrite); + try { + this.#checkObservedEdits(slot); + const state = animationRateStateOf(rig); + // Named rate1 and ordinary stock owners never pay the capture/query path. + if (this.#rawMeta.length && ((state?.effectiveRate ?? 1) > 1 || this.#rawSlots.has(slot))) { + this.#prepareRawFrame(); + this.#trackSkeleton(slot, rig, true); + let display = this.#rawSlots.get(slot); + if (!display) { + display = { retired: false }; + this.#rawSlots.set(slot, display); + } + const held = + state !== undefined && + !state.evaluated && + this.#writtenEvaluations.get(rig) === state.evaluation && + this.#writtenWorldMatrices.get(rig)?.equals(rig.matrixWorld); + const skeleton = animationPacketField(rig, "skeleton"); + const update = skeleton instanceof Skeleton ? this.#method(skeleton, "update") : undefined; + const shared = + skeleton instanceof Skeleton && (this.#skeletonOwners.get(skeleton)?.size ?? 0) > 1; + // First eligibility on a held tick must wait for a genuine stock refresh. + if (held && !display.current && !display.retired && update === skeletonUpdate && !shared) { + this.#writeStock(slot, rig, state); + return; + } + const repeatedRefresh = !held && display.writeFrame === this.#frame; + const packet = + !repeatedRefresh && + !display.retired && + (state?.effectiveRate ?? 1) > 1 && + update === skeletonUpdate && + !shared + ? held && display.current + ? readAnimationPosePacketTime(rig, display.current.version) + : readAnimationPosePacket(rig) + : undefined; + if (packet && state) { + display.writeFrame = this.#frame; + if (!held && skeleton instanceof Skeleton) + this.#rawUpdates.set(skeleton, { frame: this.#frame, version: packet.version }); + const changed = display.current?.version !== packet.version; + display.current = packet; + this.#packRaw(slot, packet, false, changed); + if (this.#fresh.includes(slot)) this.#packRaw(slot, packet, true); + this.#writtenEvaluations.set(rig, state.evaluation); + const world = this.#writtenWorldMatrices.get(rig) ?? new Matrix4(); + world.copy(rig.matrixWorld); + this.#writtenWorldMatrices.set(rig, world); + this.#syncObserved(slot, display); + return; + } + if (held && display.current) + this.#current.set( + evaluateAnimationPoseFallback(display.current.version), + slot * this.bones * 16, + ); + display.current = undefined; + display.retired ||= (state?.effectiveRate ?? 1) > 1 || update !== skeletonUpdate || shared; + this.#rawMeta[slot * 4] = 0; + this.#rawMeta[slot * 4 + 1] = 0; + this.#markRawDirty(); + this.#dirty = true; + } + this.#writeStock(slot, rig, state); + const display = this.#rawSlots.get(slot); + if (display) this.#syncObserved(slot, display); + } finally { + endSpan(SPANS.skinnedWrite); + } + } + + #method(object: object, key: string): unknown { + for (let owner: object | null = object; owner !== null; owner = Object.getPrototypeOf(owner)) { + const d = Object.getOwnPropertyDescriptor(owner, key); + if (d) return Object.hasOwn(d, "value") ? d.value : undefined; + } + return undefined; + } + + #prepareRawFrame(): void { + if (this.#rawFrame === this.#frame) return; + this.#rawFrame = this.#frame; + for (const [slot, display] of this.#rawSlots) { + if (display.current && display.previous?.version !== display.current.version) { + this.#rawPrevious.set(this.#rawData.subarray(slot * 576, (slot + 1) * 576), slot * 576); + this.#rawPreviousRoots.set(this.#rawRoots.subarray(slot * 32, (slot + 1) * 32), slot * 32); + } + display.previous = display.current; + this.#rawMeta[slot * 4 + 2] = this.#rawMeta[slot * 4] as number; + this.#rawMeta[slot * 4 + 3] = this.#rawMeta[slot * 4 + 1] as number; + } + this.#markRawDirty(); + } + + #markRawDirty(force = false): void { + if (!force && this.#rawDirtyFrame === this.#frame) return; + this.#rawDirtyFrame = this.#frame; + for (const attribute of this.#rawAttributes) attribute.needsUpdate = true; + } + + #packRaw(slot: number, packet: IAnimationPosePacket, old: boolean, changed = true): void { + const data = old ? this.#rawPrevious : this.#rawData; + const roots = old ? this.#rawPreviousRoots : this.#rawRoots; + const meta = slot * 4 + (old ? 2 : 0); + const v = packet.version; + this.#rawMeta[meta] = 1; + this.#rawMeta[meta + 1] = packet.time; + if (changed) { + roots.set(v.rootWorld, slot * 32); + roots.set(v.bindInverse, slot * 32 + 16); + for (let b = 0; b < 12; b++) { + const at = (slot * 12 + b) * 48; + const rest = v.rest[b]; + const key = v.keys[b]; + const inverse = v.bindInverses[b]; + if (!rest || !inverse) throw new Error("incomplete owned raw pose"); + data.set(rest.local, at); + data.set(inverse, at + 16); + data.set([...rest.position, b === 0 ? 0 : 1, ...rest.scale, 0], at + 32); + data.set(key ? [...key.times, 0, ...key.values, 0] : [0, 1, 2, 0, 0, 0, 0, 0], at + 40); + } + } + this.#markRawDirty(); + } + + #checkObservedEdits(only?: number): void { + if (!this.#observed) return; + for (const [slot, display] of this.#rawSlots) { + if (only !== undefined && slot !== only) continue; + if (this.#rawMeta[slot * 4] !== 1 && this.#rawMeta[slot * 4 + 2] !== 1) continue; + const changed = (actual: Float32Array | undefined, expected: Float32Array | undefined) => + expected !== undefined && + actual !== undefined && + expected.some((v, i) => !Object.is(v, actual[slot * this.bones * 16 + i])); + if ( + !changed(this.#current, display.expectedCurrent) && + !changed(this.#previous, display.expectedPrevious) + ) + continue; + display.current = undefined; + display.previous = undefined; + display.retired = true; + display.expectedCurrent = undefined; + display.expectedPrevious = undefined; + this.#rawMeta.fill(0, slot * 4, slot * 4 + 4); + this.#markRawDirty(true); + this.#currentAttribute.needsUpdate = true; + if (this.#previousAttribute) this.#previousAttribute.needsUpdate = true; + this.#dirty = true; + } + } + + #syncObserved(slot: number, display: IRawDisplaySlot): void { + if (!this.#observed) return; + const offset = slot * this.bones * 16; + if (display.current) { + const matrix = evaluateAnimationPosePacket(display.current); + this.#current.set(matrix, offset); + display.expectedCurrent = matrix; + } else display.expectedCurrent = this.#current.slice(offset, offset + this.bones * 16); + display.expectedPrevious = this.#previous?.slice(offset, offset + this.bones * 16); + this.#dirty = true; + } + + #observe(): void { + this.#checkObservedEdits(); + if (this.#observed) return; + this.#prepareRawFrame(); + for (const [slot, display] of this.#rawSlots) { + const offset = slot * this.bones * 16; + if (display.current) this.#current.set(evaluateAnimationPosePacket(display.current), offset); + const previous = + display.previous ?? + (!this.#fresh.includes(slot) && this.#rawMeta[slot * 4 + 2] === 1 + ? display.current + : undefined); + if (previous && this.#previous) + this.#previous.set(evaluateAnimationPosePacket(previous), offset); + display.expectedCurrent = this.#current.slice(offset, offset + this.bones * 16); + display.expectedPrevious = this.#previous?.slice(offset, offset + this.bones * 16); + } + this.#observed = true; + this.#currentAttribute.needsUpdate = true; + if (this.#previousAttribute) this.#previousAttribute.needsUpdate = true; + this.#dirty = true; + } + + #untrackSkeleton(slot: number): void { + const skeleton = this.#ownedSkeletons.get(slot); + if (!skeleton) return; + const owners = this.#skeletonOwners.get(skeleton); + owners?.delete(slot); + if (!owners?.size) this.#skeletonOwners.delete(skeleton); + this.#ownedSkeletons.delete(slot); + } + + #trackSkeleton(slot: number, rig: Object3D, charged = false): void { + const skeleton = animationPacketField(rig, "skeleton"); + if (this.#ownedSkeletons.get(slot) === skeleton && !charged) return; + if (this.#ownedSkeletons.get(slot) !== skeleton) this.#untrackSkeleton(slot); + if (!(skeleton instanceof Skeleton)) return; + this.#ownedSkeletons.set(slot, skeleton); + let owners = this.#skeletonOwners.get(skeleton); + if (!owners) { + owners = new Set(); + this.#skeletonOwners.set(skeleton, owners); + } + owners.add(slot); + if (owners.size < 2 || ![...owners].some((owner) => this.#rawSlots.get(owner)?.current)) return; + // Between reconciles, retain the last published RAW pose until begin advances its history. + if (!charged && !this.#reconciling) return; + // Late sharing must also revoke an already-written RAW owner before this draw. + if (!charged) beginSpan(SPANS.skinnedWrite); + try { + this.#prepareRawFrame(); + for (const owner of owners) { + const display = this.#rawSlots.get(owner); + if (!display) continue; + if (display.current) + this.#current.set( + evaluateAnimationPoseFallback(display.current.version), + owner * this.bones * 16, + ); + display.current = undefined; + display.retired = true; + this.#syncObserved(owner, display); + this.#rawMeta[owner * 4] = 0; + this.#rawMeta[owner * 4 + 1] = 0; + this.#dirty = true; + } + this.#markRawDirty(); + } finally { + if (!charged) endSpan(SPANS.skinnedWrite); + } + } + + #writeStock(slot: number, rig: SkinnedMesh, state: IAnimationRateStateLike | undefined): void { + const writtenWorld = this.#writtenWorldMatrices.get(rig); + if ( + state !== undefined && + !state.evaluated && + this.#writtenEvaluations.get(rig) === state.evaluation && + writtenWorld?.equals(rig.matrixWorld) + ) + return; const skeleton = rig.skeleton; if (this.#updated.get(skeleton) !== this.#frame) { - skeleton.update(); + const captured = this.#rawUpdates.get(skeleton); + if (captured?.frame === this.#frame) { + (skeleton.boneMatrices as Float32Array).set( + evaluateAnimationPoseFallback(captured.version), + ); + if (skeleton.boneTexture !== null) skeleton.boneTexture.needsUpdate = true; + } else skeleton.update(); this.#updated.set(skeleton, this.#frame); } const bones = skeleton.boneMatrices as Float32Array; @@ -385,10 +856,18 @@ export class SkinnedBatch { } } this.#dirty = true; + if (state !== undefined) { + this.#writtenEvaluations.set(rig, state.evaluation); + const nextWorld = writtenWorld ?? new Matrix4(); + nextWorld.copy(rig.matrixWorld); + this.#writtenWorldMatrices.set(rig, nextWorld); + } } /** Ends one reconcile: brand-new slots get history equal to their pose, then the palette uploads. */ end(): void { + this.#reconciling = false; + this.#checkObservedEdits(); const stride = this.bones * 16; if (this.#previous !== undefined) { for (const slot of this.#fresh) @@ -397,6 +876,12 @@ export class SkinnedBatch { slot * stride, ); } + if (this.#observed && this.#previous) + for (const slot of this.#fresh) { + const display = this.#rawSlots.get(slot); + if (display) + display.expectedPrevious = this.#previous.slice(slot * stride, (slot + 1) * stride); + } this.#fresh.length = 0; this.mesh.geometry.instanceCount = this.used; if (!this.#dirty) return; @@ -413,7 +898,27 @@ export class SkinnedBatch { this.mesh.removeFromParent(); this.#currentAttribute.dispose(); this.#previousAttribute?.dispose(); + for (const attribute of this.#rawAttributes) attribute.dispose(); this.mesh.material.dispose(); this.instances.clear(); + this.#slotRigs.clear(); + this.mesh.userData["threenative.animationPosePackets"] = undefined; + this.#rawSlots.clear(); + this.#ownedSkeletons.clear(); + this.#skeletonOwners.clear(); + } +} + +function animationRateStateOf(object: Object3D): IAnimationRateStateLike | undefined { + for (let node: Object3D | null = object; node !== null; node = node.parent) { + const value = node.userData[ANIMATION_RATE_STATE_KEY]; + if ( + typeof value === "object" && + value !== null && + typeof (value as IAnimationRateStateLike).evaluated === "boolean" && + typeof (value as IAnimationRateStateLike).evaluation === "number" + ) + return value as IAnimationRateStateLike; } + return undefined; } diff --git a/packages/core/src/render-camera-cull.ts b/packages/core/src/render-camera-cull.ts index c99f491612..aa098fd39e 100644 --- a/packages/core/src/render-camera-cull.ts +++ b/packages/core/src/render-camera-cull.ts @@ -1,4 +1,4 @@ -import { type Camera, Frustum, Matrix4, type Object3D, Vector3 } from "three"; +import { type Camera, Frustum, Matrix4, type Object3D, Ray, Sphere, Vector3 } from "three"; import { isRenderable } from "./projection-plan.js"; @@ -63,6 +63,7 @@ const DYNAMIC_BOUNDS = Symbol("render-camera-cull-dynamic-bounds"); /** What the gate remembers about a geometry's position buffer between consults. */ interface IPositionVersion { version: number; + frame: number; /** True when the previous consult also saw a change, so this one is the second in a row. */ changedLastConsult: boolean; /** True when a rewrite was exempted without a rescan, so the cached sphere is stale. */ @@ -72,6 +73,21 @@ interface IPositionVersion { /** A sphere nearer than this to the camera would divide by zero; treat it as this far away. */ const MIN_DISTANCE = 1e-4; +/** Internal mirror metadata: a skinned batch carries its authored source rigs to the camera gate. */ +const ANIMATION_RATE_SOURCES_KEY = "threenative.animationRateSources"; + +/** Internal observations shared with AnimationPlayer, owned by the root rather than the game. */ +export interface IAnimationRateState { + rate: number; + /** Cadence actually selected by the player on its last update; absent metadata stays stock. */ + effectiveRate?: number; + frame: number; + source: RenderCameraCull | undefined; + evaluated: boolean; + evaluation: number; +} +export const animationRateStates = new WeakMap(); + export interface IRenderCameraCullOptions { /** * Projected diameter in pixels below which an object is skipped, or `false` to leave every @@ -141,12 +157,16 @@ export function alwaysRender(object: Object3D, enabled = true): void { * all owned by the instance. */ export class RenderCameraCull { + #animationFrame = 0; + #animationPrepared = false; + readonly #animationSphere = new Sphere(); readonly #enabled: boolean; readonly #minimumPixels: number; readonly #center = new Vector3(); readonly #frustum = new Frustum(); readonly #projectionScreen = new Matrix4(); readonly #hidden: Object3D[] = []; + readonly #animationVisitor: (object: Object3D) => void; readonly #visitor: (object: Object3D) => void; /** Last position-buffer version seen per geometry, so a rewritten dynamic bound is recomputed. */ readonly #positionVersions = new WeakMap(); @@ -157,6 +177,7 @@ export class RenderCameraCull { #cameraZ = 0; #scale = 0; #cameraResolved = true; + readonly #animationRay = new Ray(); #considered = 0; #culled = 0; #exemptCameraAttached = 0; @@ -175,6 +196,7 @@ export class RenderCameraCull { } this.#enabled = requested !== false; this.#minimumPixels = requested === false ? DEFAULT_MINIMUM_PROJECTED_PIXELS : requested; + this.#animationVisitor = (object) => this.#visitAnimation(object); this.#visitor = (object) => this.#visit(object); } @@ -195,6 +217,28 @@ export class RenderCameraCull { }; } + get animationFrame(): number { + return this.#animationFrame; + } + + /** + * Publishes camera-driven animation metadata without changing draw visibility. + * + * The game calls this before scene `beforeRender` callbacks and projection packing, then calls + * {@link apply} after those phases for the one visibility cull that feeds the draw. + */ + prepareAnimation( + root: { traverseVisible(callback: (object: Object3D) => void): void }, + camera: Camera, + viewportHeight: number, + ): void { + this.restore(); + this.#animationFrame += 1; + this.#animationPrepared = true; + this.#prepareCamera(camera, viewportHeight); + root.traverseVisible(this.#animationVisitor); + } + /** * Hides every renderable under `root` that this camera resolves to fewer than the threshold, * then calls `restore` implicitly on the next call. Call {@link restore} after the render. @@ -209,6 +253,8 @@ export class RenderCameraCull { viewportHeight: number, ): void { this.restore(); + if (!this.#animationPrepared) this.#animationFrame += 1; + this.#animationPrepared = false; this.#considered = 0; this.#culled = 0; this.#exemptCameraAttached = 0; @@ -217,6 +263,25 @@ export class RenderCameraCull { this.#exemptWithoutBounds = 0; this.#exemptDynamicBounds = 0; this.#exemptFrustumCulled = 0; + this.#prepareCamera(camera, viewportHeight); + root.traverseVisible(this.#visitor); + } + + /** Undoes every hide this gate made, leaving the authored scene as the game left it. */ + restore(): void { + for (let index = 0; index < this.#hiddenCount; index += 1) { + (this.#hidden[index] as Object3D).visible = true; + } + this.#hiddenCount = 0; + } + + dispose(): void { + this.restore(); + this.#animationPrepared = false; + this.#camera = undefined; + } + + #prepareCamera(camera: Camera, viewportHeight: number): void { this.#cameraResolved = true; this.#camera = undefined; // The walk runs whether or not the gate is enabled: turning the convention off must not turn @@ -242,35 +307,46 @@ export class RenderCameraCull { this.#cameraX = elements[12] as number; this.#cameraY = elements[13] as number; this.#cameraZ = elements[14] as number; + this.#animationRay.origin.set(this.#cameraX, this.#cameraY, this.#cameraZ); + this.#animationRay.direction + .set(-(elements[8] as number), -(elements[9] as number), -(elements[10] as number)) + .normalize(); // The main frustum is what tells an off-camera shadow caster from a visible but unresolvable // one. Perspective cameras keep `matrixWorldInverse` in step inside `updateMatrixWorld`. this.#projectionScreen.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); this.#frustum.setFromProjectionMatrix(this.#projectionScreen); } - root.traverseVisible(this.#visitor); } - /** Undoes every hide this gate made, leaving the authored scene as the game left it. */ - restore(): void { - for (let index = 0; index < this.#hiddenCount; index += 1) { - (this.#hidden[index] as Object3D).visible = true; + #visitAnimation(object: Object3D): void { + if (!isRenderable(object)) return; + this.#observeAnimationSources(object); + if (this.#isCameraAttached(object)) { + this.#observeAnimation(object, undefined); + return; } - this.#hiddenCount = 0; - } - - dispose(): void { - this.restore(); - this.#camera = undefined; + if (object.userData[ALWAYS_RENDER_KEY] === true) { + this.#observeAnimation(object, undefined); + return; + } + if (object.frustumCulled === false) { + this.#observeAnimation(object, undefined); + return; + } + this.#observeAnimation(object, boundsOf(object, this.#positionVersions, this.#animationFrame)); } #visit(object: Object3D): void { if (!isRenderable(object)) return; this.#considered += 1; + this.#observeAnimationSources(object); if (this.#isCameraAttached(object)) { + this.#observeAnimation(object, undefined); this.#exemptCameraAttached += 1; return; } if (object.userData[ALWAYS_RENDER_KEY] === true) { + this.#observeAnimation(object, undefined); this.#exemptMarked += 1; return; } @@ -278,6 +354,7 @@ export class RenderCameraCull { // trusted — the pooled tracer, particle and whitewater batches set it for exactly that // reason. A second, stricter cull does not get to override the opt-out. if (object.frustumCulled === false) { + this.#observeAnimation(object, undefined); this.#exemptFrustumCulled += 1; return; } @@ -287,7 +364,8 @@ export class RenderCameraCull { // cheaply enumerable here, so where the gate cannot know, it keeps the caster. A caster the // main camera *can* see is ordinary geometry — its shadow is in view with it, and a // sub-resolution object casts a sub-resolution shadow. - const sphere = boundsOf(object, this.#positionVersions); + const sphere = boundsOf(object, this.#positionVersions, this.#animationFrame); + this.#observeAnimation(object, sphere); // A buffer rewritten every frame has extents the gate cannot know without a full scan each // frame, which costs more than the draw it might remove. Keep it drawn, like an opt-out. if (sphere === DYNAMIC_BOUNDS) { @@ -315,8 +393,7 @@ export class RenderCameraCull { const dz = this.#center.z - this.#cameraZ; const distance = Math.sqrt(dx * dx + dy * dy + dz * dz); const worldRadius = sphere.radius * object.matrixWorld.getMaxScaleOnAxis(); - const projectedDiameter = - (2 * worldRadius * this.#scale) / Math.max(distance - worldRadius, MIN_DISTANCE); + const projectedDiameter = projectedDiameterPixels(worldRadius, distance, this.#scale); if (projectedDiameter >= this.#minimumPixels) return; object.visible = false; this.#hidden[this.#hiddenCount] = object; @@ -324,6 +401,80 @@ export class RenderCameraCull { this.#culled += 1; } + #observeAnimation( + object: Object3D, + sphere: IBoundingSphereLike | typeof DYNAMIC_BOUNDS | undefined, + notRendered = false, + ): void { + for (let root: Object3D | null = object; root !== null; root = root.parent) { + const state = animationRateStates.get(root); + if (state === undefined) continue; + let rate = notRendered ? 8 : 1; + if ( + !notRendered && + this.#cameraResolved && + sphere !== undefined && + sphere !== DYNAMIC_BOUNDS && + Number.isFinite(sphere.radius) && + sphere.radius > 0 + ) { + this.#center.copy(sphere.center).applyMatrix4(object.matrixWorld); + const radius = sphere.radius * object.matrixWorld.getMaxScaleOnAxis(); + // A known bound on the camera's forward centre ray is the conservative observable target + // signal. Test each source independently so a nearer bound cannot hide a farther target. + if (this.#animationRay.intersectsSphere(this.#animationSphere.set(this.#center, radius))) + rate = 1; + else { + const distance = Math.hypot( + this.#center.x - this.#cameraX, + this.#center.y - this.#cameraY, + this.#center.z - this.#cameraZ, + ); + const pixels = projectedDiameterPixels(radius, distance, this.#scale); + // PRD-570's provisional pixel bands; the visual gate still has to earn these thresholds. + rate = this.#frustum.intersectsSphere(this.#animationSphere.set(this.#center, radius)) + ? pixels >= 128 + ? 1 + : pixels >= 32 + ? 2 + : pixels >= 8 + ? 4 + : 8 + : 8; + } + } + if (state.source === this && state.frame === this.#animationFrame) + state.rate = Math.min(state.rate, rate); + else state.rate = rate; + state.source = this; + state.frame = this.#animationFrame; + } + } + + #observeAnimationSources(object: Object3D): void { + const sources = object.userData[ANIMATION_RATE_SOURCES_KEY]; + if (!(sources instanceof Map)) return; + for (const source of sources.keys()) { + const visible = this.#isVisibleInWorld(source); + if (this.#isCameraAttached(source)) { + this.#observeAnimation(source, undefined); + continue; + } + this.#observeAnimation( + source, + visible ? boundsOf(source, this.#positionVersions, this.#animationFrame) : undefined, + !visible, + ); + } + } + + #isVisibleInWorld(object: Object3D): boolean { + for (let node: Object3D | null = object; node !== null; node = node.parent) { + if (!node.visible) return false; + } + return true; + } + #isCameraAttached(object: Object3D): boolean { const camera = this.#camera; if (camera === undefined) return false; @@ -334,9 +485,19 @@ export class RenderCameraCull { } } +/** Pure projected-diameter formula shared by the draw gate and per-rig animation policy. */ +export function projectedDiameterPixels( + radius: number, + distance: number, + pixelsPerWorldUnit: number, +): number { + return (2 * radius * pixelsPerWorldUnit) / Math.max(distance - radius, MIN_DISTANCE); +} + function boundsOf( object: Object3D, versions: WeakMap, + frame: number, ): IBoundingSphereLike | typeof DYNAMIC_BOUNDS | undefined { const cullable = object as ICullable; // An `InstancedMesh` carries its own instance-aware bound; the geometry one is not it. @@ -347,16 +508,20 @@ function boundsOf( if (geometry.boundingSphere == null) { geometry.computeBoundingSphere?.(); if (version !== undefined) - versions.set(geometry, { version, changedLastConsult: false, stale: false }); + versions.set(geometry, { version, frame, changedLastConsult: false, stale: false }); return geometry.boundingSphere ?? undefined; } if (version === undefined) return geometry.boundingSphere ?? undefined; const known = versions.get(geometry); if (known === undefined) { - versions.set(geometry, { version, changedLastConsult: false, stale: false }); + versions.set(geometry, { version, frame, changedLastConsult: false, stale: false }); return geometry.boundingSphere ?? undefined; } + const sameFrame = known.frame === frame; + known.frame = frame; if (known.version === version) { + // Animation preparation and draw culling share one frame's observation. + if (sameFrame) return known.stale ? DYNAMIC_BOUNDS : (geometry.boundingSphere ?? undefined); // A buffer that settled after a per-frame rewrite has knowable extents again: scan it once and // cull it like any other, rather than exempting it for the rest of the run. Settling also lets // the next change count as a fresh one rather than as the second frame of a rewrite. diff --git a/packages/core/src/skeletal-mesh.ts b/packages/core/src/skeletal-mesh.ts index 29a41b57d2..d71f96aae8 100644 --- a/packages/core/src/skeletal-mesh.ts +++ b/packages/core/src/skeletal-mesh.ts @@ -11,6 +11,7 @@ export interface ISkeletalMesh3DOptions { readonly size?: INormaliseToMetresOptions; readonly strideRoot?: Object3D; readonly strideSync?: boolean; + readonly updateRate?: "auto" | number; } export class SkeletalMesh3D extends AnimationPlayer { @@ -24,6 +25,7 @@ export class SkeletalMesh3D extends AnimationPlayer { root, strideRoot: options.strideRoot ?? root, strideSync: options.strideSync, + updateRate: options.updateRate, preparation: rigPreparation(options.source), }); if (options.size !== undefined) { diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 6c6de1cf0a..5176861975 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -249,24 +249,21 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // PRD-560: fingerprints include both height fog and PRD-571 histogram exposure. // Recomputed 2026-10-11 on the merge of develop e6ed27869 into PRD-563: measured through // createProject on the merged tree. - // PRD-566/569/609 merge: measured all thirteen trees; action-rpg carries emitParticle bursts and onCull instructions. - // Recomputed 2026-10-13: the onCull paragraph in action-rpg AGENTS.md folded to one line to meet the 100-line budget. - // PRD-565: only the copied canonical Three patch changes; substituting its parent bytes - // recovers all thirteen prior hashes. Measured through the actual createProject tree test. - "action-rpg": "2ad57690b87fe80faa26931ea11d6ea210ac787bb58c7bb0f83e7b653426eeb7", - minimal: "6e9d255a6033e239e73dfabec172dfebb4a88849c01ca461d476ab8b17b91cbd", - platformer: "2ccf93236a0d6c1b8aa00bd3f86585427149f3788640dccb491f8d451c2c6894", - puzzle: "a25a9129f070b8a4cd25c1d3a174950b0367eba85d5a414b62325a571e2c40f3", - racing: "0fb3b6a6eacfc54acd74ae086cb5502789fdfe83e0466306202053843314f663", - rain: "9523312a5c0573b6ae886bd63ade36922a86f7a9b8e3730f7ead743609357bd7", - rts: "8a8d80b543d77717243274896b5b7a6a723b92e3a3f252fd470b2fb6dcfc12c8", - runner: "b14123d911dc3eb7d73e1ab3f91269b480a028938efff4a2bea4f56f2e570906", + // PRD-570 + PRD-565: all thirteen measured trees combine animation guidance and the canonical Three patch. + "action-rpg": "3c9e98d1ded0b7b1fd908b6b0ebe3fcb52a2b6bdf6bacbf5a1dcb02db191afc8", + minimal: "e21ddfb6359cdf4f0b3c52613d44c37ba53b7bf5157e36de2a5c5671da60ef8b", + platformer: "88718002367665831dce314b53d1e25e5222712e9dfc87c4fdd09c9728a71d4c", + puzzle: "e33b07123c243c5e8f12ad012558cc4a8513a4355d85502aaea362f3af9412c4", + racing: "efc2f465899f1b119fe0d1bbc47eebef59a2cde5d350fed10df57cec0549dbf2", + rain: "75e267e10af895474649fb9e19c5f71d2def556089814f0d2e0c0904fb10a93a", + rts: "fc2c9401fc6c40801a7b43e05ddbdd85ba7b489b96f7d1a27dde1dc4101d0c4c", + runner: "266f26349f6ba763f25b58ac496df7c9b3e751fd0ce862cc9c5cc6ca7226c17b", // Initial finite-height readiness plus its scene-owned lifecycle helper and mirrored docs. - sailing: "73da167ba1fdc6360c58e61b51a2f3dba047fbe1c4e88648e9bce4abb64325da", - shooter: "ca09b0b25f3ba689af53772d82be0978d82cdaff8f997b04b160874428ec3e54", - snow: "482c1c7f1cd2f233d43cc3fd378c983b4ab8f13dca4bd6fca008e5b120cb0705", - starter: "74df32f5ea8f1aeeea4d2eaf9c423f2fca93fbb1fa392de7acdb6b8bd994db8c", - "tower-defense": "7d816e530aee8842027a1666782d96c51b2cbfde55c4c23a7cdd1b0fd6a267ac", + sailing: "16498c84b1114d84b2a992e25511072ed63a4a8d1d670f244b7af92851e4d0a9", + shooter: "377d02a8f5d7f3c14e5959aa031d835e7d7f031c6bfd6af691cdcfd8abfda42f", + snow: "9a6c5f9b287e52e6b03fd5801b497a58bb72c150d7ef565515430244af504e49", + starter: "8089e83fa57cb18b990f9806d02d517d0e50765918207697981545be51ebd8da", + "tower-defense": "061e7f4efadfaee981bed31998de689de449b2afb4e614362b9d5d061712d5f2", }; const GENERATED_SCAFFOLD_METADATA = diff --git a/packages/create-threenative/agent-docs/references/capability-reference.md b/packages/create-threenative/agent-docs/references/capability-reference.md index ca37b8710a..43a17b2694 100644 --- a/packages/create-threenative/agent-docs/references/capability-reference.md +++ b/packages/create-threenative/agent-docs/references/capability-reference.md @@ -549,15 +549,15 @@ animation.setWeights([0.5, 0.5]); animation.update(dt); ### `AnimationPlayer` -`class` — Play a skinned or sprite animation from game code. A locomotion clip's playback rate is matched to the ground the body actually covers, so feet do not skate or spin — on by default, `strideSync: false` to keep the authored rate, and `player.stride` reports the measurement either way. Name the body a game moves as `strideRoot` when the rig is a child of it. Clips authored **in place** — every ActorX and Unreal export, every Mixamo "in place" clip, every stock animal pack — are matched too: their stride is read off the ground a planted foot sweeps, and `stride.inPlace` says so. +`class` — Play a skinned or sprite animation from game code. A locomotion clip's playback rate is matched to the ground the body actually covers, so feet do not skate or spin — on by default, `strideSync: false` to keep the authored rate, and `player.stride` reports the measurement either way. Name the body a game moves as `strideRoot` when the rig is a child of it. Clips authored **in place** — every ActorX and Unreal export, every Mixamo "in place" clip, every stock animal pack — are matched too: their stride is read off the ground a planted foot sweeps, and `stride.inPlace` says so. Evaluation is automatic by default: `updateRate: "auto"` follows measured camera visibility and projected size. `updateRate: 1` evaluates every frame; another positive safe integer names that frame interval. `updateRateReport` reports the effective rate, whether a named override is active, and whether the latest update evaluated. One-shots and fades stay full-rate; root-motion `AnimationComposer` advances every tick, and auto camera-ray targets and camera-parented rigs stay full-rate. A public `mixer` `"loop"` listener promotes the player to full-rate and observes only loop events after registration, so skipped pre-registration cycles are not replayed; `updateRateReport` reports that effective rate. ```ts export class AnimationPlayer { … } ``` -- **Use when:** play an animation on a character · switch a character between idle and attack clips · stop a walking character's feet from sliding or spinning · match a walk or run cycle to how fast a character is moving · match an in-place walk cycle with no root motion to the body's speed · find out what speed an animation clip was authored for +- **Use when:** play an animation on a character · switch a character between idle and attack clips · stop a walking character's feet from sliding or spinning · match a walk or run cycle to how fast a character is moving · match an in-place walk cycle with no root motion to the body's speed · find out what speed an animation clip was authored for · reduce animation CPU for distant characters · inspect the effective animation evaluation rate - **Constraints:** name the body a game moves as strideRoot when the animated rig is a child of it · a one-shot clip always plays at its authored rate; the matched rate re-times loops only · the matched rate is held inside 0.15x-3x; a speed outside what the clip's own stride supports is clamped, and `stride.rate` says so -- **Overrides:** strideSync controls whether the matched rate is applied while stride is still measured +- **Overrides:** strideSync controls whether the matched rate is applied while stride is still measured · updateRate: "auto" chooses the camera-based interval; 1 is full evaluation, and another positive safe integer requests that interval; a public `mixer` `"loop"` listener promotes effective evaluation to 1 to preserve event timing ```ts const player = new AnimationPlayer({ clips, root: rig, strideRoot: body }); @@ -2231,15 +2231,15 @@ if (spansRequested()) setSpanRecorder(new SpanRecorder()); ### `SkeletalMesh3D` -`class` — Shared preparation for an imported rigged character. Instances the rig with a skeleton-safe clone, normalises size with skin-aware measurement, validates requested clips against the file and rig at load time, and sets up AnimationPlayer with honest stride-root accounting. +`class` — Shared preparation for an imported rigged character. Instances the rig with a skeleton-safe clone, normalises size with skin-aware measurement, validates requested clips against the file and rig at load time, and sets up AnimationPlayer with honest stride-root accounting. Animation evaluation inherits AnimationPlayer's automatic camera-based `updateRate: "auto"` default; `updateRate: 1` evaluates every frame, another positive safe integer requests that interval, and `updateRateReport` exposes the effective rate, override status and latest result. One-shots and fades stay full-rate; root-motion `AnimationComposer` advances every tick, and auto camera-ray targets and camera-parented rigs stay full-rate. A public `mixer` `"loop"` listener promotes effective evaluation to 1 and observes only loop events after registration, so skipped pre-registration cycles are not replayed. ```ts export class SkeletalMesh3D extends AnimationPlayer { … } ``` -- **Use when:** put an animated character in the scene · my imported character renders deformed · instance an imported rigged character · validate animation clips on a character rig at load time · prepare a skinned character with safe skeleton cloning and stride sync +- **Use when:** put an animated character in the scene · my imported character renders deformed · instance an imported rigged character · validate animation clips on a character rig at load time · prepare a skinned character with safe skeleton cloning and stride sync · reduce animation CPU for a crowd of imported rigs · inspect an imported rig's effective animation evaluation rate - **Constraints:** use strideRoot to name the body moved by game code when the rig is parented under it · requiredClips fails closed at load time if any requested clip is missing or binds 0 tracks -- **Overrides:** strideSync controls whether locomotion playback rate matches ground covered · size normalises the instance to real-world metres with skin-aware measurement +- **Overrides:** strideSync controls whether locomotion playback rate matches ground covered · size normalises the instance to real-world metres with skin-aware measurement · updateRate: "auto" chooses the camera-based interval; 1 is full evaluation, and another positive safe integer requests that interval; a public `mixer` `"loop"` listener promotes effective evaluation to 1 to preserve event timing ```ts import { SkeletalMesh3D } from "@threenative/core"; diff --git a/packages/create-threenative/capabilities.json b/packages/create-threenative/capabilities.json index d84efdd9c0..365ac6e14c 100644 --- a/packages/create-threenative/capabilities.json +++ b/packages/create-threenative/capabilities.json @@ -719,7 +719,8 @@ "importPath": "@threenative/core", "kind": "class", "overrides": [ - "strideSync controls whether the matched rate is applied while stride is still measured" + "strideSync controls whether the matched rate is applied while stride is still measured", + "updateRate: \"auto\" chooses the camera-based interval; 1 is full evaluation, and another positive safe integer requests that interval; a public `mixer` `\"loop\"` listener promotes effective evaluation to 1 to preserve event timing" ], "package": "@threenative/core", "signature": "export class AnimationPlayer { … }", @@ -729,9 +730,11 @@ "stop a walking character's feet from sliding or spinning", "match a walk or run cycle to how fast a character is moving", "match an in-place walk cycle with no root motion to the body's speed", - "find out what speed an animation clip was authored for" + "find out what speed an animation clip was authored for", + "reduce animation CPU for distant characters", + "inspect the effective animation evaluation rate" ], - "summary": "Play a skinned or sprite animation from game code. A locomotion clip's playback rate is matched to the ground the body actually covers, so feet do not skate or spin — on by default, `strideSync: false` to keep the authored rate, and `player.stride` reports the measurement either way. Name the body a game moves as `strideRoot` when the rig is a child of it. Clips authored **in place** — every ActorX and Unreal export, every Mixamo \"in place\" clip, every stock animal pack — are matched too: their stride is read off the ground a planted foot sweeps, and `stride.inPlace` says so.", + "summary": "Play a skinned or sprite animation from game code. A locomotion clip's playback rate is matched to the ground the body actually covers, so feet do not skate or spin — on by default, `strideSync: false` to keep the authored rate, and `player.stride` reports the measurement either way. Name the body a game moves as `strideRoot` when the rig is a child of it. Clips authored **in place** — every ActorX and Unreal export, every Mixamo \"in place\" clip, every stock animal pack — are matched too: their stride is read off the ground a planted foot sweeps, and `stride.inPlace` says so. Evaluation is automatic by default: `updateRate: \"auto\"` follows measured camera visibility and projected size. `updateRate: 1` evaluates every frame; another positive safe integer names that frame interval. `updateRateReport` reports the effective rate, whether a named override is active, and whether the latest update evaluated. One-shots and fades stay full-rate; root-motion `AnimationComposer` advances every tick, and auto camera-ray targets and camera-parented rigs stay full-rate. A public `mixer` `\"loop\"` listener promotes the player to full-rate and observes only loop events after registration, so skipped pre-registration cycles are not replayed; `updateRateReport` reports that effective rate.", "supersedes": [], "symbol": "AnimationPlayer" }, @@ -2909,7 +2912,8 @@ "kind": "class", "overrides": [ "strideSync controls whether locomotion playback rate matches ground covered", - "size normalises the instance to real-world metres with skin-aware measurement" + "size normalises the instance to real-world metres with skin-aware measurement", + "updateRate: \"auto\" chooses the camera-based interval; 1 is full evaluation, and another positive safe integer requests that interval; a public `mixer` `\"loop\"` listener promotes effective evaluation to 1 to preserve event timing" ], "package": "@threenative/core", "signature": "export class SkeletalMesh3D extends AnimationPlayer { … }", @@ -2918,9 +2922,11 @@ "my imported character renders deformed", "instance an imported rigged character", "validate animation clips on a character rig at load time", - "prepare a skinned character with safe skeleton cloning and stride sync" + "prepare a skinned character with safe skeleton cloning and stride sync", + "reduce animation CPU for a crowd of imported rigs", + "inspect an imported rig's effective animation evaluation rate" ], - "summary": "Shared preparation for an imported rigged character. Instances the rig with a skeleton-safe clone, normalises size with skin-aware measurement, validates requested clips against the file and rig at load time, and sets up AnimationPlayer with honest stride-root accounting.", + "summary": "Shared preparation for an imported rigged character. Instances the rig with a skeleton-safe clone, normalises size with skin-aware measurement, validates requested clips against the file and rig at load time, and sets up AnimationPlayer with honest stride-root accounting. Animation evaluation inherits AnimationPlayer's automatic camera-based `updateRate: \"auto\"` default; `updateRate: 1` evaluates every frame, another positive safe integer requests that interval, and `updateRateReport` exposes the effective rate, override status and latest result. One-shots and fades stay full-rate; root-motion `AnimationComposer` advances every tick, and auto camera-ray targets and camera-parented rigs stay full-rate. A public `mixer` `\"loop\"` listener promotes effective evaluation to 1 and observes only loop events after registration, so skipped pre-registration cycles are not replayed.", "supersedes": [], "symbol": "SkeletalMesh3D" }, diff --git a/packages/create-threenative/templates/action-rpg/AGENTS.md b/packages/create-threenative/templates/action-rpg/AGENTS.md index 1281443263..40953c1b4c 100644 --- a/packages/create-threenative/templates/action-rpg/AGENTS.md +++ b/packages/create-threenative/templates/action-rpg/AGENTS.md @@ -67,13 +67,13 @@ Relative look capture: a binding with `pointerRelative: true` captures the canva `game.goto("")` also rebuilds the scene, but resets the game's state. Seeded randomness is deterministic only when `defineGame({ seed })` is configured. -`AnimationPlayer` re-times a looping clip to the ground the body covers so feet do not skate — in-place clips too, off a planted foot; a speed past what the clip carries is **clamped at 3x**, so set it near `player.stride.clipGroundSpeed`. `strideSync: false` opts out, `maxFootSlide` bounds it. `clipPoseError` scores a retarget against its source -per bone in degrees — whole quaternions relative to each rig's bind pose, so bind conventions +`AnimationPlayer` re-times a looping clip to the ground the body covers so feet do not skate — in-place clips too, off a planted foot; a speed past what the clip carries is **clamped at 3x**, so set it near `player.stride.clipGroundSpeed`. `strideSync: false` opts out, `maxFootSlide` bounds it. `clipPoseError` scores a retarget against its source per bone in degrees — whole quaternions relative to each rig's bind pose, so bind conventions cancel and an axis-rolled limb is caught where a bone-direction check reads zero. `clipTrackBindings` names tracks that bind nothing (`.undefined`, which plays the bind pose), `clipBoneCoverage` names bones the clip does not drive, which keep the previous clip's pose, and `boneContact` reports in metres whether a bone reaches its prop. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look — see `node_modules/create-threenative/agent-docs/references/dream-loop.md` WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale` starts at 0.85 for `mobile-low`, otherwise 1; a numeric value pins it. `TN_FRAME_BUDGET` reports the actual scale under both policies. `src/render/` is yours to edit and is where the base look lives: the prototype test scene, a metre diff --git a/packages/create-threenative/templates/action-rpg/CLAUDE.md b/packages/create-threenative/templates/action-rpg/CLAUDE.md index 6ffbc0ead9..9d80511712 100644 --- a/packages/create-threenative/templates/action-rpg/CLAUDE.md +++ b/packages/create-threenative/templates/action-rpg/CLAUDE.md @@ -69,13 +69,13 @@ Relative look capture: a binding with `pointerRelative: true` captures the canva `game.goto("")` also rebuilds the scene, but resets the game's state. Seeded randomness is deterministic only when `defineGame({ seed })` is configured. -`AnimationPlayer` re-times a looping clip to the ground the body covers so feet do not skate — in-place clips too, off a planted foot; a speed past what the clip carries is **clamped at 3x**, so set it near `player.stride.clipGroundSpeed`. `strideSync: false` opts out, `maxFootSlide` bounds it. `clipPoseError` scores a retarget against its source -per bone in degrees — whole quaternions relative to each rig's bind pose, so bind conventions +`AnimationPlayer` re-times a looping clip to the ground the body covers so feet do not skate — in-place clips too, off a planted foot; a speed past what the clip carries is **clamped at 3x**, so set it near `player.stride.clipGroundSpeed`. `strideSync: false` opts out, `maxFootSlide` bounds it. `clipPoseError` scores a retarget against its source per bone in degrees — whole quaternions relative to each rig's bind pose, so bind conventions cancel and an axis-rolled limb is caught where a bone-direction check reads zero. `clipTrackBindings` names tracks that bind nothing (`.undefined`, which plays the bind pose), `clipBoneCoverage` names bones the clip does not drive, which keep the previous clip's pose, and `boneContact` reports in metres whether a bone reaches its prop. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look — see `node_modules/create-threenative/agent-docs/references/dream-loop.md` WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale` starts at 0.85 for `mobile-low`, otherwise 1; a numeric value pins it. `TN_FRAME_BUDGET` reports the actual scale under both policies. `src/render/` is yours to edit and is where the base look lives: the prototype test scene, a metre diff --git a/packages/create-threenative/templates/minimal/AGENTS.md b/packages/create-threenative/templates/minimal/AGENTS.md index 74765161c7..5f4fc44c22 100644 --- a/packages/create-threenative/templates/minimal/AGENTS.md +++ b/packages/create-threenative/templates/minimal/AGENTS.md @@ -82,6 +82,7 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. diff --git a/packages/create-threenative/templates/minimal/CLAUDE.md b/packages/create-threenative/templates/minimal/CLAUDE.md index 77fbc75d25..f035c1f484 100644 --- a/packages/create-threenative/templates/minimal/CLAUDE.md +++ b/packages/create-threenative/templates/minimal/CLAUDE.md @@ -84,6 +84,7 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. diff --git a/packages/create-threenative/templates/platformer/AGENTS.md b/packages/create-threenative/templates/platformer/AGENTS.md index e80e97545d..ff69f2bc9a 100644 --- a/packages/create-threenative/templates/platformer/AGENTS.md +++ b/packages/create-threenative/templates/platformer/AGENTS.md @@ -47,6 +47,8 @@ WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale When an animation looks wrong, measure it before rewriting it. `clipPoseError` scores a retargeted clip against its source per bone in degrees — whole quaternions relative to each rig's own bind pose, so the two rigs' bind conventions cancel and a limb rolled about its own axis is caught where a bone-direction check reads zero. `clipTrackBindings` names tracks that bind nothing (the `.undefined` failure that plays the bind pose instead of the animation), `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. + ## Budget real time for the look Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. Open a capture after visual changes. A scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict and the `DEV_MODE=true` chip shows it on screen (it draws the verdict alone — no frame rate, which reads throttled under a compositor or a virtual display; see `agent-docs/references/performance-basics.md`); `TN_FRAME_SPANS=1` adds the render phase's own span tree when you need to know which part costs the milliseconds. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. diff --git a/packages/create-threenative/templates/platformer/CLAUDE.md b/packages/create-threenative/templates/platformer/CLAUDE.md index 0e98e7ec23..898a2e5284 100644 --- a/packages/create-threenative/templates/platformer/CLAUDE.md +++ b/packages/create-threenative/templates/platformer/CLAUDE.md @@ -49,6 +49,8 @@ WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale When an animation looks wrong, measure it before rewriting it. `clipPoseError` scores a retargeted clip against its source per bone in degrees — whole quaternions relative to each rig's own bind pose, so the two rigs' bind conventions cancel and a limb rolled about its own axis is caught where a bone-direction check reads zero. `clipTrackBindings` names tracks that bind nothing (the `.undefined` failure that plays the bind pose instead of the animation), `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. + ## Budget real time for the look Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. Open a capture after visual changes. A scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict and the `DEV_MODE=true` chip shows it on screen (it draws the verdict alone — no frame rate, which reads throttled under a compositor or a virtual display; see `agent-docs/references/performance-basics.md`); `TN_FRAME_SPANS=1` adds the render phase's own span tree when you need to know which part costs the milliseconds. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. diff --git a/packages/create-threenative/templates/puzzle/AGENTS.md b/packages/create-threenative/templates/puzzle/AGENTS.md index 05330a9ee3..6ffb7d1ae5 100644 --- a/packages/create-threenative/templates/puzzle/AGENTS.md +++ b/packages/create-threenative/templates/puzzle/AGENTS.md @@ -82,8 +82,8 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look — see `node_modules/create-threenative/agent-docs/references/dream-loop.md` - WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale` starts at 0.85 for `mobile-low`, otherwise 1; a numeric value pins it. `TN_FRAME_BUDGET` reports the actual scale under both policies. Edit `src/render/` directly; `postprocessing.ts` reports stages but decides no game colour. Quality tiers live in `src/render/quality.ts`: `low`, `medium`, `high`; the GPU family from `ctx.renderer.gpuClass` sets the start (`discrete` `high`, `integrated` and `mobile-high` `medium`, other phones `low`); `isMobile()` chooses `low`, otherwise `high` when the family is `unknown`; override with `setupPost(..., { tier: "low" })`. Unknown tiers throw and `TN_QUALITY_TIER` diff --git a/packages/create-threenative/templates/puzzle/CLAUDE.md b/packages/create-threenative/templates/puzzle/CLAUDE.md index 94520236ac..01f0a2d7f5 100644 --- a/packages/create-threenative/templates/puzzle/CLAUDE.md +++ b/packages/create-threenative/templates/puzzle/CLAUDE.md @@ -84,8 +84,8 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look — see `node_modules/create-threenative/agent-docs/references/dream-loop.md` - WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale` starts at 0.85 for `mobile-low`, otherwise 1; a numeric value pins it. `TN_FRAME_BUDGET` reports the actual scale under both policies. Edit `src/render/` directly; `postprocessing.ts` reports stages but decides no game colour. Quality tiers live in `src/render/quality.ts`: `low`, `medium`, `high`; the GPU family from `ctx.renderer.gpuClass` sets the start (`discrete` `high`, `integrated` and `mobile-high` `medium`, other phones `low`); `isMobile()` chooses `low`, otherwise `high` when the family is `unknown`; override with `setupPost(..., { tier: "low" })`. Unknown tiers throw and `TN_QUALITY_TIER` diff --git a/packages/create-threenative/templates/racing/AGENTS.md b/packages/create-threenative/templates/racing/AGENTS.md index 003daca191..78c0e89691 100644 --- a/packages/create-threenative/templates/racing/AGENTS.md +++ b/packages/create-threenative/templates/racing/AGENTS.md @@ -83,8 +83,8 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look - Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. Open a capture after visual changes. A scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict and the `DEV_MODE=true` chip shows it on screen (it draws the verdict alone — no frame rate, which reads throttled under a compositor or a virtual display; see `agent-docs/references/performance-basics.md`); `TN_FRAME_SPANS=1` adds the render phase's own span tree when you need to know which part costs the milliseconds. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. diff --git a/packages/create-threenative/templates/racing/CLAUDE.md b/packages/create-threenative/templates/racing/CLAUDE.md index f8aa697961..1acdf4e65e 100644 --- a/packages/create-threenative/templates/racing/CLAUDE.md +++ b/packages/create-threenative/templates/racing/CLAUDE.md @@ -85,8 +85,8 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look - Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. Open a capture after visual changes. A scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict and the `DEV_MODE=true` chip shows it on screen (it draws the verdict alone — no frame rate, which reads throttled under a compositor or a virtual display; see `agent-docs/references/performance-basics.md`); `TN_FRAME_SPANS=1` adds the render phase's own span tree when you need to know which part costs the milliseconds. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. diff --git a/packages/create-threenative/templates/rain/AGENTS.md b/packages/create-threenative/templates/rain/AGENTS.md index 7f3c4b20f1..4beaaf0927 100644 --- a/packages/create-threenative/templates/rain/AGENTS.md +++ b/packages/create-threenative/templates/rain/AGENTS.md @@ -82,6 +82,7 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. diff --git a/packages/create-threenative/templates/rain/CLAUDE.md b/packages/create-threenative/templates/rain/CLAUDE.md index 78f796b426..891250f58b 100644 --- a/packages/create-threenative/templates/rain/CLAUDE.md +++ b/packages/create-threenative/templates/rain/CLAUDE.md @@ -84,6 +84,7 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. diff --git a/packages/create-threenative/templates/rts/AGENTS.md b/packages/create-threenative/templates/rts/AGENTS.md index e1c9403e33..45cf2829dc 100644 --- a/packages/create-threenative/templates/rts/AGENTS.md +++ b/packages/create-threenative/templates/rts/AGENTS.md @@ -82,6 +82,7 @@ otherwise `high` when the family is `unknown`; override with `setupPost(..., { t Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. diff --git a/packages/create-threenative/templates/rts/CLAUDE.md b/packages/create-threenative/templates/rts/CLAUDE.md index b5fc976d16..eadfa1c5ea 100644 --- a/packages/create-threenative/templates/rts/CLAUDE.md +++ b/packages/create-threenative/templates/rts/CLAUDE.md @@ -84,6 +84,7 @@ otherwise `high` when the family is `unknown`; override with `setupPost(..., { t Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. diff --git a/packages/create-threenative/templates/runner/AGENTS.md b/packages/create-threenative/templates/runner/AGENTS.md index 3eca5da3a9..99eef1af97 100644 --- a/packages/create-threenative/templates/runner/AGENTS.md +++ b/packages/create-threenative/templates/runner/AGENTS.md @@ -80,6 +80,8 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. + ## Budget real time for the look — see `node_modules/create-threenative/agent-docs/references/dream-loop.md` WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale` starts at 0.85 for `mobile-low`, otherwise 1; a numeric value pins it. `TN_FRAME_BUDGET` reports the actual scale under both policies. Edit `src/render/` directly; `postprocessing.ts` reports stages but decides no game colour. Quality diff --git a/packages/create-threenative/templates/runner/CLAUDE.md b/packages/create-threenative/templates/runner/CLAUDE.md index 2ca4b2f079..0793d51086 100644 --- a/packages/create-threenative/templates/runner/CLAUDE.md +++ b/packages/create-threenative/templates/runner/CLAUDE.md @@ -82,6 +82,8 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. + ## Budget real time for the look — see `node_modules/create-threenative/agent-docs/references/dream-loop.md` WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale` starts at 0.85 for `mobile-low`, otherwise 1; a numeric value pins it. `TN_FRAME_BUDGET` reports the actual scale under both policies. Edit `src/render/` directly; `postprocessing.ts` reports stages but decides no game colour. Quality diff --git a/packages/create-threenative/templates/sailing/AGENTS.md b/packages/create-threenative/templates/sailing/AGENTS.md index cf992068dd..f87f81d87e 100644 --- a/packages/create-threenative/templates/sailing/AGENTS.md +++ b/packages/create-threenative/templates/sailing/AGENTS.md @@ -83,8 +83,8 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look - Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. Open a capture after visual changes. A scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict and the `DEV_MODE=true` chip shows it on screen (it draws the verdict alone — no frame rate, which reads throttled under a compositor or a virtual display; see `agent-docs/references/performance-basics.md`); `TN_FRAME_SPANS=1` adds the render phase's own span tree when you need to know which part costs the milliseconds. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. diff --git a/packages/create-threenative/templates/sailing/CLAUDE.md b/packages/create-threenative/templates/sailing/CLAUDE.md index 5172f0a1a7..7de7fd5583 100644 --- a/packages/create-threenative/templates/sailing/CLAUDE.md +++ b/packages/create-threenative/templates/sailing/CLAUDE.md @@ -85,8 +85,8 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Budget real time for the look - Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. Open a capture after visual changes. A scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict and the `DEV_MODE=true` chip shows it on screen (it draws the verdict alone — no frame rate, which reads throttled under a compositor or a virtual display; see `agent-docs/references/performance-basics.md`); `TN_FRAME_SPANS=1` adds the render phase's own span tree when you need to know which part costs the milliseconds. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. diff --git a/packages/create-threenative/templates/shooter/AGENTS.md b/packages/create-threenative/templates/shooter/AGENTS.md index 1564339399..8057672547 100644 --- a/packages/create-threenative/templates/shooter/AGENTS.md +++ b/packages/create-threenative/templates/shooter/AGENTS.md @@ -72,6 +72,8 @@ WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale When an animation looks wrong, measure it before rewriting it: `clipPoseError` scores a retargeted clip per bone in degrees relative to each rig's own bind pose, `clipTrackBindings` names tracks that bind nothing (the `.undefined` failure that plays the bind pose), `clipBoneCoverage` names bones the clip does not drive, and `boneContact` reports whether a bone reaches its prop. A soldier reads as a soldier only when every AI state picks a clip that names the action — patrol and chase `Rifle_Walk`, standing aim `Rifle_Idle`, firing `Rifle_Shoot`, crouch `Rifle_Crouch_To_Idle`/`Rifle_Crouch_Walk` (the six `Rifle_*` clips are Mixamo rifle clips retargeted onto the mannequin, and `RIFLE_HOLD` in `Enemy.ts` turns the rifle to each; the soldier's `rightHandContact`/`leftHandContact` are `boneContact` metres, held under 0.05), hit `Rifle_Hit`, dead `Death01` — crossfaded, feet snapped to the floor, body turned into the direction of travel or aim, and tinted so he does not vanish into the white first-person hands. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. + ## Budget real time for the look — see `node_modules/create-threenative/agent-docs/references/dream-loop.md` Open a capture after visual changes; a scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict; `TN_FRAME_SPANS=1` adds the render phase's own span tree. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. diff --git a/packages/create-threenative/templates/shooter/CLAUDE.md b/packages/create-threenative/templates/shooter/CLAUDE.md index 3a503c7ead..eae2563f29 100644 --- a/packages/create-threenative/templates/shooter/CLAUDE.md +++ b/packages/create-threenative/templates/shooter/CLAUDE.md @@ -74,6 +74,8 @@ WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale When an animation looks wrong, measure it before rewriting it: `clipPoseError` scores a retargeted clip per bone in degrees relative to each rig's own bind pose, `clipTrackBindings` names tracks that bind nothing (the `.undefined` failure that plays the bind pose), `clipBoneCoverage` names bones the clip does not drive, and `boneContact` reports whether a bone reaches its prop. A soldier reads as a soldier only when every AI state picks a clip that names the action — patrol and chase `Rifle_Walk`, standing aim `Rifle_Idle`, firing `Rifle_Shoot`, crouch `Rifle_Crouch_To_Idle`/`Rifle_Crouch_Walk` (the six `Rifle_*` clips are Mixamo rifle clips retargeted onto the mannequin, and `RIFLE_HOLD` in `Enemy.ts` turns the rifle to each; the soldier's `rightHandContact`/`leftHandContact` are `boneContact` metres, held under 0.05), hit `Rifle_Hit`, dead `Death01` — crossfaded, feet snapped to the floor, body turned into the direction of travel or aim, and tinted so he does not vanish into the white first-person hands. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. + ## Budget real time for the look — see `node_modules/create-threenative/agent-docs/references/dream-loop.md` Open a capture after visual changes; a scenario with no assertions or missing observations fails. The engine warns you before a human does: `TN_SCENE_WARNING` fires when the GPU used under a third of the frame while the JS render phase ran longer than the display's own period, and names the census behind it — objects considered, draws per pass, triangles per draw, shadow-exempt casters. It is a scene-shape verdict, so answer it by moving the draw and object counts, not the engine: read the bucket census before promising a merge. `npx threenative doctor` repeats the last verdict; `TN_FRAME_SPANS=1` adds the render phase's own span tree. The cheapest static object is one whose transform you never write: measured on 1,561 objects, leaving them alone costs 10.10 ms of render phase against 17.45 ms when the game rewrites every transform each frame, because three skips the per-object binding update when nothing changed. So do not touch a transform you do not need to — that is worth ~7 ms where `markStatic(root)`, which additionally composes a never-moving subtree once, measured 0.009 ms. Use it for scenery you are sure of, and `invalidateStatic(object)` to announce a write inside one; it deletes matrix arithmetic, not the walk. `TN_RENDERLIST_VALIDATE=1` recomputes every world matrix the long way each frame and throws on the first that disagrees, which is how you prove a freeze did not leave something stale on screen. diff --git a/packages/create-threenative/templates/shooter/playtests/enemy-body-is-a-body.playtest.json b/packages/create-threenative/templates/shooter/playtests/enemy-body-is-a-body.playtest.json index d590c1c9d5..651430d7d5 100644 --- a/packages/create-threenative/templates/shooter/playtests/enemy-body-is-a-body.playtest.json +++ b/packages/create-threenative/templates/shooter/playtests/enemy-body-is-a-body.playtest.json @@ -65,6 +65,12 @@ "component": "footClearance", "lte": 0.03, "allowTrivial": "grounded soldier contact is a standing invariant of the patrol loop" + }, + { + "entity": "enemy", + "component": "bodyUsesOpaqueFade", + "equals": true, + "allowTrivial": "reads each live body material: blended soldiers leave background normals in the deferred pass even at opacity 1, producing a building-shaped stripe across the helmet" } ], "resources": [ diff --git a/packages/create-threenative/templates/shooter/src/entities/Enemy.ts b/packages/create-threenative/templates/shooter/src/entities/Enemy.ts index ced45ab602..d7022cf37e 100644 --- a/packages/create-threenative/templates/shooter/src/entities/Enemy.ts +++ b/packages/create-threenative/templates/shooter/src/entities/Enemy.ts @@ -576,7 +576,7 @@ export class Enemy { #leftKnee: Object3D | undefined; #rightKnee: Object3D | undefined; #colliders: readonly BoxCollider[]; - #bodyMeshes: Object3D[] = []; + #bodyMeshes: Mesh[] = []; #poseBones: Object3D[] = []; #bodyProxy: Object3D | undefined; #body: CharacterBody3D | undefined; @@ -675,11 +675,7 @@ export class Enemy { this.group.add(figure); this.#ground = new GroundSnap(figure, { enabled: false, meshes: this.#bodyMeshes }); this.#equip(figure); - // Before the first draw, not on the first death. `#respawn` also calls this, but `#respawn` - // only ever runs off the death timer, so a soldier that has never died would otherwise reach - // his own death still carrying the blend state the asset shipped with — which is both a - // mid-fight pipeline compile and, once `#setOpacity` stopped flipping the flag, a fade that - // does not render at all because opacity is ignored on an opaque material. + // Precompile the dither fade before the first death. this.#fixBlendState(); this.group.name = "enemy"; this.group.position.copy(this.#route[0] ?? ROUTE_START); @@ -913,25 +909,7 @@ export class Enemy { body.teleport(proxy.position); } - /** - * Fade a soldier in or out without changing his pipeline. - * - * `transparent` and `depthWrite` are blend and depth state, not shader uniforms, so flipping - * either one makes WebGPU compile a *new* render pipeline for every material it touches — and a - * soldier is a skinned mesh with several. This used to set `transparent = alpha < 0.999` and - * `depthWrite = alpha > 0.5`, which meant the first death in a round compiled a fresh pipeline - * per material on the frame the corpse started fading. Measured as a single ~180 ms frame, - * mid-round, entirely outside game logic: `outsideGame` peaked at 177-186 ms with a firefight in - * the scenario and 28.6 ms without one, on identical movement. - * - * So the state is fixed at construction and never moves. Only `opacity` changes, which is a - * uniform the existing pipeline already reads. `needsUpdate` is deliberately not set: it forces - * the material to be re-evaluated, and there is nothing left to re-evaluate. - * - * Keeping `depthWrite` on for a fading corpse is the deliberate half of this. It costs a little - * correctness on a half-faded body seen through another one, and it buys never compiling - * mid-fight — and a corpse fading on the ground is not what anyone is looking at. - */ + /** Change only opacity: switching pipeline state mid-death previously cost ~180 ms. */ #setOpacity(alpha: number): void { const objects = [ ...this.#bodyMeshes, @@ -951,12 +929,7 @@ export class Enemy { if (alpha < this.#opacityFloor) this.#opacityFloor = alpha; } - /** - * Put every material into its final blend state once, before the soldier is ever drawn. - * - * Called at construction so the transparent pipeline is compiled during loading, where a long - * frame is part of the loading screen, rather than on the frame someone dies. - */ + /** Dither fades keep solid soldiers in the opaque depth/normal passes. */ #fixBlendState(): void { const objects = [ ...this.#bodyMeshes, @@ -968,7 +941,8 @@ export class Enemy { if (mesh.isMesh !== true) return; const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; for (const material of materials) { - material.transparent = true; + material.transparent = false; + material.alphaHash = true; material.depthWrite = true; material.needsUpdate = true; } @@ -1603,8 +1577,7 @@ export class Enemy { this.#reattachWeapon(); this.#bodyClearance = null; this.#footClearance = null; - // Blend state first, and only here: it is what costs a pipeline, so it is set once per - // soldier and never again. The fade below is a uniform on the pipeline this just built. + // Restore the precompiled fade state before changing its opacity uniform. this.#fixBlendState(); this.#setOpacity(0); this.#reaction = 0; @@ -1905,6 +1878,7 @@ export class Enemy { * all five at once. */ materialOpacity: number; + bodyUsesOpaqueFade: boolean; /** Lowest `materialOpacity` this soldier has ever reported. See `#opacityFloor`. */ materialOpacityFloor: number; reaction: number; @@ -1984,6 +1958,11 @@ export class Enemy { deadFor: this.#deadFor, armed: this.#weapon !== undefined, materialOpacity: this.#fade, + bodyUsesOpaqueFade: this.#bodyMeshes.every((mesh) => + (Array.isArray(mesh.material) ? mesh.material : [mesh.material]).every( + (material) => !material.transparent && material.alphaHash && material.depthWrite, + ), + ), materialOpacityFloor: this.#opacityFloor, reaction: this.#reaction, bodyClearance: this.#bodyClearance, diff --git a/packages/create-threenative/templates/snow/AGENTS.md b/packages/create-threenative/templates/snow/AGENTS.md index 3ffd77fab4..f319139b76 100644 --- a/packages/create-threenative/templates/snow/AGENTS.md +++ b/packages/create-threenative/templates/snow/AGENTS.md @@ -77,6 +77,8 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. + ## Budget real time for the look Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. diff --git a/packages/create-threenative/templates/snow/CLAUDE.md b/packages/create-threenative/templates/snow/CLAUDE.md index 008f3b35dd..e82e288072 100644 --- a/packages/create-threenative/templates/snow/CLAUDE.md +++ b/packages/create-threenative/templates/snow/CLAUDE.md @@ -79,6 +79,8 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. + ## Budget real time for the look Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. diff --git a/packages/create-threenative/templates/starter/AGENTS.md b/packages/create-threenative/templates/starter/AGENTS.md index 519e26661f..65a4755a46 100644 --- a/packages/create-threenative/templates/starter/AGENTS.md +++ b/packages/create-threenative/templates/starter/AGENTS.md @@ -66,6 +66,8 @@ rig's own bind pose, so the two rigs' bind conventions cancel and a limb rolled tracks that bind nothing (the `.undefined` failure that plays the bind pose instead of the animation), `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. + ## The arena is a test arena, and its look is a grid `src/render/arena.ts` owns the ground, the four walls, one pillar, and `platform()`, the single builder both ends of the course are cut from. A platform is a light metre-grid plate over a dark-grid body down to the ground: two meshes, so the player collides with exactly the triangles they can see, and the sides read as structure while the walking surface reads as floor. The ground has no collider — that is the pit under the gap, and why falling costs a life instead of landing on scenery. Move a platform and move its support surface in `Play.ts` and `Goal.ts` with it, or grounding will answer for a surface that is no longer there. `materials.ts` is the whole look: two greys and one saturated colour. Light grid where the player walks, dark grid on sides and structure, and the single `accent` hue reserved for the crate, the pickup and the flagpole — what you can touch. `worldGridUVs` makes one grid tile one metre on every face of every prop, so call it after a geometry is placed and before it is given to a mesh. diff --git a/packages/create-threenative/templates/starter/CLAUDE.md b/packages/create-threenative/templates/starter/CLAUDE.md index 69688c6643..3f17537090 100644 --- a/packages/create-threenative/templates/starter/CLAUDE.md +++ b/packages/create-threenative/templates/starter/CLAUDE.md @@ -68,6 +68,8 @@ rig's own bind pose, so the two rigs' bind conventions cancel and a limb rolled tracks that bind nothing (the `.undefined` failure that plays the bind pose instead of the animation), `clipBoneCoverage` names bones the clip does not drive and which therefore keep the previous clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. + ## The arena is a test arena, and its look is a grid `src/render/arena.ts` owns the ground, the four walls, one pillar, and `platform()`, the single builder both ends of the course are cut from. A platform is a light metre-grid plate over a dark-grid body down to the ground: two meshes, so the player collides with exactly the triangles they can see, and the sides read as structure while the walking surface reads as floor. The ground has no collider — that is the pit under the gap, and why falling costs a life instead of landing on scenery. Move a platform and move its support surface in `Play.ts` and `Goal.ts` with it, or grounding will answer for a surface that is no longer there. `materials.ts` is the whole look: two greys and one saturated colour. Light grid where the player walks, dark grid on sides and structure, and the single `accent` hue reserved for the crate, the pickup and the flagpole — what you can touch. `worldGridUVs` makes one grid tile one metre on every face of every prop, so call it after a geometry is placed and before it is given to a mesh. diff --git a/packages/create-threenative/templates/tower-defense/AGENTS.md b/packages/create-threenative/templates/tower-defense/AGENTS.md index 0f7175983c..0d74a04897 100644 --- a/packages/create-threenative/templates/tower-defense/AGENTS.md +++ b/packages/create-threenative/templates/tower-defense/AGENTS.md @@ -78,10 +78,10 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Look and evidence ## Budget real time for the look - Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale` starts at 0.85 for `mobile-low`, otherwise 1; a numeric value pins it. `TN_FRAME_BUDGET` reports the actual scale under both policies. Edit `src/render/` directly; `postprocessing.ts` reports stages but decides no game colour. Quality diff --git a/packages/create-threenative/templates/tower-defense/CLAUDE.md b/packages/create-threenative/templates/tower-defense/CLAUDE.md index ad20fc69e6..737fb10127 100644 --- a/packages/create-threenative/templates/tower-defense/CLAUDE.md +++ b/packages/create-threenative/templates/tower-defense/CLAUDE.md @@ -80,10 +80,10 @@ caught where a bone-direction check reads zero. `clipTrackBindings` names tracks clip's pose, and `boneContact` reports in metres whether a named bone reaches the prop it is supposed to be touching. Two loading conventions come from `@threenative/core`, not from your own loops: `loadAll(items, load)` fetches six at a time and returns results **in the input's order** (a pool that pushes returns completion order, so a positional pick lands a different asset every load), and `addInSlices(objects, (object) => ctx.add(object))` attaches in 8 ms slices so hundreds of objects never land in one long frame; override `concurrency`/`sliceSize`, pass `while: () => alive` to stop a torn-down scene without throwing, and `marker: false` silences `TN_LOAD_ALL`/`TN_ADD_SLICES` but never the measurement. Gate the loading curtain on a measured readiness with `ctx.startup.hold(label, () => ready)`: core polls it every rendered frame, so it settles without a physics step. +`updateRate: "auto"` is the default and follows measured camera policy; `updateRate: 1` is full evaluation, another positive safe integer names a frame interval, and `updateRateReport` shows the effective rate, override status and latest evaluation; one-shots/fades, public mixer listeners/speed overrides, root-motion `AnimationComposer`, automatic camera-ray targets and camera-parented rigs stay full-rate. ## Look and evidence ## Budget real time for the look - Reference-driven authoring starts at `node_modules/create-threenative/agent-docs/references/dream-loop.md`. WebGPU reports `TN_GPU_CLASS` once per launch. Automatic `render.resolutionScale` starts at 0.85 for `mobile-low`, otherwise 1; a numeric value pins it. `TN_FRAME_BUDGET` reports the actual scale under both policies. Edit `src/render/` directly; `postprocessing.ts` reports stages but decides no game colour. Quality diff --git a/packages/playtest/AGENTS.md b/packages/playtest/AGENTS.md index fa40754c34..3b1cbf14a0 100644 --- a/packages/playtest/AGENTS.md +++ b/packages/playtest/AGENTS.md @@ -109,7 +109,9 @@ bridge advertising `runtime.startup` to report `phase: "ready"`; bounded by `PLAYTEST_STARTUP_READY_TIMEOUT_MS`, and a game that never gets there fails `TN_PLAYTEST_STARTUP_NOT_READY` rather than being observed mid-load. A host that has *exited* is `TN_PLAYTEST_STARTUP_HOST_EXITED`, not a slow launch. **Never fix a boot race by lengthening a -wait** — padding changes which runs get lucky. One measurement opt-in sits outside all of it: +wait** — padding changes which runs get lucky. Browser frame-time and FPS assertions select the live host clock by default; an explicit +`liveClock: false` run config retains counted ticks. Other scenarios keep counted ticks. +One measurement opt-in sits outside all of it: `--live-clock` (browser and desktop targets) makes the host's own frame pump move the simulation, so a standing scene is measurable at all. The browser injects `__THREENATIVE_PLAYTEST_CLOCK__ = "wall-clock"` ahead of every page script. The desktop runner sets `TN_PLAYTEST_CLOCK=wall-clock` for diff --git a/packages/playtest/CLAUDE.md b/packages/playtest/CLAUDE.md index 1169e3d212..6a9b2ca5c7 100644 --- a/packages/playtest/CLAUDE.md +++ b/packages/playtest/CLAUDE.md @@ -111,7 +111,9 @@ bridge advertising `runtime.startup` to report `phase: "ready"`; bounded by `PLAYTEST_STARTUP_READY_TIMEOUT_MS`, and a game that never gets there fails `TN_PLAYTEST_STARTUP_NOT_READY` rather than being observed mid-load. A host that has *exited* is `TN_PLAYTEST_STARTUP_HOST_EXITED`, not a slow launch. **Never fix a boot race by lengthening a -wait** — padding changes which runs get lucky. One measurement opt-in sits outside all of it: +wait** — padding changes which runs get lucky. Browser frame-time and FPS assertions select the live host clock by default; an explicit +`liveClock: false` run config retains counted ticks. Other scenarios keep counted ticks. +One measurement opt-in sits outside all of it: `--live-clock` (browser and desktop targets) makes the host's own frame pump move the simulation, so a standing scene is measurable at all. The browser injects `__THREENATIVE_PLAYTEST_CLOCK__ = "wall-clock"` ahead of every page script. The desktop runner sets `TN_PLAYTEST_CLOCK=wall-clock` for diff --git a/packages/playtest/__tests__/live-clock.spec.ts b/packages/playtest/__tests__/live-clock.spec.ts index f0672ee4e3..fcde807c3a 100644 --- a/packages/playtest/__tests__/live-clock.spec.ts +++ b/packages/playtest/__tests__/live-clock.spec.ts @@ -1,6 +1,10 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; -import { expect, test } from "vitest"; +import { expect, test, vi } from "vitest"; +import { chromium } from "playwright"; +import { runStandalonePlaytest } from "../src/runner/runner.js"; +import { PlaytestBridgeError } from "../src/runner/bridgeClient.js"; +import { waitFrames } from "../src/runner/steps.js"; import { makeTempDirSync } from "../../../test-support/temp-dir.js"; @@ -9,6 +13,12 @@ import { buildReport } from "../src/runner/runner-support.js"; import { failureReport } from "../src/runner/shared.js"; import type { IPlaytestScenario } from "../src/index.js"; +// These clock-selection tests refuse browser launch and never use the GPU. +vi.mock("../src/runner/captureLock.js", async (importOriginal) => ({ + ...await importOriginal(), + decideLockPolicy: () => ({ mode: "none" }), +})); + /** * `--live-clock`: the browser half of what the native production profile's own `--live-clock` does * there. A standing scene cannot be measured on counted ticks — the runner batches `waitTicks` into @@ -16,7 +26,7 @@ import type { IPlaytestScenario } from "../src/index.js"; * nothing after it. The switch puts the clock request ahead of every page script, and the run says * which clock it measured on, because a rate off the wrong clock is not a rate. */ -function project(): string { +function project(performance: NonNullable["performance"] = { maxFrameMsP95: 16.7 }): string { const root = makeTempDirSync("tn-live-clock-"); const scenarioPath = join(root, "standing.playtest.json"); writeFileSync(scenarioPath, JSON.stringify({ @@ -26,7 +36,7 @@ function project(): string { viewport: { height: 720, width: 1280 }, warmupFrames: 1, steps: [{ waitTicks: 300 }], - assert: { performance: { maxFrameP95: 16.7 } }, + assert: { performance }, })); return root; } @@ -95,4 +105,120 @@ test("every report names the clock it measured on", () => { message: "no bridge", severity: "error", }).clock).toBe("wall-clock"); -}); \ No newline at end of file +}); +// Frame-time assertions need the game's frame pump, not ten forced simulation ticks per frame. +test.each([ + { liveClock: undefined, target: "browser" as const, performance: { maxFrameMsP95: 16.7 }, expected: "wall-clock" }, + { liveClock: undefined, target: "browser" as const, performance: { minFps: 30 }, expected: "wall-clock" }, + { liveClock: undefined, target: "browser" as const, performance: { maxDrawCalls: 100 }, expected: "fixed-step" }, + { liveClock: false, target: "browser" as const, performance: { maxFrameMsP95: 16.7 }, expected: "fixed-step" }, + { liveClock: undefined, target: "android" as const, performance: { maxFrameMsP95: 16.7 }, expected: "fixed-step" }, +])("$target performance requests $expected before browser launch", async ({ liveClock, target, performance, expected }) => { + const root = project(performance); + const launch = vi.spyOn(chromium, "launch").mockRejectedValue(new PlaytestBridgeError({ + code: "TN_PLAYTEST_BRIDGE_MISSING", + message: "Controlled launch refusal checks the requested clock without a browser.", + fix: { instruction: "This fixture deliberately stops before a browser starts." }, + severity: "error", + })); + try { + const report = await runStandalonePlaytest({ + artifactDirectory: join(root, "artifacts"), headless: true, + projectPath: root, scenarioPath: "standing.playtest.json", timeoutMs: 1000, + trace: false, url: "http://127.0.0.1:5173", liveClock, target, + }); + expect(launch).toHaveBeenCalledOnce(); + expect(report.pass).toBe(false); + expect(report.clock).toBe(expected); + } finally { + launch.mockRestore(); + } +}); + +test("waitFrames does nothing for zero or negative frame counts", async () => { + const page = { evaluate: vi.fn() }; + await waitFrames(page as never, 0); + await waitFrames(page as never, -3); + expect(page.evaluate).not.toHaveBeenCalled(); +}); + +test("waitFrames rejects a pending RAF at the supplied remaining budget", async () => { + vi.useFakeTimers(); + try { + const page = { evaluate: vi.fn(() => new Promise(() => {})) }; + const pending = waitFrames(page as never, 1, 50).catch((error) => error); + await vi.advanceTimersByTimeAsync(50); + const error = await pending; + expect(error).toMatchObject({ diagnostic: { code: "TN_PLAYTEST_OPERATION_TIMEOUT" } }); + } finally { + vi.useRealTimers(); + } +}); + +test("waitFrames preserves queued RAF ordering through a long supplied budget", async () => { + vi.useFakeTimers(); + const callbacks: Array<() => void> = []; + const schedule = vi.fn((callback: () => void) => { callbacks.push(callback); return callbacks.length; }); + vi.stubGlobal("requestAnimationFrame", schedule); + try { + const page = { evaluate: vi.fn((callback: (count: number) => Promise, count: number) => callback(count)) }; + const settled = vi.fn(); + const pending = waitFrames(page as never, 3, 40_000).then(() => { settled(); }); + for (let frame = 0; frame < 3; frame += 1) { + expect(callbacks).toHaveLength(1); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(12_000); + const callback = callbacks.shift(); + expect(callback).toBeTypeOf("function"); + callback?.(); + } + await pending; + expect(schedule).toHaveBeenCalledTimes(3); + expect(callbacks).toHaveLength(0); + expect(settled).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } +}); + +test("waitFrames rejects after N minus one RAF callbacks and releases its timer", async () => { + vi.useFakeTimers(); + const callbacks: Array<() => void> = []; + vi.stubGlobal("requestAnimationFrame", (callback: () => void) => { callbacks.push(callback); return callbacks.length; }); + try { + const page = { evaluate: vi.fn((callback: (count: number) => Promise, count: number) => callback(count)) }; + const pending = waitFrames(page as never, 3, 50).catch((error) => error); + for (let frame = 0; frame < 2; frame += 1) { + const callback = callbacks.shift(); + expect(callback).toBeTypeOf("function"); + callback?.(); + } + expect(callbacks).toHaveLength(1); + await vi.advanceTimersByTimeAsync(50); + expect(await pending).toMatchObject({ diagnostic: { code: "TN_PLAYTEST_OPERATION_TIMEOUT" } }); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.unstubAllGlobals(); + vi.useRealTimers(); + } +}); + +test("waitFrames rejects an already-expired supplied budget without launching page.evaluate", async () => { + const page = { evaluate: vi.fn(() => new Promise(() => {})) }; + await expect(waitFrames(page as never, 1, 0)).rejects.toMatchObject({ + diagnostic: { code: "TN_PLAYTEST_OPERATION_TIMEOUT" }, + }); + expect(page.evaluate).not.toHaveBeenCalled(); +}); + +test("waitFrames rejects malformed supplied budgets", async () => { + const page = { evaluate: vi.fn(() => new Promise(() => {})) }; + for (const budget of [Number.NaN, Number.POSITIVE_INFINITY, -1]) { + await expect(waitFrames(page as never, 1, budget)).rejects.toMatchObject({ + diagnostic: { code: "TN_PLAYTEST_OPERATION_TIMEOUT" }, + }); + } + expect(page.evaluate).not.toHaveBeenCalled(); +}); diff --git a/packages/playtest/__tests__/orphan-cleanup.sh b/packages/playtest/__tests__/orphan-cleanup.sh index b106f8c9ad..e76656747a 100755 --- a/packages/playtest/__tests__/orphan-cleanup.sh +++ b/packages/playtest/__tests__/orphan-cleanup.sh @@ -122,7 +122,9 @@ fi # Initialize only this run's private infrastructure before counting; still require no lease or # queue entry after SIGTERM, so a real lock/process/profile leak cannot become a green count. capture_coordination_root="$suite_temp_root/threenative-playtest-capture" -mkdir -p -- "$capture_coordination_root/queue" +# Corepack retains its V8 cache; keep it in this run's owned infrastructure baseline. +export V8_COMPILE_CACHE_CACHE_DIR="$suite_temp_root/v8-compile-cache" +mkdir -p -- "$capture_coordination_root/queue" "$V8_COMPILE_CACHE_CACHE_DIR" capture_coordination_empty() { [[ ! -e "$capture_coordination_root/lock" && ! -L "$capture_coordination_root/lock" ]] || return 1 if [[ -d "$capture_coordination_root/queue" && ! -L "$capture_coordination_root/queue" ]]; then diff --git a/packages/playtest/__tests__/startup-ready.spec.ts b/packages/playtest/__tests__/startup-ready.spec.ts index eeb55ce4a2..b1e98f99f9 100644 --- a/packages/playtest/__tests__/startup-ready.spec.ts +++ b/packages/playtest/__tests__/startup-ready.spec.ts @@ -1,5 +1,7 @@ import { PerspectiveCamera, Scene, type Vector2, type WebGLRenderer } from "three"; -import { expect, test } from "vitest"; +import { expect, test, vi } from "vitest"; +import type { Page } from "playwright"; +import { waitFrames } from "../src/runner/steps.js"; import { installThreePlaytestBridge } from "../src/three/bridge.js"; import { validatePlaytestScenario } from "../src/scenario/schema-validate.js"; @@ -341,3 +343,182 @@ test("an unreadable liveness probe never invents a crash", async () => { }), ).resolves.toEqual({ rule: "sustained-frames", startup: ready }); }); + +test("startup frame timeouts consume the remaining deadline without resetting it", async () => { + let clock = 0; + const budgets: Array = []; + const timeout = new PlaytestBridgeError(playtestDiagnostic("TN_PLAYTEST_OPERATION_TIMEOUT", "RAF stalled", "Check rendering")); + await expect(waitForStartupReady({ + bridge: source(["runtime.startup"], [collapsing]), + now: () => clock, + timeoutMs: 200, + pump: async (remainingMs) => { + budgets.push(remainingMs); + clock += 50; + throw timeout; + }, + })).rejects.toMatchObject({ diagnostic: { code: "TN_PLAYTEST_STARTUP_NOT_READY" } }); + expect(budgets).toEqual([200, 150, 100, 50]); + expect(clock).toBe(200); +}); + + +test("warmup consumes the existing startup allowance before readiness pumps", async () => { + let clock = 0; + const budgets: Array = []; + await expect(waitForStartupReady({ + bridge: source(["runtime.startup"], [collapsing, collapsing, ready]), + now: () => clock, + timeoutMs: 200, + warmup: async (remainingMs) => { + budgets.push(remainingMs); + clock += 80; + }, + pump: async (remainingMs) => { + budgets.push(remainingMs); + clock += 50; + }, + })).resolves.toEqual({ rule: "sustained-frames", startup: ready }); + expect(budgets).toEqual([200, 120, 70]); + expect(clock).toBe(180); +}); + +test("an exhausted warmup cannot poll readiness with a fresh deadline", async () => { + let clock = 0; + const bridge = source(["runtime.startup"], [ready]); + await expect(waitForStartupReady({ + bridge, + now: () => clock, + timeoutMs: 200, + warmup: async () => { clock = 200; }, + pump: async () => { throw new Error("startup allowance already exhausted"); }, + })).rejects.toMatchObject({ diagnostic: { code: "TN_PLAYTEST_STARTUP_NOT_READY" } }); + expect(bridge.calls).toBe(0); +}); + +test.each(["warmup", "readiness"] as const)("a hung %s is Node-bounded by the shared startup allowance", async (hang) => { + vi.useFakeTimers(); + try { + let result: unknown = "pending"; + const bridge = source(["runtime.startup"], [ready]); + if (hang === "readiness") bridge.readiness = () => new Promise(() => {}); + const pending = waitForStartupReady({ + bridge, + timeoutMs: 100, + warmup: () => new Promise((resolve) => { + if (hang !== "warmup") setTimeout(resolve, 40); + }), + pump: async () => { throw new Error("hung phase cannot pump"); }, + }).then(value => { result = value; }, error => { result = error; }); + await vi.advanceTimersByTimeAsync(99); + expect(result).toBe("pending"); + await vi.advanceTimersByTimeAsync(1); + expect(result).toMatchObject({ diagnostic: { code: "TN_PLAYTEST_STARTUP_NOT_READY" } }); + await pending; + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); + +test.each(["success", "warmup-error", "readiness-error"] as const)("the shared startup timer clears after %s", async (outcome) => { + vi.useFakeTimers(); + try { + const sentinel = new Error(outcome); + const bridge = source(["runtime.startup"], [ready]); + if (outcome === "readiness-error") bridge.readiness = async () => { throw sentinel; }; + const pending = waitForStartupReady({ + bridge, + timeoutMs: 100, + warmup: async () => { if (outcome === "warmup-error") throw sentinel; }, + pump: async () => undefined, + }); + if (outcome === "success") await expect(pending).resolves.toEqual({ rule: "sustained-frames", startup: ready }); + else await expect(pending).rejects.toBe(sentinel); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); + +test("without runtime.startup warmup keeps its original operation deadline", async () => { + vi.useFakeTimers(); + try { + let result: unknown = "pending"; + const page = { evaluate: () => new Promise(() => {}) } as unknown as Page; + const bridge = source([], [undefined]); + const pending = waitForStartupReady({ + bridge, + timeoutMs: 100, + warmup: remainingMs => waitFrames(page, 10, remainingMs), + pump: async () => { throw new Error("unsupported startup must not pump"); }, + }).then(value => { result = value; }, error => { result = error; }); + await vi.advanceTimersByTimeAsync(22_499); + expect(result).toBe("pending"); + await vi.advanceTimersByTimeAsync(1); + expect(result).toMatchObject({ diagnostic: { code: "TN_PLAYTEST_OPERATION_TIMEOUT" } }); + await pending; + expect(bridge.calls).toBe(0); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); + +test.each(["collapsing", "busy"] as const)("late %s readiness cannot resume startup work after the Node deadline", async (late) => { + vi.useFakeTimers(); + try { + let release: (value: IPlaytestBridgeReady) => void = () => undefined; + let fail: (error: Error) => void = () => undefined; + let reads = 0; + let pumps = 0; + let hostChecks = 0; + const pending = waitForStartupReady({ + bridge: { + description: { capabilities: ["runtime.startup"] }, + readiness: () => { + reads += 1; + return new Promise((resolve, reject) => { release = resolve; fail = reject; }); + }, + }, + now: () => 0, + timeoutMs: 100, + hostAlive: async () => { hostChecks += 1; return true; }, + pump: () => { pumps += 1; return new Promise(() => {}); }, + }); + const failed = expect(pending).rejects.toMatchObject({ diagnostic: { code: "TN_PLAYTEST_STARTUP_NOT_READY" } }); + await vi.advanceTimersByTimeAsync(100); + await failed; + if (late === "collapsing") release({ ready: true, startup: collapsing }); + else fail(new PlaytestBridgeError(playtestDiagnostic("TN_PLAYTEST_OPERATION_TIMEOUT", "late timeout", "x"))); + await vi.advanceTimersByTimeAsync(0); + expect({ reads, pumps, hostChecks }).toEqual({ reads: 1, pumps: 0, hostChecks: 0 }); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); + +test("late warmup cannot query readiness after the Node deadline even when the clock has not moved", async () => { + vi.useFakeTimers(); + try { + let release: () => void = () => undefined; + const bridge = source(["runtime.startup"], [ready]); + const pending = waitForStartupReady({ + bridge, + now: () => 0, + timeoutMs: 100, + warmup: () => new Promise(resolve => { release = resolve; }), + pump: async () => { throw new Error("expired startup must not pump"); }, + }); + const failed = expect(pending).rejects.toMatchObject({ diagnostic: { code: "TN_PLAYTEST_STARTUP_NOT_READY" } }); + await vi.advanceTimersByTimeAsync(100); + await failed; + release(); + await vi.advanceTimersByTimeAsync(0); + expect(bridge.calls).toBe(0); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/playtest/docs/determinism.md b/packages/playtest/docs/determinism.md index e71d6fb1e9..ac5bb20686 100644 --- a/packages/playtest/docs/determinism.md +++ b/packages/playtest/docs/determinism.md @@ -73,7 +73,12 @@ runner turns `waitTicks` — and, under `runtime.fixedStep`, `waitFrames` — in watches it reports one boot window and nothing after it, because there was no second frame to close a window on. -`--live-clock` (browser and desktop targets) is the opt-in, and it is the harness half of what the +Browser assertions containing `maxFrameMsP95` or `minFps` select this live clock automatically: +forced simulation batches do not measure normal frame pacing. An explicit `liveClock: false` +run configuration retains counted ticks; draw-count-only assertions and other targets keep their +existing default. + +`--live-clock` (browser and desktop targets) also opts in, and it is the harness half of what the native production profile's own `--live-clock` already does there. On the browser the runner puts `globalThis.__THREENATIVE_PLAYTEST_CLOCK__ = "wall-clock"` in an `addInitScript` — ahead of every page script, so a request that lands after the game's module evaluated cannot happen — and on the @@ -93,7 +98,7 @@ not playing. It is a measurement switch, not scenario semantics. `holdTicks` and `waitTicks` stay tick counts, the flag is refused on the device targets (their hosts take the request from the production -profile's injected instrumentation, not from this runner), and a deterministic scenario that wants -ticks asks for none. A value the host does not recognize is refused there too: +profile's injected instrumentation, not from this runner). Ordinary deterministic scenarios retain +counted ticks. A value the host does not recognize is refused there too: `TN_PLAYTEST_CLOCK_UNSUPPORTED` on stderr, and the launch fails rather than reporting a wall-clock run that never happened. diff --git a/packages/playtest/src/runner/captureSession.ts b/packages/playtest/src/runner/captureSession.ts index 7ee57160f7..50c9fec6d3 100644 --- a/packages/playtest/src/runner/captureSession.ts +++ b/packages/playtest/src/runner/captureSession.ts @@ -140,12 +140,12 @@ export async function withBrowserCapture( if (bridge === undefined || !bridge.description.capabilities.includes("runtime.startup")) { throw new Error("TN_CAPTURE_SESSION_READINESS_REQUIRED: the page must report runtime.startup"); } - await stage("warmup", () => waitFrames(activePage, scenario.warmupFrames), startupBudget); + await stage("warmup", () => waitFrames(activePage, scenario.warmupFrames, startupBudget), startupBudget); const startup = await stage("engine startup readiness", () => waitForStartupReady({ aborted: () => controller.signal.aborted, bridge, declaredSoftware: active.allowSoftwareAdapter === true, - pump: () => waitFrames(activePage, 1), + pump: (remainingMs?: number) => waitFrames(activePage, 1, remainingMs), }), startupBudget); if (startup === undefined) throw new Error("TN_CAPTURE_SESSION_READINESS_REQUIRED: no startup observation was returned"); const provenance = await stage("adapter provenance", () => readCaptureProvenance(activePage, active, scenario)); diff --git a/packages/playtest/src/runner/config.ts b/packages/playtest/src/runner/config.ts index 6979761c71..dee0120ff7 100644 --- a/packages/playtest/src/runner/config.ts +++ b/packages/playtest/src/runner/config.ts @@ -55,7 +55,8 @@ export interface IStandalonePlaytestConfig { * Injects `__THREENATIVE_PLAYTEST_CLOCK__ = "wall-clock"` ahead of every page script, so the * host's frame pump moves the simulation and `advance` names the span its ticks cover — the * browser half of what the native production profile's `--live-clock` already does there. It is - * a measurement opt-in and never a scenario semantics change: a deterministic scenario is still + * also selected automatically for browser frame-time/FPS assertions unless explicitly false. + * It never changes the authored tick unit: a deterministic scenario is still * a count of ticks, and a run that takes this flag reports the clock it measured on. */ liveClock?: boolean; diff --git a/packages/playtest/src/runner/runner.ts b/packages/playtest/src/runner/runner.ts index 6f13946724..f1eb2a90ef 100644 --- a/packages/playtest/src/runner/runner.ts +++ b/packages/playtest/src/runner/runner.ts @@ -204,11 +204,17 @@ async function runStandalonePlaytestInternal( options: IStandalonePlaytestInternalOptions = {}, ): Promise { const usesFreePort = config.server !== undefined && config.port === 0; - const activeConfig = usesFreePort ? await resolveManagedServerConfig(config) : config; + let activeConfig = usesFreePort ? await resolveManagedServerConfig(config) : config; const scenario = withPerformanceBudget( await loadPlaytestScenario(activeConfig.projectPath, activeConfig.scenarioPath), activeConfig.performanceBudget, ); + const performance = scenario.assert?.performance; + // Forced tick batches measure the runner's simulation workload, not ordinary frame pacing. + if ((activeConfig.target ?? "browser") === "browser" && activeConfig.liveClock === undefined + && (performance?.maxFrameMsP95 !== undefined || performance?.minFps !== undefined)) { + activeConfig = { ...activeConfig, liveClock: true }; + } const browserConfig = scenario.assert?.performance === undefined ? activeConfig : { @@ -452,20 +458,21 @@ async function runStandalonePlaytestInternal( )); } } - await waitFrames(page, scenario.warmupFrames); // `warmupFrames` is a fixed count, so whether it covers the application's launch depends on // the machine. Where the application reports its own startup, wait for that instead: the // baseline below must be read from a game that is running, not from one still loading. // `allowSoftwareAdapter` is the operator saying out loud that this machine has no GPU. The // wait still runs to full readiness; the declaration is the only thing that labels the result // as the non-smoothness measurement it is. Nothing infers it from a slow run. + if (bridge === undefined || scenario.awaitStartup === false) await waitFrames(page, scenario.warmupFrames); const startupOutcome = bridge === undefined || scenario.awaitStartup === false ? undefined : await waitForStartupReady({ aborted: () => tearingDown, bridge, declaredSoftware: activeConfig.allowSoftwareAdapter === true, - pump: () => waitFrames(activePage, 1), + warmup: (remainingMs?: number) => waitFrames(activePage, scenario.warmupFrames, remainingMs), + pump: (remainingMs?: number) => waitFrames(activePage, 1, remainingMs), }); const runtimeReady = await page.evaluate(() => document.readyState !== "loading" && document.querySelector("canvas") !== null, diff --git a/packages/playtest/src/runner/startupReady.ts b/packages/playtest/src/runner/startupReady.ts index 9188c320cb..1c9b108add 100644 --- a/packages/playtest/src/runner/startupReady.ts +++ b/packages/playtest/src/runner/startupReady.ts @@ -34,7 +34,9 @@ export interface IWaitForStartupReadyOptions { * passes a short wait. This is not scenario semantics — no step is being counted here — it is * the boundary before the first observation is taken. */ - readonly pump: () => Promise; + readonly pump: (remainingMs?: number) => Promise; + /** Authored browser warmup shares startup's deadline; unsupported pages keep the default frame budget. */ + readonly warmup?: (remainingMs?: number) => Promise; /** * True once the run is being torn down, so the wait stops instead of outliving it. * @@ -84,7 +86,10 @@ export async function waitForStartupReady( options: IWaitForStartupReadyOptions, ): Promise { const { bridge, pump } = options; - if (!bridge.description.capabilities.includes("runtime.startup")) return undefined; + if (!bridge.description.capabilities.includes("runtime.startup")) { + await options.warmup?.(); + return undefined; + } const now = options.now ?? (() => Date.now()); const timeoutMs = options.timeoutMs ?? PLAYTEST_STARTUP_READY_TIMEOUT_MS; if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) @@ -93,44 +98,73 @@ export async function waitForStartupReady( const aborted = options.aborted ?? (() => false); if (aborted()) throw abortedDuringStartup(); const hostAlive = options.hostAlive ?? (async () => undefined); - let observed = await pollStartup(bridge); - // Readiness, never bare compile settlement. A game whose launch holds build its world on - // `whenReady()` (the puzzle's crate pile is one), so observing at compile settlement catches it - // mid-load: the runs then assert against a half-built world and fail on state that never had a - // chance to arrive. The bounded frame window still expires on a CPU rasteriser, so this is at - // most `STARTUP_STABLE_WINDOW_MS` more per scenario, not a wait the lane can never meet. - while (observed === BUSY || observed.phase !== "ready") { - if (aborted()) throw abortedDuringStartup(); - // Only an unreadable bridge asks the question: a host that answers is running by definition, - // and probing a live one every pump would charge each poll for a process lookup. - if (observed === BUSY && (await hostAlive()) === false) throw hostExitedDuringStartup(); - if (now() >= deadline) { - throw new PlaytestBridgeError(playtestDiagnostic( - "TN_PLAYTEST_STARTUP_NOT_READY", - `Application startup stayed '${observed === BUSY ? "unreadable, the bridge kept timing out" : observed.phase}' for ${timeoutMs}ms; the run would have observed a game that has not finished loading.`, - "Let the application reach startup readiness — check that its loading gate can complete headlessly — or remove the runtime.startup capability if it has no startup phase.", - )); - } - try { - await pump(); - } catch (error) { - if ( - error instanceof PlaytestBridgeError && - error.diagnostic.code === "TN_PLAYTEST_OPERATION_TIMEOUT" - ) { - observed = BUSY; - continue; + let observed: IPlaytestStartupObservation | typeof BUSY | undefined = options.warmup === undefined ? BUSY : undefined; + const timeoutError = () => new PlaytestBridgeError(playtestDiagnostic( + "TN_PLAYTEST_STARTUP_NOT_READY", + `Application startup stayed '${observed === undefined ? "warmup" : observed === BUSY ? "unreadable, the bridge kept timing out" : observed.phase}' for ${timeoutMs}ms; the run would have observed a game that has not finished loading.`, + "Let the application reach startup readiness — check that its loading gate can complete headlessly — or remove the runtime.startup capability if it has no startup phase.", + )); + let settled = false; + let timeout: ReturnType | undefined; + const expired = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + settled = true; + reject(timeoutError()); + }, timeoutMs); + }); + try { + return await Promise.race([expired, (async (): Promise => { + await options.warmup?.(deadline - now()); + if (settled) throw timeoutError(); + if (aborted()) throw abortedDuringStartup(); + if (settled || now() >= deadline) throw timeoutError(); + observed = BUSY; + observed = await pollStartup(bridge); + // Readiness, never bare compile settlement. A game whose launch holds build its world on + // `whenReady()` (the puzzle's crate pile is one), so observing at compile settlement catches it + // mid-load: the runs then assert against a half-built world and fail on state that never had a + // chance to arrive. The bounded frame window still expires on a CPU rasteriser, so this is at + // most `STARTUP_STABLE_WINDOW_MS` more per scenario, not a wait the lane can never meet. + while (observed === BUSY || observed.phase !== "ready") { + if (settled) throw timeoutError(); + if (aborted()) throw abortedDuringStartup(); + // Only an unreadable bridge asks the question: a host that answers is running by definition, + // and probing a live one every pump would charge each poll for a process lookup. + if (observed === BUSY) { + const alive = await hostAlive(); + if (settled) throw timeoutError(); + if (alive === false) throw hostExitedDuringStartup(); + } + if (settled || now() >= deadline) { + throw timeoutError(); + } + try { + await pump(deadline - now()); + } catch (error) { + if ( + error instanceof PlaytestBridgeError && + error.diagnostic.code === "TN_PLAYTEST_OPERATION_TIMEOUT" + ) { + observed = BUSY; + continue; + } + throw error; + } + if (settled || now() >= deadline) throw timeoutError(); + observed = await pollStartup(bridge); } - throw error; - } - observed = await pollStartup(bridge); + if (settled || now() >= deadline) throw timeoutError(); + return { + // A declared software lane reaches `ready` on the bounded window rather than on sustained + // in-budget frames, so it is labelled for what it is: not a smoothness measurement. + rule: options.declaredSoftware === true ? "compile-settled" : "sustained-frames", + startup: observed, + }; + })()]); + } finally { + settled = true; + if (timeout !== undefined) clearTimeout(timeout); } - return { - // A declared software lane reaches `ready` on the bounded window rather than on sustained - // in-budget frames, so it is labelled for what it is: not a smoothness measurement. - rule: options.declaredSoftware === true ? "compile-settled" : "sustained-frames", - startup: observed, - }; } /** diff --git a/packages/playtest/src/runner/steps.ts b/packages/playtest/src/runner/steps.ts index eb6889652b..21a67e3911 100644 --- a/packages/playtest/src/runner/steps.ts +++ b/packages/playtest/src/runner/steps.ts @@ -22,7 +22,7 @@ import { import { assertCaptureNotBlank, CaptureGuardError } from "../capture.js"; import { chromium, type Browser, type CDPSession, type Page } from "playwright"; -import { connectPlaytestBridge, PlaytestBridgeError, type IPlaytestBridgeClient } from "./bridgeClient.js"; +import { advanceTimeoutMs, connectPlaytestBridge, PlaytestBridgeError, type IPlaytestBridgeClient } from "./bridgeClient.js"; import { sampleElementVisibility } from "./sampling.js"; import { PERFORMANCE_BROWSER_ARGS, @@ -665,18 +665,45 @@ export async function sampleAfterTransition( return bridge.sample(request); } -export async function waitFrames(page: Page, frames: number): Promise { +export async function waitFrames(page: Page, frames: number, timeoutMs?: number): Promise { if (frames <= 0) return; - await page.evaluate((count) => new Promise((resolveFrame) => { - const schedule = (globalThis as unknown as { - requestAnimationFrame(callback: () => void): number; - }).requestAnimationFrame; - let remaining = count; - const next = () => { - remaining -= 1; - if (remaining <= 0) resolveFrame(); - else schedule(next); - }; - schedule(next); - }), frames); + let budgetMs: number; + if (timeoutMs === undefined) { + budgetMs = advanceTimeoutMs(frames); + } else if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + // An expired or malformed budget must not start a fresh deadline. + throw new PlaytestBridgeError(playtestDiagnostic( + "TN_PLAYTEST_OPERATION_TIMEOUT", + `waitFrames requested ${frames} frame(s) but the supplied remaining budget of ${String(timeoutMs)}ms is not a positive, finite value.`, + "Check that the page is producing requestAnimationFrame callbacks; the wait cannot complete while rendering is stalled.", + )); + } else { + budgetMs = timeoutMs; + } + let timeout: ReturnType | undefined; + try { + await Promise.race([ + page.evaluate((count) => new Promise((resolveFrame) => { + const schedule = (globalThis as unknown as { + requestAnimationFrame(callback: () => void): number; + }).requestAnimationFrame; + let remaining = count; + const next = () => { + remaining -= 1; + if (remaining <= 0) resolveFrame(); + else schedule(next); + }; + schedule(next); + }), frames), + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new PlaytestBridgeError(playtestDiagnostic( + "TN_PLAYTEST_OPERATION_TIMEOUT", + `waitFrames requested ${frames} frame(s) exceeded ${budgetMs}ms.`, + "Check that the page is producing requestAnimationFrame callbacks; the wait cannot complete while rendering is stalled.", + ))), budgetMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } } diff --git a/packages/runtime-native/conformance/scenes/shared/skinned-mesh-animation.js b/packages/runtime-native/conformance/scenes/shared/skinned-mesh-animation.js index a4153221d0..f48bacee14 100644 --- a/packages/runtime-native/conformance/scenes/shared/skinned-mesh-animation.js +++ b/packages/runtime-native/conformance/scenes/shared/skinned-mesh-animation.js @@ -1,11 +1,20 @@ import { assertCondition, startVisualScene, THREE } from "./scene-support.js"; import { playClipAt } from "./planned-animation-support.js"; +import { pass } from "three/tsl"; +import { AnimationPlayer } from "../../../../core/src/animation.ts"; +import { SkinnedBatch } from "../../../../core/src/projection-skinned.ts"; +import { velocityTexture, VelocityTracker } from "../../../../core/src/render/velocity.ts"; +import { createTemporalVelocityProbe } from "./temporal-velocity-probe.ts"; +import { REPROJECTION_PIXELS } from "./temporal-aa-fixture.js"; export function createSkinnedMeshAnimationProof() { const geometry = new THREE.BufferGeometry(); geometry.setAttribute( "position", - new THREE.Float32BufferAttribute([-0.52, -0.8, 0, 0.52, -0.8, 0, -0.52, 0.8, 0, 0.52, 0.8, 0], 3), + new THREE.Float32BufferAttribute( + [-0.52, -0.8, 0, 0.52, -0.8, 0, -0.52, 0.8, 0, 0.52, 0.8, 0], + 3, + ), ); geometry.setAttribute( "skinIndex", @@ -48,17 +57,240 @@ export function createSkinnedMeshAnimationProof() { return { mesh, rootBone, tipBone, clip, mixer, action }; } -export function startScene(canvas, dimensions) { +export function createRawSkinnedProof() { + const rigs = Array.from({ length: 3 }, (_, index) => { + const original = createSkinnedMeshAnimationProof(); + const mesh = original.mesh; + mesh.remove(original.rootBone); + const bones = Array.from({ length: 12 }, (_, i) => { + const bone = new THREE.Bone(); + bone.name = `bone${i}`; + bone.position.y = i === 0 ? 0 : 0.1; + return bone; + }); + for (let i = 1; i < 12; i++) bones[i - 1].add(bones[i]); + mesh.add(bones[0]); + const indices = mesh.geometry.getAttribute("skinIndex"); + for (let i = 0; i < indices.count; i++) indices.setXYZW(i, 11, 0, 0, 0); + mesh.bind(new THREE.Skeleton(bones)); + mesh.position.x = index === 1 ? 0.9 : -0.9; + return mesh; + }); + const clip = new THREE.AnimationClip( + "sway", + 2, + Array.from( + { length: 11 }, + (_, i) => + new THREE.NumberKeyframeTrack(`bone${i + 1}.rotation[z]`, [0, 1, 2], [-0.09, 0.09, -0.09]), + ), + ); + const allPlayers = rigs.map( + (root, index) => + new AnimationPlayer({ + root, + clips: [clip], + strideSync: false, + updateRate: index === 2 ? 1 : 4, + }), + ); + for (const player of allPlayers) { + player.play("sway"); + player.update(0, true); + } + for (const rig of rigs) rig.updateMatrixWorld(true); + const batch = new SkinnedBatch({ first: rigs[0], capacity: 2, velocity: true }); + batch.claim(rigs[0]); + batch.claim(rigs[1]); + let first = true; + const advance = () => { + for (const player of allPlayers) player.update(first ? 0 : 1 / 60); + first = false; + for (const rig of rigs) rig.updateMatrixWorld(true); + rigs[2].skeleton.update(); + batch.begin(); + batch.write(0, rigs[0]); + batch.write(1, rigs[1]); + batch.end(); + }; + const point = (mesh) => { + const result = new THREE.Vector3(); + for (const index of [0, 1, 2]) result.add(mesh.getVertexPosition(index, new THREE.Vector3())); + return result.multiplyScalar(1 / 3).applyMatrix4(mesh.matrixWorld); + }; + return { + rigs: rigs.slice(0, 2), + players: allPlayers.slice(0, 2), + batch, + advance, + stockUpdate: THREE.Skeleton.prototype.update, + receipt: () => batch.mesh.userData["threenative.animationPosePackets"](), + points: () => ({ raw: point(rigs[2]), retired: point(rigs[1]) }), + dispose: () => { + batch.dispose(); + for (const player of allPlayers) player.dispose(); + for (const rig of rigs) { + rig.geometry.dispose(); + rig.material.dispose(); + } + }, + }; +} + +export function createSkinnedVelocityPass(scene, camera) { + const scenePass = pass(scene, camera); + velocityTexture(scenePass); + return scenePass; +} + +export async function startScene(canvas, dimensions) { const proof = createSkinnedMeshAnimationProof(); - return startVisualScene(canvas, dimensions, "skinned-mesh-animation", ({ scene }) => { - scene.add(proof.mesh); - return { - ...proof, - detail: { + let complete; + let failed; + const finished = new Promise((resolve, reject) => { + complete = resolve; + failed = reject; + }); + const deadline = setTimeout(() => failed(new Error("RAW velocity proof timed out")), 30000); + // Readback assertions finish before startScene resolves and the entry opens startup readiness. + const state = await startVisualScene( + canvas, + dimensions, + "skinned-mesh-animation", + ({ renderer, scene, camera }) => { + scene.add(proof.mesh); + const raw = createRawSkinnedProof(); + scene.add(raw.batch.mesh); + const scenePass = createSkinnedVelocityPass(scene, camera); + const pipeline = new THREE.RenderPipeline(renderer); + pipeline.outputNode = scenePass.getTextureNode("output"); + const tracker = new VelocityTracker(); + const probe = createTemporalVelocityProbe(renderer, scene, camera, scenePass, raw.points); + let frame = 0; + let retired = false; + let restored = false; + let restoredRefresh = false; + let previousRetiredHeld = false; + let done = false; + let stopped = false; + let updateCalls = 0; + let draws = 0; + raw.batch.mesh.onAfterRender = () => { + draws += 1; + }; + const override = function () { + updateCalls += 1; + raw.stockUpdate.call(this); + }; + const detail = { clip: proof.clip.name, - bones: proof.mesh.skeleton.bones.length, + bones: 2, tipRotation: proof.tipBone.rotation.z, - }, - }; - }); + rawVelocity: null, + }; + const render = async () => { + if (stopped) return; + try { + if (!done) raw.advance(); + else { + raw.batch.begin(); + raw.batch.write(0, raw.rigs[0]); + raw.batch.write(1, raw.rigs[1]); + raw.batch.end(); + } + const metadata = raw.receipt(); + const held = raw.players.map((player) => !player.updateRateReport.evaluated); + if (restored && !held[1]) { + assertCondition( + metadata.slots[1].retired && !metadata.slots[1].currentRaw, + "a genuine refresh after method restoration must retain stock retirement", + ); + restoredRefresh = true; + } + tracker.update(scene); + probe.before(); + const priorDraws = draws; + pipeline.render(); + await probe.read(); + tracker.commit(scene); + assertCondition(draws > priorDraws, "RAW consumer must actually draw"); + frame += 1; + if (!done && retired && restoredRefresh && previousRetiredHeld && held.every(Boolean)) { + assertCondition( + metadata.slots[0].currentRaw && metadata.slots[0].previousRaw, + "left current and previous packets must remain RAW", + ); + assertCondition( + restored && + raw.rigs[1].skeleton.update === raw.stockUpdate && + metadata.slots[1].retired && + !metadata.slots[1].currentRaw && + !metadata.slots[1].previousRaw, + "restoring the stock method must preserve permanent stock retirement", + ); + const samples = probe.observation()?.samples; + const left = samples?.find((sample) => sample.name === "raw"); + const right = samples?.find((sample) => sample.name === "retired"); + assertCondition(left && right, "both actual velocity texels must be observed"); + for (const sample of [left, right]) + assertCondition( + Number.isFinite(sample.errorPixels) && + Number.isFinite(sample.misregistrationPixels) && + sample.errorPixels <= REPROJECTION_PIXELS && + sample.misregistrationPixels <= REPROJECTION_PIXELS, + `${sample.name} velocity must match its independent stock reference within ${REPROJECTION_PIXELS} px`, + ); + assertCondition( + left.expectedPixels > 0 && left.measuredPixels > 0, + "held RAW must consume nonzero previous-position motion", + ); + assertCondition( + right.expectedPixels === 0 && right.measuredPixels === 0, + "consecutive held stock displays must consume zero velocity", + ); + assertCondition(updateCalls === 0, "held retirement must not call author update"); + detail.rawVelocity = { + frame, + draws, + samples, + modes: metadata.slots, + updateCalls, + restored, + restoredRefresh, + precisionPixels: REPROJECTION_PIXELS, + }; + done = true; + clearTimeout(deadline); + complete(); + } + if (retired && !restored && metadata.slots[1].retired && held[1]) { + raw.rigs[1].skeleton.update = raw.stockUpdate; + restored = true; + } + previousRetiredHeld = retired && held.every(Boolean); + if (!retired && held.every(Boolean) && metadata.slots.every((slot) => slot.currentRaw)) { + raw.rigs[1].skeleton.update = override; + retired = true; + } + assertCondition( + done || frame < 24, + "RAW held/retired velocity prerequisite was never observed", + ); + } catch (error) { + stopped = true; + clearTimeout(deadline); + failed(error); + } + }; + return { + ...proof, + raw, + detail, + render, + }; + }, + { awaitRender: true }, + ); + await finished; + return state; } diff --git a/packages/runtime-native/tests/planned-animation-rows.test.mjs b/packages/runtime-native/tests/planned-animation-rows.test.mjs index b52dffd9a1..e2ac65a9a4 100644 --- a/packages/runtime-native/tests/planned-animation-rows.test.mjs +++ b/packages/runtime-native/tests/planned-animation-rows.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "vitest"; +import { PerspectiveCamera, Scene } from "three/webgpu"; import { createMixerAnimationProof } from "../conformance/scenes/shared/mixer-animation.js"; import { createMorphTargetAnimationProof } from "../conformance/scenes/shared/morph-target-animation.js"; @@ -14,7 +15,11 @@ import { playClipAt, } from "../conformance/scenes/shared/planned-animation-support.js"; import { advanceRotationWithRaf } from "../conformance/scenes/shared/raf-rotation.js"; -import { createSkinnedMeshAnimationProof } from "../conformance/scenes/shared/skinned-mesh-animation.js"; +import { + createRawSkinnedProof, + createSkinnedMeshAnimationProof, + createSkinnedVelocityPass, +} from "../conformance/scenes/shared/skinned-mesh-animation.js"; const root = fileURLToPath(new URL("../", import.meta.url)); const sceneNames = [ @@ -46,7 +51,10 @@ test("row 46 parses and evaluates an upstream glTF AnimationClip", async () => { const { mixer } = playClipAt(proof.gltf.scene, proof.clip, 0.5); assert.equal(mixer.time, 0.5); assert.ok(Math.abs(proof.animatedRoot.position.y - 0.075) < 1e-6); - assert.throws(() => assertAnimatedGltf({ scene: proof.gltf.scene, animations: [] }), /AnimationClip/u); + assert.throws( + () => assertAnimatedGltf({ scene: proof.gltf.scene, animations: [] }), + /AnimationClip/u, + ); }); test("row 50 advances only through three requestAnimationFrame callbacks", async () => { @@ -81,6 +89,46 @@ test("rows 51 through 53 exercise mixer, skeleton, and morph target semantics", assert.ok(Math.abs(morph.mesh.morphTargetInfluences[0] - 0.65) < 1e-6); }); +test("row 52 RAW fixture owns genuine players and independently retiring display slots", () => { + const proof = createRawSkinnedProof(); + const scenePass = createSkinnedVelocityPass(new Scene(), new PerspectiveCamera()); + try { + assert.equal(scenePass.getMRT().has("velocity"), true); + assert.equal(scenePass.renderTarget.textures.some((texture) => texture.name === "velocity"), true); + proof.advance(); + assert.equal(proof.batch.bones, 12); + assert.equal(proof.players.length, 2); + assert.deepEqual( + proof.receipt().slots.map((slot) => slot.currentRaw), + [true, true], + ); + const initial = proof.receipt().slots[0].currentTime; + proof.advance(); + assert.ok(proof.receipt().slots[0].currentTime > initial); + let calls = 0; + proof.rigs[1].skeleton.update = () => { + calls += 1; + }; + proof.advance(); + assert.deepEqual( + proof.receipt().slots.map((slot) => slot.currentRaw), + [true, false], + ); + assert.equal(calls, 0); + proof.rigs[1].skeleton.update = proof.stockUpdate; + for (let tick = 0; tick < 8; tick++) { + proof.advance(); + if (proof.players[1].updateRateReport.evaluated) break; + } + assert.equal(proof.players[1].updateRateReport.evaluated, true); + assert.equal(proof.receipt().slots[1].retired, true); + assert.equal(proof.receipt().slots[1].currentRaw, false); + } finally { + scenePass.renderTarget.dispose(); + proof.dispose(); + } +}); + test("all six rows are same-source, fail-closed, deterministic, and target-neutral", () => { const source = sceneNames .map((name) => readFileSync(join(root, "conformance/scenes/shared", name), "utf8")) diff --git a/packages/terrain/__tests__/editor-document.spec.ts b/packages/terrain/__tests__/editor-document.spec.ts index 2b84cd8606..a33bc8c75a 100644 --- a/packages/terrain/__tests__/editor-document.spec.ts +++ b/packages/terrain/__tests__/editor-document.spec.ts @@ -190,7 +190,7 @@ describe("project terrain editor document", () => { expect(() => new TerrainEditorDocument("relative.json")).toThrow("absolute"); }); it("streams accepted revisions and observes complete external file saves", async () => { - const { path, base, get, post } = await api(); + const { path, base, get, post, server } = await api(); const before = await get(); const cancellation = new AbortController(); const stream = await fetch(`${base}events`, { signal: cancellation.signal }); @@ -198,12 +198,23 @@ describe("project terrain editor document", () => { expect(reader).toBeTruthy(); const initial = new TextDecoder().decode((await reader?.read())?.value); expect(initial).toContain(before.revision); + // Observe the API save before the next complete external save; adjacent writes + // must not rely on a later unrelated filesystem event to refresh the document. + const committed = new Promise((resolve) => { + const changed = (changedPath: string) => { + if (changedPath !== path) return; + server.watcher.off("change", changed); + resolve(); + }; + server.watcher.on("change", changed); + }); const edit = await post({ baseRevision: before.revision, commands: [{ op: "update", id: "hills", patch: { params: { amplitude: 7 } } }], }); const next = await edit.json(); expect(new TextDecoder().decode((await reader?.read())?.value)).toContain(next.revision); + await committed; writeFileSync( path, JSON.stringify({ diff --git a/packages/terrain/src/editor/server.ts b/packages/terrain/src/editor/server.ts index f259a17da4..ea0a0dd468 100644 --- a/packages/terrain/src/editor/server.ts +++ b/packages/terrain/src/editor/server.ts @@ -1,5 +1,13 @@ import { createHash, randomUUID } from "node:crypto"; -import { lstatSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { + lstatSync, + readFileSync, + renameSync, + unlinkSync, + unwatchFile, + watchFile, + writeFileSync, +} from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import { basename, dirname, isAbsolute, resolve } from "node:path"; import type { Plugin } from "vite"; @@ -351,6 +359,10 @@ export function terrainEditor(options: { if (resolve(path) === document.path) document.refresh(); }; vite.watcher.on("add", refresh).on("change", refresh).on("unlink", refresh); + // Vite coalesces rapid same-path changes. Reconcile completed external saves + // independently so a save immediately after our atomic commit is not lost. + const refreshFile = () => document.refresh(); + watchFile(document.path, { interval: 100, persistent: false }, refreshFile); const heartbeat = setInterval(() => { for (const response of clients) if (!response.write(": keepalive\n\n")) response.end(); }, 15000); @@ -358,6 +370,7 @@ export function terrainEditor(options: { http.once("close", () => { clearInterval(heartbeat); unsubscribe(); + unwatchFile(document.path, refreshFile); for (const response of clients) response.end(); vite.watcher.off("add", refresh).off("change", refresh).off("unlink", refresh); }); diff --git a/scripts/__tests__/orphan-infrastructure.spec.ts b/scripts/__tests__/orphan-infrastructure.spec.ts index 1166ae9339..c49541868f 100644 --- a/scripts/__tests__/orphan-infrastructure.spec.ts +++ b/scripts/__tests__/orphan-infrastructure.spec.ts @@ -21,7 +21,7 @@ it.each(["0", "1"])( 'set -euo pipefail; suite_temp_root="$TN_TEST_ROOT"', source.slice(start, end), "capture_coordination_empty", - 'test "$(find "$suite_temp_root" -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq 1', + 'test "$(find "$suite_temp_root" -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq 2', 'mkdir "$capture_coordination_root/lock"', 'printf "owned lease\\n" > "$capture_coordination_root/lock/holder.json"', "if capture_coordination_empty; then exit 21; fi", diff --git a/scripts/__tests__/search-path.spec.ts b/scripts/__tests__/search-path.spec.ts index 1114ea64b4..e9c2b87f6c 100644 --- a/scripts/__tests__/search-path.spec.ts +++ b/scripts/__tests__/search-path.spec.ts @@ -166,7 +166,7 @@ describe("the tracked .ignore (PRD-357 F2/F5)", () => { .map((line) => line.split(":").slice(0, 2).join(":")); expect(sites).toEqual([ // Takes a log file path, not a directory. - "packages/playtest/__tests__/orphan-cleanup.sh:163", + "packages/playtest/__tests__/orphan-cleanup.sh:165", // Inert: an expected-substring literal inside a spec, never executed. "scripts/__tests__/sweep-delta.spec.ts:193", ]); diff --git a/scripts/__tests__/skinned-crowd-readiness.spec.ts b/scripts/__tests__/skinned-crowd-readiness.spec.ts new file mode 100644 index 0000000000..5f561e8c77 --- /dev/null +++ b/scripts/__tests__/skinned-crowd-readiness.spec.ts @@ -0,0 +1,176 @@ +import { readFileSync } from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FrameBudget, type IFrameBudgetWindow } from "../../packages/core/src/frame-budget.js"; +import { + type ISpanWindow, + SPANS, + SpanRecorder, + formatSpansWindow, +} from "../../packages/core/src/profiling/Spans.js"; +import { validatePlaytestScenario } from "../../packages/playtest/src/scenario.js"; + +vi.mock("@threenative/core", async () => ({ + ...(await import("../../packages/core/src/profiling/Spans.js")), + Scene: class {}, + SkeletalMesh3D: class {}, + lodPixelScale: () => 1, + spansRequested: () => true, +})); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +// Real producers: 120 simulation ticks may contain fewer or more than 120 renders. +function completedWindows(frames: number, arm: string) { + let tick = 0; + const recorder = new SpanRecorder(); + const windows: { budget: IFrameBudgetWindow; spans: ISpanWindow }[] = []; + const budget = new FrameBudget({ + reportEvery: 120, + simulation: { tick: () => tick, step: 1 / 60 }, + report: () => {}, + onWindow: (window) => { + const spans = recorder.window(); + if (spans === undefined) throw new Error("Missing span window"); + windows.push({ budget: window, spans }); + }, + }); + for (let window = 0; window < 3; window += 1) { + for (let frame = 0; frame < frames; frame += 1) { + const now = (window * frames + frame) * 16; + budget.beginFrame(now, now); + tick = window * 120 + Math.floor(((frame + 1) * 120) / frames); + for (let rig = 0; rig < 128; rig += 1) { + if (arm === "animated") recorder.add(SPANS.animationUpdate, 0.01); + if (arm !== "static") recorder.add(SPANS.skinnedWrite, 0.005); + } + recorder.endFrame(4); + budget.addRenderPasses([ + { kind: "main", draws: 3, triangles: 70659 }, + { kind: "shadow", draws: 1, triangles: 70656 }, + ]); + budget.addGpuMs(0.2); + budget.addGpuBucketMs({ main: 0.1, shadow: 0.1, shadowPasses: 1 }); + budget.endFrame(now + 4); + } + } + budget.dispose(); + return windows; +} + +async function fixture(arm = "animated") { + vi.stubGlobal("location", { search: `?crowdCount=128&crowdArm=${arm}` }); + vi.spyOn(console, "info").mockImplementation(() => {}); + return import("../../examples/skinned-crowd/src/scenes/Crowd.js"); +} + +describe("skinned crowd measurement bridge", () => { + it.each([30, 120, 258])( + "accepts aligned completed windows with %i renders and latches the first receipt", + async (frames) => { + const { crowdBudget, crowdSpans, crowdRate } = await fixture(); + const windows = completedWindows(frames, "animated"); + for (const { budget, spans } of windows.slice(0, 2)) { + crowdBudget(budget); + crowdSpans(formatSpansWindow(spans)); + } + expect(crowdRate.ready).toBe(true); + expect(crowdRate.measured).toMatchObject({ + window: 2, + renderFrames: frames, + simulatedSeconds: 2, + animationCalls: 128, + paletteCalls: 128, + animationUpdateMs: 1.28, + skinnedWriteMs: 0.64, + }); + const receipt = crowdRate.measured; + expect(Object.isFrozen(receipt)).toBe(true); + const next = windows[2]; + if (next === undefined) throw new Error("Missing third window"); + crowdBudget(next.budget); + crowdSpans(formatSpansWindow(next.spans)); + expect(crowdRate.measured).toBe(receipt); + }, + ); + + it.each(["held", "static"])("preserves the %s arm's absence expectations", async (arm) => { + const { crowdBudget, crowdSpans, crowdRate } = await fixture(arm); + for (const { budget, spans } of completedWindows(258, arm)) { + crowdBudget(budget); + crowdSpans(formatSpansWindow(spans)); + } + expect(crowdRate.ready).toBe(true); + expect(crowdRate.measured).toMatchObject({ + animationCalls: 0, + paletteCalls: arm === "static" ? 0 : 128, + }); + }); + + it.each([ + "frames", + "window", + "overflow", + "clock", + "incomplete", + "animation", + "palette", + "main", + "shadow", + "gpu", + "zeroGpu", + "spanFrames", + "calls", + "cpu", + ])("rejects missing or mismatched %s observations", async (missing) => { + const { crowdBudget, crowdSpans, crowdRate } = await fixture(); + const window = completedWindows(120, "animated")[1]; + if (window === undefined) throw new Error("Missing second window"); + let budget = window.budget; + let spans = window.spans; + if (missing === "frames") spans = { ...spans, frames: 119 }; + if (missing === "window") spans = { ...spans, window: 3 }; + if (missing === "overflow") spans = { ...spans, overflowed: 1 }; + if (missing === "clock") budget = { ...budget, simulatedSeconds: undefined }; + if (missing === "incomplete") budget = { ...budget, simulatedSeconds: 1 }; + if (missing === "animation" || missing === "palette") { + const rows = { ...spans.spans }; + delete rows[missing === "animation" ? "animationUpdate" : "skinnedWrite"]; + spans = { ...spans, spans: rows }; + } + if (missing === "main") budget = { ...budget, gpuMainSamples: undefined }; + if (missing === "shadow") budget = { ...budget, gpuShadowRendered: undefined }; + if (missing === "gpu") budget = { ...budget, gpu: undefined }; + if (missing === "zeroGpu") budget = { ...budget, gpuMainSamples: 0 }; + if (["spanFrames", "calls", "cpu"].includes(missing)) { + const animation = spans.spans.animationUpdate; + if (animation === undefined) throw new Error("Missing animation span"); + spans = { + ...spans, + spans: { + ...spans.spans, + animationUpdate: { + ...animation, + ...(missing === "spanFrames" ? { frames: 119 } : {}), + ...(missing === "calls" ? { perFrame: 127 } : {}), + ...(missing === "cpu" ? { mean: Number.NaN } : {}), + }, + }, + }; + } + crowdBudget(budget); + crowdSpans(formatSpansWindow(spans)); + expect(crowdRate.ready).toBe(false); + expect(crowdRate.measured).toBeNull(); + }); + + it("validates the rate scenario schema", () => { + const path = "examples/skinned-crowd/playtests/crowd-rate.playtest.json"; + expect(() => + validatePlaytestScenario(JSON.parse(readFileSync(path, "utf8")), path), + ).not.toThrow(); + }); +});