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..76285606f2 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,8 +4,8 @@ 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. +**Status:** PARTIAL — current runtime regressions, CPU/GPU attribution, native behavior and repeated visual equivalence are verified; stable CPU acceptance, shooter and final qualification remain open. +**Priority:** P2 — finish GPU attribution, the conditional skin-once decision and shooter acceptance. **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 **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. @@ -137,21 +137,326 @@ 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 +**Status:** PARTIAL — accumulated time, stagger, skipped writes and velocity history are verified. The stable Intel comparison fails AC-1's 40% reduction; native desktop is refreshed; repeated visual equivalence still needs refresh after develop integration. **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`. +- [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. +- [x] [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`. +- [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 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 `. 2026-10-13 reopened: fresh Sonnet 5.5 high inspection found no visible pose loss (NEUTRAL), but the previous captures lack two independent runs per arm, burned-in labels and complete per-run pose provenance. Repeat captures are required before this box is verified. +- [x] [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 source e89f2a17c: rebuilt Linux V8/Dawn host, physical RTX 2080/Turing, NVIDIA 615.78.08. The shipped desktop CLI passes all 16 crowd-rate assertions with zero diagnostics; its guarded scenario copy additionally retains four completed windows. The auto-policy receipt proves 128 animated/projected rigs, window 2, 329 matching render/span frames over two simulation seconds, 128 animation/palette calls per frame and 329 independently observed main/shadow GPU samples. Source, runtime-dist, native-project and host hashes pass before/after. Verdict: artifacts/prd570-behavior-175b2bae5/native/report.stdout.log; raw console and source snapshot remain beside it. This is native correctness, not an FPS or CPU-saving claim. #### Phase 3: Skinning runs once per frame, if Phase 1 earns it -**Status:** NOT STARTED +**Status:** COMPLETE — skin-once declined: measured held-minus-static main-plus-shadow GPU overhead is below 0.5 ms at 32, 128 and 256 rigs on Intel Iris Xe. **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`. +- [x] [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-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. 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/packages/core/__tests__/animation-rate.spec.ts b/packages/core/__tests__/animation-rate.spec.ts new file mode 100644 index 0000000000..4850dcf143 --- /dev/null +++ b/packages/core/__tests__/animation-rate.spec.ts @@ -0,0 +1,736 @@ +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 } 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.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 }); + 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__/projection-skinned.spec.ts b/packages/core/__tests__/projection-skinned.spec.ts index 66d86dffe1..98938380c4 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,269 @@ 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("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/capabilities.json b/packages/core/capabilities.json index 028797ead0..799b3a1bd9 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" }, @@ -2903,7 +2906,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 { … }", @@ -2912,9 +2916,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.ts b/packages/core/src/animation.ts index d53ede1c0b..7d37c14de0 100644 --- a/packages/core/src/animation.ts +++ b/packages/core/src/animation.ts @@ -2,6 +2,8 @@ import { type AnimationAction, type AnimationClip, AnimationMixer, + type AnimationMixerEventMap, + type EventListener, LoopOnce, LoopRepeat, type Object3D, @@ -9,6 +11,7 @@ import { type VectorKeyframeTrack, } from "three"; 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 +41,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 action time-scale interpolations are protected at effective rate 1 until they settle. + */ + readonly updateRate?: "auto" | number; +} + +export interface IAnimationUpdateRateReport { + readonly rate: number; + readonly overridden: boolean; + readonly evaluated: boolean; } /** @@ -225,10 +239,99 @@ 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 #beforeFirstLoopListener: BeforeFirstLoopListener; + #updateDepth = 0; + + constructor(root: Object3D, beforeFirstLoopListener: BeforeFirstLoopListener) { + super(root); + this.#beforeFirstLoopListener = beforeFirstLoopListener; + } + + get hasLoopListeners(): boolean { + return this.#loopListeners.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.hasLoopListeners) { + const duringUpdate = this.#updateDepth > 0; + this.#beforeFirstLoopListener(duringUpdate); + } + this.#loopListeners.add(loopListener); + } + super.addEventListener(type, loopListener); + return; + } + super.addEventListener(type, listener as 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; + } + super.removeEventListener(type, listener as FinishedListener); + } + + override update(deltaTime: number): AnimationMixer { + this.#updateDepth += 1; + try { + return super.update(deltaTime); + } finally { + this.#updateDepth -= 1; + } + } +} export class AnimationPlayer { readonly mixer: AnimationMixer; readonly root: Object3D; + readonly #rateState: IAnimationRateState = { + rate: 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 +367,10 @@ 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.#preparation = options.preparation; this.#strideSync = options.strideSync ?? true; this.#strideRoot = options.strideRoot ?? options.root; @@ -280,6 +386,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); } @@ -300,6 +407,62 @@ export class AnimationPlayer { ); } this.mixer.addEventListener("finished", this.#onFinished); + 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 { + // 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.hasLoopListeners) 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 +617,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 +637,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 +658,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 +734,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 +794,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 +903,87 @@ 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."); + 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.#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); + } + + #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; + 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(elapsed); + } finally { + this.#evaluating = false; + 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,10 +1007,15 @@ 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.#current = undefined; this.#mode = "loop"; @@ -776,6 +1026,7 @@ export class AnimationPlayer { this.#blendDuration = 0; this.#blendElapsed = 0; this.#blendStarts.clear(); + this.#timeScaleInterpolations.clear(); this.#resetStride(); } @@ -796,6 +1047,10 @@ export class AnimationPlayer { this.#clips.clear(); this.#clipGroundSpeed.clear(); this.#preparation = undefined; + 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 4766faed31..10ff902a5f 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/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..b104fc85d3 100644 --- a/packages/core/src/projection-skinned.ts +++ b/packages/core/src/projection-skinned.ts @@ -1,6 +1,7 @@ import { InstancedBufferGeometry, type Material, + Matrix4, Mesh, type Object3D, type Skeleton, @@ -33,6 +34,16 @@ import { StorageBufferAttribute, } from "three/webgpu"; +import { SPANS, beginSpan, endSpan } from "./profiling/Spans.js"; + +const ANIMATION_RATE_SOURCES_KEY = "threenative.animationRateSources"; +const ANIMATION_RATE_STATE_KEY = "threenative.animationRateState"; + +interface IAnimationRateStateLike { + readonly evaluated: boolean; + 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 +207,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,6 +218,8 @@ export class SkinnedBatch { readonly #fresh: number[] = []; readonly #bindScratch = new Float64Array(16); readonly #updated = new WeakMap(); + readonly #writtenEvaluations = new WeakMap(); + readonly #writtenWorldMatrices = new WeakMap(); #frame = 0; #dirty = false; #sourceVersion: number; @@ -270,6 +284,7 @@ export class SkinnedBatch { this.mesh = new Mesh(geometry, material); this.mesh.matrixAutoUpdate = false; + this.mesh.userData[ANIMATION_RATE_SOURCES_KEY] = this.instances; // The palette spans every rig; only the per-rig sources have meaningful bounds. this.mesh.frustumCulled = false; this.mesh.castShadow = first.castShadow; @@ -313,6 +328,7 @@ export class SkinnedBatch { this.used += 1; } this.instances.set(rig, slot); + this.#slotRigs.set(slot, rig); this.#fresh.push(slot); return slot; } @@ -322,6 +338,9 @@ export class SkinnedBatch { const slot = this.instances.get(rig); if (slot === undefined) return; this.instances.delete(rig); + this.#slotRigs.delete(slot); + this.#writtenEvaluations.delete(rig); + this.#writtenWorldMatrices.delete(rig); this.hide(slot); this.free.push(slot); } @@ -338,53 +357,82 @@ export class SkinnedBatch { /** 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); 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 { - const skeleton = rig.skeleton; - if (this.#updated.get(skeleton) !== this.#frame) { - skeleton.update(); - this.#updated.set(skeleton, this.#frame); - } - const bones = skeleton.boneMatrices as Float32Array; - const bind = rig.bindMatrix.elements; - const bindIsIdentity = isIdentity(bind); - const detached = rig.bindMode !== "attached"; - const prefix = this.#bindScratch; - if (detached) - multiply(rig.matrixWorld.elements, 0, rig.bindMatrixInverse.elements, 0, prefix, 0); - const out = this.#current; - const offset = slot * this.bones * 16; - // The common case is one contiguous copy: `skeleton.update` has already written this rig's - // bone matrices in palette order, and an identity bind matrix leaves them unchanged. - if (bindIsIdentity) out.set(bones, offset); - else { - for (let bone = 0; bone < this.bones; bone += 1) { - multiply(bones, bone * 16, bind, 0, out, offset + bone * 16); + beginSpan(SPANS.skinnedWrite); + try { + const state = animationRateStateOf(rig); + 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(); + this.#updated.set(skeleton, this.#frame); } - } - if (detached) { - for (let bone = 0; bone < this.bones; bone += 1) { - const at = offset + bone * 16; - // In place is safe: `multiply` reads each right-hand column before writing it. - multiply(prefix, 0, out, at, out, at); + const bones = skeleton.boneMatrices as Float32Array; + const bind = rig.bindMatrix.elements; + const bindIsIdentity = isIdentity(bind); + const detached = rig.bindMode !== "attached"; + const prefix = this.#bindScratch; + if (detached) + multiply(rig.matrixWorld.elements, 0, rig.bindMatrixInverse.elements, 0, prefix, 0); + const out = this.#current; + const offset = slot * this.bones * 16; + // The common case is one contiguous copy: `skeleton.update` has already written this rig's + // bone matrices in palette order, and an identity bind matrix leaves them unchanged. + if (bindIsIdentity) out.set(bones, offset); + else { + for (let bone = 0; bone < this.bones; bone += 1) { + multiply(bones, bone * 16, bind, 0, out, offset + bone * 16); + } } + if (detached) { + for (let bone = 0; bone < this.bones; bone += 1) { + const at = offset + bone * 16; + // In place is safe: `multiply` reads each right-hand column before writing it. + multiply(prefix, 0, out, at, out, at); + } + } + 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); + } + } finally { + endSpan(SPANS.skinnedWrite); } - this.#dirty = true; } /** Ends one reconcile: brand-new slots get history equal to their pose, then the palette uploads. */ @@ -415,5 +463,20 @@ export class SkinnedBatch { this.#previousAttribute?.dispose(); this.mesh.material.dispose(); this.instances.clear(); + this.#slotRigs.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..4c67f54ab6 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"; @@ -72,6 +72,19 @@ 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; + 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 +154,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 +174,7 @@ export class RenderCameraCull { #cameraZ = 0; #scale = 0; #cameraResolved = true; + readonly #animationRay = new Ray(); #considered = 0; #culled = 0; #exemptCameraAttached = 0; @@ -175,6 +193,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 +214,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 +250,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 +260,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 +304,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)); } #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 +351,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; } @@ -288,6 +362,7 @@ export class RenderCameraCull { // 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); + 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 +390,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 +398,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) : 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,6 +482,15 @@ 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, 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 7ba2cc1f07..3270b77290 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -248,21 +248,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 merge: measured all thirteen trees, including action-rpg onCull instructions. - "action-rpg": "cdedb47ee03791a866fdb6ca98c51e61ba05ad75dab5aa23925a3d153acfa389", - minimal: "8d834289ea3940529d925f53a4946cc718771dc4fb8577c2199ba6a29faf51d4", - platformer: "f5d4a0362bd3a6f7bb3f8530178e22d5cbe0bd8625d0c83121b53f8f5aa3a2ec", - puzzle: "cdacbec8e3543a394263bd73c42d7482b61c433df6cb60ce42bc8306f91f71e9", - racing: "cec5c42b232f5f858fdefae9275b324378c2914d5113185e3ec3a748162caf23", - rain: "05027f3370b26deb260e158240a09904ea54e593a8184767eead660810bf7551", - rts: "6f331bb87e10340cd33263edbff4f7ce1fc40d24b601ea786a06ea41e1f065a5", - runner: "4f7a6b3ace688771a7c96e67062a8b696d0b50c09376183f2f02eead97361c12", + // PRD-569/570 merge: the measured trees retain particle/onCull and updateRate guidance. + "action-rpg": "4b5b69696ed344b4306d71a1040ce86d34e5d815e17c7fdc61e5d86d38437262", + minimal: "ee2fbdf0a58d98f309614c9ecc31d276d2e7a4ba797c60a0de1fa211271dab44", + platformer: "400084c29381beb085c2a466f17798bd2f73d701c504101424b9bb498b0faac0", + puzzle: "41a369c51d108b1f167a8a2d7b4d18d5202479ccecb1c8941f77932d17040f69", + racing: "0bafc5140136d77ad37ad6d511d3f781cf8a9b1d08f0c21ed2d45650044c6756", + rain: "315f5ad5774a7d86644c8ba52ac72876d69e8aa36ecdee722dba284e10a41712", + rts: "483528014128179489bbabe59b0947d56f5bf9f9a70f6018fcf70234e5190047", + runner: "c32b137ce45a000a8c71a7c502dabb099720a0954142edd0f93f2edef82c9f09", // Initial finite-height readiness plus its scene-owned lifecycle helper and mirrored docs. - sailing: "9c9edb14b880fc446e9b921b60dd99053f45032f053c4fcb5cc820a323c43904", - shooter: "699758de47b7642a38b998dad0437b17c6a68df046d8807ddbaf35a61d9f1423", - snow: "837eee8058f3bf043eabe5161207e62fbc5accd83cd1353902b79bb55f2dd1cf", - starter: "21b5ccb273b27ff8b711c7b2c5f42345adb7ab941fa19e605b64b01626f5aa68", - "tower-defense": "95e514738bd04ba2a561f3500d877af1d0c540a9e40fb00829770455006fd14c", + sailing: "4558d95ad1035b460a870d3adff9e5faadf71e3b552179b7f42733f13dc66f82", + shooter: "1883c82288c2932f07945d1ce00cc4dfb0c3f19341b93d88b8ebd91e57aac323", + snow: "cf8cb76bc6c1653c2c1cbb766494fa1e24ab9f044d61f4664498f17329453adf", + starter: "472e2ec3a7e124e84edab5da5c65490e403d8b6151688b4c68997a96cd69a8aa", + "tower-defense": "90b330ef0fb22c2e533e83f1d2fa0b8ead6978359957025a942c7f098aec823d", }; 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 083d47c086..902eacb065 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 }); @@ -2230,15 +2230,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 028797ead0..799b3a1bd9 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" }, @@ -2903,7 +2906,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 { … }", @@ -2912,9 +2916,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 a70fdce540..cd5e94fdf6 100644 --- a/packages/create-threenative/templates/action-rpg/AGENTS.md +++ b/packages/create-threenative/templates/action-rpg/AGENTS.md @@ -74,6 +74,7 @@ cancel and an axis-rolled limb is caught where a bone-direction check reads zero `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, 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 e34313393a..708be5bcdc 100644 --- a/packages/create-threenative/templates/action-rpg/CLAUDE.md +++ b/packages/create-threenative/templates/action-rpg/CLAUDE.md @@ -76,6 +76,7 @@ cancel and an axis-rolled limb is caught where a bone-direction check reads zero `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, 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..e5c676f5d0 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, 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..69935b97f3 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, 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..17e3e0e154 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, 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..2aa56d3307 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, 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..871cd6c727 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, 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..7dbd90e2fd 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, 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..432cd4ebea 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, 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..941fdab984 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, 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..ba6599ac9c 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, 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..63a2bf6e7e 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, 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..21bf3b3779 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, 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..60c297b584 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, 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..dc01f7a9ad 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, 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..615fb72a86 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, 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..7f3a348dd2 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, 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..5acdff7d20 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, 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..c59b7261c0 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, 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..ecde3639f1 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, 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/snow/AGENTS.md b/packages/create-threenative/templates/snow/AGENTS.md index 3ffd77fab4..9b97927c0a 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, 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..b9f047c8ec 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, 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..c5859dfb7d 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, 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..1a56683ee5 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, 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..2c5d8ffac5 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, 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..1ba98d68b7 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, 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/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(); + }); +});