diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b4785630e..e5ce12fe89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -925,7 +925,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ needs.scope.outputs.candidate_sha || github.sha }} + ref: ${{ needs.scope.outputs.candidate_sha }} - uses: ./.github/actions/pnpm - uses: actions/setup-node@v7 with: @@ -947,6 +947,32 @@ jobs: # `test:browser` is the test-browser job's gate; running it again here bought nothing but # a second copy of the slowest step in the workflow. - run: pnpm exec playwright test --config=benchmark.playwright.config.ts + # Software startup regression contract only; hardware and visual acceptance remain separate. + - uses: actions/checkout@v7 + with: + ref: 88b988cd70d5db7266eb0281e5f74bd0e030b3e7 + path: .worktrees/strata-startup-baseline + - name: Build isolated reviewed Strata baseline + timeout-minutes: 5 + working-directory: .worktrees/strata-startup-baseline + run: | + set -euo pipefail + pnpm install --frozen-lockfile + pnpm --filter strata-terrain-preview... build + - name: Build candidate Strata with restored candidate packages + timeout-minutes: 3 + run: pnpm --filter strata-terrain-preview build + - name: Enforce Strata startup median + timeout-minutes: 9 + env: + EXPECTED_SOURCE_SHA: ${{ needs.scope.outputs.candidate_sha }} + run: timeout 9m pnpm tsx scripts/verify-strata-startup.ts + - uses: actions/upload-artifact@v4 + if: always() + with: + name: strata-startup-${{ needs.scope.outputs.candidate_sha }} + path: artifacts/strata-startup/ + if-no-files-found: warn # visuals is NOT a job here any more. It captures frames from a WebGPU canvas, and this runner # has no GPU — its first execution in the repository's history failed on diff --git a/biome.json b/biome.json index 3986db9351..cc7d9c0f64 100644 --- a/biome.json +++ b/biome.json @@ -12,6 +12,7 @@ "artifacts/**", "examples/strata-terrain-preview/src/world/*.json", "examples/strata-terrain-preview/local-assets/**", + "examples/strata-terrain-preview/assets/**", "build/**", "release-native/**", ".linchpin/**", @@ -210,6 +211,28 @@ "formatter": { "enabled": false } + }, + { + "include": ["examples/procedural-animals/src/render/donor/core/motion/quadruped.js"], + "linter": { + "rules": { + "style": { + "useNamingConvention": { + "level": "error", + "options": { + "conventions": [ + { + "selector": { "kind": "objectLiteralProperty" }, + "match": "(?:(?:D|DL|T|F|H|W|U|Lf)|(.+))" + } + ], + "strictCase": false, + "requireAscii": false + } + } + } + } + } } ] } diff --git a/docs/PRDs/open-world/PRD-618-the-world-is-complete-when-the-cover-lifts.md b/docs/PRDs/open-world/PRD-618-the-world-is-complete-when-the-cover-lifts.md new file mode 100644 index 0000000000..5832b8ef51 --- /dev/null +++ b/docs/PRDs/open-world/PRD-618-the-world-is-complete-when-the-cover-lifts.md @@ -0,0 +1,316 @@ +# PRD-618 — The world is complete when the cover lifts, and far trees keep their shape + +**Status:** PARTIAL +**Priority:** P1 — AC-1 to AC-9 are open: on the Strata forest, shadows and nearby props appear after the loading bar ends, and far spruces read as noise (owner report, 2026-10-15). +**Complexity:** 4 (MEDIUM) — 6–10 implementation files (`virtual-shadow.ts`, core `game.ts`, `world-cells.ts`, the Strata `loading.ts`, `game.ts`, `propStreaming.ts`, `props.ts`, `prep-fab-temperate.mjs`) (+2); startup readiness state (+2). Risk override: none. +**Owner:** unassigned +**Depends on:** [PRD-616](https://github.com/ThreeNativeHQ/threenative-engine/pull/509) for AC-2 only: until the warm-up builds the pipelines of the real output pass, a warm-up behind the cover cannot remove the pipeline builds after the reveal. Related, not blocking: [PRD-606](../Unreal/open-world/PRD-606-a-fast-travel-destination-is-ready-before-the-cover-lifts.md) (`settle()` after a teleport uses the same readiness), [PRD-537](PRD-537-far-hills-show-the-forest.md) (Machinefall impostors and their shadow regression). + +## Context + +Owner report (2026-10-15), Strata terrain preview, `?world=forest`: the loading bar finishes, then near content still loads in front of the player. Shadows on the trees appear about one second apart from the reveal, so the first frames are not the finished frame. Far trees look bad. The owner's screenshot of the forest edge shows it: trees past the 60 m switch have blocky, square leaf patches with gaps, a flat dark green, and almost no shadow on the ground below them; the near trees on the right have full crowns and ground shadows. + +Layer: both. The cover, the shadow node and the cell admission are engine mechanism (`packages/core/`). The far tree level, the LOD distances and the reveal policy are game source (`examples/strata-terrain-preview/src/render/`, `scripts/`). + +### Why content pops in after the reveal (code trace, 2026-10-15) + +1. **The cover does not wait for shaders.** `examples/strata-terrain-preview/src/render/loading.ts:513-521` calls `renderer.compileAsync(scene, camera)` and removes the cover in the same tick. The comment says this is deliberate: a full compile held a playtest behind the bar. All pipelines that the warm-up did not build compile in the first visible frames. +2. **The cover waits for a 60 m circle only.** The spawn gate is `props.readinessAt(spawn)` (`src/game.ts:343`, `:964`), and `readinessAt` defaults to `radius = 60` (`src/render/propStreaming.ts:392`). The prop ring covers the whole world (`propStreaming.ts:297-301`). Every cell past 60 m streams in after the reveal, nearest first, at `admissionBudgetMs: 2 / buckets` (`propStreaming.ts:318`). The covered budget (8×, `packages/core/src/world-cells.ts:153`) ends with the cover (`packages/core/src/game.ts:1677-1681`). The 60 m value is a constant that the author must revisit, so the root rule "auto by default" calls it a bug. +3. **Shadows are late by design.** `VirtualShadowNode` renders at most one level per frame (`packages/core/src/render/virtual-shadow.ts:2477-2481`). Each level also waits out an `invalidationDelay` after an invalidation: 0.25 s, 1 s and 3.33 s for the three levels (`virtual-shadow.ts:166-177`). Each admitted cell invalidates the shadows (`src/game.ts:757-760`). So the cells from point 2 pull the shadows of the near levels 0.25–1 s behind the props. The node does not know about the startup cover. +4. **No readiness reads the shadows.** The startup gate has `compileSettled` and cell coverage (`packages/core/src/game.ts:1358`). No signal says "every shadow level holds the casters that are resident now". + +### Why far trees look bad (code trace, 2026-10-15) + +5. **The far spruce is a triangle decimation of leaf cards.** `scripts/prep-fab-temperate.mjs:350-353` runs meshopt `simplify({ ratio: 0.14, error: 0.012 })` on each cooked spruce. `packages/assets/src/lod/cards.ts:1-20` explains why this fails on foliage: a simplifier collapses cards (wrong silhouette) or drops triangles (holes). The card thinner in that file exists for this case and the game does not call it. +6. **One reduced level from 60 m to the far plane.** Spruce has two levels (`src/render/pack.ts:90-105`). `LOD_BANDS.far = 150` (`src/render/props.ts:119`) is not used for spruce. At several hundred metres, a card mesh is sub-pixel alpha noise. The mip compensation of the cutout is `alphaTest / (1 + mip × 0.25)` (`pack.ts:294-302`), weaker than the impostor's 0.75 scale. +7. **Impostors cannot be used here.** Engine world impostors exist (`packages/core/src/world-impostor.ts`, octahedral, colour and normal baked) but stay opt-in (`?tnImpostors=1`, PRD-473 #505). The game sets `preserveAuthoredParts: true`, and `WorldCells` refuses that with impostors on (`world-cells.ts`, the `preserveAuthoredParts` guard). +8. **Hard pop at the level boundary.** `world-cells.ts:6786`: "ponytail: a hard switch, no crossfade". The two levels differ in silhouette, so the 60 m switch is visible. +9. **Shadows do not match the cutout.** The shadow pass reads the scalar `alphaTest`, not the mip-adjusted `alphaTestNode` (`pack.ts:250`), so a far tree casts a different shape from the one it shows. + +### What Unreal does (UE source checkout, read 2026-10-15; clean-room summary, no code copied) + +Paths are relative to `Engine/Source/Runtime/` unless stated. + +- **The reveal blocks on a query, with two quiet passes.** `UWorld::BlockTillLevelStreamingCompleted` (`Engine/Private/World.cpp:4813-4860`) pumps the streamer and resets its pass count to 2 while any visibility or async request is pending. World Partition calls it before BeginPlay and at match start (`WorldPartition/WorldPartition.cpp:1214-1227`). The completion query uses the grid's own loading range, not a caller radius (`WorldPartitionStreamingPolicy.cpp:1126-1128`). +- **Virtual shadow pages have no delay and no per-frame page budget.** Every requested page renders in the frame its invalidation is processed. A light with no previous render keeps no cached page, so its first frame renders all pages (`Renderer/Private/VirtualShadowMaps/VirtualShadowMapCacheManager.cpp:270`, `:322-335`). A newly unculled primitive invalidates its pages one frame late (`:1281-1305`). The only throttle, `r.Shadow.Virtual.DeferredInvalidationBudget`, defaults to unbounded and carries work over to later frames instead of waiting on a timer (`VirtualShadowMapArray.cpp:119-127`). +- **Late shaders mean "not drawn yet", not "drawn wrong".** With `r.PSOPrecache.ProxyCreationStrategy` 1 (default), a primitive has no scene proxy until its pipelines compile (`Engine/Private/PSOPrecache.cpp:36-42`). The reveal does not wait on them, so this is Unreal's own pop-in source. +- **LOD switches dither, in every pass that clips.** Two LODs draw complementary per-pixel hash masks during the transition, including the shadow depth pass (`Engine/Shaders/Private/MaterialTemplate.ush:4483-4497`, `:2891-2920`). Instanced foliage drives it by distance with a per-instance random range so the switch is not a line (`Engine/Shaders/Private/LocalVertexFactory.ush:932-941`, `foliage.RandomLODRange`). LOD is picked by projected screen size, not metres (`Engine/Private/SceneManagement.cpp:966-1046`). +- **Simplified foliage keeps its area.** After simplification, surviving leaf boundaries grow outward by lost area ÷ perimeter, capped at 4× (`Developer/NaniteBuilder/Private/Cluster.cpp:1065` → `MeshSimplify.cpp:1285-1397`). Cutout textures can rescale mips to hold alpha coverage (`bDoScaleMipsForAlphaCoverage`, `Engine/Classes/Engine/Texture.h:1371-1375`). + +## Solution + +**Rule:** the cover lifts when the first frame is the finished frame. Every cell that the spawn view draws is resident, every shadow level that covers it has rendered with those casters, and every pipeline it draws is built. The engine measures this. The game sets no radius. + +```mermaid +flowchart LR + A[startup hold] --> B{spawn view resident?} + B -- no --> C[admit at covered budget] + C --> B + B -- yes --> D{shadow levels settled?} + D -- no --> E[render due levels, no delay while covered] + E --> D + D -- yes --> F{pipelines built?} + F -- no --> G[warm-up behind cover, PRD-616] + G --> F + F -- yes --> H[cover lifts] +``` + +1. **Engine, `WorldCells`:** `readinessAt` accepts the camera. The required set becomes the cells that intersect the camera frustum within each placement's own draw reach, not a circle. The circle form stays for PRD-606 callers. Like Unreal, readiness must hold for 2 consecutive polls before the cover lifts, so a request that starts in the last ready frame cannot slip past. +2. **Engine, `VirtualShadowNode`:** while the startup cover is active, `invalidationDelay` is 0 and every due level renders in one frame. The node exposes `settled` (no level due, no delayed invalidation). Core `game.ts` adds it to the startup readiness beside `compileSettled`. After the reveal, the current delays and the one-level budget stay unchanged for steady-state refresh. Unreal has no such delay at all; replacing the post-reveal delay with a carry-over queue is a follow-up, not this PRD. +3. **Game, Strata:** `loading.ts` stops its own fire-and-forget compile and lifts the cover on the core startup readiness. `game.ts` passes the camera to `readinessAt`. The 60 m default goes. +4. **Game, far spruce:** the prep script builds the far level with the card thinner from `packages/assets/src/lod/cards.ts`, not meshopt. The thinner grows the surviving cards to hold the crown's area, capped at 4× (the Unreal rule). Past `LOD_BANDS.far`, spruce draws an engine impostor. This needs `WorldCells` to allow impostors for assets that have no authored level at that distance while `preserveAuthoredParts` keeps the authored ones. The shadow pass uses the same cutout as the view. +5. **Engine, LOD transition:** the hard switch at `world-cells.ts:6786` becomes a dithered crossfade: across a band at each boundary, both levels draw with complementary per-pixel masks, in the view and the shadow pass, with a per-instance random offset. The mechanism is engine; the band width comes from the game's `lodHysteresis`. + +**Risks:** + +- **Load time grows.** The cover now waits for the whole visible forest. AC-3 bounds it. If it fails, the next lever is the covered budget and load lanes (the PRD-606 risk), not a smaller required set. +- **A playtest photographs the bar.** That is why `loading.ts` stopped waiting for the compile. The core startup hold already polls once per rendered frame under the playtest clock (`src/game.ts:338-341`), so the new readiness must use the same hold, not a `Promise` that the fixed step never advances. +- **Covered shadow renders cost frames.** They happen behind the cover, so they cost load time only, and AC-3 includes them. + +## Acceptance Criteria + +The boxes live in the phases below; each names the AC it proves. + +- AC-1 [local]: A Strata forest scenario holds the camera still at spawn for 3 s after the reveal and reads the post-reveal counters: cells admitted inside the view frustum, shadow level renders with reason "invalidated", impostor bakes pending. Every counter is 0. Red on the current develop with the numbers recorded here. proof: `node packages/playtest/dist/runner/cli.js examples/strata-terrain-preview/playtests/reveal.playtest.json --url http://127.0.0.1:5173 --server-command "pnpm --filter strata-terrain-preview dev --host 127.0.0.1 --port 5173 --strictPort" --browser-recipe webgpu --headed`. +- AC-2 [local]: The same run reports 0 render pipelines created after the reveal. proof: the AC-1 scenario with the pipeline census assertion from PRD-616. +- AC-3 [local]: Time to ready (`timeToReadyMs`) is at most 1.25× the Phase 1 baseline, median of 3 runs per arm on the same machine. proof: the AC-1 scenario's state readout. +- AC-4 [local]: The AC-1 scenario passes on the native desktop host. proof: the AC-1 command with `--target desktop`. +- AC-5 [local]: A visual judge subagent compares the reveal frame and the +3 s frame, BEFORE and AFTER, two headed runs per arm. Criterion: nothing appears, darkens or changes shape between the two frames. Verdict IMPROVEMENT. proof: root `AGENTS.md` visual judge rule. +- AC-6 [local]: The forest overview and river views draw spruce past `LOD_BANDS.far` as impostors (`stats().impostor.drawn > 0`, `pending = 0` at the reveal). proof: the AC-1 scenario run from the `overview` view. +- AC-7 [local]: A visual judge subagent rates far trees on the `overview` and `river` views, BEFORE and AFTER, two headed runs per arm. Criteria: crowns keep a solid silhouette, no speckle or shimmer, shadows match the visible crown, no visible pop at 60 m and 150 m. Verdict IMPROVEMENT. proof: `capture-strata-worlds` skill, `--worlds forest`. +- AC-9 [local]: Inside a level's transition band, both levels draw and their dither masks are complementary (every pixel covered exactly once), in the view and the shadow pass. proof: `pnpm --filter @threenative/core test -- world-cells` with a new transition spec. +- AC-8 [local]: Steady-state frame time on the `overview` view does not get worse: AFTER p50 at most BEFORE p50 + 5%, paired runs. proof: `measure-steady-state-fps` skill on the forest overview. + +## Blocked on + +- The licensed Fab spruce bytes are local-only (`examples/strata-terrain-preview/AGENTS.md`). AC-6 to AC-8 run on a machine that has `local-assets/temperate/`; without them the procedural spruce draws and proves nothing about the Fab trees. Unblocked by running Phase 3 on the owner's machine. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +|---|---|---|---| +| View-based spawn readiness | Strata `game.ts` startup hold → `WorldCells.readinessAt(camera)` | The 60 m default circle in `propStreaming.ts:392` is deleted | AC-1 | +| Shadow settle in startup readiness | core `game.ts` startup readiness → `VirtualShadowNode.settled` | Shadows rendered after the reveal | AC-1, AC-5 | +| Cover lifts on core readiness | Strata `loading.ts` → core startup readiness | The fire-and-forget `compileAsync` in `loading.ts:513-521` is deleted | AC-1, AC-2 | +| Card-thinned far spruce + impostor band | `prep-fab-temperate.mjs` → `pack.ts` levels → `WorldCells` | meshopt `simplify` on spruce is deleted | AC-6, AC-7 | +| Dithered LOD transition | `WorldCells` level switch → view and shadow materials | The hard switch at `world-cells.ts:6786` is deleted | AC-9, AC-7 | + +## Decisions + +- 2026-10-15 (agent, from the owner's report): one PRD for both symptoms, because both are "the world the player sees is not the finished world". The 1.25× load-time bound in AC-3 is an assumption; the owner can change it after Phase 1 records the baseline. + +## Execution Phases + +#### Phase 1: Measure the pop-in (red) + +**Status:** PARTIAL +**Files:** `examples/strata-terrain-preview/playtests/reveal.playtest.json` (new); a debug readout in `src/game.ts`; `src/render/propStreaming.ts` forwards core draw-publication observations; `packages/core/src/world-cells.ts` and `packages/core/__tests__/world-cells-admission.spec.ts` implement and exercise the optional portable observer. +**Implementation:** Reuse the existing `walk`/`terrain` scenario shape. Hold the camera still for 3 s after `worldReady`. Read `props.stats()`, the shadow node stats (`invalidated`, delayed levels) and the pipeline census. Record the current numbers and `timeToReadyMs` (3 runs) in AC-1 and AC-3. +**Verification:** the AC-1 command fails on current develop, for the counter reasons and not for an environment reason (`adapter.info` names the real GPU). + +- [x] Scenario is red on the frozen develop-policy baseline with numbers recorded. proof: headed WebGPU reveal scenario; baseline source `88b988cd7` adds observation only over `1d056cec3`, and sequential paired run 1/2 fail for late shadow invalidation with zero environment errors. + - 2026-10-15 corrective attempt (base `e57975874`, runtime base `1d056cec3`): removed cumulative `byAdmission` assertions, default-zero impostor fallback and the invented 15,000 ms threshold. The probe starts after the actual canvas cover is absent, measures a 3,000 ms stationary window, differences real `byInvalidation` and device pipeline creation totals, and retains peak impostor pending work. Missing census stays unavailable (`-1`), never zero; AC-2 is not asserted yet. Game-side cell observations currently measure completed cells with in-reach placement bounds intersecting the reveal frustum; partial run publication within an unfinished cell is not covered, so this probe cannot yet qualify the full AC-1 admission claim. + - Checks: `pnpm --filter strata-terrain-preview exec tsc --noEmit -p tsconfig.json` exit 0; Biome formatting and `git diff --check` passed. Two initial scenario-schema errors were corrected (unsupported component wait, invalid text overlay); neither was a baseline result. The corrected scenario was accepted by the CLI, but its GPU run never started. + - Attempted command: `DISPLAY=:1 TN_PLAYTEST_HOST_DISPLAY=1 node packages/playtest/dist/runner/cli.js examples/strata-terrain-preview/playtests/reveal.playtest.json --url http://127.0.0.1:16180 --server-command "pnpm --filter strata-terrain-preview dev --host 127.0.0.1 --port 16180 --strictPort" --browser-recipe webgpu --headed --timeout 180000 --artifacts artifacts/playtest/prd618-corrected/baseline-1`. Exit 75: capture lock held by PID 3620521 for the full 120,000 ms queue timeout. Preserved output: `artifacts/playtest/prd618-corrected/baseline-1/run.log`. `node packages/playtest/dist/runner/cli.js doctor --text` exit 0 confirmed installed Chromium/Playwright and display support; the physical RTX 2080 was reported by `nvidia-smi`, but **no browser adapter.info was obtained**, so this is setup evidence only. + - Baseline counters, three `timeToReadyMs` samples/median, two BEFORE reveal/+3 s capture pairs and overview/river captures: **unverified, none collected**. No baseline red or AC completion claimed. Earlier `/tmp/opensecc/reveal-run` captures remain rejected. Licensed Fab bytes are absent; procedural fallback cannot qualify AC-6–AC-8. Resume after the shared GPU capture lane is available; close the partial-cell observation gap before qualifying AC-1. + + - 2026-10-15 publication correction: core `onPublication(bounds)` now observes nonempty source batches at `#swap`, after shared instance records are written, including noncasters and runs published before the rest of their cell is ready. `#buildOne` only stages entries: no draw or observation there. Strata forwards this callback and counts `publishedBatchesInView` against the stationary reveal frustum using core's existing admitted-placement draw bounds (and hence its actual reach filter). Deleted the `world.process` monkey patch, neighborhood readiness polling and duplicate placement-bound construction. This is a conservative batch count, not a completed-cell count; an unchanged replacement emits nothing. + - Focused checks: `pnpm exec vitest run packages/core/__tests__/world-cells-admission.spec.ts` exit 0 (16 tests); regression observes live instance records before cell readiness, rejects staging-only notifications and excludes outside-frustum publications. Initial test camera covered every fixture cell (35/35), corrected to 45 degrees; this was a test-setup failure, not baseline evidence. `pnpm --filter @threenative/core exec tsup --config tsup.config.ts` exit 0 rebuilt consumed runtime/types; `pnpm --filter strata-terrain-preview exec tsc --noEmit -p tsconfig.json` exit 0 after rebuild (first attempt saw stale declarations). `pnpm exec biome check --write packages/core/src/world-cells.ts packages/core/__tests__/world-cells-admission.spec.ts examples/strata-terrain-preview/src/game.ts examples/strata-terrain-preview/src/render/propStreaming.ts examples/strata-terrain-preview/playtests/reveal.playtest.json` exit 0 (existing complexity warnings); `git diff --check` exit 0. Full core suite deliberately not run. + - Baseline freeze: `pnpm --filter strata-terrain-preview build` exit 0; `cp -a examples/strata-terrain-preview/dist/. artifacts/playtest/prd618-publication/baseline-site/` exit 0 preserves the ordinary built site (75 MB, ignored). Source SHA and capture outcome are recorded below after the instrumentation commit. Build/test output is retained at `artifacts/playtest/prd618-publication/prd618-{core-build,example-build,admission-2}.log`. Licensed Fab assets are absent; this frozen procedural baseline is **not** licensed-tree evidence. No readiness, shadow or LOD policy changed. + + - Frozen baseline source: `88b988cd7` (`artifacts/playtest/prd618-publication/baseline-site/SOURCE_SHA.txt`), with the corrected scenario copied to `artifacts/playtest/prd618-publication/reveal.playtest.json`. Future Phase 2 source edits cannot replace these built bytes. Rerun the frozen site with `--server-command "python3 -m http.server 16180 --bind 127.0.0.1 --directory artifacts/playtest/prd618-publication/baseline-site"`, the saved scenario, and the same headed WebGPU/display flags. + - Corrected baseline attempt: `DISPLAY=:1 TN_PLAYTEST_HOST_DISPLAY=1 node packages/playtest/dist/runner/cli.js examples/strata-terrain-preview/playtests/reveal.playtest.json --url http://127.0.0.1:16180 --server-command "pnpm --filter strata-terrain-preview dev --host 127.0.0.1 --port 16180 --strictPort" --browser-recipe webgpu --headed --timeout 180000 --artifacts artifacts/playtest/prd618-publication/baseline-1` exited **75** after the normal 120,000 ms capture-lock queue timeout. Holder PID **3590445** was running the sailing template headed playtest; no lock bypass or holder termination attempted. Full command/output/exit preserved in `baseline-1/{command.txt,run.log,exit-code.txt}` under that artifact root. **Setup result only:** browser never launched, no `adapter.info`, counters, three time samples or two BEFORE reveal/+3 s pairs collected. AC-4 desktop remains unrun; portable callback qualification still needs the desktop reveal scenario. All AC boxes stay open; return partial until shared capture capacity is available. + + - 2026-10-15 controlled paired startup evidence: BEFORE `88b988cd7`, AFTER `3ccb616b6`; immutable ordinary built sites, same Vite preview server command, port 16181, viewport 1280×720, headed WebGPU on stable desktop `DISPLAY=:0`, physical NVIDIA Turing adapter (desktop RTX 2080). The previous borrowed `:1` display disconnected; its incomplete run is excluded. Python static-server samples are excluded from timing qualification because optional-asset 404 behavior differs from Vite. The zero-publication invariant on the legacy baseline uses the harness’s explained `allowTrivial` option; complete/samples/elapsed independently prove a measured window, and the zero-event comparison is unchanged. AFTER observations use their measured availability state. + - Sequential alternating BEFORE/AFTER samples (ms): BEFORE **11213.1001, 10831.3999, 10695.3000**; AFTER **12416.8000, 11303.6001, 11572.7000**. Medians **10831.3999 → 11572.7000 ms**, **+741.3000 ms / +6.844%**, ratio **1.06844**, below AC-3’s 1.25 bound. Each run independently has zero console/runtime errors and a physical adapter receipt. BEFORE shadow-invalidated counters **1, 1, 0**; AFTER **0, 0, 0**. All view-publication and pending-impostor counters are **0**. The baseline is RED for actual late shadow work in runs 1/2, not a missing observation/environment failure. AFTER all three reveal scenarios pass. CPU load averages are recorded with each raw sample; no own builds/captures overlapped the paired sequence. + - Raw existing-harness artifacts: `artifacts/playtest/prd618-paired/{before,after}-{1,2,3}/` contain run output, adapter receipt, reveal/+3 s PNGs and console data. Source sites: `artifacts/playtest/prd618-publication/baseline-site/` and `artifacts/playtest/prd618-paired/candidate-site/`. This qualifies startup behavior with the procedural fallback only; it does **not** qualify licensed-tree AC-6–AC-8. Paired BEFORE/AFTER visual judge and PR screenshot attachment are still pending. Final foliage integration must rerun the reveal and timing gates. + +#### Phase 2: The cover lifts on a finished frame + +**Status:** PARTIAL +**Files:** `packages/core/src/world-cells.ts` (`readinessAt` with a camera), `packages/core/src/render/virtual-shadow.ts` (`settled`, covered no-delay), `packages/core/src/game.ts` (readiness includes shadow settle), `examples/strata-terrain-preview/src/render/loading.ts`, `src/game.ts`, `src/render/propStreaming.ts`; unit specs in `packages/core/__tests__/`. The existing `packages/core/src/startup-readiness.ts` owns the two final quiet rendered polls; `packages/core/src/render/virtual-shadow-pages.ts` exposes the existing tracker's pending-key count for truthful shadow settlement. +**Implementation:** Solution steps 1–3. A unit spec proves that a covered `VirtualShadowNode` renders all due levels in one frame and reports `settled`, and that an uncovered node keeps the current delays. +**Verification:** `pnpm --filter @threenative/core test -- virtual-shadow world-cells`, then AC-1 to AC-5. + +- [ ] AC-1, AC-3 final qualification: proof: **FAIL** at source `290bf4c2b`, medians **9689.5 → 13555.5 ms**, ratio **1.398989**, limit 1.25. All three AFTER reveal scenarios pass with zero late counters. Final-source results below; no smaller required set or relaxed bound. +- [ ] AC-2 green (after PRD-616). proof: AC-1 command with the census assertion. +- [x] AC-4 desktop confirmation. proof: two fresh `reveal.playtest.json --target desktop` runs after exact grounding and TRAA input initialization pass **8/8 assertions each**. Readiness is **29526.1 / 29272.4 ms**; observation windows are **3314.4 / 3022.9 ms**, with **10 / 11 samples** and all three late counters **0**. Both report zero console errors, network errors and runtime diagnostics. Artifacts: `examples/strata-terrain-preview/artifacts/playtest/prd618-taa-prime-fixed-1/` and `prd618-taa-prime-fixed-2/`. The unchanged native binary is SHA-256 `9b3a39c689546a82818e4d08d701fc952bbe1026e7f27e4002b249180b01953d`. Warmup reports incomplete; this does not qualify AC-2 pipeline creation, licensed foliage or visual improvement. +- [ ] AC-5 **FAIL**: proof: fresh read-only judge on source `290bf4c2b`, all eight corrected-scenario images inspected; overall temporal **NEUTRAL**, requiring IMPROVEMENT. Crown/branch outlines change between endpoints in both arms. Secondary appearance preservation **PASS (NEUTRAL)** after atlas restoration. Exact first-visible-frame timing remains unproven. + +**Implementation and checks (2026-10-15):** + +- 2026-10-16 source-gate repairs: full root units exposed five failures (10718 passed). The compute and sailing fixtures now present the two required quiet polls, with original dispatch assertions retained: 13/13 focused and 242/242 neighboring tests pass. The CI contract permits only the benchmark job's reviewed baseline checkout; candidate worker checkouts remain pinned. Native coverage was regenerated after the command-buffer fix. Those contracts plus core/game/shadow suites pass 257/257. Full source gate session `90275` on `7967fe079` exits 0: `pnpm typecheck`, `pnpm lint` and `pnpm test` pass; the root suite reports 781 files (3 skipped), 10,727 tests passed and 20 skipped (10,747 total) in 364.07 s. Its docs, build and package-test wrapper checks pass too. These are repository source gates only; they do not qualify AC-1/3/5 or any other runtime, native or visual acceptance. Native startup is qualified separately by the two AC-4 desktop runs. +- Native failure localization: the framework reached ready at 11660 ms and all 74 assets settled. The game's eager grounding pass had processed 145545 of 218816 placements at the deadline, before constructing any streamed world. A shadow-identity or camera failure is not established by this run. The next correction targets exact terrain contact queries while retaining physics validation and the full placement workload. +- Exact grounding correction: the game samples the drawn Float32 terrain triangle planes directly, retains restricted visual-ray fallback, physics validation and stone footprint contacts, and does not change placement density or readiness deadlines. Four comparisons against Three.js raycasts pass; example typecheck and focused Biome checks pass. The fresh desktop scenario in `examples/strata-terrain-preview/artifacts/playtest/prd618-exact-ground-current/` exits 2 with a separate finite-camera failure: `projectionMatrix[0]=NaN`, `aspect=NaN`, and null view dimensions. AC-4 remains open; no native pass or visual verdict is claimed. +- Subsequent native correction: the game's TRAA stage converted its AO expression to an automatic RTT with initially null dimensions. TRAA's FRAME update could consume that target before its RENDER update sized it. The game now primes the actual beauty RTT from the renderer's drawing-buffer dimensions; automatic resizing stays enabled. Its real-Three regression fails before priming (`null` instead of 640) and passes afterward, including a later resize. A non-binary Float32-grid regression brings the example suite to **6/6**. The two fixed-source native confirmations above supersede the earlier failures; no temporary probe remains. +- Native screenshots and fresh read-only judge are [posted on PR #510](https://github.com/ThreeNativeHQ/threenative-engine/pull/510#issuecomment-6211539558), with original bytes on `prd-618-screenshots` at `7e0adc536222447994606cdb299e3216e33240db`. The current-source native diagnostic has two runs, each passing 8/8 assertions; the per-run and overall temporal-stability verdict is **NEUTRAL**: scene framing, terrain/prop masses, shadows and exposure remain stable from reveal to +3 seconds. This current-run stability does not satisfy AC-5's required before/after IMPROVEMENT or licensed-foliage AC-7. + +- `WorldCells.readinessAt(camera)` now derives required cells from the current camera frustum, admitted-placement authored bounds and the same placement reach/distance rules used by drawing. The position/radius API is unchanged. Chunk/terrain cell coverage stays conservative; required-cell replacement jobs and pending shadow notifications prevent camera readiness. Covered updates flush shadow notifications without the one-second steady-state throttle. Strata supplies its actual render camera, removes the 60 m radius default, and avoids scanning the view after startup settles. The publication callback and reveal assertion remain intact. +- Core discovers the active visible virtual-shadow lights before render-cadence streaming. `startupCovered` removes invalidation delays and grants every due shadow level in the same frame. `settled` requires mapped levels, no delayed/dirty/pending level, no queued region/page keys and no source `needsUpdate`. Uncovered delays and the one-level budget are unchanged. After streaming, render, shadows and held-world warm-up, startup rechecks predicate holds and shadow settlement for **two consecutive rendered polls**; late work resets the count. Fixed simulation steps are not needed to advance this gate. Strata's loading screen removes its fire-and-forget `compileAsync` and continues awaiting core readiness. +- RED: `pnpm exec vitest run packages/core/__tests__/world-cells-admission.spec.ts packages/core/__tests__/virtual-shadow.spec.ts packages/core/__tests__/startup-readiness.spec.ts -t 'requires visible distant|settles all covered|requires two quiet'` exit **1**, all three new behavior tests failed on the old implementation (camera rejected, shadow settlement absent, readiness too early). `artifacts/playtest/prd618-phase2/checks/prd618-phase2-red.log` preserves output. Supplementary test corrections isolated publication-region notifications from prewarm blanket invalidations, kept chunk cells required when prop reach excludes placements, and advanced existing game harnesses through the two added rendered polls; no runtime assertion was weakened. +- GREEN: `pnpm exec vitest run packages/core/__tests__/world-cells-admission.spec.ts packages/core/__tests__/virtual-shadow.spec.ts packages/core/__tests__/startup-readiness.spec.ts packages/core/__tests__/startup-timeline.spec.ts packages/core/__tests__/startup-ready-bound.spec.ts` exit **0**, **166/166** tests. `pnpm exec vitest run packages/core/__tests__/game.spec.ts -t 'startup compute|startup readiness|explicit warm-up|ctx.beforeRender'` exit **0**, **9 passed / 47 deliberately unselected**. These include the real game-loop wiring: work in `beforeRender` resets quiet settlement, covered shadow mode persists until readiness and then turns off. `pnpm --filter @threenative/core build`, `pnpm --filter strata-terrain-preview exec tsc --noEmit -p tsconfig.json`, and `pnpm --filter strata-terrain-preview build` each exited **0** after the final behavior edits. `pnpm exec biome check --write` on the 12 then-changed implementation/test files exited **0** (45 complexity warnings); final focused formatting and `git diff --check` passed. Full core suite and unrelated documentation gates were not run. Full passing check outputs are under `artifacts/playtest/prd618-phase2/checks/`. +- Real headed attempt: `DISPLAY=:1 TN_PLAYTEST_HOST_DISPLAY=1 node packages/playtest/dist/runner/cli.js examples/strata-terrain-preview/playtests/reveal.playtest.json --url http://127.0.0.1:16180 --server-command "pnpm --filter strata-terrain-preview dev --host 127.0.0.1 --port 16180 --strictPort" --browser-recipe webgpu --headed --timeout 180000 --artifacts artifacts/playtest/prd618-phase2/reveal-1` exited **1**. `capture.json` records actual WebGPU adapter info **vendor=nvidia, architecture=turing**; full `run.log`, `console.json`, `reveal.png` and `reveal-plus-3s.png` are retained there. Observation: **publishedBatchesInView=0, shadowInvalidated=0, impostorPendingPeak=0**, elapsed **4167 ms**, **2 samples**, `timeToReadyMs=35797.800048828125`. The only failed assertion was the runner's triviality guard: publication count was 0 before observation as well as after. The final code now reports **-1 (unobserved)** until the stationary reveal window starts and initializes the count to 0 there, matching the other unavailable counters. The scenario and its zero expectations are unchanged. This is **not a green AC-1 result**. Pipeline census remained unavailable (`pipelineCensusAvailable=false`, `pipelinesCreated=-1`); AC-2 stays open pending PRD-616/#509 and a measured zero. +- Final-code rerun: the identical command with `--artifacts artifacts/playtest/prd618-phase2/reveal-2` exited **75**, the normal **120000 ms** capture-lock timeout behind holder **PID 3762138**, queue depth **2**. Full command shape above, `reveal-2/run.log`, `console.json` and `exit-code.txt` preserve the setup outcome; no lock bypass. No final-code adapter/capture result is claimed. Three paired timing samples and the frozen BEFORE baseline rerun remain unavailable, so AC-3 is unverified. AC-4 desktop is unrun, AC-5 has no before/after judge verdict, and these unlabelled captures do not satisfy its visual proof. No licensed Fab assets are present; no tree-quality or Phase 3 claim is made. Frozen `baseline-site` from source **88b988cd7** was not modified. All Phase 2 AC boxes remain open. + +- 2026-10-15 scaffold gate repair: actual `createProject({ install: false })` generation at source `919dc0356077c5b7f399338dc3cb0ae79e531af4` versus `f281d95c99191b507a57c1100ce4af87b8f19624` byte-compared all 1,484 files in all 13 templates. The sole differing path per tree is `patches/three@0.185.1.patch` (scheduled previous-instance history upload deduplication); the migration patch is not copied. Temporarily substituting only the two predecessor patch files recovered all 13 prior full-tree fingerprints; guaranteed `finally` restoration asserted both current files byte-identical before fixture edits. Updated only measured fingerprints/comment. Focused equality: 1/1 passed; complete `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts`: 66/66 passed (exit 0). Biome, `pnpm check:docs`, six document-contract suites (246/246), and `git diff --check` passed. No product build/install, dependency mutation or runtime qualification; AC-3/AC-5 remain open. Parent owns the pre-push retry and paired startup qualification. + +#### Phase 3: Far trees keep their shape + +**Status:** PARTIAL +**Files:** `examples/strata-terrain-preview/scripts/prep-fab-temperate.mjs` (card thinner), `src/render/pack.ts` (third level, shadow cutout), `src/render/propStreaming.ts` (impostors on), `packages/core/src/world-cells.ts` (impostors beside authored levels, dithered transition). +**Implementation:** Solution steps 4 and 5. If the judge still sees far speckle, the next lever is coverage-preserving cutout mips in the cook. The game picks the distances and the look. The engine only allows the impostor band beside authored levels. +**Verification:** AC-6 to AC-8 on the owner's machine with the licensed assets. + +- [ ] AC-6 impostors drawn past 150 m. proof: AC-1 scenario from the `overview` view. +- [x] AC-9 focused dither contract: both levels submit in view/shadow, shared masks use complementary `<` / `>=` comparisons including equality, and the source retires after the band. proof: `pnpm exec vitest run packages/core/__tests__/world-cells-data.spec.ts packages/core/__tests__/world-impostor-surface.spec.ts` (included in the 130/130 green focused run below). Shader graphs and CPU submission are tested; headed GPU/judge proof remains AC-7. +- [ ] AC-7 judge IMPROVEMENT. proof: judge subagent verdict on the PR. +- [ ] AC-8 frame time holds. proof: `measure-steady-state-fps`. + + +**Phase 3 implementation and focused checks (2026-10-15):** + +- 2026-10-16 licensed-input correction: all seven owned packs are imported; the newer `KiteDemo421` artifact succeeds where the older artifact's metadata scan failed on unrelated textures. Actual spruce model 2 contains a welded 10-triangle component beside 1522 reducible cards, exceeding the existing eight-triangle card limit. The reducer now preserves oversized components unchanged while thinning eligible whole cards; the limit, attribute preservation and edge/area guards remain. Synthetic regressions check paired source triangles, every rung's preserved geometry, terminal budget overhead and rejection when nothing is reducible. Asset suites pass 34/34 and the package builds. At this stage, no real candidate cook or visual verdict was claimed. The original baseline cook reached encoding but lacked the isolated snapshot's transcoder resolution; its dependency link is repaired and its retry was refused by the laptop thermal guard. + +- 2026-10-16 isolated conditioning trial: the full 2048-pixel baseline cook failed at 132925956 uncooked / 222295813 total bytes. An isolated 1024-pixel candidate cook completed all **67 prepared models** with the same quality floors, geometry and budget checks: **38589218 uncooked bytes**, **76938275 compiler total bytes**, and **76353413 live manifest bytes**. Existing shipping settings remain unchanged. Copied valid output, cook log, trial configuration and exit-0 receipt are in ignored `artifacts/playtest/prd618-candidate-1024-trial/`; all raw packs are preserved. The matched original-foliage 1024 baseline launcher is prepared but not started at ignored `artifacts/playtest/prd618-baseline-1024-trial/run.sh`. No shipping adoption or visual acceptance is claimed. + +- 2026-10-16 lint unblock: Biome excludes the generated native asset staging tree alongside the existing licensed source exclusion. An exact-file naming convention preserves the donor's eight mathematical property names while temporary fixtures still reject other non-camelCase properties and variables. `pnpm lint` exited 0; the arm's full Biome error check covered 3,810 files. No runtime or visual AC is satisfied by this configuration repair. + +- The public `thinFoliageCards(document, keepRatio)` extends the existing asset-cook mechanism: `generateCardChain` plus its attribute-copy attachment, with whole-card selection and the existing 2x edge / 4x area cap. The spruce prep uses the game's existing 0.14 ratio, preserves opaque parts, and fails if MASK cards cannot be reduced. It no longer triangle-decimates spruce. Tests retain every semantic, check copied attribute values and opaque indices, whole-card triangle pairs, the existing area cap and invalid ratios. No licensed file was cooked or substituted. +- Strata explicitly enables the existing impostor baker and sets spruce's new per-asset `impostorDistance` to `LOD_BANDS.far` (150 m). The manifest validates this positive gate after every authored rung and includes it in alias identity. On the explicit CPU/preserved-parts path, authored near/mid and their shadow parts stay intact; only the terminal rung is a whole-asset impostor, and the old coarse-wide substitution/far aggregate is bypassed for this path. Other assets without a terminal gate are not baked on this preserved-parts path. Default launch opt-in and unsupported GPU/hysteresis guards remain. Stream readiness waits for queued bakes and their newly owed prewarm draws; stats exposes live terminal instances before fragment masks, not a GPU-submission receipt. +- The existing node-material and instance-matrix machinery supplies a complementary per-pixel view/shadow mask. The game's `lodHysteresis` sets the band; recovered placement roots supply a stable offset, and the live follow eye plus existing LOD bias drive both masks. Both neighboring levels enter existing batches with admission headroom, and added band-edge refilter gates retire the source afterward. Strata's shadow mask reads the exact view `opacityNode` sample and mip-adjusted `alphaTestNode`; a scalar floor preserves shadow classification while original cutoff metadata is retained for the atlas. No appearance preset, dependency, renderer, report or harness was added. +- RED: standalone-card test failed with the missing public mechanism; authored/impostor test failed on the original unsupported-combination guard; the actual view/shadow transition test against `HEAD` world-cells failed with missing masks; the actual Strata loader/cutout test against `HEAD` pack failed with a null shadow mask (all exit 1). GREEN: `pnpm exec vitest run packages/assets/__tests__/foliage-lod.spec.ts packages/core/__tests__/world-cells-data.spec.ts packages/core/__tests__/world-cells-chain-lod.spec.ts packages/core/__tests__/world-impostors.spec.ts packages/core/__tests__/world-impostor-surface.spec.ts packages/core/__tests__/world-cells-admission.spec.ts` exit 0, **130/130**. This includes the real stubbed bake integration past 150 m, exact authored source membership, both view/shadow submissions, pixel comparison complementarity, cutout node identity, source retirement and unsupported guards. One intermediate test run raced a concurrent core rebuild and could not import `@threenative/core/world`; the final sequential run above passed after the build completed. Initial test setup also needed awaiting cutout conversion and unwrapping Three's `VarNode`; these were test failures, not licensed/runtime evidence. +- `pnpm --filter @threenative/core build` (including declarations/publint), `pnpm --filter @threenative/assets exec tsup --config tsup.config.ts`, package core/assets `exec tsc --noEmit`, Strata `exec tsc --noEmit -p tsconfig.json`, Strata `build`, prep-script `node --check`, focused `biome check --write` and `git diff --check` all exit 0 (Biome complexity warnings and existing build chunk warnings remain). **No final headed reveal, startup/timing run, native qualification or visual judgement was run here.** Parent owns the six fresh timing samples, stable-display NVIDIA reveal/captures and fresh visual judge/PR screenshots. AC-6–AC-8 remain open pending real local Fab bytes and runtime/judge/performance proof; AC-2 still depends on PRD-616/#509. Phase 2's paired startup result belongs to source `3ccb616b6` and must be rerun on this foliage revision. Both frozen sites and native build/dependencies were untouched. + +**Visual and native follow-up (2026-10-15, partial):** The fresh read-only startup judge compared two runs per arm at reveal and +3 seconds: all four comparisons and the overall verdict were **NEUTRAL**. This does not pass AC-5's IMPROVEMENT requirement. Existing polling screenshots cannot guarantee the actual first visible frame; a continuous capture is being checked, without changing acceptance criteria. The eight large BEFORE/AFTER images and the honest verdict are posted on [PR #510](https://github.com/ThreeNativeHQ/threenative-engine/pull/510#issuecomment-6202515492), from orphan screenshot commit 2d7bf1ee6f387b8316185332f92c8a1d3fc6fcdc. + +Native host build and the Strata desktop bundle passed. The first native reveal run on RTX 2080 failed: four already-satisfied assertions and 120 failed asset probes; AC-4 remains open. Its measured window did transition complete false→true, samples 1→7 and elapsed 0.055→3017.306 ms. Diagnostics remain enabled while staging and scenario timing are corrected. Native readiness was 49,874 ms; this unpaired native result is not a browser startup comparison. + +The owner's square-leaf check is in progress. Source inspection confirmed procedural leaves are textured cards and that a missing needle atlas selects opaque green fallback cards (propMaterials.ts). The native report contains a failed bare needle-atlas.png request, but not a failure for the staged assets/needle-atlas.png retry; atlas failure and the square-card trigger are therefore **not confirmed**. A close alpha-cutout capture is still required; licensed Fab foliage remains unverified. + +**Review repair and native green (2026-10-15):** Review found an unbounded final settlement wait. Commit 42ac0c371 bounds it with the existing stable-window budget, rejects readiness and reports launch failure without lifting the cover; 116 focused tests, core build and types passed. The screen-coordinate dither finding is not accepted: complementary masks apply within each view/shadow pass; replacing this with each LOD's different world fragment coordinate would lose complementarity. The explicit 150 m impostor gate is deliberately unscaled: the CPU selector supplies impostor:false for explicit impostorDistance, matching shader/admission gates. + +After native staging emits an actual-files-only manifest, the reveal scenario passes on RTX 2080 with zero diagnostics and a measured three-second window. Exactly four held-invariant triviality reasons are recorded; complete/samples/elapsed remain unwaived. The source atlas passes alpha transparency but both close-up runs show clipped outer leaf edges according to a fresh read-only judge. The atlas boundary fix and fresh visual qualification are in progress; no leaf visual acceptance is claimed yet. + +**Square-leaf repair qualified (2026-10-15):** Commit aa545be5e fits complete generated needle strokes inside their atlas cells before rasterization, preserving their full silhouette; the previous generator clipped them at cell boundaries. The existing custom-asset verifier now rejects needle coverage at tile edges. The original atlas failed at dense-cell pixel (40, 0); all three regenerated cells pass, and repeated generation is byte-identical. Atlas, surface relief and provenance hashes were regenerated together. The fixture's first quad has the actual source indices and whole-cell UVs, so it is not a cropped fragment of a larger strip. + +Two matched headed WebGPU close-ups per arm verified NVIDIA/Turing, fallback=false. Formal runs leaf-before-hw-1/2 and leaf-after-hw-1/2 under artifacts/playtest/prd618-video produced exactly the same PNG bytes as the original judged pairs. The fresh read-only judge returned **IMPROVEMENT for both pairs and overall**: complete irregular needle tips replace straight clipped edges, while branch detail and alpha holes survive. Fine tips remain jagged and dense bands remain flat-looking. The [PR comment](https://github.com/ThreeNativeHQ/threenative-engine/pull/510#issuecomment-6202942061) includes all four large stamped images from orphan screenshot commit a0facc4d3d123426f2eb5c2ed3b2e3676309a994. This qualifies the square-edge repair only; AC-5 and licensed-foliage AC-7 stay open. + +Full typecheck passed after correcting a fixture dictionary spread type. Normal full lint passed with warnings after formatting the generated native manifest. The full test command passed core/assets packages and 1579 native tests, but failed eight native tests because their executables were not built; native prerequisites are being repaired without product changes. This is not a full-suite pass. The full terrain scenario and final code review remain pending. + +**Final review repairs (2026-10-15):** Review pass 2 found two P2 issues, now corrected locally. Commit 4c5cc8174 isolates the native manifest under assets/ and runs the native host from that directory, preserving the web root and available prepared/temperate aliases. Its temporary-fixture regression executes the real staging script and Vite configuration, verifies web and native asset resolution, legacy-manifest migration and missing optional handling; static checks pass. Rendering real licensed KTX2 content is still unverified. Commit 7701cd799 cancels readiness timers during game cleanup and ignores late completions after stop; the reproduced post-stop false settlement failure is prevented, while active timeout/error propagation and the two quiet frames remain covered. All 93 focused tests, package typecheck and Biome pass. Core rebuild and final runtime reruns wait for the active full terrain scenario to finish. + +**Full terrain run failed (2026-10-15):** The headed NVIDIA/Turing run at 1920×1080 completed with exit 1: startup/view/performance assertions failed and 59 console errors were captured. The first concrete errors are WebGPU vertex inputs at location 16 exceeding the 16-slot limit, in both MeshStandardNodeMaterial and ShadowMaterial. This exposes a new engine dither-path regression that focused graph tests missed; its repair is in progress. The full result is /tmp/prd618-terrain-final.log and captures are under examples/strata-terrain-preview/artifacts/playtest/prd618-terrain-final/. Its 33.997 s showcase startup is not a controlled comparison with the earlier 1280×720 paired sample and does not establish the final-source startup ratio. Final-source AC-1/AC-3 and full terrain qualification remain unverified. + +The native legacy-manifest migration also now handles prior-revision byte counts (54890e682), tested against stale atlas sizes while preserving non-equivalent authored manifests. Native unit prerequisites are built for V8 and a QuickJS test configuration with optional Rust overlay/SWC/WebTransport disabled to avoid static-link collisions; these binaries have not yet been executed. This test-only QuickJS configuration is not UI qualification. + +**Final-source startup regression (2026-10-15):** Six sequential controlled headed NVIDIA/Turing runs at 1280×720, frozen BEFORE `88b988cd7`, AFTER `18b51b109`: BEFORE **10718.8000, 10599.8999, 10247.6001 ms**; AFTER **26829.5000, 24483.1001, 26130.1001 ms**. Medians **10599.8999 → 26130.1001 ms**, ratio **2.46513**, so AC-3 fails. Diagnostics, actual three-second observation and all AFTER late-work counters pass. No own builds or other captures overlapped these pairs. Raw logs/captures: `artifacts/playtest/prd618-final/{before,after}-{1,2,3}/`; command driver/output: `/tmp/prd618-final-startup.py`, `/tmp/prd618-final-startup.log`. Earlier Phase 2 +6.844% is historical, not final-source acceptance. Profiling attributes repeated readiness visibility scans (7.275 s inclusive in the perturbed candidate profile); commit `35576af61` eliminates duplicate bounds transforms for unchanged exact inputs, with 59 focused tests and package build/typecheck passing. Its paired timing is pending; no startup improvement claimed yet. + +**Owner-required CI hardening (2026-10-15):** Add a blocking startup comparison using the real Strata forest workload, three comparable runs per arm and the existing 1.25 median bound; missing timing, mismatched adapter, runtime errors and an incomplete reveal observation must fail. Keep the baseline pinned rather than silently ratcheting it to a slower candidate. CI timing does not replace physical-GPU qualification or the fresh visual judge. +- [ ] CI startup regression gate runs from an existing workflow and rejects the measured 2.46513× regression. proof: required benchmark workflow command, comparator regression test and actual paired run; qualification pending. + +**CI hardening implemented, runtime qualification pending (2026-10-15):** Commits `452de2e95` and `c7944ed8d` add the real forest startup comparison to the existing required `benchmark` job. Baseline source is pinned to `88b988cd70d5db7266eb0281e5f74bd0e030b3e7`, built with its own workspace dependencies; the candidate uses the scoped SHA. Six runs alternate pair order and enforce median AFTER ≤ 1.25× BEFORE. Missing/malformed observations, incomplete three-second windows, diagnostics, adapter changes and nonzero AFTER late counters fail closed. Hosted execution is explicitly a software regression contract, not hardware or visual acceptance. The baseline needs a retained ref so squash merging this PR cannot discard its source. **215 focused CI tests pass** (guard, structure, needs); replaying the six actual `18b51b109` logs exits 1 specifically for **2.465127 > 1.25**. This proves rejection of the measured failure, not a successful end-to-end hosted run; the CI box stays open. Whole-package comparison was narrowed to dependency/toolchain fields so native command edits do not falsely invalidate the pair. + +**Readiness-cache timing rerun (2026-10-15):** Source `35576af61`, same frozen BEFORE and NVIDIA/Turing 1280×720: BEFORE **10836.8999, 10805.8999, 9696.3999 ms**; AFTER **22172.5000, 26401.6001, 22929.3000 ms**. Medians **10805.8999 → 22929.3000 ms**, ratio **2.12192**: AC-3 still fails. All AFTER diagnostics/counters/window assertions pass; BEFORE run 3 exposes one late shadow invalidation. Raw artifacts: `artifacts/playtest/prd618-perf/{before,after}-{1,2,3}/`; driver/output `/tmp/prd618-perf-startup.py`, `/tmp/prd618-perf-startup.log`. No own builds or other GPU captures overlapped; focused CI CPU checks overlapped part of this diagnostic sequence, so no final acceptance measurement is claimed. The remaining attribution hypothesis is five spruce atlases advancing one view per render cadence with intervening full-world draws; temporary boundary tracing is required before changing scheduling. + +**Checkpoint checks and current visual verdict (2026-10-15):** Full `pnpm typecheck` passes after narrowing the guard's indexed accesses and annotating the existing real-WGSL fixture accumulator as `Node<"vec4">`; all four WGSL budget tests pass unchanged. The 215 focused CI tests and actual six-log rejection remain green. Fresh read-only judging of two matched headed NVIDIA/Turing runs per arm at source `35576af61` returns **NEUTRAL in all four paired frames and overall; AC-5 FAILED**: both arms appear stable between reveal and +3 s, with no visible completeness gain. Endpoint captures cannot exclude changes between them. Eight large BEFORE/AFTER-stamped images are retained on screenshot branch commit `3d00c562cbf99161a82151216236a0e53ae7f3a5`; judge output `/tmp/prd618-final355-visual.log`. Licensed AC-7 is not assessed. + + +**Covered impostor cadence (2026-10-15, bounded follow-up):** Existing trace provenance resolves to source `e64fb16e8`: `/tmp/prd618-bake-trace-build.log` records that HEAD, the temporary patch's original blobs match current `world-cells.ts` (`7001b68dd`) and `warmup.ts` (`19fdc98fb`), and the frozen diagnostic bundle SHA-256 is `751c4195e8603925ceed33f9d6017a7cb9d6278218640f6005ff097564dcf61d`. `artifacts/playtest/prd618-bake-trace/run-1/console.json` records five covered atlases, 80 view starts/ends over **11216.8 ms**, **94.1 ms** summed synchronous capture CPU, median inter-start gap **99.8 ms**; the dominant measured bake cost is render-update cadence, not synchronous capture CPU. GPU execution time and intervening world draw count are not measured by those probes. Initial/held warm-up wall spans are **3798.9/6657.4 ms**, separate from bake CPU. + +The engine now drains only the current atlas's remaining views while covered, retains one view per uncovered update, and yields immediately on no view progress. No public API, render quality, pipeline validation or submission-readiness policy changed. Focused regression was observed red (**2 captures, expected 16**) before the fix; impostor/baker specs pass **55/55**, admission/streamed-warmup specs **24/24**, consumed core build and package typecheck exit **0**. Focused Biome exits **0** with existing complexity warnings. No temporary diagnostic source remains. + +One current-source AFTER diagnostic: `DISPLAY=:0 XAUTHORITY=/run/user/1000/xauth_zPIAAP TN_PLAYTEST_HOST_DISPLAY=1 CAPTURE_LOCK=1 node packages/playtest/dist/runner/cli.js examples/strata-terrain-preview/playtests/reveal.playtest.json --url http://127.0.0.1:16188 --server-command "pnpm --filter strata-terrain-preview dev --host 127.0.0.1 --port 16188 --strictPort" --browser-recipe webgpu --headed --timeout 180000 --artifacts artifacts/playtest/prd618-resume-trace/after-1` exited **0**, real adapter **nvidia/turing**, **0 console errors**, all scenario assertions pass. `timeToReadyMs=31228.3999`; stationary observation **4597.9 ms**, **2 samples**, late publication/shadow invalidation/impostor pending/pipeline creation counters all **0**. Five completed 16-view atlases are logged. Startup timeline: load **3723.9**, entered **4444.0**, compile settled **13670.7**, framework ready **13823.2**, ready **30911.2 ms**; reported initial/held warm-up elapsed **9160/8097 ms** (reported elapsed is not independent wall-time attribution). Full run output, console and captures remain under `artifacts/playtest/prd618-resume-trace/`. The first attempt exited **2** before launch because DISPLAY lacked Xauthority; retry used the active Xwayland credentials and normal capture lock. This dev-server sample is diagnostic, **not AC-3 proof or a speedup claim**; the parent owns paired built-source measurements and fresh visual judgment. No acceptance box ticked; licensed assets remain absent. + +**Covered-cadence paired qualification (2026-10-15):** Six alternating built-source headed NVIDIA/Turing runs compare frozen BEFORE `88b988cd7` with AFTER `b69509f68`. BEFORE **10209.3000, 10013.2000, 9636.1001 ms**; AFTER **15129.8999, 14569.0000, 17295.3999 ms**. Medians **10013.2000 → 15129.8999 ms**, ratio **1.510995**: **AC-3 still fails**. All six scenarios exit 0; the existing comparator accepts diagnostics, adapter identity, stationary observation and AFTER late-work counters, then exits 1 specifically for the startup ratio. All sources remain clean, with no own build or competing capture during this valid sequence. Artifacts and rejection output: `artifacts/playtest/prd618-resume-pairs/retry/`. The earlier incomplete attempt exited 75 waiting for another capture and is excluded. Parent independently reran the impostor spec (**41/41**) and rebuilt both arms. No new acceptance box is ticked; fresh visual and native qualification remain pending. + + +**Scheduled instance-history reuse (2026-10-15, bounded follow-up):** The actual patched Three hook now caches only the last scheduled immutable VelocityTracker snapshot identity on its per-object history record. Repeated scheduled draws skip the CPU copy and version invalidations; a changed snapshot (including reused A/B/A buffers) uploads again. Unscheduled Three history still copies every draw and clears the cached scheduled identity. The tracker, camera/skinning/wind paths, count/allocation logic and dynamic-buffer upload policy are unchanged. All three active mirrors (`build/three.webgpu.js`, `build/three.webgpu.nodes.js`, `src/nodes/accessors/Instance.js`) agree. The shipped installer actually consumes `three@0.185.1-prd269-upgrade.patch`, so that file gains exact-hash migrations from the preceding installed blobs; it is not an obsolete patch edit. Resolved dependency targets are inside this checkout and each modified file has link count 1; no baseline/dependency/build bytes in another checkout were read or changed. Installer exits 0 (`patched`, then `unchanged`). + +The real draw-boundary regression was observed red (3 failures: repeated uploads), then green: `pnpm exec vitest run packages/core/__tests__/instance-velocity-upload.spec.ts packages/core/__tests__/render-velocity.spec.ts packages/core/__tests__/three-patch-upgrade.spec.ts` passes **62/62**, including the new predecessor's LF/CRLF/idempotence/custom-edit-refusal cases. The separate packaging run passes **8/8**. `pnpm --filter @threenative/core typecheck`, focused `pnpm exec biome check`, `pnpm --filter @threenative/core build` and `pnpm --filter strata-terrain-preview build` exit 0. Patch-context whitespace is intentional; `git -c core.whitespace=-blank-at-eol,-space-before-tab diff --check` passes. + +One built-source headed WebGPU reveal, dedicated port **16191**, normal capture lock and active Xwayland credentials, exits **0**, adapter **nvidia/turing**, no diagnostics. Observation **4681 ms / 3 samples**: late in-view publication, invalidated shadow render, pending impostor peak and post-reveal pipeline creation are all **0**. `timeToReadyMs=13062.6001` is a single diagnostic sample, not a speedup or AC-3 qualification. Artifacts: `artifacts/playtest/prd618-velocity-reuse/reveal/`. The existing `examples/abyss-framework/playtests/velocity-history.playtest.json` also passes on the same adapter with `velocity.html?instanced`: **25680 moving pixels**, oracle error **0.015625 px**, stationary/outside-footprint/stopped velocity **0**. The same existing scenario also passes with `velocity.html?instanced&dynamic` (same adapter, moving coverage and error bounds; no diagnostics). The prior CPU profile covered only part of startup; neither its writeBuffer time nor this run establishes full-startup attribution. Parent owns fresh alternating timing samples and visual judgment; **AC-3 and AC-5 remain open**, with no acceptance boxes changed. + + +**b25 paired evidence preparation (2026-10-15):** Existing six alternating runs in `artifacts/playtest/prd618-b25-pairs/{1-before,2-after,3-after,4-before,5-before,6-after}/` were parsed with exported `parseStartupReport` and checked with `verifyStartupRuns`. All source receipts match BEFORE `88b988cd70d5db7266eb0281e5f74bd0e030b3e7` / AFTER `b25bb33b7c97ec104c49abb2b95fa6506a9137f4` (executable change `919dc0356077c5b7f399338dc3cb0ae79e531af4`); recorded configuration is headed X11 WebGPU, NVIDIA/Turing, 1280×720. BEFORE timings **9439.39990234375, 9508.699951171875, 9364.89990234375 ms**; AFTER **13343.5, 13356.10009765625, 12939.10009765625 ms**. Medians **9439.39990234375 → 13343.5 ms**, ratio **1.4135962177730053**, **FAIL against 1.25**; the comparator rejects specifically at that bound. All six scenario statuses are **0**, with empty diagnostics. In run order, measured windows are **3185.2999999970198, 5201.5, 5080.29999999702, 3251.2000000029802, 3063.0999999940395, 5173 ms**, with **2, 3, 3, 2, 2, 3 samples**; observed late in-view publication, shadow invalidation and pending-impostor counters are **0 in every run, including baseline**. These runs establish no baseline red. + +Eight authentic figures in `artifacts/playtest/prd618-b25-pairs/judge/{before,after}-{1,2}-{reveal,reveal-plus-3s}.png` use BEFORE runs 1/4 and AFTER runs 2/3 respectively, each with a separate 112 px black top band and 56 px white DejaVuSans BEFORE/AFTER label. Exact pixel equality of the unchanged 1280×720 source region was asserted after PNG round-trip. **Limitation:** the runner treats the scenario's descriptive subject as an unresolved entity and waits 30 frames after each step: these are **DELAYED ENDPOINTS, not exact first-visible frames**, and cannot exclude intervening changes. Fresh visual judge pending; no visual verdict supplied here. **AC-3 and AC-5 remain open.** No code/scenario changes, builds, installs, tests, captures or push/PR changes were performed for this evidence preparation. + +**Bounded native camera investigation (2026-10-15, no fix justified):** The fresh desktop rerun fails exit 2 at `WorldCells.readinessAt` finite projection/inverse-transform validation (`packages/core/src/world-cells.ts`). Its `device-response-observations.json` records successful ready request 703 followed by advance request 704 returning that error, immediately after prop-world creation in `console.json`; neither artifact retains an exception stack or the offending matrix values. The emitted desktop bundle preserves the source spread/every(Number.isFinite) check unchanged. This does not prove a cross-runtime array defect or identify which camera input became non-finite. After the eight-read investigation limit, no engine, test or scenario change was justified. `pnpm exec vitest run packages/core/__tests__/world-cells-admission.spec.ts` passes **19/19**, exit 0; this is Node coverage, not browser/native reproduction or red/green proof. Next owner: parent native runtime lane, capture the thrown stack and both matrices plus camera projection/pose at request 704, then compare the same browser lifecycle before assigning a fix. No native host rebuild or GPU capture was run; AC-4 remains open. + +2026-10-15 reveal scenario correction: removed the descriptive `subject` because the runner interprets it as an entity ID and otherwise inserts 30-frame settling waits after each step. Four headed NVIDIA/Turing diagnostic captures with the corrected scenario pass (two current-atlas, two baseline-atlas); `artifacts/playtest/prd618-atlas-isolation/` retains commands and receipts. This removes the unintended waits, but does not guarantee the exact first visible frame or qualify AC-5. Original textures and browser build were restored after the atlas-only diagnostic; its fresh judge remains pending. + +**Atlas regression rollback (2026-10-15):** The frozen `88b988cd70d5db7266eb0281e5f74bd0e030b3e7` generator diff contains only the unrequested stroke-fitting change; restored that generator and both needle PNGs, preserving parent `68bf3794a` and all engine/LOD/licensed-card work. The existing four-run headed NVIDIA/Turing atlas-only ablation and fresh read-only judge viewed eight images: **IMPROVEMENT in all four paired frames**, no added lighting/shadow regression (`artifacts/playtest/prd618-atlas-isolation/judge/`; primary log `.linchpin/prd618-atlas-judge.log` in the owning checkout). This is diagnostic red/green recovery, **not AC-5 qualification**: broad cards/holes remain; exact first-visible-frame, global temporal stability, AC-3, licensed Fab and full gates remain open. No acceptance boxes changed. Verification: two generator runs reproduce exact frozen baseline bytes deterministically; SHA-256 needle atlas `d2f1fd6eb3f87083a1c72a07add0c485f477b7b5e64e1d9db0108298c75eae50`, needle surface `45d236f6b61f745753fd86b06b0c27d451ae118c9f0164ce44dda51f297ecbf5`. Surface contracts **2/2 pass**; adjacent LOD contracts **27/32 pass**, with five unchanged authored-cutoff failures (`alphaTest=0.0001`, expected `0.33`, `prop-lod.spec.ts:355/430/451`); contracts were not weakened. Focused Biome, documentation links and six documentation/CI contract files (**246/246**) pass. No captures, host builds, installs or push/PR/merge performed. + + +**Authored cutout cutoff recovery (2026-10-15, bounded follow-up):** Game-owned `src/render/pack.ts` copied the imported scalar cutoff, then overwrote it with `1e-4` in `cutoutSurface`. Removed that overwrite; the authored scalar survives while the existing mip-aware `alphaTestNode`, matching `maskShadowNode`, normals and texture bindings remain intact. Compared the exact function against `88b988cd7`. Unchanged stub-based LOD tests reproduce **27/32 pass, five failures** before and **32/32 pass** after; nearest surface tests **2/2 pass**. Focused Biome exits 0 (four complexity warnings); example TypeScript check exits 0. No licensed bytes, GPU captures, visual judge, playtest, native build or full gates run; view/shadow runtime equivalence remains unverified. No AC-6/7 or other acceptance boxes changed. + +**Final-source qualification (2026-10-15, `290bf4c2b`):** Six sequential alternating headed WebGPU runs, same corrected scenario and 1280×720 stationary forest pose, physical NVIDIA/Turing. Frozen BEFORE `88b988cd7`: **9836, 9689.5, 9615.3999 ms**; AFTER: **13555.5, 13756.1001, 13377.3999 ms**. Existing comparator validates source receipts, adapter identity, diagnostics, all observations and AFTER zero counters, then rejects median ratio **1.3989886 > 1.25**. All six scenario exits are 0; windows exceed 3 s with **45, 37, 33, 46, 41, 34 samples** respectively, and all three late counters are 0 in both arms. No new baseline red is claimed. Artifacts: `artifacts/playtest/prd618-final-290-pairs/`; eight labelled figures retain the original screenshot pixels unchanged. Fresh judge: temporal NEUTRAL / AC-5 FAIL; secondary NEUTRAL / PASS. No licensed Fab proof, exact-first-visible-frame guarantee or full-board qualification. PR remains draft and out of the merge queue. + + +**Covered queue drain (2026-10-15, third bounded performance hypothesis):** `WorldCells.#stepImpostor` returned after adopting one atlas, leaving distinct queued atlases until later updates. A nonrecursive outer loop now captures queue length plus any in-flight job at entry while covered; uncovered updates retain one view. Existing cache, budget, disposal, failure and inner-view/no-progress guards remain. Regression observed red: two distinct assets required 32 captures but only 16 completed. Green covers two queued jobs both with and without an in-flight bake; focused impostor, bake and startup-readiness suites pass **81/81**. Core typecheck, focused Biome (23 warnings, no errors), core build/publint and `git diff --check` pass. Timing, fresh visual judgment, playtest and native qualification remain pending with the parent; this is not a speedup or acceptance claim. No acceptance boxes changed. If the paired startup ratio still exceeds 1.25, stop performance edits and question the cross-atlas update/warmup-gap hypothesis. + + +**Evidence reset after owner review (2026-10-15):** The owner correctly rejected the visually similar BEFORE/AFTER endpoints as improvement evidence. The covered-queue timing series at source `53eb08e84` was stopped after two samples; the entire partial series is excluded from qualification. PR #510 remains draft, explicitly labels the visual evidence failed, and collapses the existing screenshot comment without deleting it. No acceptance box changes. + +A bounded read-only capture audit found that `reveal.png` follows startup readiness polling, a baseline bridge sample, the `world-ready` step/sample, another fixed tick, and the reveal label sample/event drain. It is not synchronized to the first uncovered frame (`packages/playtest/src/runner/runner.ts:455,655,755`; `steps.ts:323`). Existing Playwright tracing starts before page creation (`runner.ts:383`), but does not guarantee every presented frame. One frozen-baseline headed WebGPU diagnostic with `--trace` exited **2**, `TN_PLAYTEST_PAGE_CLOSED`, before producing `trace.zip`; no usable reveal evidence resulted. Runner doctor passes Node, Playwright, Chromium and display prerequisites; this does not diagnose the browser closure. Log: `/tmp/prd618-first-frame-trace.log`; retained console: `artifacts/playtest/prd618-first-frame-trace/console.json`. No automatic retry. Next qualification prerequisite: observe the actual cover-to-world transition and reproduce the baseline defect before another improvement comparison. + + +**Trace failure investigation (2026-10-15):** Host journal for 18:30–18:34 records repeated NVIDIA **Xid 31 / MMU faults** in Chrome processes, plus KWin page-flip stalls, including 18:32:17 and 18:32:35 during the failed baseline trace. Process ownership has not been established, so these faults are correlated infrastructure evidence, not proof that this capture caused them or that a driver repair is known. Earlyoom reports over 31 GiB available in this interval. Further GPU captures are stopped pending diagnosis. PRD-616/#509 is still open at `cec31f309`, with `ci-required` failed; AC-2 remains dependent and unverified. + +A second bounded source audit confirms that core startup phase ready precedes Strata cover removal, and that the existing reveal counter probe starts on the next physics callback. This leaves a cover-removal-to-probe gap in which onPublication events are ignored. A game-local measurement repair now initializes the existing reveal counters synchronously after successful cover removal using the loading controller callback; cleanup and failed startup never initialize the probe. The first counter sample no longer defines the window start. The real loading harness reproduced the missing callback (expected one observation, received none); all **23/23** focused loading tests pass after the fix, including success, early cleanup and failed startup. The adjacent forest startup contracts also pass **3/3**; Strata typecheck, documentation links and targeted Biome pass (seven existing complexity warnings). No GPU runtime rerun, first-frame pixel capture or visual improvement is claimed; all affected acceptance boxes remain open. + + +**Develop integration and laptop staging (2026-10-15):** Integrated remote PR head `9120caf4e` without conflicts at local merge `ad7310bfc`, preserving the newer local probe fixes. The merge imports the asset MCP pin, tool manifest, CI structure adjustment and lockfile. The focused CI structure suite passes **160/160**. Browser build passes; **235** built files for candidate `ad7310bfc`, frozen baseline `88b988cd7` and the shipped runner/dependencies were copied to the owned laptop lane `/home/joao/offload/threenative-engine/prd618-capture`, with **zero hash mismatches**. This is staging only, not hardware or visual qualification. + +Both frozen installs initially rejected the merged lockfile's stale Three.js patch hash. Pnpm regenerated exactly **56** occurrences of `00ce98ec…` to the actual patch hash `cc327847…`, with no dependency version or resolution changes (verified by exact old-file substitution). Local `pnpm install --frozen-lockfile --ignore-scripts` now passes. The no-install scaffold byte-stability test passes (**1 passed, 65 skipped**); loading/forest startup contracts pass **26/26**. Remote install attempts and runner doctor were refused with **exit 75**, laptop package temperature **97–100°C**; no capture or remote build ran. Remote dependencies and doctor remain unverified, and no thermal gate is bypassed. Full qualification, AC-3 and visual acceptance remain open. + + +**Post-install typecheck repair (2026-10-15):** Full `pnpm typecheck` reproduced TS2532 in the existing A/B/A instance velocity fixture (`instance-velocity-upload.spec.ts:169`): indexed typed-array access may be undefined. An explicit missing-second-matrix guard preserves the original increment and upload expectations, without casts or fallback values. The focused fixture passes **8/8** and full typecheck now passes. This changes only a test fixture; full lint and unit qualification are next, and no runtime/visual acceptance is claimed. + + +**Full-unit failure and authored far gate repair (2026-10-15):** Full lint passes with warnings, but `pnpm test` exits **1**: terrain reports **3 failed, 234 passed**. The failures concern boundary hysteresis, dense three-level canopy admission, and cutout material UUIDs; they are not waived. Native reports **1587 passed/62 skipped**, **29** parity tests and Rust groups **21+2** passed. Core's package step is publint only, so this run does not establish full core unit coverage. The command also reports temporary-directory growth. The three-level spruce adapter incorrectly specified an impostor at 150 m alongside its authored 150 m gate. It now leaves that authored far gate intact, while two-level spruce retains its existing 150 m impostor. The focused dense-canopy test passes terminal-order validation but still fails on backlog **10, expected 0**; the repair is partial. Biome and diff checks pass; runtime/visual qualification remains open. The identity and hysteresis assertions remain unchanged pending contract reconciliation. + +**Laptop startup diagnostic (2026-10-15):** The owner requested a ten-minute fan check; the scheduled reading at **19:14:32 America/Vancouver** was **55°C**, load **0.16**, 20 GB free. This is not sustained-load thermal proof. The first startup attempt failed before browser launch because the install filter omitted workspace dependencies. Including both filtered dependency closures resolved it. The second baseline job `prd618-baseline-first-frame-2` completes **exit 0**, scenario pass and empty diagnostics on actual **intel/gen-12lp WebGPU**, frozen built baseline `88b988cd7`, 1280×720. Its **12,664,811-byte** trace retains **103** frames spanning startup. Frame 63 is covered; frame 64 is the first recorded uncovered world frame; frame 85 is a later stationary view. These samples do not guarantee capture of every presented frame or yet demonstrate the owner's pop-in defect. Raw trace and extracted JPEGs: `artifacts/playtest/prd618-laptop-baseline-trace-2/`. No candidate comparison, timing qualification or visual acceptance is claimed, and no further capture batch has been started. + + +**Fresh baseline-only visual diagnostic:** A fresh read-only judge opened the covered, first-recorded-uncovered and later JPEGs. It observes no late props or shadow darkening in the sampled pair; outline motion remains uncertain. This does not reproduce the reported loading defect, prove every presented frame, or earn AC-5. Trace JPEG resolution is **800×450**, below the 1280×720 viewport. No AFTER arm was captured. Judge log: `/tmp/prd618-baseline-early-judge.log`. The imported Fab Temperate path is requested for the required licensed-content checks; procedural fallback is not substituted for them. + + +**Bounded dither contract repair (2026-10-15, incomplete):** Changed only `packages/terrain/__tests__/prop-streaming.spec.ts` and public option comments in `packages/core/src/world-cells.ts`; no executable engine changes. Initial focused file reproduces **7 pass / 3 fail**. Replacement boundary assertions check exact submitted authored geometry, neighboring levels, complementary mask comparisons, live eye/distance-bias uniforms, and return outside the fade; these are CPU submission/graph contracts, not pixel proof. The fixture uses cliff reach (300 m) so its 220 m far-only return remains eligible. Its added eviction assertion remains **failing**: the retained 500 m excursion does not evict this residency ring. A follow-up text replacement intended to add 2000 m did not match the formatted array; stopped after the repeated failure rather than continuing edits. + +Dense admission now derives a fresh-mesh ceiling from actual occupied cells, variants and level/role parts (at most 540), at two fresh meshes/update plus eight loading/publication microtask updates per cell, below 1000 updates. With a deterministic clock, admission backlog and failures reach zero; **1250 unique placement/role instances pass** (raw submissions overlap across LODs). Material assertions compare authored geometry, counts, source alphaTest/color/shared opacityNode and distinct surfaces, plus source view/shadow masks composed under AND with the same dither child. The source-value assertion exposes an unresolved executable defect: wrapper `alphaTest=0`, source `0.4`; installed Three.js `NodeMaterial.copy` skips `_alphaTest` and does not copy the inherited accessor. The expectation remains intact; fixing executable code exceeds this brief. Subsequent material assertions remain unverified behind that failure. + +Verification: combined full streaming/data/surface run **48 pass / 4 fail** (three streaming assertions then failing, plus existing surface cutoff expectation `0.0001` versus authored `0.42`); final selected boundary/admission run **1 pass / 1 fail / 8 skipped**, with only eviction failing. Focused existing core authored/dither surface contracts **10 pass / 32 skipped**. Biome on both touched TypeScript files exits 0, **23 warnings**, no errors; `git diff --check` passes. No acceptance ticks, native/visual/playtest claims, commits, pushes, GitHub actions or lock edits. + +**Cutoff root cause and CPU repair (2026-10-15):** The stronger surface assertions exposed a real engine defect: Three.js `NodeMaterial.copy` omits the inherited `alphaTest` accessor and its private backing field. `cloneLitMaterial` now explicitly restores the source cutoff after cloning; classic and node-material dither tests assert it survives. The existing Strata pack expectation now requires its authored `0.42`, consistent with the earlier removal of the scalar override. Rebuilt core before checking the terrain consumer, which imports built package output. The boundary walk now includes a 2000 m excursion that actually evicts the resident ring, then returns through the authored overlap bands. AND-node assertions unwrap Three.js's VarNode and keep both authored masks and a shared fade child mandatory. **29/29 focused tests, 237/237 terrain tests, repository typecheck, core build, game build, and Biome pass** (Biome retains warnings). The prior incomplete-repair note remains history; these CPU failures are resolved. Public comments describe positive `lodHysteresis` as the explicit opt-in to per-LOD material wrappers; default source identity remains unchanged. No visual, native, startup-ratio or full-CI acceptance is claimed; runtime smoke is pending. AC boxes remain unchanged. + +**Cutoff repair runtime smoke:** Source `132affcd2`, rebuilt game bundle copied into the owned laptop lane, passes `reveal.playtest.json` on headed physical Intel WebGPU (`vendor=intel`, `architecture=gen-12lp`), viewport 1280×720, `pass=true`, diagnostics empty. Raw capture and trace are retained under `artifacts/playtest/prd618-cutoff-runtime-1/`; job `prd618-cutoff-runtime-1` exits 0. This is one candidate smoke run, not a before/after visual verdict, native proof, startup-ratio qualification, or AC-5 improvement. No AC box is ticked. The licensed Fab asset path, fresh qualifying visual improvement, final performance ratio and full board remain outstanding. + +**Parallel unblock work (2026-10-15):** Cheap read-only arms independently checked startup, current CI and licensed assets; a bounded implementation arm resolved the develop merge. Incoming develop `8e7c96490` conflicted only in game-test imports and scaffold fingerprints. Kept both test imports/blocks, refreshed starter fingerprint from merged source, and preserved the other repaired fingerprints. The automatically merged core changes keep compute running behind the startup cover and compile WebGPU compute with zero workgroups. Full affected startup/game/warmup/surface checks pass **141/141**, repository typecheck passes, the scaffold byte-stability check passes, and both conflict files pass Biome. No full board is claimed. + +On source `863a66c0e` the Intel smoke timeline has framework-ready **5842.2 ms**, ready **16451.3 ms**, gap **10609.1 ms**. Its existing `TN_STARTUP_WARMUP_HELD` report measures **6424 ms**, 247 candidates, six newly created pipelines, none failed/pending. This attributes 60.6% of that gap to held warmup, not all of it. Candidate count versus new-pipeline count alone does not prove removable duplicate compilation; no warmup work or required set was removed. A unit test cannot establish the physical GPU timing improvement. + +PR #510 draft CI skips rather than qualifies. PR #509's inspected starter smoke fails the movement predicate: POSITION remains **0** after a three-second ArrowRight hold, with no page errors; focus/input/physics cause remains unobserved. No other PR was modified. Licensed Temperate bytes are absent from the explicit configured paths checked in this checkout, the owning checkout, and the owned laptop lane; no matching import was found in the checked Downloads locations. No download/substitute was used. AC-4 is reopened because subsequent reveal/clone changes require final-source native confirmation; historical source `290bf4c2b` proof is retained. No other acceptance box changes. + +**Startup CPU attribution and candidate (2026-10-15):** The existing browser `--cpu-prof` started after navigation/startup. Moved its existing CDP start before `openRunnerPage`; the same cleanup remains, and 22 CPU-profile/orchestration tests plus the playtest build pass. A headed Intel/gen-12lp reveal run at source `943a2458a` with this client-side instrumentation passes with no diagnostics and writes `artifacts/playtest/prd618-startup-cpu943-1/startup.cpuprofile`: **17491 samples / 18.463 s**, including startup compilation stacks. Profiling perturbs timing; it is not AC-3 qualification. Weighted child-walk time is **17.13%**; the own warmup reflector-search chain accounts for **15.24% inclusive**, versus **0.14%** for program-key hashing. Exact bundle positions resolve to `nodeHasReflector` / `hasReflector` / `renderWarmPasses`, not a vendor traversal caller. + +**Hypothesis:** Deduplicate the pure reflector-presence walk within one search, preserving the shallowest visited depth and resetting between searches. This removes repeated DAG expansion without skipping any material, visible content, render or shadow/reflection pass. A depth-12 shared graph reproduces **16382 child visits** before the fix against a <50 bound; the candidate passes that bound. The map permits a shallower revisit after a deep path, and a strengthened mutation test changes the same node between searches. Cycle termination and existing exception handling remain. **88 focused warmup/startup/profiler/orchestration tests pass; core and game rebuilds pass.** GPU re-profile, physical paired timing, fresh visual preservation and current-source native verification of this candidate remain pending; no performance acceptance box is ticked. + +**Native checkpoint before the reflector candidate:** Source `943a2458a` host freshly rebuilt successfully (log `artifacts/gates/prd618-cpu/native943-build.log`), SHA256 `3b96c9a900baf9f1266d6b068f69132f2ad361b67385b4256aaed05cc81aa5bc`. Fresh `build:desktop` bundle and staged assets pass the unchanged reveal scenario once on NVIDIA RTX 2080/Turing Vulkan, 1280×720: **11 samples / 3083.33 ms**, all three late counters zero, no diagnostics. Raw artifacts: `examples/strata-terrain-preview/artifacts/playtest/prd618-native943/`. AC-4 remains open until rerunning with the reflector candidate; no new C++ rebuild is needed because the pending change is shared JavaScript only. + +**Licensed content recovery:** A read-only installed assets-MCP `fab_list_owned(query=Temperate, unrealOnly=true)` returns all **seven** owned Temperate packs, including Spruce Forest (`f8044501-17a2-498f-b198-5f1bc71ee87a`, Unreal artifact `interactd68b8eae94a6V4`). Earlier folder lookup did not inspect this installed acquisition route. Existing import/cache recovery is being checked; no asset was purchased, claimed, downloaded, imported, or substituted in these measurements. + +### Parallel qualification checkpoint (2026-10-16) + +- Candidate `1ca595062` Intel headed WebGPU startup CPU capture `artifacts/playtest/prd618-reflector-cpu-2/startup.cpuprofile` completed with scenario exit 0. Compared with the preceding `943a2458a` profile on the same adapter, sampled reflector-inclusive time fell from 2,812.069 ms (15.23%) to 13.746 ms (0.09%); combined `_getChildren`/`getChildren` self time fell from 3,177.085 ms to 619.663 ms. Profiled reveal timestamps were 13,709.5 ms before and 10,757.6 ms after. Profiling perturbs timing: this does not satisfy AC3's unprofiled three-run comparison or any visual criterion. +- Fresh native candidate execution failed the finite-camera readiness assertion: `projectionMatrix[0]=NaN`, finite position/rotation/near/far, and `aspect=NaN`. Rejecting invalid viewport extents did not fix this; that attempted change was removed. Three's TRAA also writes camera aspect through `PerspectiveCamera.setViewOffset`, so the earlier assumption that `Viewport.resize` was the sole writer was false. Native AC4 remains open while the actual invalid writer is observed. +- The already-cached owned Spruce Forest source converted successfully despite the MCP observer timing out: the completed ignored `local-assets/temperate/spruce-forest/import-report.json` reports eight models exported and zero failed. Material fidelity is degraded and requires actual visual validation. Independent review of the remaining six-pack import plan confirms all 59 model filters and 18 explicit standalone texture filters, including unconditional RockFace003 textures. The user explicitly accepted the Fab EULA for the six owned packs; the Grass Library import has started through the existing Fab tool. No appearance or far-impostor acceptance is claimed from conversion alone. + +- Subsequent native camera-write probe built successfully but failed before capturing invalid view-offset arguments: the observed console contained native frame-op replay reporting unfinished GPU objects, then `createView` on undefined and a null native bind-group handle. Startup stayed collapsing for 180 seconds. The temporary viewport instrumentation was removed; built probe artifacts remain diagnostic only. The retained WorldCells finite-camera error now identifies the invalid matrix element, pose, projection settings and view offset without relaxing validation. All 19 admission tests, root typecheck and focused Biome check pass. +- The six-run unprofiled startup qualification produced **zero captures**: existing CI containers held the laptop quiet lock, so only this lane's queued wrapper was cancelled. No timing medians or ratio are claimed, and no other job was stopped. Grass Library (11 models, 3 textures), Ground Foliage (8 models, 3 textures), and Meadow Flowers (8 models, 3 textures) imports completed with zero failures; their conversion reports mark materials degraded, so appearance acceptance remains open. + +### Native discard compatibility repair and visible PR evidence + +The native replay rejected finished-but-unsubmitted command buffers as unfinished GPU objects. Three’s transient-depth abort finishes and discards failed work without submitting it. Replay now retains rejection of open encoders/passes/owners, accepts closed discarded work, and releases those remaining command buffers before clearing the packet. The native frame-op contract executable passes (1/1 via CTest), recorder/native-profiling checks pass (29/29), and the freshly rebuilt native forest reveal scenario passes (8/8 assertions, zero diagnostics). The earlier camera-aspect failure’s exact writer is not established; no independent camera fix is claimed. + +[Current labelled diagnostic images on PR #510](https://github.com/ThreeNativeHQ/threenative-engine/pull/510#issuecomment-6208456415) show reveal and +3 seconds for source 943a2458a versus 1ca595062. A fresh read-only visual judge returned NEUTRAL at each matched timepoint and overall. The CPU-profiled fallback captures preserve appearance in this sample, but are only one run per arm and do not satisfy AC5/7’s required IMPROVEMENT or the repeated final-source visual gate. Image-only evidence is on the existing orphan screenshot branch; no product source was pushed with that upload. diff --git a/docs/verification/native-coverage-2026-08-28.md b/docs/verification/native-coverage-2026-08-28.md index dc90f94cb5..655ec7c61b 100644 --- a/docs/verification/native-coverage-2026-08-28.md +++ b/docs/verification/native-coverage-2026-08-28.md @@ -21,12 +21,12 @@ targets could not be built and are named below. | `src/storage/` | 327 | 286 | 87.46% | | `src/utils/` | 0 | 0 | 0.00% | | `src/vfs/` | 239 | 195 | 81.59% | -| `src/webgpu/` | 9885 | 7581 | 76.69% | +| `src/webgpu/` | 9888 | 7592 | 76.78% | | `src/webtransport/` | 1391 | 1078 | 77.50% | | `src/workers/` | 615 | 527 | 85.69% | -| **TOTAL** | **24858** | **19489** | **78.40%** | +| **TOTAL** | **24861** | **19500** | **78.44%** | -Source digest: `sha256:1a8ed34cf063a9530e8fa11b2722ae0b4c475402ee1baef998b5dea0acd3a4d7` +Source digest: `sha256:b71a3030b463e0e2288fa81b1e556ab3fce303d3268ea3fc1786bdb9b8b316eb` 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/examples/strata-terrain-preview/.gitignore b/examples/strata-terrain-preview/.gitignore index a2fa9ba83b..b1c6dd3336 100644 --- a/examples/strata-terrain-preview/.gitignore +++ b/examples/strata-terrain-preview/.gitignore @@ -2,3 +2,4 @@ /dist/ /src/world/.bake-stamp /assets/ +/assets.manifest.json diff --git a/examples/strata-terrain-preview/__tests__/groundSampler.spec.ts b/examples/strata-terrain-preview/__tests__/groundSampler.spec.ts new file mode 100644 index 0000000000..67ac07b137 --- /dev/null +++ b/examples/strata-terrain-preview/__tests__/groundSampler.spec.ts @@ -0,0 +1,153 @@ +import { Heightfield } from "@threenative/core/world"; +import { Mesh, Raycaster, Vector3 } from "three"; +import { describe, expect, it } from "vitest"; +import { createGroundSampler } from "../src/render/groundSampler.js"; + +/** + * The drawn mesh is the reference: every case compares the sampler with a stock Three `Raycaster` + * on the same geometry, never with a mirrored copy of the sampler's own formula. + */ +function sampleCase(heights: number[], size: number, rows: number, columns: number) { + const field = new Heightfield({ + rows, + columns, + width: size, + depth: size, + origin: { x: 0, z: 0 }, + heights: Float32Array.from(heights), + }); + const mesh = new Mesh(field.toGeometry()); + mesh.updateMatrixWorld(true); + const sampler = createGroundSampler(field, mesh.geometry, mesh.matrixWorld); + const ray = (x: number, z: number): number | null => { + const hit = new Raycaster(new Vector3(x, 1000, z), new Vector3(0, -1, 0)).intersectObject( + mesh, + false, + )[0]; + return hit?.point.y ?? null; + }; + return { field, mesh, sampler, ray }; +} + +const sobel = (col: number, row: number): number => ((col * 7 + row * 13) % 9) - 4; + +describe("exact ground sampler", () => { + it("uses the drawn triangle planes, not the bilinear height, on a saddle", () => { + // Row-major [UL, UR, LL, LR]: opposite corners up, adjacent corners down. + const { field, sampler, ray } = sampleCase([0, 1, 1, 0], 2, 2, 2); + for (const [x, z] of [ + [0.1, 0.1], + [-0.1, -0.1], + [0.25, 0.25], + [-0.25, -0.25], + ]) { + const expected = ray(x as number, z as number) as number; + expect(Number.isFinite(expected)).toBe(true); + const sampled = sampler(x as number, z as number) as number; + expect(sampled).toBeCloseTo(expected, 6); + // Bilinear floats well above the drawn triangle away from the shared edge. + expect(Math.abs(sampled - field.heightAt(x as number, z as number))).toBeGreaterThan(0.2); + } + // Points on both diagonals and the shared edge agree with the raycaster. + for (const [x, z] of [ + [0, 0], + [0.5, 0.5], + [-0.5, -0.5], + [0.6, 0.2], + [-0.6, -0.2], + ]) { + const expected = ray(x as number, z as number) as number; + expect(Number.isFinite(expected)).toBe(true); + expect(sampler(x as number, z as number)).toBeCloseTo(expected, 6); + } + }); + + it("matches the raycaster on cell boundaries and interior samples of an irregular grid", () => { + const rows = 5; + const columns = 5; + const heights: number[] = []; + for (let row = 0; row < rows; row += 1) + for (let column = 0; column < columns; column += 1) heights.push(sobel(column, row)); + const { sampler, ray } = sampleCase(heights, 4, rows, columns); + const points: [number, number][] = [ + [-2, -2], + [0, 0], + [2, 2], + [-1, 0], + [0, -1], + [1, 0], + [0, 1], + [-1.5, 0.5], + [0.5, 1.5], + [1.25, -0.75], + [0.3, 0.3], + [-0.2, 1.1], + ]; + for (const [x, z] of points) { + const expected = ray(x, z) as number; + expect(Number.isFinite(expected)).toBe(true); + expect(sampler(x, z)).toBeCloseTo(expected, 6); + } + }); + + it("matches Float32 vertices, diagonals, and the far bound on a non-binary grid", () => { + const rows = 17; + const columns = 17; + const heights: number[] = []; + for (let row = 0; row < rows; row += 1) + for (let column = 0; column < columns; column += 1) heights.push(sobel(column, row)); + const { mesh, sampler, ray } = sampleCase(heights, 10.3, rows, columns); + const position = mesh.geometry.getAttribute("position"); + const points: [number, number][] = []; + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const vertex = row * columns + column; + points.push([position.getX(vertex), position.getZ(vertex)]); + if (row === rows - 1 || column === columns - 1) continue; + const lowerRight = vertex + columns + 1; + points.push([ + (position.getX(vertex) + position.getX(lowerRight)) / 2, + (position.getZ(vertex) + position.getZ(lowerRight)) / 2, + ]); + } + } + for (const [x, z] of points) { + const expected = ray(x, z); + expect(expected).not.toBeNull(); + expect(sampler(x, z)).not.toBeNull(); + expect(sampler(x, z) as number).toBeCloseTo(expected as number, 6); + } + }); + + it("follows a translated mesh and matches the raycaster there", () => { + const { field, mesh } = sampleCase([0, 1, 1, 0], 2, 2, 2); + mesh.position.set(12, 3, -7); + mesh.updateMatrixWorld(true); + const moved = createGroundSampler(field, mesh.geometry, mesh.matrixWorld); + for (const [lx, lz] of [ + [-0.25, -0.25], + [0.25, 0.25], + [0, 0], + [0.5, -0.5], + ]) { + const hit = new Raycaster( + new Vector3(12 + (lx as number), 1000, -7 + (lz as number)), + new Vector3(0, -1, 0), + ).intersectObject(mesh, false)[0]; + expect(moved(12 + (lx as number), -7 + (lz as number))).toBeCloseTo( + hit?.point.y as number, + 6, + ); + } + }); + + it("returns null outside the grid and for a rotated topology, so the ray fallback runs", () => { + const { field, mesh, sampler } = sampleCase([0, 1, 1, 0], 2, 2, 2); + expect(sampler(2.5, 0)).toBeNull(); + expect(sampler(0, -2.5)).toBeNull(); + mesh.rotation.y = Math.PI / 2; + mesh.updateMatrixWorld(true); + const rotated = createGroundSampler(field, mesh.geometry, mesh.matrixWorld); + expect(rotated(0, 0)).toBeNull(); + }); +}); diff --git a/examples/strata-terrain-preview/__tests__/temporalAA.spec.ts b/examples/strata-terrain-preview/__tests__/temporalAA.spec.ts new file mode 100644 index 0000000000..a0f2716baf --- /dev/null +++ b/examples/strata-terrain-preview/__tests__/temporalAA.spec.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { DataTexture, FloatType, PerspectiveCamera, RedFormat } from "three"; +import TRAANode, { traa } from "three/addons/tsl/display/TRAANode.js"; +import type RTTNode from "three/src/nodes/utils/RTTNode.js"; +import { texture, vec4 } from "three/tsl"; +import type { TextureNode } from "three/webgpu"; +import { describe, expect, it } from "vitest"; +import { createOutdoorTRAA } from "../src/render/sky.js"; + +describe("Strata temporal AA input", () => { + it("primes the actual beauty RTT and leaves automatic resizing enabled", () => { + const camera = new PerspectiveCamera(); + const depthVelocity = texture( + new DataTexture(new Float32Array([1]), 1, 1, RedFormat, FloatType), + ); + const input = vec4(1).mul(0.5); + const unprimed = traa(input, depthVelocity, depthVelocity, camera); + assert(unprimed instanceof TRAANode); + const unprimedBeauty = unprimed.beautyNode; + assert(isRTTNode(unprimedBeauty)); + assert(unprimedBeauty.renderTarget); + expect(unprimedBeauty.width).toBeNull(); + expect(unprimedBeauty.height).toBeNull(); + expect(unprimedBeauty.renderTarget?.width).toBeNull(); + expect(unprimedBeauty.renderTarget?.height).toBeNull(); + + const renderer = { + surface: () => ({ + resolutionScale: 1, + scaleSource: "pinned" as const, + sampleCount: 1, + drawingBufferWidth: 640, + drawingBufferHeight: 360, + atFloor: false, + }), + }; + const node = createOutdoorTRAA(input, depthVelocity, depthVelocity, camera, renderer); + assert(node instanceof TRAANode); + const beauty = node.beautyNode; + assert(isRTTNode(beauty)); + assert(beauty.renderTarget); + const target = beauty.renderTarget; + expect(target.texture.width).toBe(640); + expect(target.texture.height).toBe(360); + expect(Number.isFinite(target.texture.width)).toBe(true); + expect(Number.isFinite(target.texture.height)).toBe(true); + expect(beauty.autoResize).toBe(true); + + beauty.setSize(320, 180); + expect([target.width, target.height]).toEqual([320, 180]); + expect(beauty.autoResize).toBe(true); + }); +}); + +function isRTTNode(node: TextureNode): node is RTTNode { + return "isRTTNode" in node && Reflect.get(node, "isRTTNode") === true; +} diff --git a/examples/strata-terrain-preview/package.json b/examples/strata-terrain-preview/package.json index a037ad09fa..2968c58a53 100644 --- a/examples/strata-terrain-preview/package.json +++ b/examples/strata-terrain-preview/package.json @@ -9,8 +9,9 @@ "build": "pnpm bake && vite build", "build:desktop": "pnpm bake && node ../../packages/runtime-native/scripts/bundle.mjs --project . --entry src/game.ts --target desktop --output dist/strata-terrain-native.js", "typecheck": "pnpm bake && tsc --noEmit -p tsconfig.json", + "test": "vitest run", "test:terrain:web": "node ../../packages/playtest/dist/runner/cli.js playtests/terrain.playtest.json --url 'http://127.0.0.1:5196/?showcase=1' --server-command 'pnpm dev --host 127.0.0.1 --port 5196 --strictPort' --browser-recipe webgpu --headed --artifacts artifacts/playtest/web && node scripts/verify-ocean.mjs artifacts/playtest/web", - "test:terrain:desktop": "node scripts/stage-native-assets.mjs && pnpm build:desktop && node ../../packages/playtest/dist/runner/cli.js playtests/terrain.playtest.json --target desktop --executable ../../packages/runtime-native/build/tn-linux/mystral --host-arg run --host-arg dist/strata-terrain-native.js --artifacts artifacts/playtest/desktop && node scripts/verify-ocean.mjs artifacts/playtest/desktop && sh ../../scripts/xvfb.sh ../../packages/runtime-native/build/tn-linux/mystral run dist/strata-terrain-native.js --headless --frames 300 --screenshot artifacts/playtest/desktop/terrain-300frames.png", + "test:terrain:desktop": "node scripts/stage-native-assets.mjs && pnpm build:desktop && node ../../packages/playtest/dist/runner/cli.js ../playtests/terrain.playtest.json --project assets --target desktop --executable ../../../packages/runtime-native/build/tn-linux/mystral --host-arg run --host-arg ../dist/strata-terrain-native.js --artifacts ../artifacts/playtest/desktop && node scripts/verify-ocean.mjs artifacts/playtest/desktop && (cd assets && sh ../../../scripts/xvfb.sh ../../../packages/runtime-native/build/tn-linux/mystral run ../dist/strata-terrain-native.js --headless --frames 300 --screenshot ../artifacts/playtest/desktop/terrain-300frames.png)", "test:terrain:editor": "node scripts/verify-editor.mjs", "test:consumer": "node scripts/verify-consumer.mjs", "test:kit": "node scripts/verify-starter-kit.mjs", diff --git a/examples/strata-terrain-preview/playtests/reveal.playtest.json b/examples/strata-terrain-preview/playtests/reveal.playtest.json new file mode 100644 index 0000000000..1b20d01717 --- /dev/null +++ b/examples/strata-terrain-preview/playtests/reveal.playtest.json @@ -0,0 +1,99 @@ +{ + "schemaVersion": 1, + "name": "strata-terrain-reveal", + "viewport": { + "width": 1280, + "height": 720 + }, + "warmupFrames": 0, + "artifacts": { + "screenshots": true, + "console": true + }, + "steps": [ + { + "label": "world-ready", + "waitForResource": { + "id": "GameState", + "path": "timeToReadyMs", + "gte": 1 + }, + "timeoutMs": 120000, + "release": true + }, + { + "kind": "wait", + "label": "reveal", + "waitTicks": 1, + "release": true, + "screenshot": "reveal" + }, + { + "label": "reveal-plus-3s", + "timeoutMs": 30000, + "screenshot": "reveal-plus-3s", + "release": true, + "waitForResource": { + "id": "GameState", + "path": "revealComplete", + "equals": true + } + } + ], + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "resources": [ + { + "id": "GameState", + "path": "timeToReadyMs", + "gte": 1, + "allowTrivial": "Native attaches after readiness; this recorded startup duration is already positive when reveal sampling begins." + } + ], + "components": [ + { + "entity": "props", + "component": "reveal", + "path": "complete", + "equals": true + }, + { + "entity": "props", + "component": "reveal", + "path": "samples", + "gte": 2 + }, + { + "entity": "props", + "component": "reveal", + "path": "elapsedMs", + "gte": 3000 + }, + { + "entity": "props", + "component": "reveal", + "path": "publishedBatchesInView", + "equals": 0, + "allowTrivial": "Held invariant: no visible prop batches may publish throughout the measured post-reveal window." + }, + { + "entity": "props", + "component": "reveal", + "path": "shadowInvalidated", + "equals": 0, + "allowTrivial": "Held invariant: shadows must remain valid throughout the measured post-reveal window." + }, + { + "entity": "props", + "component": "reveal", + "path": "impostorPendingPeak", + "equals": 0, + "allowTrivial": "Held invariant: no impostor bake may remain pending throughout the measured post-reveal window." + } + ] + } +} diff --git a/examples/strata-terrain-preview/scripts/prep-fab-temperate.mjs b/examples/strata-terrain-preview/scripts/prep-fab-temperate.mjs index 71c6d3955c..bc1d22b2d7 100644 --- a/examples/strata-terrain-preview/scripts/prep-fab-temperate.mjs +++ b/examples/strata-terrain-preview/scripts/prep-fab-temperate.mjs @@ -37,17 +37,11 @@ const STAGE = join( ); const requireAssets = createRequire(join(REPO, "packages/assets/package.json")); const sharp = requireAssets("sharp"); -const { MeshoptSimplifier } = requireAssets("meshoptimizer"); -await MeshoptSimplifier.ready; const { NodeIO } = requireAssets("@gltf-transform/core"); -const { - dedup, - join: joinMeshes, - weld, - simplify, - normals, -} = requireAssets("@gltf-transform/functions"); -const { compileAssets } = await import(pathToFileURL(join(REPO, "packages/assets/dist/index.js"))); +const { dedup, join: joinMeshes, weld, normals } = requireAssets("@gltf-transform/functions"); +const { compileAssets, thinFoliageCards } = await import( + pathToFileURL(join(REPO, "packages/assets/dist/index.js")) +); const io = new NodeIO(); const set = (albedo, normal, mask = null, channel = 0) => ({ albedo, normal, mask, channel }); const atlases = { @@ -349,7 +343,7 @@ for (const { pack, name, logical } of selected) { console.log(`[prep] ${logical}: ${name}`); if (logical.startsWith("spruce/")) { const low = await io.read(target); - await low.transform(simplify({ simplifier: MeshoptSimplifier, ratio: 0.14, error: 0.012 })); + if (thinFoliageCards(low, 0.14) === 0) throw new Error(`No spruce cards thinned: ${logical}`); await io.write(join(STAGE, `${logical}-far.glb`), low); } } diff --git a/examples/strata-terrain-preview/scripts/stage-native-assets.mjs b/examples/strata-terrain-preview/scripts/stage-native-assets.mjs index 1283fdec0f..bcfd95b4f1 100644 --- a/examples/strata-terrain-preview/scripts/stage-native-assets.mjs +++ b/examples/strata-terrain-preview/scripts/stage-native-assets.mjs @@ -1,29 +1,33 @@ -// The native host resolves a texture at `` or `assets/` beside the game; the web build -// gets the ground's CC0 starter maps from Vite's publicDir. Mirror that root into `assets/` with hard -// links so a desktop run reads the very bytes the browser does. The licensed `local-assets/` mounts -// are not staged: their cooked KTX2 models load untextured on the native host today, which is worse -// than the procedural fallback they leave in place, and the ground already falls back from -// `temperate/` to these public maps by name. import { copyFileSync, existsSync, linkSync, mkdirSync, + readFileSync, readdirSync, statSync, unlinkSync, + writeFileSync, } from "node:fs"; -import { dirname, join, relative } from "node:path"; +import { dirname, join, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(dirname(fileURLToPath(import.meta.url))); const out = join(here, "assets"); -// vite.config.ts publicDir, served at the root. +// Match Vite publicDir and preparedAssets mounts, including first-root-wins /basis. const roots = [ { dir: join(here, "../../packages/terrain/starter-assets"), mount: "", required: true }, + ...[ + ["temperate", "temperate"], + ["temperate/basis", "basis"], + ["prepared", "prepared"], + ["landscape-pro", "landscape-pro"], + ["landscape-pro/basis", "basis"], + ].map(([dir, mount]) => ({ dir: join(here, "local-assets", dir), mount })), ]; let linked = 0; +const entries = {}; function mirror(source, target) { for (const entry of readdirSync(source, { withFileTypes: true })) { const from = join(source, entry.name); @@ -34,10 +38,11 @@ function mirror(source, target) { continue; } if (!entry.isFile()) continue; - if (existsSync(to)) { - if (statSync(to).ino === statSync(from).ino) continue; - unlinkSync(to); - } + const logical = relative(out, to).split(sep).join("/"); + if (entries[logical]) continue; + entries[logical] = { output: logical, bytes: statSync(from).size }; + if (existsSync(to) && statSync(to).ino === statSync(from).ino) continue; + if (existsSync(to)) unlinkSync(to); try { linkSync(from, to); } catch (error) { @@ -59,4 +64,39 @@ for (const root of roots) { mkdirSync(target, { recursive: true }); mirror(root.dir, target); } +if (Object.keys(entries).length === 0) throw new Error("stage-native-assets: no staged assets"); +// A nonempty manifest skips bare-path probes and rejects absent optional assets before fetching. +writeFileSync( + join(out, "assets.manifest.json"), + `${JSON.stringify({ version: 1, entries }, null, 2)}\n`, +); +// Remove only this script's old starter-only manifest, never a user-authored manifest. +const legacy = join(here, "assets.manifest.json"); +if (existsSync(legacy)) { + const old = JSON.parse(readFileSync(legacy, "utf8")); + const starter = Object.fromEntries( + Object.entries(entries) + .filter(([key]) => existsSync(join(roots[0].dir, key))) + .map(([key, entry]) => [key, { ...entry, output: `assets/${key}` }]), + ); + // Generated identity survives asset revisions; byte counts do not. + if ( + old?.version === 1 && + Object.keys(old).length === 2 && + old.entries && + !Array.isArray(old.entries) && + Object.keys(old.entries).length === Object.keys(starter).length && + Object.entries(starter).every(([key, entry]) => { + const previous = old.entries[key]; + return ( + previous && + Object.keys(previous).length === 2 && + previous.output === entry.output && + Number.isSafeInteger(previous.bytes) && + previous.bytes >= 0 + ); + }) + ) + unlinkSync(legacy); +} console.log(`stage-native-assets: ${linked} file(s) linked into ${relative(here, out)}/`); diff --git a/examples/strata-terrain-preview/scripts/verify-custom-assets.mjs b/examples/strata-terrain-preview/scripts/verify-custom-assets.mjs index d51f730f13..8d9acfced8 100644 --- a/examples/strata-terrain-preview/scripts/verify-custom-assets.mjs +++ b/examples/strata-terrain-preview/scripts/verify-custom-assets.mjs @@ -27,7 +27,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { deflateSync } from "node:zlib"; +import { deflateSync, inflateSync } from "node:zlib"; import { createServer } from "vite"; import { advanceFixedStep, @@ -39,6 +39,43 @@ import { const root = resolve("."); const PORT = 5185; +// The seeded generator writes RGBA/filter-0 PNGs; whole-cell UVs need transparent borders. +const atlas = readFileSync( + new URL("../../../packages/terrain/starter-assets/needle-atlas.png", import.meta.url), +); +assert.equal(atlas.readUInt32BE(16), 1024); +assert.equal(atlas.readUInt32BE(20), 1024); +const compressed = []; +for (let offset = 8; offset < atlas.length; ) { + const length = atlas.readUInt32BE(offset); + if (atlas.toString("ascii", offset + 4, offset + 8) === "IDAT") + compressed.push(atlas.subarray(offset + 8, offset + 8 + length)); + offset += length + 12; +} +const pixels = inflateSync(Buffer.concat(compressed)); +assert.equal(pixels.length, (1024 * 4 + 1) * 1024); +for (let y = 0; y < 1024; y += 1) assert.equal(pixels[y * 4097], 0, "atlas PNG filter"); +for (const [name, column, row] of [ + ["dense", 0, 0], + ["open", 1, 0], + ["tip", 0, 1], +]) { + let covered = 0; + for (let y = 0; y < 512; y += 1) { + for (let x = 0; x < 512; x += 1) { + const alpha = pixels[(row * 512 + y) * 4097 + 1 + (column * 512 + x) * 4 + 3]; + if (x === 0 || x === 511 || y === 0 || y === 511) + assert.equal(alpha, 0, `${name}: clipped needle at cell boundary (${x}, ${y})`); + if (alpha > 132) covered += 1; + } + } + assert(covered > 512, `${name}: empty needle silhouette`); +} +if (process.argv.includes("--needle-only")) { + console.log("Needle atlas: three nonempty silhouettes with transparent whole-cell borders"); + process.exit(0); +} + /** Every path the stock mapping names. A request for any of them is a starter request. */ const STARTER_PREFIXES = [ "bark_brown_02/", diff --git a/examples/strata-terrain-preview/scripts/verify-native-staging.mjs b/examples/strata-terrain-preview/scripts/verify-native-staging.mjs new file mode 100644 index 0000000000..5e639b329b --- /dev/null +++ b/examples/strata-terrain-preview/scripts/verify-native-staging.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createAssetLoader } from "@threenative/core"; +import { createServer } from "vite"; + +const example = dirname(dirname(fileURLToPath(import.meta.url))); +const temp = mkdtempSync(join(tmpdir(), "strata-stage-")); +const game = join(temp, "examples/game"); +const originalFetch = globalThis.fetch; +let server; +try { + for (const dir of ["scripts", "terrain", "local-assets/temperate", "local-assets/prepared"]) + mkdirSync(join(game, dir), { recursive: true }); + writeFileSync(join(game, "package.json"), JSON.stringify({ type: "module" })); + copyFileSync(join(example, "terrain/world.json"), join(game, "terrain/world.json")); + const starter = join(temp, "packages/terrain/starter-assets"); + mkdirSync(starter, { recursive: true }); + writeFileSync(join(starter, "starter.bin"), "starter"); + for (const mount of ["temperate", "prepared"]) + writeFileSync(join(game, `local-assets/${mount}/licensed.bin`), mount); + copyFileSync( + join(example, "scripts/stage-native-assets.mjs"), + join(game, "scripts/stage-native-assets.mjs"), + ); + symlinkSync(join(example, "node_modules"), join(game, "node_modules"), "dir"); + copyFileSync(join(example, "vite.config.ts"), join(game, "vite.config.ts")); + // Legacy byte counts belong to a previous asset revision. + writeFileSync( + join(game, "assets.manifest.json"), + JSON.stringify({ + version: 1, + entries: { + "starter.bin": { output: "assets/starter.bin", bytes: 13 }, + }, + }), + ); + execFileSync(process.execPath, [join(game, "scripts/stage-native-assets.mjs")]); + assert.equal(existsSync(join(game, "assets.manifest.json")), false); + execFileSync(process.execPath, [join(game, "scripts/stage-native-assets.mjs")]); + assert.equal(existsSync(join(game, "assets.manifest.json")), false); + const authored = JSON.stringify({ + version: 1, + entries: { "starter.bin": { output: "custom/starter.bin", bytes: 13 } }, + }); + writeFileSync(join(game, "assets.manifest.json"), authored); + execFileSync(process.execPath, [join(game, "scripts/stage-native-assets.mjs")]); + assert.equal(readFileSync(join(game, "assets.manifest.json"), "utf8"), authored); + rmSync(join(game, "assets.manifest.json")); + server = await createServer({ + configFile: join(game, "vite.config.ts"), + root: game, + server: { port: 0 }, + optimizeDeps: { noDiscovery: true }, + }); + await server.listen(); + const origin = server.resolvedUrls.local[0]; + globalThis.fetch = (url, options) => originalFetch(new URL(url, origin), options); + const web = createAssetLoader(); + for (const mount of ["temperate", "prepared"]) { + const [url] = await web.resolve(`${mount}/licensed.bin`); + assert.equal(await (await fetch(url)).text(), mount); + } + const requests = []; + globalThis.fetch = async (url) => { + requests.push(url); + return new Response(readFileSync(join(game, "assets", url)), { + headers: { "content-type": "application/json" }, + }); + }; + const native = createAssetLoader(); + for (const mount of ["temperate", "prepared"]) { + const [url] = await native.resolve(`${mount}/licensed.bin`); + assert.equal(await (await fetch(url)).text(), mount); + } + const before = requests.length; + await assert.rejects(native.resolve("temperate/absent.glb"), /not listed/); + assert.equal(requests.length, before, "missing optional art must fail before IO"); + assert.equal(requests.filter((url) => url === "assets.manifest.json").length, 1); + rmSync(join(game, "local-assets"), { recursive: true }); + execFileSync(process.execPath, [join(game, "scripts/stage-native-assets.mjs")]); + await assert.rejects(createAssetLoader().resolve("temperate/licensed.bin"), /not listed/); + console.log( + "PASS: stage then web/native preserve licensed bytes; absent optional art rejects without IO", + ); +} finally { + globalThis.fetch = originalFetch; + await server?.close(); + rmSync(temp, { recursive: true, force: true }); +} diff --git a/examples/strata-terrain-preview/src/game.ts b/examples/strata-terrain-preview/src/game.ts index 9c1ec7ba13..22366b8419 100644 --- a/examples/strata-terrain-preview/src/game.ts +++ b/examples/strata-terrain-preview/src/game.ts @@ -10,6 +10,8 @@ import { } from "@threenative/physics"; import { CapsuleGeometry, + Frustum, + Matrix4, Mesh, MeshStandardMaterial, OrthographicCamera, @@ -17,6 +19,7 @@ import { Vector3, } from "three"; import { BIOMES, type WorldName } from "./render/biomes.js"; +import { createGroundSampler } from "./render/groundSampler.js"; import { createLoadingScreen, createSpawnReadiness, @@ -196,6 +199,7 @@ const initialState = { canopyComparison: { normals: "authored", specular: "disabled" } as ICanopyComparison, showcase: false, worldReady: false, + revealComplete: false, loadingError: "", spawnCellsRequired: 0, spawnCellsLoaded: 0, @@ -302,8 +306,74 @@ function terrainScene(world: WorldName): new () => Scene= 0) return; + if (ctx.canvasLayer.opaque || ctx.startup.phase !== "ready" || !props) + throw new Error("Reveal probe requires ready props and an uncovered world"); + const shadows = sky.sun.shadow.shadowNode; + if (!(shadows instanceof VirtualShadowNode)) + throw new Error("Reveal probe requires virtual shadows"); + const creations = ctx.renderer.pipelineCensus?.()?.counts.deviceCreations; + reveal.startedMs = performance.now(); + reveal.publishedBatchesInView = 0; + shadowAtReveal = shadows.stats.byInvalidation; + pipelinesAtReveal = creations; + ctx.camera.updateMatrixWorld(true); + revealFrustum.setFromProjectionMatrix( + revealMatrix.multiplyMatrices(ctx.camera.projectionMatrix, ctx.camera.matrixWorldInverse), + ctx.camera.coordinateSystem, + ); + } + ctx.afterPhysics(() => { + if ( + released || + reveal.complete || + reveal.startedMs < 0 || + ctx.canvasLayer.opaque || + ctx.startup.phase !== "ready" || + !props + ) + return; + const shadows = sky.sun.shadow.shadowNode; + if (!(shadows instanceof VirtualShadowNode)) + throw new Error("Reveal probe requires virtual shadows"); + const census = ctx.renderer.pipelineCensus?.(); + const creations = census?.counts.deviceCreations; + reveal.samples++; + reveal.elapsedMs = performance.now() - reveal.startedMs; + reveal.shadowInvalidated = shadows.stats.byInvalidation - shadowAtReveal; + reveal.impostorPendingPeak = Math.max( + reveal.impostorPendingPeak, + props.worlds.reduce((sum, stream) => sum + stream.stats().impostor.pending, 0), + ); + reveal.pipelineCensusAvailable = + census?.complete === true && creations !== undefined && pipelinesAtReveal !== undefined; + if ( + reveal.pipelineCensusAvailable && + creations !== undefined && + pipelinesAtReveal !== undefined + ) + reveal.pipelinesCreated = creations - pipelinesAtReveal; + reveal.complete = reveal.elapsedMs >= 3000; + if (reveal.complete) ctx.state.set({ revealComplete: true }); + }); const textureUploads = new AbortController(); let propsStage = "waiting-for-physics"; let propsSettled = false; @@ -323,7 +393,7 @@ function terrainScene(world: WorldName): new () => Scene Scene { - const region = props?.readinessAt(spawn); + const region = + !admission.ready || ctx.startup.phase !== "ready" + ? props?.readinessAt(ctx.camera) + : undefined; admission.observe(region?.ready === true, region?.failures ?? 0); - return admission.ready || admission.error !== ""; + return (region?.ready === true && admission.ready) || admission.error !== ""; }, 120_000, () => @@ -352,6 +425,7 @@ function terrainScene(world: WorldName): new () => Scene Scene { const [x, , z] = at; - const visual = ctx.raycast({ - direction: new Vector3(0, -1, 0), - origin: new Vector3(x, top + 2, z), - targets: [mesh], - }); + let visual = groundSample(x, z); + if (visual === null) { + visual = + ctx.raycast({ + direction: new Vector3(0, -1, 0), + origin: new Vector3(x, top + 2, z), + targets: [mesh], + })?.point.y ?? null; + } const physical = ctx.physics.directSpaceState.intersectRay({ collisionMask: 4, from: { x, y: top + 2, z }, to: { x, y: -100, z }, }); - let height = visual?.point.y ?? physical?.position.y ?? null; + let height = visual ?? physical?.position.y ?? null; if ( height !== null && (world === "forest" || world === "coastal") && @@ -662,12 +743,17 @@ function terrainScene(world: WorldName): new () => Scene Scene { + if (reveal.startedMs < 0 || reveal.complete) return; + if (revealFrustum.intersectsBox(bounds)) reveal.publishedBatchesInView++; + }, invalidateShadows: (region) => { const shadows = sky.sun.shadow.shadowNode; if (shadows instanceof VirtualShadowNode) invalidatePropShadows(shadows, region); @@ -778,6 +868,7 @@ function terrainScene(world: WorldName): new () => Scene ({ + reveal, streaming: props?.stats(), boulders: scatter.counts.boulder, bushes: scatter.counts.bush, @@ -961,7 +1052,10 @@ function terrainScene(world: WorldName): new () => Scene number | null; + +/** + * Builds the triangle-plane contact height of a `Heightfield.toGeometry()` mesh under a world point. + * + * It reads the drawn float32 positions and the same two-triangle split `toGeometry` emits, so a prop + * rests on the triangle the eye sees instead of the bilinear height. `null` means outside the grid or + * a transform this sampler does not model, and the caller keeps its raycast fallback for that. + */ +export function createGroundSampler( + field: Heightfield, + geometry: BufferGeometry, + matrixWorld: Matrix4, +): GroundSampler { + const translation = new Vector3(); + const rotation = new Quaternion(); + const scale = new Vector3(); + matrixWorld.decompose(translation, rotation, scale); + const upright = + Math.abs(rotation.x) < 1e-6 && + Math.abs(rotation.y) < 1e-6 && + Math.abs(rotation.z) < 1e-6 && + Math.abs(rotation.w - 1) < 1e-6 && + scale.x > 0 && + scale.x === scale.y && + scale.y === scale.z; + if (!upright) return () => null; + + const position = geometry.getAttribute("position"); + const { columns, rows } = field; + const x0 = position.getX(0); + const z0 = position.getZ(0); + const cellWidth = position.getX(1) - x0; + const cellDepth = position.getZ(columns) - z0; + if (!(cellWidth > 0) || !(cellDepth > 0)) return () => null; + + const a = new Vector3(); + const b = new Vector3(); + const c = new Vector3(); + return (worldX, worldZ) => { + const x = (worldX - translation.x) / scale.x; + const z = (worldZ - translation.z) / scale.z; + const fc = (x - x0) / cellWidth; + const fr = (z - z0) / cellDepth; + if (!(fc >= 0 && fr >= 0 && fc <= columns - 1 && fr <= rows - 1)) return null; + const column = Math.min(columns - 2, Math.floor(fc)); + const row = Math.min(rows - 2, Math.floor(fr)); + const upperLeft = row * columns + column; + const upperRight = upperLeft + 1; + const lowerLeft = upperLeft + columns; + const lowerRight = lowerLeft + 1; + if (fc - column + (fr - row) <= 1) { + a.fromBufferAttribute(position, upperLeft); + b.fromBufferAttribute(position, lowerLeft); + c.fromBufferAttribute(position, upperRight); + } else { + a.fromBufferAttribute(position, upperRight); + b.fromBufferAttribute(position, lowerLeft); + c.fromBufferAttribute(position, lowerRight); + } + const denominator = (b.z - c.z) * (a.x - c.x) + (c.x - b.x) * (a.z - c.z); + if (denominator === 0) return null; + const wa = ((b.z - c.z) * (x - c.x) + (c.x - b.x) * (z - c.z)) / denominator; + const wb = ((c.z - a.z) * (x - c.x) + (a.x - c.x) * (z - c.z)) / denominator; + return (wa * a.y + wb * b.y + (1 - wa - wb) * c.y) * scale.y + translation.y; + }; +} diff --git a/examples/strata-terrain-preview/src/render/loading.ts b/examples/strata-terrain-preview/src/render/loading.ts index cd702ff25e..f9985c15c2 100644 --- a/examples/strata-terrain-preview/src/render/loading.ts +++ b/examples/strata-terrain-preview/src/render/loading.ts @@ -205,6 +205,7 @@ export const loading = { /* END THREENATIVE LOADING APPEARANCE */ interface ILoadingHost { + onReveal?(): void; readonly assets?: { texture(path: string): Promise }; readonly camera: Camera; readonly canvasLayer: { @@ -510,18 +511,8 @@ export function createLoadingScreen(host: ILoadingHost): ILoadingController { return; } if (done) return; - // Prewarm the world's shaders, but do not wait for them here. `whenReady()` already means the - // world is safe to show, and compiling a lit, post-processed scene takes far longer than the - // launch window: hold the screen for it and a playtest — which advances a fixed step as fast - // as the machine allows — runs an entire scenario behind the launch screen, so every capture - // photographs the loading bar instead of the game. The prewarm still happens; it no longer - // decides when the player sees the world. - try { - void host.renderer.compileAsync(host.scene, host.camera).catch(() => undefined); - } catch { - // A renderer without compileAsync is not a reason to keep the world hidden. - } finish(); + host.onReveal?.(); })(); return { diff --git a/examples/strata-terrain-preview/src/render/pack.ts b/examples/strata-terrain-preview/src/render/pack.ts index 674b075bb1..4fe7b4e102 100644 --- a/examples/strata-terrain-preview/src/render/pack.ts +++ b/examples/strata-terrain-preview/src/render/pack.ts @@ -94,7 +94,7 @@ for (let i = 0; i < 5; i++) { path: `spruce/${i}`, metres: [12, 14, 13, 10, 11][i] ?? 12, }); - // Reduced full-tree geometry preserves the adult silhouette and its trunk at distance. + // Whole-card thinning keeps the authored trunk and caps surviving card area growth at 4x. species.push({ asset: "spruce", variant: i, @@ -301,6 +301,9 @@ function cutoutSurface( ? float(0.5) : float(world === "forest" ? source.alphaTest : 0.42).div(float(1).add(mip.mul(0.25))); material.opacityNode = sampled.a; + material.maskShadowNode = sampled.a.greaterThan(material.alphaTestNode as Node<"float">); + // Keep the authored scalar for cutout classification and impostor capture. + material.userData.tnImpostorAlphaTest = source.alphaTest; if (canopy) { lightNeedles(material, material.aoNode as Node<"float">); if (!otherBiome) material.emissiveNode = (material.emissiveNode as Node<"vec3">).mul(0.18); diff --git a/examples/strata-terrain-preview/src/render/propStreaming.ts b/examples/strata-terrain-preview/src/render/propStreaming.ts index 159f4c077a..19813d08c4 100644 --- a/examples/strata-terrain-preview/src/render/propStreaming.ts +++ b/examples/strata-terrain-preview/src/render/propStreaming.ts @@ -8,7 +8,7 @@ import { WorldCells, } from "@threenative/core/world"; import type { IPlacement } from "@threenative/terrain"; -import { Box3, Group, InstancedMesh, Mesh, Quaternion, Vector3 } from "three"; +import { Box3, type Camera, Group, InstancedMesh, Mesh, Quaternion, Vector3 } from "three"; import type { IPropPreparationProgress } from "./propProgress.js"; import { DRAW_REACH, @@ -37,6 +37,7 @@ interface IStreamOptions { readonly whileCurrent: () => boolean; readonly invalidateShadows?: (region?: IShadowRegion) => void; readonly onProgress?: (progress: IPropPreparationProgress) => void; + readonly onPublication?: (bounds: Box3) => void; } /** Keep WorldCells' changed caster bounds when forwarding to the existing shadow renderer. */ @@ -233,6 +234,9 @@ export async function createStreamedProps(options: IStreamOptions) { } definitions[record.key] = { glb: `${record.key}/0.glb`, + ...(record.key.startsWith("spruce:") && levels.length <= 2 + ? { impostorDistance: LOD_BANDS.far } + : {}), bounds: { min: bounds.min.toArray(), max: bounds.max.toArray() }, lods: levels.slice(1).map((level, i) => ({ glb: `${record.key}/${level}.glb`, @@ -313,8 +317,10 @@ export async function createStreamedProps(options: IStreamOptions) { data: { manifest, placements: placements.buffer }, placementReach, gpuScene: false, + onPublication: options.onPublication, adaptiveLod: false, preserveAuthoredParts: true, + impostors: true, lodHysteresis: LOD_BANDS.hysteresis, transparentScatter: "blend", admissionBudgetMs: 2 / Math.max(1, buckets.size), @@ -364,6 +370,10 @@ export async function createStreamedProps(options: IStreamOptions) { loadsInFlight: sum.loadsInFlight + s.loadsInFlight, loadsQueued: sum.loadsQueued + s.loadsQueued, pendingPrewarm: sum.pendingPrewarm + s.pendingPrewarm, + impostor: { + pending: sum.impostor.pending + s.impostor.pending, + drawn: sum.impostor.drawn + s.impostor.drawn, + }, admissionMs: sum.admissionMs + s.admission.spentMs, admissionBacklog: sum.admissionBacklog + s.admission.backlog, admissionDeferred: sum.admissionDeferred + s.admission.deferred, @@ -378,6 +388,7 @@ export async function createStreamedProps(options: IStreamOptions) { loadsInFlight: 0, loadsQueued: 0, pendingPrewarm: 0, + impostor: { pending: 0, drawn: 0 }, admissionMs: 0, admissionBacklog: 0, admissionDeferred: 0, @@ -389,15 +400,21 @@ export async function createStreamedProps(options: IStreamOptions) { byId, stats, compileSlices, - readinessAt(position: { readonly x: number; readonly z: number }, radius = 60) { - const regions = worlds.map((world) => world.readinessAt(position, radius)); + readinessAt(camera: Camera) { + const regions = worlds.map((world) => world.readinessAt(camera)); const required = regions.reduce((n, r) => n + r.requiredCells, 0); const loaded = regions.reduce((n, r) => n + r.loadedCells, 0); return { required, loaded, failures: regions.reduce((n, r) => n + r.failures, 0), - ready: warmed.size === worlds.length && regions.every((r) => r.ready), + ready: + warmed.size === worlds.length && + regions.every((r) => r.ready) && + worlds.every((world) => { + const state = world.stats(); + return state.impostor.pending === 0 && state.pendingPrewarm === 0; + }), }; }, get ready() { @@ -408,6 +425,7 @@ export async function createStreamedProps(options: IStreamOptions) { s.loadsQueued === 0 && s.admissionBacklog === 0 && s.pendingPrewarm === 0 && + s.impostor.pending === 0 && s.failures === 0 ); }, diff --git a/examples/strata-terrain-preview/src/render/props.ts b/examples/strata-terrain-preview/src/render/props.ts index 75a6ea545a..88aa917d18 100644 --- a/examples/strata-terrain-preview/src/render/props.ts +++ b/examples/strata-terrain-preview/src/render/props.ts @@ -112,7 +112,7 @@ export type PropRole = * * The bands are the game's, because the triangle budget behind them is the game's: a prop is drawn * from its full prepared detail inside `near`, from the mid level past it, and from the far - * cross-card past that. The hysteresis is a fifth of the band rather than a fixed number of + * engine impostor past that. The dither transition is a fifth of the band rather than a fixed number of * metres, so the same slack works for a two-metre rock, a fourteen-metre spruce and a * twenty-two-metre pine. */ diff --git a/examples/strata-terrain-preview/src/render/sky.ts b/examples/strata-terrain-preview/src/render/sky.ts index 810be0966f..892dbb29e5 100644 --- a/examples/strata-terrain-preview/src/render/sky.ts +++ b/examples/strata-terrain-preview/src/render/sky.ts @@ -197,6 +197,19 @@ export function createOutdoorSky(camera: Object3D, biome?: IBiome): IOutdoorSky }; } +export function createOutdoorTRAA( + input: Node, + depth: Parameters[1], + velocityNode: Parameters[2], + camera: ICtx["camera"], + renderer: Pick, +): Node { + const node = traa(input as Node<"vec4">, depth, velocityNode as never, camera) as unknown as Node; + const surface = renderer.surface(); + Reflect.get(node, "beautyNode").setSize(surface.drawingBufferWidth, surface.drawingBufferHeight); + return node; +} + /** Contact-scale occlusion through the installed chain; daylight still owns exposure and tone. */ export function installOutdoorOcclusion( ctx: Pick, @@ -288,13 +301,7 @@ export function installOutdoorOcclusion( name: "traa", minimumTier: "high", build: (input, context) => - traa( - input as Node<"vec4">, - depth, - // The chain hands the scene-side velocity node; three's declaration asks for a texture node. - context.velocityNode as never, - camera, - ) as unknown as Node, + createOutdoorTRAA(input as Node, depth, context.velocityNode as never, camera, renderer), }, { name: "grade", diff --git a/examples/strata-terrain-preview/vitest.config.ts b/examples/strata-terrain-preview/vitest.config.ts new file mode 100644 index 0000000000..1725439f37 --- /dev/null +++ b/examples/strata-terrain-preview/vitest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + root: import.meta.dirname, + test: { environment: "node", include: ["__tests__/*.spec.ts"], maxWorkers: 1 }, +}); diff --git a/packages/assets/__tests__/foliage-lod.spec.ts b/packages/assets/__tests__/foliage-lod.spec.ts index 9e02210507..0c9d240655 100644 --- a/packages/assets/__tests__/foliage-lod.spec.ts +++ b/packages/assets/__tests__/foliage-lod.spec.ts @@ -465,3 +465,257 @@ function vertexAlphaDocument(alpha: number): Document { ); return document; } + +it("writes a standalone far crown with whole cards and all authored attributes", async () => { + const { thinFoliageCards } = await import("../src/lod/generate.js"); + const document = await readWithLod(await readFile(FIXTURE)); + await convertFoliageCutout(document); + const before = document + .getRoot() + .listMeshes() + .flatMap((mesh) => mesh.listPrimitives()) + .map((primitive) => ({ + primitive, + count: primitive.getIndices()?.getCount(), + semantics: primitive.listSemantics(), + attributes: new Map( + primitive + .listSemantics() + .map((semantic) => [ + semantic, + Array.from(primitive.getAttribute(semantic)?.getArray() ?? []), + ]), + ), + })); + const result = thinFoliageCards(document, 0.14); + expect(result).toBeGreaterThan(0); + for (const { primitive, count, semantics, attributes } of before) { + expect(primitive.listSemantics()).toEqual(semantics); + for (const semantic of semantics) { + const original = attributes.get(semantic) ?? []; + const accessor = primitive.getAttribute(semantic) as Accessor; + const current = Array.from(accessor.getArray() ?? []); + expect(current.slice(0, original.length)).toEqual(original); + if (semantic === "POSITION") continue; + const size = accessor.getElementSize(); + for (let at = original.length; at < current.length; at += size) { + const copied = current.slice(at, at + size); + let matched = false; + for (let source = 0; source < original.length; source += size) + if (copied.every((value, i) => value === original[source + i])) { + matched = true; + break; + } + expect(matched).toBe(true); + } + } + + if (primitive.getMaterial()?.getAlphaMode() !== "MASK") + expect(primitive.getIndices()?.getCount()).toBe(count); + else { + expect((primitive.getIndices() as Accessor).getCount() % 6).toBe(0); + expect((primitive.getIndices() as Accessor).getCount()).toBeLessThan(count as number); + const positionCount = (primitive.getAttribute("POSITION") as Accessor).getCount(); + for (const semantic of semantics) + expect((primitive.getAttribute(semantic) as Accessor).getCount()).toBe(positionCount); + } + } +}); + +it.each([0, 1, -0.1, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects invalid standalone card keep ratio %s", + async (ratio) => { + const { thinFoliageCards } = await import("../src/lod/generate.js"); + expect(() => thinFoliageCards(new Document(), ratio)).toThrow(/keepRatio/); + }, +); + +/** A MASK card set: one ten-triangle fan too large to thin beside `cards` whole two-triangle quads. */ +function fanBesideCardsDocument(cards: number): Document { + const document = new Document(); + const buffer = document.createBuffer(); + const positions: number[] = [0, 0, 0]; + const normals: number[] = [0, 1, 0]; + const rim = 11; + for (let vertex = 0; vertex < rim; vertex += 1) { + const angle = (vertex / rim) * Math.PI * 2; + positions.push(Math.cos(angle), 0, Math.sin(angle)); + normals.push(0, 1, 0); + } + // Ten fan triangles over the card cap: one connected component that cannot be a card. + const indices: number[] = []; + for (let triangle = 0; triangle < 10; triangle += 1) indices.push(0, 1 + triangle, 2 + triangle); + // Separate quads, clustered so one grid cell holds them all. + for (let card = 0; card < cards; card += 1) { + const base = positions.length / 3; + const x = 2 + (card % 8) * 0.02; + const z = Math.floor(card / 8) * 0.02; + const size = 0.01; + positions.push(x, 0, z, x + size, 0, z, x + size, 0, z + size, x, 0, z + size); + for (let corner = 0; corner < 4; corner += 1) normals.push(0, 1, 0); + indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + } + // Original vertex IDs, copied onto every appended card copy, so derived indices map back to source. + const source = Float32Array.from({ length: positions.length / 3 }, (_, vertex) => vertex); + const primitive = document + .createPrimitive() + .setAttribute( + "POSITION", + document + .createAccessor() + .setType("VEC3") + .setArray(Float32Array.from(positions)) + .setBuffer(buffer), + ) + .setAttribute( + "NORMAL", + document + .createAccessor() + .setType("VEC3") + .setArray(Float32Array.from(normals)) + .setBuffer(buffer), + ) + .setAttribute( + "_SOURCE_VERTEX", + document.createAccessor().setType("SCALAR").setArray(source).setBuffer(buffer), + ) + .setIndices( + document + .createAccessor() + .setType("SCALAR") + .setArray(Uint32Array.from(indices)) + .setBuffer(buffer), + ) + .setMaterial(document.createMaterial("leaf").setAlphaMode("MASK").setAlphaCutoff(0.5)); + document + .createScene() + .addChild( + document.createNode("leaves").setMesh(document.createMesh("leaves").addPrimitive(primitive)), + ); + return document; +} + +describe("whole-card thinning beside an unspendable component (PRD-618)", () => { + it("preserves an oversized component intact and still thins the eligible whole cards", async () => { + const { thinFoliageCards } = await import("../src/lod/generate.js"); + const document = fanBesideCardsDocument(24); + const primitive = document.getRoot().listMeshes()[0]?.listPrimitives()[0]; + if (primitive === undefined) throw new Error("no primitive"); + const before = Array.from(primitive.getIndices()?.getArray() as ArrayLike); + const lod0Vertices = (primitive.getAttribute("POSITION") as Accessor).getCount(); + const normalsBefore = Array.from( + primitive.getAttribute("NORMAL")?.getArray() as ArrayLike, + ); + // The authored source IDs, before any copy is appended: appended copies never move these. + const sourceBefore = Array.from( + (primitive.getAttribute("_SOURCE_VERTEX") as Accessor).getArray() as ArrayLike, + ); + + expect(thinFoliageCards(document, 0.14)).toBeGreaterThan(0); + + const after = Array.from((primitive.getIndices() as Accessor).getArray() as ArrayLike); + // A genuine saving: the derived level draws fewer triangles than LOD0. + expect(after.length).toBeLessThan(before.length); + + // Map every derived index back to its source vertex ID, so a triangle is compared as authored. + const source = Array.from( + (primitive.getAttribute("_SOURCE_VERTEX") as Accessor).getArray() as ArrayLike, + ); + const drawn = new Set(); + for (let at = 0; at + 2 < after.length; at += 3) + drawn.add( + `${String(source[after[at] as number])},${String(source[after[at + 1] as number])},${String(source[after[at + 2] as number])}`, + ); + + // The oversized component survives verbatim, drawn from LOD0's own vertices, not a scaled copy. + for (let triangle = 0; triangle < 10; triangle += 1) { + const a = before[triangle * 3] as number; + const b = before[triangle * 3 + 1] as number; + const c = before[triangle * 3 + 2] as number; + expect(a).toBeLessThan(lod0Vertices); + expect(drawn.has(`${String(a)},${String(b)},${String(c)}`)).toBe(true); + } + + // Each source quad contributes both its triangles or neither: no whole card is half-dropped. + const quads = (before.length / 3 - 10) / 2; + let whole = 0; + for (let quad = 0; quad < quads; quad += 1) { + const at = (10 + quad * 2) * 3; + const first = `${String(before[at])},${String(before[at + 1])},${String(before[at + 2])}`; + const second = `${String(before[at + 3])},${String(before[at + 4])},${String(before[at + 5])}`; + expect(drawn.has(first)).toBe(drawn.has(second)); + if (drawn.has(first)) whole += 1; + } + expect(whole).toBeGreaterThan(0); + expect(whole).toBeLessThan(quads); + + // Copies are only ever appended: the original attributes are untouched. + const normalsAfter = Array.from( + primitive.getAttribute("NORMAL")?.getArray() as ArrayLike, + ); + expect(normalsAfter.slice(0, normalsBefore.length)).toEqual(normalsBefore); + const sourceAfter = Array.from( + (primitive.getAttribute("_SOURCE_VERTEX") as Accessor).getArray() as ArrayLike, + ); + // The authored prefix is byte-for-byte the pre-thinning array; only copies follow it. + expect(sourceAfter.slice(0, sourceBefore.length)).toEqual(sourceBefore); + }); + + it("keeps preserved triangles on every rung and budgets the terminal target around them", async () => { + const { generateCardChain } = await import("../src/lod/cards.js"); + const document = fanBesideCardsDocument(24); + const primitive = document.getRoot().listMeshes()[0]?.listPrimitives()[0]; + if (primitive === undefined) throw new Error("no primitive"); + const positions = Float32Array.from( + (primitive.getAttribute("POSITION") as Accessor).getArray() as ArrayLike, + ); + const indices = Uint32Array.from( + (primitive.getIndices() as Accessor).getArray() as ArrayLike, + ); + // The ten fan triangles verbatim: one component too large to be a card, so every rung must + // carry it unchanged at the front of its index buffer. + const fan = Array.from(indices.slice(0, 30)); + + // 24 cards clustered in one LOD0 cell: the ratio ladder floors at 6 cards (22 triangles with + // the preserved 10) and cannot reach 20, so only the terminal level can. It fits only when the + // preserved triangles are removed from its card budget. + const chain = generateCardChain(positions, indices, 4, 0, 1, [0.5, 0.25], 20); + expect(chain).not.toBeNull(); + if (chain === null) return; + expect(chain.counts.length).toBeGreaterThanOrEqual(3); + for (const buffer of chain.indices) + expect(Array.from(buffer.slice(0, fan.length))).toEqual(fan); + expect(chain.counts.at(-1) as number).toBeLessThanOrEqual(20); + + // A target the preserved component already exceeds yields no compliant rung at all: the chain + // is exactly the one with no terminal, never a level that silently overshoots the target. + const ladder = generateCardChain(positions, indices, 4, 0, 1, [0.5, 0.25]); + const exhausted = generateCardChain(positions, indices, 4, 0, 1, [0.5, 0.25], 6); + expect(exhausted?.counts).toEqual(ladder?.counts); + }); + + it("rejects when every component is too large to be a card", async () => { + const { thinFoliageCards } = await import("../src/lod/generate.js"); + // The same fan with no eligible card beside it: nothing can be spent, so the primitive fails + // closed rather than pretending a level exists. + expect(() => thinFoliageCards(fanBesideCardsDocument(0), 0.14)).toThrow(/whole cards/); + }); + + it("still refuses a non-static card set", async () => { + const { thinFoliageCards } = await import("../src/lod/generate.js"); + const document = fanBesideCardsDocument(24); + const primitive = document.getRoot().listMeshes()[0]?.listPrimitives()[0]; + const position = primitive?.getAttribute("POSITION"); + if (primitive === undefined || position === null || position === undefined) + throw new Error("no primitive"); + primitive.setAttribute( + "JOINTS_0", + document + .createAccessor() + .setType("VEC4") + .setArray(new Float32Array(position.getCount() * 4)) + .setBuffer(document.getRoot().listBuffers()[0] as ReturnType), + ); + expect(() => thinFoliageCards(document, 0.14)).toThrow(/static triangle card sets/); + }); +}); diff --git a/packages/assets/src/index.ts b/packages/assets/src/index.ts index 1fc00ba3ba..0576ae3acc 100644 --- a/packages/assets/src/index.ts +++ b/packages/assets/src/index.ts @@ -258,3 +258,11 @@ export type { IDedupeCensus, IMaterialBucket, IMaterialState } from "./content/d */ export { censusDocument, materialStateOf, totalCensus } from "./content/census.js"; export type { IContentCensus } from "./content/census.js"; + +/** + * Build a standalone card-thinned foliage mesh, preserving opaque parts and vertex attributes. + * @situation prepare a far foliage mesh by thinning whole cards instead of triangles + * @constraint mutates the supplied document; rejects unsupported MASK topology and invalid ratios + * @example thinFoliageCards(document, 0.14); + */ +export { thinFoliageCards } from "./lod/generate.js"; diff --git a/packages/assets/src/lod/cards.ts b/packages/assets/src/lod/cards.ts index 72f9ddc23d..1b40fa74c7 100644 --- a/packages/assets/src/lod/cards.ts +++ b/packages/assets/src/lod/cards.ts @@ -102,15 +102,28 @@ export interface ICardChain { readonly lod0Triangles: number; } +/** Reducible cards and triangles retained unchanged. */ +interface ICardSet { + readonly cards: ICard[]; + /** + * Triangle start offsets of components larger than {@link MAX_CARD_TRIANGLES}. They are not cards, + * so they cannot be scaled or dropped; every derived level keeps them verbatim beside the cards. + */ + readonly preserved: readonly number[]; +} + /** - * Connected components of the index buffer, or `null` when the primitive is not a card set. + * Connected components of the index buffer, or `null` when the primitive has no thinable card. * * Union-find over shared vertices, which is the only cheap definition of "card" that does not need a * per-export convention: an exporter that duplicates the four corners of every quad, one that - * shares a stem vertex, and a folded card all decompose the same way. `null` means a component was - * too large to be a card, and the caller keeps the primitive's existing behaviour. + * shares a stem vertex, and a folded card all decompose the same way. A component larger than + * {@link MAX_CARD_TRIANGLES} is a shell or a trunk fan, not a card: it is collected into + * {@link ICardSet.preserved} rather than aborting the whole primitive, so one such component cannot + * veto the thousands of eligible cards beside it. `null` means there is no eligible card at all, and + * the caller keeps the primitive's existing behaviour. */ -function findCards(positions: Float32Array, indices: Uint32Array): ICard[] | null { +function findCards(positions: Float32Array, indices: Uint32Array): ICardSet | null { const vertexCount = Math.floor(positions.length / 3); const parent = new Int32Array(vertexCount); for (let vertex = 0; vertex < vertexCount; vertex += 1) parent[vertex] = vertex; @@ -141,12 +154,17 @@ function findCards(positions: Float32Array, indices: Uint32Array): ICard[] | nul const group = grouped.get(root); if (group === undefined) grouped.set(root, [triangle]); else group.push(triangle); - if ((group?.length ?? 1) > MAX_CARD_TRIANGLES) return null; } const cards: ICard[] = []; + const preserved: number[] = []; for (const triangles of grouped.values()) { if (triangles.length === 0) continue; + // Too large to be a card: keep every triangle, in index order, in every level. + if (triangles.length > MAX_CARD_TRIANGLES) { + for (const triangle of triangles) preserved.push(triangle); + continue; + } let area = 0; let sx = 0; let sy = 0; @@ -185,8 +203,31 @@ function findCards(positions: Float32Array, indices: Uint32Array): ICard[] | nul triangles, }); } - // A primitive with no triangles, or with fewer cards than levels, has nothing to spend. - return cards.length > 0 ? cards : null; + // No eligible card means no thinning: preserve the primitive's existing behaviour rather than + // inventing a level from components this reducer may not touch. + if (cards.length === 0) return null; + preserved.sort((a, b) => a - b); + return { cards, preserved }; +} + +/** The original index values of the listed triangles, copied verbatim to keep in every level. */ +function preservedIndices(triangles: readonly number[], source: Uint32Array): Uint32Array { + if (triangles.length === 0) return new Uint32Array(0); + const out = new Uint32Array(triangles.length * TRIANGLE); + let at = 0; + for (const triangle of triangles) + for (let corner = 0; corner < TRIANGLE; corner += 1, at += 1) + out[at] = source[triangle + corner] as number; + return out; +} + +/** `cards` with the preserved triangles in front, so a level draws both from the same index buffer. */ +function withPreserved(preserved: Uint32Array, cards: Uint32Array): Uint32Array { + if (preserved.length === 0) return cards; + const out = new Uint32Array(preserved.length + cards.length); + out.set(preserved); + out.set(cards, preserved.length); + return out; } /** @@ -378,6 +419,8 @@ function cardTriangles(card: ICard): number { * then spends what is left of the target on further cards within each cell. Every occupied coarse * region stays populated, the level is never empty, and the triangle count reaches the target. * + * The caller subtracts preserved triangles from `target`; `null` means the cards cannot fit. + * * Returns the level and the grid factor that reached it, so the caller can report the coarse cell * width as the level's error (see {@link generateCardChain}). */ @@ -390,7 +433,7 @@ function terminalCardLevel( target: number, previousTriangles: number, ): { cards: ICard[]; factor: number; summary: ICardLevelSummary } | null { - if (target >= previousTriangles) return null; + if (target <= 0 || target >= previousTriangles) return null; let factor = 1; let { buckets, cells } = bucketsOf(lod0Placed); const baseTriangles = (): number => @@ -399,12 +442,13 @@ function terminalCardLevel( total + cardTriangles((buckets.get(cell) as IPlacedCard[])[0]?.card as ICard), 0, ); - // Widen the grid until one card per occupied coarse cell fits the target. `factor` growing without - // bound ends at a single cell and a single card, which is always under the target. + // Widen the grid until one card per occupied coarse cell fits, or only one cell remains. while (baseTriangles() > target && cells.length > 1) { factor *= 2; ({ buckets, cells } = bucketsOf(placeCards(cards, cellSize * factor).placed)); } + // Even one card cannot fit the remaining budget. + if (baseTriangles() > target) return null; const kept: ICard[] = []; const keptSet = new Set(); let triangles = 0; @@ -457,8 +501,14 @@ function terminalCardLevel( * which is the distance from a kept card to the nearest surface the level no longer draws. It rises * as the level coarsens, which is the monotonicity the runtime's selection requires. * - * `terminalTriangles`, when given, is the triangle target of one extra terminal level past the - * ratio ladder (see {@link terminalCardLevel}); it reaches below the ladder's per-cell floor. + * `terminalTriangles`, when given, is the triangle ceiling of one extra terminal level past the + * ratio ladder (see {@link terminalCardLevel}); it reaches below the ladder's per-cell floor. The + * triangles of any preserved component are subtracted before the terminal's cards are budgeted, so + * the level it adds keeps the whole primitive — cards plus preserved geometry — at or under it. + * + * A component too large to be a card is not a reason to abandon the primitive: it rides verbatim at + * the front of every level's index buffer, so one weld-fused shell cannot veto the eligible cards + * beside it. Only a primitive with no eligible card returns `null`. */ export function generateCardChain( positions: Float32Array, @@ -469,8 +519,11 @@ export function generateCardChain( keepRatios: readonly number[] = CARD_KEEP_RATIOS, terminalTriangles?: number, ): ICardChain | null { - const cards = findCards(positions, indices); - if (cards === null) return null; + const found = findCards(positions, indices); + if (found === null) return null; + const { cards } = found; + // Components too large to thin, kept verbatim at the front of every level's index buffer. + const preserved = preservedIndices(found.preserved, indices); const lod0Triangles = Math.floor(indices.length / TRIANGLE); if (lod0Triangles <= 0) return null; @@ -510,8 +563,8 @@ export function generateCardChain( const level = thinLevel(placed, keep, lod0Area, lod0Cells); // Never empty: a level that drops the last card draws nothing at all. if (level.cards.length === 0 || level.cards.length === cards.length) continue; - const buffer = indicesOf(level.cards, indices); - const triangles = Math.floor(buffer.length / TRIANGLE); + const cardIndices = indicesOf(level.cards, indices); + const triangles = Math.floor((preserved.length + cardIndices.length) / TRIANGLE); if (triangles >= previousTriangles) continue; if ((previousTriangles - triangles) / previousTriangles < minSaving) continue; const absoluteError = cellSize * (1 - keep); @@ -521,7 +574,7 @@ export function generateCardChain( counts.push(triangles); absoluteErrors.push(absoluteError); errors.push(absoluteError / Math.max(scale, 1e-6)); - buffers.push(scaled.indices); + buffers.push(withPreserved(preserved, scaled.indices)); copies.push(scaled.copies); previousTriangles = triangles; } @@ -537,12 +590,13 @@ export function generateCardChain( lod0Cells, lod0Area, cellSize, - terminalTriangles, - previousTriangles, + // Preserved geometry counts against both the target and the previous rung. + terminalTriangles - Math.floor(preserved.length / TRIANGLE), + previousTriangles - Math.floor(preserved.length / TRIANGLE), ); if (terminal !== null) { - const buffer = indicesOf(terminal.cards, indices); - const triangles = Math.floor(buffer.length / TRIANGLE); + const cardIndices = indicesOf(terminal.cards, indices); + const triangles = Math.floor((preserved.length + cardIndices.length) / TRIANGLE); if (triangles > 0 && triangles < previousTriangles) { const absoluteError = cellSize * terminal.factor; const scaled = scaledLevel(terminal, indices, positions, base); @@ -551,7 +605,7 @@ export function generateCardChain( counts.push(triangles); absoluteErrors.push(absoluteError); errors.push(absoluteError / Math.max(scale, 1e-6)); - buffers.push(scaled.indices); + buffers.push(withPreserved(preserved, scaled.indices)); copies.push(scaled.copies); previousTriangles = triangles; } diff --git a/packages/assets/src/lod/generate.ts b/packages/assets/src/lod/generate.ts index 3d2f08815a..db0e2e5e41 100644 --- a/packages/assets/src/lod/generate.ts +++ b/packages/assets/src/lod/generate.ts @@ -969,6 +969,51 @@ function appendCardCopies(primitive: Primitive, copies: readonly ICardCopies[]): } } +/** Thin MASK card sets into a standalone mesh; opaque parts and every vertex attribute survive. */ +export function thinFoliageCards(document: Document, keepRatio: number): number { + if (!Number.isFinite(keepRatio) || keepRatio <= 0 || keepRatio >= 1) + throw new Error("thinFoliageCards keepRatio must be in (0, 1)."); + let reduced = 0; + for (const mesh of document.getRoot().listMeshes()) + for (const primitive of mesh.listPrimitives()) { + if (primitive.getMaterial()?.getAlphaMode() !== "MASK") continue; + if ( + primitive.getMode() !== 4 || + primitive.listTargets().length > 0 || + primitive.getAttribute("JOINTS_0") + ) + throw new Error("thinFoliageCards requires static triangle card sets."); + const positions = positionsOf(primitive); + if (positions === null) throw new Error("thinFoliageCards requires POSITION."); + const indices = sourceIndices(primitive, positions.length / 3); + const chain = generateCardChain( + positions, + indices, + 2, + 0, + 1, + [keepRatio], + Math.ceil((indices.length / 3) * keepRatio), + ); + if (chain === null) + throw new Error("thinFoliageCards cannot reduce this MASK primitive as whole cards."); + appendCardCopies(primitive, chain.copies); + primitive.setIndices( + ( + primitive.getIndices() ?? + document + .createAccessor() + .setType("SCALAR") + .setBuffer((primitive.getAttribute("POSITION") as Accessor).getBuffer()) + ) + .clone() + .setArray(chain.indices.at(-1) as Uint32Array), + ); + reduced += 1; + } + return reduced; +} + function reachableNodes(document: Document): GltfNode[] { const visited = new Set(); const nodes: GltfNode[] = []; diff --git a/packages/core/__tests__/compute-driven.spec.ts b/packages/core/__tests__/compute-driven.spec.ts index 8b532a5a85..5f026779cf 100644 --- a/packages/core/__tests__/compute-driven.spec.ts +++ b/packages/core/__tests__/compute-driven.spec.ts @@ -234,14 +234,16 @@ describe("ComputeDrivenRegistry", () => { for (let index = 0; index < 4; index += 1) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); await Promise.resolve(); - for (const time of [32, 48, 64, 80, 96]) { + // Readiness needs five in-budget frames after compile settles, then two rendered quiet + // polls once the world has been admitted, before render-cadence compute is allowed. + for (const time of [32, 48, 64, 80, 96, 112, 128]) { frame(time); await Promise.resolve(); } await ready; - frame(112); - frame(128); + frame(144); + frame(160); expect(dispatched).toHaveLength(2); } finally { game.stop(); diff --git a/packages/core/__tests__/game-auto-scale.spec.ts b/packages/core/__tests__/game-auto-scale.spec.ts index 3e24a0015a..2046e0a28b 100644 --- a/packages/core/__tests__/game-auto-scale.spec.ts +++ b/packages/core/__tests__/game-auto-scale.spec.ts @@ -409,6 +409,12 @@ describe('resolutionScale: "auto"', () => { // in the window record, then the refund gives it back because 100 ms frames never // improve on fewer pixels. releaseTier(); + // Final settlement observes rendered frames after the held warm-up, including two quiet ones. + for (let index = 0; index < 10; index += 1) { + time += 8; + frame(time); + await Promise.resolve(); + } await game.ctx?.startup.whenReady(); postReadiness.length = 0; for (let index = 0; index < 6; index += 1) { diff --git a/packages/core/__tests__/game.spec.ts b/packages/core/__tests__/game.spec.ts index 32f115011c..94cc3b829a 100644 --- a/packages/core/__tests__/game.spec.ts +++ b/packages/core/__tests__/game.spec.ts @@ -14,9 +14,11 @@ import { MeshBasicNodeMaterial } from "three/webgpu"; import { describe, expect, it, vi } from "vitest"; import { type IGamePlatformSource, defineGame } from "../src/game.js"; import { InputMap } from "../src/input.js"; +import { onLaunchFailure, resetLaunchFailures } from "../src/launch-diagnostics.js"; import type { IRenderPerformanceSample } from "../src/loop.js"; import { type ICtx, Scene } from "../src/scene.js"; import { SoftBody3D } from "../src/softbody.js"; +import { StartupReadiness } from "../src/startup-readiness.js"; import { UI_INTENT_MESSAGE, UI_READY_INTENT, connectUiBridge } from "../src/ui-bridge.js"; import { subscribeUiState } from "../src/ui-state.js"; @@ -785,6 +787,89 @@ describe("IGame", () => { } }); + it.each([0, 10_000, 20_000])( + "does not report startup failure after stop at %ims", + async (stopAt) => { + vi.useFakeTimers(); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + const requestFrame = globalThis.requestAnimationFrame; + let frame!: (time: number) => void; + vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { + frame = callback; + return 1; + }); + resetLaunchFailures(); + const failure = vi.fn(); + const off = onLaunchFailure(failure); + const game = defineGame({ + renderer: renderer(testCanvas()), + scenes: { test: EmptyScene }, + start: "test", + }); + try { + await game.start(); + frame(16); + // Stop before queued rejection handlers run, including an already-expired settlement. + vi.advanceTimersByTime(stopAt); + game.stop(); + await vi.advanceTimersByTimeAsync(60_000); + expect(failure).not.toHaveBeenCalled(); + } finally { + game.stop(); + off(); + resetLaunchFailures(); + if (requestFrame === undefined) Reflect.deleteProperty(globalThis, "requestAnimationFrame"); + else vi.stubGlobal("requestAnimationFrame", requestFrame); + errorLog.mockRestore(); + vi.useRealTimers(); + } + }, + ); + + it("propagates final settlement failure without publishing readiness or lifting the cover", async () => { + let fail!: (error: Error) => void; + const pending = new Promise((_resolve, reject) => { + fail = reject; + }); + const readySpy = vi.spyOn(StartupReadiness.prototype, "whenReady").mockReturnValue(pending); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + resetLaunchFailures(); + const failure = vi.fn(); + const off = onLaunchFailure(failure); + class CoveredScene extends Scene { + static override readonly initialState = {}; + override enter(ctx: ICtx): void { + ctx.canvasLayer.opaque = true; + } + } + const game = defineGame({ + renderer: renderer(testCanvas()), + scenes: { test: CoveredScene }, + start: "test", + }); + try { + await game.start(); + const ctx = game.ctx; + if (ctx === undefined) throw new Error("Game did not start."); + const boom = new Error( + "TN_STARTUP_SETTLEMENT_TIMEOUT: required world/shadow settlement missing", + ); + const rejected = expect(ctx.startup.whenReady()).rejects.toBe(boom); + fail(boom); + await rejected; + expect(failure).toHaveBeenCalledWith({ kind: "stalled", message: String(boom) }); + expect(ctx.startup.timeline.readyMs).toBeUndefined(); + expect(ctx.startup.phase).not.toBe("ready"); + expect(ctx.canvasLayer.opaque).toBe(true); + } finally { + game.stop(); + off(); + resetLaunchFailures(); + readySpy.mockRestore(); + errorLog.mockRestore(); + } + }); + it("does not resolve startup readiness before compile and sustained frames behind an opaque layer", async () => { const canvas = testCanvas(); const events: string[] = []; @@ -886,12 +971,19 @@ describe("IGame", () => { } frame(112); await Promise.resolve(); + expect(ready).toBe(false); + // The sustained frame window is followed by two quiet rendered readiness polls. + frame(128); + await Promise.resolve(); + expect(ready).toBe(false); + frame(144); + await Promise.resolve(); await Promise.resolve(); expect(ready).toBe(true); expect(ctx.startup.phase).toBe("ready"); expect(ctx.startup.progress).toBe(1); expect(events.filter((event) => event === "world").length).toBe( - worldRendersBeforeStabilityWindow + 4, + worldRendersBeforeStabilityWindow + 6, ); expect(events.slice(0, 5)).toEqual(["overlay", "compile", "overlay", "world", "scene"]); } finally { diff --git a/packages/core/__tests__/instance-velocity-upload.spec.ts b/packages/core/__tests__/instance-velocity-upload.spec.ts index 7f002d4850..f2a88e9f0b 100644 --- a/packages/core/__tests__/instance-velocity-upload.spec.ts +++ b/packages/core/__tests__/instance-velocity-upload.spec.ts @@ -12,7 +12,7 @@ import { AttributeType } from "three/src/renderers/common/Constants.js"; import Renderer from "three/src/renderers/common/Renderer.js"; import { instance } from "three/tsl"; import { type Node, NodeFrame, WGSLNodeBuilder } from "three/webgpu"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { VelocityTracker } from "../src/render/velocity.js"; /** Three's runtime builder methods are not included in its published declarations. */ @@ -137,6 +137,54 @@ describe("instance matrix uploads at the actual draw boundary", () => { }, ); + it.each([false, true])("reuses unchanged scheduled history (dynamic=%s)", (dynamic) => { + const f = fixture(dynamic); + f.draw(); + f.tracker.commit(f.scene); + f.tracker.update(f.scene); + const first = f.draw(); + if (!f.previous) throw new Error("Previous instance attribute missing"); + const data = f.previous.data; + const version = data.version; + const copy = vi.spyOn(data.array, "set"); + for (let draw = 0; draw < 4; draw += 1) { + f.tracker.commit(f.scene); + f.tracker.update(f.scene); + const drawn = f.draw(); + if (!dynamic) expect(drawn.updates).toBe(first.updates); + expect(data.version).toBe(version); + } + expect(copy).not.toHaveBeenCalled(); + copy.mockRestore(); + f.tracker.clear(); + }); + + it("uploads changed A/B/A scheduled buffers and restores history after fallback", () => { + const f = fixture(false); + const key = Symbol.for("threenative.velocity.previousInstanceMatrices"); + const a = Float32Array.from(f.crowd.instanceMatrix.array); + const b = Float32Array.from(a); + b[28] = 7; + for (const source of [a, b, a]) { + if (source === a) { + const x = a[28]; + if (x === undefined) throw new Error("Missing second instance matrix in fixture"); + a[28] = x + 1; + } + Reflect.set(f.crowd, key, source); + expect(f.draw().previous).toEqual(source); + const updates = f.draw().updates; + expect(f.draw().updates).toBe(updates); + } + Reflect.deleteProperty(f.crowd, key); + f.crowd.setMatrixAt(1, new Matrix4().makeTranslation(9, 0, -4)); + f.crowd.instanceMatrix.needsUpdate = true; + f.draw(); + Reflect.set(f.crowd, key, a); + expect(f.draw().previous).toEqual(a); + f.tracker.clear(); + }); + it("keeps unscheduled Three history advancing after each upload", () => { const f = fixture(false, true, false); f.draw(); diff --git a/packages/core/__tests__/startup-readiness.spec.ts b/packages/core/__tests__/startup-readiness.spec.ts index 7a08e25827..6157c654a2 100644 --- a/packages/core/__tests__/startup-readiness.spec.ts +++ b/packages/core/__tests__/startup-readiness.spec.ts @@ -2,6 +2,114 @@ import { describe, expect, it, vi } from "vitest"; import { StartupReadiness } from "../src/startup-readiness.js"; describe("StartupReadiness", () => { + it.each(["compile", "hold", "warmup"])("cancels timers and late %s completion", async (phase) => { + vi.useFakeTimers(); + try { + let finish!: () => void; + const pending = new Promise((resolve) => { + finish = resolve; + }); + const readiness = new StartupReadiness({ + stableFrames: 1, + settled: () => false, + afterHolds: () => pending, + }); + const ready = vi.fn(); + const failed = vi.fn(); + void readiness.whenReady().then(ready, failed); + if (phase !== "compile") + readiness.hold("view", phase === "hold" ? pending : Promise.resolve()); + readiness.start(phase === "compile" ? () => pending : undefined); + await Promise.resolve(); + readiness.observe(1); + readiness.cancel(); + readiness.cancel(); + expect(vi.getTimerCount()).toBe(0); + finish(); + await vi.advanceTimersByTimeAsync(60_000); + readiness.observe(1); + readiness.observe(1); + expect(vi.getTimerCount()).toBe(0); + expect(ready).not.toHaveBeenCalled(); + expect(failed).not.toHaveBeenCalled(); + expect(readiness.ready).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it.each(["world", "hold"])( + "rejects when required %s settlement never arrives", + async (missing) => { + vi.useFakeTimers(); + try { + let resident = true; + const readiness = new StartupReadiness({ + stableFrames: 1, + stableWindowMs: 100, + settled: () => missing !== "world", + }); + readiness.hold("view", () => resident); + const failed = vi.fn(); + void readiness.whenReady().catch(failed); + readiness.start(); + readiness.observe(1); + resident = missing !== "hold"; + readiness.observe(1); + await vi.advanceTimersByTimeAsync(99); + expect(failed).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(failed).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("TN_STARTUP_SETTLEMENT_TIMEOUT"), + }), + ); + resident = true; + readiness.observe(1); + readiness.observe(1); + expect(readiness.ready).toBe(false); + } finally { + vi.useRealTimers(); + } + }, + ); + + it("requires two quiet rendered polls after warm-up and resets for late work", async () => { + let shadowSettled = false; + let resident = true; + let finishWarmup!: () => void; + const readiness = new StartupReadiness({ + stableFrames: 1, + settled: () => shadowSettled, + afterHolds: () => + new Promise((resolve) => { + finishWarmup = resolve; + }), + }); + readiness.hold("view", () => resident); + readiness.start(); + readiness.observe(1); + expect(readiness.ready).toBe(false); + finishWarmup(); + for (let i = 0; i < 4; i++) await Promise.resolve(); + shadowSettled = true; + readiness.observe(1); + expect(readiness.ready).toBe(false); + resident = false; + readiness.observe(1); + resident = true; + readiness.observe(1); + expect(readiness.ready).toBe(false); + shadowSettled = false; + readiness.observe(1); + shadowSettled = true; + readiness.observe(1); + expect(readiness.ready).toBe(false); + readiness.observe(1); + expect(readiness.ready).toBe(true); + await readiness.whenReady(); + }); + it("waits for compile work and consecutive in-budget frames", async () => { let resolveCompile: () => void = () => undefined; const readiness = new StartupReadiness({ diff --git a/packages/core/__tests__/startup-timeline.spec.ts b/packages/core/__tests__/startup-timeline.spec.ts index 8f2b9f1a08..1a9346662a 100644 --- a/packages/core/__tests__/startup-timeline.spec.ts +++ b/packages/core/__tests__/startup-timeline.spec.ts @@ -1,5 +1,7 @@ +import { DirectionalLight } from "three"; import { describe, expect, it } from "vitest"; import { defineGame } from "../src/game.js"; +import { VirtualShadowNode } from "../src/render/virtual-shadow.js"; import type { ICtx, IStartupTimeline } from "../src/scene.js"; import { Scene } from "../src/scene.js"; @@ -157,6 +159,11 @@ describe("startup timeline", () => { expect(capturedCtx?.startup.progress).toBeLessThan(1); landDetail(); + // Warm-up and final quiet polls advance on rendered frames, even with simulation held. + for (let index = 13; index <= 40 && capturedCtx?.startup.phase !== "ready"; index++) { + await Promise.resolve(); + frame(index * 16.7); + } await capturedCtx?.startup.whenReady(); const ready = capturedCtx?.startup.timeline; expect(ready?.readyMs).toBeTypeOf("number"); @@ -172,3 +179,86 @@ describe("startup timeline", () => { } }); }); + +it("keeps virtual shadows covered until two rendered quiet polls after late work", async () => { + const canvas = new EventTarget() as EventTarget & Partial; + Object.defineProperties(canvas, { + clientHeight: { value: 90 }, + clientWidth: { value: 160 }, + parentElement: { value: null }, + }); + let ctx!: ICtx; + let shadow!: VirtualShadowNode; + let settled = false; + let sawCoveredDraw = false; + class Covered extends Scene { + static override readonly initialState = {}; + override enter(context: ICtx): void { + ctx = context; + ctx.canvasLayer.opaque = true; + ctx.canvasLayer.keepWorldRendering = true; + const light = new DirectionalLight(); + light.castShadow = true; + shadow = new VirtualShadowNode(light, { marker: false }); + Object.defineProperty(shadow, "settled", { get: () => settled }); + light.shadow.shadowNode = shadow; + ctx.add(light); + } + } + let frame!: (time: number) => void; + const previous = globalThis.requestAnimationFrame; + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: (callback: (time: number) => void) => { + frame = callback; + return 1; + }, + }); + const game = defineGame({ + renderer: { + canvas: canvas as never, + preferWebGPU: false, + webgl2Factory: () => + ({ + domElement: canvas, + render: () => { + sawCoveredDraw ||= shadow?.startupCovered === true; + }, + setSize: () => undefined, + }) as never, + }, + scenes: { covered: Covered }, + start: "covered", + }); + const pump = async (index: number) => { + frame(index * 16.7); + for (let i = 0; i < 6; i++) await Promise.resolve(); + }; + try { + await game.start(); + for (let i = 1; i <= 12; i++) await pump(i); + expect(sawCoveredDraw).toBe(true); + expect(ctx.startup.phase).toBe("collapsing"); + settled = true; + await pump(13); + expect(ctx.startup.phase).toBe("collapsing"); + const remove = ctx.beforeRender(() => { + settled = false; + }); + await pump(14); + remove(); + settled = true; + await pump(15); + expect(ctx.startup.phase).toBe("collapsing"); + await pump(16); + expect(ctx.startup.phase).toBe("ready"); + expect(shadow.startupCovered).toBe(false); + } finally { + await game.stop(); + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: previous, + }); + shadow?.dispose(); + } +}); diff --git a/packages/core/__tests__/three-patch-upgrade.spec.ts b/packages/core/__tests__/three-patch-upgrade.spec.ts index b65f92b059..399cb1d3ce 100644 --- a/packages/core/__tests__/three-patch-upgrade.spec.ts +++ b/packages/core/__tests__/three-patch-upgrade.spec.ts @@ -187,6 +187,12 @@ const ABORT_PREDECESSOR_BLOBS = { "src/renderers/webgpu/WebGPUBackend.js": "0fbcc307d39cbf6d74d9dbc5f199f2b0e4f58b33", }; +const INSTANCE_HISTORY_BLOBS = { + "build/three.webgpu.js": "f9ea33ac39db20e96822fda6b90e2bed02817435", + "build/three.webgpu.nodes.js": "93cedce745291edfbd333fb76d8154b8eff685bd", + "src/nodes/accessors/Instance.js": "18e2b7943393fd07b7989b1e3a5231a61ca24187", +}; + const MATRIX_GUARD_BLOBS = { "build/three.webgpu.js": "2a70eb319b4235c958c7b5b1ede148c808d0b4b2", "build/three.webgpu.nodes.js": "bfa242835dfef49019dd368e3593a62a221dda3d", @@ -250,6 +256,7 @@ async function previousInstalledPackage(priorBlobs: Record, crlf } describe.each([ + ["PRD618 instance-history predecessor", INSTANCE_HISTORY_BLOBS], ["PR486 matrix-guard predecessor", MATRIX_GUARD_BLOBS], ["PR486 abort predecessor", ABORT_PREDECESSOR_BLOBS], ["PR486 queue-order predecessor", QUEUE_ORDER_BLOBS], diff --git a/packages/core/__tests__/virtual-shadow.spec.ts b/packages/core/__tests__/virtual-shadow.spec.ts index c5bee2df49..1124dd18fe 100644 --- a/packages/core/__tests__/virtual-shadow.spec.ts +++ b/packages/core/__tests__/virtual-shadow.spec.ts @@ -169,6 +169,33 @@ function settle(node: VirtualShadowNode, camera: PerspectiveCamera): number { return frames; } +it("settles all covered levels immediately and restores uncovered delay and budget", () => { + const { camera, light } = world(); + const node = setupNode(light, { clipExtents: [8, 32, 128], invalidationDelay: [10, 10, 10] }); + expect(node.settled).toBe(false); + node.startupCovered = true; + node.updateBefore(frameFor(camera, 0)); + expect(node.stats).toMatchObject({ rendered: 3, deferred: 0, held: 0 }); + expect(node.settled).toBe(true); + node.invalidateAll(); + expect(node.settled).toBe(false); + node.updateBefore(frameFor(camera, 0.01)); + expect(node.stats).toMatchObject({ rendered: 3, deferred: 0, held: 0 }); + expect(node.settled).toBe(true); + node.startupCovered = false; + node.invalidateAll(); + node.updateBefore(frameFor(camera, 0.02)); + expect(node.stats).toMatchObject({ rendered: 0, held: 3 }); + expect(node.settled).toBe(false); + node.updateBefore(frameFor(camera, 11)); + expect(node.stats).toMatchObject({ rendered: 1, deferred: 2 }); + expect(node.settled).toBe(false); + node.updateBefore(frameFor(camera, 11.01)); + node.updateBefore(frameFor(camera, 11.02)); + expect(node.settled).toBe(true); + node.dispose(); +}); + interface IShaderGraphBuilder extends NodeBuilder { setShaderStage(shaderStage: "fragment"): void; flowStagesNode(node: Node, output: "vec4"): { code: string }; diff --git a/packages/core/__tests__/warmup.spec.ts b/packages/core/__tests__/warmup.spec.ts index d35b12571b..644025ad91 100644 --- a/packages/core/__tests__/warmup.spec.ts +++ b/packages/core/__tests__/warmup.spec.ts @@ -689,6 +689,114 @@ describe("scene warm-up", () => { expect(raw.shadowMap.needsUpdate).toBe(false); expect(scene.visible).toBe(false); }); + + test("should not expand a shared diamond DAG exponentially while reporting the same passes", async () => { + let calls = 0; + const leaf: { isNode: boolean; getChildren: () => unknown[] } = { + isNode: true, + getChildren: () => { + calls += 1; + return []; + }, + }; + let root: unknown = leaf; + const depth = 12; + for (let index = 0; index < depth; index += 1) { + const next: unknown = root; + const level: { isNode: boolean; getChildren: () => unknown[] } = { + isNode: true, + getChildren: () => { + calls += 1; + return [next, next]; + }, + }; + root = level; + } + const material = { isNodeMaterial: true, a: root, b: root }; + const renderer = { + compileAsync: () => Promise.resolve(), + render: vi.fn(), + raw: { shadowMap: { enabled: false } }, + }; + const scene = group("scene", [{ children: [], material, name: "water" }]); + const report = await warmUpScene(renderer as never, scene as never, {} as never, { + yieldFrame: () => Promise.resolve(), + }); + expect(report.passes).toEqual(["main"]); + expect(renderer.render).not.toHaveBeenCalled(); + expect(calls).toBeLessThan(50); + }); + + test("should retain reflection when a shared node is first reached deep past the cutoff", async () => { + const reflectorNode = { isNode: true, _reflectorBaseNode: {}, getChildren: () => [] }; + const chain = (length: number, leaf: unknown): unknown => { + let next: unknown = leaf; + for (let index = 0; index < length; index += 1) { + const current: unknown = next; + const level = { + isNode: true, + getChildren: () => [current], + }; + next = level; + } + return next; + }; + const tail = chain(4, reflectorNode); + const shared = { isNode: true, getChildren: () => [tail] }; + const deepRoot = chain(60, shared); + const material = { isNodeMaterial: true, deep: deepRoot, shallow: shared }; + const renderer = { + compileAsync: () => Promise.resolve(), + render: () => undefined, + raw: { shadowMap: { enabled: false } }, + }; + const scene = group("scene", [{ children: [], material, name: "water" }]); + const report = await warmUpScene(renderer as never, scene as never, {} as never, { + yieldFrame: () => Promise.resolve(), + }); + expect(report.passes).toEqual(["main", "reflection"]); + }); + + test("should discover a reflector added between two warm-ups", async () => { + const reflectorNode = { isNode: true, _reflectorBaseNode: {}, getChildren: () => [] }; + const children: unknown[] = []; + const plain = { isNode: true, getChildren: () => children }; + const material = { isNodeMaterial: true, colorNode: plain }; + const renderer = { + compileAsync: () => Promise.resolve(), + render: () => undefined, + raw: { shadowMap: { enabled: false } }, + }; + const scene = group("scene", [{ children: [], material, name: "water" }]); + const first = await warmUpScene(renderer as never, scene as never, {} as never, { + yieldFrame: () => Promise.resolve(), + }); + expect(first.passes).toEqual(["main"]); + children.push(reflectorNode); + const second = await warmUpScene(renderer as never, scene as never, {} as never, { + yieldFrame: () => Promise.resolve(), + }); + expect(second.passes).toEqual(["main", "reflection"]); + }); + + test("should terminate on a node cycle without a reflector", async () => { + const cyclic: { isNode: boolean; getChildren: () => unknown[] } = { + isNode: true, + getChildren: () => [cyclic], + }; + const material = { isNodeMaterial: true, colorNode: cyclic }; + const renderer = { + compileAsync: () => Promise.resolve(), + render: vi.fn(), + raw: { shadowMap: { enabled: false } }, + }; + const scene = group("scene", [{ children: [], material, name: "water" }]); + const report = await warmUpScene(renderer as never, scene as never, {} as never, { + yieldFrame: () => Promise.resolve(), + }); + expect(report.passes).toEqual(["main"]); + expect(renderer.render).not.toHaveBeenCalled(); + }); }); describe("warm-up absolute budget", () => { diff --git a/packages/core/__tests__/world-cells-admission.spec.ts b/packages/core/__tests__/world-cells-admission.spec.ts index eb4b1bc1c0..5f7f8799c9 100644 --- a/packages/core/__tests__/world-cells-admission.spec.ts +++ b/packages/core/__tests__/world-cells-admission.spec.ts @@ -1,7 +1,19 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { BoxGeometry, Group, InstancedMesh, Mesh, MeshBasicMaterial, type Object3D } from "three"; +import { + Box3, + BoxGeometry, + Frustum, + Group, + InstancedMesh, + Matrix4, + Mesh, + MeshBasicMaterial, + type Object3D, + PerspectiveCamera, + WebGPUCoordinateSystem, +} from "three"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type IWorldPackage, TerrainTiles, WorldCells } from "../src/world.js"; @@ -279,6 +291,215 @@ afterEach(() => { }); describe("WorldCells admission budget", () => { + it("flushes each covered publication to shadows while uncovered notifications remain throttled", async () => { + const notifications = async (covered: boolean): Promise => { + stubFixtureFetch(); + let clock = 0; + const tells: number[] = []; + const at = cellCenter(1, 1); + const world = await WorldCells.load({ + url: "/world/world.json", + surface, + follow: followAt(at.x, at.z), + terrain: false, + ring: 1, + budgets, + gpuScene: false, + admissionBudgetMs: 1, + coveredAdmissionBudgetMs: 1, + admissionNow: () => ++clock, + shadows: { + cast: true, + invalidate: (region) => { + // Prewarm also requests blanket invalidations; measure publication notifications only. + if (region?.admission) tells.push(clock); + }, + }, + loadModel: async () => model(), + }); + world.process(undefined, undefined, covered); + await flush(); + drain(world, 4000, covered); + world.dispose(); + return tells; + }; + const uncovered = await notifications(false); + const covered = await notifications(true); + const gaps = (times: number[]) => + [...new Set(times)] + .slice(1) + .map((time, index) => time - ([...new Set(times)][index] as number)); + expect(gaps(uncovered).every((gap) => gap >= 1000)).toBe(true); + expect(gaps(covered).some((gap) => gap < 1000)).toBe(true); + }); + + it("reuses exact view membership while readiness, projection and mutable reach stay live", async () => { + stubFixtureFetch(); + const at = cellCenter(1, 1); + const camera = new PerspectiveCamera(60, 1, 0.1, 500); + camera.position.set(at.x, 20, at.z + 200); + camera.lookAt(at.x, 0, at.z); + const reach = new Float32Array( + readFileSync(path.join(fixture, "placements.bin")).byteLength / 32, + ).fill(Number.POSITIVE_INFINITY); + const world = await WorldCells.load({ + url: "/world/world.json", + surface, + follow: camera, + terrain: false, + ring: 4, + budgets, + gpuScene: false, + placementReach: reach, + admissionBudgetMs: 1, + loadModel: async () => model(), + }); + const bounds = vi.spyOn(Box3.prototype, "applyMatrix4"); + try { + const initial = world.readinessAt(camera); + expect(initial.requiredCells).toBeGreaterThan(2); + expect(initial.ready).toBe(false); + const scans = bounds.mock.calls.length; + expect(scans).toBeGreaterThan(0); + expect(world.readinessAt(camera)).toEqual(initial); + expect(bounds.mock.calls.length).toBe(scans); + world.update(); + await flush(); + world.update(); + expect(world.readinessAt(camera).ready).toBe(false); + for (let i = 0; i < 4000 && !world.readinessAt(camera).ready; i++) { + world.update(); + await flush(); + } + expect(world.readinessAt(camera)).toMatchObject({ + ready: true, + requiredCells: initial.requiredCells, + }); + reach.fill(1); + expect(world.readinessAt(camera).requiredCells).toBe(2); + reach.fill(Number.POSITIVE_INFINITY); + expect(world.readinessAt(camera).requiredCells).toBe(initial.requiredCells); + camera.far = 1; + camera.updateProjectionMatrix(); + expect(world.readinessAt(camera).requiredCells).toBe(0); + camera.far = 500; + camera.updateProjectionMatrix(); + expect(world.readinessAt(camera).requiredCells).toBe(initial.requiredCells); + const beforeCoordinateChange = bounds.mock.calls.length; + camera.coordinateSystem = WebGPUCoordinateSystem; + world.readinessAt(camera); + expect(bounds.mock.calls.length).toBeGreaterThan(beforeCoordinateChange); + camera.lookAt(at.x, 20, at.z + 400); + expect(world.readinessAt(camera).requiredCells).toBe(0); + camera.position.x += 1; + const changed = bounds.mock.calls.length; + world.readinessAt(camera); + expect(bounds.mock.calls.length).toBeGreaterThan(changed); + world.dispose(); + expect(world.readinessAt(camera)).toMatchObject({ ready: false, cancelled: true }); + } finally { + bounds.mockRestore(); + world.dispose(); + } + }); + + it("requires visible distant placements within their draw reach, while preserving circle queries", async () => { + stubFixtureFetch(); + const at = cellCenter(1, 1); + const camera = new PerspectiveCamera(60, 1, 0.1, 500); + camera.position.set(at.x, 20, at.z + 200); + camera.lookAt(at.x, 0, at.z); + camera.updateMatrixWorld(); + const world = await WorldCells.load({ + url: "/world/world.json", + surface, + follow: camera, + terrain: false, + ring: 4, + budgets, + gpuScene: false, + loadModel: async () => model(), + }); + const visible = world.readinessAt(camera); + expect(visible.requiredCells).toBeGreaterThan(1); + expect(visible.ready).toBe(false); + expect(world.readinessAt(at, 0).requiredCells).toBe(1); + camera.lookAt(at.x, 20, at.z + 400); + camera.updateMatrixWorld(); + expect(world.readinessAt(camera).requiredCells).toBe(0); + world.dispose(); + + const reached = await WorldCells.load({ + url: "/world/world.json", + surface, + follow: camera, + terrain: false, + ring: 4, + budgets, + gpuScene: false, + loadModel: async () => model(), + placementReach: new Float32Array( + readFileSync(path.join(fixture, "placements.bin")).byteLength / 32, + ).fill(1), + }); + camera.lookAt(at.x, 0, at.z); + camera.updateMatrixWorld(); + // Two fixture cells also contain chunks: placement reach must not hide those draws. + expect(reached.readinessAt(camera).requiredCells).toBe(2); + expect(reached.readinessAt(camera).requiredCells).toBeLessThan(visible.requiredCells); + reached.dispose(); + }); + + it("observes drawable runs before cell readiness, but excludes outside-view publications", async () => { + stubFixtureFetch(); + const at = cellCenter(1, 1); + const camera = new PerspectiveCamera(45, 1, 0.1, 300); + camera.position.set(at.x, 15, at.z + 40); + camera.lookAt(at.x, 0, at.z); + camera.updateMatrixWorld(); + const frustum = new Frustum().setFromProjectionMatrix( + new Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse), + ); + let published = 0; + let inView = 0; + let partial = false; + let world: WorldCells; + let elapsed = 0; + world = await WorldCells.load({ + url: "/world/world.json", + surface, + follow: followAt(at.x, at.z), + terrain: false, + ring: 1, + budgets, + gpuScene: false, + admissionBudgetMs: 1, + admissionNow: () => ++elapsed, + shadows: { cast: false }, + loadModel: async () => model(), + onPublication: (bounds) => { + // An allocated/staged mesh is insufficient: its instance records must be live. + expect([...drawn(world).values()].some((count) => count > 0)).toBe(true); + published++; + if (frustum.intersectsBox(bounds)) inView++; + }, + }); + world.update(); + await flush(); + // Placement slices and staged parts cannot yet draw or notify. + world.update(); + expect(published).toBe(0); + for (let frame = 0; frame < 4000; frame++) { + world.update(); + if (inView > 0 && !world.readinessAt(at, 0).ready) partial = true; + if (world.stats().admission.deferred === 0) break; + } + expect(partial).toBe(true); + expect(inView).toBeGreaterThan(0); + expect(published).toBeGreaterThan(inView); + world.dispose(); + }); + it("spends at most the budget plus one unit per update, however long the backlog", async () => { const BUDGET_MS = 2; const { world: cells } = await makeWorld({ admissionBudgetMs: BUDGET_MS, priced: true }); diff --git a/packages/core/__tests__/world-cells-data.spec.ts b/packages/core/__tests__/world-cells-data.spec.ts index fb0b44463d..0b9d7c3b17 100644 --- a/packages/core/__tests__/world-cells-data.spec.ts +++ b/packages/core/__tests__/world-cells-data.spec.ts @@ -444,10 +444,12 @@ it("submits a rotated offset model when its actual geometry intersects the camer } }); -it("rejects exact authored parts combined with whole-asset impostors before model admission", async () => { - const model = vi.fn(); - const promise = WorldCells.load({ +it("allows authored parts with terminal impostors on the CPU path", async () => { + const assets = createAssetLoader(); + vi.spyOn(assets, "resolve").mockResolvedValue([]); + const world = await WorldCells.load({ url: "world.json", + assets, surface: new MeshBasicMaterial(), follow: { position: new Vector3() }, terrain: false, @@ -457,15 +459,10 @@ it("rejects exact authored parts combined with whole-asset impostors before mode gpuScene: false, preserveAuthoredParts: true, impostors: true, - loadModel: model, + lodHysteresis: 0.2, + loadModel: async () => new Group(), }); - await expect( - promise.then((world) => { - world.dispose(); - return world; - }), - ).rejects.toThrow(/preserveAuthoredParts.*impostors/u); - expect(model).not.toHaveBeenCalled(); + world.dispose(); }); it("admits a queued run for the current camera rather than its obsolete enqueue position", async () => { @@ -694,3 +691,89 @@ it("runs an owed stationary-camera refilter after the last byte-identical public world.dispose(); } }); + +it("draws both authored levels in the dither band in view and shadow with one shared mask", async () => { + const assets = createAssetLoader(); + vi.spyOn(assets, "resolve").mockResolvedValue([]); + const follow = { position: new Vector3(32, 0, -28) }; + const surface = new MeshBasicMaterial(); + const world = await WorldCells.load({ + url: "world.json", + assets, + surface, + follow, + terrain: false, + ring: 10, + budgets: { residentCells: 4, instances: 4, bytes: 128 }, + data: { + manifest: { + ...manifest, + cells: [manifest.cells[0] as import("../src/world.js").IWorldCell], + assets: { + tree: { + ...(manifest.assets.tree as import("../src/world.js").IWorldAsset), + lods: [{ glb: "far.glb", distance: 60 }], + }, + }, + }, + placements: new Float32Array([32, 0, 32, 0, 0, 0, 1, 1]).buffer, + }, + gpuScene: false, + preserveAuthoredParts: true, + lodHysteresis: 0.2, + shadows: { cast: true, castLevels: 2 }, + loadModel: async () => new Group().add(new Mesh(new BoxGeometry(), surface)), + }); + try { + for (let frame = 0; frame < 100; frame++) { + world.update(); + await Promise.resolve(); + } + const drawn = [new Set(), new Set()]; + world.traverse((node) => { + if (!(node instanceof InstancedMesh)) return; + let live = 0; + for (let i = 0; i < node.count; i++) if (node.instanceMatrix.array[i * 16 + 15]) live++; + if (!live) return; + const material = node.material as import("three/webgpu").NodeMaterial; + expect(material.maskNode).toBeTruthy(); + expect(material.maskShadowNode).toBe(material.maskNode); + drawn[node.layers.mask & 1 ? 0 : 1]?.add(Number(node.name.split(":")[1])); + }); + expect([...(drawn[0] ?? [])].sort()).toEqual([0, 1]); + expect([...(drawn[1] ?? [])].sort()).toEqual([0, 1]); + follow.position.z = -168; + for (let frame = 0; frame < 100; frame++) { + world.update(); + await Promise.resolve(); + } + let nearLive = 0; + world.traverse((node) => { + if (!(node instanceof InstancedMesh) || node.name.split(":")[1] !== "0") return; + for (let i = 0; i < node.count; i++) if (node.instanceMatrix.array[i * 16 + 15]) nearLive++; + }); + expect(nearLive).toBe(0); + } finally { + world.dispose(); + } +}); + +it("keeps the unsupported authored GPU impostor guard before model admission", async () => { + const model = vi.fn(async () => new Group()); + await expect( + WorldCells.load({ + url: "world.json", + surface: new MeshBasicMaterial(), + follow: { position: new Vector3() }, + terrain: false, + ring: 1, + budgets: { residentCells: 4, instances: 4, bytes: 128 }, + data: { manifest, placements }, + gpuScene: true, + preserveAuthoredParts: true, + impostors: true, + loadModel: model, + }), + ).rejects.toThrow(/gpuScene:false/); + expect(model).not.toHaveBeenCalled(); +}); diff --git a/packages/core/__tests__/world-impostor-surface.spec.ts b/packages/core/__tests__/world-impostor-surface.spec.ts index 2bd16d04b5..b3bacb2fe0 100644 --- a/packages/core/__tests__/world-impostor-surface.spec.ts +++ b/packages/core/__tests__/world-impostor-surface.spec.ts @@ -1,13 +1,18 @@ import { fileURLToPath } from "node:url"; import { + BoxGeometry, DoubleSide, + Group, InstancedBufferAttribute, InstancedInterleavedBuffer, + Matrix4, + Mesh, MeshPhysicalMaterial, MeshStandardMaterial, Texture, Vector3, } from "three"; +import { uniform } from "three/tsl"; import { MeshStandardNodeMaterial, NodeUpdateType } from "three/webgpu"; import ts from "typescript"; import { describe, expect, it, vi } from "vitest"; @@ -18,6 +23,7 @@ import { impostorFrameUv, instanceSyncEvent, syncInstanceRanges, + worldLodMaterial, } from "../src/render/world-impostor-surface.js"; import { IMPOSTOR_VIEWS, @@ -374,3 +380,67 @@ describe("covered continuous depth geometry controls", () => { expect(coveredCrossing((t) => ({ alpha: 1, gap: t > 0 ? 1 : -1 }))).toBeNull(); }); }); + +it.each([MeshStandardMaterial, MeshStandardNodeMaterial])( + "partitions every dither pixel with opposite comparisons in both render passes", + (MaterialClass) => { + const eye = uniform(new Vector3()); + const source = new MaterialClass({ alphaTest: 0.42 }); + const near = worldLodMaterial(source, new Matrix4(), [0, 60], 0, 0.2, eye, uniform(1), false); + const far = worldLodMaterial(source, new Matrix4(), [0, 60], 1, 0.2, eye, uniform(1), false); + expect(near.alphaTest).toBe(source.alphaTest); + expect(far.alphaTest).toBe(source.alphaTest); + const comparisons = (material: typeof near) => { + const operators: string[] = []; + material.maskNode?.traverse((node) => { + const op = Reflect.get(node, "op"); + if (op === "<" || op === ">=") operators.push(op); + }); + return operators; + }; + expect(comparisons(near)).toEqual([">="]); + expect(comparisons(far)).toEqual(["<"]); + expect(near.maskShadowNode).toBe(near.maskNode); + expect(far.maskShadowNode).toBe(far.maskNode); + // Includes the exact equality boundary: using <= on either side would double one pixel. + for (const mix of [0, 0.001, 0.25, 0.5, 0.75, 0.999, 1]) { + for (let pixel = 0; pixel < 1024; pixel++) { + const noise = pixel / 1024; + expect(Number(noise >= mix) + Number(noise < mix)).toBe(1); + } + } + near.dispose(); + far.dispose(); + source.dispose(); + }, +); + +it("Strata shadow cutout uses the view sampled alpha and mip-adjusted threshold", async () => { + const { loadPack } = await import("../../../examples/strata-terrain-preview/src/render/pack.js"); + const { createAssetLoader } = await import("../src/assets.js"); + const assets = createAssetLoader(); + vi.spyOn(assets, "resolve").mockResolvedValue([]); + vi.spyOn(assets, "texture").mockRejectedValue(new Error("optional texture absent")); + const source = new MeshStandardMaterial({ map: new Texture(), alphaTest: 0.42 }); + vi.spyOn(assets, "model").mockImplementation(async (path) => { + if (path !== "temperate/spruce/0.glb") throw new Error("optional model absent"); + return { scene: new Group().add(new Mesh(new BoxGeometry(), source)) } as never; + }); + const pack = await loadPack(assets); + try { + const material = pack.parts.get("spruce:0")?.[0]?.material as MeshStandardNodeMaterial; + expect(material).toBeTruthy(); + const wrapped = material.maskShadowNode as unknown as { + node: { op: string; aNode: unknown; bNode: unknown }; + }; + const mask = wrapped.node; + expect(mask.op).toBe(">"); + expect(mask.aNode).toBe(material.opacityNode); + expect(mask.bNode).toBe(material.alphaTestNode); + expect(material.alphaTest).toBe(0.42); + expect(material.userData.tnImpostorAlphaTest).toBe(0.42); + } finally { + pack.dispose(); + source.dispose(); + } +}); diff --git a/packages/core/__tests__/world-impostors.spec.ts b/packages/core/__tests__/world-impostors.spec.ts index 49dff6a4b7..aae4378df6 100644 --- a/packages/core/__tests__/world-impostors.spec.ts +++ b/packages/core/__tests__/world-impostors.spec.ts @@ -52,7 +52,11 @@ import { IMPOSTOR_FAR_CULL_ATTRIBUTE, WorldImpostorSurface, } from "../src/render/world-impostor-surface.js"; -import { type IImpostorRawRenderer, WorldImpostorAtlas } from "../src/render/world-impostor.js"; +import { + type IImpostorRawRenderer, + WorldImpostorAtlas, + WorldImpostorBaker, +} from "../src/render/world-impostor.js"; import type { IRendererLike } from "../src/renderer.js"; import { type IKernelInput, WorldGpuScene, cullAndSelect } from "../src/world-gpu-scene.js"; import { type IWorldAsset, type IWorldRun, cellPlacements } from "../src/world-package.js"; @@ -88,7 +92,11 @@ function farMatrixRoute(mesh: InstancedMesh) { const material = mesh.material as NodeMaterial; const body = ( material.positionNode as unknown as { - node: { shaderNode: { jsFunc: (inputs: unknown, builder: { object: Object3D }) => Node } }; + node: { + shaderNode: { + jsFunc: (inputs: unknown, builder: { object: Object3D; nodes: Set }) => Node; + }; + }; } ).node.shaderNode.jsFunc; const previous = getCurrentStack(); @@ -106,7 +114,7 @@ function farMatrixRoute(mesh: InstancedMesh) { }; setCurrentStack(scope); try { - body([], { object: mesh }).traverse(collect); + body([], { object: mesh, nodes: new Set() }).traverse(collect); scope.traverse(collect); } finally { setCurrentStack(previous); @@ -516,6 +524,88 @@ afterEach(() => { }); describe("WorldCells automatic impostors", () => { + it("keeps authored parts and draws a terminal impostor beyond the game far boundary", async () => { + const pkg = pineOnly(); + const definition = pkg.assets.pine as IWorldAsset; + const data = { ...pkg, assets: { pine: { ...definition, impostorDistance: 150 } } }; + const placements = new Float32Array([-32, 0, -32, 0, 0, 0, 1, 1]); + const one = { + ...data, + cells: [{ x: 1, z: 1, runs: [{ asset: "pine", offset: 0, count: 1 }] }], + }; + stubPlacementFetch(one, placements); + const follow = { position: { x: -32, z: -32 } }; + const loader = treeLoader(); + const world = await loadPine(loader, { + follow, + terrain: false, + ring: 10, + gpuScene: false, + preserveAuthoredParts: true, + impostors: true, + lodHysteresis: 0.2, + adaptiveLod: false, + }); + try { + await flushed(world); + runBake(world, rendererStub()); + await flushed(world); + expect(world.stats().impostor.assets).toBe(1); + expect(world.stats().impostor.pending).toBe(0); + expect(partsOf(world, "pine", 0, 0).length).toBeGreaterThan(0); + expect(partsOf(world, "pine", 0, 1).length).toBeGreaterThan(0); + follow.position.z = -232; + for (let i = 0; i < 100; i++) { + world.update(); + await flush(); + } + expect(world.stats().impostor.drawn).toBeGreaterThan(0); + expect(partsOf(world, "pine", 2, 0)[0]?.geometry.index?.count).toBe(6); + expect(partsOf(world, "pine", 2, 1)).toHaveLength(0); + } finally { + world.dispose(); + } + }); + + it.each([false, true])( + "drains distinct queued atlases in one covered update (in-flight: %s)", + async (inFlight) => { + stubManifestFetch(manyAlphaAssets(2)); + const world = await loadPine(treeLoader()); + try { + await flushed(world); + const renderer = rendererStub(); + const capture = vi.spyOn(renderer.raw as IImpostorRawRenderer, "render"); + if (inFlight) { + world.process(renderer, undefined, false); + expect(capture).toHaveBeenCalledTimes(1); + } + expect(world.stats().impostor.pending).toBe(2); + world.process(renderer, undefined, true); + expect(capture).toHaveBeenCalledTimes(32); + expect(world.stats().impostor.assets).toBe(2); + expect(world.stats().impostor.pending).toBe(0); + } finally { + world.dispose(); + } + }, + ); + + it("yields a covered bake when a view makes no progress", async () => { + stubManifestFetch(pineOnly()); + const world = await loadPine(treeLoader()); + try { + await flushed(world); + const step = vi.spyOn(WorldImpostorBaker.prototype, "step").mockReturnValue(undefined); + world.process(rendererStub(), undefined, true); + expect(step).toHaveBeenCalledTimes(1); + expect(world.stats().impostor.assets).toBe(0); + expect(world.stats().impostor.pending).toBeGreaterThan(0); + } finally { + world.dispose(); + } + }); + it("bakes an alpha-cutout asset over the render cadence and appends a two-triangle terminal level", async () => { stubManifestFetch(pineOnly()); const loader = treeLoader(); diff --git a/packages/core/__tests__/world-lod-input-budget.spec.ts b/packages/core/__tests__/world-lod-input-budget.spec.ts new file mode 100644 index 0000000000..f9d7707d04 --- /dev/null +++ b/packages/core/__tests__/world-lod-input-budget.spec.ts @@ -0,0 +1,88 @@ +import { + BoxGeometry, + DynamicDrawUsage, + Float32BufferAttribute, + InstancedMesh, + Matrix4, + MeshStandardMaterial, + Vector3, +} from "three"; +import { Fn, attribute, uniform, vec4 } from "three/tsl"; +import { type Node, NodeFrame, WGSLNodeBuilder } from "three/webgpu"; +import { expect, it } from "vitest"; +import { worldLodMaterial } from "../src/render/world-impostor-surface.js"; + +it.each([ + { pass: "maskNode", dynamic: false }, + { pass: "maskShadowNode", dynamic: false }, + { pass: "maskNode", dynamic: true }, + { pass: "maskShadowNode", dynamic: true }, +] as const)( + "keeps $pass inside the real WGSL input budget (dynamic=$dynamic)", + ({ pass, dynamic }) => { + const material = worldLodMaterial( + new MeshStandardMaterial(), + new Matrix4(), + [0, 60], + 0, + 0.2, + uniform(new Vector3()), + uniform(1), + false, + ); + const mesh = new InstancedMesh(new BoxGeometry(), material, 2); + if (dynamic) mesh.instanceMatrix.setUsage(DynamicDrawUsage); + for (let i = 0; i < 10; i++) + mesh.geometry.setAttribute(`extra${i}`, new Float32BufferAttribute(new Float32Array(24), 1)); + let origin: Node | undefined; + material[pass]?.traverse((node) => { + if (Reflect.get(node, "isVaryingNode") === true) origin = node; + }); + if (!origin) throw new Error("LOD origin varying missing"); + const builder = new WGSLNodeBuilder(mesh, { + backend: { capabilities: { getUniformBufferLimit: () => 65536 } }, + getMRT: () => null, + hasFeature: () => false, + } as never) as WGSLNodeBuilder & { + nodes: Set; + updateBeforeNodes: Node[]; + buildUpdateNodes(): void; + setShaderStage(stage: string): void; + flowStagesNode(node: Node, output: string): void; + getAttributes(stage: string): string; + }; + builder.setShaderStage("vertex"); + builder.flowStagesNode( + Fn((_inputs: unknown, builder: WGSLNodeBuilder) => { + Reflect.get(material, "setupPosition").call(material, builder); + let value: Node<"vec4"> = vec4(origin as Node<"vec3">, 1); + for (let i = 0; i < 10; i++) value = value.add(attribute(`extra${i}`, "float")); + return value; + })(), + "vec4", + ); + const declarations = builder.getAttributes("vertex"); + expect(declarations.match(/@location/g)?.length).toBeLessThanOrEqual(16); + expect(declarations.match(/: vec4/g)).toHaveLength(4); + const matrixColumns = [...builder.nodes].filter( + (node) => Reflect.get(node, "value")?.array === mesh.instanceMatrix.array, + ); + expect(matrixColumns).toHaveLength(4); + builder.buildUpdateNodes(); + mesh.setMatrixAt(1, new Matrix4().makeTranslation(23, 4, -7)); + mesh.instanceMatrix.addUpdateRange(16, 16); + mesh.instanceMatrix.needsUpdate = true; + const frame = new NodeFrame(); + frame.frameId = 1; + frame.object = mesh; + for (const node of builder.updateBeforeNodes) frame.updateBeforeNode(node); + for (const column of matrixColumns) { + const buffer = Reflect.get(column, "attribute").data; + expect(buffer.array).toBe(mesh.instanceMatrix.array); + expect(buffer.version).toBe(mesh.instanceMatrix.version); + expect(buffer.updateRanges).toEqual(mesh.instanceMatrix.updateRanges); + } + material.dispose(); + mesh.geometry.dispose(); + }, +); diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index d84efdd9c0..e6d052f14a 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -431,6 +431,22 @@ "supersedes": [], "symbol": "texturePass" }, + { + "aliases": [], + "constraints": [ + "mutates the supplied document; rejects unsupported MASK topology and invalid ratios" + ], + "example": "thinFoliageCards(document, 0.14);", + "importPath": "@threenative/assets", + "kind": "function", + "overrides": [], + "package": "@threenative/assets", + "signature": "export function thinFoliageCards(document: Document, keepRatio: number): number { … }", + "situations": ["prepare a far foliage mesh by thinning whole cards instead of triangles"], + "summary": "Build a standalone card-thinned foliage mesh, preserving opaque parts and vertex attributes.", + "supersedes": [], + "symbol": "thinFoliageCards" + }, { "aliases": [], "constraints": [ diff --git a/packages/core/patches/three@0.185.1-prd269-upgrade.patch b/packages/core/patches/three@0.185.1-prd269-upgrade.patch index 2531906954..84f3ff12db 100644 --- a/packages/core/patches/three@0.185.1-prd269-upgrade.patch +++ b/packages/core/patches/three@0.185.1-prd269-upgrade.patch @@ -18369,3 +18369,90 @@ index 0fbcc307d39cbf6d74d9dbc5f199f2b0e4f58b33..add7f3284d411fd3eb188c2568775595 } +diff --git a/build/three.webgpu.js b/build/three.webgpu.js +index f9ea33ac39db20e96822fda6b90e2bed02817435..1071a6d91ee953f1fcc6769d0b6ba5230b67bfa1 100644 +--- a/build/three.webgpu.js ++++ b/build/three.webgpu.js +@@ -18581,10 +18581,13 @@ + + const instancedMesh = builder.object; + +- const updatePrevious = ( object, array ) => { ++ const updatePrevious = ( object, array, scheduled = false ) => { + +- const { previousInstanceMatrix } = _previousInstanceMatrices.get( object ); ++ const data = _previousInstanceMatrices.get( object ); ++ if ( scheduled && data.scheduledPrevious === array ) return; ++ const { previousInstanceMatrix } = data; + previousInstanceMatrix.array.set( array ); ++ data.scheduledPrevious = scheduled ? array : undefined; + previousInstanceMatrix.needsUpdate = true; + const interleaved = _matrixBuffers.get( previousInstanceMatrix ); + if ( interleaved !== undefined ) interleaved.needsUpdate = true; +@@ -18595,7 +18598,7 @@ + OnBeforeObjectUpdate( ( { object } ) => { + + const scheduledPrevious = object[ Symbol.for( 'threenative.velocity.previousInstanceMatrices' ) ]; +- if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious ); ++ if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious, true ); + + } ); + +diff --git a/build/three.webgpu.nodes.js b/build/three.webgpu.nodes.js +index 93cedce745291edfbd333fb76d8154b8eff685bd..f656265a47ffb6034da0c6ce768264762096b197 100644 +--- a/build/three.webgpu.nodes.js ++++ b/build/three.webgpu.nodes.js +@@ -18581,10 +18581,13 @@ + + const instancedMesh = builder.object; + +- const updatePrevious = ( object, array ) => { ++ const updatePrevious = ( object, array, scheduled = false ) => { + +- const { previousInstanceMatrix } = _previousInstanceMatrices.get( object ); ++ const data = _previousInstanceMatrices.get( object ); ++ if ( scheduled && data.scheduledPrevious === array ) return; ++ const { previousInstanceMatrix } = data; + previousInstanceMatrix.array.set( array ); ++ data.scheduledPrevious = scheduled ? array : undefined; + previousInstanceMatrix.needsUpdate = true; + const interleaved = _matrixBuffers.get( previousInstanceMatrix ); + if ( interleaved !== undefined ) interleaved.needsUpdate = true; +@@ -18595,7 +18598,7 @@ + OnBeforeObjectUpdate( ( { object } ) => { + + const scheduledPrevious = object[ Symbol.for( 'threenative.velocity.previousInstanceMatrices' ) ]; +- if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious ); ++ if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious, true ); + + } ); + +diff --git a/src/nodes/accessors/Instance.js b/src/nodes/accessors/Instance.js +index 18e2b7943393fd07b7989b1e3a5231a61ca24187..faf500ca41898100d46b56ce322ddee86ef8de2a 100644 +--- a/src/nodes/accessors/Instance.js ++++ b/src/nodes/accessors/Instance.js +@@ -218,10 +218,13 @@ + + const instancedMesh = builder.object; + +- const updatePrevious = ( object, array ) => { ++ const updatePrevious = ( object, array, scheduled = false ) => { + +- const { previousInstanceMatrix } = _previousInstanceMatrices.get( object ); ++ const data = _previousInstanceMatrices.get( object ); ++ if ( scheduled && data.scheduledPrevious === array ) return; ++ const { previousInstanceMatrix } = data; + previousInstanceMatrix.array.set( array ); ++ data.scheduledPrevious = scheduled ? array : undefined; + previousInstanceMatrix.needsUpdate = true; + const interleaved = _matrixBuffers.get( previousInstanceMatrix ); + if ( interleaved !== undefined ) interleaved.needsUpdate = true; +@@ -232,7 +235,7 @@ + OnBeforeObjectUpdate( ( { object } ) => { + + const scheduledPrevious = object[ Symbol.for( 'threenative.velocity.previousInstanceMatrices' ) ]; +- if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious ); ++ if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious, true ); + + } ); + diff --git a/packages/core/patches/three@0.185.1.patch b/packages/core/patches/three@0.185.1.patch index bf70361b19..cd4c2d008b 100644 --- a/packages/core/patches/three@0.185.1.patch +++ b/packages/core/patches/three@0.185.1.patch @@ -25,7 +25,7 @@ index 0bb9262cd029f411933d077fd44197a51ec5e8e9..868a634b4294be5f32664912a50813d5 * Constructs a new batched mesh. * diff --git a/build/three.webgpu.js b/build/three.webgpu.js -index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed02817435 100644 +index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..1071a6d91ee953f1fcc6769d0b6ba5230b67bfa1 100644 --- a/build/three.webgpu.js +++ b/build/three.webgpu.js @@ -141,6 +141,15 @@ class NodeMaterialObserver { @@ -323,15 +323,18 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( interleavedMatrix !== null ) { -@@ -18496,11 +18581,28 @@ const instance = /*@__PURE__*/ Fn( ( [ matrices, colors = null ], builder ) => { +@@ -18496,11 +18581,31 @@ const instance = /*@__PURE__*/ Fn( ( [ matrices, colors = null ], builder ) => { const instancedMesh = builder.object; - OnObjectUpdate( ( { object } ) => { -+ const updatePrevious = ( object, array ) => { ++ const updatePrevious = ( object, array, scheduled = false ) => { + -+ const { previousInstanceMatrix } = _previousInstanceMatrices.get( object ); ++ const data = _previousInstanceMatrices.get( object ); ++ if ( scheduled && data.scheduledPrevious === array ) return; ++ const { previousInstanceMatrix } = data; + previousInstanceMatrix.array.set( array ); ++ data.scheduledPrevious = scheduled ? array : undefined; + previousInstanceMatrix.needsUpdate = true; + const interleaved = _matrixBuffers.get( previousInstanceMatrix ); + if ( interleaved !== undefined ) interleaved.needsUpdate = true; @@ -344,7 +347,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed - previousInstanceData.previousInstanceMatrix.array.set( matrices.array ); + const scheduledPrevious = object[ Symbol.for( 'threenative.velocity.previousInstanceMatrices' ) ]; -+ if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious ); ++ if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious, true ); + + } ); + @@ -355,7 +358,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } ); -@@ -18599,6 +18701,7 @@ const batch = /*@__PURE__*/ Fn( ( [ batchMesh ], builder ) => { +@@ -18599,6 +18704,7 @@ const batch = /*@__PURE__*/ Fn( ( [ batchMesh ], builder ) => { const indirectId = getIndirectIndex( batchMesh._indirectTexture, int( batchingIdNode ) ); const matricesTexture = batchMesh._matricesTexture; @@ -363,7 +366,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const size = int( textureSize( textureLoad( matricesTexture ), 0 ).x ).toConst(); const j = float( indirectId ).mul( 4 ).toInt().toConst(); -@@ -18626,6 +18729,19 @@ const batch = /*@__PURE__*/ Fn( ( [ batchMesh ], builder ) => { +@@ -18626,6 +18732,19 @@ const batch = /*@__PURE__*/ Fn( ( [ batchMesh ], builder ) => { positionLocal.assign( batchingMatrix.mul( positionLocal ) ); @@ -383,7 +386,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const transformedNormal = normalLocal.div( vec3( bm[ 0 ].dot( bm[ 0 ] ), bm[ 1 ].dot( bm[ 1 ] ), bm[ 2 ].dot( bm[ 2 ] ) ) ); const batchingNormal = bm.mul( transformedNormal ).xyz; -@@ -18745,7 +18861,15 @@ function getPreviousSkinnedPosition( skinnedMesh, bindMatrixNode, bindMatrixInve +@@ -18745,7 +18864,15 @@ function getPreviousSkinnedPosition( skinnedMesh, bindMatrixNode, bindMatrixInve } @@ -400,7 +403,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -18777,7 +18901,8 @@ const skinning = /*@__PURE__*/ Fn( ( [ skinnedMesh ], builder ) => { +@@ -18777,7 +18904,8 @@ const skinning = /*@__PURE__*/ Fn( ( [ skinnedMesh ], builder ) => { if ( skeletonData !== undefined ) { @@ -410,7 +413,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -18844,7 +18969,8 @@ const computeSkinning = /*@__PURE__*/ Fn( ( [ skinnedMesh, toPosition = null ], +@@ -18844,7 +18972,8 @@ const computeSkinning = /*@__PURE__*/ Fn( ( [ skinnedMesh, toPosition = null ], if ( state !== undefined ) { @@ -420,7 +423,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -29911,6 +30037,18 @@ class RenderObject { +@@ -29911,6 +30040,18 @@ class RenderObject { */ this._sourceMaterial = renderer._currentSourceMaterial; @@ -439,7 +442,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * An event listener which is defined by `RenderObjects`. It performs * clean up tasks when `dispose()` on this render object. -@@ -30098,7 +30236,7 @@ class RenderObject { +@@ -30098,7 +30239,7 @@ class RenderObject { */ getChainArray() { @@ -448,7 +451,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -30283,7 +30421,7 @@ class RenderObject { +@@ -30283,7 +30424,7 @@ class RenderObject { * * @return {string} The geometry cache key. */ @@ -457,7 +460,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const { geometry } = this; -@@ -30311,12 +30449,14 @@ class RenderObject { +@@ -30311,12 +30452,14 @@ class RenderObject { const targets = geometry.morphAttributes[ name ]; cacheKey += 'morph-' + name + ','; @@ -473,7 +476,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -30548,6 +30688,7 @@ class RenderObject { +@@ -30548,6 +30691,7 @@ class RenderObject { } cacheKey = hash$1( cacheKey, this.renderer.contextNode.id, this.renderer.contextNode.version ); @@ -481,7 +484,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed return cacheKey; -@@ -30680,6 +30821,8 @@ class RenderObjects { +@@ -30680,6 +30824,8 @@ class RenderObjects { _chainKeys$3[ 1 ] = material; _chainKeys$3[ 2 ] = renderContext; _chainKeys$3[ 3 ] = lightsNode; @@ -490,7 +493,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // -@@ -30707,9 +30850,18 @@ class RenderObjects { +@@ -30707,9 +30853,18 @@ class RenderObjects { } @@ -511,7 +514,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed renderObject.dispose(); -@@ -30719,6 +30871,8 @@ class RenderObjects { +@@ -30719,6 +30874,8 @@ class RenderObjects { renderObject.version = material.version; @@ -520,7 +523,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } } -@@ -30731,6 +30885,7 @@ class RenderObjects { +@@ -30731,6 +30888,7 @@ class RenderObjects { _chainKeys$3[ 1 ] = null; _chainKeys$3[ 2 ] = null; _chainKeys$3[ 3 ] = null; @@ -528,7 +531,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // -@@ -31165,6 +31320,14 @@ class Geometries extends DataMap { +@@ -31165,6 +31323,14 @@ class Geometries extends DataMap { */ this._geometryDisposeListeners = new Map(); @@ -543,7 +546,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } /** -@@ -31190,10 +31353,69 @@ class Geometries extends DataMap { +@@ -31190,10 +31356,69 @@ class Geometries extends DataMap { if ( this.has( renderObject ) === false ) this.initGeometry( renderObject ); @@ -613,7 +616,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * Initializes the geometry of the given render object. * -@@ -32771,7 +32993,7 @@ class Bindings extends DataMap { +@@ -32771,7 +32996,7 @@ class Bindings extends DataMap { */ updateForRender( renderObject ) { @@ -622,7 +625,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -32918,12 +33140,13 @@ class Bindings extends DataMap { +@@ -32918,12 +33143,13 @@ class Bindings extends DataMap { * Updates the given array of bindings. * * @param {Array} bindings - The bind groups. @@ -638,7 +641,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -32934,11 +33157,27 @@ class Bindings extends DataMap { +@@ -32934,11 +33160,27 @@ class Bindings extends DataMap { * * @param {BindGroup} bindGroup - The bind group to update. * @param {Array} bindings - The bind groups. @@ -667,7 +670,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed let needsBindingsUpdate = false; let cacheBindings = true; let cacheIndex = 0; -@@ -32948,7 +33187,7 @@ class Bindings extends DataMap { +@@ -32948,7 +33190,7 @@ class Bindings extends DataMap { for ( const binding of bindGroup.bindings ) { @@ -676,7 +679,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // every uniforms group is a uniform buffer. So if no update is required, // we move one with the next binding. Otherwise the next if block will update the group. -@@ -33952,7 +34191,8 @@ class RenderContexts { +@@ -33952,7 +34194,8 @@ class RenderContexts { } @@ -686,7 +689,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed renderState.clearDepthValue = this.renderer.getClearDepth(); renderState.clearStencilValue = this.renderer.getClearStencil(); -@@ -34020,6 +34260,10 @@ class Textures extends DataMap { +@@ -34020,6 +34263,10 @@ class Textures extends DataMap { * @type {Set} */ this._htmlTextures = new Set(); @@ -697,7 +700,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -34169,7 +34413,16 @@ class Textures extends DataMap { +@@ -34169,7 +34416,16 @@ class Textures extends DataMap { */ updateTexture( texture, options = {} ) { @@ -714,7 +717,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( textureData.initialized === true && textureData.version === texture.version ) return; const isRenderTarget = texture.isRenderTargetTexture || texture.isDepthTexture || texture.isFramebufferTexture; -@@ -34181,6 +34434,21 @@ class Textures extends DataMap { +@@ -34181,6 +34437,21 @@ class Textures extends DataMap { backend.destroyTexture( texture ); @@ -736,7 +739,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } // -@@ -34318,7 +34586,16 @@ class Textures extends DataMap { +@@ -34318,7 +34589,16 @@ class Textures extends DataMap { } @@ -754,7 +757,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const skipAutoGeneration = texture.isStorageTexture === true && texture.mipmapsAutoUpdate === false; -@@ -34328,7 +34605,7 @@ class Textures extends DataMap { +@@ -34328,7 +34608,7 @@ class Textures extends DataMap { } @@ -763,7 +766,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -34345,6 +34622,17 @@ class Textures extends DataMap { +@@ -34345,6 +34625,17 @@ class Textures extends DataMap { } @@ -781,7 +784,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // dispose handler if ( textureData.initialized !== true ) { -@@ -34377,9 +34665,134 @@ class Textures extends DataMap { +@@ -34377,9 +34668,134 @@ class Textures extends DataMap { } @@ -918,7 +921,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -34557,6 +34970,8 @@ class Textures extends DataMap { +@@ -34557,6 +34973,8 @@ class Textures extends DataMap { */ _destroyTexture( texture ) { @@ -927,7 +930,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( this.has( texture ) === true ) { const textureData = this.get( texture ); -@@ -39725,7 +40140,8 @@ class VelocityNode extends TempNode { +@@ -39725,7 +40143,8 @@ class VelocityNode extends TempNode { const previousModelMatrix = getPreviousMatrix( object ); @@ -937,7 +940,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // -@@ -40270,7 +40686,7 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40270,7 +40689,7 @@ class PassMultipleTextureNode extends PassTextureNode { * @param {string} textureName - The output texture name. * @param {boolean} [previousTexture=false] - Whether previous frame data should be used or not. */ @@ -946,7 +949,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // null is passed to the super call since this class does not // use an external texture for rendering pass data into. Instead -@@ -40291,6 +40707,8 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40291,6 +40710,8 @@ class PassMultipleTextureNode extends PassTextureNode { * @type {boolean} */ this.previousTexture = previousTexture; @@ -955,7 +958,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * This flag can be used for type testing. -@@ -40312,17 +40730,33 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40312,17 +40733,33 @@ class PassMultipleTextureNode extends PassTextureNode { } @@ -990,7 +993,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed newNode.uvNode = this.uvNode; newNode.levelNode = this.levelNode; newNode.biasNode = this.biasNode; -@@ -40503,6 +40937,8 @@ class PassNode extends TempNode { +@@ -40503,6 +40940,8 @@ class PassNode extends TempNode { * @type {Object} */ this._textureNodes = {}; @@ -999,7 +1002,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * A dictionary holding the internal depth nodes. -@@ -40809,6 +41245,7 @@ class PassNode extends TempNode { +@@ -40809,6 +41248,7 @@ class PassNode extends TempNode { this._previousTextures[ name ] = texture; this._textureNodes[ name ].updateTexture(); @@ -1007,7 +1010,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed this._previousTextureNodes[ name ].updateTexture(); } -@@ -40821,6 +41258,17 @@ class PassNode extends TempNode { +@@ -40821,6 +41261,17 @@ class PassNode extends TempNode { * @param {string} [name='output'] - The output name to get the texture node for. * @return {TextureNode} The texture node. */ @@ -1025,7 +1028,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed getTextureNode( name = 'output' ) { let textureNode = this._textureNodes[ name ]; -@@ -41163,6 +41611,7 @@ class PassNode extends TempNode { +@@ -41163,6 +41614,7 @@ class PassNode extends TempNode { */ dispose() { @@ -1033,7 +1036,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed this.renderTarget.dispose(); } -@@ -44431,6 +44880,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ +@@ -44431,6 +44883,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ const shadowMaterialLib = /*@__PURE__*/ new WeakMap(); @@ -1080,7 +1083,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * A shadow filtering function performing basic filtering. This is in fact an unfiltered version of the shadow map * with a binary `[0,1]` result. -@@ -44485,8 +44974,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44485,8 +44977,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, }; @@ -1091,7 +1094,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const texelSize = vec2( 1 ).div( mapSize ); const radiusScaled = radius.mul( texelSize.x ); -@@ -44517,7 +45006,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44517,7 +45009,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, */ const PCFSoftShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, depthLayer } ) => { @@ -1100,7 +1103,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const texelSize = vec2( 1 ).div( mapSize ); -@@ -45586,8 +46075,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) +@@ -45586,8 +46078,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) */ const PointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp, shadow } ) => { @@ -1111,7 +1114,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const texelSize = radius.div( mapSize.x ); -@@ -45623,9 +46112,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo +@@ -45623,9 +46115,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo const shadowPositionAbs = shadowPosition.abs().toConst(); const viewZ = shadowPositionAbs.x.max( shadowPositionAbs.y ).max( shadowPositionAbs.z ); @@ -1124,7 +1127,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const result = float( 1.0 ).toVar(); -@@ -53841,7 +54330,7 @@ class NodeBuilder { +@@ -53841,7 +54333,7 @@ class NodeBuilder { const mrt = this.renderer.getMRT(); @@ -1133,7 +1136,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -55244,15 +55733,17 @@ class NodeManager extends DataMap { +@@ -55244,15 +55736,17 @@ class NodeManager extends DataMap { * Returns `true` if the given node uniforms group must be updated or not. * * @param {NodeUniformsGroup} nodeUniformsGroup - The node uniforms group. @@ -1154,7 +1157,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // check for update -@@ -56056,6 +56547,21 @@ class NodeManager extends DataMap { +@@ -56056,6 +56550,21 @@ class NodeManager extends DataMap { } @@ -1176,7 +1179,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * Triggers the call of `updateBefore()` methods * for all nodes of the given render object. -@@ -56065,9 +56571,14 @@ class NodeManager extends DataMap { +@@ -56065,9 +56574,14 @@ class NodeManager extends DataMap { updateBefore( renderObject ) { const nodeBuilder = renderObject.getNodeBuilderState(); @@ -1191,7 +1194,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // update frame state for each node this.getNodeFrameForRender( renderObject ).updateBeforeNode( node ); -@@ -56085,10 +56596,11 @@ class NodeManager extends DataMap { +@@ -56085,10 +56599,11 @@ class NodeManager extends DataMap { updateAfter( renderObject ) { const nodeBuilder = renderObject.getNodeBuilderState(); @@ -1204,7 +1207,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed this.getNodeFrameForRender( renderObject ).updateAfterNode( node ); -@@ -56125,9 +56637,14 @@ class NodeManager extends DataMap { +@@ -56125,9 +56640,14 @@ class NodeManager extends DataMap { const nodeFrame = this.getNodeFrameForRender( renderObject ); const nodeBuilder = renderObject.getNodeBuilderState(); @@ -1219,7 +1222,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed nodeFrame.updateNode( node ); } -@@ -60919,6 +61436,7 @@ class Renderer { +@@ -60919,6 +61439,7 @@ class Renderer { // renderContext.camera = camera; @@ -1227,7 +1230,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed this.backend.beginRender( renderContext ); // process render lists -@@ -60931,13 +61449,23 @@ class Renderer { +@@ -60931,13 +61452,23 @@ class Renderer { opaque: opaqueObjects } = renderList; @@ -1253,7 +1256,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // restore render tree -@@ -60952,6 +61480,9 @@ class Renderer { +@@ -60952,6 +61483,9 @@ class Renderer { this._callDepth --; @@ -1263,7 +1266,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( frameBufferTarget !== null ) { this.setRenderTarget( outputRenderTarget, activeCubeFace, activeMipmapLevel ); -@@ -61118,6 +61649,18 @@ class Renderer { +@@ -61118,6 +61652,18 @@ class Renderer { } @@ -1282,7 +1285,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * Can be used to transfer buffer data from a storage buffer attribute * from the GPU to the CPU in context of compute shaders. -@@ -62111,6 +62654,14 @@ class Renderer { +@@ -62111,6 +62657,14 @@ class Renderer { } @@ -1297,7 +1300,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * Initializes the given render target. * -@@ -62731,6 +63282,7 @@ class Renderer { +@@ -62731,6 +63285,7 @@ class Renderer { let materialDisplacementMap; let materialDisplacementScale; let materialDisplacementBias; @@ -1305,7 +1308,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const previousSourceMaterial = this._currentSourceMaterial; -@@ -62756,6 +63308,7 @@ class Renderer { +@@ -62756,6 +63311,7 @@ class Renderer { materialDisplacementMap = overrideMaterial.displacementMap; materialDisplacementScale = overrideMaterial.displacementScale; materialDisplacementBias = overrideMaterial.displacementBias; @@ -1313,7 +1316,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( material.positionNode && material.positionNode.isNode ) { -@@ -62763,7 +63316,15 @@ class Renderer { +@@ -62763,7 +63319,15 @@ class Renderer { } @@ -1330,7 +1333,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed overrideMaterial.alphaMap = material.alphaMap; overrideMaterial.displacementMap = material.displacementMap; overrideMaterial.displacementScale = material.displacementScale; -@@ -62825,6 +63386,7 @@ class Renderer { +@@ -62825,6 +63389,7 @@ class Renderer { scene.overrideMaterial.displacementMap = materialDisplacementMap; scene.overrideMaterial.displacementScale = materialDisplacementScale; scene.overrideMaterial.displacementBias = materialDisplacementBias; @@ -1338,7 +1341,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -62886,18 +63448,17 @@ class Renderer { +@@ -62886,18 +63451,17 @@ class Renderer { // @@ -1367,7 +1370,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed this._pipelines.updateForRender( renderObject ); -@@ -62907,7 +63468,7 @@ class Renderer { +@@ -62907,7 +63471,7 @@ class Renderer { this.backend.draw( renderObject, this.info ); @@ -1376,7 +1379,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -76397,6 +76958,9 @@ class WebGPUTextureUtils { +@@ -76397,6 +76961,9 @@ class WebGPUTextureUtils { */ this._samplerCache = new Map(); @@ -1386,7 +1389,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } /** -@@ -76613,7 +77177,12 @@ class WebGPUTextureUtils { +@@ -76613,7 +77180,12 @@ class WebGPUTextureUtils { } @@ -1400,7 +1403,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed usage |= GPUTextureUsage.RENDER_ATTACHMENT; -@@ -76686,6 +77255,23 @@ class WebGPUTextureUtils { +@@ -76686,6 +77258,23 @@ class WebGPUTextureUtils { destroyTexture( texture, isDefaultTexture = false ) { const backend = this.backend; @@ -1424,7 +1427,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const textureData = backend.get( texture ); if ( textureData.texture !== undefined && isDefaultTexture === false && texture.isExternalTexture !== true ) textureData.texture.destroy(); -@@ -76726,7 +77312,7 @@ class WebGPUTextureUtils { +@@ -76726,7 +77315,7 @@ class WebGPUTextureUtils { const colorTexture = canvasTarget.colorTexture; const colorTextureData = backend.get( colorTexture ); @@ -1433,7 +1436,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed return colorTextureData.texture; -@@ -76751,8 +77337,8 @@ class WebGPUTextureUtils { +@@ -76751,8 +77340,8 @@ class WebGPUTextureUtils { // @@ -1444,7 +1447,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed colorTexture.samples = samples; colorTextureData.texture = colorBuffer; -@@ -76775,64 +77361,59 @@ class WebGPUTextureUtils { +@@ -76775,64 +77364,59 @@ class WebGPUTextureUtils { const canvasTarget = backend.renderer.getCanvasTarget(); const { width, height } = backend.getDrawingBufferSize(); const samples = backend.renderer.currentSamples; @@ -1537,7 +1540,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * Uploads the updated texture data to the GPU. * -@@ -76987,6 +77568,143 @@ class WebGPUTextureUtils { +@@ -76987,6 +77571,143 @@ class WebGPUTextureUtils { } @@ -1681,7 +1684,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * Returns texture data as a typed array. * -@@ -77060,6 +77778,8 @@ class WebGPUTextureUtils { +@@ -77060,6 +77781,8 @@ class WebGPUTextureUtils { */ dispose() { @@ -1690,7 +1693,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed this._samplerCache.clear(); } -@@ -78986,7 +79706,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -78986,7 +79709,7 @@ class WGSLNodeBuilder extends NodeBuilder { * @param {?string} offsetSnippet - A WGSL snippet that represents the offset that will be applied to the unnormalized texture coordinate before sampling the texture. * @return {string} The WGSL snippet. */ @@ -1699,7 +1702,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( levelSnippet === null ) levelSnippet = '0u'; -@@ -79004,7 +79724,14 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79004,7 +79727,14 @@ class WGSLNodeBuilder extends NodeBuilder { } else { @@ -1715,7 +1718,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( this.renderer.backend.compatibilityMode && texture.isDepthTexture ) { -@@ -79545,7 +80272,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79545,7 +80275,7 @@ class WGSLNodeBuilder extends NodeBuilder { // Cube textures always need samplers (they use textureSampleLevel, not textureLoad) // Also textureGather always need sampler. @@ -1724,7 +1727,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( needsSampler ) { -@@ -79586,7 +80313,8 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79586,7 +80316,8 @@ class WGSLNodeBuilder extends NodeBuilder { uniformGPU = buffer; @@ -1734,7 +1737,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } else { -@@ -79974,7 +80702,8 @@ ${ flowData.code } +@@ -79974,7 +80705,8 @@ ${ flowData.code } for ( const { name, property, type } of builtins.values() ) { @@ -1744,7 +1747,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -80230,6 +80959,8 @@ ${ flowData.code } +@@ -80230,6 +80962,8 @@ ${ flowData.code } getVaryings( shaderStage ) { const snippets = []; @@ -1753,7 +1756,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( shaderStage === 'vertex' ) { -@@ -80252,7 +80983,12 @@ ${ flowData.code } +@@ -80252,7 +80986,12 @@ ${ flowData.code } let attributesSnippet = `@location( ${ varyingIndex ++ } )`; @@ -1767,7 +1770,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const samplingSnippet = varying.interpolationSampling !== null ? `, ${ varying.interpolationSampling } )` : ' )'; -@@ -80329,9 +81065,12 @@ ${ flowData.code } +@@ -80329,9 +81068,12 @@ ${ flowData.code } const textureNode = uniform.node; const texture = textureNode.value; @@ -1783,7 +1786,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( needsSampler ) { -@@ -80353,7 +81092,7 @@ ${ flowData.code } +@@ -80353,7 +81095,7 @@ ${ flowData.code } const { primarySamples } = backend.utils.getTextureSampleData( texture ); @@ -1792,7 +1795,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed multisampled = '_multisampled'; -@@ -81107,7 +81846,19 @@ class WebGPUAttributeUtils { +@@ -81107,7 +81849,19 @@ class WebGPUAttributeUtils { buffer.unmap(); @@ -1812,7 +1815,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -81128,6 +81879,13 @@ class WebGPUAttributeUtils { +@@ -81128,6 +81882,13 @@ class WebGPUAttributeUtils { const bufferData = backend.get( bufferAttribute ); const buffer = backend.get( bufferAttribute ).buffer; @@ -1826,7 +1829,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed let array = bufferAttribute.array; const itemSize = bufferData._itemSize; -@@ -81209,6 +81967,9 @@ class WebGPUAttributeUtils { +@@ -81209,6 +81970,9 @@ class WebGPUAttributeUtils { } @@ -1836,7 +1839,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } /** -@@ -81295,9 +82056,15 @@ class WebGPUAttributeUtils { +@@ -81295,9 +82059,15 @@ class WebGPUAttributeUtils { destroyAttribute( attribute ) { const backend = this.backend; @@ -1854,7 +1857,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed backend.delete( attribute ); -@@ -81598,18 +82365,16 @@ class WebGPUBindingUtils { +@@ -81598,18 +82368,16 @@ class WebGPUBindingUtils { const bindingsData = backend.get( bindGroup ); @@ -1878,7 +1881,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // try to find an existing layout in the cache -@@ -81682,6 +82447,7 @@ class WebGPUBindingUtils { +@@ -81682,6 +82450,7 @@ class WebGPUBindingUtils { } @@ -1886,7 +1889,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed bindingsData.group = bindGroupGPU; } -@@ -81701,6 +82467,7 @@ class WebGPUBindingUtils { +@@ -81701,6 +82470,7 @@ class WebGPUBindingUtils { const updateRanges = binding.updateRanges; @@ -1894,7 +1897,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( updateRanges.length === 0 ) { device.queue.writeBuffer( -@@ -81754,6 +82521,15 @@ class WebGPUBindingUtils { +@@ -81754,6 +82524,15 @@ class WebGPUBindingUtils { } @@ -1910,7 +1913,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } /** -@@ -81830,7 +82606,20 @@ class WebGPUBindingUtils { +@@ -81830,7 +82609,20 @@ class WebGPUBindingUtils { let resourceGPU; @@ -1932,7 +1935,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed resourceGPU = device.importExternalTexture( { source: textureData.externalTexture } ); -@@ -81904,6 +82693,12 @@ class WebGPUBindingUtils { +@@ -81904,6 +82696,12 @@ class WebGPUBindingUtils { } const bindGroupGPU = device.createBindGroup( _bindGroupDescriptor ); @@ -1945,7 +1948,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed _bindGroupDescriptor.reset(); -@@ -82001,7 +82796,7 @@ class WebGPUBindingUtils { +@@ -82001,7 +82799,7 @@ class WebGPUBindingUtils { const { primarySamples } = backend.utils.getTextureSampleData( binding.texture ); @@ -1954,7 +1957,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed texture.multisampled = true; -@@ -82039,7 +82834,7 @@ class WebGPUBindingUtils { +@@ -82039,7 +82837,7 @@ class WebGPUBindingUtils { } else if ( type === FloatType ) { @@ -1963,7 +1966,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed texture.sampleType = GPUTextureSampleType.Float; -@@ -82131,6 +82926,9 @@ class WebGPUBindingUtils { +@@ -82131,6 +82929,9 @@ class WebGPUBindingUtils { } @@ -1973,7 +1976,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } /** -@@ -82502,6 +83300,16 @@ class WebGPUPipelineUtils { +@@ -82502,6 +83303,16 @@ class WebGPUPipelineUtils { } @@ -1990,7 +1993,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // create pipeline device.pushErrorScope( 'validation' ); -@@ -82515,6 +83323,7 @@ class WebGPUPipelineUtils { +@@ -82515,6 +83326,7 @@ class WebGPUPipelineUtils { if ( promises === null ) { pipelineData.pipeline = device.createRenderPipeline( _renderPipelineDescriptor ); @@ -1998,7 +2001,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed _renderPipelineDescriptor.reset(); -@@ -82553,6 +83362,15 @@ class WebGPUPipelineUtils { +@@ -82553,6 +83365,15 @@ class WebGPUPipelineUtils { _renderPipelineDescriptor.reset(); @@ -2014,7 +2017,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( pipelinePromise !== null ) { try { -@@ -82567,7 +83385,7 @@ class WebGPUPipelineUtils { +@@ -82567,7 +83388,7 @@ class WebGPUPipelineUtils { } @@ -2023,7 +2026,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( errorScope !== null || asyncError !== null ) { -@@ -83814,6 +84632,27 @@ class WebGPUBackend extends Backend { +@@ -83814,6 +84635,27 @@ class WebGPUBackend extends Backend { */ this.compatibilityMode = null; @@ -2051,7 +2054,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * A reference to the device. * -@@ -83926,7 +84765,11 @@ class WebGPUBackend extends Backend { +@@ -83926,7 +84768,11 @@ class WebGPUBackend extends Backend { xrCompatible: renderer.xr.enabled }; @@ -2064,7 +2067,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( adapter === null ) { -@@ -84153,7 +84996,9 @@ class WebGPUBackend extends Backend { +@@ -84153,7 +84999,9 @@ class WebGPUBackend extends Backend { let descriptor = canvasData.descriptor; @@ -2075,7 +2078,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed descriptor = new GPURenderPassDescriptor(); descriptor.colorAttachments.push( new GPURenderPassColorAttachment() ); -@@ -84180,6 +85025,9 @@ class WebGPUBackend extends Backend { +@@ -84180,6 +85028,9 @@ class WebGPUBackend extends Backend { canvasData.descriptor = descriptor; canvasData.samples = samples; @@ -2085,7 +2088,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -84496,6 +85344,150 @@ class WebGPUBackend extends Backend { +@@ -84496,6 +85347,150 @@ class WebGPUBackend extends Backend { * * @param {RenderContext} renderContext - The render context. */ @@ -2236,7 +2239,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed beginRender( renderContext ) { const renderContextData = this.get( renderContext ); -@@ -84648,10 +85640,15 @@ class WebGPUBackend extends Backend { +@@ -84648,10 +85643,15 @@ class WebGPUBackend extends Backend { // @@ -2252,7 +2255,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // Layered render targets: prepare bundle encoders for each camera in the array camera. if ( this._isRenderCameraDepthArray( renderContext ) === true ) { -@@ -84945,6 +85942,8 @@ class WebGPUBackend extends Backend { +@@ -84945,6 +85945,8 @@ class WebGPUBackend extends Backend { } @@ -2261,7 +2264,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( occlusionQueryCount > 0 ) { const bufferSize = occlusionQueryCount * 8; // 8 byte entries for query results -@@ -84987,7 +85986,16 @@ class WebGPUBackend extends Backend { +@@ -84987,7 +85989,16 @@ class WebGPUBackend extends Backend { } @@ -2279,7 +2282,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // -@@ -85081,6 +86089,8 @@ class WebGPUBackend extends Backend { +@@ -85081,6 +86092,8 @@ class WebGPUBackend extends Backend { */ updateViewport( renderContext ) { @@ -2288,7 +2291,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const { currentPass } = this.get( renderContext ); const { x, y, width, height, minDepth, maxDepth } = renderContext.viewportValue; -@@ -85095,6 +86105,8 @@ class WebGPUBackend extends Backend { +@@ -85095,6 +86108,8 @@ class WebGPUBackend extends Backend { */ updateScissor( renderContext ) { @@ -2297,7 +2300,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const { currentPass } = this.get( renderContext ); const { x, y, width, height } = renderContext.scissorValue; -@@ -85461,13 +86473,12 @@ class WebGPUBackend extends Backend { +@@ -85461,13 +86476,12 @@ class WebGPUBackend extends Backend { for ( let i = 0, l = bindings.length; i < l; i ++ ) { const bindGroup = bindings[ i ]; @@ -2314,7 +2317,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -85593,6 +86604,11 @@ class WebGPUBackend extends Backend { +@@ -85593,6 +86607,11 @@ class WebGPUBackend extends Backend { } else { passEncoderGPU.draw( vertexCount, instanceCount, firstVertex, 0 ); @@ -2326,7 +2329,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -85628,6 +86644,19 @@ class WebGPUBackend extends Backend { +@@ -85628,6 +86647,19 @@ class WebGPUBackend extends Backend { // vertex buffers const vertexBuffers = renderObject.getVertexBuffers(); @@ -2346,7 +2349,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( renderObject.camera.isArrayCamera && renderObject.camera.cameras.length > 0 ) { -@@ -85766,6 +86795,7 @@ class WebGPUBackend extends Backend { +@@ -85766,6 +86798,7 @@ class WebGPUBackend extends Backend { const utils = this.utils; const sampleCount = utils.getSampleCountRenderContext( renderObject.context ); @@ -2354,7 +2357,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const colorSpace = utils.getCurrentColorSpace( renderObject.context ); const colorFormat = utils.getCurrentColorFormat( renderObject.context ); const depthStencilFormat = utils.getCurrentDepthStencilFormat( renderObject.context ); -@@ -85787,7 +86817,8 @@ class WebGPUBackend extends Backend { +@@ -85787,7 +86820,8 @@ class WebGPUBackend extends Backend { data.colorFormat !== colorFormat || data.depthStencilFormat !== depthStencilFormat || data.primitiveTopology !== primitiveTopology || data.frontFaceCW !== frontFaceCW || @@ -2364,7 +2367,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed ) { data.material = material; data.materialVersion = material.version; -@@ -85807,6 +86838,7 @@ class WebGPUBackend extends Backend { +@@ -85807,6 +86841,7 @@ class WebGPUBackend extends Backend { data.primitiveTopology = primitiveTopology; data.frontFaceCW = frontFaceCW; data.clippingContextCacheKey = renderObject.clippingContextCacheKey; @@ -2372,7 +2375,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed needsUpdate = true; -@@ -85828,6 +86860,7 @@ class WebGPUBackend extends Backend { +@@ -85828,6 +86863,7 @@ class WebGPUBackend extends Backend { const utils = this.utils; const renderContext = renderObject.context; @@ -2380,7 +2383,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed // meshes with negative scale have a different frontFace render pipeline // descriptor value so the following must be honored in the cache key -@@ -85846,9 +86879,10 @@ class WebGPUBackend extends Backend { +@@ -85846,9 +86882,10 @@ class WebGPUBackend extends Backend { material.side, frontFaceCW, utils.getSampleCountRenderContext( renderContext ), @@ -2392,7 +2395,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed renderObject.clippingContextCacheKey ].join(); -@@ -85916,6 +86950,12 @@ class WebGPUBackend extends Backend { +@@ -85916,6 +86953,12 @@ class WebGPUBackend extends Backend { } @@ -2405,7 +2408,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed /** * Generates mipmaps for the given texture. * -@@ -86017,6 +87057,8 @@ class WebGPUBackend extends Backend { +@@ -86017,6 +87060,8 @@ class WebGPUBackend extends Backend { _shaderModuleDescriptor.label = program.stage + ( program.name !== '' ? `_${ program.name }` : '' ); _shaderModuleDescriptor.code = program.code; @@ -2414,7 +2417,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed programGPU.module = { module: this.device.createShaderModule( _shaderModuleDescriptor ), entryPoint: 'main' -@@ -86164,6 +87206,7 @@ class WebGPUBackend extends Backend { +@@ -86164,6 +87209,7 @@ class WebGPUBackend extends Backend { _bufferDescriptor.reset(); uniformBufferData.buffer = bufferGPU; @@ -2422,7 +2425,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed } -@@ -86430,6 +87473,7 @@ class WebGPUBackend extends Backend { +@@ -86430,6 +87476,7 @@ class WebGPUBackend extends Backend { submit( this.device, encoder.finish() ); @@ -2430,7 +2433,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed if ( dstLevel === 0 && dstTexture.generateMipmaps ) { this.textureUtils.generateMipmaps( dstTexture ); -@@ -86447,6 +87491,8 @@ class WebGPUBackend extends Backend { +@@ -86447,6 +87494,8 @@ class WebGPUBackend extends Backend { */ copyFramebufferToTexture( texture, renderContext, rectangle ) { @@ -2439,7 +2442,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed const renderContextData = this.get( renderContext ); let sourceGPU = null; -@@ -87073,7 +88119,20 @@ class RenderPipeline { +@@ -87073,7 +88122,20 @@ class RenderPipeline { const currentXR = renderer.xr.enabled; renderer.xr.enabled = false; @@ -2462,7 +2465,7 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..f9ea33ac39db20e96822fda6b90e2bed renderer.xr.enabled = currentXR; diff --git a/build/three.webgpu.nodes.js b/build/three.webgpu.nodes.js -index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8eff685bd 100644 +index f707acc24f5711fdae065ce920b08435b0869c1b..f656265a47ffb6034da0c6ce768264762096b197 100644 --- a/build/three.webgpu.nodes.js +++ b/build/three.webgpu.nodes.js @@ -141,6 +141,15 @@ class NodeMaterialObserver { @@ -2760,15 +2763,18 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( interleavedMatrix !== null ) { -@@ -18496,11 +18581,28 @@ const instance = /*@__PURE__*/ Fn( ( [ matrices, colors = null ], builder ) => { +@@ -18496,11 +18581,31 @@ const instance = /*@__PURE__*/ Fn( ( [ matrices, colors = null ], builder ) => { const instancedMesh = builder.object; - OnObjectUpdate( ( { object } ) => { -+ const updatePrevious = ( object, array ) => { ++ const updatePrevious = ( object, array, scheduled = false ) => { + -+ const { previousInstanceMatrix } = _previousInstanceMatrices.get( object ); ++ const data = _previousInstanceMatrices.get( object ); ++ if ( scheduled && data.scheduledPrevious === array ) return; ++ const { previousInstanceMatrix } = data; + previousInstanceMatrix.array.set( array ); ++ data.scheduledPrevious = scheduled ? array : undefined; + previousInstanceMatrix.needsUpdate = true; + const interleaved = _matrixBuffers.get( previousInstanceMatrix ); + if ( interleaved !== undefined ) interleaved.needsUpdate = true; @@ -2780,7 +2786,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 - const previousInstanceData = _previousInstanceMatrices.get( object ); + const scheduledPrevious = object[ Symbol.for( 'threenative.velocity.previousInstanceMatrices' ) ]; -+ if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious ); ++ if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious, true ); - previousInstanceData.previousInstanceMatrix.array.set( matrices.array ); + } ); @@ -2792,7 +2798,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } ); -@@ -18745,7 +18847,15 @@ function getPreviousSkinnedPosition( skinnedMesh, bindMatrixNode, bindMatrixInve +@@ -18745,7 +18850,15 @@ function getPreviousSkinnedPosition( skinnedMesh, bindMatrixNode, bindMatrixInve } @@ -2809,7 +2815,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -31165,6 +31275,14 @@ class Geometries extends DataMap { +@@ -31165,6 +31278,14 @@ class Geometries extends DataMap { */ this._geometryDisposeListeners = new Map(); @@ -2824,7 +2830,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } /** -@@ -31190,10 +31308,69 @@ class Geometries extends DataMap { +@@ -31190,10 +31311,69 @@ class Geometries extends DataMap { if ( this.has( renderObject ) === false ) this.initGeometry( renderObject ); @@ -2894,7 +2900,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 /** * Initializes the geometry of the given render object. * -@@ -32771,7 +32948,7 @@ class Bindings extends DataMap { +@@ -32771,7 +32951,7 @@ class Bindings extends DataMap { */ updateForRender( renderObject ) { @@ -2903,7 +2909,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -32918,12 +33095,13 @@ class Bindings extends DataMap { +@@ -32918,12 +33098,13 @@ class Bindings extends DataMap { * Updates the given array of bindings. * * @param {Array} bindings - The bind groups. @@ -2919,7 +2925,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -32934,11 +33112,27 @@ class Bindings extends DataMap { +@@ -32934,11 +33115,27 @@ class Bindings extends DataMap { * * @param {BindGroup} bindGroup - The bind group to update. * @param {Array} bindings - The bind groups. @@ -2948,7 +2954,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 let needsBindingsUpdate = false; let cacheBindings = true; let cacheIndex = 0; -@@ -32948,7 +33142,7 @@ class Bindings extends DataMap { +@@ -32948,7 +33145,7 @@ class Bindings extends DataMap { for ( const binding of bindGroup.bindings ) { @@ -2957,7 +2963,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 // every uniforms group is a uniform buffer. So if no update is required, // we move one with the next binding. Otherwise the next if block will update the group. -@@ -34020,6 +34214,10 @@ class Textures extends DataMap { +@@ -34020,6 +34217,10 @@ class Textures extends DataMap { * @type {Set} */ this._htmlTextures = new Set(); @@ -2968,7 +2974,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -34169,7 +34367,16 @@ class Textures extends DataMap { +@@ -34169,7 +34370,16 @@ class Textures extends DataMap { */ updateTexture( texture, options = {} ) { @@ -2985,7 +2991,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( textureData.initialized === true && textureData.version === texture.version ) return; const isRenderTarget = texture.isRenderTargetTexture || texture.isDepthTexture || texture.isFramebufferTexture; -@@ -34181,6 +34388,21 @@ class Textures extends DataMap { +@@ -34181,6 +34391,21 @@ class Textures extends DataMap { backend.destroyTexture( texture ); @@ -3007,7 +3013,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } // -@@ -34318,7 +34540,16 @@ class Textures extends DataMap { +@@ -34318,7 +34543,16 @@ class Textures extends DataMap { } @@ -3025,7 +3031,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 const skipAutoGeneration = texture.isStorageTexture === true && texture.mipmapsAutoUpdate === false; -@@ -34328,7 +34559,7 @@ class Textures extends DataMap { +@@ -34328,7 +34562,7 @@ class Textures extends DataMap { } @@ -3034,7 +3040,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -34345,6 +34576,17 @@ class Textures extends DataMap { +@@ -34345,6 +34579,17 @@ class Textures extends DataMap { } @@ -3052,7 +3058,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 // dispose handler if ( textureData.initialized !== true ) { -@@ -34377,9 +34619,134 @@ class Textures extends DataMap { +@@ -34377,9 +34622,134 @@ class Textures extends DataMap { } @@ -3189,7 +3195,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -34557,6 +34924,8 @@ class Textures extends DataMap { +@@ -34557,6 +34927,8 @@ class Textures extends DataMap { */ _destroyTexture( texture ) { @@ -3198,7 +3204,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( this.has( texture ) === true ) { const textureData = this.get( texture ); -@@ -40270,7 +40639,7 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40270,7 +40642,7 @@ class PassMultipleTextureNode extends PassTextureNode { * @param {string} textureName - The output texture name. * @param {boolean} [previousTexture=false] - Whether previous frame data should be used or not. */ @@ -3207,7 +3213,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 // null is passed to the super call since this class does not // use an external texture for rendering pass data into. Instead -@@ -40291,6 +40660,8 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40291,6 +40663,8 @@ class PassMultipleTextureNode extends PassTextureNode { * @type {boolean} */ this.previousTexture = previousTexture; @@ -3216,7 +3222,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 /** * This flag can be used for type testing. -@@ -40312,17 +40683,33 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40312,17 +40686,33 @@ class PassMultipleTextureNode extends PassTextureNode { } @@ -3251,7 +3257,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 newNode.uvNode = this.uvNode; newNode.levelNode = this.levelNode; newNode.biasNode = this.biasNode; -@@ -40503,6 +40890,8 @@ class PassNode extends TempNode { +@@ -40503,6 +40893,8 @@ class PassNode extends TempNode { * @type {Object} */ this._textureNodes = {}; @@ -3260,7 +3266,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 /** * A dictionary holding the internal depth nodes. -@@ -40809,6 +41198,7 @@ class PassNode extends TempNode { +@@ -40809,6 +41201,7 @@ class PassNode extends TempNode { this._previousTextures[ name ] = texture; this._textureNodes[ name ].updateTexture(); @@ -3268,7 +3274,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 this._previousTextureNodes[ name ].updateTexture(); } -@@ -40821,6 +41211,17 @@ class PassNode extends TempNode { +@@ -40821,6 +41214,17 @@ class PassNode extends TempNode { * @param {string} [name='output'] - The output name to get the texture node for. * @return {TextureNode} The texture node. */ @@ -3286,7 +3292,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 getTextureNode( name = 'output' ) { let textureNode = this._textureNodes[ name ]; -@@ -41163,6 +41564,7 @@ class PassNode extends TempNode { +@@ -41163,6 +41567,7 @@ class PassNode extends TempNode { */ dispose() { @@ -3294,7 +3300,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 this.renderTarget.dispose(); } -@@ -44431,6 +44833,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ +@@ -44431,6 +44836,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ const shadowMaterialLib = /*@__PURE__*/ new WeakMap(); @@ -3341,7 +3347,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 /** * A shadow filtering function performing basic filtering. This is in fact an unfiltered version of the shadow map * with a binary `[0,1]` result. -@@ -44485,8 +44927,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44485,8 +44930,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, }; @@ -3352,7 +3358,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 const texelSize = vec2( 1 ).div( mapSize ); const radiusScaled = radius.mul( texelSize.x ); -@@ -44517,7 +44959,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44517,7 +44962,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, */ const PCFSoftShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, depthLayer } ) => { @@ -3361,7 +3367,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 const texelSize = vec2( 1 ).div( mapSize ); -@@ -45586,8 +46028,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) +@@ -45586,8 +46031,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) */ const PointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp, shadow } ) => { @@ -3372,7 +3378,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 const texelSize = radius.div( mapSize.x ); -@@ -45623,9 +46065,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo +@@ -45623,9 +46068,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo const shadowPositionAbs = shadowPosition.abs().toConst(); const viewZ = shadowPositionAbs.x.max( shadowPositionAbs.y ).max( shadowPositionAbs.z ); @@ -3385,7 +3391,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 const result = float( 1.0 ).toVar(); -@@ -55244,15 +55686,17 @@ class NodeManager extends DataMap { +@@ -55244,15 +55689,17 @@ class NodeManager extends DataMap { * Returns `true` if the given node uniforms group must be updated or not. * * @param {NodeUniformsGroup} nodeUniformsGroup - The node uniforms group. @@ -3406,7 +3412,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 // check for update -@@ -56056,6 +56500,21 @@ class NodeManager extends DataMap { +@@ -56056,6 +56503,21 @@ class NodeManager extends DataMap { } @@ -3428,7 +3434,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 /** * Triggers the call of `updateBefore()` methods * for all nodes of the given render object. -@@ -56065,9 +56524,14 @@ class NodeManager extends DataMap { +@@ -56065,9 +56527,14 @@ class NodeManager extends DataMap { updateBefore( renderObject ) { const nodeBuilder = renderObject.getNodeBuilderState(); @@ -3443,7 +3449,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 // update frame state for each node this.getNodeFrameForRender( renderObject ).updateBeforeNode( node ); -@@ -56085,10 +56549,11 @@ class NodeManager extends DataMap { +@@ -56085,10 +56552,11 @@ class NodeManager extends DataMap { updateAfter( renderObject ) { const nodeBuilder = renderObject.getNodeBuilderState(); @@ -3456,7 +3462,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 this.getNodeFrameForRender( renderObject ).updateAfterNode( node ); -@@ -56125,9 +56590,14 @@ class NodeManager extends DataMap { +@@ -56125,9 +56593,14 @@ class NodeManager extends DataMap { const nodeFrame = this.getNodeFrameForRender( renderObject ); const nodeBuilder = renderObject.getNodeBuilderState(); @@ -3471,7 +3477,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 nodeFrame.updateNode( node ); } -@@ -60919,6 +61389,7 @@ class Renderer { +@@ -60919,6 +61392,7 @@ class Renderer { // renderContext.camera = camera; @@ -3479,7 +3485,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 this.backend.beginRender( renderContext ); // process render lists -@@ -60931,13 +61402,23 @@ class Renderer { +@@ -60931,13 +61405,23 @@ class Renderer { opaque: opaqueObjects } = renderList; @@ -3505,7 +3511,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 // restore render tree -@@ -60952,6 +61433,9 @@ class Renderer { +@@ -60952,6 +61436,9 @@ class Renderer { this._callDepth --; @@ -3515,7 +3521,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( frameBufferTarget !== null ) { this.setRenderTarget( outputRenderTarget, activeCubeFace, activeMipmapLevel ); -@@ -61118,6 +61602,18 @@ class Renderer { +@@ -61118,6 +61605,18 @@ class Renderer { } @@ -3534,7 +3540,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 /** * Can be used to transfer buffer data from a storage buffer attribute * from the GPU to the CPU in context of compute shaders. -@@ -62111,6 +62607,14 @@ class Renderer { +@@ -62111,6 +62610,14 @@ class Renderer { } @@ -3549,7 +3555,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 /** * Initializes the given render target. * -@@ -62886,18 +63390,17 @@ class Renderer { +@@ -62886,18 +63393,17 @@ class Renderer { // @@ -3578,7 +3584,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 this._pipelines.updateForRender( renderObject ); -@@ -62907,7 +63410,7 @@ class Renderer { +@@ -62907,7 +63413,7 @@ class Renderer { this.backend.draw( renderObject, this.info ); @@ -3587,7 +3593,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -76397,6 +76900,9 @@ class WebGPUTextureUtils { +@@ -76397,6 +76903,9 @@ class WebGPUTextureUtils { */ this._samplerCache = new Map(); @@ -3597,7 +3603,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } /** -@@ -76613,7 +77119,12 @@ class WebGPUTextureUtils { +@@ -76613,7 +77122,12 @@ class WebGPUTextureUtils { } @@ -3611,7 +3617,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 usage |= GPUTextureUsage.RENDER_ATTACHMENT; -@@ -76686,6 +77197,23 @@ class WebGPUTextureUtils { +@@ -76686,6 +77200,23 @@ class WebGPUTextureUtils { destroyTexture( texture, isDefaultTexture = false ) { const backend = this.backend; @@ -3635,7 +3641,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 const textureData = backend.get( texture ); if ( textureData.texture !== undefined && isDefaultTexture === false && texture.isExternalTexture !== true ) textureData.texture.destroy(); -@@ -76726,7 +77254,7 @@ class WebGPUTextureUtils { +@@ -76726,7 +77257,7 @@ class WebGPUTextureUtils { const colorTexture = canvasTarget.colorTexture; const colorTextureData = backend.get( colorTexture ); @@ -3644,7 +3650,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 return colorTextureData.texture; -@@ -76751,8 +77279,8 @@ class WebGPUTextureUtils { +@@ -76751,8 +77282,8 @@ class WebGPUTextureUtils { // @@ -3655,7 +3661,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 colorTexture.samples = samples; colorTextureData.texture = colorBuffer; -@@ -76775,64 +77303,59 @@ class WebGPUTextureUtils { +@@ -76775,64 +77306,59 @@ class WebGPUTextureUtils { const canvasTarget = backend.renderer.getCanvasTarget(); const { width, height } = backend.getDrawingBufferSize(); const samples = backend.renderer.currentSamples; @@ -3748,7 +3754,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 /** * Uploads the updated texture data to the GPU. * -@@ -76987,6 +77510,143 @@ class WebGPUTextureUtils { +@@ -76987,6 +77513,143 @@ class WebGPUTextureUtils { } @@ -3892,7 +3898,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 /** * Returns texture data as a typed array. * -@@ -77060,6 +77720,8 @@ class WebGPUTextureUtils { +@@ -77060,6 +77723,8 @@ class WebGPUTextureUtils { */ dispose() { @@ -3901,7 +3907,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 this._samplerCache.clear(); } -@@ -78986,7 +79648,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -78986,7 +79651,7 @@ class WGSLNodeBuilder extends NodeBuilder { * @param {?string} offsetSnippet - A WGSL snippet that represents the offset that will be applied to the unnormalized texture coordinate before sampling the texture. * @return {string} The WGSL snippet. */ @@ -3910,7 +3916,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( levelSnippet === null ) levelSnippet = '0u'; -@@ -79004,7 +79666,14 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79004,7 +79669,14 @@ class WGSLNodeBuilder extends NodeBuilder { } else { @@ -3926,7 +3932,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( this.renderer.backend.compatibilityMode && texture.isDepthTexture ) { -@@ -79545,7 +80214,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79545,7 +80217,7 @@ class WGSLNodeBuilder extends NodeBuilder { // Cube textures always need samplers (they use textureSampleLevel, not textureLoad) // Also textureGather always need sampler. @@ -3935,7 +3941,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( needsSampler ) { -@@ -79974,7 +80643,8 @@ ${ flowData.code } +@@ -79974,7 +80646,8 @@ ${ flowData.code } for ( const { name, property, type } of builtins.values() ) { @@ -3945,7 +3951,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -80230,6 +80900,8 @@ ${ flowData.code } +@@ -80230,6 +80903,8 @@ ${ flowData.code } getVaryings( shaderStage ) { const snippets = []; @@ -3954,7 +3960,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( shaderStage === 'vertex' ) { -@@ -80252,7 +80924,12 @@ ${ flowData.code } +@@ -80252,7 +80927,12 @@ ${ flowData.code } let attributesSnippet = `@location( ${ varyingIndex ++ } )`; @@ -3968,7 +3974,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 const samplingSnippet = varying.interpolationSampling !== null ? `, ${ varying.interpolationSampling } )` : ' )'; -@@ -80353,7 +81030,7 @@ ${ flowData.code } +@@ -80353,7 +81033,7 @@ ${ flowData.code } const { primarySamples } = backend.utils.getTextureSampleData( texture ); @@ -3977,7 +3983,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 multisampled = '_multisampled'; -@@ -81107,7 +81784,19 @@ class WebGPUAttributeUtils { +@@ -81107,7 +81787,19 @@ class WebGPUAttributeUtils { buffer.unmap(); @@ -3997,7 +4003,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -81128,6 +81817,13 @@ class WebGPUAttributeUtils { +@@ -81128,6 +81820,13 @@ class WebGPUAttributeUtils { const bufferData = backend.get( bufferAttribute ); const buffer = backend.get( bufferAttribute ).buffer; @@ -4011,7 +4017,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 let array = bufferAttribute.array; const itemSize = bufferData._itemSize; -@@ -81209,6 +81905,9 @@ class WebGPUAttributeUtils { +@@ -81209,6 +81908,9 @@ class WebGPUAttributeUtils { } @@ -4021,7 +4027,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } /** -@@ -81295,9 +81994,15 @@ class WebGPUAttributeUtils { +@@ -81295,9 +81997,15 @@ class WebGPUAttributeUtils { destroyAttribute( attribute ) { const backend = this.backend; @@ -4039,7 +4045,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 backend.delete( attribute ); -@@ -81682,6 +82387,7 @@ class WebGPUBindingUtils { +@@ -81682,6 +82390,7 @@ class WebGPUBindingUtils { } @@ -4047,7 +4053,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 bindingsData.group = bindGroupGPU; } -@@ -81701,6 +82407,7 @@ class WebGPUBindingUtils { +@@ -81701,6 +82410,7 @@ class WebGPUBindingUtils { const updateRanges = binding.updateRanges; @@ -4055,7 +4061,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( updateRanges.length === 0 ) { device.queue.writeBuffer( -@@ -81754,6 +82461,15 @@ class WebGPUBindingUtils { +@@ -81754,6 +82464,15 @@ class WebGPUBindingUtils { } @@ -4071,7 +4077,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } /** -@@ -81830,7 +82546,20 @@ class WebGPUBindingUtils { +@@ -81830,7 +82549,20 @@ class WebGPUBindingUtils { let resourceGPU; @@ -4093,7 +4099,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 resourceGPU = device.importExternalTexture( { source: textureData.externalTexture } ); -@@ -81904,6 +82633,12 @@ class WebGPUBindingUtils { +@@ -81904,6 +82636,12 @@ class WebGPUBindingUtils { } const bindGroupGPU = device.createBindGroup( _bindGroupDescriptor ); @@ -4106,7 +4112,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 _bindGroupDescriptor.reset(); -@@ -82001,7 +82736,7 @@ class WebGPUBindingUtils { +@@ -82001,7 +82739,7 @@ class WebGPUBindingUtils { const { primarySamples } = backend.utils.getTextureSampleData( binding.texture ); @@ -4115,7 +4121,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 texture.multisampled = true; -@@ -82039,7 +82774,7 @@ class WebGPUBindingUtils { +@@ -82039,7 +82777,7 @@ class WebGPUBindingUtils { } else if ( type === FloatType ) { @@ -4124,7 +4130,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 texture.sampleType = GPUTextureSampleType.Float; -@@ -82502,6 +83237,16 @@ class WebGPUPipelineUtils { +@@ -82502,6 +83240,16 @@ class WebGPUPipelineUtils { } @@ -4141,7 +4147,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 // create pipeline device.pushErrorScope( 'validation' ); -@@ -82515,6 +83260,7 @@ class WebGPUPipelineUtils { +@@ -82515,6 +83263,7 @@ class WebGPUPipelineUtils { if ( promises === null ) { pipelineData.pipeline = device.createRenderPipeline( _renderPipelineDescriptor ); @@ -4149,7 +4155,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 _renderPipelineDescriptor.reset(); -@@ -82553,6 +83299,15 @@ class WebGPUPipelineUtils { +@@ -82553,6 +83302,15 @@ class WebGPUPipelineUtils { _renderPipelineDescriptor.reset(); @@ -4165,7 +4171,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( pipelinePromise !== null ) { try { -@@ -82567,7 +83322,7 @@ class WebGPUPipelineUtils { +@@ -82567,7 +83325,7 @@ class WebGPUPipelineUtils { } @@ -4174,7 +4180,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( errorScope !== null || asyncError !== null ) { -@@ -84153,7 +84908,9 @@ class WebGPUBackend extends Backend { +@@ -84153,7 +84911,9 @@ class WebGPUBackend extends Backend { let descriptor = canvasData.descriptor; @@ -4185,7 +4191,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 descriptor = new GPURenderPassDescriptor(); descriptor.colorAttachments.push( new GPURenderPassColorAttachment() ); -@@ -84180,6 +84937,9 @@ class WebGPUBackend extends Backend { +@@ -84180,6 +84940,9 @@ class WebGPUBackend extends Backend { canvasData.descriptor = descriptor; canvasData.samples = samples; @@ -4195,7 +4201,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -84496,6 +85256,150 @@ class WebGPUBackend extends Backend { +@@ -84496,6 +85259,150 @@ class WebGPUBackend extends Backend { * * @param {RenderContext} renderContext - The render context. */ @@ -4346,7 +4352,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 beginRender( renderContext ) { const renderContextData = this.get( renderContext ); -@@ -84648,10 +85552,15 @@ class WebGPUBackend extends Backend { +@@ -84648,10 +85555,15 @@ class WebGPUBackend extends Backend { // @@ -4362,7 +4368,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 // Layered render targets: prepare bundle encoders for each camera in the array camera. if ( this._isRenderCameraDepthArray( renderContext ) === true ) { -@@ -84945,6 +85854,8 @@ class WebGPUBackend extends Backend { +@@ -84945,6 +85857,8 @@ class WebGPUBackend extends Backend { } @@ -4371,7 +4377,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( occlusionQueryCount > 0 ) { const bufferSize = occlusionQueryCount * 8; // 8 byte entries for query results -@@ -84987,7 +85898,16 @@ class WebGPUBackend extends Backend { +@@ -84987,7 +85901,16 @@ class WebGPUBackend extends Backend { } @@ -4389,7 +4395,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 // -@@ -85081,6 +86001,8 @@ class WebGPUBackend extends Backend { +@@ -85081,6 +86004,8 @@ class WebGPUBackend extends Backend { */ updateViewport( renderContext ) { @@ -4398,7 +4404,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 const { currentPass } = this.get( renderContext ); const { x, y, width, height, minDepth, maxDepth } = renderContext.viewportValue; -@@ -85095,6 +86017,8 @@ class WebGPUBackend extends Backend { +@@ -85095,6 +86020,8 @@ class WebGPUBackend extends Backend { */ updateScissor( renderContext ) { @@ -4407,7 +4413,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 const { currentPass } = this.get( renderContext ); const { x, y, width, height } = renderContext.scissorValue; -@@ -85593,6 +86517,11 @@ class WebGPUBackend extends Backend { +@@ -85593,6 +86520,11 @@ class WebGPUBackend extends Backend { } else { passEncoderGPU.draw( vertexCount, instanceCount, firstVertex, 0 ); @@ -4419,7 +4425,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -85628,6 +86557,19 @@ class WebGPUBackend extends Backend { +@@ -85628,6 +86560,19 @@ class WebGPUBackend extends Backend { // vertex buffers const vertexBuffers = renderObject.getVertexBuffers(); @@ -4439,7 +4445,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( renderObject.camera.isArrayCamera && renderObject.camera.cameras.length > 0 ) { -@@ -85916,6 +86858,12 @@ class WebGPUBackend extends Backend { +@@ -85916,6 +86861,12 @@ class WebGPUBackend extends Backend { } @@ -4452,7 +4458,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 /** * Generates mipmaps for the given texture. * -@@ -86017,6 +86965,8 @@ class WebGPUBackend extends Backend { +@@ -86017,6 +86968,8 @@ class WebGPUBackend extends Backend { _shaderModuleDescriptor.label = program.stage + ( program.name !== '' ? `_${ program.name }` : '' ); _shaderModuleDescriptor.code = program.code; @@ -4461,7 +4467,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 programGPU.module = { module: this.device.createShaderModule( _shaderModuleDescriptor ), entryPoint: 'main' -@@ -86164,6 +87114,7 @@ class WebGPUBackend extends Backend { +@@ -86164,6 +87117,7 @@ class WebGPUBackend extends Backend { _bufferDescriptor.reset(); uniformBufferData.buffer = bufferGPU; @@ -4469,7 +4475,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 } -@@ -86430,6 +87381,7 @@ class WebGPUBackend extends Backend { +@@ -86430,6 +87384,7 @@ class WebGPUBackend extends Backend { submit( this.device, encoder.finish() ); @@ -4477,7 +4483,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 if ( dstLevel === 0 && dstTexture.generateMipmaps ) { this.textureUtils.generateMipmaps( dstTexture ); -@@ -86447,6 +87399,8 @@ class WebGPUBackend extends Backend { +@@ -86447,6 +87402,8 @@ class WebGPUBackend extends Backend { */ copyFramebufferToTexture( texture, renderContext, rectangle ) { @@ -4486,7 +4492,7 @@ index f707acc24f5711fdae065ce920b08435b0869c1b..93cedce745291edfbd333fb76d8154b8 const renderContextData = this.get( renderContext ); let sourceGPU = null; -@@ -87020,7 +87974,20 @@ class RenderPipeline { +@@ -87020,7 +87977,20 @@ class RenderPipeline { const currentXR = renderer.xr.enabled; renderer.xr.enabled = false; @@ -4806,7 +4812,7 @@ index ab62e98dadab2539d7e1d663fd42267bbc8ebcaa..2cf4bb6039853d5c922e89041419250b - - diff --git a/src/nodes/accessors/Instance.js b/src/nodes/accessors/Instance.js -index b89834b9166c75fd946ade24db265bf8c367d244..18e2b7943393fd07b7989b1e3a5231a61ca24187 100644 +index b89834b9166c75fd946ade24db265bf8c367d244..faf500ca41898100d46b56ce322ddee86ef8de2a 100644 --- a/src/nodes/accessors/Instance.js +++ b/src/nodes/accessors/Instance.js @@ -1,6 +1,6 @@ @@ -4846,15 +4852,18 @@ index b89834b9166c75fd946ade24db265bf8c367d244..18e2b7943393fd07b7989b1e3a5231a6 if ( interleavedMatrix !== null ) { -@@ -216,11 +218,28 @@ export const instance = /*@__PURE__*/ Fn( ( [ matrices, colors = null ], builder +@@ -216,11 +218,31 @@ export const instance = /*@__PURE__*/ Fn( ( [ matrices, colors = null ], builder const instancedMesh = builder.object; - OnObjectUpdate( ( { object } ) => { -+ const updatePrevious = ( object, array ) => { ++ const updatePrevious = ( object, array, scheduled = false ) => { + -+ const { previousInstanceMatrix } = _previousInstanceMatrices.get( object ); ++ const data = _previousInstanceMatrices.get( object ); ++ if ( scheduled && data.scheduledPrevious === array ) return; ++ const { previousInstanceMatrix } = data; + previousInstanceMatrix.array.set( array ); ++ data.scheduledPrevious = scheduled ? array : undefined; + previousInstanceMatrix.needsUpdate = true; + const interleaved = _matrixBuffers.get( previousInstanceMatrix ); + if ( interleaved !== undefined ) interleaved.needsUpdate = true; @@ -4866,7 +4875,7 @@ index b89834b9166c75fd946ade24db265bf8c367d244..18e2b7943393fd07b7989b1e3a5231a6 - const previousInstanceData = _previousInstanceMatrices.get( object ); + const scheduledPrevious = object[ Symbol.for( 'threenative.velocity.previousInstanceMatrices' ) ]; -+ if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious ); ++ if ( scheduledPrevious !== undefined ) updatePrevious( object, scheduledPrevious, true ); + + } ); + diff --git a/packages/core/src/game.ts b/packages/core/src/game.ts index a907be8bf9..f9b3e3db80 100644 --- a/packages/core/src/game.ts +++ b/packages/core/src/game.ts @@ -20,7 +20,7 @@ import { rendererBackendIdentity, } from "./geometry-capture.js"; import { type ContextMenuPolicy, type InputBindings, InputMap } from "./input.js"; -import { watchDeviceLoss, watchStartupStall } from "./launch-diagnostics.js"; +import { reportLaunchFailure, watchDeviceLoss, watchStartupStall } from "./launch-diagnostics.js"; import { FixedStepLoop, type IAfterPhysicsPhase, @@ -68,6 +68,7 @@ import { formatProjectionWindow } from "./projection-marker.js"; import { type IRandom, createRandom } from "./random.js"; import { RenderCameraCull } from "./render-camera-cull.js"; import { RenderPassBudget } from "./render-pass-budget.js"; +import { VirtualShadowNode } from "./render/virtual-shadow.js"; import { SceneRenderProjection } from "./renderProjection.js"; import { resolveMatrixWorldMode, @@ -1126,10 +1127,16 @@ class GameImpl, TPhysics> let worldRendered = false; let loadingFramePresented = false; let markProjectionSettled: () => void = () => undefined; - const projectionReady = new Promise((resolve) => { + let rejectProjection: (error: unknown) => void = () => undefined; + const projectionReady = new Promise((resolve, reject) => { markProjectionSettled = resolve; + rejectProjection = reject; }); + // Readiness is optional to await; retain its rejection for callers without an unhandled one. + void projectionReady.catch(() => undefined); + const startupShadows = new Set(); const startupReadiness = new StartupReadiness({ + settled: () => worldRendered && [...startupShadows].every((node) => node.settled), /** * The second warm-up: everything a game attached while it held startup. * @@ -1149,6 +1156,7 @@ class GameImpl, TPhysics> await warmUp("TN_STARTUP_WARMUP_HELD", Math.round(STARTUP_COMPILE_BUDGET_MS / 3), false); }, }); + this.#cleanup.push(() => startupReadiness.cancel()); // Startup readiness does not wait on `ui-ready`, and cannot. The intent proves the page's // script ran, not that a pixel reached the screen, so waiting on it never closed the race it // was added for — it only made the losing side lose later, after up to 45 s of held frames. @@ -1180,6 +1188,7 @@ class GameImpl, TPhysics> workProgress: () => assets.progress.settled + startupReadiness.workProgress, stallMs: STARTUP_STALL_MS, }); + this.#cleanup.push(stopStallWatch); watchDeviceLoss( ( renderer.raw as { @@ -1192,28 +1201,40 @@ class GameImpl, TPhysics> // framework cost" and "what the player waited for", and collapsing them is how a valley that // took 8.8 s to appear reported 1.5 s. void startupReadiness.whenFrameworkReady().then(() => { + if (this.#aborted) return; timeline.compileSettledMs ??= now(); timeline.frameworkReadyMs ??= now(); }); - void startupReadiness.whenReady().then(() => { - stopStallWatch(); - // A renderer without first-use compilation settles without running the compile closure - // below, so the settle stamp is guaranteed here at the latest. - timeline.compileSettledMs ??= now(); - timeline.frameworkReadyMs ??= now(); - timeline.readyMs ??= now(); - projectionSettled = true; - markProjectionSettled(); - // A host capturing a frame has no other way to know the world is on screen. Counting frames - // cannot express it: on a software rasteriser the gate resolves on its bounded window rather - // than on five in-budget frames, so a fast 300-frame run finishes before the world is shown - // and captures the loading state. The native screenshot path waits on this flag. - (globalThis as Record)[STARTUP_READY_GLOBAL] = true; - // Again at ready: a world that streams its foliage in under `startup.hold` has none at entry. - const unlit = unlitFoliageWarning(threeScene); - if (unlit !== undefined) - console.warn(`${formatUnlitFoliageWarning(unlit)} ${UNLIT_FOLIAGE_FIX}`); - }); + void startupReadiness.whenReady().then( + () => { + if (this.#aborted) return; + stopStallWatch(); + // A renderer without first-use compilation settles without running the compile closure + // below, so the settle stamp is guaranteed here at the latest. + timeline.compileSettledMs ??= now(); + timeline.frameworkReadyMs ??= now(); + timeline.readyMs ??= now(); + projectionSettled = true; + for (const node of startupShadows) node.startupCovered = false; + startupShadows.clear(); + markProjectionSettled(); + // A host capturing a frame has no other way to know the world is on screen. Counting frames + // cannot express it: on a software rasteriser the gate resolves on its bounded window rather + // than on five in-budget frames, so a fast 300-frame run finishes before the world is shown + // and captures the loading state. The native screenshot path waits on this flag. + (globalThis as Record)[STARTUP_READY_GLOBAL] = true; + // Again at ready: a world that streams its foliage in under `startup.hold` has none at entry. + const unlit = unlitFoliageWarning(threeScene); + if (unlit !== undefined) + console.warn(`${formatUnlitFoliageWarning(unlit)} ${UNLIT_FOLIAGE_FIX}`); + }, + (error: unknown) => { + if (this.#aborted) return; + stopStallWatch(); + rejectProjection(error); + reportLaunchFailure({ kind: "stalled", message: String(error) }); + }, + ); const warmUp = async (marker: string, budgetMs: number, stamp: boolean): Promise => { if (this.#aborted || this.#renderer === undefined) return; let report: IWarmUpReport | undefined; @@ -1638,6 +1659,18 @@ class GameImpl, TPhysics> }, onRender: () => { observeCompilation(); + if (!projectionSettled) { + for (const node of startupShadows) node.startupCovered = false; + startupShadows.clear(); + threeScene.traverseVisible((object) => { + if (!object.castShadow || !object.visible) return; + const node: unknown = Reflect.get(object, "shadow")?.shadowNode; + if (node instanceof VirtualShadowNode) { + startupShadows.add(node); + node.startupCovered = startupCoverActive(); + } + }); + } renderPassBudget?.beginFrame(); // Runs on web as well as native, so the two stay one behaviour rather than diverging into // a fast path nobody tests. When the world is drawn, reconciliation happens immediately diff --git a/packages/core/src/render/virtual-shadow-pages.ts b/packages/core/src/render/virtual-shadow-pages.ts index 91258e917a..8934c1ea92 100644 --- a/packages/core/src/render/virtual-shadow-pages.ts +++ b/packages/core/src/render/virtual-shadow-pages.ts @@ -766,6 +766,11 @@ export class ShadowInvalidationTracker { this.clipmap = clipmap; } + /** Dirty page keys not yet consumed by the next shadow render. */ + get pending(): number { + return this.#invalidated.size; + } + get trackedCount(): number { return this.#tracked.size; } diff --git a/packages/core/src/render/virtual-shadow.ts b/packages/core/src/render/virtual-shadow.ts index ae9b389c66..3c837b4e4b 100644 --- a/packages/core/src/render/virtual-shadow.ts +++ b/packages/core/src/render/virtual-shadow.ts @@ -922,6 +922,23 @@ export class VirtualShadowNode extends ShadowBaseNode { */ readonly tracker: ShadowInvalidationTracker; #levels: ILevel[] = []; + /** Core startup cover: drain all due levels without steady-state invalidation delays. */ + startupCovered = false; + + /** Every map is present and no requested/delayed invalidation remains. */ + get settled(): boolean { + return ( + this.#initialised && + this.#levels.length > 0 && + !this.#invalidateAll && + this.#regions.length === 0 && + !(this.light as DirectionalLight).shadow.needsUpdate && + this.tracker.pending === 0 && + this.#levels.every( + (level) => level.mapped.value === 1 && !level.dirty && level.pending === REASON_NONE, + ) + ); + } #invalidateAll = false; /** Regions handed to `invalidateRegion`, read and cleared by the next `updateBefore`. */ #regions: Array = []; @@ -2968,7 +2985,9 @@ export class VirtualShadowNode extends ShadowBaseNode { // *own* last render, so a burst spanning that delay is one render and a lone ask is one // render, and the level keeps the map and window it has in between — already right for // every static caster in it, missing only what streamed in since. - const delay = Math.max(this.options.invalidationDelay[index] ?? 0, gpuDelay); + const delay = this.startupCovered + ? 0 + : Math.max(this.options.invalidationDelay[index] ?? 0, gpuDelay); const matured = level.dirty && (urgent || now - level.lastRender >= delay); let reason: RenderReason = REASON_NONE; if (windowMoved) reason = REASON_MOVE; @@ -2983,7 +3002,7 @@ export class VirtualShadowNode extends ShadowBaseNode { // can hide it from. A level that reads part of that spot draws on this frame too, so the // blob never stands next to the mover's new shadow. It fires once per caster, on the frame // the node classifies it, and at most one render per level. - const grant = due && (!budgetSpent || (urgentSelected && matured)); + const grant = due && (this.startupCovered || !budgetSpent || (urgentSelected && matured)); // The rectangles this render draws, or undefined for the whole level. let passes: IPartialPass[] | undefined; // The pages this render counted as rectangles, kept for the derived span below that turns diff --git a/packages/core/src/render/world-impostor-surface.ts b/packages/core/src/render/world-impostor-surface.ts index 0507a31d88..9bc8767dff 100644 --- a/packages/core/src/render/world-impostor-surface.ts +++ b/packages/core/src/render/world-impostor-surface.ts @@ -7,7 +7,6 @@ import { type InstancedBufferAttribute, InstancedInterleavedBuffer, type Material, - type Object3D, Sphere, Vector3, } from "three"; @@ -29,6 +28,7 @@ import { element, float, floor, + fract, instanceIndex, instancedBufferAttribute, instancedDynamicBufferAttribute, @@ -40,11 +40,14 @@ import { modelWorldMatrix, normalize, positionGeometry, + screenCoordinate, select, + sin, storage, texture, transformNormal, uniform, + varying, varyingProperty, vec2, vec3, @@ -56,6 +59,7 @@ import { MeshPhysicalNodeMaterial, MeshStandardNodeMaterial, type Node, + type NodeBuilder, type NodeMaterial, } from "three/webgpu"; import { IMPOSTOR_VIEW_GRID, type WorldImpostorAtlas } from "./world-impostor.js"; @@ -262,6 +266,8 @@ export function instanceSyncEvent( /** * The per-instance matrix as a `mat4` node, choosing the path by the attribute three installed. * + * Standard instancing already built the column nodes: reuse those exact nodes and its upload + * synchronization. Only a custom vertex path without them needs the fallback below. * A `StorageInstancedBufferAttribute` is read through `storage(...).element(instanceIndex)`, which * is the same seam three's own `Instance.js` uses and which includes the indirect draw's * `firstInstance`. A plain CPU `InstancedBufferAttribute` cannot require storage support, so it is @@ -270,7 +276,23 @@ export function instanceSyncEvent( * derived buffer exactly as `Instance.js` does — a derived buffer nobody re-uploads draws a stale * pose. See {@link instanceSyncEvent}. */ -function instanceMatrixNode(attribute: InstancedBufferAttribute): Node<"mat4"> { +function instanceMatrixNode( + attribute: InstancedBufferAttribute, + builder: NodeBuilder, +): Node<"mat4"> { + // Reuse Three's actual column nodes: a second interleaved buffer consumes four more locations. + const columns = [...(Reflect.get(builder, "nodes") as Iterable)].filter((node) => { + const value = Reflect.get(node, "value"); + return ( + value?.isInstancedInterleavedBuffer === true && + value.array === attribute.array && + Reflect.get(node, "bufferStride") === 16 + ); + }); + if (columns.length === 4) { + columns.sort((a, b) => Reflect.get(a, "bufferOffset") - Reflect.get(b, "bufferOffset")); + return mat4(...(columns as [Node<"vec4">, Node<"vec4">, Node<"vec4">, Node<"vec4">])); + } const count = Math.max(attribute.count, 1); if ( (attribute as { isStorageInstancedBufferAttribute?: boolean }) @@ -286,7 +308,7 @@ function instanceMatrixNode(attribute: InstancedBufferAttribute): Node<"mat4"> { attribute.usage === DynamicDrawUsage ? instancedDynamicBufferAttribute : instancedBufferAttribute; - const columns = [ + const ownColumns = [ bufferFn(interleaved, "vec4", 16, 0), bufferFn(interleaved, "vec4", 16, 4), bufferFn(interleaved, "vec4", 16, 8), @@ -294,17 +316,19 @@ function instanceMatrixNode(attribute: InstancedBufferAttribute): Node<"mat4"> { ]; instanceSyncEvent(attribute, interleaved).toStack(); return mat4( - columns[0] as Node<"vec4">, - columns[1] as Node<"vec4">, - columns[2] as Node<"vec4">, - columns[3] as Node<"vec4">, + ownColumns[0] as Node<"vec4">, + ownColumns[1] as Node<"vec4">, + ownColumns[2] as Node<"vec4">, + ownColumns[3] as Node<"vec4">, ) as Node<"mat4">; } /** `cameraViewMatrix * modelWorldMatrix * instanceMatrix`: asset-space to view-space per instance. */ -function instanceViewMatrixNode(object: Object3D): Node<"mat4"> { +function instanceViewMatrixNode(builder: NodeBuilder): Node<"mat4"> { + const object = builder.object; const attribute = (object as { instanceMatrix?: InstancedBufferAttribute }).instanceMatrix; - const instance = attribute === undefined ? identityMat4() : instanceMatrixNode(attribute); + const instance = + attribute === undefined ? identityMat4() : instanceMatrixNode(attribute, builder); return cameraViewMatrix.mul(modelWorldMatrix).mul(instance) as Node<"mat4">; } @@ -318,9 +342,14 @@ function identityMat4(): Node<"mat4"> { ) as Node<"mat4">; } -/** A node twin of a source material: its class and every value it authored, by reference. */ +/** A node twin retaining the source material's authored values and shader nodes. */ function cloneLitMaterial(source: Material): NodeMaterial { - if (Reflect.get(source, "isNodeMaterial") === true) return (source as NodeMaterial).clone(); + if (Reflect.get(source, "isNodeMaterial") === true) { + const twin = (source as NodeMaterial).clone(); + // NodeMaterial.copy skips the inherited alphaTest accessor and its private backing value. + twin.alphaTest = source.alphaTest; + return twin; + } const nodeClass = NODE_CLASSES.get(source.type); if (nodeClass === undefined) throw new Error(`WorldImpostorSurface cannot convert material type '${source.type}'.`); @@ -390,9 +419,9 @@ export class WorldImpostorSurface { const relVarying = varyingProperty("vec3", `${name}Rel`); const dirVarying = varyingProperty("vec3", `${name}Dir`); - const positionNode = Fn((_inputs: unknown, builder: { readonly object: Object3D }) => { + const positionNode = Fn((_inputs: unknown, builder: NodeBuilder) => { const object = builder.object; - const instance = userInstanceMatrix(object); + const instance = userInstanceMatrix(builder); const view = cameraViewMatrix.mul(modelWorldMatrix).mul(instance); const centerView = view.mul(vec4(centerUniform, 1)).xyz; // The authored cull contract in `world-gpu-scene.ts` `cullAndSelect` measures horizontal @@ -449,8 +478,8 @@ export class WorldImpostorSurface { material.colorNode = vec4(fragment.albedo, fragment.coverage); material.alphaTestNode = fragment.threshold; material.maskShadowNode = fragment.coverage.greaterThan(fragment.threshold); - material.normalNode = Fn((_inputs: unknown, builder: { readonly object: Object3D }) => - transformNormal(fragment.normalAsset, instanceViewMatrixNode(builder.object)), + material.normalNode = Fn((_inputs: unknown, builder: NodeBuilder) => + transformNormal(fragment.normalAsset, instanceViewMatrixNode(builder)), )(); material.positionNode = positionNode; @@ -487,9 +516,10 @@ export class WorldImpostorSurface { let surfaceId = 0; /** The attribute node for the position pass; a plain `Mesh` falls back to the identity. */ -function userInstanceMatrix(object: Object3D): Node<"mat4"> { +function userInstanceMatrix(builder: NodeBuilder): Node<"mat4"> { + const object = builder.object; const attribute = (object as { instanceMatrix?: InstancedBufferAttribute }).instanceMatrix; - return attribute === undefined ? identityMat4() : instanceMatrixNode(attribute); + return attribute === undefined ? identityMat4() : instanceMatrixNode(attribute, builder); } interface IFragmentNodes { @@ -720,3 +750,48 @@ export function impostorFrameUv( v: 0.5 - (relative.dot(basis.up) / radius) * 0.5, }; } + +/** Complementary LOD masks over the caller's distance bands, shared by colour and shadow passes. */ +export function worldLodMaterial( + source: Material, + local: import("three").Matrix4, + distances: readonly number[], + level: number, + width: number, + eye: Node<"vec3">, + distanceBias: Node<"float">, + spatial: boolean, +): NodeMaterial { + const material = cloneLitMaterial(source); + const inverse = uniform(local.clone().invert()); + const root = Fn( + (_inputs: unknown, builder: NodeBuilder) => + userInstanceMatrix(builder) + .mul(inverse) + .mul(vec4(0, 0, 0, 1)).xyz, + )(); + // Quantise recovered roots so part-local float rounding cannot change the instance hash. + const origin = varying(floor(root.mul(16).add(0.5)).div(16)); + const distance = (spatial ? length(origin.sub(eye)) : length(origin.xz.sub(eye.xz))).mul( + distanceBias, + ); + const random = fract(sin(dot(origin.xz, vec2(12.9898, 78.233))).mul(43758.5453)); + const noise = fract(sin(dot(floor(screenCoordinate.xy), vec2(12.9898, 78.233))).mul(43758.5453)); + let mask = float(1).greaterThan(0); + for (let gate = 1; gate < distances.length; gate++) { + const at = distances[gate] as number; + const span = Math.min( + at * width, + (at - (distances[gate - 1] as number)) * width, + ((distances[gate + 1] ?? Number.POSITIVE_INFINITY) - at) * width, + ); + const mix = distance.sub(at).div(span).add(0.5).add(random.sub(0.5).mul(0.5)).clamp(0, 1); + if (gate === level) mask = mask.and(noise.lessThan(mix)); + if (gate === level + 1) mask = mask.and(noise.greaterThanEqual(mix)); + } + material.maskNode = material.maskNode ? (material.maskNode as Node<"bool">).and(mask) : mask; + material.maskShadowNode = material.maskShadowNode + ? (material.maskShadowNode as Node<"bool">).and(mask) + : mask; + return material; +} diff --git a/packages/core/src/startup-readiness.ts b/packages/core/src/startup-readiness.ts index 40aad373b5..91721e48cf 100644 --- a/packages/core/src/startup-readiness.ts +++ b/packages/core/src/startup-readiness.ts @@ -1,6 +1,11 @@ export type StartupCompile = () => Promise | void; export interface IStartupReadinessOptions { + /** + * Final render-owned work (such as shadow maps), checked after warm-up for two quiet frames. + * Missing settlement rejects whenReady() after stableWindowMs; readiness stays false. + */ + readonly settled?: () => boolean; /** Maximum wall-clock time spent waiting for first-use compilation. */ readonly compileBudgetMs?: number; /** Maximum measured callback duration for a stable startup frame. */ @@ -94,6 +99,12 @@ export class StartupReadiness { #stableFrameCount = 0; #started = false; #ready = false; + #failed = false; + #cancelled = false; + readonly #settled: (() => boolean) | undefined; + #quietFrames = 0; + #finishing = false; + #afterHoldsDone = false; #frameWindowDone = false; readonly #holds = new Map(); #holdBudgetMs: number; @@ -101,6 +112,7 @@ export class StartupReadiness { #compileTimer: ReturnType | undefined; #windowTimer: ReturnType | undefined; #resolveReady: () => void = () => undefined; + #rejectReady: (error: Error) => void = () => undefined; #resolveFrameworkReady: () => void = () => undefined; readonly #readyPromise: Promise; readonly #frameworkReadyPromise: Promise; @@ -127,14 +139,29 @@ export class StartupReadiness { "holdBudgetMs", ); this.#afterHolds = options.afterHolds; - this.#readyPromise = new Promise((resolve) => { + this.#settled = options.settled; + this.#readyPromise = new Promise((resolve, reject) => { this.#resolveReady = resolve; + this.#rejectReady = reject; }); this.#frameworkReadyPromise = new Promise((resolve) => { this.#resolveFrameworkReady = resolve; }); } + /** Stop observing a discarded launch without publishing readiness or failure. */ + cancel(): void { + this.#cancelled = true; + if (this.#compileTimer !== undefined) clearTimeout(this.#compileTimer); + if (this.#windowTimer !== undefined) clearTimeout(this.#windowTimer); + this.#compileTimer = undefined; + this.#windowTimer = undefined; + for (const hold of this.#holds.values()) { + if (hold.timer !== undefined) clearTimeout(hold.timer); + hold.timer = undefined; + } + } + whenReady(): Promise { return this.#readyPromise; } @@ -218,6 +245,7 @@ export class StartupReadiness { budgetMs: number = this.#holdBudgetMs, progress?: () => number, ): void { + if (this.#cancelled) return; if (typeof label !== "string" || label.trim() === "") throw new Error("TN_STARTUP_HOLD_LABEL_INVALID: a hold needs a non-empty label."); if (this.#holds.has(label)) @@ -238,7 +266,7 @@ export class StartupReadiness { }; this.#holds.set(label, hold); const release = (expired: boolean): void => { - if (hold.settled) return; + if (this.#cancelled || hold.settled) return; this.#sampleHold(hold); hold.progress = undefined; hold.settled = true; @@ -287,7 +315,7 @@ export class StartupReadiness { /** Begin the one-time first-use gate after the entered scene has been built. */ start(compile?: StartupCompile): void { - if (this.#started) return; + if (this.#cancelled || this.#started) return; this.#started = true; if (compile === undefined) { this.#finishCompile(); @@ -298,7 +326,9 @@ export class StartupReadiness { // Defer the call one microtask so the already-visible loading layer gets a present before a // renderer that performs synchronous first-use work starts compiling. void Promise.resolve() - .then(() => compile()) + .then(() => { + if (!this.#cancelled) return compile(); + }) .then( () => this.#finishCompile(), () => this.#finishCompile(), @@ -307,8 +337,23 @@ export class StartupReadiness { /** Feed actual callback wall time, not requestAnimationFrame timestamp spacing. */ observe(frameMs: number): void { + if (this.#cancelled || this.#failed) return; if (!this.#ready) this.#pollHolds(); if (!this.#started || this.#ready) return; + if (this.#afterHoldsDone && this.#settled !== undefined) { + const quiet = + this.#settled() && + [...this.#holds.values()].every( + (hold) => hold.settled && (hold.ready === undefined || hold.ready()), + ); + this.#quietFrames = quiet ? this.#quietFrames + 1 : 0; + if (this.#quietFrames >= 2) { + if (this.#windowTimer !== undefined) clearTimeout(this.#windowTimer); + this.#windowTimer = undefined; + this.#ready = true; + this.#resolveReady(); + } + } if ( this.#compileSettled && Number.isFinite(frameMs) && @@ -325,7 +370,7 @@ export class StartupReadiness { } #finishCompile(): void { - if (this.#compileSettled) return; + if (this.#cancelled || this.#compileSettled) return; this.#compileSettled = true; if (this.#compileTimer !== undefined) clearTimeout(this.#compileTimer); this.#compileTimer = undefined; @@ -339,7 +384,7 @@ export class StartupReadiness { /** The framework's own work is done. Whether the *world* is ready also depends on the holds. */ #finishFrameWindow(): void { - if (this.#frameWindowDone) return; + if (this.#cancelled || this.#frameWindowDone) return; this.#frameWindowDone = true; if (this.#windowTimer !== undefined) clearTimeout(this.#windowTimer); this.#windowTimer = undefined; @@ -348,26 +393,40 @@ export class StartupReadiness { } #resolveIfComplete(): void { - if (this.#ready || !this.#frameWindowDone) return; + if (this.#cancelled || this.#ready || this.#finishing || !this.#frameWindowDone) return; for (const hold of this.#holds.values()) if (!hold.settled) return; - // Claim readiness before awaiting, so a hold that settles during `afterHolds` cannot re-enter - // and run it twice. - this.#ready = true; + this.#finishing = true; + // Legacy users keep their bounded startup contract; render-owned settlement is opt-in here. + if (this.#settled === undefined) this.#ready = true; + const finish = (): void => { + if (this.#cancelled) return; + this.#afterHoldsDone = true; + if (this.#settled === undefined) this.#resolveReady(); + else { + // Required world/shadow work must fail closed within the existing frame-window budget. + this.#windowTimer = setTimeout(() => { + this.#windowTimer = undefined; + this.#failed = true; + this.#rejectReady( + new Error( + `TN_STARTUP_SETTLEMENT_TIMEOUT: required world/shadow settlement did not complete within ${this.#stableWindowMs}ms.`, + ), + ); + }, this.#stableWindowMs); + } + }; const afterHolds = this.#afterHolds; - // No holds means no second tier, which means the framework's own pass already saw the whole - // scene. Resolving synchronously there keeps every game that does not stream exactly as it was. if (afterHolds === undefined || this.#holds.size === 0) { - this.#resolveReady(); + finish(); return; } void (async () => { try { await afterHolds(); - } catch { - // Swallowed on purpose: a warm-up that fails costs hitches, not a game that never starts. + } finally { + finish(); } - this.#resolveReady(); - })(); + })().catch(() => undefined); } } diff --git a/packages/core/src/warmup.ts b/packages/core/src/warmup.ts index 7117b58d90..88e2f8372c 100644 --- a/packages/core/src/warmup.ts +++ b/packages/core/src/warmup.ts @@ -663,25 +663,30 @@ interface IReflectorNodeLike { * Whether a TSL node graph contains a reflector. * * Three's `reflector()` hangs a `_reflectorBaseNode` off a `ReflectorNode` that a material's - * `colorNode` (or a node composed around it) references. The walk is bounded and only descends - * real nodes, so a material with no reflector costs one shallow pass. + * `colorNode` (or a node composed around it) references. Shared nodes are revisited only at a + * shallower depth, preserving the depth bound without expanding every path through the graph. */ -function nodeHasReflector(root: unknown, depth: number): boolean { +function nodeHasReflector(root: unknown, depth: number, seen: WeakMap): boolean { if (depth > 64 || typeof root !== "object" || root === null) return false; const node = root as IReflectorNodeLike; if (node._reflectorBaseNode !== undefined) return true; if (node.isNode !== true || typeof node.getChildren !== "function") return false; + const prior = seen.get(root); + if (prior !== undefined && prior <= depth) return false; + seen.set(root, depth); try { for (const child of node.getChildren()) { - if (nodeHasReflector(child, depth + 1)) return true; + if (nodeHasReflector(child, depth + 1, seen)) return true; } } catch { + seen.delete(root); return false; } return false; } function hasReflector(scene: Object3D): boolean { + const seen = new WeakMap(); const stack: Object3D[] = [scene]; while (stack.length > 0) { const object = stack.pop() as Object3D & { material?: unknown }; @@ -692,7 +697,7 @@ function hasReflector(scene: Object3D): boolean { (material as { isNodeMaterial?: boolean }).isNodeMaterial === true ) { for (const value of Object.values(material as Record)) { - if (nodeHasReflector(value, 0)) return true; + if (nodeHasReflector(value, 0, seen)) return true; } } const children = object.children ?? []; diff --git a/packages/core/src/world-cells.ts b/packages/core/src/world-cells.ts index 469049e59b..69a1abca16 100644 --- a/packages/core/src/world-cells.ts +++ b/packages/core/src/world-cells.ts @@ -16,6 +16,7 @@ import { Sphere, Vector3, } from "three"; +import { uniform } from "three/tsl"; import { BundleGroup } from "three/webgpu"; import { type IAssetLoader, createAssetLoader } from "./assets.js"; import type { IComputeDriven } from "./compute-driven.js"; @@ -48,6 +49,7 @@ import { import { IMPOSTOR_FAR_CULL_ATTRIBUTE, WorldImpostorSurface, + worldLodMaterial, } from "./render/world-impostor-surface.js"; import { type IImpostorPart, @@ -431,10 +433,13 @@ export interface IWorldCellsLoadOptions { * Filtered by the existing admission/refilter budget, never by a separate culler. */ readonly placementReach?: Float32Array; - /** Inward return margin for authored CPU LODs, in [0, 1). Requires gpuScene:false and impostors:false; omission keeps source levels. - * Omitted, selection uses the existing direct gates. State survives cell eviction/revisit. */ + /** Complementary view/shadow dither band as a fraction of each distance gap, in [0, 1). Requires gpuScene:false. + * Positive values opt into per-LOD material wrappers preserving authored nodes and copied scalar values. + * Impostors require preserveAuthoredParts:true here; omission keeps direct level selection and raw materials. */ readonly lodHysteresis?: number; - /** Preserve supplied part/material identity and exact authored LOD membership, including shadow batches. Requires impostors:false; omission keeps source levels. + /** Preserve supplied parts and exact authored LOD membership, including shadow batches. + * Materials retain source identity unless positive lodHysteresis opts into dither wrappers; + * wrappers share authored nodes but do not share all later scalar mutations. Terminal impostors are supported with gpuScene:false; omission keeps source levels. * Default content sharing and missing-alpha-part coverage remain enabled when omitted. */ readonly preserveAuthoredParts?: boolean; /** Already decoded immutable world-v1 data; URL still supplies the base for model paths. */ @@ -568,6 +573,10 @@ export interface IWorldCellsLoadOptions { * test can prove the ceiling instead of hoping a machine is slow enough to show it. */ readonly admissionNow?: () => number; + /** Called synchronously when a nonempty source batch becomes drawable, including runs in + * unfinished cells and replacements. Bounds use the admitted placements' authored draw bounds; + * staging alone never calls this observer. Copy bounds if retaining them. */ + readonly onPublication?: (bounds: Box3) => void; /** * The screen-space projection the extra levels of a baked AutoLOD chain are switched at, for an * asset whose `world.json` entry names no nonempty authored `lods` of its own. An explicit empty @@ -707,8 +716,7 @@ export interface IWorldCellsLoadOptions { * Bake a whole-asset octahedral impostor for every alpha-cutout foliage asset and append it as the * asset's terminal, two-triangle LOD. Default false: omission keeps the authored/generated source * levels, and `?tnImpostors=1` or `TN_IMPOSTORS=1` opts a launch in. It is opt-in until the judged - * Machinefall capture passes (PRD-473 AC-3); `preserveAuthoredParts` and `lodHysteresis` still - * require it to be off. + * Machinefall capture passes (PRD-473 AC-3); authored parts with dither are supported on the explicit CPU path. * * Set true to bake them, or leave it off to keep the source levels for a visual comparison. * @@ -882,6 +890,8 @@ export interface IWorldCellsStats { readonly atlasBytes: number; readonly budgetBytes: number; readonly terminalTriangles: number; + /** Live main-pass terminal instances (before fragment masks), including far aggregates. */ + readonly drawn: number; /** * The whole-map far aggregates: one `InstancedMesh` per atlas cache key, the ORIGINAL placements * they hold (`instances`), how many of those the near ring has taken back (`nearOwned`), the @@ -3561,6 +3571,7 @@ async function assetSignature( parts.push(`${await url(lod.glb)}@${String(lod.distance)}`); parts.push( definition.maxDistance === undefined ? "" : String(definition.maxDistance), + definition.impostorDistance === undefined ? "" : String(definition.impostorDistance), definition.bounds.min.join(","), definition.bounds.max.join(","), ); @@ -4159,6 +4170,7 @@ export class WorldCells extends Group implements IComputeDriven { readonly #cells: readonly IWorldCell[]; readonly #cellSize: number; readonly #follow: IWorldCellsFollow; + readonly #onPublication: IWorldCellsInit["onPublication"]; readonly #loader: IAssetLoader; /** * Whether {@link #loader} was created here rather than supplied by the game. Only an internally @@ -4198,8 +4210,13 @@ export class WorldCells extends Group implements IComputeDriven { readonly #terrain: TerrainTiles | undefined; readonly #hasColliders: boolean; readonly #lodHysteresis: number; - readonly #placementLevels: Uint32Array | undefined; + readonly #lodEye = uniform(new Vector3()); + readonly #lodDistanceBias = uniform(1); + readonly #lodMaterials = new Map(); readonly #placementReach: Float32Array | undefined; + readonly #readinessVisible = new Map(); + #readinessView: number[] = []; + #readinessReach: Float32Array | undefined; readonly #reachRanges: Map< IWorldCell, Map @@ -4612,6 +4629,7 @@ export class WorldCells extends Group implements IComputeDriven { : admissionBudgetMs(init.coveredAdmissionBudgetMs); this.#now = init.admissionNow ?? ((): number => globalThis.performance?.now() ?? Date.now()); this.#follow = init.follow; + this.#onPublication = init.onPublication; this.#manifest = init.manifest; // Before the first `#canonical` read, which is the run loop below. this.#aliases = init.aliases; @@ -4639,10 +4657,6 @@ export class WorldCells extends Group implements IComputeDriven { this.#minZ = init.manifest.extent.minZ; this.#placements = init.placements; this.#lodHysteresis = init.lodHysteresis ?? 0; - this.#placementLevels = - this.#lodHysteresis > 0 - ? new Uint32Array(init.placements.byteLength / PLACEMENT_RECORD_BYTES) - : undefined; this.#placementReach = init.placementReach; this.#reachRanges = init.reachRanges; this.#rebuildsPerUpdate = positiveInteger(init.rebuildsPerUpdate ?? 16, "rebuildsPerUpdate"); @@ -4768,19 +4782,17 @@ export class WorldCells extends Group implements IComputeDriven { } static async load(options: IWorldCellsLoadOptions): Promise { - if (options.preserveAuthoredParts && options.impostors) - throw new Error("WorldCells preserveAuthoredParts requires impostors:false."); + if (options.preserveAuthoredParts && options.impostors && options.gpuScene !== false) + throw new Error("WorldCells preserveAuthoredParts with impostors requires gpuScene:false."); if ( options.lodHysteresis !== undefined && (!Number.isFinite(options.lodHysteresis) || options.lodHysteresis < 0 || options.lodHysteresis >= 1 || options.gpuScene !== false || - options.impostors === true) + (options.impostors === true && !options.preserveAuthoredParts)) ) - throw new Error( - "WorldCells lodHysteresis must be in [0, 1) with gpuScene:false and impostors:false.", - ); + throw new Error("WorldCells lodHysteresis must be in [0, 1) with gpuScene:false."); const assets = options.assets ?? createAssetLoader(); const baseUrl = options.url.slice(0, options.url.lastIndexOf("/") + 1); // Logical paths, which is what the loader keys its manifest by: the authored name with no @@ -4991,7 +5003,9 @@ export class WorldCells extends Group implements IComputeDriven { for (const group of recorded) if (group.parent !== null) this.#bumpBundle(group); } // The adaptive LOD bias, before the dispatch below reads the gates it scales. + this.#lodEye.value.set(this.#follow.position.x, this.#followY(), this.#follow.position.z); this.#adaptLodBias(renderer); + this.#lodDistanceBias.value = biasedLodDistance(1); // The first frame that hands over a renderer is the only one that can answer whether this // backend can run the GPU scene, and it prints its answer once: `enable` reports, then returns // early for the rest of the world's life. @@ -5095,9 +5109,8 @@ export class WorldCells extends Group implements IComputeDriven { // shader builds the residency is about to need, and the allowance that spreads those is // `PREWARM_PER_UPDATE`. this.#drainPrewarm(); - // One bake view per render update, after the prewarm so its own atlas allocation never delays a - // node build the walk is waiting on, and before the levels are told so a level the bake just - // appended is in this frame's window. + // Drain the atlas while covered, one view otherwise, after prewarm so allocation cannot delay + // a node build, and before telling levels so a newly appended level is in this frame's window. this.#stepImpostor(renderer); // After the bake handoff, so a wide block it deferred to this frame's exhausted allowance is // claimed now; a no-op on every frame with nothing waiting. @@ -5106,7 +5119,7 @@ export class WorldCells extends Group implements IComputeDriven { // far mesh without the camera ever traversing its cell. Bounded to one asset per idle frame. this.#pumpFarAcquisition(); // After the drain, so the levels are told about this update's records and not the last one's. - this.#tellShadows(); + this.#tellShadows(this.#covered); // After the drain too, so a record admitted this update is in the window the camera draws and // not one frame behind it. for (const state of this.#cellProxies) this.#restoreCellProxySource(state); @@ -5491,7 +5504,7 @@ export class WorldCells extends Group implements IComputeDriven { if (this.#released || this.#shared.has(entry.key) || this.#retired.has(entry.key)) return; const shared = new SharedBatch( entry.geometry, - entry.material, + this.#lodSurface(entry.asset, entry.level, entry.part, entry.material), this.#runMax.get(entry.asset) ?? 1, this.#budgets.residentCells + 1, entry.key, @@ -5632,7 +5645,7 @@ export class WorldCells extends Group implements IComputeDriven { const name = role === "main" ? key - : role === "wide" && asset.impostor !== undefined + : role === "wide" && asset.impostor !== undefined && !this.#preserveAuthoredParts ? wideKey : `${key}@${role === "cluster" ? this.#followCluster() : WIDE_CLUSTER}`; // A retained key is already paid for; see `#mintPrewarm`. @@ -5948,35 +5961,110 @@ export class WorldCells extends Group implements IComputeDriven { return cell.chunksLoaded && cell.completedRuns.size === cell.cell.runs.length; } - /** Completed content intersecting a square around a fixed spawn, in world metres. */ + /** Completed content in a camera view/draw reach, or the existing spawn square in metres. */ readinessAt( - position: { readonly x: number; readonly z: number }, + position: Camera | { readonly x: number; readonly z: number }, radius = 0, ): IWorldRegionReadiness { + const camera = "isCamera" in position && position.isCamera ? (position as Camera) : undefined; + camera?.updateMatrixWorld(true); + const point = + camera === undefined + ? (position as { readonly x: number; readonly z: number }) + : camera.getWorldPosition(new Vector3()); + if (camera !== undefined) { + if ( + ![...camera.projectionMatrix.elements, ...camera.matrixWorldInverse.elements].every( + Number.isFinite, + ) + ) { + const name = camera.projectionMatrix.elements.every(Number.isFinite) + ? "matrixWorldInverse" + : "projectionMatrix"; + const elements = camera[name].elements; + const index = elements.findIndex((value) => !Number.isFinite(value)); + throw new Error( + `WorldCells readiness requires a finite camera projection and transform. ${name}[${index}]=${elements[index]}; position=${camera.position.toArray()}; rotation=${camera.quaternion.toArray()}; near=${Reflect.get(camera, "near")}; far=${Reflect.get(camera, "far")}; aspect=${Reflect.get(camera, "aspect")}; view=${JSON.stringify(Reflect.get(camera, "view"))}.`, + ); + } + _cullFrustum.setFromProjectionMatrix( + _cullProjScreen.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse), + camera.coordinateSystem, + ); + } if ( - !Number.isFinite(position.x) || - !Number.isFinite(position.z) || + !Number.isFinite(point.x) || + !Number.isFinite(point.z) || !Number.isFinite(radius) || radius < 0 ) throw new Error("WorldCells readiness requires finite coordinates and a nonnegative radius."); - const lowX = Math.floor((position.x - radius - this.#minX) / this.#cellSize); - const highX = Math.floor((position.x + radius - this.#minX) / this.#cellSize); - const lowZ = Math.floor((position.z - radius - this.#minZ) / this.#cellSize); - const highZ = Math.floor((position.z + radius - this.#minZ) / this.#cellSize); + if (camera !== undefined) { + const view = [ + ...camera.projectionMatrix.elements, + ...camera.matrixWorldInverse.elements, + point.x, + (point as Vector3).y, + point.z, + camera.coordinateSystem, + Number(this.#gpuScene.on), + ]; + // Package placements/bounds are immutable; reach is compared too so edits cannot go stale. + if ( + !view.every((value, index) => value === this.#readinessView[index]) || + this.#placementReach?.some((value, index) => value !== this.#readinessReach?.[index]) + ) { + this.#readinessVisible.clear(); + this.#readinessView = view; + if (this.#placementReach !== undefined) { + this.#readinessReach ??= new Float32Array(this.#placementReach.length); + this.#readinessReach.set(this.#placementReach); + } + } + } + const lowX = Math.floor((point.x - radius - this.#minX) / this.#cellSize); + const highX = Math.floor((point.x + radius - this.#minX) / this.#cellSize); + const lowZ = Math.floor((point.z - radius - this.#minZ) / this.#cellSize); + const highZ = Math.floor((point.z + radius - this.#minZ) / this.#cellSize); let requiredCells = 0; let loadedCells = 0; let failures = 0; + let terrainRequired = 0; + let terrainLoaded = 0; for (const cell of this.#cells) { - if (cell.x < lowX || cell.x > highX || cell.z < lowZ || cell.z > highZ) continue; + if ( + camera === undefined + ? cell.x < lowX || cell.x > highX || cell.z < lowZ || cell.z > highZ + : !this.#cellInView(cell, point as Vector3) + ) + continue; requiredCells++; + if (camera !== undefined && this.#terrain !== undefined) { + const coverage = this.#terrain.readinessAt( + { + x: this.#minX + (cell.x + 0.5) * this.#cellSize, + z: this.#minZ + (cell.z + 0.5) * this.#cellSize, + }, + this.#cellSize / 2, + ); + terrainRequired += coverage.required; + terrainLoaded += coverage.loaded; + } const resident = this.#resident.get(cellKey(cell.x, cell.z)); - if (resident && this.#cellLoaded(resident)) loadedCells++; + if ( + resident && + this.#cellLoaded(resident) && + (camera === undefined || !this.#jobs.some((job) => job.cell === resident)) + ) + loadedCells++; if (resident?.chunkFailed) failures++; for (const run of cell.runs) if (this.#assets.get(this.#canonical(run.asset))?.failed) failures++; } - const terrain = this.#terrain?.readinessAt(position, radius) ?? { required: 0, loaded: 0 }; + const terrain = + camera === undefined + ? (this.#terrain?.readinessAt(point, radius) ?? { required: 0, loaded: 0 }) + : { required: terrainRequired, loaded: terrainLoaded }; return { requiredCells, loadedCells, @@ -5988,10 +6076,65 @@ export class WorldCells extends Group implements IComputeDriven { !this.#released && failures === 0 && loadedCells === requiredCells && - terrain.loaded === terrain.required, + terrain.loaded === terrain.required && + (camera === undefined || (!this.#shadowMoved && !this.#refilterOwed)), }; } + /** Use the same authored placement bounds and distance test as admission, even before residency. */ + #cellInView(cell: IWorldCell, eye: Vector3): boolean { + const cached = this.#readinessVisible.get(cell); + if (cached !== undefined) return cached; + this.#readinessVisible.set(cell, false); + if (this.#terrain !== undefined || (cell.chunks?.length ?? 0) > 0) { + const box = this.#recordBounds; + box.min.set( + this.#minX + cell.x * this.#cellSize, + this.#extentBounds.min.y, + this.#minZ + cell.z * this.#cellSize, + ); + box.max.set( + this.#minX + (cell.x + 1) * this.#cellSize, + this.#extentBounds.max.y, + this.#minZ + (cell.z + 1) * this.#cellSize, + ); + if (_cullFrustum.intersectsBox(box)) { + this.#readinessVisible.set(cell, true); + return true; + } + } + for (const run of cell.runs) { + const asset = this.#manifest.assets[this.#canonical(run.asset)]; + if (asset === undefined) continue; + const records = cellPlacements(this.#placements, run); + for (let index = 0; index < run.count; index++) { + const at = index * PLACEMENT_RECORD_FLOATS; + this.#position.fromArray(records, at); + const distance = + this.#placementReach === undefined + ? Math.hypot(this.#position.x - eye.x, this.#position.z - eye.z) + : this.#position.distanceTo(eye); + const reach = this.#placementReach?.[run.offset + index]; + if (reach !== undefined && distance > reach) continue; + this.#rotation.fromArray(records, at + 3); + this.#scale.setScalar(records[at + 7] as number); + this.#matrix.compose(this.#position, this.#rotation, this.#scale); + const box = this.#boundsAt(asset.bounds, this.#matrix); + const inner = cullDistance(asset.maxDistance); + const centre = box.getCenter(this.#sourceCentre); + const drawDistance = this.#gpuScene.on + ? Math.hypot(centre.x - eye.x, centre.z - eye.z) + : distance; + if (inner !== undefined && drawDistance > inner) continue; + if (_cullFrustum.intersectsBox(box)) { + this.#readinessVisible.set(cell, true); + return true; + } + } + } + return false; + } + dispose(): void { if (this.#released) return; this.#released = true; @@ -6177,18 +6320,30 @@ export class WorldCells extends Group implements IComputeDriven { resolvedGlb: "", spilled: [], }; - if (this.#lodHysteresis > 0 && definition.lods !== undefined) { - asset.gates = [ - ...asset.gates, - ...asset.distances.slice(1).map((gate) => gate * (1 - this.#lodHysteresis)), - ]; - asset.threshold = Math.min(...asset.gates); - } + this.#ditherGates(asset); this.#assets.set(id, asset); for (const gate of asset.gates) this.#noteGate(gate); return asset; } + /** Refilter at admission-band edges too, so discarded source levels retire after the fade. */ + #ditherGates(asset: IAssetState): void { + if (this.#lodHysteresis <= 0) return; + const gates = [...asset.gates]; + for (let level = 0; level < asset.distances.length; level++) { + const near = asset.distances[level] ?? 0; + const far = asset.distances[level + 1] ?? Number.POSITIVE_INFINITY; + const slack = + Math.max(near, Number.isFinite(far) ? far : near) * this.#lodHysteresis + + (asset.distances[1] ?? 6) / 8; + if (near > slack) gates.push(near - slack); + if (Number.isFinite(far)) gates.push(far + slack); + } + asset.gates = gates; + asset.threshold = Math.min(...gates); + for (const gate of gates) this.#noteGate(gate); + } + #acquire(run: IWorldRun, cell: IResidentCell): void { const id = this.#canonical(run.asset); let asset = this.#assets.get(id); @@ -6440,6 +6595,10 @@ export class WorldCells extends Group implements IComputeDriven { // The source meshes and records are now published together. Until this point a queued impostor // replacement may have already changed the GPU slot table while the old mesh still draws. this.#publishGpuGates(job.asset); + for (const entry of job.fresh) { + if (entry.batch.count > 0 && entry.shadowBounds !== undefined) + this.#onPublication?.(entry.shadowBounds); + } // A batch the walk just wrote to, cleared or compacted holds new records, so the shadow levels // that drew the old ones are stale. One flag, told at most once a second. if (job.fresh.some((entry) => entry.batch.count > 0)) @@ -6593,7 +6752,7 @@ export class WorldCells extends Group implements IComputeDriven { * what the changed records' own bounds add: a hook that forwards it redraws the levels whose * window covers them, which for a streaming world is usually the finest one alone. */ - #tellShadows(): void { + #tellShadows(covered = false): void { if (!this.#shadowMoved) return; // No hook means there is no level to tell, so the flag is discharged here. Leaving it set made // `#residencyStale` true forever, and every update ran a full residency pass on a world that had @@ -6606,7 +6765,7 @@ export class WorldCells extends Group implements IComputeDriven { return; } const now = this.#now(); - if (now - this.#shadowToldAt < 1e3) return; + if (!covered && now - this.#shadowToldAt < 1e3) return; this.#shadowToldAt = now; this.#shadowMoved = false; const region = this.#shadowRegion; @@ -6823,24 +6982,13 @@ export class WorldCells extends Group implements IComputeDriven { // whole-asset impostor's terminal gate scaled by this placement's own scale. The bias is the // same adaptive multiplier the dispatch carries, so a biased walk crosses a switch here too. let level = levelAtGates( - { distances: asset.distances, impostor: asset.impostor !== undefined }, + { + distances: asset.distances, + impostor: asset.impostor !== undefined && asset.definition.impostorDistance === undefined, + }, biasedLodDistance(distance), scale, ); - if (this.#placementLevels !== undefined && asset.definition.lods !== undefined) { - const record = job.run.offset + index; - let previous = this.#placementLevels[record] as number; - // Outward gates remain authored; returning inward retains the previous level until the margin. - if (level > previous) previous = level; - while ( - previous > level && - biasedLodDistance(distance) < - (asset.distances[previous] as number) * (1 - this.#lodHysteresis) - ) - previous--; - level = previous; - this.#placementLevels[record] = level; - } // The record's own world bounds: the placement widened by the asset's authored bounds at the // scale it is drawn at. Read here because this loop already holds the placement and the scale, // and it is the one number the shadow levels' invalidation is tested against — the cell box is @@ -6850,69 +6998,85 @@ export class WorldCells extends Group implements IComputeDriven { // at the placement root: the parts' own offsets are baked into the atlas, so composing a source // part's offset again would apply it twice. One root record per placement, beside the per-part // batches and read only by the wide half; see `ICellBatch.wideRoot`. - if (wantRoots) { - job.roots ??= []; - const roots = job.roots; - let root = roots[level]; - if (root === undefined) { - const anchor = asset.levels[level]?.[0]; - if (anchor !== undefined) { - root = new InstancedBatch({ geometry: anchor.geometry, material: anchor.material }); - roots[level] = root; - } + const selected = [level]; + if (this.#lodHysteresis > 0) { + const d = biasedLodDistance(distance); + for (let adjacent = 0; adjacent < asset.levels.length; adjacent++) { + if (adjacent === level) continue; + const near = asset.distances[adjacent] ?? 0; + const far = asset.distances[adjacent + 1] ?? Number.POSITIVE_INFINITY; + // Admission headroom covers the existing refilter cadence; masks use the live eye. + const slack = + Math.max(near, Number.isFinite(far) ? far : near) * this.#lodHysteresis + + (asset.distances[1] ?? 6) / 8; + if (d >= near - slack && d <= far + slack) selected.push(adjacent); } - root?.add(this.#matrix); } - // The GPU scene's source record for this placement: the placement's own transform, the level it - // reached, and a sphere the dispatch tests against the camera's planes. The part offset is the - // key's own, so one record serves every part of the level — see `#placeSources`. - if (this.#gpuScene.on) { - const list = job.sources ?? []; - job.sources = list; - // The sphere is the asset's authored bounds *as placed*, centred where the bounds' centre - // lands under the placement's own transform: a sphere at the placement instead is not a - // bound of the placement, and every prop whose model does not straddle the origin lost the - // half of itself that reaches away from it — a post, a stump, a fern, all of them culled at - // the edge of the view with their bases outside it. The CPU path's own gate is the cell's box - // over the same widened bounds, and it is conservative in exactly the way this is now. The - // centre is the matrix applied to the authored centre, so the placement's quaternion rotates - // it: composing the offsets by hand dropped the rotation and put a turned off-centre prop's - // sphere where the unturned one would be. The radius is rotation-invariant and takes the - // scale alone, which is uniform. - const centre = this.#sourceCentre - .set( - ((bounds.min[0] as number) + (bounds.max[0] as number)) * 0.5, - ((bounds.min[1] as number) + (bounds.max[1] as number)) * 0.5, - ((bounds.min[2] as number) + (bounds.max[2] as number)) * 0.5, - ) - .applyMatrix4(this.#matrix); - list.push({ - level, - matrix: this.#matrix.clone(), - // `boundsRadius` is already the asset's authored bounds radius (half the diagonal), so the - // placement's sphere radius is that times its own uniform scale — halving it again put the - // sphere inside the prop and culled the half of it that reaches away from the placement. - radius: boundsRadius * Math.abs(scale), - // The magnitude, finite-sanitised: the dispatch's impostor gate takes `|scale|` exactly as - // `levelAtGates` does, so a mirrored placement reaches the atlas the same way. - scale: Number.isFinite(scale) ? Math.abs(scale) : 1, - x: centre.x, - y: centre.y, - z: centre.z, - }); - } - // Authored bounds include model-part offsets; apply the whole placement transform so - // rotated, mirrored and off-centre casters cannot fall outside visibility/shadow regions. - this.#boundsAt(bounds, this.#matrix); - const parts = asset.levels[level] as readonly IAssetPart[]; - const levelBatches = job.batches?.[level] as InstancedBatch[]; - const levelBoxes = job.boxes?.[level] as Box3[] | undefined; - for (const [part, entry] of parts.entries()) { - this.#instance.multiplyMatrices(this.#matrix, entry.local); - (levelBatches[part] as InstancedBatch).add(this.#instance); - const box = levelBoxes?.[part]; - if (box === undefined) continue; - box.union(this.#recordBounds); + for (level of selected) { + if (wantRoots) { + job.roots ??= []; + const roots = job.roots; + let root = roots[level]; + if (root === undefined) { + const anchor = asset.levels[level]?.[0]; + if (anchor !== undefined) { + root = new InstancedBatch({ geometry: anchor.geometry, material: anchor.material }); + roots[level] = root; + } + } + root?.add(this.#matrix); + } + // The GPU scene's source record for this placement: the placement's own transform, the level it + // reached, and a sphere the dispatch tests against the camera's planes. The part offset is the + // key's own, so one record serves every part of the level — see `#placeSources`. + if (this.#gpuScene.on) { + const list = job.sources ?? []; + job.sources = list; + // The sphere is the asset's authored bounds *as placed*, centred where the bounds' centre + // lands under the placement's own transform: a sphere at the placement instead is not a + // bound of the placement, and every prop whose model does not straddle the origin lost the + // half of itself that reaches away from it — a post, a stump, a fern, all of them culled at + // the edge of the view with their bases outside it. The CPU path's own gate is the cell's box + // over the same widened bounds, and it is conservative in exactly the way this is now. The + // centre is the matrix applied to the authored centre, so the placement's quaternion rotates + // it: composing the offsets by hand dropped the rotation and put a turned off-centre prop's + // sphere where the unturned one would be. The radius is rotation-invariant and takes the + // scale alone, which is uniform. + const centre = this.#sourceCentre + .set( + ((bounds.min[0] as number) + (bounds.max[0] as number)) * 0.5, + ((bounds.min[1] as number) + (bounds.max[1] as number)) * 0.5, + ((bounds.min[2] as number) + (bounds.max[2] as number)) * 0.5, + ) + .applyMatrix4(this.#matrix); + list.push({ + level, + matrix: this.#matrix.clone(), + // `boundsRadius` is already the asset's authored bounds radius (half the diagonal), so the + // placement's sphere radius is that times its own uniform scale — halving it again put the + // sphere inside the prop and culled the half of it that reaches away from the placement. + radius: boundsRadius * Math.abs(scale), + // The magnitude, finite-sanitised: the dispatch's impostor gate takes `|scale|` exactly as + // `levelAtGates` does, so a mirrored placement reaches the atlas the same way. + scale: Number.isFinite(scale) ? Math.abs(scale) : 1, + x: centre.x, + y: centre.y, + z: centre.z, + }); + } + // Authored bounds include model-part offsets; apply the whole placement transform so + // rotated, mirrored and off-centre casters cannot fall outside visibility/shadow regions. + this.#boundsAt(bounds, this.#matrix); + const parts = asset.levels[level] as readonly IAssetPart[]; + const levelBatches = job.batches?.[level] as InstancedBatch[]; + const levelBoxes = job.boxes?.[level] as Box3[] | undefined; + for (const [part, entry] of parts.entries()) { + this.#instance.multiplyMatrices(this.#matrix, entry.local); + (levelBatches[part] as InstancedBatch).add(this.#instance); + const box = levelBoxes?.[part]; + if (box === undefined) continue; + box.union(this.#recordBounds); + } } } } @@ -6923,9 +7087,6 @@ export class WorldCells extends Group implements IComputeDriven { * renderer pays for again on the frame it first draws the result. The mesh joins the cell in * {@link #swap}, with the rest of the job, so it is never half a replacement. * - * ponytail: a hard switch, no crossfade — a placement crosses a level boundary by being rebuilt - * into the other level's batch, so the swap pops. A blend needs a per-instance mix the - * InstancedBatch has no slot for; add one when a game's swap is visible enough to pay for it. */ /** * Fold one batch's own filter point into the cell's bracket, which only ever widens. See @@ -7021,7 +7182,8 @@ export class WorldCells extends Group implements IComputeDriven { // A whole-asset impostor is one shape for every part and level, so its far shadow is ONE mesh per // asset holding one root record per placement, not one mesh per level whose records a source part // offset would then double. Every other asset keeps the per-key mesh its per-part shapes need. - const impostor = this.#assets.get(assetId)?.impostor !== undefined; + const impostor = + !this.#preserveAuthoredParts && this.#assets.get(assetId)?.impostor !== undefined; const key = impostor ? `${assetId}:*@${WIDE_CLUSTER}` : `${this.#keyOf(entry, level)}@${WIDE_CLUSTER}`; @@ -7245,7 +7407,7 @@ export class WorldCells extends Group implements IComputeDriven { */ #wideOwed(assetId: string, part: number): boolean { if (part === 0) return true; - return this.#assets.get(assetId)?.impostor === undefined; + return this.#preserveAuthoredParts || this.#assets.get(assetId)?.impostor === undefined; } /** The `@x,z` half of a caster cluster key, for a resident cell. */ @@ -7262,6 +7424,28 @@ export class WorldCells extends Group implements IComputeDriven { return clusterOf(cell.x, cell.z, this.#cellsPerCullCell); } + #lodSurface(assetId: string, level: number, partIndex: number, source: Material): Material { + const asset = this.#assets.get(assetId); + const part = asset?.levels[level]?.[partIndex]; + if (this.#lodHysteresis <= 0 || !asset || !part || asset.distances.length < 2) return source; + const key = `${assetId}:${level}:${partIndex}:${asset.distances.length}`; + let material = this.#lodMaterials.get(key); + if (!material) { + material = worldLodMaterial( + source, + part.local, + asset.distances, + level, + this.#lodHysteresis, + this.#lodEye, + this.#lodDistanceBias, + this.#placementReach !== undefined, + ); + this.#lodMaterials.set(key, material); + } + return material; + } + /** * The shared batch for one key, created (a new mesh: counted against the frame's fresh allowance) * the first time any cell draws it. `undefined` means the allowance is spent and the caller waits @@ -7286,8 +7470,13 @@ export class WorldCells extends Group implements IComputeDriven { level = entry.level, ): SharedBatch | undefined { const existing = this.#shared.get(key); - if (existing !== undefined) return existing; - const shape = this.#shapeFor(entry, role, level); + if (existing !== undefined && this.#lodHysteresis === 0) return existing; + const shape = { ...this.#shapeFor(entry, role, level) }; + shape.material = this.#lodSurface(assetId, level, entry.part, shape.material); + if (existing !== undefined) { + existing.mesh.material = shape.material; + return existing; + } const smallCaster = this.#smallCaster(assetId); // The batch released when this asset's last cell left the ring is the one to draw into again, // so the mesh keeps the uuid and the node three built for it. It costs no fresh allowance, @@ -8373,7 +8562,7 @@ export class WorldCells extends Group implements IComputeDriven { * asset with no alpha-cutout LOD0 part never bakes, so it never needs the extra records. */ #rootsNeeded(asset: IAssetState): boolean { - if (this.#impostors === false) return false; + if (this.#impostors === false || this.#preserveAuthoredParts) return false; const parts = asset.levels[0]; return parts !== undefined && this.#impostorSource(parts) !== undefined; } @@ -8392,7 +8581,12 @@ export class WorldCells extends Group implements IComputeDriven { if (!isAlphaFoliage(material)) continue; return { source: material, - alphaTest: material.alphaTest > 0 ? material.alphaTest : DEFAULT_CUTOUT_ALPHA, + alphaTest: + typeof material.userData.tnImpostorAlphaTest === "number" + ? material.userData.tnImpostorAlphaTest + : material.alphaTest > 0 + ? material.alphaTest + : DEFAULT_CUTOUT_ALPHA, }; } return undefined; @@ -8437,7 +8631,12 @@ export class WorldCells extends Group implements IComputeDriven { * never baked: a solid impostor is not the thing it replaces. */ #maybeQueueImpostor(asset: IAssetState): void { - if (this.#impostors === false || asset.impostor !== undefined) return; + if ( + this.#impostors === false || + asset.impostor !== undefined || + (this.#preserveAuthoredParts && asset.definition.impostorDistance === undefined) + ) + return; const parts = asset.levels[0]; if (parts === undefined) return; const source = this.#impostorSource(parts); @@ -8455,7 +8654,7 @@ export class WorldCells extends Group implements IComputeDriven { } /** - * One view of the bake per render-cadence update, then the terminal level when it completes. + * Drain the entry queue while covered; otherwise capture one view per render update. * * The renderer is asked for its raw seam and the bake waits for one that can run it: a world that * has no renderer yet — or a backend whose raw seam is not the layered-target API — simply does @@ -8474,70 +8673,85 @@ export class WorldCells extends Group implements IComputeDriven { this.#dropUnsupportedImpostors(); return; } - if (this.#impostorBaking === undefined) { - while (this.#impostorQueue.length > 0) { - const asset = this.#impostorQueue.shift() as IAssetState; - this.#impostorQueued.delete(asset.id); - if (this.#assets.get(asset.id) !== asset || asset.disposed || asset.impostor !== undefined) - continue; - const parts = asset.levels[0]; - if (parts === undefined || this.#impostorSource(parts) === undefined) continue; - // Re-check the completed cache when DEQUEUED, not only when queued: two entries whose bake - // was queued before either finished share one atlas instead of baking it twice. - const key = this.#impostorKey(asset); - const cached = this.#impostorRecords.get(key); - if (cached !== undefined) { - this.#touchImpostor(key, cached); - this.#finishImpostor(asset, cached); - continue; - } - // Enforce the declared colour+mip budget BEFORE the atlas is allocated, and never at the - // cost of an atlas a live asset draws from: inactive LRU records make room first. - if ( - this.#impostorRecordBytes + this.#impostorReservedBytes + IMPOSTOR_ATLAS_BYTES > - this.#impostorBudgetBytes - ) - this.#evictImpostors(IMPOSTOR_ATLAS_BYTES); - if (this.#impostorRecordBytes + IMPOSTOR_ATLAS_BYTES > this.#impostorBudgetBytes) { - // Out of room: keep the source LODs and say why, rather than overrun the budget with one - // more atlas or evict one still in use. - this.#reportImpostor(asset, "budget"); - // A far-only species fetched for the far half alone has no near run to fall back on, and - // nothing here retries an atlas-budget refusal, so keeping its source GLB alive buys - // nothing. Release it; the near path re-acquires it normally if the follow point reaches it. - if (this.#farTemp.has(asset.id)) this.#releaseFarTemp(asset.id); - continue; + const jobs = this.#covered + ? this.#impostorQueue.length + (this.#impostorBaking === undefined ? 0 : 1) + : 1; + for (let remainingJobs = jobs; remainingJobs > 0; remainingJobs -= 1) { + if (this.#impostorBaking === undefined) { + while (this.#impostorQueue.length > 0) { + const asset = this.#impostorQueue.shift() as IAssetState; + this.#impostorQueued.delete(asset.id); + if ( + this.#assets.get(asset.id) !== asset || + asset.disposed || + asset.impostor !== undefined + ) + continue; + const parts = asset.levels[0]; + if (parts === undefined || this.#impostorSource(parts) === undefined) continue; + // Re-check the completed cache when DEQUEUED, not only when queued: two entries whose bake + // was queued before either finished share one atlas instead of baking it twice. + const key = this.#impostorKey(asset); + const cached = this.#impostorRecords.get(key); + if (cached !== undefined) { + this.#touchImpostor(key, cached); + this.#finishImpostor(asset, cached); + continue; + } + // Enforce the declared colour+mip budget BEFORE the atlas is allocated, and never at the + // cost of an atlas a live asset draws from: inactive LRU records make room first. + if ( + this.#impostorRecordBytes + this.#impostorReservedBytes + IMPOSTOR_ATLAS_BYTES > + this.#impostorBudgetBytes + ) + this.#evictImpostors(IMPOSTOR_ATLAS_BYTES); + if (this.#impostorRecordBytes + IMPOSTOR_ATLAS_BYTES > this.#impostorBudgetBytes) { + // Out of room: keep the source LODs and say why, rather than overrun the budget with one + // more atlas or evict one still in use. + this.#reportImpostor(asset, "budget"); + // A far-only species fetched for the far half alone has no near run to fall back on, and + // nothing here retries an atlas-budget refusal, so keeping its source GLB alive buys + // nothing. Release it; the near path re-acquires it normally if the follow point reaches it. + if (this.#farTemp.has(asset.id)) this.#releaseFarTemp(asset.id); + continue; + } + const staged: IImpostorPart[] = impostorParts(parts); + try { + this.#baker.begin(asset.id, staged, { pixels: IMPOSTOR_FRAME_PIXELS }); + } catch { + this.#failures += 1; + this.#reportImpostor(asset, "begin-failed"); + continue; + } + this.#impostorReservedBytes = IMPOSTOR_ATLAS_BYTES; + this.#impostorBaking = asset; + break; } - const staged: IImpostorPart[] = impostorParts(parts); - try { - this.#baker.begin(asset.id, staged, { pixels: IMPOSTOR_FRAME_PIXELS }); - } catch { - this.#failures += 1; - this.#reportImpostor(asset, "begin-failed"); - continue; + } + const baking = this.#impostorBaking; + if (baking === undefined) return; + const raw = impostorRawRenderer(renderer); + if (raw === undefined) return; + let atlas: WorldImpostorAtlas | undefined; + try { + const views = this.#covered ? IMPOSTOR_VIEWS - (this.#baker.pending?.view ?? 0) : 1; + for (let remaining = views; remaining > 0; remaining -= 1) { + const view = this.#baker.pending?.view; + atlas = this.#baker.step(raw); + // A deferred capture must yield to the next update, never spin waiting for compilation. + if (atlas !== undefined || this.#baker.pending?.view === view) break; } - this.#impostorReservedBytes = IMPOSTOR_ATLAS_BYTES; - this.#impostorBaking = asset; - break; + } catch { + this.#failures += 1; + this.#impostorBaking = undefined; + this.#impostorReservedBytes = 0; + this.#reportImpostor(baking, "bake-failed"); + return; } - } - const baking = this.#impostorBaking; - if (baking === undefined) return; - const raw = impostorRawRenderer(renderer); - if (raw === undefined) return; - let atlas: WorldImpostorAtlas | undefined; - try { - atlas = this.#baker.step(raw); - } catch { - this.#failures += 1; + if (atlas === undefined) return; this.#impostorBaking = undefined; - this.#impostorReservedBytes = 0; - this.#reportImpostor(baking, "bake-failed"); - return; + this.#adoptImpostorAtlas(baking, atlas); } - if (atlas === undefined) return; - this.#impostorBaking = undefined; - this.#adoptImpostorAtlas(baking, atlas); } /** @@ -8640,7 +8854,9 @@ export class WorldCells extends Group implements IComputeDriven { // the atlas 8-12x as far instead of switching at the unit-scale distance. There is no fixed // margin: `levelAtGates` floors the terminal one representable Float32 step past the last source // gate, so the source level keeps a window while the ordering survives the f32 store. - const switchAt = (2 * record.radius * this.#autoLodPixelsPerUnit) / IMPOSTOR_FRAME_PIXELS; + const switchAt = + asset.definition.impostorDistance ?? + (2 * record.radius * this.#autoLodPixelsPerUnit) / IMPOSTOR_FRAME_PIXELS; if (!Number.isFinite(switchAt) || switchAt <= 0) { surface.dispose(); return; @@ -8657,11 +8873,21 @@ export class WorldCells extends Group implements IComputeDriven { this.#noteGate(switchAt); asset.threshold = asset.gates.length === 0 ? undefined : Math.min(...asset.gates); asset.impostor = { part, record, surface }; + this.#ditherGates(asset); + for (const shared of this.#shared.values()) { + if (!shared.mesh.name.startsWith(`${asset.id}:`)) continue; + const fields = shared.mesh.name.slice(asset.id.length + 1).split(":"); + const level = Number(fields[0]); + const partIndex = Number(fields[1]?.split("@")[0]); + const sourcePart = asset.levels[level]?.[partIndex]; + if (sourcePart) + shared.mesh.material = this.#lodSurface(asset.id, level, partIndex, sourcePart.material); + } record.users += 1; // The whole-map far aggregate for this atlas key, before the rebuild loop below: its segments // exist when the swaps land, and any run already near-ready is handed over immediately. A budget // refusal keeps only the far cohort's metadata and surface in `#farRetry`; see `#buildFar`. - this.#buildFar(asset, record); + if (!this.#preserveAuthoredParts) this.#buildFar(asset, record); this.#reportImpostor(asset, "ready"); // A far-only asset has no near batches or casters to hand over; once its aggregate is up its // temporary source has done its job and goes, while the atlas the aggregate borrows stays. A @@ -8674,7 +8900,7 @@ export class WorldCells extends Group implements IComputeDriven { // The far casters of the levels that were already built: part 0's key is re-pointed at the one // whole-asset shape, and the per-part keys the terminal level has no counterpart for are dropped, // so the far shadow draws one quad per placement rather than a duplicate per bark/needle part. - this.#retargetWideCasters(asset); + if (!this.#preserveAuthoredParts) this.#retargetWideCasters(asset); if (this.#gpuScene.on) { // Sources already carry each placement's root transform. Publish the new terminal key and // gates together; the dispatch selects it without rebuilding resident runs on the CPU. @@ -9264,7 +9490,7 @@ export class WorldCells extends Group implements IComputeDriven { for (const id of ids) { const asset = this.#assets.get(id); if (asset === undefined || asset.impostor === undefined) continue; - this.#retargetWideCasters(asset); + if (!this.#preserveAuthoredParts) this.#retargetWideCasters(asset); } } @@ -9284,6 +9510,16 @@ export class WorldCells extends Group implements IComputeDriven { far: this.#farStats(), pending: this.#impostorQueue.length + (this.#impostorBaking === undefined ? 0 : 1), terminalTriangles, + drawn: [...this.#shared.values()].reduce( + (sum, shared) => + sum + + (shared.role === "main" && + shared.mesh.visible && + (shared.mesh.material as Material).userData.tnWholeAssetImpostor + ? shared.live + : 0), + this.#farStats().live, + ), }; } @@ -10374,6 +10610,11 @@ export class WorldCells extends Group implements IComputeDriven { asset.refcount -= 1; if (asset.refcount > 0) return; this.#assets.delete(id); + for (const [key, material] of this.#lodMaterials) + if (key.startsWith(`${id}:`)) { + material.dispose(); + this.#lodMaterials.delete(key); + } asset.disposed = true; // Before anything is released: a pending bake stages this asset's borrowed geometry, and a view // drawn after the teardown reads a disposed buffer. Then the terminal level's own surface, whose diff --git a/packages/core/src/world-package.ts b/packages/core/src/world-package.ts index 0338081986..d43b06962f 100644 --- a/packages/core/src/world-package.ts +++ b/packages/core/src/world-package.ts @@ -53,6 +53,8 @@ export interface IWorldAssetBounds { } export interface IWorldAsset { + /** Caller-owned terminal impostor distance in world metres; requires WorldCells impostors opt-in. */ + readonly impostorDistance?: number; readonly glb: string; readonly lods?: readonly IWorldAssetLod[]; readonly bounds: IWorldAssetBounds; @@ -295,6 +297,13 @@ export function validateWorldPackage( ); } } + if ( + value.impostorDistance !== undefined && + (typeof value.impostorDistance !== "number" || + !Number.isFinite(value.impostorDistance) || + value.impostorDistance <= 0) + ) + malformed(`${path}.impostorDistance`, "impostorDistance must be positive finite."); if (value.lods !== undefined) { if (!Array.isArray(value.lods)) { malformed(`${path}.lods`, `${path}.lods must be an array.`); @@ -307,6 +316,15 @@ export function validateWorldPackage( } text(lod.glb, `${lodPath}.glb`); finite(lod.distance, `${lodPath}.distance`); + if ( + typeof value.impostorDistance === "number" && + typeof lod.distance === "number" && + value.impostorDistance <= lod.distance + ) + malformed( + `${path}.impostorDistance`, + "impostorDistance must follow every authored LOD gate.", + ); }); } } diff --git a/packages/create-threenative/__tests__/sailing-readiness-host.spec.ts b/packages/create-threenative/__tests__/sailing-readiness-host.spec.ts index 6f6342a6c2..a512a0871e 100644 --- a/packages/create-threenative/__tests__/sailing-readiness-host.spec.ts +++ b/packages/create-threenative/__tests__/sailing-readiness-host.spec.ts @@ -105,14 +105,18 @@ it("the real Game keeps world callbacks progressing during a height hold with it expect(updates).toBe(heldUpdates); height = 0; await present(27); + // Readiness now requires two rendered quiet polls after the world has been admitted and the + // shadow maps have settled, so the released hold needs two presented frames to close the gate. + await present(28); + await present(29); await ctx?.startup.whenReady(); expect(ctx?.startup.phase).toBe("ready"); const finalSamples = samples; - await present(28); + await present(30); expect(samples).toBe(finalSamples); expect(dispatches).toBe(1); await game.goto("sea"); - await present(29); + await present(31); expect(dispatches).toBe(1); expect(samples).toBe(finalSamples); } finally { diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 789e9ca752..4cc7f730e7 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -189,6 +189,10 @@ const BUG_REPORT_SKILL_PATHS = [ // Three patch. Restoring the previous patch recovers every prior fingerprint. // Recomputed from clean committed c22e5ba8d for the independently reviewed bounded-upload // package patch embedded by every scaffold. No template, capability or appearance change. +// PRD-618: byte-compared all 1,484 files across thirteen actual no-install trees at +// 919dc0356 against f281d95c9: only patches/three@0.185.1.patch changes, deduplicating +// scheduled previous-instance history uploads in source and both WebGPU bundles. +// The migration patch is not copied; predecessor patch bytes recover every prior fingerprint. const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // PRD-569 merge: measured all thirteen createProject trees; action-rpg retains onCull. // PRD-563: every kit's render/quality.ts and scenes carry the GPU-class start tier. @@ -253,20 +257,20 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Recomputed 2026-10-13: the onCull paragraph in action-rpg AGENTS.md folded to one line to meet the 100-line budget. // PRD-565: only the copied canonical Three patch changes; substituting its parent bytes // recovers all thirteen prior hashes. Measured through the actual createProject tree test. - "action-rpg": "2ad57690b87fe80faa26931ea11d6ea210ac787bb58c7bb0f83e7b653426eeb7", - minimal: "6e9d255a6033e239e73dfabec172dfebb4a88849c01ca461d476ab8b17b91cbd", - platformer: "2ccf93236a0d6c1b8aa00bd3f86585427149f3788640dccb491f8d451c2c6894", - puzzle: "a25a9129f070b8a4cd25c1d3a174950b0367eba85d5a414b62325a571e2c40f3", - racing: "0fb3b6a6eacfc54acd74ae086cb5502789fdfe83e0466306202053843314f663", - rain: "9523312a5c0573b6ae886bd63ade36922a86f7a9b8e3730f7ead743609357bd7", - rts: "8a8d80b543d77717243274896b5b7a6a723b92e3a3f252fd470b2fb6dcfc12c8", - runner: "b14123d911dc3eb7d73e1ab3f91269b480a028938efff4a2bea4f56f2e570906", + "action-rpg": "02844ed9ee786d1974368dda7788cb9e5c2937d2ffe2bb58e20bca912af8333f", + minimal: "872c3d3d2c8a31b5787b2c3903c913be3b438b85960dcad6f9fb49aa9e453f67", + platformer: "a3d50a816ca38072b5f119bb8b278b6faf717a9156da8062fe1c8d3cb17869c5", + puzzle: "edd94e0e83c6b11f699f1eddbb27c9ccef9498789b5f3aa0be14d5013135d819", + racing: "587e197af3849d1e86dd8fcde93aa9e95b3415531973ad123dbffa0fd2c0ebf6", + rain: "da0d5d44d2dd7c67d862438b324673ff030954e8d806c860e68c3946113ce0f2", + rts: "6d9e8539bf955792742f9735c843a68dd61094081f84d41debfa4f24216e4311", + runner: "2c471ae34e4461ebe51a13986885a1b855495e86f7d4bc21cc3170ad8f416d91", // Initial finite-height readiness plus its scene-owned lifecycle helper and mirrored docs. - sailing: "73da167ba1fdc6360c58e61b51a2f3dba047fbe1c4e88648e9bce4abb64325da", - shooter: "ca09b0b25f3ba689af53772d82be0978d82cdaff8f997b04b160874428ec3e54", - snow: "482c1c7f1cd2f233d43cc3fd378c983b4ab8f13dca4bd6fca008e5b120cb0705", - starter: "fe84f7a992a2ffa07792e10efe4b673e01499f3d4d21bc09dac8e4397645c1c4", - "tower-defense": "7d816e530aee8842027a1666782d96c51b2cbfde55c4c23a7cdd1b0fd6a267ac", + sailing: "7a027d3d5a791fea6edb1ef969375362ecff576158b887889b046dc188f4758c", + shooter: "6391e4df9d4233d1239ed82e9abed74d28a6da15860d11b56cf8d404c74cb356", + snow: "e836d6b316d99273cb45e88770199a0ccb2333b2ad83953b4660f945f83cb049", + starter: "99806a1649ae75ea5fb48051c48250b898fddd083d29c559faf7ea3a84c14bce", + "tower-defense": "1cac420eee7bbbed3a703fcb21d53af0bc6c185a905eca4698503a09dcbfa575", }; const GENERATED_SCAFFOLD_METADATA = diff --git a/packages/create-threenative/agent-docs/references/capability-reference.md b/packages/create-threenative/agent-docs/references/capability-reference.md index ca37b8710a..9f7c58bf26 100644 --- a/packages/create-threenative/agent-docs/references/capability-reference.md +++ b/packages/create-threenative/agent-docs/references/capability-reference.md @@ -340,6 +340,21 @@ export function texturePass(options: ITexturePassOptions = { … } const pass = texturePass({ quality: 150 }); ``` +### `thinFoliageCards` + +`function` — Build a standalone card-thinned foliage mesh, preserving opaque parts and vertex attributes. + +```ts +export function thinFoliageCards(document: Document, keepRatio: number): number { … } +``` + +- **Use when:** prepare a far foliage mesh by thinning whole cards instead of triangles +- **Constraints:** mutates the supplied document; rejects unsupported MASK topology and invalid ratios + +```ts +thinFoliageCards(document, 0.14); +``` + ### `totalCensus` `function` — Census one glTF document, or a directory of them: the merge buckets a scene has now, and the buckets it would have once its atlasable textures shared pages. diff --git a/packages/create-threenative/capabilities.json b/packages/create-threenative/capabilities.json index d84efdd9c0..e6d052f14a 100644 --- a/packages/create-threenative/capabilities.json +++ b/packages/create-threenative/capabilities.json @@ -431,6 +431,22 @@ "supersedes": [], "symbol": "texturePass" }, + { + "aliases": [], + "constraints": [ + "mutates the supplied document; rejects unsupported MASK topology and invalid ratios" + ], + "example": "thinFoliageCards(document, 0.14);", + "importPath": "@threenative/assets", + "kind": "function", + "overrides": [], + "package": "@threenative/assets", + "signature": "export function thinFoliageCards(document: Document, keepRatio: number): number { … }", + "situations": ["prepare a far foliage mesh by thinning whole cards instead of triangles"], + "summary": "Build a standalone card-thinned foliage mesh, preserving opaque parts and vertex attributes.", + "supersedes": [], + "symbol": "thinFoliageCards" + }, { "aliases": [], "constraints": [ diff --git a/packages/playtest/docs/reference.md b/packages/playtest/docs/reference.md index d4548ff157..5157da027d 100644 --- a/packages/playtest/docs/reference.md +++ b/packages/playtest/docs/reference.md @@ -17,7 +17,7 @@ browser and Android, which is the only thing that says which build the run exerc project; there is no root `pnpm dev`. `--browser-recipe webgpu` supplies the current Chromium WebGPU flags including `--enable-features=Vulkan`, without which Chromium silently serves WebGPU from SwiftShader and reports healthy-looking limits from a CPU rasteriser; `--browser-arg` is the escape -hatch, and a run that does not name its adapter is not evidence. **On Linux the runner provisions a +hatch, and a run that does not name its adapter is not evidence. `--cpu-prof` starts before page navigation so the profile includes startup plus steps, and profiling perturbs the measurement so it is diagnose-only, never a timing verdict. **On Linux the runner provisions a private Xvfb for every pixel-producing run, whether or not a display exists** (stripping Wayland env itself), so a scenario never opens windows over whatever the operator is doing — a sweep that borrows `:0` makes the machine unusable for as long as it runs. Set `TN_PLAYTEST_HOST_DISPLAY=1` to diff --git a/packages/playtest/src/runner/runner.ts b/packages/playtest/src/runner/runner.ts index 50aded52f7..f58b09dd28 100644 --- a/packages/playtest/src/runner/runner.ts +++ b/packages/playtest/src/runner/runner.ts @@ -436,6 +436,9 @@ async function runStandalonePlaytestInternal( pageLifecycle.closed = true; }); const activePage = page; + if (activeConfig.cpuProfilePath !== undefined) { + cpuProfile = await startBrowserCpuProfile(activePage, activeConfig.cpuProfilePath); + } const bridge = await openRunnerPage(page, browserConfig, scenario, options.remoteBrowser); // From here on the page is expected to stay put; anything that moves it is evidence. pageLifecycle.settled = true; @@ -703,9 +706,6 @@ async function runStandalonePlaytestInternal( : {}) : undefined; let framebufferCoverage: IPlaytestFramebufferCoverageObservation | undefined; - if (activeConfig.cpuProfilePath !== undefined) { - cpuProfile = await startBrowserCpuProfile(activePage, activeConfig.cpuProfilePath); - } for (const [index, step] of scenario.steps.entries()) { const framebufferAssertion = scenario.assert?.framebufferCoverage; if (framebufferAssertion !== undefined diff --git a/packages/runtime-native/src/webgpu/bindings_frame_stream.cpp b/packages/runtime-native/src/webgpu/bindings_frame_stream.cpp index dbd8767025..835dd01b84 100644 --- a/packages/runtime-native/src/webgpu/bindings_frame_stream.cpp +++ b/packages/runtime-native/src/webgpu/bindings_frame_stream.cpp @@ -1061,8 +1061,9 @@ static bool replayFrameRecords(BindingsState* state, const uint8_t* data, size_t .count()); if (replayNs >= kSlowReplayNs) reportSlowReplay(replayNs, opTiming, kFrameOpNames, kFrameOpCount, seen); + // Finished, unsubmitted buffers are valid discarded work; only recording objects remain open. if (r.ok && (!renderPasses.empty() || !renderOwners.empty() || !computePasses.empty() || - !computeOwners.empty() || !encoders.empty() || !commandBuffers.empty())) { + !computeOwners.empty() || !encoders.empty())) { fail("frame ended with unfinished GPU objects"); } if (!r.ok || seen != declaredOps || r.cursor != declaredBytes) { @@ -1100,6 +1101,10 @@ static bool replayFrameRecords(BindingsState* state, const uint8_t* data, size_t computePasses.clear(); computeOwners.clear(); encoders.clear(); + // Submitted buffers were already released and erased; release discarded buffers here. + for (const auto& [id, commandBuffer] : commandBuffers) { + wgpuCommandBufferRelease(commandBuffer); + } commandBuffers.clear(); submittedCommandBuffers.clear(); rawCommandBuffers.clear(); diff --git a/packages/runtime-native/tests/frame-op-stream.test.mjs b/packages/runtime-native/tests/frame-op-stream.test.mjs index 9c08172663..85f66947c6 100644 --- a/packages/runtime-native/tests/frame-op-stream.test.mjs +++ b/packages/runtime-native/tests/frame-op-stream.test.mjs @@ -283,6 +283,13 @@ describe("packed frame op stream", () => { expect(drain()).toBeNull(); }); + it("records a finished command buffer without a submit", () => { + const { device, drain } = harness(); + device.createCommandEncoder().finish(); + expect(records(drain()).result.map(({ opcode }) => opcode)).toEqual([2, 28]); + expect(drain()).toBeNull(); + }); + it("keeps render and compute pass methods on shared receiver-aware prototypes", () => { const { device, drain } = harness(); const encoderA = device.createCommandEncoder(); diff --git a/packages/runtime-native/tests/frame_op_stream_replay_test.cpp b/packages/runtime-native/tests/frame_op_stream_replay_test.cpp index d6b0f309e8..0925426a76 100644 --- a/packages/runtime-native/tests/frame_op_stream_replay_test.cpp +++ b/packages/runtime-native/tests/frame_op_stream_replay_test.cpp @@ -735,6 +735,34 @@ void runPlanContract() { expectAccepted(state, patchPacket(41, "[{index:2,runs:[{offset:8,words:[1,901]}]}]"), emptyFrameOrder, "a patch of unchanged words applies and replays"); + // A frame may finish a command encoder and never submit it: three's transient-depth abort ends + // a pass, finishes the encoder, and discards the command buffer. A finished buffer is closed + // work, not an object still recording, so the frame is accepted and the completed buffer is + // released with the rest of the frame rather than reported as unfinished. + auto discardPacket = [](int sequence) { + std::ostringstream out; + out << "() => { const b = new ArrayBuffer(56); const v = new DataView(b);" + " v.setUint32(0, 0x544e4652, true); v.setUint32(4, 3, true); v.setUint32(8, 56, true);" + " v.setUint32(12, 1, true); v.setUint32(16, " + << sequence + << ", true); v.setUint32(20, 2, true);" + " v.setUint32(24, 2, true); v.setUint32(28, 16, true); v.setUint32(32, 900, true);" + " v.setUint32(40, 28, true); v.setUint32(44, 16, true); v.setUint32(48, 900, true);" + " v.setUint32(52, 901, true); return b; }"; + return out.str(); + }; + expectAccepted(state, discardPacket(42), {"createCommandEncoder", "finish"}, + "a frame that finishes a command buffer without submitting it is accepted"); + // The same encoder left open still fails closed, so accepting a finished buffer did not weaken + // the guard on the objects that are genuinely still recording. + expectMalformed(state, + "() => { const b = new ArrayBuffer(40); const v = new DataView(b);" + " v.setUint32(0, 0x544e4652, true); v.setUint32(4, 3, true); v.setUint32(8, 40, true);" + " v.setUint32(12, 1, true); v.setUint32(16, 44, true); v.setUint32(20, 1, true);" + " v.setUint32(24, 2, true); v.setUint32(28, 16, true); v.setUint32(32, 900, true); return b; }", + "frame ended with unfinished GPU objects", + "native parser rejects a frame that leaves its command encoder open"); + // A rejection drops the plan, so every structural case is judged against a capture it sets up // itself rather than against whatever the case before it left behind. int caseSequence = 50; diff --git a/packages/terrain/__tests__/prop-streaming.spec.ts b/packages/terrain/__tests__/prop-streaming.spec.ts index e96bb294bd..f78799bba9 100644 --- a/packages/terrain/__tests__/prop-streaming.spec.ts +++ b/packages/terrain/__tests__/prop-streaming.spec.ts @@ -317,10 +317,10 @@ it("retains every unlimited canopy placement and keeps prewarm pending until sce expect(streamed.worlds.every((world) => world.released)).toBe(true); }); -it("preserves authored LOD return hysteresis through an oscillating boundary walk and eviction/revisit", async () => { +it("submits authored neighbor LODs with complementary masks through a boundary walk and eviction/revisit", async () => { const placement = { - id: "boulder:boundary", - asset: "boulder", + id: "cliff:boundary", + asset: "cliff", position: [0, 0, 0], rotation: 0, scale: 1, @@ -335,7 +335,7 @@ it("preserves authored LOD return hysteresis through an oscillating boundary wal variant: 0, level, })); - const parts = new Map([[`boulder:${variantFor(placement, "boulder")}`, levels]]); + const parts = new Map([[`cliff:${variantFor(placement, "cliff")}`, levels]]); const materials = { bark: material } as never; const original = createProps([placement], () => ({ height: 0, offset: 0 }), parts, materials); const follow = { position: new Vector3() }; @@ -349,20 +349,54 @@ it("preserves authored LOD return hysteresis through an oscillating boundary wal whileCurrent: () => true, }); if (!streamed) throw new Error("cancelled"); - const active = (meshes: InstancedMesh[]) => - meshes - .filter((mesh) => mesh.count > 0) - .map((mesh) => Array.from(mesh.geometry.getAttribute("position").array)); + try { - for (const distance of [0, 65, 500, 55, 61, 59, 61, 59, 47, 155, 140, 151, 149, 119, 47]) { + for (const distance of [ + 0, 65, 500, 2000, 55, 61, 59, 61, 59, 47, 155, 140, 151, 149, 119, 47, 0, 220, + ]) { follow.position.x = distance; original.setLevels(follow.position); for (let frame = 0; frame < 100; frame++) { for (const world of streamed.worlds) world.update(); await Promise.resolve(); } - expect(active(streamed.meshes), `distance ${distance}`).toEqual(active(original.meshes)); + // CPU submission includes headroom beyond the shader fade; this is not pixel proof. + const submitted = streamed.meshes.filter((mesh) => mesh.count > 0); + const expected = + distance >= 500 + ? [] + : distance < 22.5 + ? [0] + : distance <= 79.5 + ? [0, 1] + : distance < 112.5 + ? [1] + : distance <= 187.5 + ? [1, 2] + : [2]; + expect( + submitted.map((mesh) => levels.findIndex((part) => part.geometry === mesh.geometry)).sort(), + `submitted authored levels at ${distance}`, + ).toEqual(expected); + for (const mesh of submitted) { + expect(mesh.count).toBe(1); + const surface = mesh.material as import("three/webgpu").NodeMaterial; + expect(surface.maskShadowNode).toBe(surface.maskNode); + const comparisons: string[] = []; + const values: unknown[] = []; + surface.maskNode?.traverse((node) => { + const op = Reflect.get(node, "op"); + if (op === "<" || op === ">=") comparisons.push(op); + if (Reflect.get(node, "isUniformNode")) values.push(Reflect.get(node, "value")); + }); + const level = levels.findIndex((part) => part.geometry === mesh.geometry); + expect(comparisons).toEqual(level === 0 ? [">="] : level === 1 ? ["<", ">="] : ["<"]); + expect(values.some((value) => value instanceof Vector3 && value.x === distance)).toBe(true); + expect(values).toContain(1); // Unadapted distance bias. + } } + expect(streamed.stats().evictions).toBeGreaterThan(0); + expect(streamed.stats().failures).toBe(0); } finally { streamed.dispose(); original.dispose(); @@ -487,13 +521,44 @@ it("drains representative multi-part canopy cell admission without thousands of }); if (!streamed) throw new Error("cancelled"); try { - for (let frame = 0; frame < 128; frame++) { - for (const world of streamed.worlds) world.update(); - await Promise.resolve(); + const cellSize = 128; + const cells = new Set( + placements.map( + (p) => + `${Math.floor((p.position[0] + 256) / cellSize)},${Math.floor((p.position[2] + 256) / cellSize)}`, + ), + ); + const levelRoles = parts.values().next().value?.length ?? 0; + const freshMeshes = cells.size * parts.size * levelRoles + 2 * parts.size * levelRoles; + expect(freshMeshes).toBeLessThanOrEqual(540); + // Two fresh meshes/update; allow eight microtask updates per cell for loading/publication. + const updateCap = Math.ceil(freshMeshes / 2) + cells.size * 8; + expect(updateCap).toBeLessThan(1000); + const timer = vi.spyOn(performance, "now").mockReturnValue(0); + try { + for (let frame = 0; frame < updateCap; frame++) { + for (const world of streamed.worlds) world.update(); + await Promise.resolve(); + } + expect(streamed.stats().admissionBacklog).toBe(0); + expect(streamed.stats().failures).toBe(0); + // Neighbor LOD submissions overlap. Count each authored placement/role once. + const instances = new Set(); + for (const mesh of streamed.meshes) { + const role = [...parts.values()] + .flat() + .find((part) => part.geometry === mesh.geometry)?.role; + expect(role).toBeDefined(); + for (let i = 0; i < mesh.count; i++) { + const pose = new Matrix4(); + mesh.getMatrixAt(i, pose); + instances.add(`${role}:${pose.elements.join(",")}`); + } + } + expect(instances.size).toBe(placements.length * 2); + } finally { + timer.mockRestore(); } - expect(streamed.stats().admissionBacklog).toBe(0); - expect(streamed.stats().failures).toBe(0); - expect(streamed.meshes.reduce((n, m) => n + m.count, 0)).toBe(placements.length * 2); } finally { streamed.dispose(); } @@ -504,7 +569,10 @@ it("preserves authored cutout LOD parts and distinct custom shader surfaces", as const { float } = await import("three/tsl"); const materials = [0.2, 0.8, 0.5].map((opacity) => { const material = new MeshPhysicalNodeMaterial({ alphaTest: 0.4 }); + material.color.setRGB(opacity, 0.3, 0.6); material.opacityNode = float(opacity); + material.maskNode = float(opacity).greaterThan(0.1); + material.maskShadowNode = float(opacity).greaterThan(0.15); return material; }); const placements = [ @@ -560,7 +628,7 @@ it("preserves authored cutout LOD parts and distinct custom shader surfaces", as if (!streamed) throw new Error("cancelled"); new Group().add(...streamed.worlds); try { - for (const z of [0, 80, 0]) { + for (const z of [0, 60, 80, 0]) { follow.position.z = z; original.setLevels(follow.position); for (let i = 0; i < 200; i++) { @@ -580,22 +648,49 @@ it("preserves authored cutout LOD parts and distinct custom shader surfaces", as } await Promise.resolve(); } - const active = (meshes: InstancedMesh[]) => - meshes - .filter((m) => m.count > 0) - .map((m) => ({ - material: (m.material as MeshBasicMaterial).uuid, - geometry: m.geometry.getAttribute("position").array.toString(), - count: m.count, - })); - expect(active(streamed.meshes)).toEqual(active(original.meshes)); + const authored = parts.values().next().value; + if (!authored) throw new Error("Missing authored parts"); + const expected = z === 0 ? authored.slice(0, 2) : z === 60 ? authored : authored.slice(2); + const active = (meshes: InstancedMesh[]) => meshes.filter((mesh) => mesh.count > 0); + const view = active(streamed.meshes); + expect(view.map((mesh) => mesh.geometry)).toEqual(expected.map((part) => part.geometry)); + for (const mesh of view) { + const part = authored.find((part) => part.geometry === mesh.geometry); + if (!part?.material) throw new Error("Unauthored geometry"); + const surface = mesh.material as typeof part.material; + expect(surface).not.toBe(part.material); + expect(surface.alphaTest).toBe(part.material.alphaTest); + expect(surface.color.equals(part.material.color)).toBe(true); + expect(surface.opacityNode).toBe(part.material.opacityNode); + expect(mesh.count).toBe(placements.length); + const viewMask = Reflect.get(surface.maskNode as object, "node"); + const shadowMask = Reflect.get(surface.maskShadowNode as object, "node"); + expect(Reflect.get(viewMask, "op")).toBe("&&"); + expect(Reflect.get(viewMask, "aNode")).toBe(part.material.maskNode); + expect(Reflect.get(shadowMask, "op")).toBe("&&"); + expect(Reflect.get(shadowMask, "aNode")).toBe(part.material.maskShadowNode); + expect(Reflect.get(viewMask, "bNode")).toBe(Reflect.get(shadowMask, "bNode")); + } + expect(new Set(view.map((mesh) => mesh.material)).size).toBe(expected.length); for (const layer of [28, 27]) { const casters: InstancedMesh[] = []; for (const world of streamed.worlds) world.traverse((node) => { if (node instanceof InstancedMesh && node.layers.isEnabled(layer)) casters.push(node); }); - expect(active(casters)).toEqual(active(original.meshes.filter((mesh) => mesh.castShadow))); + expect( + active(casters).map((mesh) => ({ + geometry: mesh.geometry, + material: mesh.material, + count: mesh.count, + })), + ).toEqual( + view.map((mesh) => ({ + geometry: mesh.geometry, + material: mesh.material, + count: mesh.count, + })), + ); } } } finally { diff --git a/packages/terrain/__tests__/strata-loading.spec.ts b/packages/terrain/__tests__/strata-loading.spec.ts index d80b378315..30879059fc 100644 --- a/packages/terrain/__tests__/strata-loading.spec.ts +++ b/packages/terrain/__tests__/strata-loading.spec.ts @@ -358,6 +358,40 @@ it("releases its layer only after the combined spawn and startup promise resolve expect(source.canvasLayer.scene.children).toHaveLength(0); }); +it.each(["ready", "cleanup", "failed"] as const)( + "notifies reveal synchronously after removing the cover only for successful startup: %s", + async (outcome) => { + let resolve: () => void = () => undefined; + let reject: (reason: Error) => void = () => undefined; + const source = host( + new Promise((done, fail) => { + resolve = done; + reject = fail; + }), + ); + const observations: { opaque: boolean; meshes: number }[] = []; + const screen = createLoadingScreen({ + ...source, + onReveal: () => { + observations.push({ + opaque: source.canvasLayer.opaque, + meshes: source.canvasLayer.scene.children.length, + }); + }, + }); + expect(observations).toEqual([]); + if (outcome === "cleanup") screen.finish(); + if (outcome === "failed") reject(new Error("Spawn failed")); + else resolve(); + await Promise.resolve(); + expect(observations).toEqual(outcome === "ready" ? [{ opaque: false, meshes: 0 }] : []); + screen.finish(); + screen.finish(); + await Promise.resolve(); + expect(observations).toHaveLength(outcome === "ready" ? 1 : 0); + }, +); + it("latches playable coverage so later spawn eviction never disables controls", async () => { const spawn = createSpawnReadiness("Strata spawn"); expect(spawn.ready).toBe(false); diff --git a/packages/terrain/starter-assets/credits.json b/packages/terrain/starter-assets/credits.json index 67716e65d3..a5f7f7b4ce 100644 --- a/packages/terrain/starter-assets/credits.json +++ b/packages/terrain/starter-assets/credits.json @@ -838,10 +838,10 @@ "generator": "examples/strata-terrain-preview/scripts/make-needle-atlas.mjs", "seed": 466468, "command": "node scripts/make-needle-atlas.mjs", - "bytes": 321934, + "bytes": 277676, "dimensions": "1024x1024 (4 cells of 512x512)", "preprocessing": "none. Drawn from a seeded PRNG and deflated as PNG by the script itself: no image library, no upload, byte-identical on every run.", - "sha256": "d2f1fd6eb3f87083a1c72a07add0c485f477b7b5e64e1d9db0108298c75eae50", + "sha256": "6bebd788b446d26a4b98738e9ba78686f98323e654d83c43741a0c50c31533f2", "why": "A photograph of foliage does not give a card with the twig down the middle and needles fanning off both sides, which is what makes an alpha-cutout branch read as a branch from every angle." }, "needle-surface.png": { @@ -849,10 +849,10 @@ "generator": "examples/strata-terrain-preview/scripts/make-needle-atlas.mjs", "seed": 466468, "command": "node scripts/make-needle-atlas.mjs", - "bytes": 1021932, + "bytes": 819103, "dimensions": "1024x1024 (4 cells of 512x512)", "preprocessing": "none. Cooked from the coverage and ridge the same run recorded while drawing the albedo, and deflated as PNG by the script itself: no image library, byte-identical on every run.", - "sha256": "45d236f6b61f745753fd86b06b0c27d451ae118c9f0164ce44dda51f297ecbf5", + "sha256": "f057b920099776c979607b9270199dc828c05bd8aa71e39c875c69c9160f4239", "why": "An alpha-tested quad has no thickness, so a crown built from cards had exactly one flat normal per card and read as stacked flat quads. A normal per needle plus the spray's own occlusion is what makes a card a mass of needles rather than a silhouette." } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eebb7dacc7..bb22f2faea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,7 +117,7 @@ patchedDependencies: hash: e5decbc72f46c0747e26e2ca910e92936f157872011d75fbd32d3d3bcf0d3c1a path: patches/@tailwindcss__node@4.3.3.patch three@0.185.1: - hash: 00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437 + hash: cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772 path: packages/core/patches/three@0.185.1.patch tsup@8.5.1: hash: bdcba0cb0e0023b6ee572aae9dd40456c0c3bb0edd9c2abd290caed6ebef0770 @@ -198,7 +198,7 @@ importers: version: 19.2.0(react@19.2.0) three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@tailwindcss/vite': specifier: 'catalog:' @@ -245,7 +245,7 @@ importers: dependencies: three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: vite: specifier: 'catalog:' @@ -264,7 +264,7 @@ importers: version: link:../../packages/physics three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -289,7 +289,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@threenative/playtest': specifier: workspace:* @@ -323,7 +323,7 @@ importers: version: link:../../packages/playtest three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -354,7 +354,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -382,7 +382,7 @@ importers: version: link:../../packages/physics three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@threenative/playtest': specifier: workspace:* @@ -410,7 +410,7 @@ importers: version: link:../../packages/playtest three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -429,7 +429,7 @@ importers: version: link:../../packages/physics three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -448,7 +448,7 @@ importers: dependencies: three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -473,7 +473,7 @@ importers: version: 0.33.0(react@19.2.0) three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@tailwindcss/vite': specifier: 'catalog:' @@ -522,7 +522,7 @@ importers: version: link:../../packages/physics three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/react': specifier: 'catalog:' @@ -556,7 +556,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -578,11 +578,11 @@ importers: version: 0.2.0(@types/react@19.2.0)(react@19.2.0) three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@threenative/playtest': specifier: 0.2.0 - version: 0.2.0(playwright@1.62.1)(three@0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437)) + version: 0.2.0(playwright@1.62.1)(three@0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772)) '@types/three': specifier: 0.185.3 version: 0.185.3 @@ -610,7 +610,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -638,7 +638,7 @@ importers: version: link:../../packages/playtest three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -663,7 +663,7 @@ importers: version: link:../../packages/playtest three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -685,7 +685,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -707,7 +707,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -729,7 +729,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -757,7 +757,7 @@ importers: version: link:../../packages/playtest three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -782,7 +782,7 @@ importers: version: link:../../packages/playtest three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@threenative/runtime-native': specifier: workspace:* @@ -810,7 +810,7 @@ importers: version: link:../../packages/procedural-animals three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@threenative/assets': specifier: workspace:* @@ -838,7 +838,7 @@ importers: version: link:../../packages/playtest three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@threenative/assets': specifier: workspace:* @@ -872,7 +872,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/three': specifier: 0.185.3 @@ -897,7 +897,7 @@ importers: version: link:../../packages/physics three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@threenative/terrain': specifier: workspace:* @@ -928,7 +928,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@threenative/playtest': specifier: workspace:* @@ -984,10 +984,10 @@ importers: version: 0.35.4(@types/node@20.19.43) three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) three-mesh-bvh: specifier: 'catalog:' - version: 0.9.14(three@0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437)) + version: 0.9.14(three@0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772)) wasm-media-encoders: specifier: 0.7.0 version: 0.7.0 @@ -1045,10 +1045,10 @@ importers: dependencies: three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) three-mesh-bvh: specifier: 'catalog:' - version: 0.9.14(three@0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437)) + version: 0.9.14(three@0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772)) threenative-asset-mcp: specifier: 0.13.0 version: 0.13.0(@types/node@20.19.43) @@ -1124,7 +1124,7 @@ importers: version: 1.1.1 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@threenative/assets': specifier: workspace:* @@ -1197,7 +1197,7 @@ importers: version: 0.3.22 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) tsup: specifier: 8.5.1 version: 8.5.1(patch_hash=bdcba0cb0e0023b6ee572aae9dd40456c0c3bb0edd9c2abd290caed6ebef0770)(@typescript/typescript6@6.0.2)(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.5)(yaml@2.9.1) @@ -1228,7 +1228,7 @@ importers: version: 0.3.22 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) tsup: specifier: 8.5.1 version: 8.5.1(patch_hash=bdcba0cb0e0023b6ee572aae9dd40456c0c3bb0edd9c2abd290caed6ebef0770)(@typescript/typescript6@6.0.2)(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.5)(yaml@2.9.1) @@ -1259,7 +1259,7 @@ importers: version: 0.3.22 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) tsup: specifier: 8.5.1 version: 8.5.1(patch_hash=bdcba0cb0e0023b6ee572aae9dd40456c0c3bb0edd9c2abd290caed6ebef0770)(@typescript/typescript6@6.0.2)(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.5)(yaml@2.9.1) @@ -1271,10 +1271,10 @@ importers: dependencies: procedural-animals: specifier: 'catalog:' - version: https://codeload.github.com/majidmanzarpour/threejs-procedural-animals/tar.gz/c95ae49346aa8e140a924376cec6cf0073d99512(three@0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437)) + version: https://codeload.github.com/majidmanzarpour/threejs-procedural-animals/tar.gz/c95ae49346aa8e140a924376cec6cf0073d99512(three@0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772)) three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@threenative/assets': specifier: workspace:* @@ -1302,7 +1302,7 @@ importers: dependencies: three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/node': specifier: 20.19.43 @@ -1349,7 +1349,7 @@ importers: version: 0.3.22 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(vite@8.2.0(patch_hash=c99095f3002ba7f0997f348d6c1bcf5952587ed5dee18c058269c0d634d146c1)(@types/node@20.19.43)(esbuild@0.27.0)(jiti@2.7.0)(tsx@4.23.5)(yaml@2.9.1)) @@ -1376,7 +1376,7 @@ importers: version: 0.3.22 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) tsup: specifier: 'catalog:' version: 8.5.1(patch_hash=bdcba0cb0e0023b6ee572aae9dd40456c0c3bb0edd9c2abd290caed6ebef0770)(@typescript/typescript6@6.0.2)(jiti@2.7.0)(postcss@8.5.25)(tsx@4.23.5)(yaml@2.9.1) @@ -1394,7 +1394,7 @@ importers: version: 0.8.3 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + version: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) devDependencies: '@types/node': specifier: 20.19.43 @@ -4763,8 +4763,8 @@ snapshots: '@threenative/core@0.2.0(@types/react@19.2.0)(react@19.2.0)': dependencies: - three: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) - three-mesh-bvh: 0.9.14(three@0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437)) + three: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) + three-mesh-bvh: 0.9.14(three@0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772)) zustand: 5.0.14(@types/react@19.2.0)(react@19.2.0) transitivePeerDependencies: - '@types/react' @@ -4772,19 +4772,19 @@ snapshots: - react - use-sync-external-store - '@threenative/playtest@0.2.0(playwright@1.62.1)(three@0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437))': + '@threenative/playtest@0.2.0(playwright@1.62.1)(three@0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772))': dependencies: pngjs: 7.0.0 optionalDependencies: playwright: 1.62.1 - three: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + three: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) - '@threenative/playtest@0.3.2(playwright@1.62.0)(three@0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437))': + '@threenative/playtest@0.3.2(playwright@1.62.0)(three@0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772))': dependencies: pngjs: 7.0.0 optionalDependencies: playwright: 1.62.0 - three: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + three: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) '@threenative/runtime-native@0.2.0': dependencies: @@ -5708,9 +5708,9 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - procedural-animals@https://codeload.github.com/majidmanzarpour/threejs-procedural-animals/tar.gz/c95ae49346aa8e140a924376cec6cf0073d99512(three@0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437)): + procedural-animals@https://codeload.github.com/majidmanzarpour/threejs-procedural-animals/tar.gz/c95ae49346aa8e140a924376cec6cf0073d99512(three@0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772)): dependencies: - three: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + three: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) proj4@2.22.0(geotiff@3.0.5): dependencies: @@ -5987,11 +5987,11 @@ snapshots: dependencies: any-promise: 1.3.0 - three-mesh-bvh@0.9.14(three@0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437)): + three-mesh-bvh@0.9.14(three@0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772)): dependencies: - three: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + three: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) - three@0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437): {} + three@0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772): {} threenative-asset-mcp@0.13.0(@types/node@20.19.43): dependencies: @@ -5999,12 +5999,12 @@ snapshots: '@gltf-transform/extensions': 4.4.2 '@gltf-transform/functions': 4.4.2(@types/node@20.19.43) '@modelcontextprotocol/server': 2.0.0 - '@threenative/playtest': 0.3.2(playwright@1.62.0)(three@0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437)) + '@threenative/playtest': 0.3.2(playwright@1.62.0)(three@0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772)) '@zip.js/zip.js': 2.8.34 ag-psd: 31.0.3 playwright: 1.62.0 sharp: 0.35.4(@types/node@20.19.43) - three: 0.185.1(patch_hash=00ce98ec952e72dff1f8f10e10ecc691f27365e9ae7a7482becdad1614181437) + three: 0.185.1(patch_hash=cc3278471970d5a53b06fc4c5b5a724cd12c39d674060cb18a49b7f33c271772) yaml: 2.9.1 zod: 4.4.3 transitivePeerDependencies: diff --git a/scripts/__tests__/ci-efficiency.spec.ts b/scripts/__tests__/ci-efficiency.spec.ts index c207e5788c..d692c167a4 100644 --- a/scripts/__tests__/ci-efficiency.spec.ts +++ b/scripts/__tests__/ci-efficiency.spec.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { makeTempDirSync } from "../../test-support/temp-dir.js"; import { ciJobGraph, declaredNeeds, jobSections } from "../ci-workflow.js"; +import { BASELINE_SHA as STRATA_STARTUP_BASELINE_SHA } from "../verify-strata-startup.js"; const { copyTemplateOwnership, integrationEvidence } = await import( new URL("../../test-support/ci-integration-fixture.ts", import.meta.url).href @@ -627,6 +628,16 @@ describe("PRD-373 fixed full candidates and current package products", () => { if (name === "scope") continue; for (const checkout of section.split("uses: actions/checkout@").slice(1)) { const step = checkout.split(/^ {6}- /mu)[0] ?? ""; + // The benchmark compares the candidate with an isolated, reviewed baseline; + // its separate contract pins that checkout's path and SHA. + if ( + relative === ".github/workflows/ci.yml" && + name === "benchmark" && + step.includes("path: .worktrees/strata-startup-baseline") + ) { + expect(step, `${relative}: ${name}`).toContain(`ref: ${STRATA_STARTUP_BASELINE_SHA}`); + continue; + } expect(step, `${relative}: ${name}`).toContain( "ref: ${{ needs.scope.outputs.candidate_sha", ); diff --git a/scripts/__tests__/verify-strata-startup.spec.ts b/scripts/__tests__/verify-strata-startup.spec.ts new file mode 100644 index 0000000000..5c3ef2eb90 --- /dev/null +++ b/scripts/__tests__/verify-strata-startup.spec.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { expect, it } from "vitest"; +import { + BASELINE_SHA, + parseStartupReport, + verifyStartupPackageIdentity, + verifyStartupRuns, +} from "../verify-strata-startup.js"; + +function run(time = 100, arm: "before" | "after" = "after") { + const values = { + "resource.GameState.timeToReadyMs": time, + "component.props.reveal.complete": true, + "component.props.reveal.samples": 2, + "component.props.reveal.elapsedMs": 3000, + "component.props.reveal.publishedBatchesInView": 0, + "component.props.reveal.shadowInvalidated": 0, + "component.props.reveal.impostorPendingPeak": 0, + diagnostics: 0, + }; + return { + arm, + status: 0, + report: { + pass: true, + diagnostics: [], + observations: { console: [], hud: {}, network: [], resources: {} }, + capture: { + adapter: { vendor: "google", architecture: "swiftshader" }, + rendererKind: "webgpu" as const, + target: "web" as const, + captureMethod: "page.screenshot" as const, + browserArgs: [], + viewport: { width: 1280, height: 720 }, + }, + assertionResults: Object.entries(values).map(([id, after]) => ({ + id, + pass: true, + details: { after }, + })), + }, + }; +} +function pairs(before = 100, after = 120) { + return [ + run(before, "before"), + run(after), + run(after), + run(before, "before"), + run(before, "before"), + run(after), + ] as const; +} +function assertion(r: ReturnType, index: number) { + const value = r.report.assertionResults[index]; + assert(value, `fixture assertion ${index} missing`); + return value; +} + +it("accepts three alternating pairs within the owner bound", () => { + expect(verifyStartupRuns(pairs()).ratio).toBe(1.2); +}); +it("rejects the controlled real startup regression", () => { + expect(() => verifyStartupRuns(pairs(10599.9, 26130.1))).toThrow(/1.25/); +}); +it("fails closed on missing observations, short samples, diagnostics and subprocess failure", () => { + for (const mutate of [ + (r: ReturnType) => { + Object.assign(r.report, { diagnostics: [{ code: "BAD" }] }); + }, + (r: ReturnType) => { + Object.assign(r.report, { observations: {} }); + }, + (r: ReturnType) => { + Object.assign(r.report.capture, { adapter: {} }); + }, + (r: ReturnType) => { + assertion(r, 1).details.after = false; + }, + (r: ReturnType) => { + Object.assign(assertion(r, 2).details, { after: "2" }); + }, + (r: ReturnType) => { + r.report.assertionResults = []; + }, + (r: ReturnType) => { + assertion(r, 2).details.after = 1; + }, + (r: ReturnType) => { + assertion(r, 3).details.after = 2999; + }, + (r: ReturnType) => { + r.status = 2; + }, + (r: ReturnType) => { + r.report.capture.adapter.vendor = "other"; + }, + (r: ReturnType) => { + assertion(r, 7).pass = false; + }, + ]) { + const runs = pairs(); + mutate(runs[1]); + expect(() => verifyStartupRuns(runs)).toThrow(); + } + for (const value of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) + expect(() => verifyStartupRuns(pairs(100, value))).toThrow(); +}); +it("permits only historical BEFORE late counter failures", () => { + const runs = pairs(); + runs[0].status = 1; + runs[0].report.pass = false; + assertion(runs[0], 4).pass = false; + assertion(runs[0], 4).details.after = 5; + expect(verifyStartupRuns(runs).ratio).toBe(1.2); + assertion(runs[1], 4).pass = false; + expect(() => verifyStartupRuns(runs)).toThrow(); +}); +it("pins reviewed old source and requires the real command inside benchmark", () => { + expect(BASELINE_SHA).toBe("88b988cd70d5db7266eb0281e5f74bd0e030b3e7"); + const ci = readFileSync(".github/workflows/ci.yml", "utf8"); + const section = ci.split(" benchmark:\n")[1]; + assert(section, "benchmark job missing"); + const benchmark = section.split("\n # visuals")[0]; + assert(benchmark, "benchmark body missing"); + expect(benchmark).not.toContain("continue-on-error:"); + expect(benchmark).toContain("ref: 88b988cd70d5db7266eb0281e5f74bd0e030b3e7"); + expect(benchmark).toContain("path: .worktrees/strata-startup-baseline"); + const gateSection = benchmark.split(" - name: Enforce Strata startup median")[1]; + assert(gateSection, "startup gate missing"); + const gate = gateSection.split("\n - ")[0]; + assert(gate, "startup gate body missing"); + expect(gate).toContain("EXPECTED_SOURCE_SHA: ${{ needs.scope.outputs.candidate_sha }}"); + expect(gate).toContain("timeout 9m pnpm tsx scripts/verify-strata-startup.ts"); + expect(gate).not.toMatch(/continue-on-error:|if:|\|\|\s*(true|:)|set \+e/); +}); + +it("parses real runner log framing and rejects absent or malformed reports", () => { + expect( + parseStartupReport(`{"captureLock":{}}\n${JSON.stringify(run().report, null, 2)}`), + ).toEqual(run().report); + for (const log of ["", "{}", '{"assertionResults": [']) + expect(() => parseStartupReport(log)).toThrow(); +}); + +it("requires all six runs in alternating pair order", () => { + expect(() => verifyStartupRuns(pairs().slice(1))).toThrow(); + const runs = [...pairs()]; + runs.reverse(); + expect(() => verifyStartupRuns(runs)).toThrow(); +}); + +it("accepts native command edits but rejects dependency or compiler identity changes", () => { + const baseline = { + name: "strata-terrain-preview", + type: "module", + version: "0.1.0", + scripts: { "test:terrain:desktop": "old native staging" }, + dependencies: { "@threenative/core": "workspace:*", three: "catalog:" }, + devDependencies: { vite: "catalog:", typescript: "catalog:" }, + engines: { node: ">=20" }, + }; + const candidate = { + ...baseline, + scripts: { + "test:terrain:desktop": "new native staging", + "verify-native": "node scripts/verify-native.mjs", + }, + }; + expect(() => verifyStartupPackageIdentity(baseline, candidate)).not.toThrow(); + expect(() => + verifyStartupPackageIdentity(baseline, { + ...candidate, + dependencies: { ...candidate.dependencies, three: "other" }, + }), + ).toThrow(/dependencies/); + expect(() => + verifyStartupPackageIdentity(baseline, { ...candidate, devDependencies: { vite: "other" } }), + ).toThrow(/devDependencies/); + expect(() => + verifyStartupPackageIdentity(baseline, { ...candidate, engines: { node: ">=24" } }), + ).toThrow(/engines/); +}); diff --git a/scripts/api-surface.json b/scripts/api-surface.json index 1730325b5b..c9d14ecc6a 100644 --- a/scripts/api-surface.json +++ b/scripts/api-surface.json @@ -25,6 +25,7 @@ "@threenative/assets#rewriteUvs": "export function rewriteUvs(uv: Float32Array, transform: IAtlasTransform): Float32Array { … }", "@threenative/assets#runHealthReport": "export async function runHealthReport( inputs: readonly IAssetHealthInput[], targets: IAssetTargets = { … }", "@threenative/assets#texturePass": "export function texturePass(options: ITexturePassOptions = { … }", + "@threenative/assets#thinFoliageCards": "export function thinFoliageCards(document: Document, keepRatio: number): number { … }", "@threenative/assets#totalCensus": "export function totalCensus(entries: readonly IContentCensus[]): Omit { … }", "@threenative/assets#uvsTile": "export function uvsTile(uv: ArrayLike): boolean { … }", "@threenative/assets#watchAssets": "export function watchAssets(options: IAssetWatchOptions = { … }", diff --git a/scripts/verify-strata-startup.ts b/scripts/verify-strata-startup.ts new file mode 100644 index 0000000000..a6333b4725 --- /dev/null +++ b/scripts/verify-strata-startup.ts @@ -0,0 +1,273 @@ +/** Hosted software regression contract only; never hardware qualification or a visual verdict. */ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { closeSync, mkdirSync, openSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { IStandalonePlaytestReport } from "../packages/playtest/src/runner/shared.js"; + +export const BASELINE_SHA = "88b988cd70d5db7266eb0281e5f74bd0e030b3e7"; +const ORDER = ["before", "after", "after", "before", "before", "after"] as const; +const late = ["publishedBatchesInView", "shadowInvalidated", "impostorPendingPeak"].map( + (key) => `component.props.reveal.${key}`, +); +type StartupReport = Pick< + IStandalonePlaytestReport, + "pass" | "capture" | "diagnostics" | "observations" | "assertionResults" +>; +interface IStartupRun { + arm: "before" | "after"; + status: number | null; + report: StartupReport; +} + +export function verifyStartupRuns(runs: readonly IStartupRun[]) { + assert.deepEqual( + runs.map(({ arm }) => arm), + ORDER, + "require three alternating pairs", + ); + const times: { before: number[]; after: number[] } = { before: [], after: [] }; + let adapter: unknown; + for (const { arm, status, report } of runs) { + assert(status === 0 || (arm === "before" && status === 1), "subprocess failure"); + assert( + Array.isArray(report.diagnostics) && report.diagnostics.length === 0, + "diagnostics must be empty", + ); + assert(Array.isArray(report.observations?.console), "console observations missing"); + assert( + report.observations.console.every( + (entry) => + entry.type !== "error" && + entry.source !== "page-error" && + entry.source !== "unhandled-rejection", + ), + "console diagnostics", + ); + assert( + typeof report.capture?.adapter?.vendor === "string" && + report.capture.adapter.vendor.trim() && + typeof report.capture.adapter.architecture === "string" && + report.capture.adapter.architecture.trim(), + "adapter identity missing", + ); + assert.equal(report.capture.rendererKind, "webgpu"); + assert.deepEqual(report.capture.viewport, { width: 1280, height: 720 }); + adapter ??= report.capture.adapter; + assert.deepEqual(report.capture.adapter, adapter, "adapter changed between runs"); + assert( + Array.isArray(report.assertionResults) && report.assertionResults.length > 0, + "assertions missing", + ); + const assertions = new Map(report.assertionResults.map((a) => [a.id, a])); + assert.equal(assertions.size, report.assertionResults.length, "duplicate assertions"); + const value = (id: string): unknown => { + const assertion = assertions.get(id); + assert(assertion?.pass === true, `missing or failed ${id}`); + return (assertion.details as { after?: unknown })?.after; + }; + const positive = (id: string, minimum: number) => { + const result = value(id); + assert( + typeof result === "number" && Number.isFinite(result) && result >= minimum, + `invalid ${id}`, + ); + return result; + }; + value("diagnostics"); + assert.equal(value("component.props.reveal.complete"), true); + const samples = positive("component.props.reveal.samples", 2); + assert(Number.isInteger(samples), "malformed samples"); + positive("component.props.reveal.elapsedMs", 3000); + times[arm].push(positive("resource.GameState.timeToReadyMs", Number.MIN_VALUE)); + for (const id of late) { + const counter = assertions.get(id); + const observed = (counter?.details as { after?: unknown })?.after; + assert( + typeof observed === "number" && Number.isFinite(observed) && observed >= 0, + `missing or malformed ${id}`, + ); + if (arm === "after") { + assert.equal(counter?.pass, true, id); + assert.equal(observed, 0, id); + } + } + assert( + report.assertionResults.every( + (a) => a.pass === true || (arm === "before" && late.includes(a.id)), + ), + "unexpected assertion failure", + ); + assert.equal( + report.pass, + report.assertionResults.every((a) => a.pass === true), + ); + assert.equal(status, report.pass ? 0 : 1, "subprocess/report disagreement"); + } + const median = (values: number[]) => { + const value = values.sort((a, b) => a - b)[1]; + assert(value !== undefined, "median requires three observations"); + return value; + }; + const before = median(times.before); + const after = median(times.after); + const ratio = after / before; + assert( + Number.isFinite(ratio) && after <= before * 1.25, + `startup median ${after}/${before} = ${ratio} exceeds 1.25`, + ); + return { + before, + after, + ratio, + times, + adapter, + qualification: "software regression contract; no hardware or visual qualification", + }; +} + +export function parseStartupReport(log: string): StartupReport { + for (const match of log.matchAll(/^\{/gm)) { + try { + const parsed = JSON.parse(log.slice(match.index)); + if (parsed.assertionResults) return parsed; + } catch { + /* Display/lock JSON precedes the final runner report. */ + } + } + throw new Error("missing or malformed startup report"); +} + +export function verifyStartupPackageIdentity( + baseline: Record, + candidate: Record, +) { + for (const field of [ + "name", + "version", + "private", + "type", + "engines", + "packageManager", + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ]) + assert.deepEqual(candidate[field], baseline[field], `incompatible package identity: ${field}`); +} + +function main() { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const commonDirectory = execFileSync( + "git", + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { cwd: root, encoding: "utf8" }, + ).trim(); + const baseline = path.join(path.dirname(commonDirectory), ".worktrees/strata-startup-baseline"); + const output = path.join(root, "artifacts/strata-startup"); + mkdirSync(output, { recursive: true }); + const git = (cwd: string) => + execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim(); + assert.equal(git(baseline), BASELINE_SHA, "baseline must remain reviewed source"); + assert.match(process.env.EXPECTED_SOURCE_SHA ?? "", /^[a-f0-9]{40}$/); + assert.equal(git(root), process.env.EXPECTED_SOURCE_SHA, "candidate must match scoped source"); + for (const file of ["pnpm-lock.yaml", "pnpm-workspace.yaml"]) + assert.equal( + readFileSync(path.join(root, file), "utf8"), + readFileSync(path.join(baseline, file), "utf8"), + `incompatible identity: ${file}`, + ); + const packageIdentity = (cwd: string) => + JSON.parse( + readFileSync(path.join(cwd, "examples/strata-terrain-preview/package.json"), "utf8"), + ); + verifyStartupPackageIdentity(packageIdentity(baseline), packageIdentity(root)); + for (const cwd of [root, baseline]) + assert.equal( + realpathSync( + path.join(cwd, "examples/strata-terrain-preview/node_modules/@threenative/core"), + ), + realpathSync(path.join(cwd, "packages/core")), + "game must resolve its own core", + ); + const scenario = JSON.parse( + readFileSync( + path.join(root, "examples/strata-terrain-preview/playtests/reveal.playtest.json"), + "utf8", + ), + ); + assert.deepEqual(scenario.viewport, { width: 1280, height: 720 }); + // Drop only diagnostic images: hosted WebGPU screenshots can time out. Preserve every assertion. + scenario.artifacts.screenshots = false; + for (const step of scenario.steps) step.screenshot = undefined; + const scenarioPath = path.join(output, "frozen.playtest.json"); + writeFileSync(scenarioPath, JSON.stringify(scenario)); + const toolchain = (cwd: string) => + execFileSync("pnpm", ["exec", "vite", "--version"], { + cwd: path.join(cwd, "examples/strata-terrain-preview"), + encoding: "utf8", + }).trim(); + const compiler = toolchain(root); + assert.equal(toolchain(baseline), compiler, "compiler identity differs"); + writeFileSync( + path.join(output, "identity.json"), + JSON.stringify({ + baseline: BASELINE_SHA, + candidate: git(root), + node: process.version, + workload: createHash("sha256").update(JSON.stringify(scenario)).digest("hex"), + browser: execFileSync("pnpm", ["exec", "playwright", "--version"], { + cwd: root, + encoding: "utf8", + }), + compiler, + }), + ); + const runs: IStartupRun[] = []; + const deadline = Date.now() + 8 * 60_000; + for (const [index, arm] of ORDER.entries()) { + const directory = path.join(output, `${index + 1}-${arm}`); + mkdirSync(directory); + const fd = openSync(path.join(directory, "run.log"), "w"); + const cwd = arm === "before" ? baseline : root; + const result = spawnSync( + process.execPath, + [ + path.join(root, "packages/playtest/dist/runner/cli.js"), + scenarioPath, + "--url", + "http://127.0.0.1:16181/?world=forest", + "--server-command", + "pnpm --filter strata-terrain-preview exec vite preview --host 127.0.0.1 --port 16181 --strictPort", + "--browser-recipe", + "webgpu", + "--headed", + "--no-screenshots", + "--allow-software", + "--timeout", + "75000", + "--artifacts", + directory, + ], + { + cwd, + stdio: ["ignore", fd, fd], + timeout: Math.max(1, Math.min(80_000, deadline - Date.now())), + killSignal: "SIGKILL", + }, + ); + closeSync(fd); + assert(!result.error && !result.signal, `subprocess failed: ${result.error ?? result.signal}`); + const log = readFileSync(path.join(directory, "run.log"), "utf8"); + const report = parseStartupReport(log); + writeFileSync(path.join(directory, "report.json"), JSON.stringify(report)); + runs.push({ arm, status: result.status, report }); + } + const summary = verifyStartupRuns(runs); + writeFileSync(path.join(output, "summary.json"), JSON.stringify(summary, null, 2)); + console.log(JSON.stringify(summary)); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();