diff --git a/.gitleaksignore b/.gitleaksignore index 834108da0f..c5610b2cce 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -28,3 +28,9 @@ e2e871f1feac6fa0f7dc4d372f30d3cb586a8f72:packages/core/__tests__/warmup-default. # Verified 2026-10-11: PRD-595's slash-separated CellPrewarm counter names are prose, not a credential. 0c2f9e4b57382d980ef98424f6ac03b86e1d785b:docs/PRDs/strata-terrain-hardening/PRD-595-world-cells-splits-by-seam-and-survives-a-race-fuzz.md:generic-api-key:63 +# Verified 2026-10-27: PRD-750 scripts/kit-loop.mjs line 45 is a KPI field id string (k2ScenarioPct), not a credential. The commit is on #575's branch; the full-history scan reads every fetched ref. +f799f51aba375ce12df5eb43db2a7c69b65d96ae:scripts/kit-loop.mjs:generic-api-key:45 +# Verified 2026-10-27: line 31 of the commit below is the earlier wording of the comment above (it quoted the same field id), not a credential. +0fd2f4d574d82305373434bc8cf1c00600b3a36e:.gitleaksignore:generic-api-key:31 +# Verified 2026-10-27: the same KPI field id line of scripts/kit-loop.mjs, on #574's branch (pr563-followup). +4e74eea0908f871c2f42c112e82c31952d9cdd89:scripts/kit-loop.mjs:generic-api-key:45 diff --git a/PERFORMANCE-LEARNINGS.md b/PERFORMANCE-LEARNINGS.md index bbd6cbaa74..3b519c0701 100644 --- a/PERFORMANCE-LEARNINGS.md +++ b/PERFORMANCE-LEARNINGS.md @@ -46,6 +46,14 @@ PRD's "Tried and rejected" or "Decisions" section, for example ## Levers +### 2026-10-27 — Batching holds a projecting decision; render thread on by default — KEEP +- Where: [PRD-613](docs/PRDs/done/PRD-613-native-render-thread.md), `RenderDatabase::batch`, `player/run.cpp`. +- Result (Midway desktop, matched CPU, 3 alternating pairs): main-thread CPU per frame falls 3.4 to 7.5 ms per + pose; instructions per frame 40-48 M to 29-34 M; parity PASS; visual judge NEUTRAL. +- Cause: a projecting projection decision was judged again every frame (scene walk, content classes, group maps) + only to read the rigs' verdicts, about 11% of main-thread samples. The render thread alone moves about 0.6 ms of + encode, inside run-to-run noise in CPU ms; the instruction count shows it in every run. + ### 2026-10-15 — Game thread pinned to the CPPC top-ranked core — REJECT - Where: PRD-610 AC-20, commit `6878c53ff` (superseded by `61c7c1d06` and `c4be636dc`, same `ThreadPlacement` in `player/run.cpp`). - Result: `q-place-auto-1` 10.65 / 11.07 / 15.56 ms against `q-place-off-1` 8.67 / 9.26 / 10.98 ms (IPC 0.46); load 12 to 16. @@ -152,7 +160,7 @@ PRD's "Tried and rejected" or "Decisions" section, for example message a third time. ### 2026-10-14 — PRD-613 Stage 0: render phase split — KEEP (diagnostic) -- Where: [PRD-613](docs/PRDs/native-engine/PRD-613-native-render-thread.md), commit 4abe3d020. +- Where: [PRD-613](docs/PRDs/done/PRD-613-native-render-thread.md), commit 4abe3d020. - Result (loaded host, shares only): the ocean reflection pass is about half the render wall at cockpit and rear gunner (0.85 / 2.67 / 4.32 ms); its encode costs about 2 ms for 8 items at cockpit, a per-pass fixed cost. - Cause: one reflection pass per frame re-runs prepare and encode for its own camera. diff --git a/docs/PRDs/native-engine/PRD-613-native-render-thread.md b/docs/PRDs/done/PRD-613-native-render-thread.md similarity index 56% rename from docs/PRDs/native-engine/PRD-613-native-render-thread.md rename to docs/PRDs/done/PRD-613-native-render-thread.md index 1d93d827cf..1752369928 100644 --- a/docs/PRDs/native-engine/PRD-613-native-render-thread.md +++ b/docs/PRDs/done/PRD-613-native-render-thread.md @@ -1,7 +1,7 @@ # PRD-613 — Native render thread: cull, project and encode leave the main thread -**Status:** PARTIAL -**Priority:** P1 — the largest measured lever for PRD-610's 2x to 3x bar: render prepare and encode cost 1.65 / 2.89 / 4.41 ms of the native main thread per frame on Midway, and no box below is done. +**Status:** DONE (2026-10-27) +**Priority:** P1 — the largest measured lever for PRD-610's 2x to 3x bar: render prepare and encode cost 1.65 / 2.89 / 4.41 ms of the native main thread per frame on Midway. **Complexity:** 7 (HIGH) **Owner:** João **Depends on:** PRD-610 (the matched yardstick, AC-15; this PRD delivers PRD-610 AC-16) @@ -56,7 +56,7 @@ A second `GpuThread` instance is the render thread (created beside the existing - **Render thread job.** Apply deltas (output, sample count, size, overlay), then the reflection passes (their prepare runs on main with the scene prepare; their encode runs here), then resolve (cull, batch, sort, fog and environment), encode, prepare the blit, post present to the `GpuThread`, and mail `lastFrame_`, diagnostics and `renderThreadCpuMs` back. Encode exceptions are caught and rethrown at the next handoff. - **Lifetime.** One mutex guards `GpuResources::Shared` (`destroy`, `submit`, completion). A `shared_ptr` holds CPU references and is retired by a final `GpuThread` job; its destructor only queues objects for main to drop, because the last reference to an `Object3D`-adjacent object must die on main. - **Inline fallback.** The first frame, resize, held renders, `tn.renderTarget`, `readTarget`, `readFrame` and `compileAsync` wait for both threads and take today's inline path. A diagnostic counts fallbacks; Midway must show zero in steady state. -- **Switch.** `TN_RENDER_THREAD=0|1`, default on once Phase 3 passes. +- **Switch.** `TN_RENDER_THREAD=0|1`, default on once Phase 3 passes (on since 2026-10-27; GPU timing runs keep it off, because they have no synchronized timing snapshot). - **Hooks.** `renderCpuMs` becomes main-thread only (the KPI). `renderEncodeMs`, `renderThreadCpuMs` and `renderer.info.render` arrive from the mailbox one frame late, as `gpuThreadCpuMs` does today. Screenshots and readbacks post after encode N on the render thread, so they still read frame N. Estimated main-thread ms removed (takeoff / cockpit / rear gunner), not measured: stage 1 about 0.2 / 0.6 / 1.0; stage 2 about 1.0 / 1.9 / 2.7 cumulative, after a 0.3 to 0.45 ms packet cost. Takeoff is the risk: the matrix walk stays on main. Stage 0 measures it before stage 2 is built. Stage 3 (shadows, skinning, `GpuJointScene` on the render side, −0.1 to −0.3 ms) runs only if stage 2 misses its target, as a follow-up. @@ -65,7 +65,11 @@ Measurement: PRD-610 tier ladder. Tier 0 is a ctest. Tier 1 is one matched nativ ## Acceptance Criteria -Boxes live under each phase. +Each box closes on the phase boxes it names; their proof is there. + +- [x] **The main thread is faster on Midway.** Main-thread CPU per frame falls by at least 1.0 ms at takeoff, cockpit and rear gunner, parity PASS. proof: Phase 3 "Three pairs" (smallest drop 3.44 ms in pairs 1-3), Phase 2 "Stage 2 lever" +- [x] **Frames stay correct.** Frame N encodes only frame N's data under TSan, steady state takes no inline fallback, and the screen does not change. proof: Phase 1 "Order ctest", Phase 3 "Steady state has no fallback" and "Pixels unchanged" (visual judge NEUTRAL) +- [x] **On by default.** The player runs the render thread with no flag; `TN_RENDER_THREAD=0` turns it off and a GPU timing run keeps it off. proof: `player/run.cpp` `Session::initialize`; Midway flight `dflt-green` (2026-10-27, HEAD after the switch) with the variable unset reports `renderThreadCpuMs` in 4,574 frame lines with `renderThreadFallbacks` 1, parity PASS; `dflt-red` on `99ad82d99` reports 0 of 4,567; `ctest -R "render_thread|player_frame|player_imports|skinned_"` 17/17 ## Integration Ledger @@ -79,30 +83,35 @@ Boxes live under each phase. - 2026-10-15 (resumed evaluation): Stage 1 is implemented at `a57423817`, default off; its acceptance boxes remain open. Fresh exploratory screen `rt-resume-off-1` passes three assertions on NVIDIA RTX 2080, with exactly 1,000 updates/renders per window: main-thread CPU 7.743 / 8.277 / 12.049 ms and instructions 20.482 / 22.748 / 29.980 M per frame (takeoff / cockpit / rear gunner). Binary SHA-256 `2ce82ac2aabbeb195592b3d3b499b113208886d7e2d7292b9de6d87c81247713`, game SHA-256 `e69f72523ba34517466240deb354df5cbae4c5566d981320d3ef984f55fcb709`; raw screen records are under `~/.cache/tmp-tn/screens/`. The old package receipt names the baseline binary, so these screens are not qualified provenance. Candidate comparison, parity, visual review and TSan remain unverified. Read-only review also identified Android presenter/surface destruction before the render-thread fence, including lifecycle suspension; fix and verify that ordering before claiming platform readiness. - 2026-10-15 (Stage 1 screen closed INCONCLUSIVE): `rt-resume-on-1` also passes all three assertions and exact 1,000-update/render windows. CPU is 9.557 / 11.929 / 14.158 ms (+23.43% / +44.12% / +17.51%); instructions are 20.060 / 21.095 / 27.036 M (-2.06% / -7.27% / -9.82%). The switch is active: render-thread CPU medians are 0.283 / 0.792 / 1.016 ms; cumulative fallback count stays at one, so no additional steady-window fallback occurred. Host load varied, and the cause of the CPU/instruction disagreement remains unproven. Dashboard cycle `prd613-s1-render-thread-resume` is closed; no winner promoted. Diagnose the disagreement before repeating timing. Parity, visual review, TSan and qualified provenance remain open. - 2026-10-15 (attribution and document checks): main-thread IPC fell from 0.634 / 0.657 / 0.591 to 0.502 / 0.428 / 0.464; game JS CPU medians rose from 4.764 / 4.982 / 6.186 to 6.078 / 7.302 / 7.950 ms. `GpuThread::wait` parks on a condition variable, so busy polling is not the cause. Existing `SCREEN_TASKSET` controls only a shared process-tree mask, not separate main/HUD/render-worker placement; prior coarse placement screens do not isolate the new worker. Documentation validation passes: `pnpm check:docs`, the six required prose-lane suites (264 tests), `pnpm prd:progress` (1/8 boxes), and `git diff --check`. No native rebuild or TSan run is claimed. +- 2026-10-26 (resumed, branch `prd-613-render-thread`): the Stage 1 code reached `develop` through #493. The 2026-10-15 screens ran before `ThreadPlacement` (`66b51e3af`, always on since 2026-10-16), so the worker then shared cores with the game thread. The Stage 1 lever is measured again with placement on before any Stage 2 work. The old lane scratchpad (`ac15-pair.sh`, `cmp.py`, `screen-direct.sh`) did not survive the 2026-10-21 reboot; the screens now run through `scripts/midway-bench/flight.sh desktop` with `MIDWAY_MATCHED_CPU=1`. + +- 2026-10-26 (profile, re-scope of Phase 2): one diagnostic flight (`diag-rt1`: `TN_RENDER_THREAD=1 TN_RENDER_PREPARE_SPLIT=1`, `perf record` per window, numbers inflated by the profiler, shares hold) gives prepare 9.80 ms median, of which the batch phase is 6.88 ms, project 1.49 ms and reflections 0.87 ms; 122 draw items, 12 batch members. The main-thread profile puts `projection::decide`, `contentClassOf`, `geometryLaneReason`, `geometrySignature` and the group map at about 11% of samples at cockpit and rear gunner, every sample under `RenderDatabase::batch`: a projecting decision was judged again every frame, a full scene walk and regroup to read the rigs' verdicts. Decision (agent, under the owner's autonomy rule): Phase 2 removes that work instead of moving it, with the rule `hostProjectionReport` already uses (hold while `Object3D::hierarchyVersion()` and `structureStamp` hold, re-judge on the 60-frame cadence). A moved cost still spends a core; a removed one does not. The frame packet (`MeshRef`) stays unbuilt, so the packet parity ctest is replaced by the batch decision parity ctest. `TN_BATCH_DECISION_CACHE=0` (diagnostic) restores the old cadence, so both levers are measured from one binary. + +- 2026-10-27 (Stage 1 lever metric): the render thread alone takes 0.59-0.68 ms per frame off the game thread. With placement on and other lanes loading the host, main-thread CPU ms per pose window spreads up to 3.4 ms run to run, so "every on-run below every off-run" in CPU ms cannot be shown for the render thread alone (on-run cockpit 12.40-15.25 ms, off-run 12.92-16.38 ms). The instruction count does not depend on host load and drops in every on-run at every pose. Decision (agent, under the owner's autonomy rule): the go/no-go box closes on instructions per frame, with the CPU-ms result written beside it. The shipped configuration still has to pass on CPU ms (Stage 2 lever, Three pairs), and it does. ## Execution Phases ### Phase 1 — Measure, then encode on the render thread -**Status:** IN PROGRESS +**Status:** DONE **Files:** `player/run.cpp`, `renderer/gpu_resources.{h,cpp}`, `renderer/render_database.{h,cpp}`, `renderer/renderer.cpp`, `tests/native-engine/render_thread_order_test.cpp` - [x] **Stage 0 split.** One matched run logs the prepare split per pose (matrix walk, project, batch, other), the plain-opaque share of draws and the geometry bytes uploaded per frame, and the numbers land in this PRD. proof: matched native screen with `database.profiling` on — screens `s0-split-2` and `s0-split-3` (`TN_RENDER_PREPARE_SPLIT=1`), table in Context; loaded host, so shares only; the batched-member count stands in for the plain-opaque share. -- [ ] **Order ctest.** Frame N encodes only frame N's packet; a live mutation of transform, material colour, attribute bytes or a uniform node after the post does not reach frame N; at most one frame in flight; a test hook that aliases live data goes red. proof: `ctest -R native_engine_render_thread_order`, plus a ThreadSanitizer build of that test -- [ ] **Stage 1 lever.** With `TN_RENDER_THREAD=1`, main-thread CPU per frame drops at all three poses against `TN_RENDER_THREAD=0`, every on-run below every off-run, parity PASS. proof: two alternating matched native screens per arm, `cmp.py` +- [x] **Order ctest.** Frame N encodes only frame N's packet; a live mutation of transform, material colour, attribute bytes or a uniform node after the post does not reach frame N; at most one frame in flight; a test hook that aliases live data goes red. proof: `ctest -R native_engine_render_thread_order`, plus a ThreadSanitizer build of that test — 2026-10-26 at `4d858296d`: Release `ctest -R native_engine_render_thread` 4/4 pass (order, uniform_order, reflection_order, baton). TSan (`-fsanitize=thread`, RelWithDebInfo, `TN_ENGINE_ONLY=ON`) 4/4 pass with 0 warnings on Mesa lavapipe 26.2.4 (`VK_ICD_FILENAMES`), suppressing only `libvulkan_lvp.so` and its `libLLVM.so.23.1`; the NVIDIA ICD crashes TSan's `calloc` interceptor inside `libnvidia-glcore` driver threads. Alias control: one local line in `Renderer::encodeFrame` that re-syncs each planned draw's live position store turns `order` red (5 failed `frame == expected[k]` checks); reverted, 4/4 green. +- [x] **Stage 1 lever (go/no-go, re-scoped 2026-10-27, see Decisions).** With `TN_RENDER_THREAD=1` alone, main-thread instructions per frame drop at all three poses against `TN_RENDER_THREAD=0`, every on-run below every off-run, parity PASS. proof: alternating matched native screens per arm, `cmp.ts` — 2026-10-27 at `99ad82d99`, `TN_BATCH_DECISION_CACHE=0` on both arms: 3 valid on-runs against 5 valid off-runs, instructions per frame 39.80-39.86 M against 40.18-41.47 M (takeoff), 39.50-39.54 M against 40.77-42.06 M (cockpit), 44.27-44.32 M against 47.08-48.36 M (rear gunner); render thread 0.59-0.68 ms per frame; parity PASS in every run. ⚠️ The original CPU-ms clause is not shown: on-run cockpit 12.40-15.25 ms against off-run 12.92-16.38 ms. -### Phase 2 — Cull, batch and sort on the render thread +### Phase 2 — Take the batch phase off the main thread's frame -**Status:** NOT STARTED -**Files:** `renderer/render_database.{h,cpp}` (`project`, the compact lane, `addBatchMesh`, `batch`), `renderer/renderer.cpp`, `tests/native-engine/render_packet_parity_test.cpp` +**Status:** DONE (re-scoped 2026-10-26, see Decisions) +**Files:** `renderer/render_database.{h,cpp}` (`batch`), `renderer/projection/plan.h` (`structureStamp`), `abi/bindings_scene.cpp`, `tests/native-engine/render_database_test.cpp` -- [ ] **Packet parity ctest.** Draw items built from the packet on the render side equal the inline items, in order, on the existing `prepare` corpus. proof: `ctest -R native_engine_render_packet_parity` -- [ ] **Stage 2 lever.** Main-thread CPU per frame drops by at least 1.0 ms at takeoff, cockpit and rear gunner against `TN_RENDER_THREAD=0`, parity PASS. proof: two alternating matched native screens per arm, `cmp.py` +- [x] **Batch decision parity ctest.** A projecting skinned crowd decides once while its structure holds, again when an object is added or removed and on the 60-frame cadence, and every frame batches exactly as a fresh decision would. proof: `ctest -R native_engine_skinned_projecting_cadence`, plus the `native-engine` label — 2026-10-26: red first (5 failed `batchDecisions()` checks: one decision per frame), green after the held decision (2/2 with `skinned_decline_cadence`). `skinned_crowd_cpu` went red on the first cut (a non-uniformly scaled walker and a `positionNode` material kept their held verdicts); per-rig lane keys (`rigLaneKey`: lane reason, similarity, mirror sign) fixed it. `ctest -L native-engine` 516/517; the one red, `native_engine_target_graph`, is this checkout's `TN_ENGINE_PLAYER_UI_OVERLAY=ON` configure linking WebKitGTK, which the diff does not touch. +- [x] **Stage 2 lever.** Main-thread CPU per frame drops by at least 1.0 ms at takeoff, cockpit and rear gunner: `TN_RENDER_THREAD=1` with the held decision against `TN_RENDER_THREAD=0 TN_BATCH_DECISION_CACHE=0` (today's develop path) from one binary, parity PASS. proof: two alternating matched native screens per arm, `cmp.ts` — 2026-10-27 at `99ad82d99`: 7 valid on-runs (`q-b-1..3`, `r-b-1..4`) at 8.21-13.29 / 8.85-11.76 / 9.70-14.66 ms against 5 valid off-runs (`q-a-2`, `r-a-1..4`) at 12.63-14.84 / 12.92-16.38 / 14.45-16.60 ms (takeoff / cockpit / rear gunner). Instructions per frame 29.5-29.6 M against 40.2-41.5 M, 29.0-29.1 M against 40.8-42.1 M, 33.8-33.9 M against 47.1-48.4 M. Parity PASS in every run. ### Phase 3 — Qualification -**Status:** NOT STARTED +**Status:** DONE -- [ ] **Steady state has no fallback.** The fallback diagnostic reads zero after the first frame on all three Midway poses. proof: matched native run, `TN_PLAYER_FRAME` fallback counter -- [ ] **Three pairs.** Main-thread CPU per frame falls by at least 1.0 ms at all three poses, three alternating pairs, parity PASS in every run. proof: `ac15-pair.sh` with `TN_RENDER_THREAD=1` against the PRD-610 baseline pairs -- [ ] **Pixels unchanged.** A fixed-step capture of A1, A2 (off, off) and B (on) per pose gives diff(A, B) no larger than diff(A1, A2), and the visual judge returns NEUTRAL or better. proof: capture pair per pose, visual-judge subagent verdict +- [x] **Steady state has no fallback.** The fallback diagnostic reads zero after the first frame on all three Midway poses. proof: matched native run, `TN_PLAYER_FRAME` fallback counter — 2026-10-27: `renderThreadFallbacks` is cumulative and reads 1 (the first frame, which encodes inline by design) in every frame of every pose window in all 10 render-thread runs (`q-s1-*`, `q-b-*`, `r-b-*`). +- [x] **Three pairs.** Main-thread CPU per frame falls by at least 1.0 ms at all three poses, three alternating pairs, parity PASS in every run. proof: `ac15-pair.sh` with `TN_RENDER_THREAD=1` against the PRD-610 baseline pairs — 2026-10-27 at `99ad82d99` (`flight.sh desktop`, `MIDWAY_MATCHED_CPU=1`; the off arm is today's `develop` path, `TN_RENDER_THREAD=0 TN_BATCH_DECISION_CACHE=0`, since the PRD-610 binaries predate placement). Pairs `r-a-k`/`r-b-k`, drop in ms (takeoff / cockpit / rear gunner): 6.63 / 7.50 / 6.43, 3.53 / 3.44 / 4.75, 4.78 / 5.58 / 6.15. A fourth pair gave 3.52 / 2.68 / 0.59 (its on-run rear gunner window ran at IPC 0.57 against 0.73-0.83 elsewhere, under host load). Parity PASS in all 8 runs. +- [x] **Pixels unchanged.** A fixed-step capture of A1, A2 (off, off) and B (on) per pose gives diff(A, B) no larger than diff(A1, A2), and the visual judge returns NEUTRAL or better. proof: capture pair per pose, visual-judge subagent verdict — 2026-10-27 at `99ad82d99`: the fixed-step pose frames of 7 off-runs and 10 on-runs (`q-*`, `r-*`), pixels over 1% grey difference below the label band, every A-A and A-B pair. Cockpit: 0 in every pair. Rear gunner: 0 or 213 in A-A and A-B pairs alike (the HUD radar's contacts land half a pixel apart from run to run on both arms). Takeoff: the off-runs fall into two states 4 pixels apart; 4 of the 10 on-runs equal an off-run state and 6 land on a third, 9-12 pixels away, in the same 22x19 region (the pilot's head at the canopy). `TN_BATCH_DECISION_CACHE=1` and `=0` runs fall into the same states, so the held decision adds no difference. Visual judge (fresh Opus subagent, read-only, criteria set before judging): NEUTRAL at takeoff, cockpit and rear gunner, no missing objects, no pose, material, shadow or flicker change. Sheets: `prd-613-screenshots` branch. ⚠️ Follow-up outside this PRD: the takeoff pilot pose and the rear gunner radar vary between runs of the same arm, so some state there is sampled off the fixed-step clock. diff --git a/docs/PRDs/native-engine/README.md b/docs/PRDs/native-engine/README.md index 6cb84bb5b7..e3de0490ae 100644 --- a/docs/PRDs/native-engine/README.md +++ b/docs/PRDs/native-engine/README.md @@ -82,7 +82,7 @@ Full text and rationale: [PRD-497 § Decisions](PRD-497-n00-architecture-decisio | N20 | [PRD-533 — Platform qualification, performance and default promotion](PRD-533-n20-platform-qualification-performance-default-promotion.md) | CP1, N00–N16, N18, N19 | | N21 | [PRD-535 — The JS engine is deleted](PRD-535-n21-the-js-engine-is-deleted.md) | N20 + one release, N19 | | Perf | [PRD-612 — The web Wasm engine beats three.js on Midway](PRD-612-web-wasm-engine-beats-threejs-on-midway.md) (owner bar 1.5x) | N19, PRD-610 | -| Perf | [PRD-613 — Native render thread](PRD-613-native-render-thread.md) (PRD-610 AC-16) | N09, PRD-610 | +| Perf | [PRD-613 — Native render thread](../done/PRD-613-native-render-thread.md) (PRD-610 AC-16) | N09, PRD-610 | ```mermaid flowchart LR diff --git a/docs/PRDs/performance/PRD-610-native-engine-wins-on-real-games.md b/docs/PRDs/performance/PRD-610-native-engine-wins-on-real-games.md index 5fa1a44e08..e8f8a799cc 100644 --- a/docs/PRDs/performance/PRD-610-native-engine-wins-on-real-games.md +++ b/docs/PRDs/performance/PRD-610-native-engine-wins-on-real-games.md @@ -312,7 +312,7 @@ Both wrappers exit 0, actual reports PASS 3/3 with 4,398 reported frames, fixed- - 2026-10-15 decision — REJECT the isolated pointer-compressed V8 13.1.201.22 build, not pointer compression generally: compiler/configuration can also differ. `v8pc-resume-base-1` → `v8pc-resume-on-1`, takeoff / cockpit / rear gunner CPU: 8.897 → 12.210 / 8.194 → 10.747 / 12.242 → 12.875 ms/frame; instructions: 20.449 → 20.871 / 22.762 → 23.144 / 29.996 → 30.299 M/frame; IPC: 0.577 → 0.420 / 0.704 → 0.529 / 0.607 → 0.575. Every pose worsened CPU and instructions, so the second pair was stopped. Both exit 0 and pass 3/3 assertions on RTX 2080; all six windows have exactly 1,000 frames/updates/renders; candidate adapter checks pass 27/27. Raw evidence: `~/.cache/tmp-tn/screens/v8pc-resume-{base,on}-1/`; dashboard rows are exploratory and excluded, with no qualified parity. Default baseline and candidate archive remain preserved; AC-20 stays open. Dashboard publication took priority; cycle `native-v8-pointer-compression` closed in 27.56 minutes (05:38:24–06:05:58 UTC), within the 30-minute target. -- [ ] AC-16 [local]: delivered by [PRD-613](../native-engine/PRD-613-native-render-thread.md) (owner, 2026-10-14: separate PRD). Cull, project and encode run on a render thread, and the player main thread's CPU per frame falls by at least 1.0 ms on the lockstep window. proof: a ctest on render-thread ordering (frame N encodes only frame N's snapshot; a mutation goes red), a fixed-step capture pair whose A-against-B difference is no larger than A against A, three alternating pairs per pose, visual judge NEUTRAL or better +- [ ] AC-16 [local]: delivered by [PRD-613](../done/PRD-613-native-render-thread.md) (owner, 2026-10-14: separate PRD). 2026-10-27: PRD-613 is done; the 1.0 ms bar and the four proofs pass, but cull and project stay on the main thread (its batch decision is held instead, see its Decisions), so this box waits for the owner. Cull, project and encode run on a render thread, and the player main thread's CPU per frame falls by at least 1.0 ms on the lockstep window. proof: a ctest on render-thread ordering (frame N encodes only frame N's snapshot; a mutation goes red), a fixed-step capture pair whose A-against-B difference is no larger than A against A, three alternating pairs per pose, visual judge NEUTRAL or better - [ ] AC-17 [local]: animation costs the player main thread at most 0.4 ms per frame on the lockstep window (about 1.05 ms today), and an animated value the game reads is current for its frame. proof: a ctest on read-before-join (red when a game-read track evaluates in a job), the mixer meter on three alternating pairs, visual judge NEUTRAL or better at all three poses - [ ] AC-18 [local]: the HUD bridge, positional audio and the player shim cost the player main thread at most 0.2 ms per frame on the lockstep window (about 0.45 ms today). proof: V8 profile owner rows before and after on the same window, an instruction A/B per moved piece, and a spec or conformance case per native fast path - [ ] AC-5 [local]: the HUD snapshot round trip is under 16.7 ms p95 in flight. proof: `TN_UI_SNAPSHOT lastRoundTripMs` from `flight.sh desktop` — Evidence so far: p95 168 ms at filing, 26 ms on package T. The WPE backend and a state snapshot were rejected (see Context). Open lead from the 2026-10-13 trace: reading the offscreen window's own surface cost 0.6 ms, against 30 to 49 ms for a full-page snapshot. diff --git a/docs/verification/native-coverage-2026-08-28.md b/docs/verification/native-coverage-2026-08-28.md index 4b8225286e..95889a0cec 100644 --- a/docs/verification/native-coverage-2026-08-28.md +++ b/docs/verification/native-coverage-2026-08-28.md @@ -11,23 +11,23 @@ targets could not be built and are named below. | `src/audio/` | 1471 | 1301 | 88.44% | | `src/canvas/` | 1334 | 1110 | 83.21% | | `src/cli/` | 1691 | 1243 | 73.51% | -| `src/engine/` | 38678 | 32336 | 83.60% | +| `src/engine/` | 38672 | 32356 | 83.67% | | `src/fs/` | 235 | 189 | 80.43% | | `src/http/` | 410 | 377 | 91.95% | | `src/js/` | 2835 | 2227 | 78.55% | | `src/platform/` | 1507 | 1047 | 69.48% | | `src/raytracing/` | 643 | 543 | 84.45% | -| `src/runtime.cpp` | 2410 | 1869 | 77.55% | +| `src/runtime.cpp` | 2410 | 1867 | 77.47% | | `src/screenshot_gate.cpp` | 27 | 24 | 88.89% | | `src/storage/` | 370 | 329 | 88.92% | | `src/utils/` | 0 | 0 | 0.00% | | `src/vfs/` | 239 | 195 | 81.59% | -| `src/webgpu/` | 10061 | 7716 | 76.69% | +| `src/webgpu/` | 10061 | 7719 | 76.72% | | `src/webtransport/` | 1391 | 1078 | 77.50% | -| `src/workers/` | 615 | 524 | 85.20% | -| **TOTAL** | **63990** | **52168** | **81.53%** | +| `src/workers/` | 615 | 527 | 85.69% | +| **TOTAL** | **63984** | **52192** | **81.57%** | -Source digest: `sha256:b7d49175bb6939252fb198d3075a67ff9761587b7de71314d74f5f77f6be02d3` +Source digest: `sha256:1b2fa340692ebed80693a9e5e459c1b2005ad41c8aea925b6f2104dde06cc1bb` The default `pnpm budgets` gate reads this committed measurement without configuring or compiling the native host. Any native source, native C++ test, CTest registration, or coverage aggregation diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index f1d6f21e51..750c3ad0a7 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -209,9 +209,9 @@ const BUG_REPORT_SKILL_PATHS = [ // Re-measured 2026-10-20 (PRD-626): every tree gains eslint.config.js and biome.json, three scripts and // two dev dependencies, and the pristine sources lose the unused bindings the new lint found. // Refreshed 2026-10-24: shared authoring routes, delegation skills and prompt hook change all 13 trees. - // Recomputed 2026-10-26 (reference MCP hook): every template's AGENTS.md names classic-game - // mechanics references beside capability search in the authoring skill list, so all sixteen - // trees move. No other scaffold byte changed. +// Recomputed 2026-10-26 (reference MCP hook): every template's AGENTS.md names classic-game +// mechanics references beside capability search in the authoring skill list, so all sixteen +// trees move. No other scaffold byte changed. const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // PRD-723: RTS now ports its visually approved filmic/metered look, readable HUD/rings and full-resolution override. // Other kits retain the measured phase-1 core imports, vendor removal and guidance. diff --git a/packages/runtime-native/cmake/NativeEngine.cmake b/packages/runtime-native/cmake/NativeEngine.cmake index 9cf8bc2c3b..1f40611874 100644 --- a/packages/runtime-native/cmake/NativeEngine.cmake +++ b/packages/runtime-native/cmake/NativeEngine.cmake @@ -285,6 +285,7 @@ if(NOT MYSTRAL_PLATFORM STREQUAL "ios" AND NOT MYSTRAL_PLATFORM STREQUAL "androi native_engine_batched_vs_unbatched=batched_vs_unbatched native_engine_skinned_batched_vs_unbatched=skinned_crowd native_engine_skinned_decline_cadence=skinned_decline_cadence + native_engine_skinned_projecting_cadence=skinned_projecting_cadence native_engine_skinned_normalized_weights=skinned_normalized_weights native_engine_normal_map_tilt=normal_map_tilt native_engine_renderer_gpu_mipmaps=gpu_mipmaps diff --git a/packages/runtime-native/src/engine/abi/bindings_scene.cpp b/packages/runtime-native/src/engine/abi/bindings_scene.cpp index 6de0cb6d0d..18ecd36e6f 100644 --- a/packages/runtime-native/src/engine/abi/bindings_scene.cpp +++ b/packages/runtime-native/src/engine/abi/bindings_scene.cpp @@ -215,38 +215,6 @@ void registerEvents(ClassBinding& b, EventDispatcher* (*dispatcher)(void*), std: } } -// One number that changes whenever anything the projection's stability proof reads off the engine -// changes (projection-stability.ts): the walk order, each node's visibility, and for a mesh its -// geometry, material, flags, render order and layer mask. It walks the way that proof walks, so a -// light or LOD hides its children. A hash, so a match is a claim at 2^-53 and a mismatch only sends -// the caller to its own exact walk. -double structureStamp(Object3D& root) { - uint64_t h = 0xcbf29ce484222325ULL; - const auto mix = [&h](uint64_t v) { - h = (h ^ v) * 0x100000001b3ULL; - h ^= h >> 29; - }; - std::vector stack(root.children.rbegin(), root.children.rend()); - while (!stack.empty()) { - const Object3D* object = stack.back(); - stack.pop_back(); - const auto* mesh = dynamic_cast(object); - mix(object->id() * 2 + (object->visible() ? 1 : 0)); - if (mesh != nullptr) { - mix(reinterpret_cast(mesh->geometry.get())); - mix(reinterpret_cast(mesh->material.get())); - mix(static_cast(mesh->castShadow()) | static_cast(mesh->receiveShadow()) << 1 | - static_cast(mesh->frustumCulled) << 2); - mix(static_cast(static_cast(mesh->renderOrder()))); - mix(std::bit_cast(mesh->layers().mask)); - } - mix(object->children.size()); - if (object->isLight() || object->type() == "LOD") continue; - for (auto it = object->children.rbegin(); it != object->children.rend(); ++it) stack.push_back(*it); - } - return static_cast(h & ((1ULL << 53) - 1)); -} - // core's host projection seam (renderProjection.ts): the verdict the native renderer's own batching gives a // root, in one call per frame. It is projection::decide, the port of core's scanProjection, kept while the // structure stamp it reads holds and re-judged on core's decline cadence (matrices move a verdict too: a @@ -278,7 +246,7 @@ Value hostProjectionReport(Object3D& root, int minMeshes) { if (settled) { ++host.sinceDecide; } else { - const double stamp = structureStamp(root); + const double stamp = projection::structureStamp(root); if (stamp != host.stamp || minMeshes != host.minMeshes || ++host.sinceDecide >= kDeclineRescanFrames) { host.decision = projection::decide(root, minMeshes); host.stamp = stamp; @@ -561,7 +529,7 @@ void registerObject3D(ClassBinding& b) { }; // core's projection-stability proof asks for this where it exists; three has no such method. b.methods["__tnStructureStamp"] = [](void* self, const Args&, Store&) { - return Value::of(structureStamp(*as(self))); + return Value::of(projection::structureStamp(*as(self))); }; // core's SceneRenderProjection asks for this where it exists, instead of building a mirror: the native // renderer batches this scene itself, so one call answers what that batching will make of it. diff --git a/packages/runtime-native/src/engine/player/core-assets.mjs b/packages/runtime-native/src/engine/player/core-assets.mjs index 76739c6d3d..92cbb22a8c 100644 --- a/packages/runtime-native/src/engine/player/core-assets.mjs +++ b/packages/runtime-native/src/engine/player/core-assets.mjs @@ -1,7 +1,7 @@ // The owned engine consumes verified TNPK entries, never browser loaders or network fallbacks. import { audio } from "./core-audio.mjs"; export function createAssetLoader(options = {}) { - if (Object.keys(options).some((key) => !["renderer", "basePath", "manifest", "sourcePath"].includes(key))) + if (Object.keys(options).some((key) => !["renderer", "basePath", "manifest", "sourcePath", "cache", "backgroundFill"].includes(key))) throw new Error("TN_NATIVE_ASSET_OPTIONS_UNSUPPORTED: this player accepts cooked package paths only"); // core's fallback for a game without a manifest: the verbatim path, then `/`. const sourcePath = (options.sourcePath ?? "assets").replace(/^\/+|\/+$/g, ""); @@ -55,5 +55,13 @@ export function createAssetLoader(options = {}) { resolve: async (path) => [resolved.get(path)?.url ?? `tnpk:${path}`], release: (kind, path) => cache.delete(`${kind}:${path}`), clear() { cache.clear(); resolved.clear(); }, + // core's disk cache (PRD-743) is web-only: a native game reads its installed package, so the report + // says so and prefetch and the background fill have nothing to fetch. `cache: false` changes nothing here. + get cache() { + return { enabled: false, persisted: false, hits: 0, misses: 0, bytesOnDisk: 0, bytesTotal: 0, filling: false, + backgroundFill: false, reason: "native host reads installed files", clear: async () => {} }; + }, + prefetch: async () => ({ requested: 0, requestedBytes: 0, settled: 0, settledBytes: 0 }), + startBackgroundFill: async () => {}, }; } diff --git a/packages/runtime-native/src/engine/player/run.cpp b/packages/runtime-native/src/engine/player/run.cpp index 6a2572953e..3f6de24757 100644 --- a/packages/runtime-native/src/engine/player/run.cpp +++ b/packages/runtime-native/src/engine/player/run.cpp @@ -613,7 +613,8 @@ struct Session { double gpuThreadCpuMs = -1; // the GPU thread's CPU for the previous frame's present job double gpuJobCpuMs = -1; // written by that job; copied above only after a wait // PRD-613: the encode half of the frame (every pass, main and reflections) runs here, off the game - // thread, while the next frame's JS runs. TN_RENDER_THREAD=1 enables it; it needs the GPU thread. + // thread, while the next frame's JS runs. On by default; TN_RENDER_THREAD=0 turns it off and GPU timing runs + // keep it off. It needs the GPU thread. // The game thread touches the renderer only after waitRenderThread() and before it posts the next job. std::shared_ptr renderThread; std::vector> encodeJobs; // this frame's passes, in order; the job reads them, so they live here @@ -774,7 +775,7 @@ int Session::initialize() { gpuThread = std::make_shared(); renderer->gpu().setThread(gpuThread); // Only a game that fences its own renderer touches (setRenderBarrier) can share the renderer with a thread. - if (const char* rt = std::getenv("TN_RENDER_THREAD"); rt && std::strcmp(rt, "1") == 0 && game.setRenderBarrier) { + if (const char* rt = std::getenv("TN_RENDER_THREAD"); !gpuTiming && !(rt && std::strcmp(rt, "0") == 0) && game.setRenderBarrier) { renderThread = std::make_shared(); renderer->collectFrame(); game.setRenderBarrier([this] { waitRenderThread(false); }); diff --git a/packages/runtime-native/src/engine/renderer/projection/plan.h b/packages/runtime-native/src/engine/renderer/projection/plan.h index 523c5f2924..fdfedebaf0 100644 --- a/packages/runtime-native/src/engine/renderer/projection/plan.h +++ b/packages/runtime-native/src/engine/renderer/projection/plan.h @@ -114,6 +114,15 @@ inline const char* skinnedLaneReason(const SkinnedMesh& rig) { return nullptr; } +// What decide reads off a rig on every frame, outside the structure stamp (PRD-613): its lane reason (material +// nodes, transparency, render order, skeleton) and whether its world matrix is a similarity and not mirrored. +// A held decision is judged again the frame this changes for any of its rigs. +inline uintptr_t rigLaneKey(const SkinnedMesh& rig) { + const double* e = rig.matrixWorld.elements.data(); + return reinterpret_cast(skinnedLaneReason(rig)) * 4 + (isSimilarityTransform(e) ? 2 : 0) + + (rig.matrixWorld.determinant() > 0 ? 1 : 0); +} + // batchFlagsOf: layers, castShadow, receiveShadow, frustumCulled in one key. inline double batchFlags(const Mesh& m) { return double(m.layers().mask) * 8 + (m.castShadow() ? 4 : 0) + (m.receiveShadow() ? 2 : 0) + (m.frustumCulled ? 1 : 0); @@ -390,6 +399,38 @@ inline std::shared_ptr canonicalGeometry(const std::shared_ptr> 29; + }; + std::vector stack(root.children.rbegin(), root.children.rend()); + while (!stack.empty()) { + const Object3D* object = stack.back(); + stack.pop_back(); + const auto* mesh = dynamic_cast(object); + mix(object->id() * 2 + (object->visible() ? 1 : 0)); + if (mesh != nullptr) { + mix(reinterpret_cast(mesh->geometry.get())); + mix(reinterpret_cast(mesh->material.get())); + mix(static_cast(mesh->castShadow()) | static_cast(mesh->receiveShadow()) << 1 | + static_cast(mesh->frustumCulled) << 2); + mix(static_cast(static_cast(mesh->renderOrder()))); + mix(std::bit_cast(mesh->layers().mask)); + } + mix(object->children.size()); + if (object->isLight() || object->type() == "LOD") continue; + for (auto it = object->children.rbegin(); it != object->children.rend(); ++it) stack.push_back(*it); + } + return static_cast(h & ((1ULL << 53) - 1)); +} + /** * One frame's projection decision for `scene`, as a fresh SceneRenderProjection reconcile makes it. * `skinnedVerdictsOnly` records a per-object verdict for rigs alone: the renderer asks no other object, and diff --git a/packages/runtime-native/src/engine/renderer/render_database.cpp b/packages/runtime-native/src/engine/renderer/render_database.cpp index e570529128..9f00e020ab 100644 --- a/packages/runtime-native/src/engine/renderer/render_database.cpp +++ b/packages/runtime-native/src/engine/renderer/render_database.cpp @@ -987,13 +987,36 @@ void RenderDatabase::project(Object3D& object, const Camera& camera, std::vector // first member's place. void RenderDatabase::batch(std::vector& items, Object3D& scene, const std::vector>& ordered) { - // A decline writes no verdict, so a settled one is an empty decision until its rescan; an empty - // scene (still loading) or a projecting one is judged again next frame, as SceneRenderProjection. - projection::Decision decision; - if (std::any_of(items.begin(), items.end(), [](const DrawItem& item) { return item.skinnedRig && !item.gpuJoints; }) && - ++framesSinceDeclineScan_ >= kDeclineRescanFrames) { - decision = projection::decide(scene, 200, true); - framesSinceDeclineScan_ = decision.projecting || decision.sourceRenderables == 0 ? kDeclineRescanFrames : 0; + // A decline writes no verdict, so a settled one holds no verdict until its rescan; an empty scene (still + // loading) is judged again next frame. A projecting one holds while the scene's structure does, re-judged on + // the same cadence (PRD-613: hostProjectionReport's rule; deciding every frame cost Midway ~1.5 ms). + static const projection::Decision none; + const projection::Decision* decision = &none; + if (std::any_of(items.begin(), items.end(), [](const DrawItem& item) { return item.skinnedRig && !item.gpuJoints; })) { + bool due = ++framesSinceDeclineScan_ >= kDeclineRescanFrames || &scene != batchScene_; + if (!due && batchDecision_.projecting && Object3D::hierarchyVersion() != batchHierarchy_) { + batchHierarchy_ = Object3D::hierarchyVersion(); + due = projection::structureStamp(scene) != batchStamp_; + } + // The structure held, so every rig is alive: a rig that scaled, mirrored or changed material leaves now. + for (std::size_t i = 0; !due && i < batchRigs_.size(); ++i) + due = projection::detail::rigLaneKey(*batchRigs_[i].first) != batchRigs_[i].second; + if (due) { + batchDecision_ = projection::decide(scene, 200, true); + ++batchDecisions_; + batchScene_ = &scene; + batchHierarchy_ = Object3D::hierarchyVersion(); + batchStamp_ = batchDecision_.projecting ? projection::structureStamp(scene) : -1; + batchRigs_.clear(); + if (batchDecision_.projecting) + for (const auto& [object, lane] : batchDecision_.verdict) + if (const auto* rig = dynamic_cast(object)) + batchRigs_.emplace_back(rig, projection::detail::rigLaneKey(*rig)); + // TN_BATCH_DECISION_CACHE=0 (diagnostic): a projecting decision is judged again next frame, as before PRD-613. + static const bool held = [] { const char* flag = std::getenv("TN_BATCH_DECISION_CACHE"); return !flag || std::strcmp(flag, "0") != 0; }(); + framesSinceDeclineScan_ = batchDecision_.sourceRenderables == 0 || (!held && batchDecision_.projecting) ? kDeclineRescanFrames : 0; + } + decision = &batchDecision_; } skinnedPalettes_.clear(); batchGroupsList_.clear(); @@ -1001,8 +1024,8 @@ void RenderDatabase::batch(std::vector& items, Object3D& scene, for (const auto& [depth, item] : ordered) { const std::size_t i = item - items.data(); const DrawItem& d = items[i]; - const auto verdict = decision.verdict.find(d.skinnedRig); - const bool skinned = bool(d.gpuJoints) || (d.boneMatrices && verdict != decision.verdict.end() && verdict->second == "skinned"); + const auto verdict = decision->verdict.find(d.skinnedRig); + const bool skinned = bool(d.gpuJoints) || (d.boneMatrices && verdict != decision->verdict.end() && verdict->second == "skinned"); if ((virtualShadows_ && d.castShadow) || (!d.batchable && !skinned)) continue; auto same = [&](const std::vector& g) { diff --git a/packages/runtime-native/src/engine/renderer/render_database.h b/packages/runtime-native/src/engine/renderer/render_database.h index 8a50276322..1102089899 100644 --- a/packages/runtime-native/src/engine/renderer/render_database.h +++ b/packages/runtime-native/src/engine/renderer/render_database.h @@ -12,6 +12,7 @@ #include #include "engine/renderer/renderer.h" +#include "engine/renderer/projection/plan.h" #include "engine/animation/skinning/palette.h" #include "engine/scene/camera.h" #include "engine/scene/geometry.h" @@ -107,6 +108,8 @@ class RenderDatabase { static constexpr std::size_t kMinBatchMembers = 4; /** Draws the last render merged by batching: groups made and meshes they absorbed. */ [[nodiscard]] std::pair lastBatches() const { return {batchGroups_, batchMembers_}; } + /** How many projection decisions batching has made (PRD-613): a settled scene makes none per frame. */ + [[nodiscard]] uint64_t batchDecisions() const { return batchDecisions_; } private: // A material's pmremTexture source and reflector, walked once per material version: the graph @@ -209,6 +212,12 @@ class RenderDatabase { // frame. Starts at the interval so the first batch always scans. static constexpr std::size_t kDeclineRescanFrames = 60; std::size_t framesSinceDeclineScan_ = kDeclineRescanFrames; + uint64_t batchDecisions_ = 0; + projection::Decision batchDecision_; // the last one, held while its scene's structure holds + const Object3D* batchScene_ = nullptr; + uint64_t batchHierarchy_ = ~uint64_t(0); + double batchStamp_ = -1; + std::vector> batchRigs_; // its rigs and their lane keys std::size_t batchGroups_ = 0, batchMembers_ = 0; std::vector items_; diff --git a/packages/runtime-native/tests/native-engine/player-imports.mjs b/packages/runtime-native/tests/native-engine/player-imports.mjs index a30bb39d3b..de2c6529c2 100644 --- a/packages/runtime-native/tests/native-engine/player-imports.mjs +++ b/packages/runtime-native/tests/native-engine/player-imports.mjs @@ -498,6 +498,12 @@ check(record.value instanceof ArrayBuffer && String.fromCharCode(...new Uint8Arr let mismatch = false; try { globalThis.tn.loadAsset("audio", "sky.jpg"); } catch (error) { mismatch = /TN_NATIVE_ASSET_KIND_MISMATCH/.test(error.message); } check(mismatch, "a texture entry is not audio"); +// core's IAssetLoader on a native host: no disk cache (the files are installed), prefetch serves nothing. +// The playtest bridge's state sample reads this report on every observation. +const loader = createAssetLoader({ cache: false, backgroundFill: false }); +check(loader.cache.enabled === false && loader.cache.reason === "native host reads installed files" && + loader.cache.bytesOnDisk === 0 && typeof loader.cache.clear === "function", "native asset cache report"); +check(typeof loader.prefetch === "function" && typeof loader.startBackgroundFill === "function", "native prefetch and fill"); // A decode settles on the first tick's drain, which a check never runs: reaching the decoder leaves // both pending, and any refusal before it rejects inside this check's microtasks. // "audio/engine.ogg" is not a package name: core's source fallback finds it under assets/, as the diff --git a/packages/runtime-native/tests/native-engine/render_database_test.cpp b/packages/runtime-native/tests/native-engine/render_database_test.cpp index 8c0b186c79..9773081223 100644 --- a/packages/runtime-native/tests/native-engine/render_database_test.cpp +++ b/packages/runtime-native/tests/native-engine/render_database_test.cpp @@ -2695,7 +2695,7 @@ void skinnedCrowdPixels() { // A settled projection decline re-judges every 60 frames, as core's SceneRenderProjection does // (DECLINE_RESCAN_FRAMES): a crowd blocked by a render hook draws exactly, and once the hook leaves -// it batches on the 60th frame after the declining scan. A projecting decision is re-judged every frame. +// it batches on the 60th frame after the declining scan. void skinnedDeclineCadence() { player::SkinnedCrowd crowd(false); Mesh hooked(makeBoxGeometry(), std::make_shared(MaterialType::Standard)); @@ -2719,6 +2719,43 @@ void skinnedDeclineCadence() { CHECK(database.lastBatches().first == 0); } +// PRD-613: a projecting decision holds while the scene's structure does, re-judged on the same 60-frame +// cadence as a decline (hostProjectionReport's rule). Moving bones and rigs re-decide nothing; adding or +// removing an object does. Every frame batches as a fresh decision would. +void skinnedProjectingCadence() { + player::SkinnedCrowd crowd(false); + RenderDatabase database; + LightState lights; + const auto fresh = [&] { + RenderDatabase once; + LightState l; + once.prepare(crowd.scene(), crowd.camera(), l); + return once.lastBatches(); + }; + database.prepare(crowd.scene(), crowd.camera(), lights); + CHECK(database.lastBatches().first > 0); + CHECK(database.batchDecisions() == 1); + for (int frame = 1; frame < 59; ++frame) { + crowd.update(1.0 / 60); + database.prepare(crowd.scene(), crowd.camera(), lights); + CHECK(database.lastBatches() == fresh()); + } + CHECK(database.batchDecisions() == 1); + Mesh box(makeBoxGeometry(), std::make_shared(MaterialType::Standard)); + crowd.scene().add(box); + database.prepare(crowd.scene(), crowd.camera(), lights); + CHECK(database.batchDecisions() == 2); + CHECK(database.lastBatches() == fresh()); + crowd.scene().remove(box); + database.prepare(crowd.scene(), crowd.camera(), lights); + CHECK(database.batchDecisions() == 3); + for (int frame = 1; frame < 60; ++frame) database.prepare(crowd.scene(), crowd.camera(), lights); + CHECK(database.batchDecisions() == 3); + database.prepare(crowd.scene(), crowd.camera(), lights); // the 60th frame since the last decision + CHECK(database.batchDecisions() == 4); + CHECK(database.lastBatches() == fresh()); +} + // PRD-526: glTF stores WEIGHTS_0 as normalized unsigned bytes. The shader reads vec4, so the // vertex format must be Unorm8x4: bound as Float32x4 the buffer reads as zeros and the rig vanishes. void skinnedNormalizedWeights() { @@ -5933,7 +5970,7 @@ TN_TEST_MAIN({"gpu_joint_production", gpuJointProduction}, {"multi_material_grou {"virtual_sun_lod_rung", virtualSunLodRung}, {"virtual_sun_level_sizes", virtualSunLevelSizes}, {"virtual_sun_auto_map_size", virtualSunAutoMapSize}, - {"batched_vs_unbatched", batchedVsUnbatched}, {"skinned_crowd", skinnedCrowdPixels}, {"skinned_decline_cadence", skinnedDeclineCadence}, {"skinned_normalized_weights", skinnedNormalizedWeights}, {"normal_map_tilt", normalMapTilt}, {"unsupported_map_slot", unsupportedMapSlot}, {"env_map_slot_is_read", envMapSlotIsRead}, {"converted_copies_swept", convertedCopiesAreSwept}, {"gpu_timer_covers_offscreen", gpuTimerCoversOffscreen}, {"gpu_timer_splits_the_gather", gpuTimerSplitsTheLabelledGather}, {"gpu_timer_covers_shadows", gpuTimerCoversShadows}, {"overlay_over_frame", overlayOverFrame}, {"gpu_timer_is_opt_in", gpuTimerIsOptIn}, {"msaa_edges", msaaEdges}, {"shadow_camera_layers", shadowCameraLayers}, {"flat_shading_needs_no_normals", flatShadingNeedsNoNormals}, {"map_sampleability_changes", mapSampleabilityChanges}, {"batch_caches_stay_put", batchCachesStayPut}, {"frustum_culling", frustumCulling}, + {"batched_vs_unbatched", batchedVsUnbatched}, {"skinned_crowd", skinnedCrowdPixels}, {"skinned_decline_cadence", skinnedDeclineCadence}, {"skinned_projecting_cadence", skinnedProjectingCadence}, {"skinned_normalized_weights", skinnedNormalizedWeights}, {"normal_map_tilt", normalMapTilt}, {"unsupported_map_slot", unsupportedMapSlot}, {"env_map_slot_is_read", envMapSlotIsRead}, {"converted_copies_swept", convertedCopiesAreSwept}, {"gpu_timer_covers_offscreen", gpuTimerCoversOffscreen}, {"gpu_timer_splits_the_gather", gpuTimerSplitsTheLabelledGather}, {"gpu_timer_covers_shadows", gpuTimerCoversShadows}, {"overlay_over_frame", overlayOverFrame}, {"gpu_timer_is_opt_in", gpuTimerIsOptIn}, {"msaa_edges", msaaEdges}, {"shadow_camera_layers", shadowCameraLayers}, {"flat_shading_needs_no_normals", flatShadingNeedsNoNormals}, {"map_sampleability_changes", mapSampleabilityChanges}, {"batch_caches_stay_put", batchCachesStayPut}, {"frustum_culling", frustumCulling}, {"shadow_caster_frustum_culling", shadowCasterFrustumCulling}, {"masked_shadow", maskedShadow}, {"leaf_receiver", leafReceiver}, {"shadow_noise_frame", shadowNoiseFrame},{"foliage_transmission", foliageTransmission}, {"rough_diffuse", roughDiffuse}, {"point_shadow_cube_faces", pointShadowCubeFaces}, {"lamp_source_radius", lampSourceRadius}, {"lamp_source_radius_wall", lampSourceRadiusWall}, {"lamp_source_radius_crate", lampSourceRadiusCrate}, {"contact_shadow_foot", contactShadowFoot}, {"virtual_sun_contact_foot", virtualSunContactFoot}, {"lamp_cache", lampCache}, {"lamp_atlas", lampAtlas}, {"gpu_timer_splits_lamp_shadows", gpuTimerSplitsLampShadows}, {"shadow_lights_follow_hierarchy", shadowLightsFollowHierarchy}, {"records_survive_siblings", recordsSurviveSiblings}, {"steady_frame_hashes_no_record", steadyFrameHashesNoRecord}, {"nested_record_cache", nestedRecordCache}, {"frustum_record_retention", frustumRecordRetention}, {"world_sphere_measured_once", worldSphereMeasuredOnce}, {"reflection_reuse", reflectionReuse})