diff --git a/docs/PRDs/Unreal/README.md b/docs/PRDs/Unreal/README.md index 8b7ab6d940..7b6bbe9430 100644 --- a/docs/PRDs/Unreal/README.md +++ b/docs/PRDs/Unreal/README.md @@ -91,7 +91,7 @@ earlier wave unless the "Waits on" column says so. | 2 | [PRD-561](../done/PRD-561-contact-shadows-from-screen-depth.md) | Small objects and feet sit on the ground (screen-space contact shadows) | 5 MEDIUM | — | | 2 | [PRD-563](../done/PRD-563-the-first-frame-starts-at-the-tier-the-gpu-family-holds.md) | The first frame starts at the tier the GPU family can hold | 5 MEDIUM | — | | 2 | [PRD-569](./vfx/PRD-569-gpu-particle-emitters-cull-and-yield-to-the-frame-budget.md) | Off-view GPU emitters stop costing; effects yield to the frame budget | 5 MEDIUM | — | -| 2 | [PRD-566](./open-world/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md) | Terrain LOD morphs on the GPU and picks its level by screen error | 3 LOW | — | +| 2 | [PRD-566](../done/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md) | Terrain LOD morphs on the GPU and picks its level by screen error | 3 LOW | — | | 3 | [PRD-564](./performance/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md) | The resolution scale changes without reallocating a target | 6 MEDIUM | its Phase 1 hitch measurement | | 3 | [PRD-570](./animation/PRD-570-a-character-costs-what-the-camera-sees-of-it.md) | Distant and hidden characters animate and skin for less | 5 MEDIUM | its Phase 1 cost measurement | | 3 | [PRD-562](./rendering/PRD-562-aerial-perspective-from-a-froxel-volume.md) | Distant land takes the colour of the sky it is seen through | 4 MEDIUM | — | diff --git a/docs/PRDs/Unreal/open-world/PRD-460-invisible-streaming-transitions.md b/docs/PRDs/Unreal/open-world/PRD-460-invisible-streaming-transitions.md index dfaa4040a2..99e1b124e7 100644 --- a/docs/PRDs/Unreal/open-world/PRD-460-invisible-streaming-transitions.md +++ b/docs/PRDs/Unreal/open-world/PRD-460-invisible-streaming-transitions.md @@ -24,7 +24,7 @@ than a missing effect: Terrain already does this correctly and is the precedent, not the work: a tile's LOD change morphs vertex positions over `LOD_TRANSITION_FRAMES = 3` (`packages/core/src/world-tiles.ts:229`). -[PRD-566](PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md) moves that morph to +[PRD-566](../../done/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md) moves that morph to the GPU; it stays out of this PRD. Scatter has no equivalent because the mix is per instance and `InstancedBatch` writes only `instanceMatrix` (`packages/core/src/instanced-batch.ts:236-238`). diff --git a/docs/PRDs/Unreal/open-world/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md b/docs/PRDs/Unreal/open-world/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md deleted file mode 100644 index b1301c3c35..0000000000 --- a/docs/PRDs/Unreal/open-world/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md +++ /dev/null @@ -1,80 +0,0 @@ -# PRD-566 — Terrain LOD morphs on the GPU and picks its level by screen error - -**Status:** NOT STARTED -**Priority:** P2 — Phase 1 measurement gate, GPU morph and screen-error selection are all unbuilt. -**Complexity:** 3 (LOW) — 1–5 implementation files (`world-tiles.ts`, the terrain probe) (+1), per-tile transition state already exists (+0), native proof reuses the installed morph path (+0); the score stays 3 because the morph replaces a CPU loop with stock three morph targets; risk override: none -**Owner:** João -**Depends on:** None. Respects [PRD-473](../../open-world/PRD-473-open-worlds-hold-120-fps-gpu-driven.md) (merged blocks, rejected height texture), [PRD-460](PRD-460-invisible-streaming-transitions.md) (terrain is its non-goal) and [PRD-461](../../done/open-world/PRD-461-view-distance-basics.md) (switches stay in the haze). - -## Context - -`TerrainTiles` changes a tile's level with a three-frame blend (`LOD_TRANSITION_FRAMES = 3`, `packages/core/src/world-tiles.ts:229`). Each blend frame runs `updateLodTransitionGeometry` (`world-tiles.ts:1335`). It visits every vertex of the finer level. For each vertex it calls `tile.field.heightAt` and `tile.field.normalAt`, interpolates the coarser level, and writes `position.setY` and `normal.setXYZ`. `normalAt` reads five heights, so a 65×65 tile makes about 25,000 field reads per blend frame. The same file records that this access pattern cost 46 ms when level building used it (`fieldHeightGrid`, `world-tiles.ts:501`). No measurement exists for the blend frames. `blendingTiles` (`world-tiles.ts:2136`) counts the tiles that pay the cost. - -The level itself comes from fixed distances in metres. `lodDistances` defaults to two and four tile widths (`world-tiles.ts:2006`), `lodLevelForDistance` picks the level (`world-tiles.ts:336`), and three's `LOD` switches on the same numbers (`world-tiles.ts:2516`). The same metres give a different screen error on a 1440p desktop and on a phone. Props already pick levels by projected pixel error with hysteresis (`selectLodLevel`, `packages/core/src/model-lod.ts:166`; default budget `DISCRETE_LOD_DEFAULT_ERROR_PIXELS = 1`, `model-lod.ts:211`). Terrain already measures each level's surface error for its pop bound (`coarsestSelectableLevel`, `world-tiles.ts:1484`). - -**What Unreal does.** Clean-room summary, no code copied: - -- UE 5.8.3: `Engine/Shaders/Private/LandscapeVertexFactory.ush:655-685`. The vertex shader computes a fractional LOD. The fraction is the morph alpha. Each vertex lerps its position and height toward the same point snapped to the next-coarser grid, and lerps its normal the same way. The CPU writes no vertex. Near a section border the shader also blends the fraction toward the neighbor section's LOD, so two sections agree on their shared edge (`LandscapeVertexFactory.ush:117-148`). -- UE 5.8.3: `Engine/Source/Runtime/Landscape/Private/LandscapeRender.cpp:575-590` (`ComputeLODFromScreenSize`). The section's squared screen size gives the LOD. Between the LOD0 and LOD1 screen sizes the LOD is a linear fraction. Beyond LOD1 it is a logarithm with a distribution scalar. `LandscapeRender.cpp:4482-4504` divides the squared screen size by the square of the view's LOD distance factor before the pick. Landscape cancels the global static-mesh LOD scale cvar when it builds its thresholds (`LandscapeRender.cpp:1549-1551`). The metric is the section's bounding-sphere size on screen. ThreeNative uses surface error in pixels instead. - -**Constraints already decided here:** - -- PRD-473 rejected an instanced flat grid that samples a per-tile height texture, because the engine would then own the game's `surface` (rule 3). Stock three morph targets avoid this: three applies them to any material, so the game's surface material stays untouched. -- PRD-473 also requires a tile to stay outside its merged block while it morphs. This PRD keeps that rule. -- PRD-460 lists terrain as a non-goal ("it morphs already"), so this work does not belong in PRD-460. -- PRD-461's recipe test asserts that every LOD switch lies at or beyond fog near (`packages/core/__tests__/world-streaming-recipe.spec.ts:68`). A screen-error pick must not move a switch nearer than that. - -## Solution - -1. **Measure first.** Add a blend-frame case to `packages/core/__tests__/world-tiles-cost.spec.ts`. It counts `heightAt` and `normalAt` calls per blending tile, as the file's operation-count cases do, and times one blend frame under `TN_BENCH=1`. The terrain probe reports the largest `blendingTiles` count in a frame. **Gate:** continue only if one blending 65×65 tile costs at least 0.5 ms per blend frame in the bench case, or the terrain playtest shows blend frames in its worst 1% of frame times. Otherwise record the decline under `## Decisions` and delete Phases 2–3 (R4). -2. **Morph with stock three morph targets.** When a level is built, the finer level of each adjacent pair gets one relative morph target. Its position delta is the coarser surface height minus the fine height at each fine vertex (the same `interpolatedLevelHeight` values the CPU blend computes now), and it carries the matching normal delta. These come from the shared `fieldHeightGrid`, once per level. The skirt vertices need the same delta as the edge vertex they hang from, because `updateLevelSkirts` moves them with it on every CPU blend frame (`world-tiles.ts:1272`). A blend frame then sets only `mesh.morphTargetInfluences[0]`. The transition lifecycle, the three-frame default and the merge-block exclusion stay unchanged. With validation on, the CPU still computes the blended heights for `maxLodPop`, because the pop scan reads CPU attributes. -3. **Pick by screen error, with `lodDistances` as the floor.** Each tile keeps its levels' absolute surface errors, which `coarsestSelectableLevel` already computes. Selection calls the shared `selectLodLevel` with the props' resolved `maxPixelError` policy, so one knob governs both and no new constant appears. `lodDistances`, when given or defaulted, is the nearest distance at which a switch may happen. Screen error can only hold a finer level farther out, never coarsen nearer. This keeps PRD-461's haze rule true by construction. The phone-side saving (coarsening earlier) is deliberately not claimed. - -**Risks:** - -- **Memory.** Each finer level carries one more position attribute and one more normal attribute (about 100 KB for a 65×65 level). This is counted against `residentByteBudget`. -- **Shader variants.** A morph target adds a pipeline variant per surface material. The pipeline census must show one variant, not one per tile. -- **Seam measurement.** `refreshEdgeSamples` (`world-tiles.ts:983`) reads the CPU position attribute for the seam checks. After the change the CPU attribute holds the fine surface, so seam checks read stale edges mid-blend unless the validate path keeps the CPU blend, as step 2 says. -- **Native.** Morph targets are proven by conformance row `53-morph-target-animation`. A terrain claim needs its own desktop run. - -## Acceptance Criteria - -- [ ] AC-1 [local]: A morph frame writes no `position` and no `normal` attribute on the CPU, and the tile's rendered surface at morph progress p equals the CPU blend at p within 1 mm. proof: red-green `pnpm exec vitest run packages/core/__tests__/world-terrain-tiles.spec.ts` — Evidence: pending. -- [ ] AC-2 [local]: The abyss-framework terrain walk keeps `lodTransitions > 0`, `maxLodPop ≤ 16` and `maxLodTransitionFrames` unchanged, with 0 console errors on a named WebGPU adapter. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/terrain.playtest.json --url 'http://127.0.0.1:5183/?terrain' --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5183 --strictPort' --browser-recipe webgpu` — Evidence: pending. -- [ ] AC-3 [local]: Under PRD-461's recipe, every screen-error switch distance at 1280×720 and at 2560×1440 is at least fog near (180 m) and at least the `lodDistances` floor. proof: `pnpm exec vitest run packages/core/__tests__/world-streaming-recipe.spec.ts` — Evidence: pending. - -## Integration Ledger - -| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | -| --- | --- | --- | --- | -| GPU terrain morph | A game streams terrain through `TerrainTiles.follow` / `WorldCells`; a level change starts a transition | `updateLodTransitionGeometry`'s per-vertex CPU writes are deleted; the CPU blend survives only behind `validate` | AC-1, AC-2 | -| Screen-error terrain LOD | The same `follow` call that picks the level | `lodLevelForDistance` stays only as the floor | AC-3 | - -## Execution Phases - -#### Phase 1: Measure the blend frame -**Status:** NOT STARTED -**Files:** `packages/core/__tests__/world-tiles-cost.spec.ts`, `examples/abyss-framework/src/scenes/TerrainProbe.ts` -**Implementation:** an operation-count case for one blend frame, a `TN_BENCH` timing case, and a probe field for the most blending tiles in one frame. The gate verdict goes under `## Decisions`. -- [ ] The blend frame's field reads per tile are counted, and its time per 65×65 tile is recorded here. proof: `TN_BENCH=1 pnpm exec vitest run packages/core/__tests__/world-tiles-cost.spec.ts` -- [ ] The terrain walk reports the largest per-frame `blendingTiles` and whether blend frames sit in the worst 1%. proof: the AC-2 playtest command - -#### Phase 2: Morph targets replace the CPU blend -**Status:** NOT STARTED -**Files:** `packages/core/src/world-tiles.ts`, `packages/core/__tests__/world-terrain-tiles.spec.ts` -**Implementation:** build the relative morph target (skirt vertices included) with each finer level, drive the influence from the existing transition progress, keep the CPU blend only for `validate`, and count the morph bytes in `residentByteBudget`. -**Verification:** AC-1 and AC-2 close this phase. -- [ ] The same walk runs on the desktop host with matching transition counts; no mobile claim. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/terrain.playtest.json --target desktop --executable ` - -#### Phase 3: Screen-error selection with the distance floor -**Status:** NOT STARTED -**Files:** `packages/core/src/world-tiles.ts`, `packages/core/__tests__/world-streaming-recipe.spec.ts`, `docs/guides/world-streaming.md` -**Implementation:** keep absolute errors per level, call `selectLodLevel` with the props' resolved policy, and clamp to the `lodDistances` floor. The guide states that `lodDistances` is now the nearest switch. -**Verification:** AC-3 closes this phase. -- [ ] Two viewports choose different levels for the same tile, and neither switches nearer than the floor. proof: `pnpm exec vitest run packages/core/__tests__/world-terrain-tiles.spec.ts` -- [ ] The guide documents the floor and the shared pixel budget. proof: `pnpm check:docs` - -## Decisions - -- 2026-10-12 (Unreal source review): Unreal drives the morph from distance every frame and keeps no transition state. This PRD keeps the three-frame transition because PRD-473 and the merge-block rule depend on it. The morph target must also move the skirt vertices, and the validate path must keep the CPU blend for seam measurement. Both are now in Phase 2 and the Risks. -- 2026-10-12 (Unreal source review): code line numbers in this PRD were refreshed to the current `world-tiles.ts`. diff --git a/docs/PRDs/done/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md b/docs/PRDs/done/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md new file mode 100644 index 0000000000..06f8096797 --- /dev/null +++ b/docs/PRDs/done/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md @@ -0,0 +1,290 @@ +# PRD-566 — Terrain LOD morphs on the GPU and picks its level by screen error + +**Status:** DONE — 6/6 phase boxes and 3/3 acceptance criteria verified; browser and desktop walks pass 33/33, and aligned blend-frame worst-1% membership is measured. +**Priority:** P1 — completed terrain LOD morph, screen-error selection and aligned blend-frame measurement. +**Complexity:** 3 (LOW) — 1–5 implementation files (`world-tiles.ts`, the terrain probe) (+1), per-tile transition state already exists (+0), native proof reuses the installed morph path (+0); the score stays 3 because the morph replaces a CPU loop with stock three morph targets; risk override: none +**Owner:** João +**Depends on:** None. Respects [PRD-473](../open-world/PRD-473-open-worlds-hold-120-fps-gpu-driven.md) (merged blocks, rejected height texture), [PRD-460](../Unreal/open-world/PRD-460-invisible-streaming-transitions.md) (terrain is its non-goal) and [PRD-461](open-world/PRD-461-view-distance-basics.md) (switches stay in the haze). + +## Context + +`TerrainTiles` changes a tile's level with a three-frame blend (`LOD_TRANSITION_FRAMES = 3`, `packages/core/src/world-tiles.ts:219`). Each blend frame runs `updateLodTransitionGeometry` (`world-tiles.ts:1292`). It visits every vertex of the finer level. For each vertex it calls `tile.field.heightAt` and `tile.field.normalAt`, interpolates the coarser level, and writes `position.setY` and `normal.setXYZ`. `normalAt` reads five heights, so a 65×65 tile makes about 25,000 field reads per blend frame. The same file records that this access pattern cost 46 ms when level building used it (`fieldHeightGrid`, `world-tiles.ts:489`). No measurement exists for the blend frames. `blendingTiles` (`world-tiles.ts:2064`) counts the tiles that pay the cost. + +The level itself comes from fixed distances in metres. `lodDistances` defaults to two and four tile widths (`world-tiles.ts:1934`), `lodLevelForDistance` picks the level (`world-tiles.ts:2182`), and three's `LOD` switches on the same numbers (`world-tiles.ts:2391`). The same metres give a different screen error on a 1440p desktop and on a phone. Props already pick levels by projected pixel error with hysteresis (`selectLodLevel`, `packages/core/src/model-lod.ts:166`; default budget `DISCRETE_LOD_DEFAULT_ERROR_PIXELS = 1`, `model-lod.ts:211`). Terrain already measures each level's surface error for its pop bound (`coarsestSelectableLevel`, `world-tiles.ts:1421`). + +**What Unreal does.** Clean-room summary, no code copied: + +- UE 5.8.3: `Engine/Shaders/Private/LandscapeVertexFactory.ush:655-685`. The vertex shader computes a fractional LOD. The fraction is the morph alpha. Each vertex lerps its position and height toward the same point snapped to the next-coarser grid, and lerps its normal the same way. The CPU writes no vertex. +- UE 5.8.3: `Engine/Source/Runtime/Landscape/Private/LandscapeRender.cpp:575-590` (`ComputeLODFromScreenSize`). The section's squared screen size gives the LOD. Between the LOD0 and LOD1 screen sizes the LOD is a linear fraction. Beyond LOD1 it is a logarithm with a distribution scalar. `LandscapeRender.cpp:4482-4504` scales the screen size by the view's LOD distance factor before the pick. + +**Constraints already decided here:** + +- PRD-473 rejected an instanced flat grid that samples a per-tile height texture, because the engine would then own the game's `surface` (rule 3). Stock three morph targets avoid this: three applies them to any material, so the game's surface material stays untouched. +- PRD-473 also requires a tile to stay outside its merged block while it morphs. This PRD keeps that rule. +- PRD-460 lists terrain as a non-goal ("it morphs already"), so this work does not belong in PRD-460. +- PRD-461's recipe test asserts that every LOD switch lies at or beyond fog near (`packages/core/__tests__/world-streaming-recipe.spec.ts:68`). A screen-error pick must not move a switch nearer than that. + +## Solution + +1. **Measure first.** Add a blend-frame case to `packages/core/__tests__/world-tiles-cost.spec.ts`. It counts `heightAt` and `normalAt` calls per blending tile, as the file's operation-count cases do, and times one blend frame under `TN_BENCH=1`. The terrain probe reports the largest `blendingTiles` count in a frame. **Gate:** continue only if one blending 65×65 tile costs at least 0.5 ms per blend frame in the bench case, or the terrain playtest shows blend frames in its worst 1% of frame times. Otherwise record the decline under `## Decisions` and delete Phases 2–3 (R4). +2. **Morph with stock three morph targets.** Each finer level gets relative position/normal targets for every reachable coarser level, preserving direct and skipped transitions. The deltas reuse the levels built from the shared `fieldHeightGrid` and the existing surface/normal interpolation. Blend frames change only the selected `mesh.morphTargetInfluences` entry. Shared sides remain canonical; when neighbor membership changes, immutable targets are rebuilt under the existing `terrainSeam` span, preserving base attributes and disposing the old geometry/cache. The transition lifecycle, three-frame default and merge-block exclusion stay unchanged. `validate: true` keeps the original CPU reference path and its `maxLodPop` scan; arithmetic parity is tested against it. Actual GPU rendering remains an open browser/native proof. +3. **Pick by screen error, with `lodDistances` as the floor.** Each tile keeps its levels' absolute surface errors, which `coarsestSelectableLevel` already computes. Selection calls the shared `selectLodLevel` with the props' resolved `maxPixelError` policy, so one knob governs both and no new constant appears. `lodDistances`, when given or defaulted, is the nearest distance at which a switch may happen. Screen error can only hold a finer level farther out, never coarsen nearer. This keeps PRD-461's haze rule true by construction. The phone-side saving (coarsening earlier) is deliberately not claimed. + +**Risks:** + +- **Memory.** Each fine-to-coarse pair retains position/normal delta arrays plus Three's packed CPU/GPU texture copies. Admission reserves twice the unpadded texels to bound row padding without renderer limits. Total default-tile reserves are 51,080 bytes for 9×9 and 1,869,960 bytes for 65×65, charged to `residentByteBudget`; a fixed byte cap can admit fewer tiles. Neighbor membership changes also rebuild targets and retire the old geometry/cache, so the `terrainSeam` span must be included in performance qualification. +- **Shader variants.** Preserve the original one-variant intent for a shared terrain material/layout: never create a pipeline variant per tile. Stock target counts differ by LOD, so any unavoidable extra variants must remain bounded and shared per material/layout, with a dated decision documenting the actual census. That census is unrun; this gate remains open. +- **Native.** Conformance row `53-morph-target-animation` exercises one position target. Terrain normal/multiple-target interpolation and geometry/cache replacement still need their own desktop run. + +## Acceptance Criteria + +- [x] AC-1 [local]: A morph frame writes no `position` and no `normal` attribute on the CPU, and the tile's rendered surface at morph progress p equals the CPU blend at p within 1 mm. proof: red-green `pnpm exec vitest run packages/core/__tests__/world-terrain-tiles.spec.ts` — 2026-10-10: red on the unchanged CPU path (`morphTargetsRelative` false); green unit parity uses stock `Mesh.getVertexPosition` and normalized normal deltas against the unchanged `validate: true` CPU reference for isolated, two-tile and nine-tile residency. It covers skipped levels, refinement, interruption, shared-edge activation/removal, skirts, stable base-attribute versions/identity and the unchanged three-frame duration. This is stock morph arithmetic proof; actual browser/native GPU rendering and visual judgement remain open under AC-2/Phase 2. 2026-10-11 GPU observation: headed `terrain-raster.playtest.json` with `--browser-recipe webgpu` on NVIDIA/Turing (one walk, `artifacts/prd566-payload-fix-20261011T195753Z/walk/capture.json`) renders each morph-blending tile (influence strictly between 0 and 1) into a float world-position target and compares it with a ray cast on a same-frame CPU reference: `gpuSurfaceSamples` 0→1152, `gpuSurfaceMaxError` 7.97e-6 m (bound 1 mm); both assertions pass. Earlier 20–31 m errors came from the probe, not the morph: `NodeMaterial` clamps `colorNode` with `.max(0)`, fixed by `fragmentNode`. The scenario still exits 1 on the three known landform-topology metrics and two stale seam-baseline triviality guards, unrelated to this box; payload failure fixed by exposing only the newest 64 LOD-trace samples. No screen output changes, so no visual judge. +- [x] AC-2 [local]: The abyss-framework terrain walk keeps `lodTransitions > 0`, `maxLodPop ≤ 16` and `maxLodTransitionFrames` unchanged, with 0 console errors on a named WebGPU adapter. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/terrain.playtest.json --url 'http://127.0.0.1:5183/?terrain' --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5183 --strictPort' --browser-recipe webgpu` — Evidence: pending. 2026-10-11 green on the AC-2 claims: headed `--browser-recipe webgpu`, NVIDIA/Turing, fixed clock, 180 ticks (`artifacts/prd566-ac2-final-20261011T233351Z/fixed.log`): `lodTransitions` 0→4, `maxLodPop` 0.4825 m (bound 16), `maxLodTransitionFrames` 3, `maxBlendingTiles` 1, `activeMorphFrames` 8, no console errors; 28/31 assertions pass. The scenario still exits 1 on the three world-topology landform metrics (power-spectrum slope 2.11, median 64 m relief 0.48, Horton–Strahler 3), which measure the example heightfield, not LOD, and predate this PRD. Cause of the earlier 0→0 reading: at the old 1280×720 framing the walk projects 2.4 px/m, so every tile's coarser level costs 1.388 px of error against the 0.85 px coarsening threshold and the screen-error policy correctly keeps LOD0; the fixture now uses the sibling's 1280×300 framing so coarser levels are eligible, with every threshold unchanged. The two seam guards that fired as already satisfied now carry `allowTrivial` reasons (the CPU reference validates from its first observation). +- [x] AC-3 [local]: Under PRD-461's recipe, every screen-error switch distance at 1280×720 and at 2560×1440 is at least fog near (180 m) and at least the `lodDistances` floor. proof: `pnpm exec vitest run packages/core/__tests__/world-streaming-recipe.spec.ts` — 2026-10-10: sampler-based unit walk at 1280×720 switches at 180/360 m; 2560×1440 holds finer until 480 m. Both respect explicit floors and fog near 180 m. This is selection arithmetic proof; actual rendered fog/visual proof remains open. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| GPU terrain morph | A game streams terrain through `TerrainTiles.follow` / `WorldCells`; a level change starts a transition | `updateLodTransitionGeometry`'s per-vertex CPU writes are deleted; the CPU blend survives only behind `validate` | AC-1, AC-2 | +| Screen-error terrain LOD | The same `follow` call that picks the level | `lodLevelForDistance` stays only as the floor | AC-3 | + +## Execution Phases + +#### Phase 1: Measure the blend frame +**Status:** DONE — aligned worst-1% blend membership measured (3116ac3ae; 4 of 6 intervals at or above p99 are blend frames). +**Files:** `packages/core/__tests__/world-tiles-cost.spec.ts`, `examples/abyss-framework/src/scenes/TerrainProbe.ts` +**Implementation:** an operation-count case for one blend frame, a `TN_BENCH` timing case, and a probe field for the most blending tiles in one frame. The gate verdict goes under `## Decisions`. +- [x] The blend frame's field reads per tile are counted, and its time per 65×65 tile is recorded here. proof: `TN_BENCH=1 pnpm exec vitest run packages/core/__tests__/world-tiles-cost.spec.ts` — 2026-10-10: exit 0, 20/20 tests pass; 4,225 `normalAt` and 25,350 `heightAt` calls per tile. The final separate `-t 'times one blending'` run (exit 0, 1 passed/19 filtered) measured 2.416 ms mean, 1.692 ms median, 10.069 ms p99 across 100 frames after 20 warm-ups; earlier isolated mean 1.239 ms. No spies in the timed loop; one resident 65×65 tile, validation/merging off, no admissions or transition completion during the timed `process()` call. This includes the one-tile process bookkeeping; transition-start `follow()` and completion/restoration are outside the timer. Final full bench also measured 1.575 ms per 289-tile walking `follow()+process()` and 0.310 ms settled. A bounded repeat during PRD-564’s allowed CPU window measured 1.266 ms mean, 1.171 ms median, 2.613 ms p99 (same 100 samples/20 warm-ups; 1 passed/19 filtered; desktop load 16.67/20.41/24.00). This was lower contention, not an unloaded measurement. Logs: `/tmp/prd566-bench-final.log`, `/tmp/prd566-bench-final-isolated.log`, `/tmp/prd566-quiescent-bench.log`. +- [x] The terrain walk reports the largest per-frame `blendingTiles` and whether blend frames sit in the worst 1%. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/terrain.playtest.json --url 'http://127.0.0.1:5183/?terrain' --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5183 --strictPort' --browser-recipe webgpu` in the prescribed NVIDIA/session environment — 2026-10-12: exit 0, 33/33 assertions, NVIDIA/Turing; log `/tmp/prd566-box59-walk.log`. The probe retains `{frame, startedAt, blendingTiles, frameMs}` at the pre-process world-draw boundary; the next draw closes the preceding entry's interval, and the unfinished newest entry is omitted. No separate-series join. `/tmp/prd566-box59-counts.js` validates consecutive frame ordinals and timestamp/interval equality: 506 completed retained frames, peak 5 blending tiles, 48 blend frames, 6 intervals at or above nearest-rank p99 162.60000002384186 ms, including 4 blend frames. **Yes, some blend frames sit in the worst 1%.** These are loaded-host world-draw intervals, not GPU durations or evidence of causation. Red-green `pnpm exec vitest run packages/core/__tests__/loop.spec.ts`: red exit 1 (1 failed/35 passed; `/tmp/prd566-box59-red.log`), green exit 0 (36/36; `/tmp/prd566-box59-green.log`), including preceding-draw membership and bounded retention. `pnpm typecheck`: exit 0 (`/tmp/prd566-box59-typecheck.log`). `pnpm lint`: exit 1 on existing errors outside the alignment change (`/tmp/prd566-box59-lint.log`); no lint waiver or green claim. `pnpm test`: exit 1 (`/tmp/prd566-box59-tests-local.log`); native suite 14 failed/1,567 passed/62 skipped due to unavailable GPU configuration and missing native test executables; no full-suite green claim. Shipped doctor exits 0 (`/tmp/prd566-box59-doctor.log`) and reports no DISPLAY in that suite environment. The laptop suite could not start (missing dependencies and Git HEAD), so the suite was run locally. `pnpm check:docs`: exit 0, 3,025 links across 1,347 Markdown files (`/tmp/prd566-box59-docs.log`). `pnpm prd:progress`: 3/3 phases (6/6 boxes), 3/3 acceptance, `prd:100% — ready`. + Failed history only: the earlier SwiftShader walk exited 1 (27/31); `/tmp/prd566-finish-browser.log` is not hardware proof. Historical positional-tail correlation claims below are superseded by the round 3 alignment audit and do not close this box. Latest attempt, 2026-10-12 UTC: rebuilt the shared WebGPU recipe with `--use-angle=vulkan`, `--use-vulkan`, and `--disable-vulkan-surface` (no shared `--no-sandbox` addition). The AC-2 command above, after sourcing `~/.cache/nvidia-615.71.09/env.sh` with `DISPLAY=:0`, `XAUTHORITY=/run/user/1000/xauth_SWsSOq`, and `TN_PLAYTEST_HOST_DISPLAY=1`, reports `vendor=nvidia`, `architecture=turing`; exit 1, 28/31 assertions pass, zero console errors, and no `TN_CAPTURE_BLANK` or software-adapter diagnostic. Fixed-step walk: 180 ticks, 159 retained per-world-draw blend samples, 12 positive samples, peak `blendingTiles=1`, transitions 0→4, max transition frames 3, active morph frames 8, CPU-reference max LOD pop 0.4825448989868164 m. Only the power-spectrum-slope, median-64m-relief, and Horton–Strahler topology assertions fail. No per-frame timing or p99 frame time is reported, so worst-1% blend membership is unmeasured and this box stays open. Recipe regression: red exit 1 (missing ANGLE Vulkan flag), green exit 0 (5/5); focused browser/CLI/provenance tests 39/39, playtest build and typecheck exit 0. Log: `/tmp/prd566-harness-walk.log`; other checks: `/tmp/prd566-harness-{red,green,focused,build,typecheck,doctor}.log`. Desktop box unchanged. + Harness brief rerun, 2026-10-12 UTC at `d90c4b7d6`: the exact AC-2 recipe command and prescribed driver/session environment again report `vendor=nvidia`, `architecture=turing` without `--no-sandbox`; exit 1, 28/31 assertions pass over 180 fixed-step ticks. Retained blend samples: 103, 12 positive, peak 1; transitions 0→4, max transition frames 3, active morph frames 8. Both `TN_TERRAIN_TILES` console lines report 9 tiles, 0 blocks, 9 draws and blending 0; the runtime peak is captured separately. No console-error or `TN_CAPTURE_BLANK` failure; only the same three topology assertions fail. Frame-time p99 and worst-1% blend correlation remain unreported, so box A stays open. Existing red evidence is reused (1 failed/4 passed); current unit rerun passes 5/5 and playtest rebuild exits 0. Log: `/tmp/prd566-harness-rerun-walk.log`; unit/build: `/tmp/prd566-harness-unit.log`, `/tmp/prd566-harness-build.log`. Box B was not rerun. + + Box A disposition, 2026-10-12 UTC: remains unticked. Fixed-step retained-window numbers are measured below, but the box’s declared live-clock proof retains no blend frames for correlation, and neither walk exits 0 because of the three pre-existing topology failures. A live-clock observation retaining the blend interval is still required. + Finish measurement, 2026-10-12 UTC: the existing runtime already records frame times, but the scenario omitted `assert.performance`, so the runner discarded them. Added `minFps: 1` to request and sanity-check the series; focused regression red exit 1, green exit 0 (1 passed/26 filtered; `/tmp/prd566-frame-times-{red,green}.log`). Exact AC-2 command plus `--artifacts /tmp/prd566-tail-artifacts` exits 1, 30/33 assertions pass on NVIDIA/Turing. The paired newest 1,024 world-draw blend counts and completed runtime frame intervals have equal length: peak blend count 1, nine positive counts, nearest-rank p99 69.5 ms, slowest `ceil(1024 * 0.01) = 11` frames contain one blend frame (86.4 ms); eight other blend frames are below p99. Runtime samples are frame intervals, not GPU durations; fixed-step simulation and the validating CPU reference preclude attributing this tail to GPU morph cost. The retained window excludes older draws, so this is a tail-window observation, not a complete walk distribution. Log: `/tmp/prd566-tail-walk.log`; capture: `/tmp/prd566-tail-artifacts/capture.json`. A companion with `--live-clock --artifacts /tmp/prd566-tail-live-artifacts` exits 1, 30/33 pass, transitions 4, duration 3, peak blend count 1; its newest 1,024 samples contain zero blend frames (p99 17.7 ms), so no live-clock blend membership is claimed. Log: `/tmp/prd566-tail-live-walk.log`. Both runs fail only the same three topology assertions. + + Box A completed, 2026-10-12: example-layer landform repair, with every threshold and scenario assertion unchanged. The old outward-draining bowl and isolated short ridges yielded spectrum slope 2.111969, median local relief 0.480765 and stream order 3. The converging, mirrored multiscale catchment yields 4.388048, 0.243431 and 5; steep-slope fraction 0.182566 still exceeds 0.1. The camera now follows player height so the live walk remains visible. Regression `pnpm exec vitest run packages/core/__tests__/world-topology.spec.ts packages/core/__tests__/world-terrain-tiles.spec.ts`: red exit 1 on the three original metrics (`/tmp/prd566-A-red.log`), green exit 0, 78/78 tests (`/tmp/prd566-A-final-units.log`); example typecheck and changed-file Biome exit 0. + Exact fixed-step proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/terrain.playtest.json --url 'http://127.0.0.1:5183/?terrain' --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5183 --strictPort' --browser-recipe webgpu --artifacts /tmp/prd566-A-camera-fixed-artifacts`: exit 0, 33/33 assertions, NVIDIA/Turing, 180 scenario ticks, movement 417.089762 m, transitions 0→39, peak blending 5, max transition duration 3, zero diagnostics; log `/tmp/prd566-A-camera-fixed.log`. GPU environment: `. ~/.cache/nvidia-615.71.09/env.sh; export DISPLAY=:0 XAUTHORITY=/run/user/1000/xauth_SWsSOq TN_PLAYTEST_HOST_DISPLAY=1`. + Exact live proof: the fixed-step command with `--live-clock --artifacts /tmp/prd566-A-camera-live-artifacts` replacing its artifact argument: exit 0, 33/33 assertions, NVIDIA/Turing, movement 843.330023 m, transitions 0→75, peak blending 3; log `/tmp/prd566-A-camera-live.log`. The aligned 1,024-entry per-draw blend/performance tails contain 46 positive blend samples; one of the slowest 11 frames (ceil(1%)) blends, with that tail's minimum frame time 34.8 ms. This is loaded-host live-clock correlation, not an unloaded performance claim. No changed expectation, waiver or deleted assertion. + + Resume box A verified here, 2026-10-12: `. ~/.cache/nvidia-615.71.09/env.sh; export DISPLAY=:0 XAUTHORITY=/run/user/1000/xauth_SWsSOq TN_PLAYTEST_HOST_DISPLAY=1; node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/terrain.playtest.json --url 'http://127.0.0.1:5183/?terrain' --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5183 --strictPort' --browser-recipe webgpu` exits 0, 33/33 assertions, NVIDIA/Turing (`/tmp/prd566-resume-boxA.log`). Topology slope 4.388047961919133, median 64 m relief 0.24343068968090456, stream order 5; transitions 39, blending-tile peak 5, duration 3. Retained runtime frame-interval series has 769 samples, nearest-rank p99 70.4 ms; frame-budget windows report p99 6.8/20.8/50.1/29.8 ms. These are loaded-host fixed-step observations, not unloaded GPU timing. Reviewed `e2714a6df`: it changes the actual sampler and height-following camera, not an expectation; all old/new assertion thresholds are identical. Focused `pnpm exec vitest run packages/playtest/__tests__/desktop-playtest.spec.ts packages/playtest/__tests__/unobservable-lane.spec.ts packages/core/__tests__/world-topology.spec.ts` exits 0, 57/57 (`/tmp/prd566-resume-focused-green.log`). + +#### Phase 2: Morph targets replace the CPU blend +**Status:** COMPLETE — existing acceptance criteria and browser/desktop walk proof pass; both walks record 39 transitions. +**Files:** `packages/core/src/world-tiles.ts`, `packages/core/__tests__/world-terrain-tiles.spec.ts` +**Implementation:** build relative position/normal targets for every reachable fine-to-coarse pair, drive their influence from existing transition progress, retain the original CPU reference for `validate`, and count delta arrays plus Three's packed CPU/GPU textures in `residentByteBudget`. Shared-side membership changes regenerate immutable targets while preserving the base attributes and disposing the old geometry/cache; their cost is included in the existing `terrainSeam` span. +**Verification:** AC-1 and AC-2 close this phase. +- [x] Stock morph arithmetic, residency accounting, disposal and focused terrain regressions pass. proof: `pnpm exec vitest run packages/core/__tests__/world-terrain-tiles.spec.ts packages/core/__tests__/world-tiles-cost.spec.ts packages/core/__tests__/world-terrain-merge.spec.ts packages/core/__tests__/world-terrain-merge-walk.spec.ts packages/core/__tests__/world-terrain-merge-inflight.spec.ts packages/core/__tests__/world-terrain-jobs.spec.ts packages/core/__tests__/world-streaming-recipe.spec.ts packages/core/__tests__/constraints.spec.ts packages/core/__tests__/build.spec.ts` — 2026-10-10: 9 files pass, 124 tests pass/4 opt-in timing cases skipped, including exact-fit/one-byte-short memory cases (9×9 and padded 65×65), the 8,192-row fallback case and zero/duplicate effective LODs. Core typecheck passes. After the accounting follow-up, `pnpm --filter @threenative/core build` and its publint step both pass (exit 0; `/tmp/prd566-accounting-build.log`). Biome passes (9 complexity warnings across the accounting follow-up's two checked files); `pnpm check:docs` checks 3,025 links. Latest focused log: `/tmp/prd566-residency-green.log`. A 65×65 GPU blend frame reads zero field heights/normals; the validating CPU case retains 4,225 `normalAt` / 25,350 `heightAt` reads. No new performance timing or GPU/visual run is claimed. +- [x] The same walk runs on the desktop host with matching transition counts; no mobile claim. proof: `. ~/.cache/nvidia-615.71.09/env.sh; export DISPLAY=:0 XAUTHORITY=/run/user/1000/xauth_SWsSOq TN_PLAYTEST_HOST_DISPLAY=1 MYSTRAL_BUNDLE=/tmp/prd566-round3-native.bundle; sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/terrain.playtest.json --target desktop --executable packages/runtime-native/build/tn-linux/mystral --host-arg run --host-arg prd566-finish-native-game.js --artifacts /tmp/prd566-resume-desktop-artifacts` — 2026-10-12: exit 0, 33/33 assertions, NVIDIA/Turing, transitions 0→39 (browser 39), no network failures; log `/tmp/prd566-round3-desktop-warmup.log`. `--artifacts` selects the retained proof directory; the CLI has a default directory, so the flag is not functionally required, but this proof includes it. The bundle-relative entry and `MYSTRAL_BUNDLE` are required for this packaged invocation: the original absolute `/tmp/...js` entry does not resolve inside the VFS. Package preparation: `node packages/runtime-native/scripts/bundle.mjs --project examples/abyss-framework --entry src/terrain-game.ts --target desktop --output /tmp/prd566-finish-native-game.js`, then `packages/runtime-native/build/tn-linux/mystral compile /tmp/prd566-finish-native-game.js --include /tmp/terrain-assets --root /tmp --bundle-only --out /tmp/prd566-round3-native.bundle` (both exit 0; `/tmp/prd566-round3-{bundle,package}.log`). The procedural example now ships an empty version-1 manifest in `public/terrain-assets`; native packaging includes it. Desktop driver now honors the scenario viewport (1280×300 instead of 1280×720), and device warmup observes frames instead of advancing 10 extra simulation ticks. Those extra ticks left tile `7:0` at 48.352 m instead of browser 47.750 m from the player, missing its unchanged 48 m transition threshold. Manifest and viewport regressions fail before repair and pass after; warmup regression retains every warmup frame, startup recovery, and failure propagation. Earlier positional-tail correlations do not close the separate aligned worst-1% box. Round 3 verification: focused desktop/startup-warmup/bundle suite exit 0, 50/50 (`/tmp/prd566-round3-focused.log`), transport/fail-closed suite exit 0, 22/22 (`/tmp/prd566-round3-transport.log`); playtest build/typecheck and root typecheck exit 0. Root lint exits 1 on pre-existing parameter reassignment at `src/world/terrainSampler.ts:34-35` (`/tmp/prd566-round3-lint.log`); this round does not edit the sampler. Full repository test suite and mobile execution were not run. + + Finish native-entry repair, 2026-10-12 UTC: example-layer fix, not a packager guard change. `src/terrain-game.ts` default-exports the unchanged terrain game definition, shared by the browser entry; browser DOM/CSS/React mounting stays in `src/terrain-main.tsx`, and input uses the engine’s default target. Native-entry regression red exit 1 (missing portable entry), green exit 0 (one test); final focused suite exit 0 (3 passed/26 filtered; `/tmp/prd566-final-focused.log`). `node packages/runtime-native/scripts/bundle.mjs --project examples/abyss-framework --entry src/terrain-game.ts --target desktop --output /tmp/prd566-finish-native-game.js` exits 0 (`/tmp/prd566-native-entry-bundle.log`). Existing built host reused; no native rebuild needed. The exact desktop proof command above now runs the runner but exits 2 before host launch with `TN_PLAYTEST_UNSUPPORTED_ON_TARGET: Desktop desktop target does not support network assertions.` The scenario explicitly requires `noNetworkErrors: true`, while desktop transport has no CDP network observer. Log: `/tmp/prd566-native-entry-walk.log`. No assertion waived; box B remains open, transition counts unobserved. Browser rerun after sharing the entry exits 1, 30/33 assertions pass, movement 374.39 m, transitions 4, peak blending 1, maximum transition duration 3; only the same three topology metrics fail (`/tmp/prd566-shared-entry-browser.log`, NVIDIA/Turing capture `/tmp/prd566-shared-entry-browser-artifacts/capture.json`). Example typecheck and changed-file Biome exit 0 (`/tmp/prd566-terrain-typecheck.log`, `/tmp/prd566-final-biome.log`). + + Finish broad checks: `pnpm lint` exits 0 (existing warnings; `/tmp/prd566-final-lint.log`). `pnpm typecheck` exits 1 on unchanged `packages/core/__tests__/world-streaming-recipe.spec.ts:120,130` (`width`/`height` possibly undefined; `/tmp/prd566-final-typecheck.log`). `pnpm test` exits 1 in package-test after docs/build pass: native suite 13 failed/1,566 passed/62 skipped, including missing native test executables, absent QuickJS host, `ENOBUFS` and missing/null adapter or pump observations; root unit phase not reached (`/tmp/prd566-final-test.log`). The focused changed-code checks above pass; no broad-green claim. Laptop status showed 2 GB free memory, so this gate stayed local. + + Resume native observer, 2026-10-12: engine-layer repair in `runtime-native`, because fetch failures must survive startup and reach every native game through the host mailbox. The inherited fetch shim retains HTTP/non-OK and file failures, caps them at 256 with a named overflow failure, and returns copied entries; `runtime.cpp` exposes `fetch.network` as `playtest.network`. Regression against an isolated HEAD source copy: `pnpm exec vitest run tests/fetch-shim.test.mjs -t "network observation"` (cwd `/tmp/prd566-observer-red-ihtzi4we/packages/runtime-native`) exits 1, both new observer tests fail (`/tmp/prd566-resume-fetch-red.log`). Current `pnpm exec vitest run tests/fetch-shim.test.mjs` (cwd `packages/runtime-native`) exits 0, 15/15 tests (`/tmp/prd566-resume-fetch-green.log`). `pnpm native:build` exits 0, existing build is already current (`/tmp/prd566-resume-native-build.log`). The NVIDIA desktop walk actually delivers the failed manifest request through this channel; complete walk parity remains unticked below. + + Resume box B remains open, 2026-10-12: runner-layer repair consumes the native mailbox channel, requires `runtime.network` on desktop, and fails on absent, malformed or overflowing observations. Android/iOS remain explicitly unsupported for network assertions. Isolated pre-change runner regression exits 1, 5/5 selected observer tests fail (`/tmp/prd566-resume-observer-red.log`). Two desktop input mocks initially failed because they lacked the now-required channel; only fixture capabilities/observations were updated, with assertions unchanged (red 2/2, `/tmp/prd566-resume-fixture-red.log`; green `pnpm exec vitest run packages/playtest/__tests__/device-playtest.spec.ts packages/playtest/__tests__/three-bridge.spec.ts`, exit 0, 61/61, `/tmp/prd566-resume-transport-green.log`). `pnpm --filter @threenative/playtest build` and `pnpm --filter @threenative/playtest typecheck` exit 0 (`/tmp/prd566-resume-playtest-{build,typecheck}.log`). `git diff --check` and changed-fixture Biome pass. + Desktop bundle: `node packages/runtime-native/scripts/bundle.mjs --project examples/abyss-framework --entry src/terrain-game.ts --target desktop --output /tmp/prd566-finish-native-game.js`, exit 0 (`/tmp/prd566-resume-bundle.log`). Exact requested desktop command `sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/terrain.playtest.json --target desktop --executable packages/runtime-native/build/tn-linux/mystral --host-arg run --host-arg /tmp/prd566-finish-native-game.js` exits 2 (`/tmp/prd566-resume-boxB.log`): capture times out after a native `createView` error without the NVIDIA environment. Shipped `doctor --text` exits 0 (`/tmp/prd566-resume-doctor.log`). The same desktop command after `. ~/.cache/nvidia-615.71.09/env.sh`, with `--artifacts /tmp/prd566-resume-desktop-artifacts`, exits 1 (`/tmp/prd566-resume-boxB-gpu.log`): NVIDIA GeForce RTX 2080, 32/33 assertions pass, zero console/runtime errors, one observed failed GET `/terrain-assets/assets.manifest.json`; transitions 38 versus browser 39, peak blending 8, duration 3. The procedural game's declared `/terrain-assets` path has no manifest, and the raw desktop bundle has no staged asset root. Both the real asset failure and transition-count mismatch require resolution before ticking B. No waiver, assertion deletion or changed expectation. + Existing negative scenarios, same desktop host: `device-smoke-misspelled.playtest.json` exits 2 with `TN_PLAYTEST_SCENARIO_INVALID` (`/tmp/prd566-resume-misspelled.log`); `device-smoke-wrong-value.playtest.json`, bundled `examples/native-smoke/src/game.ts`, exits 1 with `TN_PLAYTEST_VISIBILITY_FAILED` (cube present, 43,360 px against the unchanged 1,000,000,000 px minimum; `/tmp/prd566-resume-wrong-value.log`). Broad typecheck/lint/test were not rerun; focused checks are the verification for this bounded resume. `pnpm check:docs` exits 0 (3,025 relative links, `/tmp/prd566-resume-docs.log`); `pnpm prd:progress` exits 0: phases 2/3 complete (5/6 boxes), acceptance 3/3, `prd:75%`. No push; PRD stays PARTIAL in place. + +#### Phase 3: Screen-error selection with the distance floor +**Status:** SOURCE IMPLEMENTED — focused unit and guide proof pass; runtime GPU/visual proof remains open +**Files:** `packages/core/src/world-tiles.ts`, `packages/core/src/world-cells.ts`, terrain/streaming/WorldCells unit tests, `docs/guides/world-streaming.md` +**Implementation:** keep absolute errors per level, call `selectLodLevel` with the props' resolved policy, and restrict selectable candidates to the `lodDistances` floor. WorldCells passes its resolved policy and current raster view; settled terrain rechecks retained errors when the camera or viewport changes without a residency rebuild. The guide states that `lodDistances` is now the nearest switch. +**Verification:** AC-3 closes this phase. +- [x] Two viewports choose different levels for the same tile, and neither switches nearer than the floor. proof: focused terrain/streaming/WorldCells tests — red on unchanged distance-only source (2 failed); green verifies 720/1440 raster rows, camera/viewport refresh after settling, explicit/default distance floors, no-view continuity, residency eviction/admission, retained error reuse and WorldCells resolved-policy reachability. 2026-10-10: 152 tests pass across 7 focused files; core typecheck passes. View-only transitions also schedule the existing admission-budgeted merge rebuild (red: no work owed; green: converges). `/tmp/prd566-phase3-green.log`. Current-source `pnpm --filter @threenative/core build` passes including DTS generation and publint (`All good!`); `pnpm api:surface:check` passes (14 packages, 412 symbols). Build log: `/tmp/prd566-phase3-build.log`. +- [x] The guide documents the floor and the shared pixel budget. proof: `pnpm check:docs` — current raster height/projection, shared `autoLod.maxPixelError`/hysteresis and no-view fallback documented; 3,025 links pass. Biome passes with complexity warnings; `git diff --check` passes. + +## Decisions + +- 2026-10-12 UTC, finish topology triage: `/tmp/prd566-tail-walk.log` and `/tmp/prd566-tail-live-walk.log` fail `world.topology.powerSpectrumSlope` (2.111969383399509), `world.topology.median64mRelief` (0.4807647097279308), and `world.topology.maxHortonStrahlerOrder` (3), matching the previously verified NVIDIA walk. These are landform metrics, not seam/stitch triviality guards. `TerrainTiles` constructs the independent `topologyField` directly from `sampleHeight` plus `worldPasses` before any transition, and summarizes its heights/flow in the constructor (`packages/core/src/world-tiles.ts`); mesh morph targets and LOD selection cannot affect that field. They are pre-existing sampler/topology failures, not caused by this PR’s morph change. No assertion, sampler or morph code changed for them. + +- 2026-10-10, Phase 1 measurement arm: the measured 2.416 ms per blending 65×65 tile (earlier isolated mean 1.239 ms) meets the unchanged ≥0.5 ms bench gate. Root accepted the unchanged CPU-cost branch after the 1.266 ms bounded repeat; Phases 2–3 remain unticked until their implementation and proof pass. Observed desktop load ranged from 17.56/22.30/25.80 to 30.53/29.26/27.42; these are loaded-desktop results, not unloaded hardware qualification. The laptop declined jobs at 99–100°C. No valid worst-1% or visual-judge verdict is claimed. Remaining proof: rerun AC-2 with `--live-clock` on a named real adapter/display, request the existing `runtime.performance` series, and correlate explicitly aligned world-draw samples with the probe's `blendFrames`; loader/error frames can prevent a valid tail zip, and fixed-step/SwiftShader capture does not hold that claim. +- The walk exposed an engine telemetry blocker: `TerrainTiles.debug()` emitted absent `pendingConstruction` as `undefined`, causing `$.components.terrain.pendingConstruction must be JSON-safe`. Omit only that absent optional field in the shared engine method; no terrain rendering or LOD selection changed. Focused red: 1 failed/19 filtered; green cost file: 16 passed/4 opt-in bench skips. Core/probe typechecks and core build passed; Biome passed with five existing complexity warnings; `pnpm check:docs` checked 3,025 links successfully. No commits, push, PR, merge or archive were performed. + +- 2026-10-10, Phase 2 implementation (root accepted): adjacent-only targets cannot preserve existing direct/skipped three-frame transitions or the normalized coarse-normal endpoint. Build each reachable fine-to-coarse pair instead; transition selection and timing stay unchanged. Shared edges must remain canonical exactly as the original CPU seam reconciliation leaves them. Targets therefore pin currently shared sides, with geometry/cache replacement only when neighbor membership changes; base position/normal buffers retain their identity, and retired geometry is disposed. This topology work is charged to the existing `terrainSeam` profiling span, not hidden in the blend-only timing case. Memory admission reserves delta arrays and both copies of Three's RGBA32F packed texture, including 4,096-texel row padding: default 9×9 tile 33,224 bytes; default 65×65 tile 1,430,088 bytes. `validate: true` deliberately retains the original CPU path and its original residency cost. Existing native row `53-morph-target-animation` proves only one position target in its source; it does not establish this terrain normal/multiple-target/geometry-replacement path or a target-count ceiling. Desktop, named-adapter walk, pipeline census, same-pose two-run BEFORE/AFTER captures and the requested independent visual verdict remain unrun. BEFORE is still commit `692a39b1b`; no push, merge or archive in this bounded source task. + +- 2026-10-10, root residency review follow-up: installed Three `src/nodes/accessors/Morph.js:82–91` fixes WebGPU/node packing at 4,096 texels, but `src/renderers/webgl/WebGLMorphtargets.js:43–50` uses `capabilities.maxTextureSize`; core's renderer can select that WebGL fallback (`packages/core/src/renderer.ts:1502`). A 65×65 finer surface has 8,970 texels: an 8,192 limit pads to 16,384 texels, exceeding the first commit's 12,288-texel reservation. Red: new fallback test reserved 500,856 morph bytes versus at least 631,928 required. Fix: without a renderer limit, reserve twice the unpadded texel count; for any row width, packing adds less than one row, so the actual allocation is strictly below that bound. CPU delta arrays and both CPU/GPU texture copies remain charged. Updated conservative default-tile reserves are 51,080 bytes (9×9) and 1,869,960 bytes (65×65), which can admit fewer tiles at a fixed byte cap. `resolutionFor` is exact division plus one, with no clamp; equal effective resolutions arise from duplicate factors and both admission/build count them identically. Zero targets retain the original 5,904-byte one-level 9×9 tile. Green: 124 tests pass/4 timing skips across 9 focused files; core typecheck passes. Browser/native allocation, pipeline/visual/performance proof and Phase 3 remain open; no rebuild, push, merge or archive in this follow-up. + +- 2026-10-10, Phase 3 source: retain each level’s already measured absolute surface error during bounded construction and pass only distance-eligible candidates to installed `selectLodLevel`. WorldCells forwards its existing resolved props pixel budget and hysteresis with the actual render camera/raster height; no new policy knob or projection constant. View updates select from retained errors and preserve the existing three-frame blend and neighbor coordination. A view-only retarget marks pending merge rebuilds as deferred admission, keeping them on the existing budgeted follow path rather than rebuilding geometry inside projection selection. Calls without a view keep distance-only behavior. The original no-per-tile pipeline gate is restored explicitly: target-count layouts may require a bounded shared set, but no extra-count decision or passing census is claimed before measurement. Browser/native/GPU, pipeline census, same-pose two-run visual judgement and valid hardware worst-1% performance remain open. + +- 2026-10-10, topology retention reclamation (source/unit proof only): read-only Opus diagnosis identified the 1025² observation's four retained Float32 buffers (16,810,000 bytes) as diagnostics-only; resident fields, colliders, morph targets and LOD decisions do not consume that observation. Red: the unchanged real probe sampler, 65-grid tiles, 20,000,000-byte cap, nine-tile ring and four-iteration CPU world passes throw `TerrainTileBudgetError` at x=32 m (`/tmp/prd566-topology-walk-red.log`, 2 failed, 6.01 s); five debug/lifetime checks also fail because the observation remains attached (`/tmp/prd566-topology-debug-red.log`, 7.14 s). Cache the identical raw diagnostics once at ≤10,000 samples or the identical summary/description above that threshold, then detach and release the field. The original full construction charge and cap rejection precede release; construction peaks remain recorded. Raw JS number-array payloads reserve eight bytes per element (17×9 heights plus flow: 2,448 bytes), and large summaries retain zero sample-buffer bytes. Debug returns independent raw arrays/origin/metrics; disposal clears the cache, and cap failure detaches the field. Eager stock morphs, transitions, topology extent, erosion settings, wanted keys and caps remain unchanged. + +- 2026-10-10, verified reclamation results: focused green 7/7 in 13.71 s checks raw 17×9 and large 1025² deep equality with prior Heightfield-derived debug output, absent-flow description-only behavior, mutation safety, construction peaks, cleanup and both walk paths (`/tmp/prd566-topology-green.log`). The final 1280×720 orthographic probe view repeat passes both x=0…330 m walks in 13.85 s, checking all nine exact ring keys and resident bytes at every metre (`/tmp/prd566-topology-walk-green.log`). GPU-morph residency is 9×1,903,760 = 17,133,840 bytes; CPU-reference residency is 9×354,728 = 3,192,552 bytes. Both retain zero large-observation sample bytes; observed peaks are respectively 17,133,840 and 16,810,000 bytes. These are current-source unit measurements: the earlier eight-tile/19,672,592-byte result was current `validate:true`, not measured historical commit `692a39b1b`. The seven existing focused terrain/WorldCells files pass 161/161 tests in 24.07 s, including the unchanged 152-test morph/LOD slice plus two earlier JSON diagnostics cases and seven reclamation cases (`/tmp/prd566-topology-focused.log`). Core typecheck, build/DTS and publint pass (`/tmp/prd566-topology-typecheck.log`, `/tmp/prd566-topology-build.log`). Accounting still excludes base vertex/index GPU copies in both arms and merged-block copies as previously documented; this is not total physical memory. Garbage-collection delay, summary scratch allocations and geometry-replacement overlap peaks remain unmeasured. Named-adapter browser walk, native, pipeline, before/after captures, requested independent visual verdict and performance gates remain open; no new acceptance box closes. + +- 2026-10-10, single functional hardware candidate at `d2848bbcae953334950ccb373dd4764f245b94c4`: fresh example build supplied by root; terrain bundle SHA256 `2488034ee5e46830e8b02dd0be174686e55c2a221d4b4f1df11094bc87fc0351`. Doctor exits 0 with NVIDIA/Turing, nonblank 1280×720 and no console errors. The unchanged headed `terrain.playtest.json` exits 1 (19 assertions pass, 12 fail) on actual WebGPU NVIDIA/Turing, RTX 2080 driver 615.71.09. Exact commands, source/bundle/scenario hashes, GPU metadata, logs and snapshot are in `artifacts/prd566-functional-d2848bbca/receipt.json`, `doctor.json`, `walk-report.json` and `snapshot.json`; runtime uses the shipped five Vulkan flags plus `--use-angle=vulkan --use-vulkan --disable-vulkan-surface --no-sandbox` as nine separate `--browser-arg` values. Movement reaches x=373.153 m. Both observed endpoint key sets contain nine tiles and match the CPU reference, moving from x-columns −1…1 to 5…7; main residency/peak is 17,133,840 bytes and reference residency is 3,192,552 bytes. Unblended parity advances 12→47 frames and 125,580→412,620 vertices with zero position/normal error, but `maxBlendingTiles`, `lodTransitions`, `maxLodTransitionFrames`, `activeMorphFrames`, reference `stitchedEdges` and reference `maxLodPop` remain zero: this run does not exercise active morphs. The harness also rejects initially satisfied seam/parity-counter assertions as trivial, despite the counters changing. Topology gates fail at power-spectrum slope 2.1119693834, median 64 m relief 0.4807647097 and Horton–Strahler order 3. These are actual failures, not a runtime pass; no assertions/settings were weakened and no fix/retry was attempted. Intermediate per-tick resident keys and individual LOD levels are not exposed in this scenario report. The complete, non-overflowed pipeline census reports 40 creations, four unique programs and 40 unique pipeline identities with no failures/pending events; terrain material 17 accounts for two programs and 38 pipeline creations (37 creations across 27 objects for its main program). The bounded shared-layout/no-per-tile pipeline gate is not accepted from this failed, zero-transition run. The owned preview/browser/private display were stopped; unrelated GPU users were untouched. The single candidate screenshot is UNJUDGED and is not a historical BEFORE/AFTER comparison. Native, active-GPU, pipeline, visual and performance gates remain open. + +- 2026-10-10, bounded pipeline-key correction candidate: actual Three `RenderObject.getGeometryCacheKey()` keys morph attribute IDs because its normal callers share morph textures/node references; the WebGPU backend reused that identity for pipeline layout. Source and shipped-build patch hunks now let only `getRenderCacheKey()` request structural morph names, target counts, vertex counts, item sizes and relative mode. The default geometry identity and existing vertex stride/offset/item-size/normalized/name/index keys remain unchanged; no texture, node or binding ownership changed. Actual source/distribution backend tests reproduce unequal equal-layout keys (2 failures) and pass against an isolated copy of Three with the candidate patch. The full existing pipeline-scope and packaging files pass 22/22 tests in 19.00 s; clean-consumer patch application, idempotency, nested/hoisted dependency resolution and both source/build assertions pass. Focused TypeScript and Biome checks pass. Fixture aliases and consumer-copy substitution are confined to `/tmp/prd566-cache-fixture.vitest.mjs`; installed dependencies and lockfile remain untouched. Delivery still needs a reviewed pnpm patch-hash refresh/reapplication: `pnpm-workspace.yaml` names the patch, and `pnpm-lock.yaml` retains the old three hash and its 56 references. No new runtime run, native execution, per-tile active-LOD proof, visual judgement or performance claim; the original 12 scenario failures remain open. Logs: `/tmp/prd566-cache-red.log`, `/tmp/prd566-cache-final.log`, `/tmp/prd566-cache-typecheck.log`, `/tmp/prd566-cache-biome.log`. + +- 2026-10-10, bounded normal patch delivery attempt: offline lock-only refresh passed in 9 s; SHA256 verification and byte comparison confirm exactly 56 old-to-new patch-hash replacements and no other lockfile changes. The next frozen offline `--ignore-scripts` install stopped at `ERR_PNPM_PATCH_FAILED` for the new Three patch-hash directory. No retry, online fallback, postinstall, build or runtime run followed, and normal unaliased tests did not run. `packages/core/node_modules/three` now points at that new-hash directory but the structural-key methods are absent, so normal patch application is not qualified. The original lockfile bytes and resource metadata (load 12.48/9.03/9.42; 25,500 MiB available RAM) are preserved under `/tmp/prd566-cache-delivery/`, along with exact supervisor/install logs. The earlier 22/22 isolated-fixture result remains scoped to that fixture; native and the original 12 runtime failures remain open. + +- 2026-10-10, corrected normal patch delivery: the four new morph-key hunk headers used already-patched source offsets as pristine offsets. The custom installer searched whole-file context while pnpm's bounded search rejected them. Only those four headers were corrected; all non-header patch bytes stayed unchanged. Candidate SHA256 is `21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d`. One 240 s supervisor completed offline lock-only refresh (21.5 s), exact byte verification against `git show HEAD:pnpm-lock.yaml` (56 old-to-new hash replacements only), frozen offline `--ignore-scripts` install (6.9 s), and the normal unaliased pipeline-scope/packaging command (22/22 tests, 39.24 s). Source/backend-key tests and clean/nested/hoisted packaging/idempotency now pass against the normal installed dependency. Distribution backend tests still inject the source `RenderObject` getter; this does not prove execution of the built RenderObject class or native rendering. Logs and preserved HEAD lockfile are under `/tmp/prd566-cache-delivery-corrected-PkL7bh/`. No postinstall/MCP setup, build, runtime, commit or push ran. Coverage inspection: Three advertises `./webgpu` → `build/three.webgpu.js`; `./tsl` → `build/three.tsl.js`, which imports `three/webgpu`; core renderer imports `three/webgpu`, and native `bundle.mjs` dedupes `three` without remapping these entrypoints. `three.webgpu.nodes.js` is not an advertised package export or selected by those owned imports/bundler rules, so no additional patch was added. This is shipped-entrypoint evidence, not an all-builds/native qualification; the original 12 runtime failures remain open. + +- 2026-10-10, local pipeline-key delivery committed as `3c1464388`: one 240 s supervisor ran `pnpm --filter @threenative/core build` and `pnpm --filter abyss-framework build`, both exit 0. Core ESM/DTS, bundled MCP assets and publint pass; the fixture packages successfully with the corrected `21ed5f…` Three dependency. Exact commands, source HEAD/hashes, logs and all generated JS SHA256 values are preserved in `/tmp/prd566-cache-build-cHcPer/`. The fixture's `assets/terrain-CC8iA-TC.js` SHA256 is `74003a58c3267b15fc66cf038a351ded79c84be6a8e7aaf84d8ecef8991e6faa`; its `assets/terrain-main-Bf_eUC17.js` SHA256 is `68d1e77caf227e0e3cd5896ef3b941846158be1efc544cc9588f451584998d81`. Existing dynamic-import/chunk-size warnings and three unmanaged packaged files remain warnings, not runtime evidence. `pnpm prd:progress` remains `prd:50%` (4/6 phase boxes, 2/3 acceptance boxes). No browser/native run or push occurred; all 12 original runtime failures, active per-tile LOD, native, visual and cost gates remain open. + +- 2026-10-10, bounded generic component-assertion correction candidate: `evaluateRichPlaytestAssertions` calls `emitWorldGameplay` once for component assertions; its initial-value guard considered numeric/equality checks alone, so a real `changed:true` or `changed:false` comparison could be rejected as a trivial held range. Explicit temporal comparisons now bypass only that initial-value guard, still require all final comparators, and require both baseline/current values; missing either fails closed. Numeric-only initially satisfied ranges remain rejected without a waiver. The focused 17-case regression slice reproduces nine failures before the fix and passes 17/17 afterward, covering met/unmet change and equality/range requirements plus missing baseline/current/both. The complete nearest file passes 25/26: its untouched scenario-contract test still expects `stitchedEdges.entity = terrain`, while the existing fixture uses `terrainCpuReference`; no scenario or old expectation was edited in this component. Playtest TypeScript and Biome pass (five existing complexity warnings), and focused diff whitespace checks pass. Logs: `/tmp/prd566-world-guard-red.log`, `/tmp/prd566-world-guard-focused-green.log`, `/tmp/prd566-world-guard-green.log`, `/tmp/prd566-world-guard-typecheck.log`, `/tmp/prd566-world-guard-biome.log`. No build, browser, full suite, commit or push ran. Progress remains 50%; no extra box is ticked, and the original 12 runtime failures plus active-LOD/native/visual/cost proof remain open. + +- 2026-10-10, component-guard contract follow-up: root review authorized correcting only the stale `stitchedEdges.entity` expectation to `terrainCpuReference`. The existing scenario already measures seam/stitch values on that validated CPU reference (`validate:true`); the main GPU terrain uses `validate:false`, and `TerrainProbe.#checkParity` separately compares resident sets, transition counts and per-tile levels/surfaces. The contract still requires `gte:1` and forbids an `allowTrivial` waiver; all other thresholds, changed checks and scenario bytes are untouched. Only the newly added regression block was formatted by Biome. The complete nearest file now passes 26/26, playtest TypeScript and Biome pass (five existing warnings), and focused diff whitespace checks pass. Logs: `/tmp/prd566-world-guard-full-final.log`, `/tmp/prd566-world-guard-typecheck-final.log`, `/tmp/prd566-world-guard-biome-final.log`. No runtime or acceptance gate closes; progress remains 50% and the original 12 runtime failures stay open. + +- 2026-10-10, bounded resident-LOD observability candidate: `TerrainTiles.debug()` now reports each resident key, current `lodLevel`, distance, installed distance floor, retained selectable cap, the existing selector's current target, and next-coarser retained surface error/projected pixel error/depth/degeneracy. The debug-only calculation reuses `projectedLodError`, `worldSphere`, `conservativeViewDepth` and `#targetLod`; no selector or sampler was replaced. Policy snapshots report the installed distance thresholds, raster height, pixel budget, hysteresis and strict coarsening boundary. `TerrainProbe` adds the actual camera projection/world matrices and raster width/height to its existing entity snapshot. No per-frame selection path, camera, terrain, scene, scenario, threshold or default changed. A missing-observability regression fails before the change and passes afterward. Two focused tests pass: the actual seeded sampler reaches resident heightfields at world-metre coordinates on adjacent tiles without debug resampling; the actual selector coarsens the analytic 0.01 m error at normal projection and refines it at 100× zoom, where it measures 2.4 px. The full nearest terrain file passes 75/75 (51.03 s), including both original 330 m residency walks; core/example TypeScript, Biome and diff whitespace checks pass (11 pre-existing complexity warnings). Logs: `/tmp/prd566-lod-debug-{red,focused,full,core-ts,example-ts,biome}.log`. No sampler-unit bug was reproduced. This is unit-level observability/selection evidence, not a new browser observation: zero transitions still do not establish LOD0. No build, browser, native run, push or visual/performance verdict occurred. Progress remains 50%; all 12 original runtime failures and runtime active-LOD/native/visual/cost gates remain open. + +- 2026-10-10, bounded functional delivery at `45775a1b5207aca43af30191eebf91a95048a24b`: one 240 s supervisor completed normal core, playtest and abyss-framework builds, before/after scene doctors (both exit 0), and exactly one unmodified headed terrain walk (exit 1), in 74.56 s. Capture confirms NVIDIA/Turing, WebGPU, all nine prior manual browser arguments and 1280×720 raster. This was a loaded desktop (GPU 31% before/37% after; CPU load 25.14 before), so no timing/performance claim follows. The walk passes 21/31 checks versus 19/31 at `d2848bbca`; only `parityFrames` and `parityVertices` move from failure to pass after the generic temporal-guard correction. Ten checks still fail: the three unchanged topology metrics (slope 2.1119693834, relief 0.4807647097, order 3), blending, seam triviality, stitched edges, changed pop, transition frames, transitions and active morph frames. No assertion or threshold was changed. Parity frames rise 15→51, compared vertices 139035→435045, position/normal errors remain zero; active morph frames remain zero, so active GPU morph parity remains unproven. + The new bridge snapshots directly observe all nine resident tiles at baseline and endpoint: each actual current/target LOD is 0, selectable cap is 2, and next-coarser LOD1 errors span 1.38817749–8.37396126 px initially and 1.57268143–9.50155792 px finally. Those measured errors exceed the installed 1 px budget and 0.85 px coarsening boundary. The actual orthographic projection matrix retains vertical scale 1/150 and the raster remains 1280×720; installed distance thresholds remain [48,96] m. This establishes those two snapshots, not every intermediate tile state from zero counters. The complete non-overflowed census now records four creations/four programs/four pipelines, zero failures/pending/dropped events. For the same terrain main program `3c9e0c39d4f3153f/8b0216abd1646161`, creations fall from 37 across 27 objects to one; the separate merged-terrain program also creates one pipeline. This qualifies the recorded cache-sharing observation for this unblended walk only, not active-LOD/native/visual/performance acceptance. Exact commands, source/bundle/artifact SHA256 values, doctors, complete report/census and nine-key before/after LOD/camera snapshots are under `artifacts/prd566-functional-lod-debug/`; supervisor source/log are `/tmp/prd566-lod-functional/`. Terrain bundle SHA256 is `ae84ef706d4a72381de375cd894cd0f13b9f9d76227f221291c5d649210c91c0`; terrain-main is `232889643d9e5600af68b7ac91256424548a4fca8cf0be379159fed16f600c15`. The owned preview/runner terminated, port 5266 is free, and private display :9 has no remaining display/compositor process; unrelated jobs were untouched. The single AFTER capture remains UNJUDGED, without a comparison or formal visual verdict. Progress stays 50%; no phase box closes, no native run/push/PR comment occurred, and the historical 12-failure receipt remains preserved alongside the new ten-failure result. + +- 2026-10-10, root-authorized supplemental raster coverage: add `examples/abyss-framework/playtests/terrain-raster.playtest.json` as an exact copy of the default walk except its name and viewport height (720→300 rows; width remains 1280). The existing orthographic 300 m vertical span predicts 1 px/m instead of 2.4 px/m, so the unchanged 1 px budget and 0.15 hysteresis may permit some tiles to coarsen while retaining finer detail on others. This is a prediction requiring an actual named-adapter observation; active transitions, morph parity and all 31 runtime checks remain unverified for the companion. Every assertion, diagnostic, step and movement requirement is preserved; no camera, scene, sampler, floor, budget, cap or engine code changes. The default scenario remains byte-identical and its latest 21/31 result (ten failures, including three topology failures) stays open. Supplemental coverage does not replace AC-2 or the original phase/native requirements, and cannot establish 31 passing checks while topology fails. No acceptance/phase box is changed or ticked; progress stays 50%. + +- 2026-10-10, root-authorized single supplemental hardware attempt: reused the prior `45775a1b5207aca43af30191eebf91a95048a24b` compiled fixture/core/playtest bundles without build or install, from source HEAD `dbdfb069632a27991e491db0f320842fc589d79c`. All five recorded source hashes and 27 fixture JS hashes matched the prior receipt before and after; core/playtest compiled JS hashes were stable across the attempt. Within the 240 s supervisor, the before-scene doctor exited 2 after a total 79.74 s with `TN_PLAYTEST_BRIDGE_MISSING`; the runner stopped at this first non-game error, before starting the companion walk. No retry or after-scene doctor ran. Consequently all 31 companion outcomes, active-morph/CPU-parity counters, actual browser adapter/canvas raster, nine-tile LOD/camera snapshots, measured pixel-error scale and pipeline census are unobserved; no capture or visual verdict exists. The machine was loaded (RTX 2080 utilization 29% before/30% after, 38°C; one-minute CPU load 17.87 before/26.65 after), so no performance claim follows. The owned preview and runner are terminal, port 5266 is free, and no exact display :9 Xvfb/compositor process remains. Commands, diagnostics, source/bundle guards, resources and the unchanged before-PRD copy are under `artifacts/prd566-functional-raster/`; supervisor source/log are `/tmp/prd566-raster-functional/`. No source/scenario/default/threshold changes, rebuild, install, native run, commit or push followed. The default 21/31 result and its ten failures remain open; original AC-2/phase/native requirements are unchanged and progress remains 50%, with no new box ticked. + +- 2026-10-10, root-reviewed read-only Opus/system-log diagnosis: the sole Claude Opus 5.5 HIGH arm exited 0 in 464.20 s, with HEAD, tracked/staged diffs and 48 source/compiled-file guards unchanged; its full report is `/tmp/prd566-bridge-opus-high-4b2o9zn4/full-report.txt`. Successful and failed before-scene doctor commands are identical, and the failed doctor never loaded the 300-row scenario. `connectPlaytestBridge` converts every failed bridge wait into false and discards the original error; the doctor also omits collected page errors on this path. The evidence therefore cannot distinguish slow startup, a page error, stopped frame polling or a browser/GPU crash. Equality of the earlier runner build rests on timestamps, not a contemporaneous runner hash. The approved read-only check of 2026-10-10 23:25:25–23:26:40 UTC (16:25:25–16:26:40 PDT) completed in 1.45 s: kernel and full journal queries both exit 0 without access warnings, with no matching OOM kill, NVRM/Xid, segfault or Chrome crash record. The only relevant journal line is an earlyoom memory-status sample at 23:26:17 UTC (26,992 MiB available; 7,711 MiB swap free), with no kill reported. `coredumpctl` exits 1 with `No coredumps found.` and no permission warning. These accessible records do not establish why the bridge wait failed or exclude an unrecorded failure. Historical `DISPLAY` was not retained in the raster invocation receipt/supervisor, so its past value remains unknown. Raw query stdout/stderr, filtered lines, exact commands, before-PRD copy and 49-file source guards are under `artifacts/prd566-functional-raster/diagnostics/`; no sudo, install, model/browser retry, source fix, commit, push or GitHub mutation ran. A separately authorized diagnostic doctor capturing Playwright/page errors would be the next discriminating runtime step; it has not been run. All original AC-2/phase/native requirements and the ten default failures remain open; progress stays 50% with no boxes changed. + +- 2026-10-10, root-authorized one doctor-only diagnostic: exact prior preview/doctor argv and unchanged compiled bundles, adding only `DEBUG=pw:api,pw:browser,pw:protocol` to the doctor environment. The inherited `DISPLAY`, `WAYLAND_DISPLAY` and `TN_PLAYTEST_HOST_DISPLAY` were absent; Chromium debug launch confirms headless mode without forcing a display. Doctor exits 0 in 8.216 s (9.938 s total within the 240 s supervisor), reports `nvidia turing`, 1280×720 canvas at DPR 1, nonblank output and zero console errors. The bridge `page.waitForFunction` starts at 00:19:04.559 UTC and succeeds at 00:19:09.306 UTC: 4.747 s, so no failure interval exists in this attempt. Protocol logs contain three terrain/projection info messages, the timestamp-tracking-disabled warning and a 5,466.4 ms startup hitch log; no page exception, target crash or GPU-process exit event was observed. The browser process closes normally with exit 0. Ten one-second resource/pressure/NVIDIA samples were retained (GPU utilization 7–32%, temperature 44→45°C; one-minute CPU load 8.09→8.06); this is functional doctor evidence, not performance qualification. All 48 source/compiled hashes and HEAD/diff guards remain unchanged; owned doctor/preview process groups are gone and port 5266 is free. Raw protocol/browser output, exact commands/environment, API wait intervals, telemetry, artifact hashes, before-PRD copy and receipts are under `artifacts/prd566-functional-raster/doctor-diagnostic/`; supervisor source/log are `/tmp/prd566-doctor-diagnostic-mb1cx8xu/`. This proves only the guarded doctor passes now: the earlier bridge failure’s underlying cause remains unknown. No companion walk, model call, build, install, source fix, commit, push or GitHub mutation ran. Original AC-2/phase/native requirements and ten default failures stay open; progress remains 50%, with no box rewritten or ticked. + +- 2026-10-10, root-authorized one supplemental headed walk: reused the unchanged compiled bundles with all 48 source/compiled guards passing before and after; default scenario SHA256 remains `78f82cefe74e9cb05b11e078d745be632a663b74ed47f2eb1f00d21459d421d8`, companion SHA256 remains `c0f90e7c1c03e4d4349601b743b31d62e4c4ff8252a14c795d89f734ebd438fd`. Within the 240 s supervisor, the before-scene doctor passes and the single 180-tick companion walk exits 1 in a total 20.32 s, with 25/31 checks passing. The six failures are the unchanged topology slope/relief/Horton metrics, initial-value triviality for `terrainCpuReference.maxSeamGap` and `stitchedEdges`, and diagnostics. No assertion or threshold changed. The runner records seven console/runtime errors: morph reference parity first fails at frame 13, tile 1:0, vertex 1 (position 0.0011539459 m, normal 0.0005596084); frame 35, tile 5:0 reaches position 0.1524820328 m / normal 0.0116778331, and frame 41 reaches 0.0762414932 m / 0.0058388641. Four further console error entries report `TerrainTiles seam diagnostic bridge topology does not match current neighboring edges.` The passing aggregate max-error assertions (position 8.90096e-6, normal 6.13158e-8) do not establish parity in the presence of those larger thrown mismatches. The supervisor stops on returned error diagnostics; no after-scene doctor or retry runs. + The actual capture reports NVIDIA/Turing, WebGPU, the exact nine authorized browser arguments and 1280×300 raster on owned private Xvfb display :9 (1600×900×24 with xcompmgr). Both camera snapshots report raster 1280×300 and vertical projection scale 1/150; all 18 before/after tile observations have `errorPixels/surfaceError = 1`. Policy stays 1 px, hysteresis 0.15, strict coarsening below 0.85 px, floors [48,96] m and cap 2. Two of nine tiles are at LOD1 in each snapshot (before 1:0/1:-1; after 5:0/6:-1); remaining tiles retain LOD0, with every actual current/target level, floor and cap retained in the snapshots. Recorded transitions rise 0→4, maximum blending tiles 0→1, duration 0→3 frames and active-morph frames 0→2. CPU attribute/influence comparisons rise 12→46 frames and 181,794→585,750 vertices, but the logged parity failures prevent a successful parity claim; these counters do not prove actual GPU output/pixel/native parity. The complete, non-overflowed pipeline census contains five creations/programs/pipelines, zero failures/pending/dropped events and one creation per recorded program, including the additional active morph layout. This is a recorded census, not a visual or performance acceptance verdict. + Exact commands, all 31 outcomes, raw console/runtime errors, camera/LOD snapshots, census, resources, hashes, ownership and cleanup receipts are under `artifacts/prd566-functional-raster/companion-walk/`; supervisor source/log are `/tmp/prd566-companion-walk-vyllkopl/`. The single AFTER capture is UNJUDGED, without comparison. The loaded RTX 2080 reads 27% utilization before and at the post-terminal resource sample, with temperature 44→43°C and CPU load 7.29→7.79; no performance claim follows. Owned walk/preview process groups and recorded Xvfb/xcompmgr processes are gone; port 5266 is free. No source/scenario edit, build, install, model, native run, commit, push or GitHub mutation ran. The next blocking finding is the observed active-morph reference mismatch together with seam-diagnostic topology errors, requiring diagnosis before further proof. The original default 21/31 result with ten failures remains open, original AC-2/phase/native requirements remain unchanged, and progress stays 50% with no box ticked pending root review. + + +- 2026-10-10, root-authorized node-only asynchronous seam reproduction (production unchanged): the read-only Opus 5.5 HIGH diagnosis (`/tmp/prd566-active-morph-opus-high-z3d7fy_c/full-report.txt`, exit 0, 402.72 s) proposed that the validating CPU reference can blend shared edges while an outstanding seam job prevents canonical edge restoration, and that an older reply can then encounter changed bridge endpoints. Added a parameterized regression in the existing `packages/core/__tests__/world-terrain-jobs.spec.ts`, reusing its FakeWorker and terrain fixture. Both arms compare `validate:false` and `validate:true` instances following (0,0)→(12,0), all position/normal vertices including skirts, shared-edge vertex 1 and interior vertex 4×17+5. The inline control passes; holding seam replies through two transition frames reproduces a red full-vertex parity assertion at each frame and a red valid-asynchronous-reconciliation assertion when replies are released. The focused final run exits 1: one passing inline case, one failing held-worker case, five failed assertions, 11 unrelated cases excluded (8.91 s; raw public mesh/debug observations and rejection in `artifacts/prd566-functional-raster/node-repro/focused-final.log`). A prior focused run reproduces the same discrepancies and rejection; no third behavior run or production fix was attempted. + With held replies and weights 1/3 then 2/3, shared-edge position errors are 0.0096298456 m and 0.0192598104 m, while the sampled interior error remains zero. Across every compared vertex, position maxima are 0.0096302032 m and 0.0192604065 m, and normal maxima are 0.0041927275 and 0.0083854949. GPU edge morph deltas are [0,0] and the GPU base/morphed edge stays −1.9648307562 m; the validating CPU edge moves to −1.9552009106 m then −1.9455709457 m while both workers retain seam jobs. Releasing those replies produces `TerrainTiles seam diagnostic bridge topology does not match current neighboring edges.` The next completed-transition frame agrees. This supports the proposed engine/reference edge reconciliation and asynchronous reply timing defect in this controlled fixture; it does not identify every missing state of the original browser failures or prove actual GPU pixels. The test retains normative full-vertex 0.001 position/normal limits and asserts that asynchronous reconciliation produces no rejection; no edge exclusion, epsilon increase or exception waiver was introduced. + Focused test TypeScript check passes; focused Biome passes with one cognitive-complexity warning (41), and whitespace diff check passes. The original/default and supplemental scenarios remain unchanged. The exact test and local PRD delta are preserved in `artifacts/prd566-functional-raster/node-repro/actual.patch`, with typecheck/Biome logs alongside. The next bounded fix belongs in the engine seam/transition reconciliation lifecycle, coordinating reference edge restoration and reply validity with transition progress; no game probe workaround is supported. Original AC-2, phase/native requirements, default ten failures and the supplemental six failures remain open. Progress stays 50%; no checkbox was changed, and no browser, build, install, commit, push or GitHub mutation ran. + + +- 2026-10-10, root-authorized bounded engine reconciliation fix: `TerrainTiles` now restores validating CPU shared-edge positions and normals through the existing canonical-edge routine even while a seam reply is pending. Asynchronous seam requests capture the existing ring epoch and geometry/topology signature; a superseded reply is not applied or recorded as settled, and the existing seam lifecycle requests reconciliation of the current ring. A matching reply still runs the existing topology diagnostics, and missing bridge results now throw rather than silently completing a partial pass. Inline results do not need the asynchronous signature guard. This belongs in the engine because both the public validating reference and worker seam lifecycle are owned by `TerrainTiles`; no game/probe workaround, terrain job protocol, sampler, LOD policy, default, cap or floor changed. + The accepted node red reproduction is green with full-vertex 0.001 position/normal assertions intact: held-worker shared-edge and sampled interior errors are zero throughout all three transition frames; all-vertex position maxima are 1.90735e-6 m, 2.02656e-6 m and zero, and normal maxima are 3.22615e-8, 2.34554e-8 and zero. Replies captured before frame two and before the completed transition each leave a fresh seam request outstanding; the matching completed-transition reply settles with no rejection. Added unchanged-current-topology malformed-index and missing-bridge-count cases explicitly retain loud rejection. No validation exception is waived for valid data. An initial nearest run caught synchronous non-finite signatures recursing through invalidation; restricting stale-signature checks to asynchronous results and retaining non-finite diagnostic failure fixes that regression. Existing corruption assertions are unchanged. + Final nearest jobs/tiles tests pass 90/90 in 15.31 s (`artifacts/prd566-functional-raster/engine-reconciliation/nearest-final.log`); the targeted reproduction/malformed/current-corruption slice passes 7/7 (`focused-corruption.log`). Core and focused test TypeScript checks pass; focused Biome passes with nine complexity warnings, and whitespace diff check passes. Actual source/test/PRD patches and before copies are under `artifacts/prd566-functional-raster/engine-reconciliation/`. These are node attribute/influence and asynchronous lifecycle results, not actual GPU output, browser, native, visual or performance proof. No build, install, browser, model, commit, push or GitHub mutation ran. Both scenario files remain byte-identical to their previously recorded hashes. Original AC-2/phase/native requirements, default ten failures and supplemental six failures remain open pending root review and fresh runtime evidence; progress remains 50% with no checkbox changed. + + +- 2026-10-10, root-authorized fresh-build bounded runtime proof after the engine reconciliation fix: before building, preserved the complete pre-fix core/playtest/example dist bytes in `artifacts/prd566-functional-raster/engine-runtime/pre-fix-compiled-baseline/` (159 files, approximately 19.2 MB, all 43 prior compiled JS hashes verified against the retained prior receipt; the prior five source hashes complete the recorded 48-file provenance). This preserves a runnable BEFORE fixture for a future identical-pose visual arm; it is not a visual verdict. Built core and the abyss-framework fixture once in the same checkout using their existing build commands; both exit 0. Playtest compiled hashes are unchanged. Fresh terrain bundle SHA256 is `5194b62404f3ab3a90a049b6aa22f3bb578d18eea541814a2b636c4665655a0a`; terrain-main is `55800d49c3ca6dc2efa7639683f3b4fc4e55f919422539f66b96c8635fc93781`. HEAD, tracked/staged diffs and guarded source files stay unchanged through build/runtime; fresh compiled files also pass the post-run hash guard. Source/build provenance and exact commands are in `engine-runtime/build-provenance.json`, `commands.json`, build logs and `receipt.json`. + Within the original 240 s budget, the measured build/runtime window finishes in 117.01 s. Scene doctors before and after both exit 0. Each unchanged scenario runs exactly once with all 31 assertions and 180 ticks: the default 1280×720 exits 1 at 21/31 with the same ten known failures; supplemental 1280×300 exits 1 at 26/31. The supplemental failures are the three unchanged landform topology metrics (power-spectrum slope 2.1119693834, median 64 m relief 0.4807647097, Horton–Strahler order 3) and initial-value triviality for CPU-reference maxSeamGap and stitchedEdges. The wrapper initially misclassified the default's known game assertion diagnostics as console errors and stopped its preview; reading the raw console established zero errors and only the existing game assertion diagnostic codes, so the already-authorized companion proceeded once under the same absolute deadline, without repeating default, builds or the before doctor. Both raw receipts and the correction are retained. Neither scenario logs a runtime/console exception; the previously observed morph-reference and seam-topology exceptions are absent in this attempt. + Both captures identify WebGPU NVIDIA/Turing (RTX 2080, driver 615.71.09), the exact nine authorized browser arguments and private owned Xvfb display :8. The existing camera diagnostics record actual raster 1280×720 / 1280×300, vertical projection 1/150 and every observed pixel-error/surface-error ratio at respectively 2.4 / 1 px per metre. Default stays LOD0 on all nine observed tiles. Supplemental stays at two LOD1 tiles per nine-tile snapshot, policy 1 px, hysteresis 0.15, strict coarsening below 0.85 px, floors [48,96] and cap 2 unchanged. Supplemental transitions reach 4, maximum blending tiles 1, duration 3 frames and active-morph comparison frames 5; CPU attribute/influence comparisons advance 12→44 frames and 181,794→602,067 vertices, with measured maximum position error 1.2397766e-5 m and normal error 6.1315814e-8. This is successful bounded attribute/influence comparison with active transitions and no logged parity exception, not proof of GPU output/pixels/native parity. Default active-morph counters stay zero. Complete non-overflowed pipeline censuses contain respectively 4 and 5 creations/programs/pipelines, zero failures/pending/dropped events. All snapshots, outcomes and raw console logs are retained under `engine-runtime/walk-default*` and `walk-companion*`; each single AFTER capture is UNJUDGED. Supplemental merged-draw diagnostics report `terrainTiles.tiles=10` while `residentTiles=9` in both GPU and CPU-reference instances after the walk; this count discrepancy is recorded without inferring its cause or actual duplicate rendering. + Recorded owned preview/walk process groups and Xvfb/compositor processes are gone; port 5266 is free. Resource samples show GPU utilization 28→37%, temperature 39→40°C and one-minute CPU load 4.70→3.13: functional evidence only, without performance qualification. Original/default and supplemental scenario bytes remain unchanged. No source fix during proof, install, model, native run, commit, push or GitHub mutation ran. Original AC-2/phase/native requirements, all ten default failures, the supplemental five assertion failures and independent visual qualification remain open; progress stays 50% with no checkbox changed pending root review. + + +- 2026-10-10, bounded count/membership diagnosis stopped at its limit: the single read-only Claude Opus 5.5 HIGH invocation timed out with exit 124 after 600.27 s including termination/guards; no provider final report or completed causal result was returned. HEAD, clean status, tracked/staged diffs and all 48 guarded source/compiled hashes remained unchanged, and the owned process group is gone. No retry, fallback, reproduction or source change ran. Raw output and terminal receipt remain at `/tmp/prd566-merge-count-opus-high-1id5darc/`; the 10-versus-9 count mechanism and any duplicated rendering remain unproven. Progress remains 50% with all existing checkbox states unchanged. + +- 2026-10-10, bounded test-only merge-membership reproduction stopped on fixture setup: the single focused node invocation exits 1 in 3.38 s; both control and held arms fail at `Expected an initial captured merge.` before any worker reply, LOD change, mesh payload observation or normative membership assertion. No initial merge request was captured within the fixture’s four follow calls. This is an unrelated setup failure, not a reproduction of stale membership or evidence for duplicated rendering; the count hypothesis remains unverified. Per the stop rule, no fixture edit, retry or production fix followed. The existing test-only candidate and exact output are retained in `/tmp/prd566-merge-repro-w90kfpbm/actual.patch` and `focused.log` for root review of the missing initial dispatch. Production, compiled fixtures and both scenarios remain unchanged. Progress remains 50%; all existing checkbox states and the accepted timeout decision are preserved. No browser, GPU run, build, install, model, commit, push or GitHub mutation ran. + +- 2026-10-10, root-authorized corrected node-only eviction reproduction: replaced the unsuccessful mixed-LOD setup with the existing uniform fixture and immediate initial merge capture; fixture-only `residentTileBudget: 9` forces a public-follow move to replace its complete 3×3 ring. The single focused invocation exits 1 in 3.52 s: control passes, held reply fails the two normative assertions preventing obsolete geometry resurrection and orphan settled membership. Both arms capture four parts / 4,884 vertices with initial resident keys 2:2 through 4:4, then follow to a disjoint ring 10:2 through 12:4. Every initial resident is absent before the held reply is released; both current rings have nine visible individual tiles and zero blending tiles. No arbitrary follow loops, delays, worker draining or hardcoded fine-LOD/point assumption remain. + Control releases the valid captured merge before eviction: its old block is removed, the captured position payload is absent, and settled census is nine tiles / nine draws / zero blocks / zero block bytes. Held releases the identical valid payload after eviction: a block named `tn-terrain-block:0:0,0` with byte-identical captured positions reappears, settled census becomes 13 tiles / ten draws / one block / 227,808 bytes against nine residents, and all nine current individual tiles remain visible. This demonstrates obsolete captured geometry and four orphan counted members in this deterministic engine fixture; it does not establish the cause of the original browser 10-versus-9 observation or actual GPU pixels. The minimal supported production change is to validate the captured merge plan against current residency, membership, LOD and transition eligibility before applying it, then reconcile superseded work through the existing dirty-block lifecycle. No production change is included. + Raw output, invocation receipt, before copies, current source guards and actual test/PRD patch are under `/tmp/prd566-merge-eviction-repro-oac5vfqw/`. The earlier failed-attempt and timeout decisions remain preserved. Production and all 48 guarded source/compiled hashes remain unchanged; both scenario hashes remain unchanged. Progress stays 50% with every existing checkbox unchanged. No additional test run, browser, GPU run, build, install, model, commit, push or GitHub mutation ran. + +- 2026-10-10, root-authorized bounded engine merge-reply fix: `TerrainTiles.#rebuildBlock` captures local geometry identity, input attribute/index identities and versions, and origin coordinates for each dispatched part. `mergeJob` at `world-tiles.ts:721–740` consumes positions, normals and indices only; UV/color buffers are not merge inputs. Before any asynchronous reply allocates block geometry or changes accounting/membership, it must still match the current eligible resident member set, part geometry/input snapshots, and retained dirty marker. Eligibility reuses `#blockMembers` (same LOD and no transition). Superseded work frees its in-flight marker, leaves the block on the existing dirty queue and is not applied or counted as rebuilt. The existing one-block-per-follow admission budget, inline application and current-result failure handling remain unchanged. This is an engine lifecycle fix, with no global epoch, vertex checksum, public API, game/probe workaround or new abstraction. + The accepted held-eviction reproduction is green: control and held both retain nine current residents/visible individual tiles, zero blending and no resurrected captured position payload; held census stays nine tiles / nine draws / zero blocks / zero applied rebuilds immediately after the stale reply. The existing worker scheduler then applies valid replacement work for the new ring: both arms settle to nine counted tiles / four draws / three blocks / 455,616 block bytes; held records three valid rebuilds, control four including its earlier valid old-ring merge. Added position-attribute replacement, normal-version and index-version cases all fail before the fix by applying a superseded block, then pass with fresh work eventually settling. The combined focused slice changes from one pass/four failures to five passes; final focused invocation exits 0 in 2.52 s. + Nearest terrain jobs and tiles suites pass 95/95 in 16.86 s, preserving existing malformed/current-corruption and dead-worker cases. Core and focused-test TypeScript checks pass; focused Biome passes with nine complexity warnings and whitespace diff check passes. The temporary test TypeScript config initially missed its external node type-root resolution, then exposed a missing type-only `BufferAttribute` import; both were corrected, with initial diagnostics retained. The final import is type-only. Actual patch, source before copies, red/green/nearest outputs and TypeScript/Biome logs are under `/tmp/prd566-merge-fix-h7____bk/`. Only the owned production source, existing test and this PRD change; the other 47 guarded source/compiled files and both scenario hashes remain unchanged. This proves node-level stale merge invalidation and eventual valid replacement, not the cause of the original browser 10-versus-9 observation, GPU pixels, visual acceptance, performance or native behavior. Direct external array edits without `needsUpdate` remain outside version detection; existing validating diagnostics are unchanged. No browser/GPU/native run, build, install, external model, commit, push or GitHub mutation ran. Progress remains 50% with all existing checkbox states unchanged. + +- 2026-10-10, root-authorized single bounded runtime verification of the merge-reply fix on the existing PR-485 lane: HEAD `e4ee3fb0f9f30bc41b70feb23f1bfc14266f4711`, exactly the three reviewed owned dirty files and the reviewed patch/source guards matched before starting. Preserved the complete AFTER-seam/BEFORE-merge core/playtest/fixture dist bytes in the distinct `artifacts/prd566-functional-raster/merge-runtime/pre-fix-compiled-baseline/` (159 files, 19,161,975 bytes, all copied bytes verified and 43 compiled JS guards matched). The prior BEFORE-seam baseline is untouched. Fresh same-checkout core and fixture builds each exit 0; playtest is reused unchanged. The 240 s hard-deadline wrapper (tool session 32883; deadline 2026-10-11T01:51:16Z at recorded one-second precision) terminates normally in 62.90 s at 01:48:19Z. Before/between/after scene doctors all exit 0. Default 720-row and companion 300-row scenarios each run once with all 31 assertions and unchanged bytes/thresholds: exits 1 at 21/31 and 26/31 respectively, with no retries. The same ten default failures remain; the companion’s five failures remain slope 2.1119693834, relief 0.4807647097, Horton–Strahler order 3, and initial-value triviality for CPU-reference maxSeamGap/stitchedEdges. Raw game assertion diagnostics are retained without misclassifying them as console errors. Both walks have zero console errors and no recent runtime errors. + Actual captures identify NVIDIA/Turing (RTX 2080, driver 615.71.09), private owned Xvfb display :9 and the exact nine browser arguments. Camera diagnostics record 1280×720 and 1280×300 rasters, vertical projection 1/150, and measured SSE/surface-error ratios 2.4 and 1 px/m; budget 1 px, hysteresis 0.15, coarsening below 0.85 px, floors [48,96] and cap 2 are unchanged. The companion retains two LOD1 tiles per nine-tile endpoint snapshot. Both GPU and CPU-reference instances now report nine counted tiles against nine residents at both endpoints. Default endpoint census is two blocks/two draws/1,908,792 block bytes; companion endpoint is one block/eight draws/424,176 block bytes, with zero blending at observation. The earlier 10-versus-9 endpoint discrepancy is not observed in this attempt; this does not trace the original browser reply race or establish pixel coverage. Companion records four transitions, maximum blending one, three transition frames and eight active-morph comparison frames; CPU attribute/influence comparisons advance 12→44 frames and 181,794→720,720 vertices, with maximum position error 1.2397766e-5 m and normal error 6.1315814e-8. Default active-morph frames remain zero. These are functional attribute/influence observations, not actual GPU output parity. Complete non-overflowed pipeline censuses contain four/default and five/companion creations/programs/pipelines, with zero failures, pending or dropped events. + Exact commands, build/source/bundle provenance, all 31 outcomes per walk, census, before/after LOD/camera snapshots, raw console logs and terminal receipts are under `artifacts/prd566-functional-raster/merge-runtime/`; wrapper source/log are `/tmp/prd566-merge-runtime-yaf4afl7/`. Fresh terrain bundle SHA256 is `aca9d77ec91f5abc4b675bc9ef27bb19924ef9858927cb4f6f165df5fe23ef12`; terrain-main is `744ab4b6c3424a404897fc8e48b17e7e197a0f0de7ef1f9f9e16224cbca179c9`. Source, tracked/staged diff and fresh compiled guards pass across build/runtime. Cleanup verification finds no remaining process in any recorded owned group, no owned display process and no port-5266 listener; the preview group briefly existed during initial cleanup and was terminated before this final verification. Loaded-host samples show GPU utilization 32→38%, temperature 43→45°C and one-minute CPU load 4.05→3.99: FUNCTIONAL ONLY, without a performance claim. Each single AFTER capture is UNJUDGED. No production/test/scenario edit during verification, install, external model, commit, push or GitHub mutation ran. Only this dated PRD decision is added after terminal proof; progress remains 50% with every existing checkbox unchanged, and original AC-2/phase/native/independent visual requirements remain open. + +- 2026-10-10, bounded diagnostic-only preparation: the existing TerrainProbe before-render hook now retains up to 1,024 raw `TerrainTiles.debug()` eligibility snapshots, with explicit overflow/missing-top-level-field reporting, render sample ordinal, scene-local update ordinal, parity frame, player position, resolved policy, resident keys, pending construction, ring epoch, transitions/blending and the engine-provided per-tile candidate projections/targets. This does not alter selection, camera, worker/process, scenario or rendering appearance, and it does not cover every selector invocation. The existing scenario-contract test passes (1 selected, 25 skipped); Biome passes with one existing parity-comparator complexity warning. The ONE authorized runtime wrapper terminated before any build, doctor or browser command in 0.83 s: its retained historical 48-path guard references missing hashed fixture asset `examples/abyss-framework/dist/assets/GLTFLoader-AojqAran.js`. Current compiled JS guards matched (43), and the distinct pre-diagnostic compiled baseline was copied and verified (159 files, 19,164,336 bytes); 8 current source guards and tracked/staged diffs remained unchanged. No retry occurred, no eligibility samples or captures were produced, and no eligibility/impossibility result is claimed. Actual receipt: `artifacts/prd566-functional-raster/eligibility-runtime/receipt.json`; concise blocker/receipt: `/tmp/prd566-eligibility-runtime-ita5sbqc/runtime-excerpt.json`. AC-1 remains open for the missing GPU-rendered 1 mm observation; phase progress remains 50% with no further checkbox changes. + +- 2026-10-10, root-authorized corrected ONE diagnostic attempt: refreshed the stale historical 48-path inventory from the latest accepted merge-runtime receipt plus all 159 verified current compiled-baseline files and eight current source guards (167 current paths total); the old/current inventories, hashed-entry correspondence and a filename-resolution addendum are retained in `artifacts/prd566-functional-raster/eligibility-runtime-corrected/guard-inventory-provenance.json` and `guard-inventory-resolution.json`. No old files were fabricated and no applicable current guard was discarded. The original pre-diagnostic compiled baseline remains byte-identical and was not overwritten. Added only `pendingConstruction` to the probe trace's missing-field list. Fresh fixture build and before/after doctors exit 0; the unchanged 1280×720 default walk runs once, exits 1 at 21/31 with the same ten assertion failures, zero console errors and no recent runtime errors. The existing private headed NVIDIA/Turing adapter and exact nine browser arguments are confirmed. The wrapper terminates normally in 54.60 s (tool session 54697, supervisor PID 3966223, walk PID 3971043; 2026-10-11T02:21:27Z–02:22:22Z, hard deadline 02:25:27Z), with no retry. + + The captured before-render trace contains 173 samples (12 already present at the assertion baseline), render sample ordinals 0–172, scene-local update ordinals 60–240 and nine resident decisions per sample, with zero overflow and no missing per-tile candidate fields. Across 1,308 distance-and-cap-eligible candidate observations, next-coarser projected errors range from 1.3881774902 to 10.2696189880 px, all above the unchanged strict 0.85 px coarsening threshold; no raw target coarsening or strict candidate-SSE positive witness was observed. `pendingConstruction` is absent in all 173 engine debug snapshots and is explicitly reported missing, rather than interpreted as a known null state. These results describe only the sampled instants, not all selector invocations or an impossibility proof. Transitions and active morph frames remain zero. The endpoint census is 9 counted tiles/9 residents, 2 blocks/2 draws and zero blending. The default/companion scenario hashes, eight source guards, tracked/staged diffs during execution and 135 post-build compiled/bundle guards are unchanged; 24 additional non-JS core/playtest files also match the preserved baseline. Final owned process groups/display are gone and the port is free; the preview group's initial post-TERM observation was transient and its later absence is recorded explicitly. Runtime receipt/all 31 outcomes and raw trace are under `artifacts/prd566-functional-raster/eligibility-runtime-corrected/`; concise receipt is `receipt-excerpt.json` and sampled eligibility analysis is `eligibility-analysis.json`. Loaded-host hardware evidence is functional only; the capture remains UNJUDGED. AC-1 and other open acceptance remain open, with no further checkbox changes and phase progress 50%. + +- 2026-10-12 UTC, finish-lane proof attempt at `d91fbe1113874af6698a211ae266152d2d065e89`: warmed server command `pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5183 --strictPort` (log `/tmp/prd566-finish-server.log`). Exact browser proof command: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/terrain.playtest.json --url "http://127.0.0.1:5183/?terrain" --headed --live-clock --artifacts /tmp/prd566-finish-browser-artifacts --browser-arg --ozone-platform=x11 --browser-arg --enable-unsafe-webgpu --browser-arg --disable-gpu-sandbox --browser-arg --ignore-gpu-blocklist --browser-arg --enable-features=Vulkan --browser-arg --use-angle=vulkan --browser-arg --use-vulkan --browser-arg --disable-vulkan-surface --browser-arg --no-sandbox`. The same nine flags recorded in commit `370806442` now return SwiftShader; auxiliary `nvidia-smi` reports `Failed to initialize NVML: Driver/library version mismatch` (userspace library 615.78). This identifies the current environment symptom, not a proven cause of every console error. The scene doctor exits 0 but explicitly warns of Google SwiftShader, so its nominal pass is not hardware qualification. The actual terrain runner exits 1 on software-adapter and blank-capture gates. No GPU repair, native-entry change, unit/cost rerun, checkbox tick, archive, push, merge or retarget occurred. The owned server was stopped; browser capture cleanup completed. `pnpm check:docs` exits 0 (3,025 links across 1,347 Markdown files; `/tmp/prd566-finish-docs.log`); `git diff --check` passes. Progress remains 4/6 phase boxes, 3/3 acceptance and `prd:50%`. + + +## CI repair — 2026-10-12 + +- Run `38280931230` at `911bdceeb`: `test-native` rejected the fetch shim hash; budgets rejected stale native coverage (digest `800273b1a2763a4f7cd2854713ea98d9cbd900b5d838671f5a395493ce83cc51`), rather than a numeric overrun. Recomputed only the fetch hash; its optional-chain method selection remains unchanged and all 15 fetch-shim tests pass. `native:coverage` exits 0 with the existing process-local NVIDIA library workaround; runtime coverage is 78.28%, and its floor is tightened from 73.26% to 73.28% as required by the five-point margin. Coverage gate specs pass 7/7. The first coverage attempt failed GPU targets before applying that workaround. +- Engine repairs: `WorldCells` constructs a terrain render view only with terrain, camera and an observed canvas height; missing canvases retain the no-view path and malformed supplied view values still reject. `TerrainTiles.rebuilds` counts completed merge work, including stale results, while stale geometry remains rejected. Existing failing specs and the new no-terrain viewport regression reproduce these faults before repair. Native preflight/tone fixtures now advertise `runtime.network` and tone fixtures provide its actual empty observation; no capability check is bypassed. +- Game repairs: native smoke ships the empty `assets.manifest.json` at its native working-directory root, eliminating the observed failed manifest request. The native loading proof now exits 0 (222,720 startup loading pixels, zero settled loading pixels). Terrain seam/stitched-edge assertions retain every bound and require change instead of waiving triviality; the captured values change from 0.02358245849609375 to 0.1454315185546875 and 132 to 5,829. The final fixed-tick browser terrain scenario exits 0, 33/33 assertions on NVIDIA/Turing; the earlier live-clock diagnostic ran beyond the sampler region and is not proof. +- Stopped after two migration-generation attempts: `three-patch-upgrade.spec.ts` retains six failing exact historical-blob assertions. The candidate Three patch fails strict application to stock Three 0.185.1 in its WebGPUBackend hunks; no historical hash expectation, patch, or assertion was changed to hide it. +- Runtime CI remains unverified: the 11 template lanes, golden platformer, browser/playtest and several integration jobs report `TN_PLAYTEST_CONSOLE_ERROR`, `TN_PLAYTEST_RUNTIME_DIAGNOSTIC`, `TN_PLAYTEST_SOFTWARE_DEVICE_LOST` or `TN_PLAYTEST_RUNNER_FAILED`; browser HMR also times out. This host has a 615.71.09 kernel / 615.78 userspace NVIDIA mismatch. Local matching libraries restore hardware access; that does not establish the cause of every CI failure or repair the runner fleet. Artifact-upload errors are downstream of each job's failed qualification step: native fluid preflight lacks `runtime.network`, exposure runs the failing tone fixtures, and native desktop/UI proofs capture the failed manifest request. Integration jobs depend on their exact-candidate `paths` inventory; native decal/animation/fog children depend on their corresponding qualification jobs, and receipt joins depend on the selected jobs. No workflow or upload policy was changed. +- Final local verification: `pnpm lint` exit 0; `pnpm typecheck` exit 0, zero `gpuClass` errors; 18 focused specs pass 409/409, fetch shim 15/15, byte-stability contract 1/1; core build/publint and `pnpm ci:fast` exit 0. Six historical migration tests remain stopped as noted above. No full CI green, macOS/Windows rerun, mobile proof, or merge is claimed. + +## Conflict review — 2026-10-12 + +Merged `origin/develop` (`7fa453f2b`) without rebase. The only conflicted file was `packages/create-threenative/__tests__/scaffold.spec.ts`: retained develop’s template updates and PRD-566’s shared morph pipeline patch. The generated-tree assertion stays exact; measured all thirteen merged no-install trees. Red: 1 failed/3 passed; green: 1 passed/65 filtered, exit 0. No lockfile conflict, so no install was run. Hash changes (old develop expectation → measured merged tree), caused solely by the shared Three patch: + +| Template | Old SHA-256 | New SHA-256 | +| --- | --- | --- | +| action-rpg | `254697940e5805434f380528ea8b69e19613a1548f64497ee23989677b59763c` | `8984c4f355d35eb5c1d0958746172f457c9c2ecad615f3bfcd38642533ff8c37` | +| minimal | `0202ac5dd7ac436e474f55dd9ec2f5f43da3d67c219b2351405e960ca8f1a71e` | `8d834289ea3940529d925f53a4946cc718771dc4fb8577c2199ba6a29faf51d4` | +| platformer | `edf75a6bfbb3b0ab7513b7e8fc3a8441b9aa41ff5f544ab77c201f1fcd8d8faf` | `f5d4a0362bd3a6f7bb3f8530178e22d5cbe0bd8625d0c83121b53f8f5aa3a2ec` | +| puzzle | `c5739a462298a6b1a0c2ba27ebca8c276fa89ccd6464d8b49f43b2da0e9e9f72` | `cdacbec8e3543a394263bd73c42d7482b61c433df6cb60ce42bc8306f91f71e9` | +| racing | `deff5eb215f9d3be8007f2f0bc4530618035db36418c05aee292791a07ca76a9` | `cec5c42b232f5f858fdefae9275b324378c2914d5113185e3ec3a748162caf23` | +| rain | `643494c2812208b8db1c24e38c06053d7fe349bb25692802c8acf3511e08dd43` | `05027f3370b26deb260e158240a09904ea54e593a8184767eead660810bf7551` | +| rts | `8dfd9b5cb3d441463389b842ff2b3cde015d81ed4d86dad089f8cd2e2916f15f` | `6f331bb87e10340cd33263edbff4f7ce1fc40d24b601ea786a06ea41e1f065a5` | +| runner | `1844ee1b87ef97d51aad6d157715bdc74df39b39a08d822403f92b2d9e4a3302` | `4f7a6b3ace688771a7c96e67062a8b696d0b50c09376183f2f02eead97361c12` | +| sailing | `a524164861fad5282f55b69a9515529604afb8f73ad25e4bcd51c7b93ce17a82` | `9c9edb14b880fc446e9b921b60dd99053f45032f053c4fcb5cc820a323c43904` | +| shooter | `816c172e0c9d86fa1be9a866a09b3b377a336f0038e5ed3600d7f153cc753301` | `699758de47b7642a38b998dad0437b17c6a68df046d8807ddbaf35a61d9f1423` | +| snow | `e4be666568eeb003b274486d791a183867c5b0e176f2888650305475c4dc820a` | `837eee8058f3bf043eabe5161207e62fbc5accd83cd1353902b79bb55f2dd1cf` | +| starter | `cb2221f6a07dd981fb19be00477e3304fe8add983eb17e0c7066d68164489b30` | `21b5ccb273b27ff8b711c7b2c5f42345adb7ab941fa19e605b64b01626f5aa68` | +| tower-defense | `54184c2d75fe452ada59a5a3ce00e326e485b9288153b5617320fbcaa88b47fb` | `95e514738bd04ba2a561f3500d877af1d0c540a9e40fb00829770455006fd14c` | + + +Review findings and repair results, ranked: + +1. **P1 — stale historical Three migration targets.** The morph cache patch changes candidate bundle/backend bytes but the upgrade patch still targeted develop’s prior blobs. Reconstructed and hash-verified all 31 historical migration inputs from clean stock Three 0.185.1 plus develop’s patch; regenerated only the ten affected blocks against the clean candidate. Existing historical input hashes and all refusal/idempotency/CRLF assertions are unchanged. Red: 18 failed/4 passed; green: 22/22, exit 0. Clean-consumer packaging: 8/8, exit 0. This supersedes the earlier stopped migration attempts. +2. **P2 — stale counter expectation.** Three held-merge attribute cases expected `terrainTiles.rebuilds = 0`, conflicting with the counter’s completed-work semantics and existing in-flight merge tests. Red: 3 failures, observed 1. New exact expectation: 1 completed stale rebuild; the separate `blocks = 0` assertion still prohibits applying stale geometry. Nearest terrain/pipeline slice: 7 files, 154 passed/4 opt-in skipped, exit 0. +3. **P2 — misleading benchmark text.** The GPU timing case still printed the old CPU reference’s 4,225 normal / 25,350 height reads. It now prints zero GPU blend reads, matching the adjacent operation-count assertions. `TN_BENCH=1` focused blend cases: 3/3, exit 0; loaded-host timing is not a performance qualification. + +CI investigation: develop run `38211146916` completed successfully, including the requested runtime lanes. Run `38287377757` is still executing other jobs; inspected its completed supply-chain and runner-template job logs directly, and used predecessor run `38280931230` for the unresolved lanes. + +- **supply-chain: stopped without waiver.** Full-history Gitleaks scanned an unrelated ref: commit `0c2f9e4b57382d980ef98424f6ac03b86e1d785b`, PRD-595 line 63, `generic-api-key`, prose about prewarm queue/key state and caster counters. That commit is absent from this branch and develop. The scanner defaults to all refs; it is unchanged by PRD-566. No ignore, scan narrowing, historical rewrite or other-worktree edit was made. Its scope/prose false positive remains for the owning lane. +- **unit 3/4: historical migration fixed** by the first finding above; current focused upgrade suite passes 22/22. +- **template-nonvisual: stopped at runner GPU failures.** First errors by kit: racing buffer 24,816 bytes; action-rpg 288; shooter 48; runner 48; sailing 262,144; tower-defense 48; rain 72; rts 288; platformer 288; starter 360; snow 2,856. All are WebGPU `createBuffer`/`mappedAtCreation` RangeErrors; sailing/starter wrap them as `TN_PLAYTEST_RUNNER_FAILED`. Latest runner shard repeats the 48-byte error. No predicate or fixture was weakened. +- **golden platformer and test-playtest: stopped at runner GPU failures.** First buffer errors are 288 and 720 bytes respectively, with `TN_PLAYTEST_SOFTWARE_DEVICE_LOST`; **test-browser** first fails on timestamp-buffer `mapAsync` AbortError (“A valid external Instance reference no longer exists”), then HMR times out. Green develop establishes the prior result, not whether a later driver/environment change or this branch causes these failures. No local GPU/driver repair or successful runtime rerun is claimed; the new CI run remains authoritative. + +Local verification: `pnpm lint` exit 0. Initial `pnpm typecheck` exit 1: ten merged templates could not see `IRendererLike.gpuClass` in stale built core declarations. `pnpm --filter @threenative/core build` including publint exit 0; rerun `pnpm typecheck` exit 0. Additional integration slice: 7 files, 169/169, exit 0. Native fetch shim: 15/15 with the package’s Vitest runner, exit 0 (the initial Node runner/config-root invocations were incorrect and are not passing checks). Scaffold merged-byte contract: 1/1; `pnpm ci:fast` exit 0, including lint, docs, mirrors and all selected drift specs. No full product CI, fresh hardware/native shader census, mobile proof or mergeability claim. Worktree retained at `.worktrees/prd566` (8.1 GB) for the unmerged PR and pending CI. + +## CI triage 2 — 2026-10-12 + +Scope: classify run `38290496496` at `09f3e245f` against develop run `38211146916`; repair only reproduced branch defects, preserve all assertions, then push and dispatch qualification. Integration: unchanged. Develop's jobs API reports success for every corresponding failed job, including the joins. The downloaded failed log is `/tmp/prd566-ci-run3.log`; retained diagnostic artifacts are under `/tmp/prd566-triage2-artifacts`. No product code or expectations changed. + +| Failure group | First real error and layer/file | Disposition | +| --- | --- | --- | +| Eleven template-nonvisual jobs, golden platformer, test-playtest | WebGPU `createBuffer` RangeError: allocation is "too large ... when mappedAtCreation == true". First sizes: platformer/rts/action-rpg/golden 288 bytes; racing 24,816; rain 72; starter 360; tower-defense/shooter 48; snow 2,856; runner 3,520; playtest 720. Sailing first reports `TN_SAILING_HEIGHT_READBACK_FAILED No finite ocean height sample arrived within the startup budget`; its diagnostic reports SwiftShader device loss (435 console events). Runner WebGPU/Three allocation; template and example fixtures are unchanged. | Runner-only under the requested classification rule: GPU/device failure with the same develop jobs passing. Recommend rerun; no code patch. Rain's missing controls, tower shots and sailing readback assertions also fail amid device loss; their behavior remains unqualified. | +| test-browser | Timestamp `mapAsync` AbortError: "A valid external Instance reference no longer exists"; HMR subsequently times out. Runner GPU/timestamp path, browser specs. | Runner-only GPU evidence; recommend rerun without changing browser assertions. | +| Integration temporal, decals, animation, auto-exposure, cold boot, capture, fog | Temporal logs the same `mapAsync` AbortError. The other saved console artifacts contain that error on SwiftShader before their missing observation, blank capture or qualification errors. Cold boot first loses its GPU observation (`state.sampleFrames` stays 0), then `after.png` is absent. Runner WebGPU/readback; existing integration fixtures. | Runner-only under the requested rule; recommend rerun. Do not patch downstream screenshot/observation assertions. | +| Integration exposure | `TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING`: adapter request resolved null. Runner adapter acquisition, `scripts/verify-tone-exposure.ts`. | Runner-only adapter failure with develop passing; recommend rerun. | +| Integration velocity | `TN_PLAYTEST_BRIDGE_MISSING`, zero frames; bridge is installed after the fixture's initial GPU render/readback (`examples/abyss-framework/velocity-main.ts`). | CI cause stopped/unproven: uploaded report retains no startup console evidence. Same candidate passes tracked 13/13 on NVIDIA/Turing; without-history control fails exactly its three intended assertions, with no console/network errors. This rules out an always-missing bridge, not every runner/branch interaction. | +| Integration fluid collision | `Actual GPU validation must complete before sampling`: `completedScopes` is undefined. `scripts/verify-fluid-collision.ts`; upload retains only a minimal failure record, no report/console. | CI cause stopped/unproven. Same candidate's existing scenario passes 11/11 on NVIDIA/Turing; validation scopes are 1 before/2 after, errors 0. No evidence supports changing the solver or assertion. | +| test-unit (3/4) | `world-gpu-scene.spec.ts:1156`: "Test timed out in 60000ms", no failed value assertion. Engine unit fixture; PRD-566's terrain path was inspected. | Stopped without a reproduced branch defect: unchanged candidate passes all 97 specs, exit 0; the named test takes 43,822 ms on a loaded desktop. Laptop offload refused at 100°C. No timeout increase, reduced loop or weakened expectation. | +| golden-path, integration receipts, ci-required | `golden-path templates: failure`; `CI_INTEGRATION_RECEIPT_INVENTORY: required receipts are missing, duplicated or unmapped`; required upstream failures. CI join scripts/workflow. | Downstream; no direct patch, as requested. | + +Local verification at `09f3e245f`: `pnpm lint` exit 0; `pnpm typecheck` exit 0; world GPU specs 97/97; focused rendered positive scenarios 24/24; velocity control rejects exactly movingPixels/oracleMaxErrorPixels/footprintMaxErrorPixels. Hardware runs used the existing process-local NVIDIA library environment and a private Xvfb; no driver/system change. The first fluid launch used an incorrect package filter and failed server startup; the corrected filter is `prd476-fluid-particles`, and only its successful rerun counts. No red-green code repair was justified by these observations. Document checks: `pnpm check:docs` exit 0; six required document/CI specs pass 246/246, exit 0; `git diff --check` passes. Fresh CI remains required; no green or mergeability claim. + +## CI last two causes — 2026-10-12 + +Scope: repair run `38315773168` on PR #485 without merging, rebasing or force-pushing. Develop run `38211146916` passed both corresponding jobs. Plan: reproduce the exact template-nonvisual and native fog commands; retain all assertions, repair their owning layers, run lint/typecheck/focused specs/pre-push, push normally and dispatch without waiting for CI. No expectation changes. + +Tower-defense red: the exact five-scenario CI step on a fresh scaffold from this run's archives exits 1; arsenal reports `Component 'shots' on entity 'tower.1' did not satisfy the assertion.` (also towers 2/3). CI records before=undefined, after=26/7/12: these entities spawn during the scenario. Harness layer: retain the strict two-observation evaluator and supply an actual earlier labeled observation for a newly spawned component, following the existing movement lifetime convention. New runner regression cases are red, 2 failed/4 passed; missing baselines, same-time samples, unchanged counters and missing final values must still fail. + +Native fog red: `VQ_FOG_RESIZE_CONTROL=1 THREENATIVE_RUNTIME_BINARY="$PWD/packages/runtime-native/build/tn-linux/mystral" node --import tsx scripts/verify-volumetric-fog.ts` exits 1 with the exact CI error `VQ07 native capture failed: TN_PLAYTEST_NETWORK_ERROR: 1 failed network request(s) were captured after runtime readiness.` Both CI and local `network.json` name `GET assets.manifest.json`. Verifier packaging layer: the bare fixture bundle omitted the asset-free manifest; reuse `compileAssets` to stage it and launch from that asset root, as the production desktop packager does. Network observation and pixel gates remain unchanged. Native source build, initial lint/typecheck and native proof specs (7/7) pass. Post-fix focused specs pass 161/161 (`runner`, `vacuous-assertion`, `assertion-report`, `desktop-playtest`, `vq-fog-native-proof`), exit 0; playtest build exits 0. Post-fix runtime and final gates remain unverified until rerun. + +The first runtime replay passed arsenal but exposed two target-priority triviality failures because the lifetime baseline had also been applied to final-value assertions. Its additional regression test failed before correction. Lifetime rebasing now applies only to temporal (`changed`) assertions in the shared `buildReport`; `componentObservations` and the final-value baseline remain unchanged. No scenario expectation changes. Native resize control passed both authored arms and their five-percent pixel gates on Linux x64 at `92feefd26`; network observations are empty. At that checkpoint, the full fog sequence and corrected five-scenario replay remained pending. Lint's two Number-namespace errors were corrected; one overlapping typecheck caught declarations mid-build and was rerun after build completion. + +Final source fix: `1f9326f05` (fog staging introduced by `92feefd26`). The exact template CI shell step, using the five paths returned by `node scripts/non-visual-scenarios.mjs /tmp/prd566-last-two-tower-defense` and `pnpm --dir /tmp/prd566-last-two-tower-defense exec threenative-playtest "${scenario_args[@]}" --browser-recipe webgpu --headed --no-screenshots --server-timeout 120000 --server-command 'pnpm dev --host 127.0.0.1 --port $PORT --strictPort'`, exits 0: 5/5 scenarios, 37/37 assertions, NVIDIA/Turing (`/tmp/prd566-last-two-tower-isolated.log`). A concurrent shared-display attempt was invalidated by unsolicited Help input (`helpOpen=true`, `paused=true`); the final replay ran without a competing native host. No gameplay fix was guessed. + +Both native CI verifier commands, `node --import tsx scripts/verify-volumetric-fog.ts` with the current built `THREENATIVE_RUNTIME_BINARY` and native job environment, exit 0 at `1f9326f05`: the ordinary sequence passes 29/29 captures; `VQ_FOG_RESIZE_CONTROL=1` passes both resize-off arms and their pixel gates. Zero captured network failures in either run. Logs: `/tmp/prd566-last-two-native-fog-green.log`, `/tmp/prd566-last-two-native-resize-final.log`. This is Linux x64 local proof; the hosted ARM lane remains unverified until CI. The existing native-smoke wrong-value and misspelled scenarios reject with exits 1 (`TN_PLAYTEST_VISIBILITY_FAILED`) and 2 (`TN_PLAYTEST_SCENARIO_INVALID`), respectively. + +Final `pnpm lint` and `pnpm typecheck`: exit 0. Focused runner/vacuous/assertion-report/desktop/fog specs: 162/162, exit 0 (`/tmp/prd566-last-two-focused-final.log`); the exact native preflight command with `--maxWorkers=1` also passes 7/7. Playtest build and scaffold typecheck exit 0. No waiver, assertion deletion, expectation change, merge, rebase or force-push. The normal push's pre-push result and dispatched CI run are reported in the final response; no CI-green or mergeability claim. + +## CI finish — 2026-10-12 + +Scope: diagnose run `38303438671` at `09c342672`, repair reproduced branch defects, run existing CI commands, push once and dispatch without merging or waiting for the verdict. Complexity: 1 (LOW), one shared harness implementation file; integration unchanged. Plan: reproduce without hardware access, preserve explicit NVIDIA launches, verify unchanged runtime assertions, run gates, push and dispatch. Phase progress remains 6/6. + +Develop run `38211146916` reports success for every corresponding failed job. Failed log: `/tmp/prd566-ci-run5.log`. Engine/harness layer: `packages/playtest/src/runner/browser.ts` supplies the CLI, capture verifiers and root browser config. A GPU-isolated `tn-ci-runner` container reproduces a null adapter with branch flags; develop flags pass 200 mapped-buffer/readback iterations on SwiftShader. Headed probing shows removing only `--disable-vulkan-surface` is insufficient: forced ANGLE/Vulkan still returns null. Defaults now match develop exactly; all three NVIDIA switches require the existing `TN_PLAYTEST_HOST_DISPLAY=1` opt-in. The NVIDIA/Turing launch path retains identical flags. + +Expectation change: the sealed recipe previously required three hardware switches unconditionally; it now requires them with the explicit host-display opt-in. Headed software failure supports the change. Added unset/`0`/`1` cases fail before the fix; final browser-input/bridge contracts pass 37/37, exit 0 (`/tmp/prd566-flags-final.log`). Existing gameplay, readback, negative-control and capture assertions are unchanged. + +Exact software fluid command `sh scripts/xvfb.sh pnpm exec tsx scripts/verify-fluid-collision.ts`: inherited flags exit 1, `Actual GPU validation must complete before sampling`, actual `undefined`, expected `1` (`/tmp/prd566-fluid-container-red.log`); final flags exit 0, actual gate passes and missing-gate control fails only its collision assertion (`/tmp/prd566-fluid-container-final.log`). The surface-only attempt was stopped after headed probing disproved it. `pnpm exec tsx scripts/verify-velocity-history.ts --variant=history` exits 0 on both NVIDIA and software: all authored motion/control variants produce expected outcomes (`/tmp/prd566-velocity-{red,container-final}.log`). The initial NVIDIA `pnpm test:playtest:ci` exits 0; root browser command exits 0, 4/4 on NVIDIA (`/tmp/prd566-browser-local.log`). + +Unit timeout: focused unchanged case passes locally in 48.26 seconds and on the laptop in 30.41 seconds, `pnpm exec vitest run packages/core/__tests__/world-gpu-scene.spec.ts -t 'spends no regroup'`, 1/1, exit 0. The CI command aborts in the laptop's history-free lane (`fatal: ambiguous argument 'HEAD'`); it is running locally with `TN_SUITE_PHASES=unit TN_SUITE_UNIT_SHARD=3/4 TN_ORPHAN_SETTLE_SECONDS=120 pnpm test`. No timeout or loop count changed. Final focused specs pass 174/174, exit 0 (`/tmp/prd566-focused-final.log`). Full-shard result: exit 1, 2,659 passed/5 failed/5 skipped; the named timeout reproduces, plus another world-GPU timeout, a placement mismatch, a capability-doc scan timeout and a build-worker mutation test hitting its 60-second ceiling (`/tmp/prd566-unit-ci-local.log`). Isolation of the world-GPU and capability-doc specs passes 111/112; only the named timeout remains (`/tmp/prd566-unit-isolated.log`). A temporary source copy substituting only develop’s `world-cells.ts`/`world-tiles.ts` passes the named case in 32.68 seconds; the branch’s paired case takes 95.53 seconds and times out. Host load rose between arms, so attribution to added morph construction remains unqualified. Stop this unit repair after repeated timeouts; the doubtful assumption is comparable host load. The build-worker mutation case separately passes 1/1 in 24.69 seconds (`/tmp/prd566-build-isolated.log`). The world-GPU timeout remains blocking, not waived. + +Review: source hunks, Three patch layouts, native observer/viewport changes and fixture assertions inspected against `origin/develop`. Shared Chromium recipe is the only reproduced blocking finding. Non-blocking: `TerrainTiles.#syncMorphEdges` rebuilds immutable targets synchronously when neighbor membership changes; transient cost is a separate performance follow-up. No speculative solver, velocity, timestamp or terrain fix. + +Final runtime/gate results: software-container `pnpm test:playtest:ci` exits 0, all five scenarios pass (`/tmp/prd566-playtest-container-final.log`); NVIDIA `pnpm test:tone` exits 0, restored exposure passes and underexposure rejects (`/tmp/prd566-tone-local.log`); `TN_GOLDEN_PATH_TEMPLATES=platformer TN_GOLDEN_PATH_SCENARIOS=1 TN_GOLDEN_PATH_ARCHIVES=/tmp/prd566-packed pnpm verify:golden-path` exits 0, including its existing broken-dependency control (`/tmp/prd566-golden-local.log`); the exact template-nonvisual step on a packed runner scaffold exits 0, 5/5 assertions (`/tmp/prd566-template-runner.log`). Root software-only browser reproduction reaches and correctly rejects the hardware-required starter look gate (`TN_PLAYTEST_SOFTWARE_ADAPTER`); no allowance or assertion was changed. The NVIDIA browser command passes 4/4. Lint and full typecheck exit 0; focused specs 174/174; six document/CI specs 246/246; `pnpm check:docs` exits 0; `pnpm ci:fast` exits 0 (agents/docs/lint/drift pass). `pnpm prd:progress` remains 6/6, acceptance 3/3, `prd:100%`. Golden-path, receipt verification and ci-required are downstream joins; no join or receipt rule changed. CI is not green or qualified for merge. + +## Fog import and unit attribution — 2026-10-12 + +Complexity: 1 → LOW; risk override: none. Scope: run `38322019460`, PR #485; repair only reproduced fog/unit causes, preserve every runtime assertion and timeout, push normally and dispatch without merging. Plan: move native asset compilation loading into the native verifier branch; reproduce the browser entry point without an assets build and rerun its unchanged 29-capture sequence. Compare the complete world-GPU spec on this branch and a `git archive origin/develop` copy, sequentially twice each on the same desktop; only repair a repeatable branch cost. Finish with focused specs, lint, typecheck and pre-push. Integration: existing fog verifier entry point and existing terrain render path; no new public surface. Results pending. + +Fog first error: `ERR_MODULE_NOT_FOUND` for `packages/assets/dist/index.js`, introduced by unconditional loading of native-only compilation. The exact verifier command reproduces it in `/tmp/prd566-fog-clean` with no assets build; the corrected module imports there with exit 0. Develop run `38316358346` at `6c23e7243` passed fog capture and unit shard 3/4. The native branch still uses `compileAssets`; browser capture assertions are unchanged. + +Unit attribution: `pnpm exec vitest run packages/core/__tests__/world-gpu-scene.spec.ts` ran branch/develop/branch/develop sequentially on the desktop, all 97/97, using archived develop source and the same dependency installation. Full-file Vitest durations: branch 59.07/56.50 s, develop 33.39/33.62 s; named case 41.683/40.914 s against 21.516/21.783 s. One-minute host load stayed 6.54–8.64, with no competing task launched by this lane during these four runs. This confirms a repeatable branch construction cost, rather than equal timings masked by host load: WorldCells rebuilds TerrainTiles on the walk, including new morph targets and neighbor-edge targets. The prop-dispatch test now samples terrain at 9×9 instead of the default 129×129 in both arms; it retains terrain residency, the three LOD factors, both 200-frame walks, every placement and all assertions. No expectation or timeout change; the existing CI timeout is not waived. Post-fix spec passes 97/97, exit 0: named case 1.127 s, complete file 17.80 s. No production terrain code changed. + +Final focused fog/native-proof specs pass 24/24, exit 0 (121/121 with the GPU spec); `pnpm lint` and `pnpm typecheck` exit 0. Playtest build and both Vite builds exit 0. Browser module import without an assets build is red/green. Full browser capture remains unverified: the archived launch rejects absent Git metadata; the checkout launch rejects its dirty source tree, both before rendering. Stop after these two failed launch attempts as required by the brief; no provenance waiver or third launch. The missing `/usr/bin/time` wrapper did not invoke the verifier and is not runtime evidence. Doctor exits 0. The push hook and fresh dispatched run are reported in the final response; no CI-green or mergeability claim. Self-review: native staging still calls the same compiler with the same arguments, browser imports no native compiler, and the test changes only incidental sample density while keeping all assertions and loops. diff --git a/docs/guides/world-streaming.md b/docs/guides/world-streaming.md index b204e07752..031eadeb68 100644 --- a/docs/guides/world-streaming.md +++ b/docs/guides/world-streaming.md @@ -122,8 +122,13 @@ scene.add(world); `terrain.streamRadius` is the terrain residency radius in tiles, and it defaults to `ring`, so a world streams ground and props over the same square. Raise it alone to put the ground out to the horizon while the props stay near: the default `lodDistances` are `tileSize` multiples, so the far -tiles of the wider ring take the coarser levels on their own. `terrain.colliderRadius` is the -separate Chebyshev radius that gets a `createCollider` body, and it also defaults to `ring`; a tile +tiles of the wider ring can take coarser levels. Each distance is the nearest allowed switch; +screen error can hold a finer level farther out. With a render camera and renderer, terrain shares +props’ resolved `autoLod.maxPixelError` budget and `lodHysteresis`, using the current drawing-buffer +height and camera projection. Resizing or moving the camera refreshes settled terrain without +resampling heights. Without a render view, existing distance-only selection is preserved. + +`terrain.colliderRadius` is the separate Chebyshev radius that gets a `createCollider` body, and it also defaults to `ring`; a tile crossing it has its body created or disposed as it goes, so a wide render ring does not cost a physics body for every tile in it. @@ -168,7 +173,8 @@ The numbers come from where each edge can be, measured from the follow point: - **Bodies reach `colliderRadius · c`.** That is at most `ring`, because physics never needs ground the props have not reached. 128 m is far above any fall in one frame; a fast vehicle may want 2. - **A terrain LOD switch is at least fog `near`**, so a switch pops inside the haze. The - distances must increase, which `TerrainTiles` checks itself. + distances must increase, which `TerrainTiles` checks itself. Screen-error selection may delay a + switch beyond these floors, so higher raster resolution cannot bring it in front of the haze. - **`budgets.residentCells` is at least `(2 · ring + 1)²`**, the wanted square. Cells kept by the `ring + 1` hysteresis yield to wanted cells when the budget is full. diff --git a/docs/verification/native-coverage-2026-08-28.md b/docs/verification/native-coverage-2026-08-28.md index c70226b7e6..fa67a269f3 100644 --- a/docs/verification/native-coverage-2026-08-28.md +++ b/docs/verification/native-coverage-2026-08-28.md @@ -16,17 +16,17 @@ targets could not be built and are named below. | `src/js/` | 2785 | 2212 | 79.43% | | `src/platform/` | 1460 | 1003 | 68.70% | | `src/raytracing/` | 461 | 399 | 86.55% | -| `src/runtime.cpp` | 2410 | 1886 | 78.26% | +| `src/runtime.cpp` | 2412 | 1888 | 78.28% | | `src/screenshot_gate.cpp` | 27 | 24 | 88.89% | | `src/storage/` | 327 | 286 | 87.46% | | `src/utils/` | 0 | 0 | 0.00% | | `src/vfs/` | 239 | 195 | 81.59% | -| `src/webgpu/` | 9802 | 7501 | 76.53% | +| `src/webgpu/` | 9802 | 7492 | 76.43% | | `src/webtransport/` | 1391 | 1078 | 77.50% | | `src/workers/` | 615 | 527 | 85.69% | -| **TOTAL** | **24730** | **19390** | **78.41%** | +| **TOTAL** | **24732** | **19383** | **78.37%** | -Source digest: `sha256:53fe120fe5582b1199d07506367f09423f1cbcbcda8438ce09515959c65e9ad0` +Source digest: `sha256:800273b1a2763a4f7cd2854713ea98d9cbd900b5d838671f5a395493ce83cc51` 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 @@ -43,7 +43,7 @@ change requires this opt-in command to refresh the record. | `src/js/` | 74.43% | | `src/platform/` | 63.70% | | `src/raytracing/` | 81.55% | -| `src/runtime.cpp` | 73.26% | +| `src/runtime.cpp` | 73.28% | | `src/screenshot_gate.cpp` | 88.89% | | `src/storage/` | 86.54% | | `src/utils/` | 0.00% | diff --git a/examples/abyss-framework/playtests/terrain-raster.playtest.json b/examples/abyss-framework/playtests/terrain-raster.playtest.json new file mode 100644 index 0000000000..cb9dd34dfb --- /dev/null +++ b/examples/abyss-framework/playtests/terrain-raster.playtest.json @@ -0,0 +1,159 @@ +{ + "assert": { + "diagnostics": { + "noConsoleErrors": true, + "noNetworkErrors": true, + "noRuntimeDiagnostics": true, + "runtimeReady": true + }, + "movement": { + "entity": "player", + "minDistance": 300, + "pathLength": 300, + "minAxisDelta": { + "axis": "x", + "min": 300 + } + }, + "components": [ + { + "component": "maxBlendingTiles", + "entity": "terrain", + "gte": 1 + }, + { + "allowTrivial": "TerrainTiles reports its hard tile cap from the first observation.", + "component": "residentTiles", + "entity": "terrain", + "lte": 9 + }, + { + "allowTrivial": "TerrainTiles reports its hard byte cap from the first observation.", + "component": "residentBytes", + "entity": "terrain", + "lte": 20000000 + }, + { + "allowTrivial": "TerrainTiles records a nonzero peak resident tile count at startup.", + "component": "peakResidentTiles", + "entity": "terrain", + "gte": 1 + }, + { + "allowTrivial": "TerrainTiles records a nonzero peak resident byte count at startup.", + "component": "peakResidentBytes", + "entity": "terrain", + "gte": 1 + }, + { + "allowTrivial": "The validated CPU reference measures seam gaps from its first observation; the bound must hold from startup, not only after movement.", + "component": "maxSeamGap", + "entity": "terrainCpuReference", + "gte": 1e-6, + "lte": 64 + }, + { + "allowTrivial": "TerrainTiles reports no consumer-visible seam after generated skirt coverage.", + "component": "maxVisualSeamGap", + "entity": "terrainCpuReference", + "lte": 0 + }, + { + "allowTrivial": "The validated CPU reference counts stitched edges from its first observation; the count must stay positive from startup, not only after movement.", + "component": "stitchedEdges", + "entity": "terrainCpuReference", + "gte": 1 + }, + { + "allowTrivial": "TerrainTiles reports the skirt vertices observed in its rendered geometry.", + "component": "skirtVertexCount", + "entity": "terrain", + "gte": 1 + }, + { + "allowTrivial": "TerrainTiles keeps the measured LOD mismatch below its declared transition bound from startup.", + "changed": true, + "component": "maxLodPop", + "entity": "terrainCpuReference", + "lte": 16 + }, + { + "component": "maxLodTransitionFrames", + "entity": "terrain", + "gte": 3 + }, + { + "changed": true, + "component": "lodTransitions", + "entity": "terrain" + }, + { + "entity": "terrain", + "component": "activeMorphFrames", + "gte": 1, + "changed": true + }, + { + "entity": "terrain", + "component": "parityFrames", + "gte": 1, + "changed": true + }, + { + "entity": "terrain", + "component": "parityVertices", + "gte": 1, + "changed": true + }, + { + "entity": "terrain", + "component": "maxPositionError", + "lte": 0.001, + "allowTrivial": "Every observed visible source vertex is compared with the independent CPU reference; mismatches throw." + }, + { + "entity": "terrain", + "component": "maxNormalError", + "lte": 0.001, + "allowTrivial": "Every observed normalized morph normal is compared with the independent CPU reference; mismatches throw." + }, + { + "entity": "terrain", + "component": "gpuSurfaceSamples", + "gte": 1, + "changed": true + }, + { + "entity": "terrain", + "component": "gpuSurfaceMaxError", + "lte": 0.001, + "allowTrivial": "Every sampled GPU-rendered world position is compared with a ray cast on a same-frame copy of the CPU reference; an uncovered texel, missed ray, failed readback or error above 1 mm throws." + } + ], + "visibility": [ + { + "allowTrivial": "TerrainTiles is intentionally visible from the initial camera framing so the capture always includes the streamed field.", + "entity": "terrain", + "minProjectedPixels": 1, + "present": true + } + ] + }, + "name": "terrain-raster", + "schemaVersion": 1, + "steps": [ + { + "holdTicks": 180, + "label": "cross-three-chunk-boundaries", + "press": ["ArrowRight"], + "release": true + } + ], + "subject": "player", + "target": "web", + "viewport": { + "height": 300, + "width": 1280 + }, + "warmupFrames": 10 +} diff --git a/examples/abyss-framework/playtests/terrain.playtest.json b/examples/abyss-framework/playtests/terrain.playtest.json index 59d04a1e44..5cfa241ad4 100644 --- a/examples/abyss-framework/playtests/terrain.playtest.json +++ b/examples/abyss-framework/playtests/terrain.playtest.json @@ -1,5 +1,6 @@ { "assert": { + "performance": { "minFps": 1 }, "diagnostics": { "noConsoleErrors": true, "noNetworkErrors": true, @@ -16,6 +17,11 @@ } }, "components": [ + { + "component": "maxBlendingTiles", + "entity": "terrain", + "gte": 1 + }, { "allowTrivial": "TerrainTiles reports its hard tile cap from the first observation.", "component": "residentTiles", @@ -41,20 +47,22 @@ "gte": 1 }, { + "changed": true, "component": "maxSeamGap", - "entity": "terrain", - "gte": 0.000001, + "entity": "terrainCpuReference", + "gte": 1e-6, "lte": 64 }, { "allowTrivial": "TerrainTiles reports no consumer-visible seam after generated skirt coverage.", "component": "maxVisualSeamGap", - "entity": "terrain", + "entity": "terrainCpuReference", "lte": 0 }, { + "changed": true, "component": "stitchedEdges", - "entity": "terrain", + "entity": "terrainCpuReference", "gte": 1 }, { @@ -67,7 +75,7 @@ "allowTrivial": "TerrainTiles keeps the measured LOD mismatch below its declared transition bound from startup.", "changed": true, "component": "maxLodPop", - "entity": "terrain", + "entity": "terrainCpuReference", "lte": 16 }, { @@ -79,6 +87,36 @@ "changed": true, "component": "lodTransitions", "entity": "terrain" + }, + { + "entity": "terrain", + "component": "activeMorphFrames", + "gte": 1, + "changed": true + }, + { + "entity": "terrain", + "component": "parityFrames", + "gte": 1, + "changed": true + }, + { + "entity": "terrain", + "component": "parityVertices", + "gte": 1, + "changed": true + }, + { + "entity": "terrain", + "component": "maxPositionError", + "lte": 0.001, + "allowTrivial": "Every observed visible source vertex is compared with the independent CPU reference; mismatches throw." + }, + { + "entity": "terrain", + "component": "maxNormalError", + "lte": 0.001, + "allowTrivial": "Every observed normalized morph normal is compared with the independent CPU reference; mismatches throw." } ], "visibility": [ @@ -103,7 +141,7 @@ "subject": "player", "target": "web", "viewport": { - "height": 720, + "height": 300, "width": 1280 }, "warmupFrames": 10 diff --git a/examples/abyss-framework/public/terrain-assets/assets.manifest.json b/examples/abyss-framework/public/terrain-assets/assets.manifest.json new file mode 100644 index 0000000000..eec10974dd --- /dev/null +++ b/examples/abyss-framework/public/terrain-assets/assets.manifest.json @@ -0,0 +1 @@ +{"version":1,"entries":{}} diff --git a/examples/abyss-framework/src/scenes/TerrainProbe.ts b/examples/abyss-framework/src/scenes/TerrainProbe.ts index 2b5b331b7e..cab1685a12 100644 --- a/examples/abyss-framework/src/scenes/TerrainProbe.ts +++ b/examples/abyss-framework/src/scenes/TerrainProbe.ts @@ -1,4 +1,9 @@ -import { type ICtx, Scene } from "@threenative/core"; +import { + DISCRETE_LOD_DEFAULT_ERROR_PIXELS, + DISCRETE_LOD_DEFAULT_HYSTERESIS, + type ICtx, + Scene, +} from "@threenative/core"; import { type IWorldCapabilities, type IWorldTileColliderInput, @@ -12,18 +17,58 @@ import { RigidBody3D, } from "@threenative/physics"; import * as THREE from "three"; +import { positionWorld, vec4 } from "three/tsl"; +import { MeshBasicNodeMaterial } from "three/webgpu"; import { terrainMaterial } from "../render/terrain.js"; import { TERRAIN_SEED, terrainHeight } from "../world/terrainSampler.js"; const TILE_SIZE = 64; const TILE_RESOLUTION = 65; const PLAYER_SPEED = 150; +// 128 RGBA32F texels are 2,048 bytes a row, so the readback needs no row padding. +const GPU_SURFACE_SIZE = 128; +const GPU_SURFACE_OBSERVATIONS = 8; + +interface IGpuRenderer { + getClearAlpha(): number; + getClearColor(target: THREE.Color): THREE.Color; + getRenderTarget(): THREE.RenderTarget | null; + readRenderTargetPixelsAsync( + target: THREE.RenderTarget, + x: number, + y: number, + width: number, + height: number, + ): Promise; + render(scene: THREE.Scene, camera: THREE.Camera): void; + setClearColor(color: THREE.ColorRepresentation, alpha?: number): void; + setRenderTarget(target: THREE.RenderTarget | null): void; +} const initialState = { chunks: 0, playerX: 0, }; +interface ITerrainBlendFrame { + readonly frame: number; + readonly startedAt: number; + readonly blendingTiles: number; + frameMs?: number; +} + +export function retainTerrainBlendFrame( + entries: ITerrainBlendFrame[], + startedAt: number, + blendingTiles: number, +): void { + const previous = entries.at(-1); + // The interval ends at the next draw; it belongs to the draw that just completed. + if (previous !== undefined) previous.frameMs = startedAt - previous.startedAt; + entries.push({ frame: (previous?.frame ?? -1) + 1, startedAt, blendingTiles }); + if (entries.length > 1_024) entries.shift(); +} + export type TerrainState = typeof initialState; type TerrainCtx = ICtx; @@ -73,10 +118,30 @@ export class TerrainProbe extends Scene { #player: TerrainPlayer | undefined; #tiles: TerrainTiles | undefined; + #reference: TerrainTiles | undefined; + #parityFrames = 0; + #parityVertices = 0; + #activeMorphFrames = 0; + #maxPositionError = 0; + #maxNormalError = 0; #capabilities: IWorldCapabilities | undefined; + #maxBlendingTiles = 0; + #frameBlendingTiles = 0; + #blendFrames: ITerrainBlendFrame[] = []; + #lodEligibilityTrace: Record[] = []; + #lodEligibilityOverflow = 0; + #sceneUpdateTick = 0; + #gpuSurfaceMaterial: MeshBasicNodeMaterial | undefined; + #gpuSurfaceStarted = 0; + #gpuSurfaceFrames = 0; + #gpuSurfaceSamples = 0; + #gpuSurfaceMaxError = 0; + #gpuSurfaceFailure: Error | undefined; + #gpuSurfaceEpoch = 0; override enter(ctx: TerrainCtx): void { this.#player = new TerrainPlayer(ctx); + this.#camera(ctx); this.#capabilities = getWorldCapabilities({ cpuFallbackIterations: 4, gpuAvailable: ctx.renderer.kind === "webgpu", @@ -101,20 +166,7 @@ export class TerrainProbe extends Scene { }, gpu: this.#capabilities.generation === "gpu", }; - const tiles = new TerrainTiles({ - createCollider: ({ field, key, object, tileX, tileZ }: IWorldTileColliderInput) => - new RigidBody3D({ - object, - physics: ctx.physics, - shape: CollisionShape3D.heightfield( - field.rows, - field.columns, - field.toColliderHeights(), - { x: TILE_SIZE, y: 1, z: TILE_SIZE }, - ), - type: "fixed", - entity: `terrain.${key}.${String(tileX)}.${String(tileZ)}`, - }), + const tileOptions = { surface: terrainMaterial(), residentByteBudget: 20_000_000, residentTileBudget: 9, @@ -123,10 +175,6 @@ export class TerrainProbe extends Scene { tileResolution: TILE_RESOLUTION, tileSize: TILE_SIZE, lodDistances: [48, 96], - // This scene exists to measure the streamed field, and seam gap, visual seam and LOD pop are - // the measurements: without this they read `undefined` and the scenario's assertions have - // nothing to hold. - validate: true, topologyObservation: { columns: 1025, depth: 1024, @@ -135,22 +183,120 @@ export class TerrainProbe extends Scene { width: 1024, }, ...(worldPasses === undefined ? {} : { worldPasses }), + }; + const tiles = new TerrainTiles({ + ...tileOptions, + createCollider: ({ field, key, object, tileX, tileZ }: IWorldTileColliderInput) => + new RigidBody3D({ + object, + physics: ctx.physics, + shape: CollisionShape3D.heightfield( + field.rows, + field.columns, + field.toColliderHeights(), + { x: TILE_SIZE, y: 1, z: TILE_SIZE }, + ), + type: "fixed", + entity: `terrain.${key}.${String(tileX)}.${String(tileZ)}`, + }), + validate: false, }); // Populate the initial resident set before registration so compute warm-up sees every // field's kernels, including fields created during the first follow operation. - tiles.follow({ x: 0, z: 0 }); + tiles.follow({ x: 0, z: 0 }, undefined, this.#view(ctx)); + const reference = new TerrainTiles({ + ...tileOptions, + validate: true, + residentTileBudget: tiles.residentTileCount, + }); + reference.follow({ x: 0, z: 0 }, undefined, this.#view(ctx)); + this.#reference = reference; this.#tiles = ctx.add(tiles); + const process = tiles.process.bind(tiles); + tiles.process = (renderer, camera, covered) => { + // Observe before process removes completed transitions, including their final blend frame. + this.#frameBlendingTiles = tiles.blendingTiles; + this.#maxBlendingTiles = Math.max(this.#maxBlendingTiles, this.#frameBlendingTiles); + retainTerrainBlendFrame(this.#blendFrames, performance.now(), this.#frameBlendingTiles); + const view = this.#view(ctx); + process(renderer, camera, covered, view); + reference.process(renderer, camera, covered, view); + this.#checkParity(); + }; + ctx.beforeRender(() => { + const failure = this.#gpuSurfaceFailure; + this.#gpuSurfaceFailure = undefined; + if (failure !== undefined) throw failure; + this.#observeGpuSurface(ctx); + if (this.#lodEligibilityTrace.length >= 1_024) { + this.#lodEligibilityOverflow++; + } else { + const snapshot = tiles.debug(); + const fields = [ + "lodPolicy", + "residentKeys", + "pendingConstruction", + "ringEpoch", + "lodTransitions", + "terrainTiles", + "lodDecisions", + ]; + this.#lodEligibilityTrace.push({ + renderSample: this.#lodEligibilityTrace.length, + sceneUpdateTick: this.#sceneUpdateTick, + parityFrame: this.#parityFrames, + playerPosition: this.#player?.mesh.position.toArray() ?? null, + lodPolicy: snapshot.lodPolicy ?? null, + residentKeys: snapshot.residentKeys ?? null, + pendingConstruction: snapshot.pendingConstruction ?? null, + ringEpoch: snapshot.ringEpoch ?? null, + lodTransitions: snapshot.lodTransitions ?? null, + terrainTiles: snapshot.terrainTiles ?? null, + lodDecisions: snapshot.lodDecisions ?? null, + missingFields: fields.filter((field) => snapshot[field] === undefined), + }); + } + }); ctx.entities.add("terrain", { debug: () => ({ ...(this.#tiles?.debug() ?? {}), + lodCamera: { + type: ctx.camera.type, + projectionMatrix: ctx.camera.projectionMatrix.toArray(), + matrixWorld: ctx.camera.matrixWorld.toArray(), + rasterWidth: ctx.renderer.domElement.width, + rasterHeight: ctx.renderer.domElement.height, + }, + maxBlendingTiles: this.#maxBlendingTiles, + // The newest draw has no ending timestamp yet; expose only completed intervals. + blendFrames: this.#blendFrames.slice(0, -1).map((entry) => ({ ...entry })), + lodEligibilityTrace: { + sampling: "beforeRender; sceneUpdateTick is a scene-local update ordinal", + overflow: this.#lodEligibilityOverflow, + // The bridge rejects snapshots over 1 MB; each entry is ~3 KB, so expose the newest 64. + retained: this.#lodEligibilityTrace.length, + samples: this.#lodEligibilityTrace.slice(-64), + }, cpuFallbackIterations: this.#capabilities?.cpuFallbackIterations ?? 0, generation: this.#capabilities?.generation ?? "unsupported", gpu: this.#capabilities?.gpu ?? false, seed: TERRAIN_SEED, + parityFrames: this.#parityFrames, + parityVertices: this.#parityVertices, + activeMorphFrames: this.#activeMorphFrames, + maxPositionError: this.#maxPositionError, + maxNormalError: this.#maxNormalError, + gpuSurfaceFrames: this.#gpuSurfaceFrames, + gpuSurfaceSamples: this.#gpuSurfaceSamples, + gpuSurfaceMaxError: this.#gpuSurfaceMaxError, }), dispose: () => this.#tiles?.dispose(), object: this.#tiles, }); + ctx.entities.add("terrainCpuReference", { + debug: () => reference.debug(), + dispose: () => reference.dispose(), + }); ctx.entities.add("player", { debug: () => ({ position: this.#player?.mesh.position.toArray() ?? [] }), dispose: () => this.#player?.dispose(), @@ -161,20 +307,251 @@ export class TerrainProbe extends Scene { } override update(ctx: TerrainCtx, dt: number): void { + this.#sceneUpdateTick++; const player = this.#player; const tiles = this.#tiles; if (player === undefined || tiles === undefined) return; const move = ctx.input.vector("move"); player.move({ x: move.x * PLAYER_SPEED, z: move.y * PLAYER_SPEED }, dt); - tiles.follow(player.mesh.position); - this.#publish(ctx); this.#camera(ctx); + const view = this.#view(ctx); + tiles.follow(player.mesh.position, undefined, view); + this.#reference?.follow(player.mesh.position, undefined, view); + this.#publish(ctx); } override exit(): void { + this.#reference?.dispose(); + this.#reference = undefined; this.#tiles = undefined; + this.#parityFrames = 0; + this.#parityVertices = 0; + this.#activeMorphFrames = 0; + this.#maxPositionError = 0; + this.#maxNormalError = 0; this.#player = undefined; this.#capabilities = undefined; + this.#maxBlendingTiles = 0; + this.#frameBlendingTiles = 0; + this.#blendFrames = []; + this.#lodEligibilityTrace = []; + this.#lodEligibilityOverflow = 0; + this.#sceneUpdateTick = 0; + this.#gpuSurfaceEpoch++; + this.#gpuSurfaceMaterial?.dispose(); + this.#gpuSurfaceMaterial = undefined; + this.#gpuSurfaceStarted = 0; + this.#gpuSurfaceFrames = 0; + this.#gpuSurfaceSamples = 0; + this.#gpuSurfaceMaxError = 0; + this.#gpuSurfaceFailure = undefined; + } + + #view(ctx: TerrainCtx) { + return { + camera: ctx.camera, + viewportHeight: ctx.renderer.domElement.height, + maxPixelError: DISCRETE_LOD_DEFAULT_ERROR_PIXELS, + hysteresis: DISCRETE_LOD_DEFAULT_HYSTERESIS, + }; + } + + #checkParity(): void { + const gpu = this.#tiles; + const cpu = this.#reference; + if (gpu === undefined || cpu === undefined) throw new Error("Terrain parity needs both paths."); + const frame = this.#parityFrames; + if ( + gpu.residentKeys.join() !== cpu.residentKeys.join() || + gpu.lodTransitions !== cpu.lodTransitions || + gpu.maxLodTransitionFrames !== cpu.maxLodTransitionFrames + ) + throw new Error( + `Terrain parity residency/transitions diverged at frame ${frame}: GPU ${gpu.residentKeys}, CPU ${cpu.residentKeys}.`, + ); + const actual = new THREE.Vector3(); + const expected = new THREE.Vector3(); + const normal = new THREE.Vector3(); + const delta = new THREE.Vector3(); + let active = false; + for (const key of gpu.residentKeys) { + const tile: ReturnType = gpu.getTile(key); + const reference = cpu.getTile(key); + if (tile === undefined || reference === undefined || tile.lodLevel !== reference.lodLevel) + throw new Error(`Terrain LOD parity diverged at frame ${frame}, tile ${key}.`); + const index = tile.lod.levels.findIndex(({ object }) => object.visible); + // Settled merged tiles have no visible source mesh; their blends always draw individually. + if (index < 0) continue; + const mesh = tile.lod.levels[index]?.object; + const oracle = reference.lod.levels[index]?.object; + if (!(mesh instanceof THREE.Mesh) || !(oracle instanceof THREE.Mesh)) + throw new Error(`Terrain parity needs matching meshes at frame ${frame}, tile ${key}.`); + const positions = oracle.geometry.getAttribute("position"); + const normals = oracle.geometry.getAttribute("normal"); + if (positions.count !== mesh.geometry.getAttribute("position").count) + throw new Error(`Terrain parity vertex count diverged at frame ${frame}, tile ${key}.`); + const weights = mesh.morphTargetInfluences ?? []; + active ||= weights.some((weight) => weight > 0 && weight < 1); + for (let vertex = 0; vertex < positions.count; vertex++) { + mesh.getVertexPosition(vertex, actual); + const positionError = actual.distanceTo(expected.fromBufferAttribute(positions, vertex)); + normal.fromBufferAttribute(mesh.geometry.getAttribute("normal"), vertex); + for (const [target, weight] of weights.entries()) { + const attribute = mesh.geometry.morphAttributes.normal?.[target]; + if (attribute !== undefined) + normal.addScaledVector(delta.fromBufferAttribute(attribute, vertex), weight); + } + const normalError = normal + .normalize() + .distanceTo(expected.fromBufferAttribute(normals, vertex).normalize()); + if ( + !Number.isFinite(positionError + normalError) || + positionError > 0.001 || + normalError > 0.001 + ) + throw new Error( + `Terrain morph parity failed at frame ${frame}, tile ${key}, vertex ${vertex}: position=${positionError}, normal=${normalError}.`, + ); + this.#maxPositionError = Math.max(this.#maxPositionError, positionError); + this.#maxNormalError = Math.max(this.#maxNormalError, normalError); + this.#parityVertices++; + } + } + if (active) this.#activeMorphFrames++; + this.#parityFrames++; + } + + // AC-1: draws a blending tile's morphed source mesh as world positions into a float target, + // then checks the readback against a ray cast on a same-frame copy of the CPU reference. + #observeGpuSurface(ctx: TerrainCtx): void { + const gpu = this.#tiles; + const cpu = this.#reference; + if (ctx.renderer.kind !== "webgpu" || gpu === undefined || cpu === undefined) return; + if (this.#gpuSurfaceStarted >= GPU_SURFACE_OBSERVATIONS) return; + for (const key of gpu.residentKeys) { + const tile = gpu.getTile(key); + const index = tile?.lod.levels.findIndex(({ object }) => object.visible) ?? -1; + const mesh = tile?.lod.levels[index]?.object; + const oracle = cpu.getTile(key)?.lod.levels[index]?.object; + if (!(mesh instanceof THREE.Mesh) || !(oracle instanceof THREE.Mesh)) continue; + if (!(mesh.morphTargetInfluences ?? []).some((weight) => weight > 0 && weight < 1)) continue; + this.#gpuSurfaceStarted++; + const epoch = this.#gpuSurfaceEpoch; + void this.#observeSurface(ctx.renderer.raw as IGpuRenderer, mesh, oracle).then( + ({ maxError, samples }) => { + if (epoch !== this.#gpuSurfaceEpoch) return; + this.#gpuSurfaceFrames++; + this.#gpuSurfaceSamples += samples; + this.#gpuSurfaceMaxError = Math.max(this.#gpuSurfaceMaxError, maxError); + }, + (error: unknown) => { + if (epoch === this.#gpuSurfaceEpoch) + this.#gpuSurfaceFailure = error instanceof Error ? error : new Error(String(error)); + }, + ); + return; + } + } + + async #observeSurface( + raw: IGpuRenderer, + mesh: THREE.Mesh, + oracle: THREE.Mesh, + ): Promise<{ maxError: number; samples: number }> { + // Copy everything now: the next blend frame rewrites the reference positions. + const positions = oracle.geometry.getAttribute("position"); + const reference = new THREE.Mesh( + new THREE.BufferGeometry() + .setAttribute("position", new THREE.BufferAttribute(Float32Array.from(positions.array), 3)) + .setIndex(oracle.geometry.index), + new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }), + ); + const target = new THREE.RenderTarget(GPU_SURFACE_SIZE, GPU_SURFACE_SIZE, { + depthBuffer: true, + format: THREE.RGBAFormat, + type: THREE.FloatType, + }); + try { + mesh.updateWorldMatrix(true, false); + reference.matrixAutoUpdate = false; + reference.matrix.copy(mesh.matrixWorld); + reference.updateMatrixWorld(true); + // fragmentNode, not colorNode: colorNode output clamps to >= 0, zeroing negative y and z. + this.#gpuSurfaceMaterial ??= new MeshBasicNodeMaterial({ + fragmentNode: vec4(positionWorld, 1), + fog: false, + side: THREE.DoubleSide, + toneMapped: false, + }); + // The same geometry shares the tile's morph texture; the copied weights are the drawn blend. + const probe = new THREE.Mesh(mesh.geometry, this.#gpuSurfaceMaterial); + probe.matrixAutoUpdate = false; + probe.matrix.copy(mesh.matrixWorld); + probe.frustumCulled = false; + probe.morphTargetInfluences = [...(mesh.morphTargetInfluences ?? [])]; + const scene = new THREE.Scene(); + scene.add(probe); + scene.updateMatrixWorld(true); + const bounds = new THREE.Box3().setFromBufferAttribute(positions as THREE.BufferAttribute); + const center = bounds.getCenter(new THREE.Vector3()).applyMatrix4(mesh.matrixWorld); + const half = Math.max(bounds.max.x - bounds.min.x, bounds.max.z - bounds.min.z) * 0.55; + const camera = new THREE.OrthographicCamera(-half, half, half, -half, 1, 8192); + camera.up.set(0, 0, -1); + camera.position.set(center.x, 4096, center.z); + camera.lookAt(center.x, 0, center.z); + camera.updateMatrixWorld(true); + const previousTarget = raw.getRenderTarget(); + const previousColor = raw.getClearColor(new THREE.Color()); + const previousAlpha = raw.getClearAlpha(); + try { + raw.setRenderTarget(target); + raw.setClearColor(0x000000, 0); + raw.render(scene, camera); + } finally { + // Restore before awaiting: the main frame must not draw into the probe target. + raw.setRenderTarget(previousTarget); + raw.setClearColor(previousColor, previousAlpha); + } + const pixels = await raw.readRenderTargetPixelsAsync( + target, + 0, + 0, + GPU_SURFACE_SIZE, + GPU_SURFACE_SIZE, + ); + const raycaster = new THREE.Raycaster(); + const origin = new THREE.Vector3(); + const down = new THREE.Vector3(0, -1, 0); + let maxError = 0; + let samples = 0; + // Interior texels only: the frame is 10% wider than the footprint, so every one is covered. + for (let row = 16; row < GPU_SURFACE_SIZE - 16; row += 8) { + for (let column = 16; column < GPU_SURFACE_SIZE - 16; column += 8) { + const offset = (row * GPU_SURFACE_SIZE + column) * 4; + const x = pixels[offset] ?? Number.NaN; + const y = pixels[offset + 1] ?? Number.NaN; + const z = pixels[offset + 2] ?? Number.NaN; + if (!((pixels[offset + 3] ?? 0) > 0.5)) + throw new Error(`GPU surface texel (${column}, ${row}) is not covered.`); + raycaster.set(origin.set(x, 4096, z), down); + const hit = raycaster.intersectObject(reference)[0]; + if (hit === undefined) + throw new Error(`CPU reference has no surface under GPU texel (${x}, ${z}).`); + const error = Math.abs(y - hit.point.y); + if (!Number.isFinite(error) || error > 0.001) + throw new Error( + `GPU surface is ${error} m from the CPU blend at (${x}, ${z}): texel=${column},${row} rgba=${pixels[offset]},${pixels[offset + 1]},${pixels[offset + 2]},${pixels[offset + 3]} hit=${hit.point.toArray()} center=${center.toArray()} half=${half} boundsMin=${bounds.min.toArray()} boundsMax=${bounds.max.toArray()} world=${mesh.matrixWorld.elements.join(",")}`, + ); + maxError = Math.max(maxError, error); + samples++; + } + } + return { maxError, samples }; + } finally { + reference.geometry.dispose(); + (reference.material as THREE.Material).dispose(); + target.dispose(); + } } #publish(ctx: TerrainCtx): void { @@ -189,7 +566,8 @@ export class TerrainProbe extends Scene { const player = this.#player?.mesh.position; const x = player?.x ?? 0; const z = player?.z ?? 0; - ctx.camera.position.set(x, 180, z + 180); - ctx.camera.lookAt(x, 0, z); + const y = player?.y ?? 0; + ctx.camera.position.set(x, y + 180, z + 180); + ctx.camera.lookAt(x, y, z); } } diff --git a/examples/abyss-framework/src/terrain-game.ts b/examples/abyss-framework/src/terrain-game.ts new file mode 100644 index 0000000000..efde51b310 --- /dev/null +++ b/examples/abyss-framework/src/terrain-game.ts @@ -0,0 +1,28 @@ +import { defineGame } from "@threenative/core"; +import { playtest } from "@threenative/core/playtest"; +import { type IPhysicsContext, rapier } from "@threenative/physics"; +import { TerrainProbe, type TerrainState } from "./scenes/TerrainProbe.js"; + +const game = defineGame({ + assets: { + basePath: "/terrain-assets", + model: async () => ({}), + }, + camera: { far: 1_000, near: 1, projection: "orthogonal", size: 150 }, + input: { + move: { + down: ["ArrowDown"], + left: ["ArrowLeft"], + right: ["ArrowRight"], + up: ["ArrowUp"], + }, + }, + initialState: { chunks: 0, playerX: 0 }, + plugins: [rapier(), playtest()], + renderer: { preferWebGPU: true }, + scenes: { terrain: TerrainProbe }, + seed: 20260808, + start: "terrain", +}); + +export default game; diff --git a/examples/abyss-framework/src/terrain-main.tsx b/examples/abyss-framework/src/terrain-main.tsx index 580cfa4236..de1aa7c8a3 100644 --- a/examples/abyss-framework/src/terrain-main.tsx +++ b/examples/abyss-framework/src/terrain-main.tsx @@ -1,34 +1,8 @@ -import { defineGame } from "@threenative/core"; -import { playtest } from "@threenative/core/playtest"; -import { type IPhysicsContext, rapier } from "@threenative/physics"; import "./style.css"; import { createRoot } from "react-dom/client"; -import { TerrainProbe, type TerrainState } from "./scenes/TerrainProbe.js"; +import game from "./terrain-game.js"; import { TerrainApp } from "./ui/TerrainApp.js"; -const game = defineGame({ - assets: { - basePath: "/terrain-assets", - model: async () => ({}), - }, - camera: { far: 1_000, near: 1, projection: "orthogonal", size: 150 }, - inputTarget: window, - input: { - move: { - down: ["ArrowDown"], - left: ["ArrowLeft"], - right: ["ArrowRight"], - up: ["ArrowUp"], - }, - }, - initialState: { chunks: 0, playerX: 0 }, - plugins: [rapier(), playtest()], - renderer: { preferWebGPU: true }, - scenes: { terrain: TerrainProbe }, - seed: 20260808, - start: "terrain", -}); - const root = document.getElementById("root"); if (root === null) throw new Error("Missing #root element."); const appRoot = root as typeof root & { __threenativeRoot?: ReturnType }; diff --git a/examples/abyss-framework/src/world/terrainSampler.ts b/examples/abyss-framework/src/world/terrainSampler.ts index 3416c4a05e..9fa61c441f 100644 --- a/examples/abyss-framework/src/world/terrainSampler.ts +++ b/examples/abyss-framework/src/world/terrainSampler.ts @@ -30,18 +30,27 @@ export function sampleTerrainHeight(seed: number, x: number, z: number): number throw new Error("Terrain sampler inputs must be finite."); if (Math.abs(x) > WORLD_HALF_EXTENT || Math.abs(z) > WORLD_HALF_EXTENT) throw new Error(`Terrain sample (${String(x)}, ${String(z)}) is outside the proof region.`); - const warpX = valueNoise(seed + 17, x / 180, z / 180) * 42; - const warpZ = valueNoise(seed + 31, x / 180, z / 180) * 38; - const basin = -0.00018 * (x * x + z * z); - const ridge = Math.max(0, Math.abs(valueNoise(seed + 401, x / 34, z / 34)) - 0.58); + // A converging catchment keeps tributaries connected instead of draining off every edge. + const absoluteX = Math.abs(x); + const absoluteZ = Math.abs(z); + const warpX = valueNoise(seed + 17, absoluteX / 180, absoluteZ / 180) * 42; + const warpZ = valueNoise(seed + 31, absoluteX / 180, absoluteZ / 180) * 38; + const basin = + 0.8 * (absoluteX + absoluteZ) + 0.0001 * (absoluteX * absoluteX + absoluteZ * absoluteZ); + const ridge = Math.max( + 0, + Math.abs(valueNoise(seed + 401, absoluteX / 34, absoluteZ / 34)) - 0.58, + ); return ( basin + - valueNoise(seed, (x + warpX) / 520, (z + warpZ) / 520) * 52 + - valueNoise(seed + 101, (x + warpX) / 220, (z + warpZ) / 220) * 18 + - valueNoise(seed + 211, (x + warpX) / 82, (z + warpZ) / 82) * 7 + - ridge * ridge * 700 + valueNoise(seed + 601, absoluteX / 8, absoluteZ / 8) * 2 + + valueNoise(seed, (absoluteX + warpX) / 520, (absoluteZ + warpZ) / 520) * 52 + + valueNoise(seed + 101, (absoluteX + warpX) / 220, (absoluteZ + warpZ) / 220) * 18 + + valueNoise(seed + 211, (absoluteX + warpX) / 82, (absoluteZ + warpZ) / 82) * 20 + + ridge * ridge * 70 ); } +// Mirror the catchment across its diagonal so neither horizontal direction dominates. export const terrainHeight = (x: number, z: number): number => - sampleTerrainHeight(TERRAIN_SEED, x, z); + (sampleTerrainHeight(TERRAIN_SEED, x, z) + sampleTerrainHeight(TERRAIN_SEED, z, x)) * 0.2; diff --git a/examples/native-smoke/assets.manifest.json b/examples/native-smoke/assets.manifest.json new file mode 100644 index 0000000000..54c91ef848 --- /dev/null +++ b/examples/native-smoke/assets.manifest.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "entries": {} +} diff --git a/packages/core/__tests__/loop.spec.ts b/packages/core/__tests__/loop.spec.ts index 04c3603692..6b7ab8d452 100644 --- a/packages/core/__tests__/loop.spec.ts +++ b/packages/core/__tests__/loop.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { retainTerrainBlendFrame } from "../../../examples/abyss-framework/src/scenes/TerrainProbe.js"; import { FRAME_BUDGET_MARKER, FrameBudget, type IFrameBudgetWindow } from "../src/frame-budget.js"; import { FixedStepLoop, @@ -622,3 +623,23 @@ describe("after-physics phase dispatch", () => { expect(seen).toEqual(["after"]); }); }); + +describe("terrain draw interval retention", () => { + it("closes the preceding draw with its own blend count, not the next draw's count", () => { + const entries: { frame: number; startedAt: number; blendingTiles: number; frameMs?: number }[] = + []; + retainTerrainBlendFrame(entries, 100, 5); + expect(entries).toEqual([{ frame: 0, startedAt: 100, blendingTiles: 5 }]); + retainTerrainBlendFrame(entries, 170, 0); + retainTerrainBlendFrame(entries, 180, 2); + expect(entries).toEqual([ + { frame: 0, startedAt: 100, blendingTiles: 5, frameMs: 70 }, + { frame: 1, startedAt: 170, blendingTiles: 0, frameMs: 10 }, + { frame: 2, startedAt: 180, blendingTiles: 2 }, + ]); + for (let i = 3; i <= 1025; i++) retainTerrainBlendFrame(entries, 180 + i, i % 2); + expect(entries).toHaveLength(1024); + expect(entries[0]?.frame).toBe(2); + expect(entries.at(-1)?.frame).toBe(1025); + }); +}); diff --git a/packages/core/__tests__/packaging.spec.ts b/packages/core/__tests__/packaging.spec.ts index 10c2a67ab9..627d006254 100644 --- a/packages/core/__tests__/packaging.spec.ts +++ b/packages/core/__tests__/packaging.spec.ts @@ -315,6 +315,14 @@ test("should apply the packed Three.js patch in a clean consumer and remain idem expect(backend).toContain("data.bindGroupLayoutKeys !== bindGroupLayoutKeys"); expect(backend).toContain("data.bindGroupLayoutKeys = bindGroupLayoutKeys"); expect(backend).toContain("bindGroupLayoutKeys,"); + expect(backend).toContain("renderObject.getGeometryCacheKey( false )"); + } + + for (const file of ["src/renderers/common/RenderObject.js", "build/three.webgpu.js"]) { + expect(patched.get(file)).toContain("getGeometryCacheKey( useMorphTargetIds = true )"); + expect(patched.get(file)).toContain("targets.length + ',' + geometry.morphTargetsRelative"); + expect(patched.get(file)).toContain("useMorphTargetIds ? attribute.id"); + expect(patched.get(file)).toContain("attribute.count + ',' + attribute.itemSize"); } await run(process.execPath, ["./scripts/postinstall.mjs"], { diff --git a/packages/core/__tests__/three-pipeline-scopes.spec.ts b/packages/core/__tests__/three-pipeline-scopes.spec.ts index 40360829ed..b87d1c11e9 100644 --- a/packages/core/__tests__/three-pipeline-scopes.spec.ts +++ b/packages/core/__tests__/three-pipeline-scopes.spec.ts @@ -1,5 +1,16 @@ import { setImmediate } from "node:timers/promises"; -import { BufferGeometry, Mesh, MeshBasicMaterial, NoBlending } from "three"; +import { + BufferGeometry, + Float32BufferAttribute, + InterleavedBuffer, + InterleavedBufferAttribute, + Mesh, + MeshBasicMaterial, + NoBlending, +} from "three"; +// @ts-expect-error Three's private render-object module has no public declaration. +import RenderObject from "three/src/renderers/common/RenderObject.js"; +import WebGPUBackend from "three/src/renderers/webgpu/WebGPUBackend.js"; // @ts-expect-error Three's private pipeline utility has no public declaration. import WebGPUPipelineUtils from "three/src/renderers/webgpu/utils/WebGPUPipelineUtils.js"; import { WebGPURenderer } from "three/webgpu"; @@ -211,3 +222,84 @@ describe.each(["source", "distribution"])("%s async render pipeline validation s expect(state.shaderDiagnostics).toHaveBeenCalledOnce(); }); }); + +describe.each(["source", "distribution"])("%s morph pipeline cache layout", (mode) => { + it("shares equal layouts without sharing geometry identity and separates layout changes", () => { + const backend = + mode === "source" + ? new WebGPUBackend() + : new WebGPURenderer({ canvas: { width: 1, height: 1 } as HTMLCanvasElement }).backend; + Reflect.set(backend, "utils", { + getSampleCountRenderContext: () => 1, + getCurrentColorSpace: () => "srgb", + getCurrentColorFormat: () => "bgra8unorm", + getCurrentDepthStencilFormat: () => "depth24plus", + getPrimitiveTopology: () => "triangle-list", + }); + const make = () => { + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 0, 1], 3)); + geometry.morphAttributes.position = [new Float32BufferAttribute(new Float32Array(9), 3)]; + geometry.morphTargetsRelative = true; + const object = new Mesh(geometry, new MeshBasicMaterial()); + return { + object, + material: object.material, + geometry, + context: {}, + getBindings: () => [], + getGeometryCacheKey: RenderObject.prototype.getGeometryCacheKey, + }; + }; + const first = make(); + const second = make(); + const key = (object: ReturnType) => + Reflect.apply(Reflect.get(backend, "getRenderCacheKey"), backend, [object]) as string; + expect(first.getGeometryCacheKey()).not.toBe(second.getGeometryCacheKey()); + expect(key(first)).toBe(key(second)); + second.geometry.morphAttributes.position?.push( + new Float32BufferAttribute(new Float32Array(9), 3), + ); + expect(key(first)).not.toBe(key(second)); + second.geometry.morphAttributes.position?.pop(); + second.geometry.morphTargetsRelative = false; + expect(key(first)).not.toBe(key(second)); + second.geometry.morphTargetsRelative = true; + second.geometry.morphAttributes.position = [ + new Float32BufferAttribute(new Float32Array(12), 4), + ]; + expect(key(first)).not.toBe(key(second)); + second.geometry.morphAttributes.position = [ + new Float32BufferAttribute(new Float32Array(12), 3), + ]; + expect(key(first)).not.toBe(key(second)); + second.geometry.morphAttributes = { normal: first.geometry.morphAttributes.position }; + expect(key(first)).not.toBe(key(second)); + second.geometry.morphAttributes = first.geometry.morphAttributes; + second.geometry.setAttribute("position", new Float32BufferAttribute(new Float32Array(12), 4)); + expect(key(first)).not.toBe(key(second)); + second.geometry.setAttribute("position", first.geometry.getAttribute("position")); + const interleaved = new InterleavedBufferAttribute( + new InterleavedBuffer(new Float32Array(12), 4), + 3, + 0, + ); + second.geometry.setAttribute("position", interleaved); + expect(key(first)).not.toBe(key(second)); + first.geometry.setAttribute("position", new InterleavedBufferAttribute(interleaved.data, 3, 1)); + expect(key(first)).not.toBe(key(second)); + first.geometry.setAttribute("position", interleaved); + expect(key(first)).toBe(key(second)); + second.geometry.setAttribute( + "position", + new InterleavedBufferAttribute(interleaved.data, 3, 0, true), + ); + expect(key(first)).not.toBe(key(second)); + second.geometry.setAttribute("position", interleaved); + second.geometry.setAttribute("normal", new Float32BufferAttribute(new Float32Array(9), 3)); + expect(key(first)).not.toBe(key(second)); + second.geometry.deleteAttribute("normal"); + second.geometry.setIndex([0, 1, 2]); + expect(key(first)).not.toBe(key(second)); + }); +}); diff --git a/packages/core/__tests__/world-cells.spec.ts b/packages/core/__tests__/world-cells.spec.ts index 9e7c2583bd..db591a1198 100644 --- a/packages/core/__tests__/world-cells.spec.ts +++ b/packages/core/__tests__/world-cells.spec.ts @@ -15,6 +15,7 @@ import { Mesh, MeshBasicMaterial, type Object3D, + PerspectiveCamera, Texture, } from "three"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -35,6 +36,7 @@ vi.mock("../src/assets.js", async (importOriginal) => { }); import { type IAssetLoader, createAssetLoader } from "../src/assets.js"; +import type { IRendererLike } from "../src/renderer.js"; import { type IWorldPackage, TerrainTiles, WorldCells } from "../src/world.js"; /** @@ -2512,6 +2514,92 @@ describe("WorldCells", () => { world.dispose(); }); + it("does not read a raster viewport for a world without terrain", async () => { + stubFixtureFetch(); + const world = await loadWorld({ + budgets: largeBudgets, + follow: followAt(0, 0), + loadModel: async () => new Group(), + ring: 0, + surface, + terrain: false, + gpuScene: false, + url: "/world/world.json", + }); + const renderer = { + info: { render: { drawCalls: 0 } }, + get domElement() { + throw new Error("No terrain viewport requested"); + }, + kind: "webgl", + raw: {}, + compileAsync: vi.fn(async () => undefined), + uploadAttributes: vi.fn(() => 1), + } as unknown as IRendererLike; + try { + expect(() => world.update(renderer, new PerspectiveCamera())).not.toThrow(); + } finally { + world.dispose(); + } + }); + + it("passes the resolved props pixel policy and current raster view to settled terrain", async () => { + stubFixtureFetch(); + const world = await loadWorld({ + budgets: largeBudgets, + follow: followAt(0, 0), + loadModel: async () => new Group(), + ring: 0, + surface, + terrain: { tileResolution: 9 }, + autoLod: { maxPixelError: 0.75 }, + gpuScene: false, + lodHysteresis: 0.2, + url: "/world/world.json", + }); + const renderer = { + info: { render: { drawCalls: 0 } }, + domElement: { height: 720 }, + kind: "webgl", + raw: {}, + compileAsync: vi.fn(async () => undefined), + uploadAttributes: vi.fn(() => 1), + } as unknown as IRendererLike; + const camera = new PerspectiveCamera(60, 1280 / 720, 0.1, 10_000); + camera.position.set(0, 0, 4000); + camera.lookAt(0, 0, 0); + const terrain = terrainOf(world); + const follow = vi.spyOn(terrain, "follow"); + const process = vi.spyOn(terrain, "process"); + try { + world.update(renderer, camera); + expect(follow.mock.calls[0]?.[2]).toEqual({ + camera, + viewportHeight: 720, + maxPixelError: 0.75, + hysteresis: 0.2, + }); + await flushed(world); + for (let frame = 0; frame < 4; frame++) world.update(renderer, camera); + follow.mockClear(); + process.mockClear(); + renderer.domElement.height = 1440; + camera.position.z = 2000; + world.update(renderer, camera); + expect(follow).not.toHaveBeenCalled(); + expect(process).toHaveBeenCalledOnce(); + expect(process.mock.calls[0]?.[3]).toEqual({ + camera, + viewportHeight: 1440, + maxPixelError: 0.75, + hysteresis: 0.2, + }); + expect(terrain.residentTileCount).toBe(1); + } finally { + world.dispose(); + } + }); + it("keeps terrain resident at its own radius while the cell ring stays at the ring", async () => { stubFixtureFetch(); const follow = followAt(0, 0); diff --git a/packages/core/__tests__/world-gpu-scene.spec.ts b/packages/core/__tests__/world-gpu-scene.spec.ts index 48163ed03f..006ae51785 100644 --- a/packages/core/__tests__/world-gpu-scene.spec.ts +++ b/packages/core/__tests__/world-gpu-scene.spec.ts @@ -1165,6 +1165,8 @@ describe("WorldCells with the GPU-driven main pass", () => { prefetchSeconds: 0, ring: 1, surface, + // This measures prop dispatch; keep terrain residency without rebuilding dense morph buffers. + terrain: { tileResolution: 9 }, url: "/world/world.json", }); const renderer = { @@ -1224,6 +1226,7 @@ describe("WorldCells with the GPU-driven main pass", () => { prefetchSeconds: 0, ring: 1, surface, + terrain: { tileResolution: 9 }, url: "/world/world.json", }); cpu.update(renderer, camera); diff --git a/packages/core/__tests__/world-streaming-recipe.spec.ts b/packages/core/__tests__/world-streaming-recipe.spec.ts index 3ff748441b..9ab1657dd1 100644 --- a/packages/core/__tests__/world-streaming-recipe.spec.ts +++ b/packages/core/__tests__/world-streaming-recipe.spec.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { MeshBasicMaterial } from "three"; +import { MeshBasicMaterial, PerspectiveCamera } from "three"; import { describe, expect, it } from "vitest"; import { TerrainTiles } from "../src/world.js"; @@ -100,6 +100,56 @@ describe("world-streaming view-distance recipe", () => { expect(recipe.residentCells).toBe((2 * recipe.ring + 1) ** 2); }); + it("keeps screen-error switches behind 180 m fog and the explicit distance floor at both raster sizes", () => { + const switches: number[][] = []; + for (const [width, height] of [ + [1280, 720], + [2560, 1440], + ] as const) { + const tiles = new TerrainTiles({ + surface: new MeshBasicMaterial(), + residentByteBudget: 2_000_000, + residentTileBudget: 1, + sampleHeight: (x) => Math.cos((Math.PI * x) / 128), + streamRadius: 0, + tileResolution: 9, + tileSize: 1024, + skirtDepth: 1, + lodDistances: [180, 360], + }); + const camera = new PerspectiveCamera(60, width / height, 0.1, 10000); + const changed: number[] = []; + let previous = 0; + try { + for (const x of [0, 179, 180, 359, 360, 480]) { + camera.position.set(0, 0, 1800 + 4 * x); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + tiles.follow({ x, z: 0 }, undefined, { + camera, + viewportHeight: height, + maxPixelError: 1, + hysteresis: 0.15, + }); + const level = tiles.getTile("0:0")?.lodLevel; + if (level === undefined) throw new Error("Expected the same resident recipe tile."); + if (level !== previous) { + expect(x).toBeGreaterThanOrEqual(180); + expect(x).toBeGreaterThanOrEqual([0, 180, 360][level] as number); + changed.push(x); + } + previous = level; + for (let frame = 0; frame < 3; frame += 1) tiles.process(); + } + expect(tiles.maxLodTransitionFrames).toBe(3); + switches.push(changed); + } finally { + tiles.dispose(); + } + } + expect(switches).toEqual([[180, 360], [480]]); + }); + it("control: fog far set against the ring corner (452 m) leaves the ring's side visible", () => { // The PRD's first draft: 420 m clears the 452 m corner, but a cell loads 256 m away. expect(() => check({ ...documented(), fogFar: 420 })).toThrow(/fog far/); diff --git a/packages/core/__tests__/world-terrain-jobs.spec.ts b/packages/core/__tests__/world-terrain-jobs.spec.ts index 6f7cf0e0d1..b13d501223 100644 --- a/packages/core/__tests__/world-terrain-jobs.spec.ts +++ b/packages/core/__tests__/world-terrain-jobs.spec.ts @@ -1,4 +1,4 @@ -import { type BufferGeometry, Mesh, MeshBasicMaterial } from "three"; +import { type BufferAttribute, type BufferGeometry, Mesh, MeshBasicMaterial, Vector3 } from "three"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SpanRecorder, setSpanRecorder } from "../src/profiling/Spans.js"; import { @@ -329,6 +329,119 @@ describe("terrain jobs", () => { } }); + it.each(["control", "held"])( + "keeps stale merge geometry out after captured members are evicted (%s)", + async (arm) => { + vi.stubGlobal("Worker", FakeWorker); + const tiles = terrain({ residentTileBudget: 9 }); + const worker = FakeWorker.latest(); + try { + tiles.follow(ISLAND); + const captured = worker.received.find(({ job }) => job.kind === "merge"); + if (captured === undefined || captured.job.kind !== "merge") + throw new Error("Expected an initial captured merge."); + const initialResidentKeys = [...tiles.residentKeys]; + const result = runTerrainJob(captured.job); + const release = async (): Promise => { + worker.received.splice(worker.received.indexOf(captured), 1); + worker.onmessage?.({ data: { id: captured.id, result } }); + await Promise.resolve(); + }; + const initialCensus = tiles.terrainTiles; + if (arm === "control") await release(); + const destination = { x: ISLAND.x + 8 * 16, z: ISLAND.z }; + tiles.follow(destination); + const currentResidentKeys = [...tiles.residentKeys]; + expect(currentResidentKeys.filter((key) => initialResidentKeys.includes(key))).toEqual([]); + expect(tiles.blendingTiles).toBe(0); + if (arm === "held") await release(); + const staleMesh = blockMeshes(tiles).find( + (mesh) => + firstDifference( + bytes(mesh.geometry, "position"), + new Uint8Array( + result.positions.buffer, + result.positions.byteOffset, + result.positions.byteLength, + ), + ) === -1, + ); + const visibleCurrentKeys = currentResidentKeys.filter((key) => + tiles.getTile(key)?.lod.levels.some(({ object }) => object.visible), + ); + const census = tiles.terrainTiles; + console.info("PRD566 evicted merge membership", { + arm, + capturedParts: captured.job.parts.length, + capturedVertices: result.positions.length / 3, + initialResidentKeys, + currentResidentKeys, + visibleCurrentKeys, + initialCensus, + census, + stalePayloadApplied: staleMesh !== undefined, + staleMeshName: staleMesh?.name, + residentTiles: tiles.residentTileCount, + blendingTiles: tiles.blendingTiles, + }); + expect(visibleCurrentKeys, "eligible current individual tiles remain visible").toEqual( + currentResidentKeys, + ); + expect.soft(staleMesh, "evicted merge payload cannot be resurrected").toBeUndefined(); + expect + .soft(census.tiles, "settled uniform-ring membership matches resident membership") + .toBe(tiles.residentTileCount); + // The existing settled-ring scheduler also proves current valid work is eventually applied. + for (let frame = 0; frame < 12; frame++) { + tiles.follow(destination); + await worker.answer(); + } + console.info("PRD566 current replacement settled", { arm, census: tiles.terrainTiles }); + expect(tiles.terrainTiles.blocks).toBeGreaterThan(0); + expect(tiles.terrainTiles.rebuilds).toBeGreaterThan(census.rebuilds); + expect(tiles.terrainTiles.tiles).toBe(tiles.residentTileCount); + } finally { + tiles.dispose(); + } + }, + ); + + it.each(["position", "normal", "index"] as const)( + "reconciles a merge superseded by its %s attribute identity or version", + async (attribute) => { + vi.stubGlobal("Worker", FakeWorker); + const tiles = terrain(); + try { + tiles.follow(ISLAND); + const worker = FakeWorker.latest(); + const captured = worker.received.find(({ job }) => job.kind === "merge"); + if (captured === undefined || captured.job.kind !== "merge") + throw new Error("Expected an initial captured merge."); + const part = captured.job.parts[0]; + if (part === undefined) throw new Error("Expected a captured tile part."); + const key = `${part.origin.x / tiles.tileSize}:${part.origin.z / tiles.tileSize}`; + const mesh = tiles.getTile(key)?.lod.levels.find(({ object }) => object.visible)?.object; + if (!(mesh instanceof Mesh)) throw new Error("Expected the captured resident mesh."); + const buffer = ( + attribute === "index" ? mesh.geometry.getIndex() : mesh.geometry.getAttribute(attribute) + ) as BufferAttribute | null; + if (buffer === null) throw new Error("Expected a captured input attribute."); + if (attribute === "position") mesh.geometry.setAttribute(attribute, buffer.clone()); + else buffer.needsUpdate = true; + worker.received.splice(worker.received.indexOf(captured), 1); + worker.onmessage?.({ data: { id: captured.id, result: runTerrainJob(captured.job) } }); + await Promise.resolve(); + expect(tiles.terrainTiles.blocks, "superseded input cannot create a block").toBe(0); + expect(tiles.terrainTiles.rebuilds, "completed stale work still costs one rebuild").toBe(1); + await settleWith(tiles); + expect(tiles.terrainTiles.blocks).toBeGreaterThan(0); + expect(tiles.terrainTiles.tiles).toBe(tiles.residentTileCount); + } finally { + tiles.dispose(); + } + }, + ); + it("writes no bridge while the seam job that builds its strips is owed", () => { vi.stubGlobal("Worker", FakeWorker); const tiles = terrain(TWO_TIERS); @@ -342,6 +455,177 @@ describe("terrain jobs", () => { } }); + it.each(["inline", "held-worker"])( + "keeps full-vertex morph parity and valid seam reconciliation with %s replies", + async (host) => { + vi.stubGlobal("Worker", host === "held-worker" ? FakeWorker : undefined); + const options = { + mergeTiles: false, + residentByteBudget: 2_000_000, + residentTileBudget: 9, + streamRadius: 1, + tileResolution: 17, + tileSize: 16, + lodDistances: [8, 16], + }; + const gpu = terrain({ ...options, validate: false }); + const gpuWorker = host === "held-worker" ? FakeWorker.latest() : undefined; + const cpu = terrain({ ...options, validate: true }); + const cpuWorker = host === "held-worker" ? FakeWorker.latest() : undefined; + const onUnhandled = vi.fn(); + process.on("unhandledRejection", onUnhandled); + try { + gpu.follow({ x: 0, z: 0 }); + cpu.follow({ x: 0, z: 0 }); + await gpuWorker?.answer(); + await cpuWorker?.answer(); + gpu.follow({ x: 12, z: 0 }); + cpu.follow({ x: 12, z: 0 }); + expect(gpu.getTile("0:-1")).toBeDefined(); + expect(cpu.getTile("0:-1")).toBeDefined(); + for (let frame = 1; frame <= 3; frame++) { + gpu.process(); + cpu.process(); + const rendered = gpu + .getTile("0:0") + ?.lod.levels.find(({ object }) => object.visible)?.object; + const reference = cpu + .getTile("0:0") + ?.lod.levels.find(({ object }) => object.visible)?.object; + if (!(rendered instanceof Mesh) || !(reference instanceof Mesh)) + throw new Error("Expected matching visible transition meshes."); + const position = reference.geometry.getAttribute("position"); + const normals = reference.geometry.getAttribute("normal"); + const actual = new Vector3(); + const expected = new Vector3(); + const normal = new Vector3(); + const delta = new Vector3(); + let maxPositionError = 0; + let maxNormalError = 0; + let edgeError = 0; + let interiorError = 0; + expect(rendered.geometry.getAttribute("position").count).toBe(position.count); + for (let vertex = 0; vertex < position.count; vertex++) { + rendered.getVertexPosition(vertex, actual); + const error = actual.distanceTo(expected.fromBufferAttribute(position, vertex)); + maxPositionError = Math.max(maxPositionError, error); + if (vertex === 1) edgeError = error; + if (vertex === 4 * 17 + 5) interiorError = error; + normal.fromBufferAttribute(rendered.geometry.getAttribute("normal"), vertex); + for (const [target, weight] of (rendered.morphTargetInfluences ?? []).entries()) { + const attribute = rendered.geometry.morphAttributes.normal?.[target]; + if (attribute !== undefined) + normal.addScaledVector(delta.fromBufferAttribute(attribute, vertex), weight); + } + maxNormalError = Math.max( + maxNormalError, + normal + .normalize() + .distanceTo(expected.fromBufferAttribute(normals, vertex).normalize()), + ); + } + console.info("PRD566 held seam parity", { + host, + frame, + edgeError, + interiorError, + maxPositionError, + maxNormalError, + gpuBaseEdgeY: rendered.geometry.getAttribute("position").getY(1), + gpuMorphedEdgeY: rendered.getVertexPosition(1, actual).y, + cpuEdgeY: position.getY(1), + gpuEdgeMorphDeltas: rendered.geometry.morphAttributes.position?.map( + (attribute: ReturnType) => attribute.getY(1), + ), + influences: rendered.morphTargetInfluences, + gpuPendingJobs: gpuWorker?.received.map(({ job }) => job.kind), + cpuPendingJobs: cpuWorker?.received.map(({ job }) => job.kind), + gpuDecisions: gpu.debug().lodDecisions, + cpuDecisions: cpu.debug().lodDecisions, + }); + expect + .soft(maxPositionError, `${host} frame ${frame}: every position`) + .toBeLessThan(0.001); + expect.soft(maxNormalError, `${host} frame ${frame}: every normal`).toBeLessThan(0.001); + if (frame === 2) { + if (host === "held-worker") { + expect(gpuWorker?.has("seam")).toBe(true); + expect(cpuWorker?.has("seam")).toBe(true); + } + await expect(Promise.all([gpuWorker?.answer(), cpuWorker?.answer()])).resolves.toEqual([ + undefined, + undefined, + ]); + await new Promise((resolve) => setImmediate(resolve)); + expect + .soft(onUnhandled, "valid asynchronous seam reconciliation") + .not.toHaveBeenCalled(); + } + } + if (host === "held-worker") { + // The old topology reply was invalidated, and the current topology still owes reconciliation. + expect(gpuWorker?.has("seam")).toBe(true); + expect(cpuWorker?.has("seam")).toBe(true); + await gpuWorker?.answer(); + await cpuWorker?.answer(); + // Completing frame three changed the rendered level after frame two's replacement request. + expect(gpuWorker?.has("seam")).toBe(true); + expect(cpuWorker?.has("seam")).toBe(true); + await gpuWorker?.answer(); + await cpuWorker?.answer(); + await new Promise((resolve) => setImmediate(resolve)); + expect(onUnhandled).not.toHaveBeenCalled(); + expect(gpuWorker?.has("seam")).toBe(false); + expect(cpuWorker?.has("seam")).toBe(false); + } + expect(gpu.lodTransitions).toBeGreaterThan(0); + expect(cpu.lodTransitions).toBe(gpu.lodTransitions); + expect(() => cpu.process()).not.toThrow(); + } finally { + process.off("unhandledRejection", onUnhandled); + gpu.dispose(); + cpu.dispose(); + } + }, + ); + + it.each(["index", "count"])( + "fails closed on malformed seam %s for the current topology", + async (fault) => { + vi.stubGlobal("Worker", FakeWorker); + const tiles = terrain({ ...TWO_TIERS, mergeTiles: false, validate: true }); + const onUnhandled = vi.fn(); + process.on("unhandledRejection", onUnhandled); + try { + tiles.follow(ISLAND); + const worker = FakeWorker.latest(); + const request = worker.received.splice(0).find(({ job }) => job.kind === "seam"); + if (request === undefined || request.job.kind !== "seam") + throw new Error("Expected a current seam request."); + const result = runTerrainJob(request.job); + const bridge = result.bridges[0]; + if (bridge === undefined) throw new Error("Expected a bridge to corrupt."); + bridge.indices[0] = bridge.positions.length; + worker.onmessage?.({ + data: { id: request.id, result: fault === "count" ? { ...result, bridges: [] } : result }, + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(onUnhandled).toHaveBeenCalledOnce(); + expect(onUnhandled.mock.calls[0]?.[0]).toEqual( + new Error( + fault === "count" + ? "TerrainTiles seam job returned an invalid bridge count." + : "TerrainTiles seam diagnostic bridge topology has invalid index data.", + ), + ); + expect(worker.has("seam")).toBe(false); + } finally { + process.off("unhandledRejection", onUnhandled); + tiles.dispose(); + } + }, + ); + it("settles the block bytes the inline path settles, from the worker's own result", async () => { const inline = inlineSettlement(); expect(inline.blocks.length).toBeGreaterThan(0); diff --git a/packages/core/__tests__/world-terrain-tiles.spec.ts b/packages/core/__tests__/world-terrain-tiles.spec.ts index bb36e974d0..7b5d33f61d 100644 --- a/packages/core/__tests__/world-terrain-tiles.spec.ts +++ b/packages/core/__tests__/world-terrain-tiles.spec.ts @@ -1,7 +1,21 @@ -import { BufferAttribute, BufferGeometry, Mesh, MeshBasicMaterial, Vector3 } from "three"; +import { + BufferAttribute, + BufferGeometry, + Mesh, + MeshBasicMaterial, + OrthographicCamera, + PerspectiveCamera, + Vector3, +} from "three"; import { describe, expect, it, vi } from "vitest"; +import { terrainHeight } from "../../../examples/abyss-framework/src/world/terrainSampler.js"; import { createAssetLoader } from "../src/assets.js"; +import { + DISCRETE_LOD_DEFAULT_ERROR_PIXELS, + DISCRETE_LOD_DEFAULT_HYSTERESIS, +} from "../src/model-lod.js"; import { type IWorldTile, type IWorldTileCollider, TerrainTiles } from "../src/world-tiles.js"; +import { summarizeWorldTopology } from "../src/world-topology.js"; import { Heightfield } from "../src/world.js"; const sampleHeight = (x: number, z: number): number => @@ -183,6 +197,33 @@ function measured(value: number | undefined): number { } describe("TerrainTiles", () => { + it.each([false, true])( + "reports JSON-safe terrain debug with validation %s without inventing diagnostics", + (validate) => { + const tiles = new TerrainTiles({ + surface: new MeshBasicMaterial(), + residentByteBudget: 1_000_000, + residentTileBudget: 1, + sampleHeight, + streamRadius: 0, + tileResolution: 9, + tileSize: 64, + validate, + }); + try { + tiles.follow({ x: 0, z: 0 }); + const debug = tiles.debug(); + expect(debug).toEqual(JSON.parse(JSON.stringify(debug))); + for (const key of ["maxSeamGap", "maxVisualSeamGap", "maxLodPop"]) { + expect(Object.hasOwn(debug, key)).toBe(validate); + if (validate) expect(Number.isFinite(debug[key])).toBe(true); + } + } finally { + tiles.dispose(); + } + }, + ); + it("counts retained topology storage against the hard byte cap", () => { expect( () => @@ -227,6 +268,132 @@ describe("TerrainTiles", () => { tiles.dispose(); }); + it.each([ + [9, 51_080], + [65, 1_869_960], + ])( + "charges every morph delta plus Three's packed CPU and GPU textures before admitting a %i grid", + (tileResolution, bytes) => { + const options = { + surface: new MeshBasicMaterial(), + sampleHeight, + residentTileBudget: 1, + streamRadius: 0, + tileResolution, + tileSize: 16, + validate: false, + }; + const tiles = new TerrainTiles({ ...options, residentByteBudget: bytes }); + tiles.follow({ x: 0, z: 0 }); + const tile = tiles.getTile("0:0"); + if (tile === undefined) throw new Error("Expected the admitted morph tile."); + expect(tile.bytes).toBe(bytes); + expect(tiles.residentBytes).toBe(bytes); + expect( + tile.lod.levels.map(({ object }) => { + if (!(object instanceof Mesh)) throw new Error("Expected an LOD mesh."); + return object.geometry.morphAttributes.position?.length ?? 0; + }), + ).toEqual([2, 1, 0]); + const disposed = tile.lod.levels.map(({ object }) => { + if (!(object instanceof Mesh)) throw new Error("Expected an LOD mesh."); + return vi.spyOn(object.geometry, "dispose"); + }); + tiles.dispose(); + for (const dispose of disposed) expect(dispose).toHaveBeenCalledOnce(); + const tooSmall = new TerrainTiles({ ...options, residentByteBudget: bytes - 1 }); + try { + expect(() => tooSmall.follow({ x: 0, z: 0 })).toThrow(/residentByteBudget/u); + expect(tooSmall.residentTileCount).toBe(0); + } finally { + tooSmall.dispose(); + options.surface.dispose(); + } + }, + ); + + it("reserves enough morph storage for stock WebGL packing at an 8192 texture limit", () => { + const options = { + surface: new MeshBasicMaterial(), + sampleHeight, + residentTileBudget: 1, + residentByteBudget: 2_000_000, + streamRadius: 0, + tileResolution: 65, + tileSize: 64, + lodFactors: [1, 2], + lodDistances: [8], + }; + const gpu = new TerrainTiles({ ...options, validate: false }); + const cpu = new TerrainTiles({ ...options, validate: true }); + try { + gpu.follow({ x: 0, z: 0 }); + cpu.follow({ x: 0, z: 0 }); + const mesh = gpu.getTile("0:0")?.lod.levels[0]?.object; + if (!(mesh instanceof Mesh)) throw new Error("Expected the finest LOD mesh."); + const positions = mesh.geometry.morphAttributes.position?.[0]; + const normals = mesh.geometry.morphAttributes.normal?.[0]; + if (positions === undefined || normals === undefined) + throw new Error("Expected both morph deltas."); + // Stock WebGLMorphtargets packs 8,970 texels into two 8,192-texel RGBA32F rows. + const textureBytes = 8192 * 2 * 4 * Float32Array.BYTES_PER_ELEMENT; + expect(gpu.residentBytes - cpu.residentBytes).toBeGreaterThanOrEqual( + positions.array.byteLength + normals.array.byteLength + textureBytes * 2, + ); + } finally { + gpu.dispose(); + cpu.dispose(); + options.surface.dispose(); + } + }); + + it.each([[[1]], [[1, 1]], [[1, 2, 2]], [[2, 1, 2]]])( + "admits exactly the charged bytes for effective LOD factors %j", + (lodFactors) => { + const options = { + surface: new MeshBasicMaterial(), + sampleHeight: vi.fn(sampleHeight), + residentTileBudget: 1, + streamRadius: 0, + tileResolution: 9, + tileSize: 16, + lodFactors, + lodDistances: lodFactors.slice(1).map((_, index) => 8 * (index + 1)), + validate: false, + }; + const probe = new TerrainTiles({ ...options, residentByteBudget: 1_000_000 }); + probe.follow({ x: 0, z: 0 }); + const bytes = probe.residentBytes; + if (lodFactors.length === 1) expect(bytes).toBe(5_904); + const tile = probe.getTile("0:0"); + if (tile === undefined) throw new Error("Expected the probe tile."); + expect( + tile.lod.levels.map(({ object }) => { + if (!(object instanceof Mesh)) throw new Error("Expected an LOD mesh."); + return object.geometry.morphAttributes.position?.length ?? 0; + }), + ).toEqual( + lodFactors.map( + (factor) => lodFactors.filter((candidate) => candidate >= factor).length - 1, + ), + ); + probe.dispose(); + const fitted = new TerrainTiles({ ...options, residentByteBudget: bytes }); + const tooSmall = new TerrainTiles({ ...options, residentByteBudget: bytes - 1 }); + try { + fitted.follow({ x: 0, z: 0 }); + expect(fitted.residentBytes).toBe(bytes); + options.sampleHeight.mockClear(); + expect(() => tooSmall.follow({ x: 0, z: 0 })).toThrow(/residentByteBudget/u); + expect(options.sampleHeight).not.toHaveBeenCalled(); + } finally { + fitted.dispose(); + tooSmall.dispose(); + options.surface.dispose(); + } + }, + ); + it("rejects a tile when retained edge samples make it exceed the byte cap", () => { const tiles = new TerrainTiles({ validate: true, @@ -394,6 +561,382 @@ describe("TerrainTiles", () => { } }); + it.each([1, 2, 9])( + "renders GPU morphs with %i resident tiles without CPU attribute writes and matches the validating CPU blend", + (residentTileBudget) => { + const surface = new MeshBasicMaterial(); + const options = { + surface, + mergeTiles: false, + residentByteBudget: 2_000_000, + residentTileBudget, + sampleHeight, + streamRadius: residentTileBudget === 1 ? 0 : 1, + tileResolution: 17, + tileSize: 64, + lodDistances: [8, 16], + }; + const gpu = new TerrainTiles({ ...options, validate: false }); + const cpu = new TerrainTiles({ ...options, validate: true }); + try { + gpu.follow({ x: 0, z: 0 }); + cpu.follow({ x: 0, z: 0 }); + const tile = gpu.getTile("0:0"); + if (tile === undefined) throw new Error("Expected the resident GPU tile."); + const versions = tile.lod.levels.map(({ object }) => { + if (!(object instanceof Mesh)) throw new Error("Expected an LOD mesh."); + return [ + object.geometry.getAttribute("position").version, + object.geometry.getAttribute("normal").version, + ]; + }); + const finest = tile.lod.levels[0]?.object; + if (!(finest instanceof Mesh)) throw new Error("Expected the finest LOD mesh."); + const originalGeometry = finest.geometry; + const originalPosition = originalGeometry.getAttribute("position"); + const originalNormal = originalGeometry.getAttribute("normal"); + const disposed = vi.spyOn(originalGeometry, "dispose"); + // Include skipped levels, refinement and an interrupted transition. + for (const [x, frames] of [ + [9, 3], + [0, 3], + [20, 1], + [0, 3], + ] as const) { + gpu.follow({ x, z: 0 }); + cpu.follow({ x, z: 0 }); + for (let frame = 0; frame <= frames; frame += 1) { + if (frame > 0) { + gpu.process(); + cpu.process(); + } + const rendered = tile.lod.levels.find(({ object }) => object.visible)?.object; + const reference = cpu + .getTile("0:0") + ?.lod.levels.find(({ object }) => object.visible)?.object; + if (!(rendered instanceof Mesh) || !(reference instanceof Mesh)) + throw new Error("Expected matching visible meshes."); + expect(rendered.material).toBe(surface); + expect(rendered.geometry.morphTargetsRelative).toBe(true); + for ( + let vertex = 0; + vertex < rendered.geometry.getAttribute("position").count; + vertex += 1 + ) { + const actual = new Vector3(); + rendered.getVertexPosition(vertex, actual); + const expected = new Vector3().fromBufferAttribute( + reference.geometry.getAttribute("position"), + vertex, + ); + expect(actual.distanceTo(expected)).toBeLessThan(0.001); + const normal = new Vector3().fromBufferAttribute( + rendered.geometry.getAttribute("normal"), + vertex, + ); + for (const [target, weight] of (rendered.morphTargetInfluences ?? []).entries()) { + const delta = rendered.geometry.morphAttributes.normal?.[target]; + if (delta !== undefined) + normal.addScaledVector(new Vector3().fromBufferAttribute(delta, vertex), weight); + } + normal.normalize(); + expect( + normal.distanceTo( + new Vector3().fromBufferAttribute( + reference.geometry.getAttribute("normal"), + vertex, + ), + ), + ).toBeLessThan(0.001); + } + expect( + tile.lod.levels.map(({ object }) => { + if (!(object instanceof Mesh)) throw new Error("Expected an LOD mesh."); + return [ + object.geometry.getAttribute("position").version, + object.geometry.getAttribute("normal").version, + ]; + }), + ).toEqual(versions); + } + } + expect(finest.geometry.getAttribute("position")).toBe(originalPosition); + expect(finest.geometry.getAttribute("normal")).toBe(originalNormal); + if (residentTileBudget === 2) expect(disposed).toHaveBeenCalledOnce(); + else expect(disposed).not.toHaveBeenCalled(); + expect(gpu.maxLodTransitionFrames).toBe(3); + expect(gpu.blendingTiles).toBe(0); + } finally { + gpu.dispose(); + cpu.dispose(); + surface.dispose(); + } + }, + ); + + it("keeps the default distance floor, preserves no-view calls and reuses retained errors across residency", () => { + const sampleHeight = vi.fn((x: number) => Math.cos((Math.PI * x) / 8)); + const tiles = new TerrainTiles({ + surface: new MeshBasicMaterial(), + residentByteBudget: 4_000_000, + residentTileBudget: 49, + mergeTiles: false, + sampleHeight, + streamRadius: 3, + tileResolution: 9, + tileSize: 64, + skirtDepth: 1, + }); + const camera = new PerspectiveCamera(60, 1280 / 720, 0.1, 10_000); + camera.position.set(0, 0, 4000); + camera.lookAt(0, 0, 0); + const view = { camera, viewportHeight: 720, maxPixelError: 1, hysteresis: 0.15 }; + try { + tiles.follow({ x: 127, z: 0 }, undefined, view); + expect(tiles.getTile("0:0")?.lodLevel).toBe(0); + tiles.follow({ x: 129, z: 0 }, undefined, view); + expect(tiles.getTile("0:0")?.lodLevel).toBe(1); + const sampled = sampleHeight.mock.calls.length; + const tileBytes = tiles.residentKeys.reduce( + (sum, key) => sum + (tiles.getTile(key)?.bytes ?? 0), + 0, + ); + for (let frame = 0; frame < 6; frame++) tiles.process(); + expect(sampleHeight).toHaveBeenCalledTimes(sampled); + expect(tiles.residentBytes).toBeGreaterThanOrEqual(tileBytes); + expect(tiles.residentBytes).toBeLessThanOrEqual(tiles.residentByteBudget); + camera.position.z = 100; + tiles.process(); + expect(tiles.getTile("0:0")?.lodLevel).toBe(0); + tiles.follow({ x: 129, z: 0 }); + expect(tiles.getTile("0:0")?.lodLevel).toBe(1); + tiles.follow({ x: 0, z: 0 }, undefined, view); + expect(tiles.residentTileCount).toBe(49); + expect(tiles.getTile("0:0")?.lodLevel).toBe(0); + tiles.follow({ x: 640, z: 0 }, undefined, view); + expect(tiles.getTile("0:0")).toBeUndefined(); + expect(tiles.residentTileCount).toBe(49); + expect(tiles.residentBytes).toBeGreaterThanOrEqual(tileBytes); + expect(tiles.residentBytes).toBeLessThanOrEqual(tiles.residentByteBudget); + } finally { + tiles.dispose(); + } + }); + + it("projects retained metre errors through the installed selector rather than treating metres as pixels", () => { + const tiles = new TerrainTiles({ + surface: new MeshBasicMaterial(), + sampleHeight: (x) => 0.01 * x * x, + residentByteBudget: 20_000_000, + residentTileBudget: 9, + tileResolution: 65, + tileSize: 64, + streamRadius: 1, + lodDistances: [48, 96], + mergeTiles: false, + }); + const camera = new OrthographicCamera( + (-150 * 1280) / 720, + (150 * 1280) / 720, + 150, + -150, + 1, + 1000, + ); + camera.position.set(64, 180, 180); + camera.lookAt(64, 0, 0); + const view = { camera, viewportHeight: 720, maxPixelError: 1, hysteresis: 0.15 }; + const decision = () => + ( + tiles.debug().lodDecisions as { + key: string; + lodLevel: number; + targetLod: number; + nextCoarser: { surfaceError: number; errorPixels: number }; + }[] + ).find((tile) => tile.key === "0:0"); + try { + tiles.follow({ x: 64, z: 0 }, undefined, view); + expect(decision()).toMatchObject({ lodLevel: 1, targetLod: 1 }); + camera.zoom = 100; + camera.updateProjectionMatrix(); + tiles.process(undefined, camera, undefined, view); + expect(decision()).toMatchObject({ lodLevel: 0, targetLod: 0 }); + // A two-metre coarse cell interpolates x² at its one-metre midpoint: + // 0.01 * ((0² + 2²)/2 - 1²) = 0.01 m; 720/300 * zoom = 240 px/m. + expect(decision()?.nextCoarser.surfaceError).toBeCloseTo(0.01, 4); + expect(decision()?.nextCoarser.errorPixels).toBeCloseTo(2.4, 2); + } finally { + tiles.dispose(); + } + }); + + it("reports actual resident LOD decisions and world-metre sampler error at the probe projection", () => { + const sampled = vi.fn(terrainHeight); + const tiles = new TerrainTiles({ + surface: new MeshBasicMaterial(), + sampleHeight: sampled, + residentByteBudget: 20_000_000, + residentTileBudget: 9, + tileResolution: 65, + tileSize: 64, + streamRadius: 1, + lodDistances: [48, 96], + mergeTiles: false, + }); + const camera = new OrthographicCamera( + -(150 * 1280) / 720, + (150 * 1280) / 720, + 150, + -150, + 1, + 1000, + ); + camera.position.set(0, 180, 180); + camera.lookAt(0, 0, 0); + const view = { + camera, + viewportHeight: 720, + maxPixelError: DISCRETE_LOD_DEFAULT_ERROR_PIXELS, + hysteresis: DISCRETE_LOD_DEFAULT_HYSTERESIS, + }; + try { + tiles.follow({ x: 0, z: 0 }, undefined, view); + const calls = sampled.mock.calls.length; + const debug = tiles.debug(); + const decisions = debug.lodDecisions as { + key: string; + lodLevel: number; + distance: number; + distanceLod: number; + maxLodLevel: number; + targetLod: number; + nextCoarser?: { + lodLevel: number; + surfaceError: number; + errorPixels: number; + degenerate: boolean; + }; + }[]; + expect(decisions).toHaveLength(9); + expect(debug.lodPolicy).toEqual({ + lodDistances: [48, 96], + maxPixelError: 1, + hysteresis: 0.15, + coarsenBelowPixels: 0.85, + viewportHeight: 720, + }); + for (const decision of decisions) { + const tile = tiles.getTile(decision.key); + expect(tile).toBeDefined(); + expect(decision.lodLevel).toBe(tile?.lodLevel); + expect(decision.distanceLod).toBe( + decision.distance < 48 ? 0 : decision.distance < 96 ? 1 : 2, + ); + expect(decision.targetLod).toBeLessThanOrEqual( + Math.min(decision.distanceLod, decision.maxLodLevel), + ); + const next = decision.nextCoarser; + expect(next).toBeDefined(); + expect(next?.lodLevel).toBe(decision.lodLevel + 1); + expect(next?.surfaceError).toBeGreaterThan(0); + expect(next?.errorPixels).toBeCloseTo(((next?.surfaceError ?? 0) * 720) / 300, 10); + expect(next?.degenerate).toBe(false); + } + const center = tiles.getTile("0:0"); + expect(center?.field.heightAt(-31, -30)).toBeCloseTo(terrainHeight(-31, -30), 5); + expect(sampled).toHaveBeenCalledWith(-31, -30); + expect(tiles.getTile("1:0")?.field.heightAt(33, -30)).toBeCloseTo(terrainHeight(33, -30), 5); + expect(sampled).toHaveBeenCalledWith(33, -30); + expect(sampled).toHaveBeenCalledTimes(calls); // Diagnostics use retained errors, never resample. + tiles.follow({ x: 0, z: 0 }); + expect(tiles.debug().lodPolicy).toEqual({ lodDistances: [48, 96] }); + expect( + (tiles.debug().lodDecisions as { nextCoarser?: { errorPixels?: number } }[])[0] + ?.nextCoarser, + ).not.toHaveProperty("errorPixels"); + } finally { + tiles.dispose(); + } + }); + + it("owes a budgeted merge pass after a view-only LOD change", () => { + const tiles = new TerrainTiles({ + surface: new MeshBasicMaterial(), + residentByteBudget: 2_000_000, + residentTileBudget: 25, + sampleHeight: (x) => Math.cos((Math.PI * x) / 8), + streamRadius: 2, + tileResolution: 9, + tileSize: 64, + skirtDepth: 1, + }); + const camera = new PerspectiveCamera(60, 1280 / 720, 0.1, 10_000); + camera.position.set(0, 0, 4000); + camera.lookAt(0, 0, 0); + const view = { camera, viewportHeight: 720, maxPixelError: 1, hysteresis: 0.15 }; + try { + for (let frame = 0; frame < 12; frame++) { + tiles.follow({ x: 127, z: 0 }, undefined, view); + tiles.process(); + } + expect(tiles.deferredAdmissions).toBe(0); + expect(tiles.getTile("1:0")?.lodLevel).toBe(0); + expect(tiles.getTile("4:0")?.lodLevel).toBe(1); + camera.position.z = 100; + tiles.process(); + expect(tiles.deferredAdmissions).toBe(1); + for (let frame = 0; frame < 12; frame++) { + tiles.follow({ x: 127, z: 0 }, undefined, view); + tiles.process(); + } + expect(tiles.deferredAdmissions).toBe(0); + expect(tiles.blendingTiles).toBe(0); + } finally { + tiles.dispose(); + } + }); + + it("holds a finer terrain level at 1440 raster rows and refreshes a settled view", () => { + const tiles = new TerrainTiles({ + surface: new MeshBasicMaterial(), + residentByteBudget: 2_000_000, + residentTileBudget: 1, + sampleHeight: (x) => Math.cos((Math.PI * x) / 4), + streamRadius: 0, + tileResolution: 17, + tileSize: 64, + skirtDepth: 1, + lodDistances: [8, 16], + }); + const camera = new PerspectiveCamera(60, 1280 / 720, 0.1, 5000); + camera.position.set(0, 0, 2000); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + const view = { camera, viewportHeight: 720, maxPixelError: 1, hysteresis: 0.15 }; + try { + tiles.follow({ x: 7, z: 0 }, undefined, view); + expect(tiles.getTile("0:0")?.lodLevel).toBe(0); + tiles.follow({ x: 9, z: 0 }, undefined, view); + expect(tiles.getTile("0:0")?.lodLevel).toBe(1); + for (let frame = 0; frame < 3; frame += 1) tiles.process(); + const transitions = tiles.lodTransitions; + tiles.process(undefined, undefined, undefined, { ...view, viewportHeight: 1440 }); + expect(tiles.getTile("0:0")?.lodLevel).toBe(0); + expect(tiles.lodTransitions).toBe(transitions + 1); + for (let frame = 0; frame < 3; frame += 1) tiles.process(); + camera.position.z = 4000; + camera.updateMatrixWorld(true); + tiles.process(); + expect(tiles.getTile("0:0")?.lodLevel).toBe(1); + for (let frame = 0; frame < 3; frame += 1) tiles.process(); + expect(tiles.maxLodTransitionFrames).toBe(3); + expect(tiles.blendingTiles).toBe(0); + } finally { + tiles.dispose(); + } + }); + it("morphs one LOD surface within the measured pop bound for three frames", () => { const tiles = new TerrainTiles({ validate: true, @@ -1194,6 +1737,207 @@ describe("TerrainTiles", () => { tiles.dispose(); }); + it.each([false, true])( + "keeps the real 20 MB probe ring resident through a 330 m walk (validate=%s)", + (validate) => { + const surface = new MeshBasicMaterial(); + const tiles = new TerrainTiles({ + validate, + surface, + sampleHeight: terrainHeight, + residentByteBudget: 20_000_000, + residentTileBudget: 9, + tileResolution: 65, + tileSize: 64, + streamRadius: 1, + lodDistances: [48, 96], + topologyObservation: { + columns: 1025, + rows: 1025, + width: 1024, + depth: 1024, + origin: { x: 0, z: 0 }, + }, + worldPasses: { + dispatchBudget: 4, + gpu: false, + erosion: { + depositionRate: 0.35, + erosionRate: 0.22, + evaporation: 0.04, + iterations: 4, + rainfall: 0.08, + sedimentCapacity: 0.7, + timeStep: 0.05, + }, + }, + }); + const initial = tiles.debug(); + const camera = new OrthographicCamera( + -(150 * 1280) / 720, + (150 * 1280) / 720, + 150, + -150, + 1, + 1000, + ); + const view = { + camera, + viewportHeight: 720, + maxPixelError: DISCRETE_LOD_DEFAULT_ERROR_PIXELS, + hysteresis: DISCRETE_LOD_DEFAULT_HYSTERESIS, + }; + const observations: { x: number; bytes: number; keys: readonly string[] }[] = []; + try { + for (let x = 0; x <= 330; x++) { + camera.position.set(x, 180, 180); + camera.lookAt(x, 0, 0); + camera.updateMatrixWorld(true); + expect( + () => tiles.follow({ x, z: 0 }, undefined, view), + `probe walk x=${x}`, + ).not.toThrow(); + tiles.process(undefined, camera, undefined, view); + observations.push({ x, bytes: tiles.residentBytes, keys: tiles.residentKeys }); + } + expect(initial.peakResidentBytes).toBe(16_810_000); + expect(initial.topologyBytes).toBe(0); + for (const { x, bytes, keys } of observations) { + const tileX = Math.floor((x + 32) / 64); + const expected = [-1, 0, 1] + .flatMap((dx) => [-1, 0, 1].map((z) => `${tileX + dx}:${z}`)) + .sort(); + expect(keys, `ring at x=${x}`).toEqual(expected); + expect(bytes, `bytes at x=${x}`).toBeLessThanOrEqual(20_000_000); + } + expect(tiles.debug().peakResidentTiles).toBe(9); + console.info("probe walk residency", { + validate, + initialBytes: observations[0]?.bytes, + finalBytes: tiles.residentBytes, + peakBytes: tiles.debug().peakResidentBytes, + }); + } finally { + tiles.dispose(); + surface.dispose(); + } + }, + 60_000, + ); + + it.each([ + { columns: 17, rows: 9, width: 32, depth: 16, passes: true }, + { columns: 17, rows: 9, width: 32, depth: 16, passes: false }, + { columns: 1025, rows: 1025, width: 1024, depth: 1024, passes: true }, + { columns: 1025, rows: 1025, width: 1024, depth: 1024, passes: false }, + ])( + "caches unchanged topology diagnostics and releases sample buffers ($columns x $rows, passes=$passes)", + ({ passes, ...grid }) => { + const observation = { ...grid, origin: { x: 0, z: 0 } }; + const worldPasses = passes + ? { + dispatchBudget: 4, + gpu: false, + erosion: { + depositionRate: 0.35, + erosionRate: 0.22, + evaporation: 0.04, + iterations: 0, + rainfall: 0.08, + sedimentCapacity: 0.7, + timeStep: 0.05, + }, + } + : undefined; + const field = Heightfield.fromSampler({ + ...observation, + sampleHeight, + ...(worldPasses ? { worldPasses } : {}), + }); + const heights = field.heights; + const flow = field.flow; + const raw = heights.length <= 10_000; + const expected = { + ...observation, + ...(raw + ? { heights: Array.from(heights), ...(flow ? { flow: Array.from(flow) } : {}) } + : flow + ? { metrics: summarizeWorldTopology({ ...grid, heights, flow }) } + : {}), + }; + const constructionBytes = field.memoryBytes; + field.detach(); + const detach = vi.spyOn(Heightfield.prototype, "detach"); + const surface = new MeshBasicMaterial(); + const tiles = new TerrainTiles({ + sampleHeight, + surface, + residentByteBudget: 20_000_000, + residentTileBudget: 9, + tileSize: 16, + tileResolution: grid.columns === 17 ? 9 : 17, + topologyObservation: observation, + ...(worldPasses ? { worldPasses } : {}), + }); + try { + expect(tiles.debug().topology).toEqual(expected); + expect(detach).toHaveBeenCalledTimes(1); + expect(tiles.debug().peakResidentBytes).toBe(constructionBytes); + expect(tiles.debug().topologyBytes).toBe(raw ? heights.length * (passes ? 2 : 1) * 8 : 0); + const mutated = tiles.debug().topology as { + origin: { x: number }; + heights?: number[]; + flow?: number[]; + metrics?: { reliefFieldEdge: number }; + }; + mutated.origin.x = 999; + if (mutated.heights) mutated.heights[0] = 999; + if (mutated.flow) mutated.flow[0] = 999; + if (mutated.metrics) mutated.metrics.reliefFieldEdge = 999; + expect(tiles.debug().topology).toEqual(expected); + tiles.dispose(); + expect(tiles.debug()).not.toHaveProperty("topology"); + expect(tiles.debug().topologyBytes).toBe(0); + expect(tiles.residentBytes).toBe(0); + expect(detach).toHaveBeenCalledTimes(1); + } finally { + tiles.dispose(); + surface.dispose(); + detach.mockRestore(); + } + }, + 60_000, + ); + + it("releases topology construction resources before propagating a byte-cap failure", () => { + const detach = vi.spyOn(Heightfield.prototype, "detach"); + const surface = new MeshBasicMaterial(); + try { + expect( + () => + new TerrainTiles({ + sampleHeight, + surface, + residentByteBudget: 1_223, + residentTileBudget: 1, + tileSize: 16, + tileResolution: 9, + topologyObservation: { + columns: 17, + rows: 9, + width: 32, + depth: 16, + origin: { x: 0, z: 0 }, + }, + }), + ).toThrow(/cannot fit the topology observation/u); + expect(detach).toHaveBeenCalledTimes(1); + } finally { + surface.dispose(); + detach.mockRestore(); + } + }); + it("publishes the resident field and routed flow for topology evaluation", () => { const tiles = new TerrainTiles({ validate: true, diff --git a/packages/core/__tests__/world-tiles-cost.spec.ts b/packages/core/__tests__/world-tiles-cost.spec.ts index 175b56acad..08f2640346 100644 --- a/packages/core/__tests__/world-tiles-cost.spec.ts +++ b/packages/core/__tests__/world-tiles-cost.spec.ts @@ -59,6 +59,16 @@ describe("TerrainTiles construction admission", () => { tiles.dispose(); }); + it("omits an absent pending construction from telemetry", () => { + const tiles = construction(); + try { + tiles.follow({ x: 0, z: 0 }); + expect(tiles.debug()).not.toHaveProperty("pendingConstruction"); + } finally { + tiles.dispose(); + } + }); + it("bounds grid and LOD error reads while converging to identical geometry", () => { const synchronous = construction(); synchronous.follow({ x: 0, z: 0 }); @@ -278,6 +288,97 @@ function expectContainsEveryVertex(mesh: Mesh): void { expect(mesh.geometry.boundingBox?.containsBox(box)).toBe(true); } +describe("TerrainTiles blend-frame cost", () => { + function blendingTile(validate = false): TerrainTiles { + const tiles = new TerrainTiles({ + mergeTiles: false, + validate, + residentByteBudget: 2_000_000, + residentTileBudget: 1, + sampleHeight, + streamRadius: 0, + surface: new MeshBasicMaterial(), + tileResolution: 65, + tileSize: 64, + lodFactors: [1, 2], + lodDistances: [8], + }); + tiles.follow({ x: 0, z: 0 }); + return tiles; + } + + it("retains the validating CPU reference's field reads for one blending 65×65 tile", () => { + const tiles = blendingTile(true); + try { + tiles.follow({ x: 9, z: 0 }); + expect(tiles.residentKeys).toEqual(["0:0"]); + expect(tiles.blendingTiles).toBe(1); + const heights = vi.spyOn(Heightfield.prototype, "heightAt"); + const normals = vi.spyOn(Heightfield.prototype, "normalAt"); + try { + tiles.process(); + expect(normals).toHaveBeenCalledTimes(65 * 65); + expect(heights).toHaveBeenCalledTimes(6 * 65 * 65); + } finally { + heights.mockRestore(); + normals.mockRestore(); + } + expect(tiles.blendingTiles).toBe(1); + } finally { + tiles.dispose(); + } + }); + + it("reads no field heights or normals for a GPU blend frame", () => { + const tiles = blendingTile(); + try { + tiles.follow({ x: 9, z: 0 }); + const heights = vi.spyOn(Heightfield.prototype, "heightAt"); + const normals = vi.spyOn(Heightfield.prototype, "normalAt"); + try { + tiles.process(); + expect(tiles.blendingTiles).toBe(1); + expect(heights).not.toHaveBeenCalled(); + expect(normals).not.toHaveBeenCalled(); + } finally { + heights.mockRestore(); + normals.mockRestore(); + } + } finally { + tiles.dispose(); + } + }); + + it.skipIf(!bench)("times one blending 65×65 tile without spies", () => { + const tiles = blendingTile(); + const timings: number[] = []; + try { + for (let frame = 0; frame < 120; frame += 1) { + tiles.follow({ x: frame % 2 === 0 ? 9 : 0, z: 0 }); + expect(tiles.blendingTiles).toBe(1); + const started = performance.now(); + tiles.process(); + const elapsed = performance.now() - started; + if (frame >= 20) timings.push(elapsed); + tiles.process(); + tiles.process(); + expect(tiles.blendingTiles).toBe(0); + } + const mean = timings.reduce((total, ms) => total + ms, 0) / timings.length; + timings.sort((a, b) => a - b); + console.log( + `TerrainTiles blend 65×65: ${mean.toFixed(3)} ms mean, ` + + `${timings[49]?.toFixed(3)} ms median, ${timings[98]?.toFixed(3)} ms p99, ` + + `${timings.length} frames, 0 normalAt + 0 heightAt per GPU blend tile; ` + + `gate >=0.5 ms: ${mean >= 0.5}`, + ); + expect(mean).toBeGreaterThan(0); + } finally { + tiles.dispose(); + } + }); +}); + describe("TerrainTiles settled-frame cost", () => { it("skips the neighbor LOD scan when follow changed no tile or target", () => { const tiles = ring(2, 17, 32, 25, 64_000_000); diff --git a/packages/core/__tests__/world-topology.spec.ts b/packages/core/__tests__/world-topology.spec.ts index 25fcf22a18..aa76c57cc9 100644 --- a/packages/core/__tests__/world-topology.spec.ts +++ b/packages/core/__tests__/world-topology.spec.ts @@ -1,7 +1,47 @@ import { describe, expect, it } from "vitest"; +import { terrainHeight } from "../../../examples/abyss-framework/src/world/terrainSampler.js"; +import { evaluateWorldTopology } from "../../playtest/src/evaluators/world-gameplay.js"; import { summarizeWorldTopology } from "../src/world-topology.js"; +import { Heightfield } from "../src/world.js"; describe("summarizeWorldTopology", () => { + it("the terrain probe's canonical kilometre field meets every landform floor", () => { + const field = Heightfield.fromSampler({ + columns: 1025, + rows: 1025, + width: 1024, + depth: 1024, + origin: { x: 0, z: 0 }, + sampleHeight: terrainHeight, + worldPasses: { + gpu: false, + dispatchBudget: 4, + erosion: { + depositionRate: 0.35, + erosionRate: 0.22, + evaporation: 0.04, + iterations: 4, + rainfall: 0.08, + sedimentCapacity: 0.7, + timeStep: 0.05, + }, + }, + }); + try { + const result = evaluateWorldTopology({ + columns: field.columns, + rows: field.rows, + width: field.width, + depth: field.depth, + heights: field.heights, + flow: field.flow, + }); + expect(result.failed, JSON.stringify(result.metrics)).toEqual([]); + } finally { + field.detach(); + } + }); + it("orders a hand-computed ridge as 2, a flat field as 1", () => { // A 3x3 valley: both sides drain into the bottom-middle outlet, two first-order // tributaries meeting there raise its Horton-Strahler order to 2. 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 191a290fb3..cb7717f684 100644 --- a/packages/core/patches/three@0.185.1-prd269-upgrade.patch +++ b/packages/core/patches/three@0.185.1-prd269-upgrade.patch @@ -1,5 +1,5 @@ diff --git a/build/three.webgpu.js b/build/three.webgpu.js -index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64db40becd 100644 +index 148869ee7f18c62570ac1eafb7df5775e5a89581..5975e257a43fe139b69c249d8c8c1edfe91e12a0 100644 --- a/build/three.webgpu.js +++ b/build/three.webgpu.js @@ -12307,7 +12307,8 @@ class TextureSizeNode extends Node { @@ -38,7 +38,32 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 } -@@ -34249,6 +34258,10 @@ class Textures extends DataMap { +@@ -30412,7 +30421,7 @@ class RenderObject { + * + * @return {string} The geometry cache key. + */ +- getGeometryCacheKey() { ++ getGeometryCacheKey( useMorphTargetIds = true ) { + + const { geometry } = this; + +@@ -30440,12 +30449,14 @@ class RenderObject { + const targets = geometry.morphAttributes[ name ]; + + cacheKey += 'morph-' + name + ','; ++ if ( ! useMorphTargetIds ) cacheKey += targets.length + ',' + geometry.morphTargetsRelative + ','; + + for ( let i = 0, l = targets.length; i < l; i ++ ) { + + const attribute = targets[ i ]; + +- cacheKey += attribute.id + ','; ++ cacheKey += useMorphTargetIds ? attribute.id + ',' : ++ attribute.count + ',' + attribute.itemSize + ','; + + } + +@@ -34249,6 +34260,10 @@ class Textures extends DataMap { * @type {Set} */ this._htmlTextures = new Set(); @@ -49,7 +74,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 } -@@ -34398,7 +34411,16 @@ class Textures extends DataMap { +@@ -34398,7 +34413,16 @@ class Textures extends DataMap { */ updateTexture( texture, options = {} ) { @@ -66,7 +91,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 if ( textureData.initialized === true && textureData.version === texture.version ) return; const isRenderTarget = texture.isRenderTargetTexture || texture.isDepthTexture || texture.isFramebufferTexture; -@@ -34562,7 +34584,16 @@ class Textures extends DataMap { +@@ -34562,7 +34586,16 @@ class Textures extends DataMap { } @@ -84,7 +109,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 const skipAutoGeneration = texture.isStorageTexture === true && texture.mipmapsAutoUpdate === false; -@@ -34572,7 +34603,7 @@ class Textures extends DataMap { +@@ -34572,7 +34605,7 @@ class Textures extends DataMap { } @@ -93,7 +118,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 } -@@ -34589,6 +34620,17 @@ class Textures extends DataMap { +@@ -34589,6 +34622,17 @@ class Textures extends DataMap { } @@ -111,7 +136,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 // dispose handler if ( textureData.initialized !== true ) { -@@ -34621,9 +34663,134 @@ class Textures extends DataMap { +@@ -34621,9 +34665,134 @@ class Textures extends DataMap { } @@ -248,7 +273,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 } -@@ -34801,6 +34968,8 @@ class Textures extends DataMap { +@@ -34801,6 +34970,8 @@ class Textures extends DataMap { */ _destroyTexture( texture ) { @@ -257,7 +282,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 if ( this.has( texture ) === true ) { const textureData = this.get( texture ); -@@ -40515,7 +40684,7 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40515,7 +40686,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. */ @@ -266,7 +291,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 // null is passed to the super call since this class does not // use an external texture for rendering pass data into. Instead -@@ -40536,6 +40705,8 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40536,6 +40707,8 @@ class PassMultipleTextureNode extends PassTextureNode { * @type {boolean} */ this.previousTexture = previousTexture; @@ -275,7 +300,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 /** * This flag can be used for type testing. -@@ -40557,17 +40728,33 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40557,17 +40730,33 @@ class PassMultipleTextureNode extends PassTextureNode { } @@ -310,7 +335,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 newNode.uvNode = this.uvNode; newNode.levelNode = this.levelNode; newNode.biasNode = this.biasNode; -@@ -40748,6 +40935,8 @@ class PassNode extends TempNode { +@@ -40748,6 +40937,8 @@ class PassNode extends TempNode { * @type {Object} */ this._textureNodes = {}; @@ -319,7 +344,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 /** * A dictionary holding the internal depth nodes. -@@ -41054,6 +41243,7 @@ class PassNode extends TempNode { +@@ -41054,6 +41245,7 @@ class PassNode extends TempNode { this._previousTextures[ name ] = texture; this._textureNodes[ name ].updateTexture(); @@ -327,7 +352,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 this._previousTextureNodes[ name ].updateTexture(); } -@@ -41066,6 +41256,17 @@ class PassNode extends TempNode { +@@ -41066,6 +41258,17 @@ class PassNode extends TempNode { * @param {string} [name='output'] - The output name to get the texture node for. * @return {TextureNode} The texture node. */ @@ -345,7 +370,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 getTextureNode( name = 'output' ) { let textureNode = this._textureNodes[ name ]; -@@ -41408,6 +41609,7 @@ class PassNode extends TempNode { +@@ -41408,6 +41611,7 @@ class PassNode extends TempNode { */ dispose() { @@ -353,7 +378,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 this.renderTarget.dispose(); } -@@ -44676,6 +44878,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ +@@ -44676,6 +44880,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ const shadowMaterialLib = /*@__PURE__*/ new WeakMap(); @@ -400,7 +425,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 /** * A shadow filtering function performing basic filtering. This is in fact an unfiltered version of the shadow map * with a binary `[0,1]` result. -@@ -44730,8 +44972,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44730,8 +44974,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, }; @@ -411,7 +436,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); const radiusScaled = radius.mul( texelSize.x ); -@@ -44762,7 +45004,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44762,7 +45006,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, */ const PCFSoftShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, depthLayer } ) => { @@ -420,7 +445,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); -@@ -45831,8 +46073,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) +@@ -45831,8 +46075,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) */ const PointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp, shadow } ) => { @@ -431,7 +456,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 const texelSize = radius.div( mapSize.x ); -@@ -45868,9 +46110,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo +@@ -45868,9 +46112,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo const shadowPositionAbs = shadowPosition.abs().toConst(); const viewZ = shadowPositionAbs.x.max( shadowPositionAbs.y ).max( shadowPositionAbs.z ); @@ -444,7 +469,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 const result = float( 1.0 ).toVar(); -@@ -61391,6 +61633,18 @@ class Renderer { +@@ -61391,6 +61635,18 @@ class Renderer { } @@ -463,7 +488,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 /** * Can be used to transfer buffer data from a storage buffer attribute * from the GPU to the CPU in context of compute shaders. -@@ -62384,6 +62638,14 @@ class Renderer { +@@ -62384,6 +62640,14 @@ class Renderer { } @@ -478,7 +503,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 /** * Initializes the given render target. * -@@ -76680,6 +76942,9 @@ class WebGPUTextureUtils { +@@ -76680,6 +76944,9 @@ class WebGPUTextureUtils { */ this._samplerCache = new Map(); @@ -488,7 +513,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 } /** -@@ -76896,7 +77161,12 @@ class WebGPUTextureUtils { +@@ -76896,7 +77163,12 @@ class WebGPUTextureUtils { } @@ -502,7 +527,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 usage |= GPUTextureUsage.RENDER_ATTACHMENT; -@@ -76969,6 +77239,22 @@ class WebGPUTextureUtils { +@@ -76969,6 +77241,22 @@ class WebGPUTextureUtils { destroyTexture( texture, isDefaultTexture = false ) { const backend = this.backend; @@ -525,7 +550,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 const textureData = backend.get( texture ); if ( textureData.texture !== undefined && isDefaultTexture === false && texture.isExternalTexture !== true ) textureData.texture.destroy(); -@@ -77009,7 +77295,7 @@ class WebGPUTextureUtils { +@@ -77009,7 +77297,7 @@ class WebGPUTextureUtils { const colorTexture = canvasTarget.colorTexture; const colorTextureData = backend.get( colorTexture ); @@ -534,7 +559,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 return colorTextureData.texture; -@@ -77034,8 +77320,8 @@ class WebGPUTextureUtils { +@@ -77034,8 +77322,8 @@ class WebGPUTextureUtils { // @@ -545,7 +570,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 colorTexture.samples = samples; colorTextureData.texture = colorBuffer; -@@ -77058,64 +77344,59 @@ class WebGPUTextureUtils { +@@ -77058,64 +77346,59 @@ class WebGPUTextureUtils { const canvasTarget = backend.renderer.getCanvasTarget(); const { width, height } = backend.getDrawingBufferSize(); const samples = backend.renderer.currentSamples; @@ -638,7 +663,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 /** * Uploads the updated texture data to the GPU. * -@@ -77270,6 +77551,143 @@ class WebGPUTextureUtils { +@@ -77270,6 +77553,143 @@ class WebGPUTextureUtils { } @@ -782,7 +807,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 /** * Returns texture data as a typed array. * -@@ -77343,6 +77761,8 @@ class WebGPUTextureUtils { +@@ -77343,6 +77763,8 @@ class WebGPUTextureUtils { */ dispose() { @@ -791,7 +816,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 this._samplerCache.clear(); } -@@ -79269,7 +79689,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79269,7 +79691,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. */ @@ -800,7 +825,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 if ( levelSnippet === null ) levelSnippet = '0u'; -@@ -79293,7 +79713,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79293,7 +79715,7 @@ class WGSLNodeBuilder extends NodeBuilder { const sampleData = texture ? this.renderer?.backend?.utils?.getTextureSampleData?.( texture ) : null; const backendMultisampled = sampleData !== null && sampleData !== undefined && sampleData.primarySamples > 1; const targetMultisampled = Boolean( texture && texture.renderTarget && texture.renderTarget.samples > 1 ); @@ -809,7 +834,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, ${ indexType }( ${ levelSnippet } ) )`; if ( this.renderer.backend.compatibilityMode && texture.isDepthTexture ) { -@@ -79835,7 +80255,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79835,7 +80257,7 @@ class WGSLNodeBuilder extends NodeBuilder { // Cube textures always need samplers (they use textureSampleLevel, not textureLoad) // Also textureGather always need sampler. @@ -818,7 +843,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 if ( needsSampler ) { -@@ -80265,7 +80685,8 @@ ${ flowData.code } +@@ -80265,7 +80687,8 @@ ${ flowData.code } for ( const { name, property, type } of builtins.values() ) { @@ -828,7 +853,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 } -@@ -80521,6 +80942,8 @@ ${ flowData.code } +@@ -80521,6 +80944,8 @@ ${ flowData.code } getVaryings( shaderStage ) { const snippets = []; @@ -837,7 +862,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 if ( shaderStage === 'vertex' ) { -@@ -80543,7 +80966,12 @@ ${ flowData.code } +@@ -80543,7 +80968,12 @@ ${ flowData.code } let attributesSnippet = `@location( ${ varyingIndex ++ } )`; @@ -851,7 +876,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 const samplingSnippet = varying.interpolationSampling !== null ? `, ${ varying.interpolationSampling } )` : ' )'; -@@ -80647,7 +81075,7 @@ ${ flowData.code } +@@ -80647,7 +81077,7 @@ ${ flowData.code } const { primarySamples } = backend.utils.getTextureSampleData( texture ); @@ -860,7 +885,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 multisampled = '_multisampled'; -@@ -82145,7 +82573,20 @@ class WebGPUBindingUtils { +@@ -82145,7 +82575,20 @@ class WebGPUBindingUtils { let resourceGPU; @@ -882,7 +907,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 resourceGPU = device.importExternalTexture( { source: textureData.externalTexture } ); -@@ -82316,7 +82757,7 @@ class WebGPUBindingUtils { +@@ -82316,7 +82759,7 @@ class WebGPUBindingUtils { const { primarySamples } = backend.utils.getTextureSampleData( binding.texture ); @@ -891,7 +916,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 texture.multisampled = true; -@@ -82354,7 +82795,7 @@ class WebGPUBindingUtils { +@@ -82354,7 +82797,7 @@ class WebGPUBindingUtils { } else if ( type === FloatType ) { @@ -900,7 +925,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 texture.sampleType = GPUTextureSampleType.Float; -@@ -82871,6 +83312,15 @@ class WebGPUPipelineUtils { +@@ -82871,6 +83314,15 @@ class WebGPUPipelineUtils { _renderPipelineDescriptor.reset(); @@ -916,7 +941,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 if ( pipelinePromise !== null ) { try { -@@ -82885,7 +83335,7 @@ class WebGPUPipelineUtils { +@@ -82885,7 +83337,7 @@ class WebGPUPipelineUtils { } @@ -925,7 +950,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 if ( errorScope !== null || asyncError !== null ) { -@@ -84140,6 +84590,19 @@ class WebGPUBackend extends Backend { +@@ -84140,6 +84592,19 @@ class WebGPUBackend extends Backend { */ this.gpu = null; @@ -945,7 +970,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 /** * A reference to the device. * -@@ -84256,6 +84719,8 @@ class WebGPUBackend extends Backend { +@@ -84256,6 +84721,8 @@ class WebGPUBackend extends Backend { const adapter = this.gpu !== null ? await this.gpu.requestAdapter( adapterOptions ) : null; @@ -954,7 +979,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 if ( adapter === null ) { throw new Error( 'THREE.WebGPUBackend: Unable to create WebGPU adapter.' ); -@@ -84481,7 +84946,9 @@ class WebGPUBackend extends Backend { +@@ -84481,7 +84948,9 @@ class WebGPUBackend extends Backend { let descriptor = canvasData.descriptor; @@ -965,7 +990,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 descriptor = new GPURenderPassDescriptor(); descriptor.colorAttachments.push( new GPURenderPassColorAttachment() ); -@@ -84508,6 +84975,9 @@ class WebGPUBackend extends Backend { +@@ -84508,6 +84977,9 @@ class WebGPUBackend extends Backend { canvasData.descriptor = descriptor; canvasData.samples = samples; @@ -975,7 +1000,16 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 } -@@ -86248,6 +86718,12 @@ class WebGPUBackend extends Backend { +@@ -86180,7 +86652,7 @@ class WebGPUBackend extends Backend { + bindGroupLayoutKeys, + utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), + utils.getPrimitiveTopology( object, material ), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); + +@@ -86248,6 +86720,12 @@ class WebGPUBackend extends Backend { } @@ -989,7 +1023,7 @@ index 148869ee7f18c62570ac1eafb7df5775e5a89581..a308a892c59283b3b9a71477b3b02d64 * Generates mipmaps for the given texture. * diff --git a/build/three.webgpu.js b/build/three.webgpu.js -index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64db40becd 100644 +index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..5975e257a43fe139b69c249d8c8c1edfe91e12a0 100644 --- a/build/three.webgpu.js +++ b/build/three.webgpu.js @@ -12307,7 +12307,8 @@ class TextureSizeNode extends Node { @@ -1069,7 +1103,32 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 } -@@ -34233,6 +34258,10 @@ class Textures extends DataMap { +@@ -30396,7 +30421,7 @@ class RenderObject { + * + * @return {string} The geometry cache key. + */ +- getGeometryCacheKey() { ++ getGeometryCacheKey( useMorphTargetIds = true ) { + + const { geometry } = this; + +@@ -30424,12 +30449,14 @@ class RenderObject { + const targets = geometry.morphAttributes[ name ]; + + cacheKey += 'morph-' + name + ','; ++ if ( ! useMorphTargetIds ) cacheKey += targets.length + ',' + geometry.morphTargetsRelative + ','; + + for ( let i = 0, l = targets.length; i < l; i ++ ) { + + const attribute = targets[ i ]; + +- cacheKey += attribute.id + ','; ++ cacheKey += useMorphTargetIds ? attribute.id + ',' : ++ attribute.count + ',' + attribute.itemSize + ','; + + } + +@@ -34233,6 +34260,10 @@ class Textures extends DataMap { * @type {Set} */ this._htmlTextures = new Set(); @@ -1080,7 +1139,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 } -@@ -34382,7 +34411,16 @@ class Textures extends DataMap { +@@ -34382,7 +34413,16 @@ class Textures extends DataMap { */ updateTexture( texture, options = {} ) { @@ -1097,7 +1156,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 if ( textureData.initialized === true && textureData.version === texture.version ) return; const isRenderTarget = texture.isRenderTargetTexture || texture.isDepthTexture || texture.isFramebufferTexture; -@@ -34546,7 +34584,16 @@ class Textures extends DataMap { +@@ -34546,7 +34586,16 @@ class Textures extends DataMap { } @@ -1115,7 +1174,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 const skipAutoGeneration = texture.isStorageTexture === true && texture.mipmapsAutoUpdate === false; -@@ -34556,7 +34603,7 @@ class Textures extends DataMap { +@@ -34556,7 +34605,7 @@ class Textures extends DataMap { } @@ -1124,7 +1183,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 } -@@ -34573,6 +34620,17 @@ class Textures extends DataMap { +@@ -34573,6 +34622,17 @@ class Textures extends DataMap { } @@ -1142,7 +1201,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 // dispose handler if ( textureData.initialized !== true ) { -@@ -34605,9 +34663,134 @@ class Textures extends DataMap { +@@ -34605,9 +34665,134 @@ class Textures extends DataMap { } @@ -1279,7 +1338,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 } -@@ -34785,6 +34968,8 @@ class Textures extends DataMap { +@@ -34785,6 +34970,8 @@ class Textures extends DataMap { */ _destroyTexture( texture ) { @@ -1288,7 +1347,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 if ( this.has( texture ) === true ) { const textureData = this.get( texture ); -@@ -40499,7 +40684,7 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40499,7 +40686,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. */ @@ -1297,7 +1356,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 // null is passed to the super call since this class does not // use an external texture for rendering pass data into. Instead -@@ -40520,6 +40705,8 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40520,6 +40707,8 @@ class PassMultipleTextureNode extends PassTextureNode { * @type {boolean} */ this.previousTexture = previousTexture; @@ -1306,7 +1365,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 /** * This flag can be used for type testing. -@@ -40541,17 +40728,33 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40541,17 +40730,33 @@ class PassMultipleTextureNode extends PassTextureNode { } @@ -1341,7 +1400,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 newNode.uvNode = this.uvNode; newNode.levelNode = this.levelNode; newNode.biasNode = this.biasNode; -@@ -40732,6 +40935,8 @@ class PassNode extends TempNode { +@@ -40732,6 +40937,8 @@ class PassNode extends TempNode { * @type {Object} */ this._textureNodes = {}; @@ -1350,7 +1409,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 /** * A dictionary holding the internal depth nodes. -@@ -41038,6 +41243,7 @@ class PassNode extends TempNode { +@@ -41038,6 +41245,7 @@ class PassNode extends TempNode { this._previousTextures[ name ] = texture; this._textureNodes[ name ].updateTexture(); @@ -1358,7 +1417,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 this._previousTextureNodes[ name ].updateTexture(); } -@@ -41050,6 +41256,17 @@ class PassNode extends TempNode { +@@ -41050,6 +41258,17 @@ class PassNode extends TempNode { * @param {string} [name='output'] - The output name to get the texture node for. * @return {TextureNode} The texture node. */ @@ -1376,7 +1435,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 getTextureNode( name = 'output' ) { let textureNode = this._textureNodes[ name ]; -@@ -41392,6 +41609,7 @@ class PassNode extends TempNode { +@@ -41392,6 +41611,7 @@ class PassNode extends TempNode { */ dispose() { @@ -1384,7 +1443,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 this.renderTarget.dispose(); } -@@ -44660,6 +44878,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ +@@ -44660,6 +44880,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ const shadowMaterialLib = /*@__PURE__*/ new WeakMap(); @@ -1431,7 +1490,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 /** * A shadow filtering function performing basic filtering. This is in fact an unfiltered version of the shadow map * with a binary `[0,1]` result. -@@ -44714,8 +44972,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44714,8 +44974,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, }; @@ -1442,7 +1501,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); const radiusScaled = radius.mul( texelSize.x ); -@@ -44746,7 +45004,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44746,7 +45006,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, */ const PCFSoftShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, depthLayer } ) => { @@ -1451,7 +1510,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); -@@ -45815,8 +46073,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) +@@ -45815,8 +46075,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) */ const PointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp, shadow } ) => { @@ -1462,7 +1521,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 const texelSize = radius.div( mapSize.x ); -@@ -45852,9 +46110,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo +@@ -45852,9 +46112,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo const shadowPositionAbs = shadowPosition.abs().toConst(); const viewZ = shadowPositionAbs.x.max( shadowPositionAbs.y ).max( shadowPositionAbs.z ); @@ -1475,7 +1534,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 const result = float( 1.0 ).toVar(); -@@ -61375,6 +61633,18 @@ class Renderer { +@@ -61375,6 +61635,18 @@ class Renderer { } @@ -1494,7 +1553,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 /** * Can be used to transfer buffer data from a storage buffer attribute * from the GPU to the CPU in context of compute shaders. -@@ -62368,6 +62638,14 @@ class Renderer { +@@ -62368,6 +62640,14 @@ class Renderer { } @@ -1509,7 +1568,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 /** * Initializes the given render target. * -@@ -63161,8 +63439,8 @@ class Renderer { +@@ -63161,8 +63441,8 @@ class Renderer { // attribute scan - because each of those answers for itself, and a step left out here // cannot: a group a node added after this object's first draw still has to be created, and // the pipeline reads that group's layout whatever this draw decided to do with it. @@ -1519,7 +1578,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 this._nodes.updateForRender( renderObject ); this._bindings.updateForRender( renderObject ); -@@ -76664,6 +76942,9 @@ class WebGPUTextureUtils { +@@ -76664,6 +76944,9 @@ class WebGPUTextureUtils { */ this._samplerCache = new Map(); @@ -1529,7 +1588,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 } /** -@@ -76880,12 +77161,12 @@ class WebGPUTextureUtils { +@@ -76880,12 +77163,12 @@ class WebGPUTextureUtils { } @@ -1546,7 +1605,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 usage |= GPUTextureUsage.RENDER_ATTACHMENT; -@@ -76958,6 +77239,22 @@ class WebGPUTextureUtils { +@@ -76958,6 +77241,22 @@ class WebGPUTextureUtils { destroyTexture( texture, isDefaultTexture = false ) { const backend = this.backend; @@ -1569,7 +1628,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 const textureData = backend.get( texture ); if ( textureData.texture !== undefined && isDefaultTexture === false && texture.isExternalTexture !== true ) textureData.texture.destroy(); -@@ -76998,7 +77295,7 @@ class WebGPUTextureUtils { +@@ -76998,7 +77297,7 @@ class WebGPUTextureUtils { const colorTexture = canvasTarget.colorTexture; const colorTextureData = backend.get( colorTexture ); @@ -1578,7 +1637,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 return colorTextureData.texture; -@@ -77023,8 +77320,8 @@ class WebGPUTextureUtils { +@@ -77023,8 +77322,8 @@ class WebGPUTextureUtils { // @@ -1589,7 +1648,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 colorTexture.samples = samples; colorTextureData.texture = colorBuffer; -@@ -77047,64 +77344,59 @@ class WebGPUTextureUtils { +@@ -77047,64 +77346,59 @@ class WebGPUTextureUtils { const canvasTarget = backend.renderer.getCanvasTarget(); const { width, height } = backend.getDrawingBufferSize(); const samples = backend.renderer.currentSamples; @@ -1682,7 +1741,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 /** * Uploads the updated texture data to the GPU. * -@@ -77259,6 +77551,143 @@ class WebGPUTextureUtils { +@@ -77259,6 +77553,143 @@ class WebGPUTextureUtils { } @@ -1826,7 +1885,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 /** * Returns texture data as a typed array. * -@@ -77332,6 +77761,8 @@ class WebGPUTextureUtils { +@@ -77332,6 +77763,8 @@ class WebGPUTextureUtils { */ dispose() { @@ -1835,7 +1894,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 this._samplerCache.clear(); } -@@ -79258,7 +79689,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79258,7 +79691,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. */ @@ -1844,7 +1903,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 if ( levelSnippet === null ) levelSnippet = '0u'; -@@ -79282,7 +79713,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79282,7 +79715,7 @@ class WGSLNodeBuilder extends NodeBuilder { const sampleData = texture ? this.renderer?.backend?.utils?.getTextureSampleData?.( texture ) : null; const backendMultisampled = sampleData !== null && sampleData !== undefined && sampleData.primarySamples > 1; const targetMultisampled = Boolean( texture && texture.renderTarget && texture.renderTarget.samples > 1 ); @@ -1853,7 +1912,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, ${ indexType }( ${ levelSnippet } ) )`; if ( this.renderer.backend.compatibilityMode && texture.isDepthTexture ) { -@@ -79824,7 +80255,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79824,7 +80257,7 @@ class WGSLNodeBuilder extends NodeBuilder { // Cube textures always need samplers (they use textureSampleLevel, not textureLoad) // Also textureGather always need sampler. @@ -1862,7 +1921,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 if ( needsSampler ) { -@@ -80254,7 +80685,8 @@ ${ flowData.code } +@@ -80254,7 +80687,8 @@ ${ flowData.code } for ( const { name, property, type } of builtins.values() ) { @@ -1872,7 +1931,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 } -@@ -80510,6 +80942,8 @@ ${ flowData.code } +@@ -80510,6 +80944,8 @@ ${ flowData.code } getVaryings( shaderStage ) { const snippets = []; @@ -1881,7 +1940,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 if ( shaderStage === 'vertex' ) { -@@ -80532,7 +80966,12 @@ ${ flowData.code } +@@ -80532,7 +80968,12 @@ ${ flowData.code } let attributesSnippet = `@location( ${ varyingIndex ++ } )`; @@ -1895,7 +1954,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 const samplingSnippet = varying.interpolationSampling !== null ? `, ${ varying.interpolationSampling } )` : ' )'; -@@ -80636,7 +81075,7 @@ ${ flowData.code } +@@ -80636,7 +81077,7 @@ ${ flowData.code } const { primarySamples } = backend.utils.getTextureSampleData( texture ); @@ -1904,7 +1963,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 multisampled = '_multisampled'; -@@ -81391,6 +81830,15 @@ class WebGPUAttributeUtils { +@@ -81391,6 +81832,15 @@ class WebGPUAttributeUtils { buffer.unmap(); bufferData.buffer = buffer; @@ -1920,7 +1979,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 } -@@ -81411,6 +81859,11 @@ class WebGPUAttributeUtils { +@@ -81411,6 +81861,11 @@ class WebGPUAttributeUtils { const bufferData = backend.get( bufferAttribute ); const buffer = backend.get( bufferAttribute ).buffer; @@ -1932,7 +1991,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 let array = bufferAttribute.array; const itemSize = bufferData._itemSize; -@@ -81492,6 +81945,9 @@ class WebGPUAttributeUtils { +@@ -81492,6 +81947,9 @@ class WebGPUAttributeUtils { } @@ -1942,7 +2001,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 } /** -@@ -82117,7 +82573,20 @@ class WebGPUBindingUtils { +@@ -82117,7 +82575,20 @@ class WebGPUBindingUtils { let resourceGPU; @@ -1964,7 +2023,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 resourceGPU = device.importExternalTexture( { source: textureData.externalTexture } ); -@@ -82288,7 +82757,7 @@ class WebGPUBindingUtils { +@@ -82288,7 +82759,7 @@ class WebGPUBindingUtils { const { primarySamples } = backend.utils.getTextureSampleData( binding.texture ); @@ -1973,7 +2032,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 texture.multisampled = true; -@@ -82326,7 +82795,7 @@ class WebGPUBindingUtils { +@@ -82326,7 +82797,7 @@ class WebGPUBindingUtils { } else if ( type === FloatType ) { @@ -1982,7 +2041,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 texture.sampleType = GPUTextureSampleType.Float; -@@ -82843,6 +83312,15 @@ class WebGPUPipelineUtils { +@@ -82843,6 +83314,15 @@ class WebGPUPipelineUtils { _renderPipelineDescriptor.reset(); @@ -1998,7 +2057,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 if ( pipelinePromise !== null ) { try { -@@ -82857,7 +83335,7 @@ class WebGPUPipelineUtils { +@@ -82857,7 +83337,7 @@ class WebGPUPipelineUtils { } @@ -2007,7 +2066,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 if ( errorScope !== null || asyncError !== null ) { -@@ -84468,7 +84946,9 @@ class WebGPUBackend extends Backend { +@@ -84468,7 +84948,9 @@ class WebGPUBackend extends Backend { let descriptor = canvasData.descriptor; @@ -2018,7 +2077,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 descriptor = new GPURenderPassDescriptor(); descriptor.colorAttachments.push( new GPURenderPassColorAttachment() ); -@@ -84495,6 +84975,9 @@ class WebGPUBackend extends Backend { +@@ -84495,6 +84977,9 @@ class WebGPUBackend extends Backend { canvasData.descriptor = descriptor; canvasData.samples = samples; @@ -2028,7 +2087,16 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 } -@@ -86235,6 +86718,12 @@ class WebGPUBackend extends Backend { +@@ -86167,7 +86652,7 @@ class WebGPUBackend extends Backend { + bindGroupLayoutKeys, + utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), + utils.getPrimitiveTopology( object, material ), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); + +@@ -86235,6 +86720,12 @@ class WebGPUBackend extends Backend { } @@ -2042,7 +2110,7 @@ index 5c5ef3c06885c724c1c2288295e7e68483ee1f6e..a308a892c59283b3b9a71477b3b02d64 * Generates mipmaps for the given texture. * diff --git a/build/three.webgpu.js b/build/three.webgpu.js -index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64db40becd 100644 +index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..5975e257a43fe139b69c249d8c8c1edfe91e12a0 100644 --- a/build/three.webgpu.js +++ b/build/three.webgpu.js @@ -12307,7 +12307,8 @@ class TextureSizeNode extends Node { @@ -2122,7 +2190,32 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 } -@@ -34233,6 +34258,10 @@ class Textures extends DataMap { +@@ -30396,7 +30421,7 @@ class RenderObject { + * + * @return {string} The geometry cache key. + */ +- getGeometryCacheKey() { ++ getGeometryCacheKey( useMorphTargetIds = true ) { + + const { geometry } = this; + +@@ -30424,12 +30449,14 @@ class RenderObject { + const targets = geometry.morphAttributes[ name ]; + + cacheKey += 'morph-' + name + ','; ++ if ( ! useMorphTargetIds ) cacheKey += targets.length + ',' + geometry.morphTargetsRelative + ','; + + for ( let i = 0, l = targets.length; i < l; i ++ ) { + + const attribute = targets[ i ]; + +- cacheKey += attribute.id + ','; ++ cacheKey += useMorphTargetIds ? attribute.id + ',' : ++ attribute.count + ',' + attribute.itemSize + ','; + + } + +@@ -34233,6 +34260,10 @@ class Textures extends DataMap { * @type {Set} */ this._htmlTextures = new Set(); @@ -2133,7 +2226,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 } -@@ -34382,7 +34411,16 @@ class Textures extends DataMap { +@@ -34382,7 +34413,16 @@ class Textures extends DataMap { */ updateTexture( texture, options = {} ) { @@ -2150,7 +2243,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 if ( textureData.initialized === true && textureData.version === texture.version ) return; const isRenderTarget = texture.isRenderTargetTexture || texture.isDepthTexture || texture.isFramebufferTexture; -@@ -34546,7 +34584,16 @@ class Textures extends DataMap { +@@ -34546,7 +34586,16 @@ class Textures extends DataMap { } @@ -2168,7 +2261,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 const skipAutoGeneration = texture.isStorageTexture === true && texture.mipmapsAutoUpdate === false; -@@ -34556,7 +34603,7 @@ class Textures extends DataMap { +@@ -34556,7 +34605,7 @@ class Textures extends DataMap { } @@ -2177,7 +2270,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 } -@@ -34573,6 +34620,17 @@ class Textures extends DataMap { +@@ -34573,6 +34622,17 @@ class Textures extends DataMap { } @@ -2195,7 +2288,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 // dispose handler if ( textureData.initialized !== true ) { -@@ -34605,9 +34663,134 @@ class Textures extends DataMap { +@@ -34605,9 +34665,134 @@ class Textures extends DataMap { } @@ -2332,7 +2425,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 } -@@ -34785,6 +34968,8 @@ class Textures extends DataMap { +@@ -34785,6 +34970,8 @@ class Textures extends DataMap { */ _destroyTexture( texture ) { @@ -2341,7 +2434,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 if ( this.has( texture ) === true ) { const textureData = this.get( texture ); -@@ -40499,7 +40684,7 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40499,7 +40686,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. */ @@ -2350,7 +2443,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 // null is passed to the super call since this class does not // use an external texture for rendering pass data into. Instead -@@ -40520,6 +40705,8 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40520,6 +40707,8 @@ class PassMultipleTextureNode extends PassTextureNode { * @type {boolean} */ this.previousTexture = previousTexture; @@ -2359,7 +2452,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 /** * This flag can be used for type testing. -@@ -40541,17 +40728,33 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40541,17 +40730,33 @@ class PassMultipleTextureNode extends PassTextureNode { } @@ -2394,7 +2487,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 newNode.uvNode = this.uvNode; newNode.levelNode = this.levelNode; newNode.biasNode = this.biasNode; -@@ -40732,6 +40935,8 @@ class PassNode extends TempNode { +@@ -40732,6 +40937,8 @@ class PassNode extends TempNode { * @type {Object} */ this._textureNodes = {}; @@ -2403,7 +2496,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 /** * A dictionary holding the internal depth nodes. -@@ -41038,6 +41243,7 @@ class PassNode extends TempNode { +@@ -41038,6 +41245,7 @@ class PassNode extends TempNode { this._previousTextures[ name ] = texture; this._textureNodes[ name ].updateTexture(); @@ -2411,7 +2504,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 this._previousTextureNodes[ name ].updateTexture(); } -@@ -41050,6 +41256,17 @@ class PassNode extends TempNode { +@@ -41050,6 +41258,17 @@ class PassNode extends TempNode { * @param {string} [name='output'] - The output name to get the texture node for. * @return {TextureNode} The texture node. */ @@ -2429,7 +2522,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 getTextureNode( name = 'output' ) { let textureNode = this._textureNodes[ name ]; -@@ -41392,6 +41609,7 @@ class PassNode extends TempNode { +@@ -41392,6 +41611,7 @@ class PassNode extends TempNode { */ dispose() { @@ -2437,7 +2530,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 this.renderTarget.dispose(); } -@@ -44660,6 +44878,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ +@@ -44660,6 +44880,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ const shadowMaterialLib = /*@__PURE__*/ new WeakMap(); @@ -2484,7 +2577,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 /** * A shadow filtering function performing basic filtering. This is in fact an unfiltered version of the shadow map * with a binary `[0,1]` result. -@@ -44714,8 +44972,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44714,8 +44974,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, }; @@ -2495,7 +2588,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); const radiusScaled = radius.mul( texelSize.x ); -@@ -44746,7 +45004,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44746,7 +45006,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, */ const PCFSoftShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, depthLayer } ) => { @@ -2504,7 +2597,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); -@@ -45815,8 +46073,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) +@@ -45815,8 +46075,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) */ const PointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp, shadow } ) => { @@ -2515,7 +2608,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 const texelSize = radius.div( mapSize.x ); -@@ -45852,9 +46110,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo +@@ -45852,9 +46112,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo const shadowPositionAbs = shadowPosition.abs().toConst(); const viewZ = shadowPositionAbs.x.max( shadowPositionAbs.y ).max( shadowPositionAbs.z ); @@ -2528,7 +2621,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 const result = float( 1.0 ).toVar(); -@@ -61375,6 +61633,18 @@ class Renderer { +@@ -61375,6 +61635,18 @@ class Renderer { } @@ -2547,7 +2640,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 /** * Can be used to transfer buffer data from a storage buffer attribute * from the GPU to the CPU in context of compute shaders. -@@ -62368,6 +62638,14 @@ class Renderer { +@@ -62368,6 +62640,14 @@ class Renderer { } @@ -2562,7 +2655,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 /** * Initializes the given render target. * -@@ -63161,8 +63439,8 @@ class Renderer { +@@ -63161,8 +63441,8 @@ class Renderer { // attribute scan - because each of those answers for itself, and a step left out here // cannot: a group a node added after this object's first draw still has to be created, and // the pipeline reads that group's layout whatever this draw decided to do with it. @@ -2572,7 +2665,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 this._nodes.updateForRender( renderObject ); this._bindings.updateForRender( renderObject ); -@@ -76664,6 +76942,9 @@ class WebGPUTextureUtils { +@@ -76664,6 +76944,9 @@ class WebGPUTextureUtils { */ this._samplerCache = new Map(); @@ -2582,7 +2675,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 } /** -@@ -76880,7 +77161,12 @@ class WebGPUTextureUtils { +@@ -76880,7 +77163,12 @@ class WebGPUTextureUtils { } @@ -2596,7 +2689,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 usage |= GPUTextureUsage.RENDER_ATTACHMENT; -@@ -76953,6 +77239,22 @@ class WebGPUTextureUtils { +@@ -76953,6 +77241,22 @@ class WebGPUTextureUtils { destroyTexture( texture, isDefaultTexture = false ) { const backend = this.backend; @@ -2619,7 +2712,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 const textureData = backend.get( texture ); if ( textureData.texture !== undefined && isDefaultTexture === false && texture.isExternalTexture !== true ) textureData.texture.destroy(); -@@ -76993,7 +77295,7 @@ class WebGPUTextureUtils { +@@ -76993,7 +77297,7 @@ class WebGPUTextureUtils { const colorTexture = canvasTarget.colorTexture; const colorTextureData = backend.get( colorTexture ); @@ -2628,7 +2721,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 return colorTextureData.texture; -@@ -77018,8 +77320,8 @@ class WebGPUTextureUtils { +@@ -77018,8 +77322,8 @@ class WebGPUTextureUtils { // @@ -2639,7 +2732,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 colorTexture.samples = samples; colorTextureData.texture = colorBuffer; -@@ -77042,64 +77344,59 @@ class WebGPUTextureUtils { +@@ -77042,64 +77346,59 @@ class WebGPUTextureUtils { const canvasTarget = backend.renderer.getCanvasTarget(); const { width, height } = backend.getDrawingBufferSize(); const samples = backend.renderer.currentSamples; @@ -2732,7 +2825,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 /** * Uploads the updated texture data to the GPU. * -@@ -77254,6 +77551,143 @@ class WebGPUTextureUtils { +@@ -77254,6 +77553,143 @@ class WebGPUTextureUtils { } @@ -2876,7 +2969,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 /** * Returns texture data as a typed array. * -@@ -77327,6 +77761,8 @@ class WebGPUTextureUtils { +@@ -77327,6 +77763,8 @@ class WebGPUTextureUtils { */ dispose() { @@ -2885,7 +2978,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 this._samplerCache.clear(); } -@@ -79253,7 +79689,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79253,7 +79691,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. */ @@ -2894,7 +2987,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 if ( levelSnippet === null ) levelSnippet = '0u'; -@@ -79277,7 +79713,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79277,7 +79715,7 @@ class WGSLNodeBuilder extends NodeBuilder { const sampleData = texture ? this.renderer?.backend?.utils?.getTextureSampleData?.( texture ) : null; const backendMultisampled = sampleData !== null && sampleData !== undefined && sampleData.primarySamples > 1; const targetMultisampled = Boolean( texture && texture.renderTarget && texture.renderTarget.samples > 1 ); @@ -2903,7 +2996,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, ${ indexType }( ${ levelSnippet } ) )`; if ( this.renderer.backend.compatibilityMode && texture.isDepthTexture ) { -@@ -79819,7 +80255,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79819,7 +80257,7 @@ class WGSLNodeBuilder extends NodeBuilder { // Cube textures always need samplers (they use textureSampleLevel, not textureLoad) // Also textureGather always need sampler. @@ -2912,7 +3005,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 if ( needsSampler ) { -@@ -80249,7 +80685,8 @@ ${ flowData.code } +@@ -80249,7 +80687,8 @@ ${ flowData.code } for ( const { name, property, type } of builtins.values() ) { @@ -2922,7 +3015,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 } -@@ -80505,6 +80942,8 @@ ${ flowData.code } +@@ -80505,6 +80944,8 @@ ${ flowData.code } getVaryings( shaderStage ) { const snippets = []; @@ -2931,7 +3024,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 if ( shaderStage === 'vertex' ) { -@@ -80527,7 +80966,12 @@ ${ flowData.code } +@@ -80527,7 +80968,12 @@ ${ flowData.code } let attributesSnippet = `@location( ${ varyingIndex ++ } )`; @@ -2945,7 +3038,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 const samplingSnippet = varying.interpolationSampling !== null ? `, ${ varying.interpolationSampling } )` : ' )'; -@@ -80631,7 +81075,7 @@ ${ flowData.code } +@@ -80631,7 +81077,7 @@ ${ flowData.code } const { primarySamples } = backend.utils.getTextureSampleData( texture ); @@ -2954,7 +3047,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 multisampled = '_multisampled'; -@@ -81386,6 +81830,15 @@ class WebGPUAttributeUtils { +@@ -81386,6 +81832,15 @@ class WebGPUAttributeUtils { buffer.unmap(); bufferData.buffer = buffer; @@ -2970,7 +3063,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 } -@@ -81406,6 +81859,11 @@ class WebGPUAttributeUtils { +@@ -81406,6 +81861,11 @@ class WebGPUAttributeUtils { const bufferData = backend.get( bufferAttribute ); const buffer = backend.get( bufferAttribute ).buffer; @@ -2982,7 +3075,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 let array = bufferAttribute.array; const itemSize = bufferData._itemSize; -@@ -81487,6 +81945,9 @@ class WebGPUAttributeUtils { +@@ -81487,6 +81947,9 @@ class WebGPUAttributeUtils { } @@ -2992,7 +3085,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 } /** -@@ -82112,7 +82573,20 @@ class WebGPUBindingUtils { +@@ -82112,7 +82575,20 @@ class WebGPUBindingUtils { let resourceGPU; @@ -3014,7 +3107,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 resourceGPU = device.importExternalTexture( { source: textureData.externalTexture } ); -@@ -82283,7 +82757,7 @@ class WebGPUBindingUtils { +@@ -82283,7 +82759,7 @@ class WebGPUBindingUtils { const { primarySamples } = backend.utils.getTextureSampleData( binding.texture ); @@ -3023,7 +3116,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 texture.multisampled = true; -@@ -82321,7 +82795,7 @@ class WebGPUBindingUtils { +@@ -82321,7 +82797,7 @@ class WebGPUBindingUtils { } else if ( type === FloatType ) { @@ -3032,7 +3125,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 texture.sampleType = GPUTextureSampleType.Float; -@@ -82838,6 +83312,15 @@ class WebGPUPipelineUtils { +@@ -82838,6 +83314,15 @@ class WebGPUPipelineUtils { _renderPipelineDescriptor.reset(); @@ -3048,7 +3141,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 if ( pipelinePromise !== null ) { try { -@@ -82852,7 +83335,7 @@ class WebGPUPipelineUtils { +@@ -82852,7 +83337,7 @@ class WebGPUPipelineUtils { } @@ -3057,7 +3150,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 if ( errorScope !== null || asyncError !== null ) { -@@ -84107,6 +84590,19 @@ class WebGPUBackend extends Backend { +@@ -84107,6 +84592,19 @@ class WebGPUBackend extends Backend { */ this.gpu = null; @@ -3077,7 +3170,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 /** * A reference to the device. * -@@ -84223,6 +84719,8 @@ class WebGPUBackend extends Backend { +@@ -84223,6 +84721,8 @@ class WebGPUBackend extends Backend { const adapter = this.gpu !== null ? await this.gpu.requestAdapter( adapterOptions ) : null; @@ -3086,7 +3179,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 if ( adapter === null ) { throw new Error( 'THREE.WebGPUBackend: Unable to create WebGPU adapter.' ); -@@ -84448,7 +84946,9 @@ class WebGPUBackend extends Backend { +@@ -84448,7 +84948,9 @@ class WebGPUBackend extends Backend { let descriptor = canvasData.descriptor; @@ -3097,7 +3190,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 descriptor = new GPURenderPassDescriptor(); descriptor.colorAttachments.push( new GPURenderPassColorAttachment() ); -@@ -84475,6 +84975,9 @@ class WebGPUBackend extends Backend { +@@ -84475,6 +84977,9 @@ class WebGPUBackend extends Backend { canvasData.descriptor = descriptor; canvasData.samples = samples; @@ -3107,7 +3200,16 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 } -@@ -86215,6 +86718,12 @@ class WebGPUBackend extends Backend { +@@ -86147,7 +86652,7 @@ class WebGPUBackend extends Backend { + bindGroupLayoutKeys, + utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), + utils.getPrimitiveTopology( object, material ), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); + +@@ -86215,6 +86720,12 @@ class WebGPUBackend extends Backend { } @@ -3121,7 +3223,7 @@ index 7aa62adb9e78ba8f0a67709d3141bf93c67419cc..a308a892c59283b3b9a71477b3b02d64 * Generates mipmaps for the given texture. * diff --git a/build/three.webgpu.js b/build/three.webgpu.js -index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64db40becd 100644 +index bdffb9b4069f7beafa85913b155e92d3e83f12d0..5975e257a43fe139b69c249d8c8c1edfe91e12a0 100644 --- a/build/three.webgpu.js +++ b/build/three.webgpu.js @@ -12307,7 +12307,8 @@ class TextureSizeNode extends Node { @@ -3160,7 +3262,32 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 } -@@ -34249,6 +34258,10 @@ class Textures extends DataMap { +@@ -30412,7 +30421,7 @@ class RenderObject { + * + * @return {string} The geometry cache key. + */ +- getGeometryCacheKey() { ++ getGeometryCacheKey( useMorphTargetIds = true ) { + + const { geometry } = this; + +@@ -30440,12 +30449,14 @@ class RenderObject { + const targets = geometry.morphAttributes[ name ]; + + cacheKey += 'morph-' + name + ','; ++ if ( ! useMorphTargetIds ) cacheKey += targets.length + ',' + geometry.morphTargetsRelative + ','; + + for ( let i = 0, l = targets.length; i < l; i ++ ) { + + const attribute = targets[ i ]; + +- cacheKey += attribute.id + ','; ++ cacheKey += useMorphTargetIds ? attribute.id + ',' : ++ attribute.count + ',' + attribute.itemSize + ','; + + } + +@@ -34249,6 +34260,10 @@ class Textures extends DataMap { * @type {Set} */ this._htmlTextures = new Set(); @@ -3171,7 +3298,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 } -@@ -34398,7 +34411,16 @@ class Textures extends DataMap { +@@ -34398,7 +34413,16 @@ class Textures extends DataMap { */ updateTexture( texture, options = {} ) { @@ -3188,7 +3315,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 if ( textureData.initialized === true && textureData.version === texture.version ) return; const isRenderTarget = texture.isRenderTargetTexture || texture.isDepthTexture || texture.isFramebufferTexture; -@@ -34562,7 +34584,16 @@ class Textures extends DataMap { +@@ -34562,7 +34586,16 @@ class Textures extends DataMap { } @@ -3206,7 +3333,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 const skipAutoGeneration = texture.isStorageTexture === true && texture.mipmapsAutoUpdate === false; -@@ -34572,7 +34603,7 @@ class Textures extends DataMap { +@@ -34572,7 +34605,7 @@ class Textures extends DataMap { } @@ -3215,7 +3342,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 } -@@ -34589,6 +34620,17 @@ class Textures extends DataMap { +@@ -34589,6 +34622,17 @@ class Textures extends DataMap { } @@ -3233,7 +3360,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 // dispose handler if ( textureData.initialized !== true ) { -@@ -34621,9 +34663,134 @@ class Textures extends DataMap { +@@ -34621,9 +34665,134 @@ class Textures extends DataMap { } @@ -3370,7 +3497,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 } -@@ -34801,6 +34968,8 @@ class Textures extends DataMap { +@@ -34801,6 +34970,8 @@ class Textures extends DataMap { */ _destroyTexture( texture ) { @@ -3379,7 +3506,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 if ( this.has( texture ) === true ) { const textureData = this.get( texture ); -@@ -40515,7 +40684,7 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40515,7 +40686,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. */ @@ -3388,7 +3515,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 // null is passed to the super call since this class does not // use an external texture for rendering pass data into. Instead -@@ -40536,6 +40705,8 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40536,6 +40707,8 @@ class PassMultipleTextureNode extends PassTextureNode { * @type {boolean} */ this.previousTexture = previousTexture; @@ -3397,7 +3524,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 /** * This flag can be used for type testing. -@@ -40557,17 +40728,33 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40557,17 +40730,33 @@ class PassMultipleTextureNode extends PassTextureNode { } @@ -3432,7 +3559,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 newNode.uvNode = this.uvNode; newNode.levelNode = this.levelNode; newNode.biasNode = this.biasNode; -@@ -40748,6 +40935,8 @@ class PassNode extends TempNode { +@@ -40748,6 +40937,8 @@ class PassNode extends TempNode { * @type {Object} */ this._textureNodes = {}; @@ -3441,7 +3568,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 /** * A dictionary holding the internal depth nodes. -@@ -41054,6 +41243,7 @@ class PassNode extends TempNode { +@@ -41054,6 +41245,7 @@ class PassNode extends TempNode { this._previousTextures[ name ] = texture; this._textureNodes[ name ].updateTexture(); @@ -3449,7 +3576,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 this._previousTextureNodes[ name ].updateTexture(); } -@@ -41066,6 +41256,17 @@ class PassNode extends TempNode { +@@ -41066,6 +41258,17 @@ class PassNode extends TempNode { * @param {string} [name='output'] - The output name to get the texture node for. * @return {TextureNode} The texture node. */ @@ -3467,7 +3594,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 getTextureNode( name = 'output' ) { let textureNode = this._textureNodes[ name ]; -@@ -41408,6 +41609,7 @@ class PassNode extends TempNode { +@@ -41408,6 +41611,7 @@ class PassNode extends TempNode { */ dispose() { @@ -3475,7 +3602,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 this.renderTarget.dispose(); } -@@ -44676,6 +44878,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ +@@ -44676,6 +44880,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ const shadowMaterialLib = /*@__PURE__*/ new WeakMap(); @@ -3522,7 +3649,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 /** * A shadow filtering function performing basic filtering. This is in fact an unfiltered version of the shadow map * with a binary `[0,1]` result. -@@ -44730,8 +44972,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44730,8 +44974,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, }; @@ -3533,7 +3660,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); const radiusScaled = radius.mul( texelSize.x ); -@@ -44762,7 +45004,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44762,7 +45006,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, */ const PCFSoftShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, depthLayer } ) => { @@ -3542,7 +3669,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); -@@ -45831,8 +46073,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) +@@ -45831,8 +46075,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) */ const PointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp, shadow } ) => { @@ -3553,7 +3680,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 const texelSize = radius.div( mapSize.x ); -@@ -45868,9 +46110,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo +@@ -45868,9 +46112,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo const shadowPositionAbs = shadowPosition.abs().toConst(); const viewZ = shadowPositionAbs.x.max( shadowPositionAbs.y ).max( shadowPositionAbs.z ); @@ -3566,7 +3693,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 const result = float( 1.0 ).toVar(); -@@ -61391,6 +61633,18 @@ class Renderer { +@@ -61391,6 +61635,18 @@ class Renderer { } @@ -3585,7 +3712,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 /** * Can be used to transfer buffer data from a storage buffer attribute * from the GPU to the CPU in context of compute shaders. -@@ -62384,6 +62638,14 @@ class Renderer { +@@ -62384,6 +62640,14 @@ class Renderer { } @@ -3600,7 +3727,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 /** * Initializes the given render target. * -@@ -76680,6 +76942,9 @@ class WebGPUTextureUtils { +@@ -76680,6 +76944,9 @@ class WebGPUTextureUtils { */ this._samplerCache = new Map(); @@ -3610,7 +3737,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 } /** -@@ -76896,7 +77161,12 @@ class WebGPUTextureUtils { +@@ -76896,7 +77163,12 @@ class WebGPUTextureUtils { } @@ -3624,7 +3751,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 usage |= GPUTextureUsage.RENDER_ATTACHMENT; -@@ -76969,6 +77239,22 @@ class WebGPUTextureUtils { +@@ -76969,6 +77241,22 @@ class WebGPUTextureUtils { destroyTexture( texture, isDefaultTexture = false ) { const backend = this.backend; @@ -3647,7 +3774,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 const textureData = backend.get( texture ); if ( textureData.texture !== undefined && isDefaultTexture === false && texture.isExternalTexture !== true ) textureData.texture.destroy(); -@@ -77009,7 +77295,7 @@ class WebGPUTextureUtils { +@@ -77009,7 +77297,7 @@ class WebGPUTextureUtils { const colorTexture = canvasTarget.colorTexture; const colorTextureData = backend.get( colorTexture ); @@ -3656,7 +3783,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 return colorTextureData.texture; -@@ -77034,8 +77320,8 @@ class WebGPUTextureUtils { +@@ -77034,8 +77322,8 @@ class WebGPUTextureUtils { // @@ -3667,7 +3794,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 colorTexture.samples = samples; colorTextureData.texture = colorBuffer; -@@ -77058,64 +77344,59 @@ class WebGPUTextureUtils { +@@ -77058,64 +77346,59 @@ class WebGPUTextureUtils { const canvasTarget = backend.renderer.getCanvasTarget(); const { width, height } = backend.getDrawingBufferSize(); const samples = backend.renderer.currentSamples; @@ -3760,7 +3887,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 /** * Uploads the updated texture data to the GPU. * -@@ -77270,6 +77551,143 @@ class WebGPUTextureUtils { +@@ -77270,6 +77553,143 @@ class WebGPUTextureUtils { } @@ -3904,7 +4031,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 /** * Returns texture data as a typed array. * -@@ -77343,6 +77761,8 @@ class WebGPUTextureUtils { +@@ -77343,6 +77763,8 @@ class WebGPUTextureUtils { */ dispose() { @@ -3913,7 +4040,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 this._samplerCache.clear(); } -@@ -79269,7 +79689,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79269,7 +79691,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. */ @@ -3922,7 +4049,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 if ( levelSnippet === null ) levelSnippet = '0u'; -@@ -79293,7 +79713,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79293,7 +79715,7 @@ class WGSLNodeBuilder extends NodeBuilder { const sampleData = texture ? this.renderer?.backend?.utils?.getTextureSampleData?.( texture ) : null; const backendMultisampled = sampleData !== null && sampleData !== undefined && sampleData.primarySamples > 1; const targetMultisampled = Boolean( texture && texture.renderTarget && texture.renderTarget.samples > 1 ); @@ -3931,7 +4058,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, ${ indexType }( ${ levelSnippet } ) )`; if ( this.renderer.backend.compatibilityMode && texture.isDepthTexture ) { -@@ -79835,7 +80255,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79835,7 +80257,7 @@ class WGSLNodeBuilder extends NodeBuilder { // Cube textures always need samplers (they use textureSampleLevel, not textureLoad) // Also textureGather always need sampler. @@ -3940,7 +4067,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 if ( needsSampler ) { -@@ -80265,7 +80685,8 @@ ${ flowData.code } +@@ -80265,7 +80687,8 @@ ${ flowData.code } for ( const { name, property, type } of builtins.values() ) { @@ -3950,7 +4077,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 } -@@ -80521,6 +80942,8 @@ ${ flowData.code } +@@ -80521,6 +80944,8 @@ ${ flowData.code } getVaryings( shaderStage ) { const snippets = []; @@ -3959,7 +4086,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 if ( shaderStage === 'vertex' ) { -@@ -80543,7 +80966,12 @@ ${ flowData.code } +@@ -80543,7 +80968,12 @@ ${ flowData.code } let attributesSnippet = `@location( ${ varyingIndex ++ } )`; @@ -3973,7 +4100,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 const samplingSnippet = varying.interpolationSampling !== null ? `, ${ varying.interpolationSampling } )` : ' )'; -@@ -80647,7 +81075,7 @@ ${ flowData.code } +@@ -80647,7 +81077,7 @@ ${ flowData.code } const { primarySamples } = backend.utils.getTextureSampleData( texture ); @@ -3982,7 +4109,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 multisampled = '_multisampled'; -@@ -81402,6 +81830,15 @@ class WebGPUAttributeUtils { +@@ -81402,6 +81832,15 @@ class WebGPUAttributeUtils { buffer.unmap(); bufferData.buffer = buffer; @@ -3998,7 +4125,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 } -@@ -81422,6 +81859,11 @@ class WebGPUAttributeUtils { +@@ -81422,6 +81861,11 @@ class WebGPUAttributeUtils { const bufferData = backend.get( bufferAttribute ); const buffer = backend.get( bufferAttribute ).buffer; @@ -4010,7 +4137,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 let array = bufferAttribute.array; const itemSize = bufferData._itemSize; -@@ -81503,6 +81945,9 @@ class WebGPUAttributeUtils { +@@ -81503,6 +81947,9 @@ class WebGPUAttributeUtils { } @@ -4020,7 +4147,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 } /** -@@ -82128,7 +82573,20 @@ class WebGPUBindingUtils { +@@ -82128,7 +82575,20 @@ class WebGPUBindingUtils { let resourceGPU; @@ -4042,7 +4169,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 resourceGPU = device.importExternalTexture( { source: textureData.externalTexture } ); -@@ -82299,7 +82757,7 @@ class WebGPUBindingUtils { +@@ -82299,7 +82759,7 @@ class WebGPUBindingUtils { const { primarySamples } = backend.utils.getTextureSampleData( binding.texture ); @@ -4051,7 +4178,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 texture.multisampled = true; -@@ -82337,7 +82795,7 @@ class WebGPUBindingUtils { +@@ -82337,7 +82797,7 @@ class WebGPUBindingUtils { } else if ( type === FloatType ) { @@ -4060,7 +4187,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 texture.sampleType = GPUTextureSampleType.Float; -@@ -82854,6 +83312,15 @@ class WebGPUPipelineUtils { +@@ -82854,6 +83314,15 @@ class WebGPUPipelineUtils { _renderPipelineDescriptor.reset(); @@ -4076,7 +4203,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 if ( pipelinePromise !== null ) { try { -@@ -82868,7 +83335,7 @@ class WebGPUPipelineUtils { +@@ -82868,7 +83337,7 @@ class WebGPUPipelineUtils { } @@ -4085,7 +4212,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 if ( errorScope !== null || asyncError !== null ) { -@@ -84123,6 +84590,19 @@ class WebGPUBackend extends Backend { +@@ -84123,6 +84592,19 @@ class WebGPUBackend extends Backend { */ this.gpu = null; @@ -4105,7 +4232,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 /** * A reference to the device. * -@@ -84239,6 +84719,8 @@ class WebGPUBackend extends Backend { +@@ -84239,6 +84721,8 @@ class WebGPUBackend extends Backend { const adapter = this.gpu !== null ? await this.gpu.requestAdapter( adapterOptions ) : null; @@ -4114,7 +4241,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 if ( adapter === null ) { throw new Error( 'THREE.WebGPUBackend: Unable to create WebGPU adapter.' ); -@@ -84464,7 +84946,9 @@ class WebGPUBackend extends Backend { +@@ -84464,7 +84948,9 @@ class WebGPUBackend extends Backend { let descriptor = canvasData.descriptor; @@ -4125,7 +4252,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 descriptor = new GPURenderPassDescriptor(); descriptor.colorAttachments.push( new GPURenderPassColorAttachment() ); -@@ -84491,6 +84975,9 @@ class WebGPUBackend extends Backend { +@@ -84491,6 +84977,9 @@ class WebGPUBackend extends Backend { canvasData.descriptor = descriptor; canvasData.samples = samples; @@ -4135,7 +4262,16 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 } -@@ -86231,6 +86718,12 @@ class WebGPUBackend extends Backend { +@@ -86163,7 +86652,7 @@ class WebGPUBackend extends Backend { + bindGroupLayoutKeys, + utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), + utils.getPrimitiveTopology( object, material ), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); + +@@ -86231,6 +86720,12 @@ class WebGPUBackend extends Backend { } @@ -4149,7 +4285,7 @@ index bdffb9b4069f7beafa85913b155e92d3e83f12d0..a308a892c59283b3b9a71477b3b02d64 * Generates mipmaps for the given texture. * diff --git a/build/three.webgpu.js b/build/three.webgpu.js -index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64db40becd 100644 +index efd0e16719cc7bbef1fbc18c241948e25eb85881..5975e257a43fe139b69c249d8c8c1edfe91e12a0 100644 --- a/build/three.webgpu.js +++ b/build/three.webgpu.js @@ -12307,7 +12307,8 @@ class TextureSizeNode extends Node { @@ -4171,7 +4307,32 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 } else if ( levelSnippet ) { -@@ -34257,6 +34258,10 @@ class Textures extends DataMap { +@@ -30420,7 +30421,7 @@ class RenderObject { + * + * @return {string} The geometry cache key. + */ +- getGeometryCacheKey() { ++ getGeometryCacheKey( useMorphTargetIds = true ) { + + const { geometry } = this; + +@@ -30448,12 +30449,14 @@ class RenderObject { + const targets = geometry.morphAttributes[ name ]; + + cacheKey += 'morph-' + name + ','; ++ if ( ! useMorphTargetIds ) cacheKey += targets.length + ',' + geometry.morphTargetsRelative + ','; + + for ( let i = 0, l = targets.length; i < l; i ++ ) { + + const attribute = targets[ i ]; + +- cacheKey += attribute.id + ','; ++ cacheKey += useMorphTargetIds ? attribute.id + ',' : ++ attribute.count + ',' + attribute.itemSize + ','; + + } + +@@ -34257,6 +34260,10 @@ class Textures extends DataMap { * @type {Set} */ this._htmlTextures = new Set(); @@ -4182,7 +4343,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 } -@@ -34406,7 +34411,16 @@ class Textures extends DataMap { +@@ -34406,7 +34413,16 @@ class Textures extends DataMap { */ updateTexture( texture, options = {} ) { @@ -4199,7 +4360,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 if ( textureData.initialized === true && textureData.version === texture.version ) return; const isRenderTarget = texture.isRenderTargetTexture || texture.isDepthTexture || texture.isFramebufferTexture; -@@ -34570,7 +34584,16 @@ class Textures extends DataMap { +@@ -34570,7 +34586,16 @@ class Textures extends DataMap { } @@ -4217,7 +4378,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 const skipAutoGeneration = texture.isStorageTexture === true && texture.mipmapsAutoUpdate === false; -@@ -34580,7 +34603,7 @@ class Textures extends DataMap { +@@ -34580,7 +34605,7 @@ class Textures extends DataMap { } @@ -4226,7 +4387,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 } -@@ -34597,6 +34620,17 @@ class Textures extends DataMap { +@@ -34597,6 +34622,17 @@ class Textures extends DataMap { } @@ -4244,7 +4405,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 // dispose handler if ( textureData.initialized !== true ) { -@@ -34629,9 +34663,134 @@ class Textures extends DataMap { +@@ -34629,9 +34665,134 @@ class Textures extends DataMap { } @@ -4381,7 +4542,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 } -@@ -34809,6 +34968,8 @@ class Textures extends DataMap { +@@ -34809,6 +34970,8 @@ class Textures extends DataMap { */ _destroyTexture( texture ) { @@ -4390,7 +4551,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 if ( this.has( texture ) === true ) { const textureData = this.get( texture ); -@@ -40523,7 +40684,7 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40523,7 +40686,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. */ @@ -4399,7 +4560,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 // null is passed to the super call since this class does not // use an external texture for rendering pass data into. Instead -@@ -40544,6 +40705,8 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40544,6 +40707,8 @@ class PassMultipleTextureNode extends PassTextureNode { * @type {boolean} */ this.previousTexture = previousTexture; @@ -4408,7 +4569,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 /** * This flag can be used for type testing. -@@ -40565,17 +40728,33 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40565,17 +40730,33 @@ class PassMultipleTextureNode extends PassTextureNode { } @@ -4443,7 +4604,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 newNode.uvNode = this.uvNode; newNode.levelNode = this.levelNode; newNode.biasNode = this.biasNode; -@@ -40756,6 +40935,8 @@ class PassNode extends TempNode { +@@ -40756,6 +40937,8 @@ class PassNode extends TempNode { * @type {Object} */ this._textureNodes = {}; @@ -4452,7 +4613,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 /** * A dictionary holding the internal depth nodes. -@@ -41062,6 +41243,7 @@ class PassNode extends TempNode { +@@ -41062,6 +41245,7 @@ class PassNode extends TempNode { this._previousTextures[ name ] = texture; this._textureNodes[ name ].updateTexture(); @@ -4460,7 +4621,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 this._previousTextureNodes[ name ].updateTexture(); } -@@ -41074,6 +41256,17 @@ class PassNode extends TempNode { +@@ -41074,6 +41258,17 @@ class PassNode extends TempNode { * @param {string} [name='output'] - The output name to get the texture node for. * @return {TextureNode} The texture node. */ @@ -4478,7 +4639,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 getTextureNode( name = 'output' ) { let textureNode = this._textureNodes[ name ]; -@@ -41416,6 +41609,7 @@ class PassNode extends TempNode { +@@ -41416,6 +41611,7 @@ class PassNode extends TempNode { */ dispose() { @@ -4486,7 +4647,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 this.renderTarget.dispose(); } -@@ -44684,6 +44878,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ +@@ -44684,6 +44880,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ const shadowMaterialLib = /*@__PURE__*/ new WeakMap(); @@ -4533,7 +4694,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 /** * A shadow filtering function performing basic filtering. This is in fact an unfiltered version of the shadow map * with a binary `[0,1]` result. -@@ -44738,8 +44972,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44738,8 +44974,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, }; @@ -4544,7 +4705,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); const radiusScaled = radius.mul( texelSize.x ); -@@ -44770,7 +45004,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44770,7 +45006,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, */ const PCFSoftShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, depthLayer } ) => { @@ -4553,7 +4714,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); -@@ -45839,8 +46073,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) +@@ -45839,8 +46075,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) */ const PointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp, shadow } ) => { @@ -4564,7 +4725,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 const texelSize = radius.div( mapSize.x ); -@@ -45876,9 +46110,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo +@@ -45876,9 +46112,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo const shadowPositionAbs = shadowPosition.abs().toConst(); const viewZ = shadowPositionAbs.x.max( shadowPositionAbs.y ).max( shadowPositionAbs.z ); @@ -4577,7 +4738,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 const result = float( 1.0 ).toVar(); -@@ -61399,6 +61633,18 @@ class Renderer { +@@ -61399,6 +61635,18 @@ class Renderer { } @@ -4596,7 +4757,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 /** * Can be used to transfer buffer data from a storage buffer attribute * from the GPU to the CPU in context of compute shaders. -@@ -62392,6 +62638,14 @@ class Renderer { +@@ -62392,6 +62640,14 @@ class Renderer { } @@ -4611,7 +4772,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 /** * Initializes the given render target. * -@@ -76688,6 +76942,9 @@ class WebGPUTextureUtils { +@@ -76688,6 +76944,9 @@ class WebGPUTextureUtils { */ this._samplerCache = new Map(); @@ -4621,7 +4782,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 } /** -@@ -76904,7 +77161,12 @@ class WebGPUTextureUtils { +@@ -76904,7 +77163,12 @@ class WebGPUTextureUtils { } @@ -4635,7 +4796,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 usage |= GPUTextureUsage.RENDER_ATTACHMENT; -@@ -76977,6 +77239,22 @@ class WebGPUTextureUtils { +@@ -76977,6 +77241,22 @@ class WebGPUTextureUtils { destroyTexture( texture, isDefaultTexture = false ) { const backend = this.backend; @@ -4658,7 +4819,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 const textureData = backend.get( texture ); if ( textureData.texture !== undefined && isDefaultTexture === false && texture.isExternalTexture !== true ) textureData.texture.destroy(); -@@ -77017,7 +77295,7 @@ class WebGPUTextureUtils { +@@ -77017,7 +77297,7 @@ class WebGPUTextureUtils { const colorTexture = canvasTarget.colorTexture; const colorTextureData = backend.get( colorTexture ); @@ -4667,7 +4828,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 return colorTextureData.texture; -@@ -77042,8 +77320,8 @@ class WebGPUTextureUtils { +@@ -77042,8 +77322,8 @@ class WebGPUTextureUtils { // @@ -4678,7 +4839,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 colorTexture.samples = samples; colorTextureData.texture = colorBuffer; -@@ -77066,64 +77344,59 @@ class WebGPUTextureUtils { +@@ -77066,64 +77346,59 @@ class WebGPUTextureUtils { const canvasTarget = backend.renderer.getCanvasTarget(); const { width, height } = backend.getDrawingBufferSize(); const samples = backend.renderer.currentSamples; @@ -4771,7 +4932,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 /** * Uploads the updated texture data to the GPU. * -@@ -77278,6 +77551,143 @@ class WebGPUTextureUtils { +@@ -77278,6 +77553,143 @@ class WebGPUTextureUtils { } @@ -4915,7 +5076,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 /** * Returns texture data as a typed array. * -@@ -77351,6 +77761,8 @@ class WebGPUTextureUtils { +@@ -77351,6 +77763,8 @@ class WebGPUTextureUtils { */ dispose() { @@ -4924,7 +5085,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 this._samplerCache.clear(); } -@@ -79277,7 +79689,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79277,7 +79691,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. */ @@ -4933,7 +5094,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 if ( levelSnippet === null ) levelSnippet = '0u'; -@@ -79301,7 +79713,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79301,7 +79715,7 @@ class WGSLNodeBuilder extends NodeBuilder { const sampleData = texture ? this.renderer?.backend?.utils?.getTextureSampleData?.( texture ) : null; const backendMultisampled = sampleData !== null && sampleData !== undefined && sampleData.primarySamples > 1; const targetMultisampled = Boolean( texture && texture.renderTarget && texture.renderTarget.samples > 1 ); @@ -4942,7 +5103,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, ${ indexType }( ${ levelSnippet } ) )`; if ( this.renderer.backend.compatibilityMode && texture.isDepthTexture ) { -@@ -79843,7 +80255,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79843,7 +80257,7 @@ class WGSLNodeBuilder extends NodeBuilder { // Cube textures always need samplers (they use textureSampleLevel, not textureLoad) // Also textureGather always need sampler. @@ -4951,7 +5112,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 if ( needsSampler ) { -@@ -80273,7 +80685,8 @@ ${ flowData.code } +@@ -80273,7 +80687,8 @@ ${ flowData.code } for ( const { name, property, type } of builtins.values() ) { @@ -4961,7 +5122,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 } -@@ -80529,6 +80942,8 @@ ${ flowData.code } +@@ -80529,6 +80944,8 @@ ${ flowData.code } getVaryings( shaderStage ) { const snippets = []; @@ -4970,7 +5131,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 if ( shaderStage === 'vertex' ) { -@@ -80551,7 +80966,12 @@ ${ flowData.code } +@@ -80551,7 +80968,12 @@ ${ flowData.code } let attributesSnippet = `@location( ${ varyingIndex ++ } )`; @@ -4984,7 +5145,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 const samplingSnippet = varying.interpolationSampling !== null ? `, ${ varying.interpolationSampling } )` : ' )'; -@@ -80655,7 +81075,7 @@ ${ flowData.code } +@@ -80655,7 +81077,7 @@ ${ flowData.code } const { primarySamples } = backend.utils.getTextureSampleData( texture ); @@ -4993,7 +5154,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 multisampled = '_multisampled'; -@@ -82153,7 +82573,20 @@ class WebGPUBindingUtils { +@@ -82153,7 +82575,20 @@ class WebGPUBindingUtils { let resourceGPU; @@ -5015,7 +5176,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 resourceGPU = device.importExternalTexture( { source: textureData.externalTexture } ); -@@ -82324,7 +82757,7 @@ class WebGPUBindingUtils { +@@ -82324,7 +82759,7 @@ class WebGPUBindingUtils { const { primarySamples } = backend.utils.getTextureSampleData( binding.texture ); @@ -5024,7 +5185,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 texture.multisampled = true; -@@ -82362,7 +82795,7 @@ class WebGPUBindingUtils { +@@ -82362,7 +82797,7 @@ class WebGPUBindingUtils { } else if ( type === FloatType ) { @@ -5033,7 +5194,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 texture.sampleType = GPUTextureSampleType.Float; -@@ -82879,6 +83312,15 @@ class WebGPUPipelineUtils { +@@ -82879,6 +83314,15 @@ class WebGPUPipelineUtils { _renderPipelineDescriptor.reset(); @@ -5049,7 +5210,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 if ( pipelinePromise !== null ) { try { -@@ -82893,7 +83335,7 @@ class WebGPUPipelineUtils { +@@ -82893,7 +83337,7 @@ class WebGPUPipelineUtils { } @@ -5058,7 +5219,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 if ( errorScope !== null || asyncError !== null ) { -@@ -84148,6 +84590,19 @@ class WebGPUBackend extends Backend { +@@ -84148,6 +84592,19 @@ class WebGPUBackend extends Backend { */ this.gpu = null; @@ -5078,7 +5239,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 /** * A reference to the device. * -@@ -84264,6 +84719,8 @@ class WebGPUBackend extends Backend { +@@ -84264,6 +84721,8 @@ class WebGPUBackend extends Backend { const adapter = this.gpu !== null ? await this.gpu.requestAdapter( adapterOptions ) : null; @@ -5087,7 +5248,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 if ( adapter === null ) { throw new Error( 'THREE.WebGPUBackend: Unable to create WebGPU adapter.' ); -@@ -84489,7 +84946,9 @@ class WebGPUBackend extends Backend { +@@ -84489,7 +84948,9 @@ class WebGPUBackend extends Backend { let descriptor = canvasData.descriptor; @@ -5098,7 +5259,7 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 descriptor = new GPURenderPassDescriptor(); descriptor.colorAttachments.push( new GPURenderPassColorAttachment() ); -@@ -84516,6 +84975,9 @@ class WebGPUBackend extends Backend { +@@ -84516,6 +84977,9 @@ class WebGPUBackend extends Backend { canvasData.descriptor = descriptor; canvasData.samples = samples; @@ -5108,7 +5269,16 @@ index efd0e16719cc7bbef1fbc18c241948e25eb85881..a308a892c59283b3b9a71477b3b02d64 } -@@ -86256,6 +86718,12 @@ class WebGPUBackend extends Backend { +@@ -86188,7 +86652,7 @@ class WebGPUBackend extends Backend { + bindGroupLayoutKeys, + utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), + utils.getPrimitiveTopology( object, material ), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); + +@@ -86256,6 +86720,12 @@ class WebGPUBackend extends Backend { } @@ -10251,7 +10421,7 @@ index 89b33efb80ca1dcb9db931071dfda62ffdba3526..b1293bfbe2ccf0583711813c625ddd5c this._bindings.updateForRender( renderObject ); diff --git a/src/renderers/webgpu/WebGPUBackend.js b/src/renderers/webgpu/WebGPUBackend.js -index 69b8b67fdeec5be24de34d8382beda9020642f77..5636b40e6708a490e926e5f590675834d23bf326 100644 +index 69b8b67fdeec5be24de34d8382beda9020642f77..b86e40b357823cf3919d9fdf3c2554f14bcd9931 100644 --- a/src/renderers/webgpu/WebGPUBackend.js +++ b/src/renderers/webgpu/WebGPUBackend.js @@ -110,6 +110,19 @@ class WebGPUBackend extends Backend { @@ -10304,6 +10474,15 @@ index 69b8b67fdeec5be24de34d8382beda9020642f77..5636b40e6708a490e926e5f590675834 } +@@ -2150,7 +2170,7 @@ class WebGPUBackend extends Backend { + bindGroupLayoutKeys, + utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), + utils.getPrimitiveTopology( object, material ), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); + @@ -2218,6 +2238,12 @@ class WebGPUBackend extends Backend { } @@ -10693,7 +10872,7 @@ index ff8ee2dbace2816105aa7152891c04f2333f33e8..57c1eb62f1e0de063f22e08ec879e31d } diff --git a/build/three.webgpu.js b/build/three.webgpu.js -index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64db40becd 100644 +index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..5975e257a43fe139b69c249d8c8c1edfe91e12a0 100644 --- a/build/three.webgpu.js +++ b/build/three.webgpu.js @@ -12307,7 +12307,8 @@ class TextureSizeNode extends Node { @@ -10715,7 +10894,32 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 } else if ( levelSnippet ) { -@@ -34257,6 +34258,10 @@ class Textures extends DataMap { +@@ -30420,7 +30421,7 @@ class RenderObject { + * + * @return {string} The geometry cache key. + */ +- getGeometryCacheKey() { ++ getGeometryCacheKey( useMorphTargetIds = true ) { + + const { geometry } = this; + +@@ -30448,12 +30449,14 @@ class RenderObject { + const targets = geometry.morphAttributes[ name ]; + + cacheKey += 'morph-' + name + ','; ++ if ( ! useMorphTargetIds ) cacheKey += targets.length + ',' + geometry.morphTargetsRelative + ','; + + for ( let i = 0, l = targets.length; i < l; i ++ ) { + + const attribute = targets[ i ]; + +- cacheKey += attribute.id + ','; ++ cacheKey += useMorphTargetIds ? attribute.id + ',' : ++ attribute.count + ',' + attribute.itemSize + ','; + + } + +@@ -34257,6 +34260,10 @@ class Textures extends DataMap { * @type {Set} */ this._htmlTextures = new Set(); @@ -10726,7 +10930,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 } -@@ -34406,7 +34411,16 @@ class Textures extends DataMap { +@@ -34406,7 +34413,16 @@ class Textures extends DataMap { */ updateTexture( texture, options = {} ) { @@ -10743,7 +10947,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 if ( textureData.initialized === true && textureData.version === texture.version ) return; const isRenderTarget = texture.isRenderTargetTexture || texture.isDepthTexture || texture.isFramebufferTexture; -@@ -34570,7 +34584,16 @@ class Textures extends DataMap { +@@ -34570,7 +34586,16 @@ class Textures extends DataMap { } @@ -10761,7 +10965,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 const skipAutoGeneration = texture.isStorageTexture === true && texture.mipmapsAutoUpdate === false; -@@ -34580,7 +34603,7 @@ class Textures extends DataMap { +@@ -34580,7 +34605,7 @@ class Textures extends DataMap { } @@ -10770,7 +10974,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 } -@@ -34597,6 +34620,17 @@ class Textures extends DataMap { +@@ -34597,6 +34622,17 @@ class Textures extends DataMap { } @@ -10788,7 +10992,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 // dispose handler if ( textureData.initialized !== true ) { -@@ -34629,9 +34663,134 @@ class Textures extends DataMap { +@@ -34629,9 +34665,134 @@ class Textures extends DataMap { } @@ -10925,7 +11129,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 } -@@ -34809,6 +34968,8 @@ class Textures extends DataMap { +@@ -34809,6 +34970,8 @@ class Textures extends DataMap { */ _destroyTexture( texture ) { @@ -10934,7 +11138,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 if ( this.has( texture ) === true ) { const textureData = this.get( texture ); -@@ -40523,7 +40684,7 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40523,7 +40686,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. */ @@ -10943,7 +11147,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 // null is passed to the super call since this class does not // use an external texture for rendering pass data into. Instead -@@ -40544,6 +40705,8 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40544,6 +40707,8 @@ class PassMultipleTextureNode extends PassTextureNode { * @type {boolean} */ this.previousTexture = previousTexture; @@ -10952,7 +11156,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 /** * This flag can be used for type testing. -@@ -40565,17 +40728,33 @@ class PassMultipleTextureNode extends PassTextureNode { +@@ -40565,17 +40730,33 @@ class PassMultipleTextureNode extends PassTextureNode { } @@ -10987,7 +11191,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 newNode.uvNode = this.uvNode; newNode.levelNode = this.levelNode; newNode.biasNode = this.biasNode; -@@ -40756,6 +40935,8 @@ class PassNode extends TempNode { +@@ -40756,6 +40937,8 @@ class PassNode extends TempNode { * @type {Object} */ this._textureNodes = {}; @@ -10996,7 +11200,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 /** * A dictionary holding the internal depth nodes. -@@ -41062,6 +41243,7 @@ class PassNode extends TempNode { +@@ -41062,6 +41245,7 @@ class PassNode extends TempNode { this._previousTextures[ name ] = texture; this._textureNodes[ name ].updateTexture(); @@ -11004,7 +11208,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 this._previousTextureNodes[ name ].updateTexture(); } -@@ -41074,6 +41256,17 @@ class PassNode extends TempNode { +@@ -41074,6 +41258,17 @@ class PassNode extends TempNode { * @param {string} [name='output'] - The output name to get the texture node for. * @return {TextureNode} The texture node. */ @@ -11022,7 +11226,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 getTextureNode( name = 'output' ) { let textureNode = this._textureNodes[ name ]; -@@ -41416,6 +41609,7 @@ class PassNode extends TempNode { +@@ -41416,6 +41611,7 @@ class PassNode extends TempNode { */ dispose() { @@ -11030,7 +11234,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 this.renderTarget.dispose(); } -@@ -44684,6 +44878,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ +@@ -44684,6 +44880,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ const shadowMaterialLib = /*@__PURE__*/ new WeakMap(); @@ -11077,7 +11281,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 /** * A shadow filtering function performing basic filtering. This is in fact an unfiltered version of the shadow map * with a binary `[0,1]` result. -@@ -44738,8 +44972,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44738,8 +44974,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, }; @@ -11088,7 +11292,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); const radiusScaled = radius.mul( texelSize.x ); -@@ -44770,7 +45004,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44770,7 +45006,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, */ const PCFSoftShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, depthLayer } ) => { @@ -11097,7 +11301,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); -@@ -45839,8 +46073,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) +@@ -45839,8 +46075,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) */ const PointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp, shadow } ) => { @@ -11108,7 +11312,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 const texelSize = radius.div( mapSize.x ); -@@ -45876,9 +46110,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo +@@ -45876,9 +46112,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo const shadowPositionAbs = shadowPosition.abs().toConst(); const viewZ = shadowPositionAbs.x.max( shadowPositionAbs.y ).max( shadowPositionAbs.z ); @@ -11121,7 +11325,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 const result = float( 1.0 ).toVar(); -@@ -61399,6 +61633,18 @@ class Renderer { +@@ -61399,6 +61635,18 @@ class Renderer { } @@ -11140,7 +11344,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 /** * Can be used to transfer buffer data from a storage buffer attribute * from the GPU to the CPU in context of compute shaders. -@@ -62392,6 +62638,14 @@ class Renderer { +@@ -62392,6 +62640,14 @@ class Renderer { } @@ -11155,7 +11359,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 /** * Initializes the given render target. * -@@ -76688,6 +76942,9 @@ class WebGPUTextureUtils { +@@ -76688,6 +76944,9 @@ class WebGPUTextureUtils { */ this._samplerCache = new Map(); @@ -11165,7 +11369,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 } /** -@@ -76904,12 +77161,12 @@ class WebGPUTextureUtils { +@@ -76904,12 +77163,12 @@ class WebGPUTextureUtils { } @@ -11182,7 +11386,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 usage |= GPUTextureUsage.RENDER_ATTACHMENT; -@@ -76982,6 +77239,22 @@ class WebGPUTextureUtils { +@@ -76982,6 +77241,22 @@ class WebGPUTextureUtils { destroyTexture( texture, isDefaultTexture = false ) { const backend = this.backend; @@ -11205,7 +11409,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 const textureData = backend.get( texture ); if ( textureData.texture !== undefined && isDefaultTexture === false && texture.isExternalTexture !== true ) textureData.texture.destroy(); -@@ -77022,7 +77295,7 @@ class WebGPUTextureUtils { +@@ -77022,7 +77297,7 @@ class WebGPUTextureUtils { const colorTexture = canvasTarget.colorTexture; const colorTextureData = backend.get( colorTexture ); @@ -11214,7 +11418,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 return colorTextureData.texture; -@@ -77047,8 +77320,8 @@ class WebGPUTextureUtils { +@@ -77047,8 +77322,8 @@ class WebGPUTextureUtils { // @@ -11225,7 +11429,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 colorTexture.samples = samples; colorTextureData.texture = colorBuffer; -@@ -77071,64 +77344,59 @@ class WebGPUTextureUtils { +@@ -77071,64 +77346,59 @@ class WebGPUTextureUtils { const canvasTarget = backend.renderer.getCanvasTarget(); const { width, height } = backend.getDrawingBufferSize(); const samples = backend.renderer.currentSamples; @@ -11318,7 +11522,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 /** * Uploads the updated texture data to the GPU. * -@@ -77283,6 +77551,143 @@ class WebGPUTextureUtils { +@@ -77283,6 +77553,143 @@ class WebGPUTextureUtils { } @@ -11462,7 +11666,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 /** * Returns texture data as a typed array. * -@@ -77356,6 +77761,8 @@ class WebGPUTextureUtils { +@@ -77356,6 +77763,8 @@ class WebGPUTextureUtils { */ dispose() { @@ -11471,7 +11675,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 this._samplerCache.clear(); } -@@ -79282,7 +79689,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79282,7 +79691,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. */ @@ -11480,7 +11684,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 if ( levelSnippet === null ) levelSnippet = '0u'; -@@ -79306,7 +79713,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79306,7 +79715,7 @@ class WGSLNodeBuilder extends NodeBuilder { const sampleData = texture ? this.renderer?.backend?.utils?.getTextureSampleData?.( texture ) : null; const backendMultisampled = sampleData !== null && sampleData !== undefined && sampleData.primarySamples > 1; const targetMultisampled = Boolean( texture && texture.renderTarget && texture.renderTarget.samples > 1 ); @@ -11489,7 +11693,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 snippet = `textureLoad( ${ textureProperty }, ${ uvIndexSnippet }, ${ indexType }( ${ levelSnippet } ) )`; if ( this.renderer.backend.compatibilityMode && texture.isDepthTexture ) { -@@ -79848,7 +80255,7 @@ class WGSLNodeBuilder extends NodeBuilder { +@@ -79848,7 +80257,7 @@ class WGSLNodeBuilder extends NodeBuilder { // Cube textures always need samplers (they use textureSampleLevel, not textureLoad) // Also textureGather always need sampler. @@ -11498,7 +11702,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 if ( needsSampler ) { -@@ -80278,7 +80685,8 @@ ${ flowData.code } +@@ -80278,7 +80687,8 @@ ${ flowData.code } for ( const { name, property, type } of builtins.values() ) { @@ -11508,7 +11712,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 } -@@ -80534,6 +80942,8 @@ ${ flowData.code } +@@ -80534,6 +80944,8 @@ ${ flowData.code } getVaryings( shaderStage ) { const snippets = []; @@ -11517,7 +11721,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 if ( shaderStage === 'vertex' ) { -@@ -80556,7 +80966,12 @@ ${ flowData.code } +@@ -80556,7 +80968,12 @@ ${ flowData.code } let attributesSnippet = `@location( ${ varyingIndex ++ } )`; @@ -11531,7 +11735,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 const samplingSnippet = varying.interpolationSampling !== null ? `, ${ varying.interpolationSampling } )` : ' )'; -@@ -80660,7 +81075,7 @@ ${ flowData.code } +@@ -80660,7 +81077,7 @@ ${ flowData.code } const { primarySamples } = backend.utils.getTextureSampleData( texture ); @@ -11540,7 +11744,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 multisampled = '_multisampled'; -@@ -82158,7 +82573,20 @@ class WebGPUBindingUtils { +@@ -82158,7 +82575,20 @@ class WebGPUBindingUtils { let resourceGPU; @@ -11562,7 +11766,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 resourceGPU = device.importExternalTexture( { source: textureData.externalTexture } ); -@@ -82329,7 +82757,7 @@ class WebGPUBindingUtils { +@@ -82329,7 +82759,7 @@ class WebGPUBindingUtils { const { primarySamples } = backend.utils.getTextureSampleData( binding.texture ); @@ -11571,7 +11775,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 texture.multisampled = true; -@@ -82367,7 +82795,7 @@ class WebGPUBindingUtils { +@@ -82367,7 +82797,7 @@ class WebGPUBindingUtils { } else if ( type === FloatType ) { @@ -11580,7 +11784,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 texture.sampleType = GPUTextureSampleType.Float; -@@ -82884,6 +83312,15 @@ class WebGPUPipelineUtils { +@@ -82884,6 +83314,15 @@ class WebGPUPipelineUtils { _renderPipelineDescriptor.reset(); @@ -11596,7 +11800,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 if ( pipelinePromise !== null ) { try { -@@ -82898,7 +83335,7 @@ class WebGPUPipelineUtils { +@@ -82898,7 +83337,7 @@ class WebGPUPipelineUtils { } @@ -11605,7 +11809,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 if ( errorScope !== null || asyncError !== null ) { -@@ -84509,7 +84946,9 @@ class WebGPUBackend extends Backend { +@@ -84509,7 +84948,9 @@ class WebGPUBackend extends Backend { let descriptor = canvasData.descriptor; @@ -11616,7 +11820,7 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 descriptor = new GPURenderPassDescriptor(); descriptor.colorAttachments.push( new GPURenderPassColorAttachment() ); -@@ -84536,6 +84975,9 @@ class WebGPUBackend extends Backend { +@@ -84536,6 +84977,9 @@ class WebGPUBackend extends Backend { canvasData.descriptor = descriptor; canvasData.samples = samples; @@ -11626,7 +11830,16 @@ index 9166d6f2dd1ac1505fa27b625f1976dff6b8ebc1..a308a892c59283b3b9a71477b3b02d64 } -@@ -86276,6 +86718,12 @@ class WebGPUBackend extends Backend { +@@ -86208,7 +86652,7 @@ class WebGPUBackend extends Backend { + bindGroupLayoutKeys, + utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), + utils.getPrimitiveTopology( object, material ), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); + +@@ -86276,6 +86720,12 @@ class WebGPUBackend extends Backend { } @@ -12869,10 +13082,35 @@ index a5da7ca09f746c0b2859ef173df663ccdc851ebc..c479d0a88794b01bca531e7cbfb780cf texture.sampleType = GPUTextureSampleType.Float; diff --git a/build/three.webgpu.js b/build/three.webgpu.js -index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64db40becd 100644 +index 941ec68ef0b8174a633054043edb94d9d3d58f5e..5975e257a43fe139b69c249d8c8c1edfe91e12a0 100644 --- a/build/three.webgpu.js +++ b/build/three.webgpu.js -@@ -34258,6 +34258,10 @@ class Textures extends DataMap { +@@ -30421,7 +30421,7 @@ class RenderObject { + * + * @return {string} The geometry cache key. + */ +- getGeometryCacheKey() { ++ getGeometryCacheKey( useMorphTargetIds = true ) { + + const { geometry } = this; + +@@ -30449,12 +30449,14 @@ class RenderObject { + const targets = geometry.morphAttributes[ name ]; + + cacheKey += 'morph-' + name + ','; ++ if ( ! useMorphTargetIds ) cacheKey += targets.length + ',' + geometry.morphTargetsRelative + ','; + + for ( let i = 0, l = targets.length; i < l; i ++ ) { + + const attribute = targets[ i ]; + +- cacheKey += attribute.id + ','; ++ cacheKey += useMorphTargetIds ? attribute.id + ',' : ++ attribute.count + ',' + attribute.itemSize + ','; + + } + +@@ -34258,6 +34260,10 @@ class Textures extends DataMap { * @type {Set} */ this._htmlTextures = new Set(); @@ -12883,7 +13121,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 } -@@ -34407,7 +34411,16 @@ class Textures extends DataMap { +@@ -34407,7 +34413,16 @@ class Textures extends DataMap { */ updateTexture( texture, options = {} ) { @@ -12900,7 +13138,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 if ( textureData.initialized === true && textureData.version === texture.version ) return; const isRenderTarget = texture.isRenderTargetTexture || texture.isDepthTexture || texture.isFramebufferTexture; -@@ -34571,7 +34584,16 @@ class Textures extends DataMap { +@@ -34571,7 +34586,16 @@ class Textures extends DataMap { } @@ -12918,7 +13156,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 const skipAutoGeneration = texture.isStorageTexture === true && texture.mipmapsAutoUpdate === false; -@@ -34581,7 +34603,7 @@ class Textures extends DataMap { +@@ -34581,7 +34605,7 @@ class Textures extends DataMap { } @@ -12927,7 +13165,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 } -@@ -34598,6 +34620,17 @@ class Textures extends DataMap { +@@ -34598,6 +34622,17 @@ class Textures extends DataMap { } @@ -12945,7 +13183,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 // dispose handler if ( textureData.initialized !== true ) { -@@ -34630,9 +34663,134 @@ class Textures extends DataMap { +@@ -34630,9 +34665,134 @@ class Textures extends DataMap { } @@ -13082,7 +13320,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 } -@@ -34810,6 +34968,8 @@ class Textures extends DataMap { +@@ -34810,6 +34970,8 @@ class Textures extends DataMap { */ _destroyTexture( texture ) { @@ -13091,7 +13329,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 if ( this.has( texture ) === true ) { const textureData = this.get( texture ); -@@ -44718,6 +44878,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ +@@ -44718,6 +44880,46 @@ var RendererUtils = /*#__PURE__*/Object.freeze({ const shadowMaterialLib = /*@__PURE__*/ new WeakMap(); @@ -13138,7 +13376,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 /** * A shadow filtering function performing basic filtering. This is in fact an unfiltered version of the shadow map * with a binary `[0,1]` result. -@@ -44772,8 +44972,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44772,8 +44974,8 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, }; @@ -13149,7 +13387,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); const radiusScaled = radius.mul( texelSize.x ); -@@ -44804,7 +45004,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, +@@ -44804,7 +45006,7 @@ const PCFShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, */ const PCFSoftShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, shadowCoord, shadow, depthLayer } ) => { @@ -13158,7 +13396,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 const texelSize = vec2( 1 ).div( mapSize ); -@@ -45873,8 +46073,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) +@@ -45873,8 +46075,8 @@ const BasicPointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp } ) */ const PointShadowFilter = /*@__PURE__*/ Fn( ( { depthTexture, bd3D, dp, shadow } ) => { @@ -13169,7 +13407,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 const texelSize = radius.div( mapSize.x ); -@@ -45910,9 +46110,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo +@@ -45910,9 +46112,9 @@ const pointShadowFilter = /*@__PURE__*/ Fn( ( { filterFn, depthTexture, shadowCo const shadowPositionAbs = shadowPosition.abs().toConst(); const viewZ = shadowPositionAbs.x.max( shadowPositionAbs.y ).max( shadowPositionAbs.z ); @@ -13182,7 +13420,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 const result = float( 1.0 ).toVar(); -@@ -62438,6 +62638,14 @@ class Renderer { +@@ -62438,6 +62640,14 @@ class Renderer { } @@ -13197,7 +13435,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 /** * Initializes the given render target. * -@@ -76734,6 +76942,9 @@ class WebGPUTextureUtils { +@@ -76734,6 +76944,9 @@ class WebGPUTextureUtils { */ this._samplerCache = new Map(); @@ -13207,7 +13445,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 } /** -@@ -76950,12 +77161,12 @@ class WebGPUTextureUtils { +@@ -76950,12 +77163,12 @@ class WebGPUTextureUtils { } @@ -13224,7 +13462,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 usage |= GPUTextureUsage.RENDER_ATTACHMENT; -@@ -77028,6 +77239,22 @@ class WebGPUTextureUtils { +@@ -77028,6 +77241,22 @@ class WebGPUTextureUtils { destroyTexture( texture, isDefaultTexture = false ) { const backend = this.backend; @@ -13247,7 +13485,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 const textureData = backend.get( texture ); if ( textureData.texture !== undefined && isDefaultTexture === false && texture.isExternalTexture !== true ) textureData.texture.destroy(); -@@ -77068,7 +77295,7 @@ class WebGPUTextureUtils { +@@ -77068,7 +77297,7 @@ class WebGPUTextureUtils { const colorTexture = canvasTarget.colorTexture; const colorTextureData = backend.get( colorTexture ); @@ -13256,7 +13494,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 return colorTextureData.texture; -@@ -77093,8 +77320,8 @@ class WebGPUTextureUtils { +@@ -77093,8 +77322,8 @@ class WebGPUTextureUtils { // @@ -13267,7 +13505,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 colorTexture.samples = samples; colorTextureData.texture = colorBuffer; -@@ -77117,64 +77344,59 @@ class WebGPUTextureUtils { +@@ -77117,64 +77346,59 @@ class WebGPUTextureUtils { const canvasTarget = backend.renderer.getCanvasTarget(); const { width, height } = backend.getDrawingBufferSize(); const samples = backend.renderer.currentSamples; @@ -13360,7 +13598,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 /** * Uploads the updated texture data to the GPU. * -@@ -77329,6 +77551,143 @@ class WebGPUTextureUtils { +@@ -77329,6 +77553,143 @@ class WebGPUTextureUtils { } @@ -13504,7 +13742,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 /** * Returns texture data as a typed array. * -@@ -77402,6 +77761,8 @@ class WebGPUTextureUtils { +@@ -77402,6 +77763,8 @@ class WebGPUTextureUtils { */ dispose() { @@ -13513,7 +13751,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 this._samplerCache.clear(); } -@@ -82951,6 +83312,15 @@ class WebGPUPipelineUtils { +@@ -82951,6 +83314,15 @@ class WebGPUPipelineUtils { _renderPipelineDescriptor.reset(); @@ -13529,7 +13767,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 if ( pipelinePromise !== null ) { try { -@@ -82965,7 +83335,7 @@ class WebGPUPipelineUtils { +@@ -82965,7 +83337,7 @@ class WebGPUPipelineUtils { } @@ -13538,7 +13776,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 if ( errorScope !== null || asyncError !== null ) { -@@ -84576,7 +84946,9 @@ class WebGPUBackend extends Backend { +@@ -84576,7 +84948,9 @@ class WebGPUBackend extends Backend { let descriptor = canvasData.descriptor; @@ -13549,7 +13787,7 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 descriptor = new GPURenderPassDescriptor(); descriptor.colorAttachments.push( new GPURenderPassColorAttachment() ); -@@ -84603,6 +84975,9 @@ class WebGPUBackend extends Backend { +@@ -84603,6 +84977,9 @@ class WebGPUBackend extends Backend { canvasData.descriptor = descriptor; canvasData.samples = samples; @@ -13559,7 +13797,16 @@ index 941ec68ef0b8174a633054043edb94d9d3d58f5e..a308a892c59283b3b9a71477b3b02d64 } -@@ -86343,6 +86718,12 @@ class WebGPUBackend extends Backend { +@@ -86275,7 +86652,7 @@ class WebGPUBackend extends Backend { + bindGroupLayoutKeys, + utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), + utils.getPrimitiveTopology( object, material ), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); + +@@ -86343,6 +86720,12 @@ class WebGPUBackend extends Backend { } @@ -14277,10 +14524,35 @@ index 57f69c17352b936a9eca118a9801805c95c401b6..95669d28b54250a3857dcb7d1b1b0e30 * Generates mipmaps for the given texture. * diff --git a/build/three.webgpu.js b/build/three.webgpu.js -index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64db40becd 100644 +index ef853fd6f7382a8c7ff66cda43471344aeb20770..5975e257a43fe139b69c249d8c8c1edfe91e12a0 100644 --- a/build/three.webgpu.js +++ b/build/three.webgpu.js -@@ -34258,6 +34258,10 @@ class Textures extends DataMap { +@@ -30421,7 +30421,7 @@ class RenderObject { + * + * @return {string} The geometry cache key. + */ +- getGeometryCacheKey() { ++ getGeometryCacheKey( useMorphTargetIds = true ) { + + const { geometry } = this; + +@@ -30449,12 +30449,14 @@ class RenderObject { + const targets = geometry.morphAttributes[ name ]; + + cacheKey += 'morph-' + name + ','; ++ if ( ! useMorphTargetIds ) cacheKey += targets.length + ',' + geometry.morphTargetsRelative + ','; + + for ( let i = 0, l = targets.length; i < l; i ++ ) { + + const attribute = targets[ i ]; + +- cacheKey += attribute.id + ','; ++ cacheKey += useMorphTargetIds ? attribute.id + ',' : ++ attribute.count + ',' + attribute.itemSize + ','; + + } + +@@ -34258,6 +34260,10 @@ class Textures extends DataMap { * @type {Set} */ this._htmlTextures = new Set(); @@ -14291,7 +14563,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 } -@@ -34407,7 +34411,16 @@ class Textures extends DataMap { +@@ -34407,7 +34413,16 @@ class Textures extends DataMap { */ updateTexture( texture, options = {} ) { @@ -14308,7 +14580,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 if ( textureData.initialized === true && textureData.version === texture.version ) return; const isRenderTarget = texture.isRenderTargetTexture || texture.isDepthTexture || texture.isFramebufferTexture; -@@ -34571,7 +34584,16 @@ class Textures extends DataMap { +@@ -34571,7 +34586,16 @@ class Textures extends DataMap { } @@ -14326,7 +14598,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 const skipAutoGeneration = texture.isStorageTexture === true && texture.mipmapsAutoUpdate === false; -@@ -34581,7 +34603,7 @@ class Textures extends DataMap { +@@ -34581,7 +34605,7 @@ class Textures extends DataMap { } @@ -14335,7 +14607,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 } -@@ -34598,6 +34620,17 @@ class Textures extends DataMap { +@@ -34598,6 +34622,17 @@ class Textures extends DataMap { } @@ -14353,7 +14625,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 // dispose handler if ( textureData.initialized !== true ) { -@@ -34630,9 +34663,134 @@ class Textures extends DataMap { +@@ -34630,9 +34665,134 @@ class Textures extends DataMap { } @@ -14490,7 +14762,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 } -@@ -34810,6 +34968,8 @@ class Textures extends DataMap { +@@ -34810,6 +34970,8 @@ class Textures extends DataMap { */ _destroyTexture( texture ) { @@ -14499,7 +14771,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 if ( this.has( texture ) === true ) { const textureData = this.get( texture ); -@@ -62478,6 +62638,14 @@ class Renderer { +@@ -62478,6 +62640,14 @@ class Renderer { } @@ -14514,7 +14786,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 /** * Initializes the given render target. * -@@ -76774,6 +76942,9 @@ class WebGPUTextureUtils { +@@ -76774,6 +76944,9 @@ class WebGPUTextureUtils { */ this._samplerCache = new Map(); @@ -14524,7 +14796,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 } /** -@@ -77068,6 +77239,22 @@ class WebGPUTextureUtils { +@@ -77068,6 +77241,22 @@ class WebGPUTextureUtils { destroyTexture( texture, isDefaultTexture = false ) { const backend = this.backend; @@ -14547,7 +14819,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 const textureData = backend.get( texture ); if ( textureData.texture !== undefined && isDefaultTexture === false && texture.isExternalTexture !== true ) textureData.texture.destroy(); -@@ -77108,7 +77295,7 @@ class WebGPUTextureUtils { +@@ -77108,7 +77297,7 @@ class WebGPUTextureUtils { const colorTexture = canvasTarget.colorTexture; const colorTextureData = backend.get( colorTexture ); @@ -14556,7 +14828,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 return colorTextureData.texture; -@@ -77133,8 +77320,8 @@ class WebGPUTextureUtils { +@@ -77133,8 +77322,8 @@ class WebGPUTextureUtils { // @@ -14567,7 +14839,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 colorTexture.samples = samples; colorTextureData.texture = colorBuffer; -@@ -77157,64 +77344,59 @@ class WebGPUTextureUtils { +@@ -77157,64 +77346,59 @@ class WebGPUTextureUtils { const canvasTarget = backend.renderer.getCanvasTarget(); const { width, height } = backend.getDrawingBufferSize(); const samples = backend.renderer.currentSamples; @@ -14660,7 +14932,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 /** * Uploads the updated texture data to the GPU. * -@@ -77369,6 +77551,143 @@ class WebGPUTextureUtils { +@@ -77369,6 +77553,143 @@ class WebGPUTextureUtils { } @@ -14804,7 +15076,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 /** * Returns texture data as a typed array. * -@@ -77442,6 +77761,8 @@ class WebGPUTextureUtils { +@@ -77442,6 +77763,8 @@ class WebGPUTextureUtils { */ dispose() { @@ -14813,7 +15085,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 this._samplerCache.clear(); } -@@ -82991,6 +83312,15 @@ class WebGPUPipelineUtils { +@@ -82991,6 +83314,15 @@ class WebGPUPipelineUtils { _renderPipelineDescriptor.reset(); @@ -14829,7 +15101,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 if ( pipelinePromise !== null ) { try { -@@ -83005,7 +83335,7 @@ class WebGPUPipelineUtils { +@@ -83005,7 +83337,7 @@ class WebGPUPipelineUtils { } @@ -14838,7 +15110,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 if ( errorScope !== null || asyncError !== null ) { -@@ -84616,7 +84946,9 @@ class WebGPUBackend extends Backend { +@@ -84616,7 +84948,9 @@ class WebGPUBackend extends Backend { let descriptor = canvasData.descriptor; @@ -14849,7 +15121,7 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 descriptor = new GPURenderPassDescriptor(); descriptor.colorAttachments.push( new GPURenderPassColorAttachment() ); -@@ -84643,6 +84975,9 @@ class WebGPUBackend extends Backend { +@@ -84643,6 +84977,9 @@ class WebGPUBackend extends Backend { canvasData.descriptor = descriptor; canvasData.samples = samples; @@ -14859,7 +15131,16 @@ index ef853fd6f7382a8c7ff66cda43471344aeb20770..a308a892c59283b3b9a71477b3b02d64 } -@@ -86383,6 +86718,12 @@ class WebGPUBackend extends Backend { +@@ -86315,7 +86652,7 @@ class WebGPUBackend extends Backend { + bindGroupLayoutKeys, + utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), + utils.getPrimitiveTopology( object, material ), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); + +@@ -86383,6 +86720,12 @@ class WebGPUBackend extends Backend { } @@ -15488,7 +15769,7 @@ index 730ce0ccd0f56d70c4a7abe7c514c5077d544d1e..b1293bfbe2ccf0583711813c625ddd5c * Initializes the given render target. * diff --git a/src/renderers/webgpu/WebGPUBackend.js b/src/renderers/webgpu/WebGPUBackend.js -index 927e3a1ad5a4665d77238ffbab4c57a603b86828..5636b40e6708a490e926e5f590675834d23bf326 100644 +index 927e3a1ad5a4665d77238ffbab4c57a603b86828..b86e40b357823cf3919d9fdf3c2554f14bcd9931 100644 --- a/src/renderers/webgpu/WebGPUBackend.js +++ b/src/renderers/webgpu/WebGPUBackend.js @@ -466,7 +466,9 @@ class WebGPUBackend extends Backend { @@ -15512,6 +15793,15 @@ index 927e3a1ad5a4665d77238ffbab4c57a603b86828..5636b40e6708a490e926e5f590675834 } +@@ -2165,7 +2170,7 @@ class WebGPUBackend extends Backend { + bindGroupLayoutKeys, + utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), + utils.getPrimitiveTopology( object, material ), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); + @@ -2233,6 +2238,12 @@ class WebGPUBackend extends Backend { } diff --git a/packages/core/patches/three@0.185.1.patch b/packages/core/patches/three@0.185.1.patch index 8b575d1a4b..ae3497c9e3 100644 --- a/packages/core/patches/three@0.185.1.patch +++ b/packages/core/patches/three@0.185.1.patch @@ -448,6 +448,30 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..a308a892c59283b3b9a71477b3b02d64 } +@@ -30286,7 +30424,7 @@ class RenderObject { +- getGeometryCacheKey() { ++ getGeometryCacheKey( useMorphTargetIds = true ) { + + const { geometry } = this; + + let cacheKey = ''; + + for ( const name of Object.keys( geometry.attributes ).sort() ) { +@@ -30312,11 +30450,13 @@ class RenderObject { + + cacheKey += 'morph-' + name + ','; ++ if ( ! useMorphTargetIds ) cacheKey += targets.length + ',' + geometry.morphTargetsRelative + ','; + + for ( let i = 0, l = targets.length; i < l; i ++ ) { + + const attribute = targets[ i ]; + +- cacheKey += attribute.id + ','; ++ cacheKey += useMorphTargetIds ? attribute.id + ',' : ++ attribute.count + ',' + attribute.itemSize + ','; + + } + @@ -30548,6 +30686,7 @@ class RenderObject { } @@ -1990,14 +2014,17 @@ index 2e490796d4d4aa9479b04a5c46ddbf31cc05b1a1..a308a892c59283b3b9a71477b3b02d64 // meshes with negative scale have a different frontFace render pipeline // descriptor value so the following must be honored in the cache key -@@ -85846,6 +86647,7 @@ class WebGPUBackend extends Backend { +@@ -85846,8 +86647,9 @@ class WebGPUBackend extends Backend { material.side, frontFaceCW, utils.getSampleCountRenderContext( renderContext ), + bindGroupLayoutKeys, utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), utils.getPrimitiveTopology( object, material ), - renderObject.getGeometryCacheKey(), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); @@ -85916,6 +86718,12 @@ class WebGPUBackend extends Backend { } @@ -4539,6 +4566,29 @@ index c900fa4c9654f8cf3512e96de52a2c1c83e189a6..a3dcba2f1fb0df19f3ddb3039a97ae40 } +@@ -672,6 +684,6 @@ class RenderObject { + * @return {string} The geometry cache key. + */ +- getGeometryCacheKey() { ++ getGeometryCacheKey( useMorphTargetIds = true ) { + + const { geometry } = this; + +@@ -700,11 +712,13 @@ class RenderObject { + + cacheKey += 'morph-' + name + ','; ++ if ( ! useMorphTargetIds ) cacheKey += targets.length + ',' + geometry.morphTargetsRelative + ','; + + for ( let i = 0, l = targets.length; i < l; i ++ ) { + + const attribute = targets[ i ]; + +- cacheKey += attribute.id + ','; ++ cacheKey += useMorphTargetIds ? attribute.id + ',' : ++ attribute.count + ',' + attribute.itemSize + ','; + + } + @@ -936,6 +948,7 @@ class RenderObject { } @@ -5167,14 +5217,17 @@ index 06e203693ed9b168a2bddf773cd8ce8dc23196e4..5636b40e6708a490e926e5f590675834 // meshes with negative scale have a different frontFace render pipeline // descriptor value so the following must be honored in the cache key -@@ -2134,6 +2167,7 @@ class WebGPUBackend extends Backend { +@@ -2134,8 +2167,9 @@ class WebGPUBackend extends Backend { material.side, frontFaceCW, utils.getSampleCountRenderContext( renderContext ), + bindGroupLayoutKeys, utils.getCurrentColorSpace( renderContext ), utils.getCurrentColorFormat( renderContext ), utils.getCurrentDepthStencilFormat( renderContext ), utils.getPrimitiveTopology( object, material ), - renderObject.getGeometryCacheKey(), +- renderObject.getGeometryCacheKey(), ++ renderObject.getGeometryCacheKey( false ), + renderObject.clippingContextCacheKey + ].join(); @@ -2204,6 +2238,12 @@ class WebGPUBackend extends Backend { } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8198903c6f..0908606660 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -445,7 +445,11 @@ export type { IClusteredMeshOptions, IClusterTable } from "./clustered-mesh.js"; * const hull = await ctx.assets.model("hull.glb"); * ctx.scene.add(hull.scene); */ -export { updateModelLods } from "./model-lod.js"; +export { + DISCRETE_LOD_DEFAULT_ERROR_PIXELS, + DISCRETE_LOD_DEFAULT_HYSTERESIS, + updateModelLods, +} from "./model-lod.js"; /** * The full-detail geometry of a mesh the loader gave an automatic LOD chain. * diff --git a/packages/core/src/world-cells.ts b/packages/core/src/world-cells.ts index 1e7599fcc2..ac7a34bfe2 100644 --- a/packages/core/src/world-cells.ts +++ b/packages/core/src/world-cells.ts @@ -4959,11 +4959,22 @@ export class WorldCells extends Group implements IComputeDriven { // walked and re-sorted, every resident cell refiltered, every terrain tile re-levelled, all of // it to reach the set it already holds. Skipped while both hold. A move, a load, a build, a // deferred admission or a shadow level not yet told runs the pass exactly as before. + const terrainView = + this.#terrain !== undefined && + this.#camera !== undefined && + this.#renderer?.domElement?.height !== undefined + ? { + camera: this.#camera, + viewportHeight: this.#renderer.domElement.height, + maxPixelError: this.#autoLodMaxPixelError, + hysteresis: this.#lodHysteresis, + } + : undefined; if (this.#residencyStale(x, z)) { this.#residencyPoint.set(x, this.#followY(), z); try { - this.#terrain?.follow({ x, z }, budget); - this.#terrain?.process(renderer); + this.#terrain?.follow({ x, z }, budget, terrainView); + this.#terrain?.process(renderer, camera, undefined, terrainView); } catch (error) { if (!(error instanceof TerrainTileBudgetError)) throw error; this.#pressure.bytes += 1; @@ -4991,11 +5002,12 @@ export class WorldCells extends Group implements IComputeDriven { } else if ( // A blend in flight is not a settled ring, and the blend is advanced by `process` alone — // `follow` only re-levels tiles. A follow point standing still used to skip both, so a - // three-frame tile morph froze on its first frame until the player moved again. Only the - // transition frames pay it: `blendingTiles` is 0 the rest of the time. + // three-frame tile morph froze on its first frame until the player moved again. + // With a render view, settled tiles also recheck cached errors against the current projection. + terrainView !== undefined || (this.#terrain?.blendingTiles ?? 0) > 0 ) { - this.#terrain?.process(renderer); + this.#terrain?.process(renderer, camera, undefined, terrainView); } const props = new AdmissionBudget(budgetMs - Math.min(budget.spentMs, terrainMs), this.#now); this.#drain(props); diff --git a/packages/core/src/world-tiles.ts b/packages/core/src/world-tiles.ts index 9fb27cbc46..cf099f14e1 100644 --- a/packages/core/src/world-tiles.ts +++ b/packages/core/src/world-tiles.ts @@ -2,6 +2,7 @@ import { Box3, BufferAttribute, BufferGeometry, + type Camera, LOD, type Matrix4, Mesh, @@ -12,6 +13,14 @@ import { import type { InterleavedBufferAttribute } from "three"; import type { IAssetLoader } from "./assets.js"; import type { IComputeDriven } from "./compute-driven.js"; +import { + type ILodView, + type IModelLodPolicy, + conservativeViewDepth, + projectedLodError, + selectLodLevel, + worldSphere, +} from "./model-lod.js"; import { SPANS, type SpanId, addSpan, spanNow, spanRecorder } from "./profiling/Spans.js"; import type { IRendererLike } from "./renderer.js"; import { @@ -86,6 +95,11 @@ export interface IWorldTilesFollowPosition { readonly z: number; } +/** Actual render view and the resolved model LOD policy; distance floors still apply. */ +export interface IWorldTilesLodView + extends Pick, + IModelLodPolicy {} + export interface IWorldTilesTopologyObservation { readonly columns: number; readonly depth: number; @@ -124,7 +138,7 @@ export interface IWorldTilesOptions { readonly tileSize: number; /** Vertex decimation factors. Defaults to 1, 2, and 4 over the same field. */ readonly lodFactors?: readonly number[]; - /** Distances in world units at which the next LOD becomes active. */ + /** Nearest world-unit distances at which the next LOD may become active; screen error may delay it. */ readonly lodDistances?: readonly number[]; /** * Re-derive the terrain every frame and assert it: a finiteness scan of every rendered vertex, @@ -149,8 +163,9 @@ export interface IWorldTilesOptions { } interface ILevelGeometry { + surfaceError: number; readonly edgeSamples: IEdgeSamples; - readonly geometry: BufferGeometry; + geometry: BufferGeometry; readonly mesh: Mesh; readonly resolution: number; readonly skirtDepth: number; @@ -170,6 +185,7 @@ interface IResidentTile extends Omit { readonly levels: readonly ILevelGeometry[]; lodTransition?: ILodTransition; lodLevel: number; + sharedMorphEdges: number; /** Coarsest level whose height error against every finer level stays inside the pop bound. */ readonly maxLodLevel: number; readonly origin: IHeightfieldOrigin; @@ -349,14 +365,24 @@ function resolutionFor(tileResolution: number, factor: number): number { return cells + 1; } -function estimatedLevelBytes(resolution: number): number { +function morphBytes(resolution: number, targets: number): number { + const vertices = resolution * resolution + resolution * 4; + // Stock Three packs both vec3 deltas into RGBA32F: retain its CPU texture and GPU copy too. + const texels = vertices * 2; + // Any row width pads to < 2 * texels; admission precedes renderer-limit discovery. + const packedTexels = texels * 2; + return targets * (vertices * 3 * 4 * 2 + packedTexels * 4 * 4 * 2); +} + +function estimatedLevelBytes(resolution: number, targets = 0): number { const vertices = resolution * resolution + resolution * 4; const triangles = (resolution - 1) * (resolution - 1) + (resolution - 1) * 4; const edgeSampleBytes = resolution * 4 * Float32Array.BYTES_PER_ELEMENT; return ( vertices * 3 * Float32Array.BYTES_PER_ELEMENT * 2 + triangles * 6 * Uint32Array.BYTES_PER_ELEMENT + - edgeSampleBytes + edgeSampleBytes + + morphBytes(resolution, targets) ); } @@ -375,9 +401,15 @@ function estimatedTileBytes( tileResolution: number, factors: readonly number[], worldPasses: IHeightfieldWorldPassOptions | undefined, + validate: boolean, ): number { return factors.reduce( - (total, factor) => total + estimatedLevelBytes(resolutionFor(tileResolution, factor)), + (total, factor) => + total + + estimatedLevelBytes( + resolutionFor(tileResolution, factor), + validate ? 0 : factors.filter((candidate) => candidate >= factor).length - 1, + ), estimatedFieldBytes(tileResolution, worldPasses), ); } @@ -652,6 +684,7 @@ function* buildLevel( const positionArray = Float32Array.from(positions); const bounds = yield* levelBounds(positionArray, chunked); const geometry = new BufferGeometry(); + geometry.morphTargetsRelative = true; geometry.setAttribute("position", new BufferAttribute(positionArray, 3)); geometry.setAttribute("normal", new BufferAttribute(Float32Array.from(normals), 3)); geometry.setIndex(new BufferAttribute(Uint32Array.from(indices), 1)); @@ -659,6 +692,7 @@ function* buildLevel( geometry.boundingSphere = bounds.sphere; const skirt = inspectSkirtGeometry(geometry, resolution, edges); return { + surfaceError: 0, edgeSamples: edgeSamplesFor(heights, resolution), geometry, mesh: terrainMesh(new Mesh(geometry, surface)), @@ -1269,6 +1303,85 @@ function interpolatedLevelNormal( .normalize(); } +/** Immutable stock morph buffers; shared sides keep the canonical surface used by seam jobs. */ +function* buildLodMorphTargets( + finer: ILevelGeometry, + coarserLevels: readonly ILevelGeometry[], + sharedEdges: number, + chunked: boolean, +): Generator { + const position = finer.geometry.getAttribute("position"); + const normal = finer.geometry.getAttribute("normal"); + const positions: BufferAttribute[] = []; + const normals: BufferAttribute[] = []; + const coarseNormal = new Vector3(); + const last = finer.resolution - 1; + for (const coarser of coarserLevels) { + const deltaPosition = new BufferAttribute(new Float32Array(position.count * 3), 3); + const deltaNormal = new BufferAttribute(new Float32Array(position.count * 3), 3); + const coarsePosition = coarser.geometry.getAttribute("position") as BufferAttribute; + const coarseNormals = coarser.geometry.getAttribute("normal") as BufferAttribute; + for (let row = 0; row <= last; row += 1) { + for (let column = 0; column <= last; column += 1) { + const index = row * finer.resolution + column; + const pinned = + (row === 0 && sharedEdges & 1) || + (row === last && sharedEdges & 2) || + (column === 0 && sharedEdges & 4) || + (column === last && sharedEdges & 8); + if (!pinned) { + deltaPosition.setY( + index, + interpolatedLevelHeight(coarser, coarsePosition, column / last, row / last) - + position.getY(index), + ); + interpolatedLevelNormal(coarser, coarseNormals, column / last, row / last, coarseNormal); + deltaNormal.setXYZ( + index, + coarseNormal.x - normal.getX(index), + coarseNormal.y - normal.getY(index), + coarseNormal.z - normal.getZ(index), + ); + } + if (chunked && (index + 1) % (CONSTRUCTION_CHUNK_SAMPLES / 2) === 0) yield; + } + } + const edges = [ + (index: number) => index, + (index: number) => last * finer.resolution + index, + (index: number) => index * finer.resolution, + (index: number) => index * finer.resolution + last, + ]; + for (const [edgeIndex, edge] of edges.entries()) + for (let index = 0; index <= last; index += 1) + deltaPosition.setY( + finer.resolution ** 2 + edgeIndex * finer.resolution + index, + deltaPosition.getY(edge(index)), + ); + positions.push(deltaPosition); + normals.push(deltaNormal); + } + finer.geometry.morphAttributes.position = positions; + finer.geometry.morphAttributes.normal = normals; +} + +function updateLodMorph(tile: IResidentTile, transition: ILodTransition, progress: number): void { + const from = tile.levels[transition.from]; + const to = tile.levels[transition.to]; + if (from === undefined || to === undefined) + throw new Error("TerrainTiles LOD transition references a missing level."); + const finer = from.resolution >= to.resolution ? from : to; + const coarser = finer === from ? to : from; + const target = tile.levels + .filter((level) => level !== finer && level.resolution <= finer.resolution) + .indexOf(coarser); + const influences = finer.mesh.morphTargetInfluences; + if (influences === undefined || influences[target] === undefined) + throw new Error("TerrainTiles LOD transition references a missing morph target."); + influences.fill(0); + influences[target] = finer === from ? progress : 1 - progress; +} + function updateLevelSkirts(level: ILevelGeometry): void { const position = level.geometry.getAttribute("position"); const normal = level.geometry.getAttribute("normal"); @@ -1503,6 +1616,7 @@ function* coarsestSelectableLevel( ), ); } + level.surfaceError = error; if (error > LOD_POP_THRESHOLD) break; coarsest = index; } @@ -1892,14 +2006,21 @@ export class TerrainTiles extends Object3D implements IComputeDriven { readonly #surface: MeshSurface; readonly #sampleHeight: IWorldTilesOptions["sampleHeight"]; readonly #streamRadius: number; - /** Canonical topology samples retained only for the declared diagnostics region. */ - readonly #topologyField: Heightfield | undefined; - readonly #topologyBytes: number; + /** Diagnostics outlive construction; their canonical field is never used by resident tiles. */ + #topology: + | (Pick & { + heights?: number[]; + flow?: number[]; + metrics?: ReturnType; + }) + | undefined; + #topologyBytes = 0; readonly #worldPasses: IHeightfieldWorldPassOptions | undefined; readonly #resident = new Map(); + #morphEdgesDirty = false; #construction: { key: string; work: Generator } | undefined; - #topologyMetrics: ReturnType | undefined; #focus: IWorldTilesFollowPosition | undefined; + #lodView: IWorldTilesLodView | undefined; #peakBytes = 0; #peakTiles = 0; #lodTransitions = 0; @@ -2019,7 +2140,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { this.#worldPasses = options.worldPasses; if (options.topologyObservation !== undefined) validateTopologyObservation(options.topologyObservation, this.tileSize, this.tileResolution); - this.#topologyField = + const topologyField = options.topologyObservation === undefined ? undefined : Heightfield.fromSampler({ @@ -2027,12 +2148,48 @@ export class TerrainTiles extends Object3D implements IComputeDriven { sampleHeight: this.#sampleHeight, ...(this.#worldPasses === undefined ? {} : { worldPasses: this.#worldPasses }), }); - this.#topologyBytes = this.#topologyField?.memoryBytes ?? 0; - if (this.#topologyBytes > this.residentByteBudget) - throw new TerrainTileBudgetError( - "TerrainTiles residentByteBudget cannot fit the topology observation.", - ); - this.#recordPeaks(); + if (topologyField !== undefined) { + try { + // Admission and the peak include the full field before its diagnostic samples are released. + this.#topologyBytes = topologyField.memoryBytes; + if (this.#topologyBytes > this.residentByteBudget) + throw new TerrainTileBudgetError( + "TerrainTiles residentByteBudget cannot fit the topology observation.", + ); + this.#recordPeaks(); + const heights = topologyField.heights; + const flow = topologyField.flow; + const description = { + columns: topologyField.columns, + depth: topologyField.depth, + origin: topologyField.origin, + rows: topologyField.rows, + width: topologyField.width, + }; + if (heights.length <= MAX_RAW_TOPOLOGY_SAMPLES) { + this.#topology = { + ...description, + heights: Array.from(heights), + ...(flow === undefined ? {} : { flow: Array.from(flow) }), + }; + // Retained JS number-array payloads use eight bytes per element, not Float32's four. + this.#topologyBytes = (heights.length + (flow?.length ?? 0)) * 8; + } else { + this.#topology = { + ...description, + ...(flow === undefined + ? {} + : { metrics: summarizeWorldTopology({ ...description, heights, flow }) }), + }; + this.#topologyBytes = 0; + } + } catch (error) { + this.#jobs.dispose(); + throw error; + } finally { + topologyField.detach(); + } + } if (this.#validate) this.#recordSeamDiagnostics(); this.frustumCulled = true; } @@ -2183,10 +2340,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { } get warmupNodes(): readonly unknown[] { - return [ - ...(this.#topologyField?.warmupNodes ?? []), - ...[...this.#resident.values()].flatMap((tile) => tile.field.warmupNodes), - ]; + return [...[...this.#resident.values()].flatMap((tile) => tile.field.warmupNodes)]; } getTile(key: string): IWorldTile | undefined { @@ -2199,15 +2353,18 @@ export class TerrainTiles extends Object3D implements IComputeDriven { * `budget` caps construction chunks as well as completed admissions. An unfinished tile stays * outside residency and resumes on a later call while it remains wanted. Moving it outside the * selected ring cancels and releases that work. Omitted, a call drains synchronously as before. + * `view` shares the resolved model pixel policy; omitted, selection remains distance-only. */ follow( position: IWorldTilesFollowPosition | Pick, budget?: IAdmissionBudget, + view?: IWorldTilesLodView, ): void { if (this.#released) throw new Error("TerrainTiles cannot follow after release."); // This pass starts owing nothing; a refusal below sets the flag that owes the next one, so // `deferredAdmissions` is exactly "the last pass did not get everything it wanted". this.#deferredAdmissions = 0; + this.#setLodView(view); const x = finite(position.x, "follow x"); const z = finite(position.z, "follow z"); const hadFocus = this.#focus !== undefined; @@ -2247,7 +2404,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { for (const candidate of selected) { const resident = this.#resident.get(keyFor(candidate.tileX, candidate.tileZ)); if (resident !== undefined) - targets.set(resident, lodLevelForDistance(candidate.distance, this.#lodDistances)); + targets.set(resident, this.#targetLod(resident, candidate.distance)); // A selected tile keeps its construction progress when sub-tile camera motion changes the // distance order. Only leaving the wanted set cancels it; otherwise a jittering camera could // restart two equally near tiles forever. @@ -2260,7 +2417,12 @@ export class TerrainTiles extends Object3D implements IComputeDriven { let built = 0; for (const candidate of missing) { const key = keyFor(candidate.tileX, candidate.tileZ); - const estimate = estimatedTileBytes(this.tileResolution, this.#factors, this.#worldPasses); + const estimate = estimatedTileBytes( + this.tileResolution, + this.#factors, + this.#worldPasses, + this.#validate, + ); if (this.residentBytes + estimate > this.residentByteBudget) { if (this.#construction?.key === key) this.#cancelConstruction(); if (candidate.tileX === centerX && candidate.tileZ === centerZ) @@ -2272,7 +2434,6 @@ export class TerrainTiles extends Object3D implements IComputeDriven { // Keep incomplete work out of the scene; later calls resume it under the same allowance. const tile = this.#admitCandidate(candidate, centerX, centerZ, budget, built === 0); if (tile === undefined) break; - this.#selectLod(tile, candidate.distance, false); built += 1; if (this.residentBytes + tile.bytes > this.residentByteBudget) { this.#disposeTile(tile); @@ -2283,13 +2444,17 @@ export class TerrainTiles extends Object3D implements IComputeDriven { continue; } this.#resident.set(tile.key, tile); + this.#morphEdgesDirty = true; this.add(tile.lod); + this.#selectLod(tile, candidate.distance, false); this.#markTileDirty(tile); this.#ringEpoch += 1; this.#recordPeaks(); } this.#recordPeaks(); this.#updateColliders(centerX, centerZ, budget); + if (!this.#validate && this.#morphEdgesDirty) + timedSpan(SPANS.terrainSeam, () => this.#syncMorphEdges()); this.#applyLodTargets(targets); // Retargeting already coordinates the resident ring; only newly admitted tiles can owe it. if (built > 0) this.#coordinateNeighborLods(hadFocus); @@ -2315,22 +2480,40 @@ export class TerrainTiles extends Object3D implements IComputeDriven { attachRenderer(renderer: IRendererLike): void { if (this.#released) throw new Error("TerrainTiles cannot attach after release."); this.#renderer = renderer; - this.#topologyField?.attachRenderer(renderer); for (const tile of this.#resident.values()) tile.field.attachRenderer(renderer); } - process(renderer = this.#renderer): void { + process( + renderer = this.#renderer, + _camera?: Camera, + _covered?: boolean, + view = this.#lodView, + ): void { if (this.#released) return; + this.#setLodView(view); + if (view !== undefined && this.#focus !== undefined) { + const targets = new Map(); + for (const tile of this.#resident.values()) + targets.set( + tile, + this.#targetLod( + tile, + Math.hypot(this.#focus.x - tile.origin.x, this.#focus.z - tile.origin.z), + ), + ); + this.#applyLodTargets(targets); + } const lodFrame = this.#validate ? this.#captureLodFrame() : undefined; this.#advanceLodTransitions(); if (renderer !== undefined) { - this.#topologyField?.process(renderer); for (const tile of this.#resident.values()) { tile.field.attachRenderer(renderer); tile.field.process(renderer); } } this.#seamPass(); + // View-only retargets still owe the existing admission-budgeted block rebuild in follow. + if (view !== undefined && this.#dirtyBlocks.size > 0) this.#deferredAdmissions = 1; if (lodFrame !== undefined) this.#recordLodPopAfterReconciliation(lodFrame); this.#recordPeaks(); this.#reportTileMarker(); @@ -2365,44 +2548,71 @@ export class TerrainTiles extends Object3D implements IComputeDriven { } debug(): Record { - const topologyField = this.#topologyField; - const topology = - topologyField === undefined - ? undefined - : (() => { - const heights = topologyField.heights; - const flow = topologyField.flow; - const description = { - columns: topologyField.columns, - depth: topologyField.depth, - origin: topologyField.origin, - rows: topologyField.rows, - width: topologyField.width, - }; - if (heights.length <= MAX_RAW_TOPOLOGY_SAMPLES) - return { - ...description, - heights: Array.from(heights), - ...(flow === undefined ? {} : { flow: Array.from(flow) }), - }; - if (flow === undefined) return description; - if (this.#topologyMetrics === undefined) - this.#topologyMetrics = summarizeWorldTopology({ - columns: topologyField.columns, - depth: topologyField.depth, - flow, - heights, - rows: topologyField.rows, - width: topologyField.width, - }); - return { - ...description, - metrics: this.#topologyMetrics, - }; - })(); + const topology = this.#topology; + const view = this.#lodView; + // Inspection only: retain per-frame selection and sampling costs unchanged. + const lodDecisions = [...this.#resident.values()].map((tile) => { + const distance = + this.#focus === undefined + ? 0 + : Math.hypot( + this.#focus.x - tile.tileX * this.tileSize, + this.#focus.z - tile.tileZ * this.tileSize, + ); + const next = tile.levels[tile.lodLevel + 1]; + const finest = tile.levels[0]; + const bounds = finest?.geometry.boundingSphere; + let projection: { errorPixels?: number; depth: number; degenerate: boolean } | undefined; + if (view !== undefined && next !== undefined && finest !== undefined && bounds != null) { + finest.mesh.updateWorldMatrix(true, false); + const sphere = worldSphere(bounds, finest.mesh); + const depth = conservativeViewDepth( + view.camera, + sphere.center, + sphere.radius, + (view.camera as Camera & { near?: number }).near ?? 0, + ); + const errorPixels = projectedLodError( + next.surfaceError * finest.mesh.matrixWorld.getMaxScaleOnAxis(), + view.camera, + view.viewportHeight, + depth.depth, + ); + projection = { ...depth, ...(Number.isFinite(errorPixels) ? { errorPixels } : {}) }; + } + return { + key: tile.key, + lodLevel: tile.lodLevel, + distance, + distanceLod: lodLevelForDistance(distance, this.#lodDistances), + maxLodLevel: tile.maxLodLevel, + targetLod: this.#targetLod(tile, distance), + ...(next === undefined + ? {} + : { + nextCoarser: { + lodLevel: tile.lodLevel + 1, + surfaceError: next.surfaceError, + ...projection, + }, + }), + }; + }); return { - maxSeamGap: this.maxSeamGap, - maxVisualSeamGap: this.maxVisualSeamGap, + lodDecisions, + lodPolicy: { + lodDistances: [...this.#lodDistances], + ...(view === undefined + ? {} + : { + viewportHeight: view.viewportHeight, + maxPixelError: view.maxPixelError, + hysteresis: view.hysteresis, + coarsenBelowPixels: (1 - view.hysteresis) * view.maxPixelError, + }), + }, + ...(this.maxSeamGap === undefined ? {} : { maxSeamGap: this.maxSeamGap }), + ...(this.maxVisualSeamGap === undefined ? {} : { maxVisualSeamGap: this.maxVisualSeamGap }), maxLodTransitionFrames: this.#maxLodTransitionFrames, peakResidentBytes: this.#peakBytes, peakResidentTiles: this.#peakTiles, @@ -2415,18 +2625,28 @@ export class TerrainTiles extends Object3D implements IComputeDriven { residentKeys: this.residentKeys, residentTiles: this.residentTileCount, residentTileBudget: this.residentTileBudget, - pendingConstruction: this.#construction?.key, + ...(this.#construction === undefined ? {} : { pendingConstruction: this.#construction.key }), topologyBytes: this.#topologyBytes, lodTransitions: this.#lodTransitions, terrainTiles: this.terrainTiles, - maxLodPop: this.maxLodPop, + ...(this.maxLodPop === undefined ? {} : { maxLodPop: this.maxLodPop }), stitchedEdges: this.#stitchedEdges, skirtVertexCount: [...this.#resident.values()].reduce( (total, tile) => total + tile.skirtVertexCount, 0, ), released: this.#released, - ...(topology === undefined ? {} : { topology }), + ...(topology === undefined + ? {} + : { + topology: { + ...topology, + origin: { ...topology.origin }, + ...(topology.heights === undefined ? {} : { heights: topology.heights.slice() }), + ...(topology.flow === undefined ? {} : { flow: topology.flow.slice() }), + ...(topology.metrics === undefined ? {} : { metrics: { ...topology.metrics } }), + }, + }), }; } @@ -2440,7 +2660,8 @@ export class TerrainTiles extends Object3D implements IComputeDriven { this.#renderer = undefined; this.#jobs.dispose(); this.#cancelConstruction(); - this.#topologyField?.detach(); + this.#topology = undefined; + this.#topologyBytes = 0; for (const key of [...this.#blocks.keys()]) this.#dissolveBlock(key); for (const tile of [...this.#resident.values()]) this.#evict(tile); this.#resident.clear(); @@ -2523,6 +2744,15 @@ export class TerrainTiles extends Object3D implements IComputeDriven { mesh.receiveShadow = this.#receiveShadow; }); const maxLodLevel = yield* coarsestSelectableLevel(levels, chunked); + if (!this.#validate) + for (const level of levels) { + const coarser = levels.filter( + (candidate) => candidate !== level && candidate.resolution <= level.resolution, + ); + if (coarser.length === 0) continue; + yield* buildLodMorphTargets(level, coarser, 0, chunked); + level.mesh.updateMorphTargets(); + } collider = this.#createCollider === undefined ? new EmptyCollider() @@ -2530,8 +2760,17 @@ export class TerrainTiles extends Object3D implements IComputeDriven { ? this.#createCollider({ field, key: keyFor(tileX, tileZ), object: lod, tileX, tileZ }) : undefined; const bytes = - levels.reduce((total, level) => total + estimatedLevelBytes(level.resolution), 0) + - field.memoryBytes; + levels.reduce( + (total, level) => + total + + estimatedLevelBytes( + level.resolution, + this.#validate + ? 0 + : levels.filter((candidate) => candidate.resolution <= level.resolution).length - 1, + ), + 0, + ) + field.memoryBytes; const tile: IResidentTile = { ...(assetKey === undefined ? {} : { assetKey }), bytes, @@ -2540,6 +2779,7 @@ export class TerrainTiles extends Object3D implements IComputeDriven { key: keyFor(tileX, tileZ), lod, lodLevel: 0, + sharedMorphEdges: 0, levels, maxLodLevel, object: lod, @@ -2704,7 +2944,86 @@ export class TerrainTiles extends Object3D implements IComputeDriven { } #selectLod(tile: IResidentTile, distance: number, countTransition = true): void { - this.#setLodLevel(tile, lodLevelForDistance(distance, this.#lodDistances), countTransition); + this.#setLodLevel(tile, this.#targetLod(tile, distance), countTransition); + } + + #setLodView(view: IWorldTilesLodView | undefined): void { + if ( + view !== undefined && + (!Number.isFinite(view.viewportHeight) || + view.viewportHeight <= 0 || + !Number.isFinite(view.maxPixelError) || + view.maxPixelError <= 0 || + !Number.isFinite(view.hysteresis) || + view.hysteresis < 0 || + view.hysteresis >= 1) + ) + throw new Error( + "TerrainTiles needs a positive raster height/pixel budget and hysteresis in [0, 1).", + ); + this.#lodView = view; + view?.camera.updateWorldMatrix(true, false); + } + + #targetLod(tile: IResidentTile, distance: number): number { + const floor = Math.min(lodLevelForDistance(distance, this.#lodDistances), tile.maxLodLevel); + const view = this.#lodView; + if (view === undefined) return floor; + const finest = tile.levels[0]; + const bounds = finest?.geometry.boundingSphere; + if (finest === undefined || bounds == null) return 0; + finest.mesh.updateWorldMatrix(true, false); + const sphere = worldSphere(bounds, finest.mesh); + const depth = conservativeViewDepth( + view.camera, + sphere.center, + sphere.radius, + (view.camera as Camera & { near?: number }).near ?? 0, + ); + const scale = finest.mesh.matrixWorld.getMaxScaleOnAxis(); + return selectLodLevel( + tile.levels.slice(0, floor + 1).map((level) => level.surfaceError * scale), + tile.lodLevel, + view.maxPixelError, + view.hysteresis, + [{ ...view, ...depth }], + ); + } + + #syncMorphEdges(): void { + for (const tile of this.#resident.values()) { + const shared = + (this.#resident.has(keyFor(tile.tileX, tile.tileZ - 1)) ? 1 : 0) | + (this.#resident.has(keyFor(tile.tileX, tile.tileZ + 1)) ? 2 : 0) | + (this.#resident.has(keyFor(tile.tileX - 1, tile.tileZ)) ? 4 : 0) | + (this.#resident.has(keyFor(tile.tileX + 1, tile.tileZ)) ? 8 : 0); + if (shared === tile.sharedMorphEdges) continue; + for (const level of tile.levels) { + const coarser = tile.levels.filter( + (candidate) => candidate !== level && candidate.resolution <= level.resolution, + ); + if (coarser.length === 0) continue; + const previous = level.geometry; + const geometry = new BufferGeometry(); + geometry.morphTargetsRelative = true; + geometry.setAttribute("position", previous.getAttribute("position")); + geometry.setAttribute("normal", previous.getAttribute("normal")); + geometry.setIndex(previous.getIndex()); + geometry.boundingBox = previous.boundingBox?.clone() ?? null; + geometry.boundingSphere = previous.boundingSphere?.clone() ?? null; + const influences = level.mesh.morphTargetInfluences?.slice(); + // Three caches morph textures by geometry, ignoring attribute versions; retire that cache. + for (const _ of buildLodMorphTargets({ ...level, geometry }, coarser, shared, false)) { + } + level.geometry = geometry; + level.mesh.geometry = geometry; + level.mesh.updateMorphTargets(); + if (influences !== undefined) level.mesh.morphTargetInfluences = influences; + previous.dispose(); + } + tile.sharedMorphEdges = shared; + } + this.#morphEdgesDirty = false; } #setLodLevel(tile: IResidentTile, level: number, countTransition = true): void { @@ -2747,7 +3066,8 @@ export class TerrainTiles extends Object3D implements IComputeDriven { const finer = previous.resolution >= next.resolution ? previous : next; setManualLodLevel(tile.lod, finerLevel); widenLevelBoundsForBlend(finer, finer === previous ? next : previous); - updateLodTransitionGeometry(tile, tile.lodTransition, 0); + if (this.#validate) updateLodTransitionGeometry(tile, tile.lodTransition, 0); + else updateLodMorph(tile, tile.lodTransition, 0); this.#setLodVisibility(tile, [finerLevel]); this.#recordLodPopAfterRetarget(interruptedFrame); } @@ -2814,13 +3134,13 @@ export class TerrainTiles extends Object3D implements IComputeDriven { throw new Error("TerrainTiles LOD transition references a missing level."); transition.elapsedFrames += 1; transition.remainingFrames -= 1; - updateLodTransitionGeometry( + (this.#validate ? updateLodTransitionGeometry : updateLodMorph)( tile, transition, Math.min(1, transition.elapsedFrames / LOD_TRANSITION_FRAMES), ); if (transition.remainingFrames > 0) { - // A blend frame rewrote the finer level's vertices, which the ring state reads. + // The validating CPU blend moved; GPU morphs keep shared edges canonical. this.#ringEpoch += 1; continue; } @@ -2904,7 +3224,8 @@ export class TerrainTiles extends Object3D implements IComputeDriven { * it does not — so this loop only restores edges, collects what each bridge is made of, and swaps * in the arrays that come back. An inline host resolves inside the call and the pass closes before * `follow` returns, exactly as it did before the jobs moved; an off-thread host closes the pass in - * the reply, and a frame that arrives first finds `#seamPending` and waits. + * the reply. Frames keep validating CPU edges canonical while it is pending; a reply whose ring + * changed is discarded and reconciled anew, never recorded as settled. */ #reconcileNeighbors(): void { const pairs = collectNeighborPairs(this.#resident, this.#pairScratch); @@ -2938,14 +3259,32 @@ export class TerrainTiles extends Object3D implements IComputeDriven { // The scratch array is reused by the next pass, so a reply that lands a frame later reads its // own copy of the pairs and not whatever the next pass left there. const snapshot = [...pairs]; + const epoch = this.#ringEpoch; + const state: number[] = []; this.#seamPending = true; const result = timedSpan(SPANS.terrainSeam, () => this.#jobs.seam(job)); + if (result instanceof Promise) this.#ringStateInto(state); settleJob(result, (settled) => { if (this.#released) return; + if (result instanceof Promise) { + const current: number[] = []; + this.#ringStateInto(current); + if (epoch !== this.#ringEpoch || !sameState(state, current)) { + // A non-finite current signature is corruption, not a legitimate superseded request. + if (this.#validate && current.some(Number.isNaN)) this.#recordSeamDiagnostics(); + // Nothing from this reply describes the current ring; leave it unsettled and request it anew. + this.#seamPending = false; + this.#seamPass(); + return; + } + } timedSpan(SPANS.terrainSeam, () => { + if (settled.kind !== "seam" || settled.bridges.length !== requests.length) + throw new Error("TerrainTiles seam job returned an invalid bridge count."); for (const [index, pending] of requests.entries()) { const data = settled.bridges[index]; - if (data === undefined) continue; + if (data === undefined) + throw new Error("TerrainTiles seam job returned an invalid bridge count."); this.#applyStitch(pending.key, pending.previousBytes, pending.request, data); } this.#settleSeamPass(snapshot, reconciled, active); @@ -3016,7 +3355,9 @@ export class TerrainTiles extends Object3D implements IComputeDriven { const from = tile.levels[transition.from]; const to = tile.levels[transition.to]; if (from === undefined || to === undefined) return; - restoreLevelSurface(tile.field, from.resolution >= to.resolution ? from : to); + const finer = from.resolution >= to.resolution ? from : to; + if (this.#validate) restoreLevelSurface(tile.field, finer); + else finer.mesh.morphTargetInfluences?.fill(0); } #recordLodPop(pop: number): void { @@ -3064,6 +3405,17 @@ export class TerrainTiles extends Object3D implements IComputeDriven { */ #seamPass(): void { if (this.#seamPending) { + // CPU blends move every vertex; shared edges must remain canonical even before a reply arrives. + if (this.#validate) + for (const [a, b] of collectNeighborPairs(this.#resident, this.#pairScratch)) { + const [aSide, bSide] = opposingEdge(a, b); + const aLevel = renderedLevel(a); + const bLevel = renderedLevel(b); + if (aLevel === undefined || bLevel === undefined) + throw new Error("TerrainTiles cannot reconcile a neighbor with no rendered LOD level."); + restoreLevelEdge(a, aLevel, aSide); + restoreLevelEdge(b, bLevel, bSide); + } this.#stitchedEdges += this.#stitches.size; return; } @@ -3243,11 +3595,54 @@ export class TerrainTiles extends Object3D implements IComputeDriven { x: blockX * TERRAIN_MERGE_BLOCK * this.tileSize, z: blockZ * TERRAIN_MERGE_BLOCK * this.tileSize, }; + const captured = parts.map(({ geometry, origin }) => ({ + geometry, + originX: origin.x, + originZ: origin.z, + attributes: [ + geometry.getAttribute("position") as BufferAttribute, + geometry.getAttribute("normal") as BufferAttribute, + geometry.getIndex(), + ].map((attribute) => ({ attribute, version: attribute?.version })), + })); this.#mergingBlocks.add(blockKey); const job = timedSpan(SPANS.terrainBlock, () => this.#jobs.merge(mergeJob(parts, blockOrigin))); const apply = (result: ITerrainMergeResult): void => { this.#mergingBlocks.delete(blockKey); if (this.#released) return; + // Completed merges cost work even when newer residency makes their result stale. + this.#blockRebuilds += 1; + if (job instanceof Promise) { + const current = this.#blockMembers(blockKey, lod); + const superseded = + this.#dirtyBlocks.has(blockKey) || + current.members.size !== members.size || + [...members].some((key) => !current.members.has(key)) || + current.parts.length !== captured.length || + current.parts.some(({ geometry, origin }, partIndex) => { + const previous = captured[partIndex]; + const attributes = [ + geometry.getAttribute("position") as BufferAttribute, + geometry.getAttribute("normal") as BufferAttribute, + geometry.getIndex(), + ]; + return ( + previous === undefined || + previous.geometry !== geometry || + previous.originX !== origin.x || + previous.originZ !== origin.z || + previous.attributes.some( + ({ attribute, version }, attributeIndex) => + attribute !== attributes[attributeIndex] || + version !== attributes[attributeIndex]?.version, + ) + ); + }); + if (superseded) { + this.#dirtyBlocks.add(blockKey); + return; + } + } timedSpan(SPANS.terrainBlock, () => this.#applyBlock({ blockKey, blockOrigin, lod, members }, result), ); @@ -3303,7 +3698,6 @@ export class TerrainTiles extends Object3D implements IComputeDriven { const tile = this.#resident.get(key); if (tile !== undefined) this.#setLodVisibility(tile); } - this.#blockRebuilds += 1; } /** The resident settled tiles a block covers, and their level geometries to concatenate. */ @@ -3372,7 +3766,10 @@ export class TerrainTiles extends Object3D implements IComputeDriven { this.#releaseStaleBlock(tile.lodLevel, tile.tileX, tile.tileZ); this.#mergedMembers.delete(tile.key); } - if (this.#resident.get(tile.key) === tile) this.#resident.delete(tile.key); + if (this.#resident.get(tile.key) === tile) { + this.#resident.delete(tile.key); + this.#morphEdgesDirty = true; + } this.#ringEpoch += 1; this.#removeStitchesForTile(tile.key); this.remove(tile.lod); diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index fa80805d73..daa7538519 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -247,20 +247,21 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // PRD-560: fingerprints include both height fog and PRD-571 histogram exposure. // Recomputed 2026-10-11 on the merge of develop e6ed27869 into PRD-563: measured through // createProject on the merged tree. - "action-rpg": "254697940e5805434f380528ea8b69e19613a1548f64497ee23989677b59763c", - minimal: "0202ac5dd7ac436e474f55dd9ec2f5f43da3d67c219b2351405e960ca8f1a71e", - platformer: "edf75a6bfbb3b0ab7513b7e8fc3a8441b9aa41ff5f544ab77c201f1fcd8d8faf", - puzzle: "c5739a462298a6b1a0c2ba27ebca8c276fa89ccd6464d8b49f43b2da0e9e9f72", - racing: "deff5eb215f9d3be8007f2f0bc4530618035db36418c05aee292791a07ca76a9", - rain: "643494c2812208b8db1c24e38c06053d7fe349bb25692802c8acf3511e08dd43", - rts: "8dfd9b5cb3d441463389b842ff2b3cde015d81ed4d86dad089f8cd2e2916f15f", - runner: "1844ee1b87ef97d51aad6d157715bdc74df39b39a08d822403f92b2d9e4a3302", + // PRD-566 merge: measured all thirteen trees; only the shared morph pipeline patch changes bytes. + "action-rpg": "8984c4f355d35eb5c1d0958746172f457c9c2ecad615f3bfcd38642533ff8c37", + minimal: "8d834289ea3940529d925f53a4946cc718771dc4fb8577c2199ba6a29faf51d4", + platformer: "f5d4a0362bd3a6f7bb3f8530178e22d5cbe0bd8625d0c83121b53f8f5aa3a2ec", + puzzle: "cdacbec8e3543a394263bd73c42d7482b61c433df6cb60ce42bc8306f91f71e9", + racing: "cec5c42b232f5f858fdefae9275b324378c2914d5113185e3ec3a748162caf23", + rain: "05027f3370b26deb260e158240a09904ea54e593a8184767eead660810bf7551", + rts: "6f331bb87e10340cd33263edbff4f7ce1fc40d24b601ea786a06ea41e1f065a5", + runner: "4f7a6b3ace688771a7c96e67062a8b696d0b50c09376183f2f02eead97361c12", // Initial finite-height readiness plus its scene-owned lifecycle helper and mirrored docs. - sailing: "a524164861fad5282f55b69a9515529604afb8f73ad25e4bcd51c7b93ce17a82", - shooter: "816c172e0c9d86fa1be9a866a09b3b377a336f0038e5ed3600d7f153cc753301", - snow: "e4be666568eeb003b274486d791a183867c5b0e176f2888650305475c4dc820a", - starter: "cb2221f6a07dd981fb19be00477e3304fe8add983eb17e0c7066d68164489b30", - "tower-defense": "54184c2d75fe452ada59a5a3ce00e326e485b9288153b5617320fbcaa88b47fb", + sailing: "9c9edb14b880fc446e9b921b60dd99053f45032f053c4fcb5cc820a323c43904", + shooter: "699758de47b7642a38b998dad0437b17c6a68df046d8807ddbaf35a61d9f1423", + snow: "837eee8058f3bf043eabe5161207e62fbc5accd83cd1353902b79bb55f2dd1cf", + starter: "21b5ccb273b27ff8b711c7b2c5f42345adb7ab941fa19e605b64b01626f5aa68", + "tower-defense": "95e514738bd04ba2a561f3500d877af1d0c540a9e40fb00829770455006fd14c", }; const GENERATED_SCAFFOLD_METADATA = diff --git a/packages/create-threenative/__tests__/shooter-decals-native.spec.ts b/packages/create-threenative/__tests__/shooter-decals-native.spec.ts index 5839c16546..b7f04190e0 100644 --- a/packages/create-threenative/__tests__/shooter-decals-native.spec.ts +++ b/packages/create-threenative/__tests__/shooter-decals-native.spec.ts @@ -88,7 +88,12 @@ it("preserves all authored predicates and passes actual minimal-native mailbox p async call(method: string): Promise { if (method === "describe") return { - capabilities: ["runtime.resources", "runtime.fixedStep", "runtime.startup"], + capabilities: [ + "runtime.network", + "runtime.resources", + "runtime.fixedStep", + "runtime.startup", + ], limits: PLAYTEST_PROTOCOL_LIMITS, name: "@threenative/playtest/three", protocolVersion: 1, diff --git a/packages/playtest/__tests__/bridgeClient.spec.ts b/packages/playtest/__tests__/bridgeClient.spec.ts index f465c2a4f1..7c00838c18 100644 --- a/packages/playtest/__tests__/bridgeClient.spec.ts +++ b/packages/playtest/__tests__/bridgeClient.spec.ts @@ -156,8 +156,10 @@ test("the WebGPU browser recipe expands to the sealed Chromium flags", () => { "--enable-unsafe-webgpu", "--disable-gpu-sandbox", "--ignore-gpu-blocklist", - // Dropping this returns every WebGPU run to SwiftShader without any error to notice. "--enable-features=Vulkan", + ...(process.env.TN_PLAYTEST_HOST_DISPLAY === "1" + ? ["--use-angle=vulkan", "--use-vulkan", "--disable-vulkan-surface"] + : []), ]); expect(() => parseStandalonePlaytestArgs(["playtests/move.json", "--browser-recipe", "unknown"], "/project"), diff --git a/packages/playtest/__tests__/browser-input.spec.ts b/packages/playtest/__tests__/browser-input.spec.ts index 4f9d6eaadb..b284b5401c 100644 --- a/packages/playtest/__tests__/browser-input.spec.ts +++ b/packages/playtest/__tests__/browser-input.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "vitest"; +import { expect, test, vi } from "vitest"; import { PERFORMANCE_BROWSER_ARGS, @@ -40,10 +40,7 @@ test("multi-pointer browser input releases missing pointers before adding new ar ]); }); -test("the webgpu recipe asks for Vulkan so the run does not land on SwiftShader", () => { - // Chromium answers `requestAdapter` either way, so a missing flag produces no error at all — - // it produces a software renderer's results. Measured under Xvfb on an RTX 2080: - // `swiftshader / google` without this flag, `turing / nvidia` with it. +test("the webgpu recipe keeps Vulkan available", () => { expect(WEBGPU_BROWSER_ARGS).toContain("--enable-features=Vulkan"); }); @@ -53,3 +50,30 @@ test("performance probes can bypass the virtual display compositor cadence", () "--disable-gpu-vsync", ]); }); + +test("the host-display webgpu recipe selects the Vulkan hardware adapter", async () => { + vi.stubEnv("TN_PLAYTEST_HOST_DISPLAY", "1"); + vi.resetModules(); + try { + const { WEBGPU_BROWSER_ARGS: args } = await import("../src/runner/browser.js"); + expect(args).toContain("--use-angle=vulkan"); + expect(args).toContain("--use-vulkan"); + } finally { + vi.unstubAllEnvs(); + vi.resetModules(); + } +}); + +test.each([undefined, "0", "1"])("surface-less Vulkan requires the host-display opt-in (%s)", async (hostDisplay) => { + vi.stubEnv("TN_PLAYTEST_HOST_DISPLAY", hostDisplay); + vi.resetModules(); + try { + const { WEBGPU_BROWSER_ARGS: args } = await import("../src/runner/browser.js"); + expect(args.includes("--disable-vulkan-surface")).toBe(hostDisplay === "1"); + expect(args.includes("--use-angle=vulkan")).toBe(hostDisplay === "1"); + expect(args.includes("--use-vulkan")).toBe(hostDisplay === "1"); + } finally { + vi.unstubAllEnvs(); + vi.resetModules(); + } +}); diff --git a/packages/playtest/__tests__/desktop-playtest.spec.ts b/packages/playtest/__tests__/desktop-playtest.spec.ts index 3c4f9ab9d7..fc42a2affb 100644 --- a/packages/playtest/__tests__/desktop-playtest.spec.ts +++ b/packages/playtest/__tests__/desktop-playtest.spec.ts @@ -9,6 +9,7 @@ import { PLAYTEST_PROTOCOL_LIMITS, PLAYTEST_PROTOCOL_VERSION, type IPlaytestBridgeV1, + type IPlaytestObservationSnapshot, type JsonValue, } from "../src/index.js"; import { assertCaptureNotBlank } from "../src/capture.js"; @@ -258,6 +259,8 @@ test.skipIf(process.platform === "win32")("desktop runner drives a real local ma expect(report.pass).toBe(true); expect(report.runtime).toBe("native"); expect(report.target).toBe("desktop"); + expect(JSON.parse(await readFile(join(mailboxRoot, "desktop-fixture-args.json"), "utf8"))) + .toEqual(["--width", "640", "--height", "360", "--windowed"]); expect(report.assertionResults).toContainEqual(expect.objectContaining({ id: "movement.distance", pass: true })); if (process.platform === "linux") { expect(await readFile(join(mailboxRoot, "desktop-fixture-display.txt"), "utf8")).toMatch(/^:\d+$/u); @@ -389,7 +392,7 @@ test("desktop runner asks for the frame series a performance assertion reads", a const performance = report.assertionResults?.find(({ id }) => id === "performance.minFps"); expect(performance?.pass).toBe(true); } finally { - if (previous === undefined) delete host.__THREENATIVE_NATIVE__; + if (previous === undefined) Reflect.deleteProperty(host, "__THREENATIVE_NATIVE__"); else host.__THREENATIVE_NATIVE__ = previous; await rm(projectPath, { force: true, recursive: true }); } @@ -606,6 +609,8 @@ test.skipIf(process.platform === "win32")("desktop screenshot timeout can be rai }); interface IDesktopScenarioOptions { + network?: JsonValue; + assertNetwork?: boolean; console?: { text: string; type: string }[]; mailboxFile?: boolean; onDriver?: (driver: FakeDesktopDriver) => void; @@ -633,7 +638,7 @@ async function runDesktopScenario(minDistance: number, options: IDesktopScenario const scenarioPath = join(projectPath, "scenario.json"); await writeFile(scenarioPath, JSON.stringify({ artifacts: { screenshots: options.screenshots ?? false }, - assert: { movement: { entity: "player", minDistance } }, + assert: { movement: { entity: "player", minDistance }, ...(options.assertNetwork ? { diagnostics: { noNetworkErrors: true } } : {}) }, name: "desktop-cross-target-scenario", schemaVersion: 1, steps: [{ @@ -649,6 +654,10 @@ async function runDesktopScenario(minDistance: number, options: IDesktopScenario })); const endpoint = `http://127.0.0.1:${await availablePort()}/playtest`; const moving = movingBridge(); + const sample = moving.bridge.sample; + moving.bridge.sample = async (request) => ({ ...await sample(request), network: (options.network === undefined ? { requests: 0, entries: [] } : options.network) as IPlaytestObservationSnapshot["network"] }); + const describe = moving.bridge.describe; + moving.bridge.describe = async () => ({ ...await describe(), capabilities: [...(await describe()).capabilities, "runtime.network"] }); const driver = new FakeDesktopDriver( moving.bridge, options.stopError, @@ -689,7 +698,7 @@ async function runDesktopScenario(minDistance: number, options: IDesktopScenario }); return { driver, report }; } finally { - if (previous === undefined) delete host.__THREENATIVE_NATIVE__; + if (previous === undefined) Reflect.deleteProperty(host, "__THREENATIVE_NATIVE__"); else host.__THREENATIVE_NATIVE__ = previous; if (ownsProjectPath) await rm(projectPath, { force: true, recursive: true }); } @@ -790,6 +799,7 @@ function movingBridge(options: { resourceReadyAtTick?: number } = {}): { bridge: "entity.observe", "runtime.diagnostics", "runtime.fixedStep", + "runtime.network", ...(resourceReadyAtTick === undefined ? [] : ["runtime.resources"]), ], limits: PLAYTEST_PROTOCOL_LIMITS, @@ -800,6 +810,7 @@ function movingBridge(options: { resourceReadyAtTick?: number } = {}): { bridge: sample: () => ({ clock: { mode: "fixed-step", tick }, diagnostics: [], + network: { requests: 0, entries: [] }, entities: [{ id: "player", transform: { position: [x, 0, 0] }, visible: true }], resources: resources(), }), @@ -848,6 +859,7 @@ const responsePath = join(root, "tn-playtest-response.json"); const screenshotRequestPath = join(root, "tn-playtest-screenshot-request.txt"); const screenshotBytes = Buffer.from(${JSON.stringify(screenshot)}, "base64"); writeFileSync(join(root, "desktop-fixture-display.txt"), process.env.DISPLAY ?? ""); +writeFileSync(join(root, "desktop-fixture-args.json"), JSON.stringify(process.argv.slice(2))); let held = false; let x = 0; let tick = 0; @@ -880,7 +892,7 @@ function poll() { let result = null; if (request.method === "describe") { result = { - capabilities: ["entity.observe", "runtime.diagnostics", "runtime.fixedStep"], + capabilities: ["entity.observe", "runtime.diagnostics", "runtime.fixedStep", "runtime.network"], limits: { maxEntitiesPerSample: 100, maxEventsPerDrain: 1000, maxPayloadBytes: 1000000, operationTimeoutMs: 5000 }, name: "desktop-mailbox-fixture", protocolVersion: 1, @@ -895,6 +907,7 @@ function poll() { result = { clock: { mode: "fixed-step", tick }, diagnostics: [], + network: { requests: 0, entries: [] }, entities: [{ id: "player", transform: { position: [x, 0, 0] }, visible: true }], resources: {}, }; @@ -943,3 +956,22 @@ function minimalConfig(target: "android" | "browser" | "desktop" | "ios"): IStan url: "http://127.0.0.1:5173", }; } + + +test("desktop evaluates the host network observation instead of rejecting the assertion", async () => { + const { report } = await runDesktopScenario(2, { assertNetwork: true, network: { + requests: 2, entries: [{ method: "GET", url: "https://host/failure" }], + } }); + expect(report.pass).toBe(false); + expect(report.diagnostics.some(({ code }) => code === "TN_PLAYTEST_UNSUPPORTED_ON_TARGET")).toBe(false); + expect(report.observations?.network).toEqual([{ method: "GET", url: "https://host/failure" }]); + expect(report.diagnostics.some(({ code }) => code === "TN_PLAYTEST_NETWORK_ERROR")).toBe(true); + const healthy = await runDesktopScenario(2, { assertNetwork: true, network: { requests: 2, entries: [] } }); + expect(healthy.report.pass).toBe(true); +}); + +test.each([null, { requests: -1, entries: [] }, { requests: 1, entries: [{ url: 123 }] }])( + "desktop fails closed for a missing or malformed network observation %j", async (network) => { + await expect(runDesktopScenario(2, { assertNetwork: true, network })).rejects.toThrow(/NETWORK.*UNOBSERVED/u); + }, +); diff --git a/packages/playtest/__tests__/device-playtest.spec.ts b/packages/playtest/__tests__/device-playtest.spec.ts index b9e6967a58..2596c4b77f 100644 --- a/packages/playtest/__tests__/device-playtest.spec.ts +++ b/packages/playtest/__tests__/device-playtest.spec.ts @@ -1106,6 +1106,7 @@ function movingBridge(options: { clearHeldAfterAdvance?: boolean; entity?: strin ...(options.setup === true ? ["entity.setup"] : []), "runtime.fixedStep", "runtime.diagnostics", + "runtime.network", ...(resourceReadyAtTick === undefined ? [] : ["runtime.resources"]), ...(options.startupPolls === undefined ? [] : ["runtime.startup"]), "scene.nodes", @@ -1141,6 +1142,7 @@ function movingBridge(options: { clearHeldAfterAdvance?: boolean; entity?: strin return { clock: { mode: "fixed-step", tick }, diagnostics: [], + network: { requests: 0, entries: [] }, entities: [ { id: options.entity ?? "player", transform: { position: [x, options.height ?? 0, 0] }, visible: true }, { bounds: { height: 40, width: 40, x: 300, y: 160 }, id: "cube", visible: true }, diff --git a/packages/playtest/__tests__/device-startup-warmup.spec.ts b/packages/playtest/__tests__/device-startup-warmup.spec.ts index d38ad2a1ae..236ea1181a 100644 --- a/packages/playtest/__tests__/device-startup-warmup.spec.ts +++ b/packages/playtest/__tests__/device-startup-warmup.spec.ts @@ -32,7 +32,7 @@ async function runStartupWarmup(mode: FixtureMode, target: "android" | "desktop" let result: unknown; if (method === "describe") { result = { - capabilities: ["runtime.fixedStep", "runtime.diagnostics", ...(mode === "no-startup" ? [] : ["runtime.startup"])], + capabilities: ["runtime.fixedStep", "runtime.diagnostics", "runtime.network", ...(mode === "no-startup" ? [] : ["runtime.startup"])], limits: PLAYTEST_PROTOCOL_LIMITS, name: "CPU startup fixture", protocolVersion: PLAYTEST_PROTOCOL_VERSION, @@ -53,8 +53,9 @@ async function runStartupWarmup(mode: FixtureMode, target: "android" | "desktop" tick += ticks; result = { clock: { mode: "fixed-step", tick }, ticks }; } else if (method === "sample") { + if (mode === "warmup-timeout") throw timeout("Warmup still times out after startup readiness."); samples.push(tick); - result = { clock: { mode: "fixed-step", tick }, diagnostics: [], entities: [], resources: {} }; + result = { clock: { mode: "fixed-step", tick }, diagnostics: [], entities: [], resources: {}, network: { requests: 0, entries: [] } }; } else { throw new Error(`Unexpected fixture operation ${method}`); } @@ -92,15 +93,15 @@ async function runStartupWarmup(mode: FixtureMode, target: "android" | "desktop" } } -test.each(["desktop", "android"] as const)("%s waits through a temporary startup timeout before executing every warmup tick", async (target) => { +test.each(["desktop", "android"] as const)("%s waits through a temporary startup timeout before observing every warmup frame without advancing simulation", async (target) => { const result = await runStartupWarmup("recovering", target); expect(result.report.diagnostics).toEqual([]); expect(result.report.assertionResults?.filter(({ pass }) => !pass)).toEqual([]); expect(result.report.pass).toBe(true); expect(result.report.observations?.startup).toMatchObject({ phase: "ready", rule: "sustained-frames" }); - expect(result.advances).toEqual([1, 1, 60, 2]); - expect(result.samples).toEqual([61, 63, 63]); - expect(result.calls.filter(({ method }) => method === "advance").map(({ timeoutMs }) => timeoutMs)).toEqual([20250, 20250, 35000, 20500]); + expect(result.advances).toEqual([1, 1, 2]); + expect(result.samples).toEqual([...Array(61).fill(1), 3, 3]); + expect(result.calls.filter(({ method }) => method === "advance").map(({ timeoutMs }) => timeoutMs)).toEqual([20250, 20250, 20500]); expect(result.report.assertionResults).toContainEqual(expect.objectContaining({ id: "diagnostics", pass: true })); expect(result.stopped).toBe(true); }); @@ -110,10 +111,10 @@ test.each(["skip-startup", "no-startup"] as const)("%s retains immediate complet expect(result.report.diagnostics).toEqual([]); expect(result.report.assertionResults?.filter(({ pass }) => !pass)).toEqual([]); expect(result.report.pass).toBe(true); - expect(result.calls.slice(0, 3).map(({ method }) => method)).toEqual(["describe", "ready", "advance"]); + expect(result.calls.slice(0, 3).map(({ method }) => method)).toEqual(["describe", "ready", "sample"]); expect(result.calls.filter(({ method }) => method === "ready")).toHaveLength(1); - expect(result.advances).toEqual([60, 2]); - expect(result.samples).toEqual([60, 62, 62]); + expect(result.advances).toEqual([2]); + expect(result.samples).toEqual([...Array(61).fill(0), 2, 2]); expect(result.report.observations?.startup).toBeUndefined(); }); @@ -123,8 +124,8 @@ test("a warmup timeout after readiness still fails before observation", async () expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ code: "TN_PLAYTEST_OPERATION_TIMEOUT", message: "Warmup still times out after startup readiness.", })); - expect(result.advances).toEqual([60]); + expect(result.advances).toEqual([]); expect(result.samples).toEqual([]); - expect(result.calls.find(({ method }) => method === "advance")?.timeoutMs).toBe(35000); + expect(result.calls.find(({ method }) => method === "sample")?.argument).toEqual({ entities: [], include: [] }); expect(result.stopped).toBe(true); }); diff --git a/packages/playtest/__tests__/runner.spec.ts b/packages/playtest/__tests__/runner.spec.ts index 5b6029b132..336859a9a2 100644 --- a/packages/playtest/__tests__/runner.spec.ts +++ b/packages/playtest/__tests__/runner.spec.ts @@ -1663,6 +1663,42 @@ test("runner derives semantic series from labeled snapshots and the exported fie expect(result.pass).toBe(true); }); +test.each([ + { initial: undefined, first: 0, firstTick: 1, final: 2, baseline: 0, pass: true }, + { initial: undefined, first: 2, firstTick: 1, final: 2, baseline: 2, pass: false }, + { initial: undefined, first: 0, firstTick: 2, final: 2, baseline: undefined, pass: false }, + { initial: undefined, first: undefined, firstTick: 1, final: 2, baseline: undefined, pass: false }, + { initial: 2, first: 0, firstTick: 1, final: 2, baseline: 2, pass: false }, + { initial: 0, first: 1, firstTick: 1, final: undefined, baseline: 0, pass: false }, +])("runner measures spawned component changes only across two observed times %j", (entry) => { + const snapshot = (shots: number | undefined, tick: number): IPlaytestObservationSnapshot => ({ + clock: { mode: "fixed-step", tick }, + components: shots === undefined ? {} : { tower: { shots } }, + }); + const result = buildReport( + CONFIG, + scenario({ components: [{ entity: "tower", component: "shots", gte: 1, changed: true }] }), + snapshot(entry.initial, 0), snapshot(entry.final, 2), [], [], undefined, {}, true, undefined, + [{ label: "placed", signals: [], snapshot: snapshot(entry.first, entry.firstTick) }], + ); + expect(result.observations?.components?.tower?.shots?.before).toBe(entry.baseline); + expect(result.pass).toBe(entry.pass); +}); + +test("runner keeps the pre-scenario baseline for final-value component assertions", () => { + const snapshot = (present: boolean, tick: number): IPlaytestObservationSnapshot => ({ + clock: { mode: "fixed-step", tick }, + components: present ? { tower: { shots: 2 } } : {}, + }); + const result = buildReport( + CONFIG, scenario({ components: [{ entity: "tower", component: "shots", gte: 1 }] }), + snapshot(false, 0), snapshot(true, 2), [], [], undefined, {}, true, undefined, + [{ label: "placed", signals: [], snapshot: snapshot(true, 1) }], + ); + expect(result.observations?.components?.tower?.shots?.before).toBeUndefined(); + expect(result.pass).toBe(true); +}); + test("runner preserves physics debug series for contact and settled assertions", () => { const currentScenario = scenario({ contacts: [{ atStep: "contact", entity: "player", kind: "contact", minCount: 1, with: "solid-body" }], diff --git a/packages/playtest/__tests__/tone-runner.spec.ts b/packages/playtest/__tests__/tone-runner.spec.ts index ff792aefc4..2ea4cb7961 100644 --- a/packages/playtest/__tests__/tone-runner.spec.ts +++ b/packages/playtest/__tests__/tone-runner.spec.ts @@ -43,10 +43,10 @@ async function run(target: "android" | "desktop" | "ios", assert: unknown, scree capabilities: ["browser.screenshot", "browser.console", "runtime.diagnostics"], start: async () => undefined, close: async () => undefined, waitForBridge: async () => true, call: async (method: string, argument?: unknown): Promise => { - if (method === "describe") return { protocolVersion: PLAYTEST_PROTOCOL_VERSION, capabilities: ["runtime.fixedStep", "runtime.diagnostics"], limits: PLAYTEST_PROTOCOL_LIMITS, name: "tone-fixture" } as T; + if (method === "describe") return { protocolVersion: PLAYTEST_PROTOCOL_VERSION, capabilities: ["runtime.network", "runtime.fixedStep", "runtime.diagnostics"], limits: PLAYTEST_PROTOCOL_LIMITS, name: "tone-fixture" } as T; if (method === "ready") return { ready: true } as T; if (method === "advance") { tick += argument as number; return undefined as T; } - if (method === "sample") return { clock: { mode: "fixed-step", tick }, diagnostics: [], entities: [], resources: {} } as T; + if (method === "sample") return { clock: { mode: "fixed-step", tick }, diagnostics: [], entities: [], resources: {}, network: { requests: 0, entries: [] } } as T; throw new Error(`Unexpected transport operation ${method}`); }, }; diff --git a/packages/playtest/__tests__/unobservable-lane.spec.ts b/packages/playtest/__tests__/unobservable-lane.spec.ts index 46d0fb3c19..d1fa26124f 100644 --- a/packages/playtest/__tests__/unobservable-lane.spec.ts +++ b/packages/playtest/__tests__/unobservable-lane.spec.ts @@ -20,8 +20,8 @@ describe("1. the default diagnostics policy on a lane with no network observer", expect(resolveDiagnosticsPolicy(undefined, "browser").networkErrorsOptOutReason).toBeUndefined(); }); - it("should waive it on every device lane, and say why in the policy the report prints", () => { - for (const target of ["android", "desktop", "ios"]) { + it("should waive it on device lanes without an observer, and say why in the policy the report prints", () => { + for (const target of ["android", "ios"]) { const policy = resolveDiagnosticsPolicy(undefined, target); // The mutation the PRD names: revert the default-on-device handling and this flips to true, // which is then compared against `network: []` and reported as a passing row. @@ -33,6 +33,11 @@ describe("1. the default diagnostics policy on a lane with no network observer", } }); + it("asserts the observed desktop network channel by default", () => { + expect(resolveDiagnosticsPolicy(undefined, "desktop").noNetworkErrors).toBe(true); + expect(resolveDiagnosticsPolicy(undefined, "desktop").networkErrorsOptOutReason).toBeUndefined(); + }); + it("should treat `diagnostics: {}` exactly as an absent block", () => { expect(resolveDiagnosticsPolicy({}, "android").noNetworkErrors).toBe(false); }); diff --git a/packages/playtest/__tests__/world-gameplay.spec.ts b/packages/playtest/__tests__/world-gameplay.spec.ts index eeaf5f9267..8b36b11d9c 100644 --- a/packages/playtest/__tests__/world-gameplay.spec.ts +++ b/packages/playtest/__tests__/world-gameplay.spec.ts @@ -179,6 +179,13 @@ test("rejects a resident-tile topology that is smaller than the declared measure ).toBe(true); }); +test("retains terrain walk frame times for the blend-tail measurement", () => { + const scenario = JSON.parse( + readFileSync(path.resolve("examples/abyss-framework/playtests/terrain.playtest.json"), "utf8"), + ); + expect(scenario.assert.performance).toEqual({ minFps: 1 }); +}); + test("requires the terrain scenario to prove an LOD transition after its baseline", () => { const scenario = JSON.parse( readFileSync(path.resolve("examples/abyss-framework/playtests/terrain.playtest.json"), "utf8"), @@ -189,9 +196,9 @@ test("requires the terrain scenario to prove an LOD transition after its baselin expect(lod).toMatchObject({ changed: true }); expect(lod).not.toHaveProperty("allowTrivial"); expect(lod).not.toHaveProperty("gte"); - expect(component("maxSeamGap")).toMatchObject({ gte: 0.000001, lte: 64 }); + expect(component("maxSeamGap")).toMatchObject({ changed: true, gte: 0.000001, lte: 64 }); expect(component("maxSeamGap")).not.toHaveProperty("allowTrivial"); - expect(component("stitchedEdges")).toMatchObject({ entity: "terrain", gte: 1 }); + expect(component("stitchedEdges")).toMatchObject({ changed: true, entity: "terrainCpuReference", gte: 1 }); expect(component("stitchedEdges")).not.toHaveProperty("allowTrivial"); expect(component("maxVisualSeamGap")).toMatchObject({ lte: 0 }); expect(component("skirtVertexCount")).toMatchObject({ gte: 1 }); @@ -230,3 +237,135 @@ test("rejects a zero raw seam observation", () => { expect.objectContaining({ id: "component.terrain.maxSeamGap.value", pass: false }), ); }); + +test.each<{ + name: string; + before?: number; + after?: number; + changed?: boolean; + equals?: number; + gte?: number; + lte?: number; + pass: boolean; + trivial?: boolean; +}>([ + { + name: "changed value within an initially satisfied range", + changed: true, + before: 2, + after: 3, + gte: 1, + lte: 4, + pass: true, + }, + { + name: "unchanged value within an initially satisfied range", + changed: false, + before: 2, + after: 2, + gte: 1, + lte: 4, + pass: true, + }, + { + name: "changed comparator unmet", + changed: true, + before: 2, + after: 2, + gte: 1, + lte: 4, + pass: false, + }, + { + name: "unchanged comparator unmet", + changed: false, + before: 2, + after: 3, + gte: 1, + lte: 4, + pass: false, + }, + { + name: "changed value outside numeric range", + changed: true, + before: 2, + after: 5, + gte: 1, + lte: 4, + pass: false, + }, + { + name: "unchanged value outside numeric range", + changed: false, + before: 5, + after: 5, + gte: 1, + lte: 4, + pass: false, + }, + { + name: "changed value matching equality", + changed: true, + before: 2, + after: 3, + equals: 3, + pass: true, + }, + { + name: "unchanged value matching equality", + changed: false, + before: 2, + after: 2, + equals: 2, + pass: true, + }, + { + name: "changed value violating equality", + changed: true, + before: 2, + after: 3, + equals: 2, + pass: false, + }, + { + name: "initially satisfied numeric range alone stays trivial", + before: 2, + after: 3, + gte: 1, + lte: 4, + pass: false, + trivial: true, + }, + { + name: "numeric range crossed after the baseline", + before: 0, + after: 3, + gte: 1, + lte: 4, + pass: true, + }, + ...[true, false].flatMap((changed) => [ + { name: `${changed}: missing baseline`, changed, after: 2, gte: 1, pass: false }, + { name: `${changed}: missing current`, changed, before: 2, pass: false }, + { name: `${changed}: missing both`, changed, pass: false }, + ]), +])("component temporal comparison: $name", ({ name: _name, before, after, pass, ...expected }) => { + const input = report({}); + if (input.observations?.components === undefined) + throw new Error("Expected component observations."); + input.observations.components.probe = { metric: { before, after } }; + const { trivial = false, ...comparison } = expected; + const result = evaluateRichPlaytestAssertions({ + report: input, + scenario: scenario({ components: [{ entity: "probe", component: "metric", ...comparison }] }), + }); + expect(result.assertions.find(({ id }) => id === "component.probe.metric.value")).toMatchObject({ + pass, + details: { trivial }, + }); + const rejectedAsTrivial = result.diagnostics.some( + ({ code, message }) => + code === "TN_PLAYTEST_ASSERTION_TRIVIAL" && message.includes("component.probe.metric.value"), + ); + expect(rejectedAsTrivial).toBe(trivial); +}); diff --git a/packages/playtest/docs/reference.md b/packages/playtest/docs/reference.md index 28e27890d4..b4f163aa6a 100644 --- a/packages/playtest/docs/reference.md +++ b/packages/playtest/docs/reference.md @@ -75,8 +75,10 @@ an assertion that only means something on one target is a fork of the harness. A device target that cannot be reached fails `TN_PLAYTEST_DEVICE_FAILED`; it never degrades -to a browser run. Where a target genuinely lacks an observer — device transport has no CDP -network observer — the assertion **errors and names the working target**, it does not skip. +to a browser run. Desktop observes HTTP and asset fetch failures through the native host mailbox +and checks network errors by default. Missing, malformed or overflowing observations fail closed. +Android and iOS still lack a runner network observer; an explicit network assertion **errors and +names the working target**, it does not skip. The negative-control scenarios in `examples/native-smoke/playtests/` (`-misspelled`, `-wrong-value`) prove the device path still fails closed; run them when you change transport or observation code. diff --git a/packages/playtest/src/assertion-report.ts b/packages/playtest/src/assertion-report.ts index 0682f74ee9..fab01e1823 100644 --- a/packages/playtest/src/assertion-report.ts +++ b/packages/playtest/src/assertion-report.ts @@ -115,10 +115,10 @@ export interface IPlaytestObservations { /** * Targets whose network observation is hardwired empty. * - * `androidRunner.ts` builds every device report with `network: []` — there is no CDP session on a - * device transport to fill it. The browser lane is the only one that observes network at all. + * Android and iOS do not yet consume a network observer. Desktop reads the native host + * fetch observation through its playtest mailbox; browser uses request events. */ -const NETWORK_BLIND_TARGETS = new Set(["android", "desktop", "ios"]); +const NETWORK_BLIND_TARGETS = new Set(["android", "ios"]); /** The reason the runner writes for itself when it waives a channel the target cannot observe. */ export const UNOBSERVABLE_NETWORK_REASON = diff --git a/packages/playtest/src/assertion-schema.ts b/packages/playtest/src/assertion-schema.ts index dda17a52c2..215a55a898 100644 --- a/packages/playtest/src/assertion-schema.ts +++ b/packages/playtest/src/assertion-schema.ts @@ -346,7 +346,7 @@ export const PLAYTEST_ASSERTION_REGISTRY: readonly IPlaytestAssertionSchemaEntry observationPath: "runtimeDiagnostics", requiredCapabilities: ["browser.console", "browser.network", "runtime.diagnostics"], resultIdPrefix: "diagnostics", - supportedOn: ["web"], + supportedOn: ["web", "desktop"], triviality: "not-applicable", trivialityRationale: "It evaluates captured error and readiness channels across the run; no initial scalar value can satisfy those diagnostics by itself.", }, @@ -697,13 +697,14 @@ export function requiredPlaytestCapabilities( // native target; an explicit true remains required everywhere. const policy = resolveDiagnosticsPolicy(scenario.assert.diagnostics, target); if (policy.noConsoleErrors) required.add("browser.console"); - if (policy.noNetworkErrors) required.add("browser.network"); + if (policy.noNetworkErrors) required.add(target === "desktop" ? "runtime.network" : "browser.network"); if (policy.noRuntimeDiagnostics) required.add("runtime.diagnostics"); } else { entry.requiredCapabilities.forEach((capability) => required.add(capability)); } } } + if (target === "desktop" && resolveDiagnosticsPolicy(scenario.assert?.diagnostics, target).noNetworkErrors) required.add("runtime.network"); for (const entry of PLAYTEST_SETUP_REGISTRY) { if (scenario.setup?.[entry.kind] !== undefined) { entry.requiredCapabilities.forEach((capability) => required.add(capability)); diff --git a/packages/playtest/src/capabilities.ts b/packages/playtest/src/capabilities.ts index c71e8e4414..caaeaec6a9 100644 --- a/packages/playtest/src/capabilities.ts +++ b/packages/playtest/src/capabilities.ts @@ -20,6 +20,7 @@ export type PlaytestCapability = | "runtime.fixedStep" | "runtime.geometry" | "runtime.physics" + | "runtime.network" | "runtime.performance" | "runtime.pipelineCensus" | "runtime.renderChain" @@ -61,6 +62,7 @@ export const PLAYTEST_CAPABILITY_REGISTRY: readonly IPlaytestCapabilityDescripto capability("runtime.fixedStep", "Advances an application-owned deterministic tick."), capability("runtime.geometry", "Answers one armed per-object geometry capture for the frame it was requested on."), capability("runtime.physics", "Samples bounded application-owned physics observations."), + capability("runtime.network", "Captures native fetch requests and failures through the host mailbox."), capability("runtime.performance", "Samples bounded per-render frame cost and renderer counts."), capability("runtime.pipelineCensus", "Samples bounded shader and pipeline creation observations."), capability("runtime.renderChain", "Reports the render stages, quality tier, and velocity route actually applied."), diff --git a/packages/playtest/src/evaluators/world-gameplay.ts b/packages/playtest/src/evaluators/world-gameplay.ts index 72e7e7c45d..26e86ab203 100644 --- a/packages/playtest/src/evaluators/world-gameplay.ts +++ b/packages/playtest/src/evaluators/world-gameplay.ts @@ -1,5 +1,5 @@ import { readPath, jsonEqual, trivialAssertionDiagnostic, componentAssertionDiagnostic, axisIndex } from '../assertion-report.js'; -// Extracted verbatim from assertion-evaluators.ts (PRD-182 Phase 2); do not edit semantics here. +// Extracted from assertion-evaluators.ts (PRD-182 Phase 2). import type { IEvaluationContext } from "./context.js"; import { hasFinalComponentExpectation, rejectsTrivialAssertion, componentValueChecks, aerodynamicForceSampleCount, aerodynamicControlValues, aerodynamicTorqueAtLabel, evaluatePathAssertion, evaluateTagCountAssertion, evaluateStateAssertion } from "./helpers.js"; @@ -27,14 +27,14 @@ export function emitWorldGameplay(ctx: IEvaluationContext): void { ]; const checks = [ ...valueChecks, - // Same absent-value trap as evaluatePathAssertion: a component that was - // never observed must not satisfy "this value did not change". + // A temporal comparison needs both observations; appearance or disappearance is not a measured change. ...(assertion.changed === undefined ? [] - : [(before !== undefined || after !== undefined) + : [(before !== undefined && after !== undefined) && (assertion.changed ? !jsonEqual(before, after) : jsonEqual(before, after))]), ]; const trivial = rejectsTrivialAssertion("components") + && assertion.changed === undefined && valueChecks.length > 0 && before !== undefined && componentValueChecks(assertion, before).every(Boolean); diff --git a/packages/playtest/src/protocol.ts b/packages/playtest/src/protocol.ts index 5adcbe8ed2..54c5dc9172 100644 --- a/packages/playtest/src/protocol.ts +++ b/packages/playtest/src/protocol.ts @@ -538,6 +538,7 @@ export interface IPlaytestObservationSnapshot { * `runtime.physics`. */ physicsSteps?: number; + network?: { requests: number; entries: Array<{ method: string; url: string }> }; performance?: IPlaytestPerformanceObservation; /** Bounded shader/pipeline creation capture supplied by the engine when diagnostics are enabled. */ pipelineCensus?: JsonValue; diff --git a/packages/playtest/src/runner/androidRunner.ts b/packages/playtest/src/runner/androidRunner.ts index fe610dcfbb..e8fb6880d7 100644 --- a/packages/playtest/src/runner/androidRunner.ts +++ b/packages/playtest/src/runner/androidRunner.ts @@ -6,6 +6,7 @@ import { assertCaptureNotBlank } from "../capture.js"; import { loadPlaytestScenario, playtestDiagnostic, + resolveDiagnosticsPolicy, playtestStepHoldTicks, playtestStepWaitTicks, type IPlaytestFramebufferCoverageObservation, @@ -239,7 +240,7 @@ async function runDevicePlaytestInternal( setupApplication = bridge.setupApplication; await throwIfAborted(target); // Startup owns temporary operation timeouts during first-use compilation. Complete that - // gate before the scenario warmup, whose full tick count must run against the ready world. + // gate before the scenario's frame warmup against the ready world. const attached = bridge; const startupOutcome = scenario.awaitStartup === false ? undefined @@ -253,7 +254,11 @@ async function runDevicePlaytestInternal( pump: () => attached.advance(1), }); await throwIfAborted(target); - if (scenario.warmupFrames > 0) await bridge.advance(scenario.warmupFrames); + // Device requests are polled on animation frames; observe warmup draws, as the browser does, + // without spending simulation ticks before the scenario starts. + for (let frame = 0; frame < scenario.warmupFrames; frame += 1) { + await bridge.sample({ entities: [], include: [] }); + } await throwIfAborted(target); const entityIds = observedEntityIds(scenario); @@ -540,13 +545,16 @@ async function runDevicePlaytestInternal( ), target.name); } const consoleEntries = await target.driver.captureConsole(); + const networkEntries = target.name === "desktop" && resolveDiagnosticsPolicy(scenario.assert?.diagnostics, target.name).noNetworkErrors + ? desktopNetworkEntries(after) + : []; const report = buildReport( config, scenario, before, after, consoleEntries, - [], + networkEntries, accumulatedPathLength(pathPositions), {}, true, @@ -573,7 +581,7 @@ async function runDevicePlaytestInternal( // there whichever target produced the run. await writeObservationArtifacts(config.artifactDirectory, scenario.artifacts, { console: consoleEntries, - network: [], + network: networkEntries, runtimeTrace: undefined, }); return { @@ -1130,12 +1138,8 @@ function unsupportedAssertion( target, ); } - // Only an explicit `true`. The omitted case is no longer the same question: since - // `resolveDiagnosticsPolicy` learned the run target, a device lane defaults the network channel - // *off* with the reason recorded, rather than defaulting it on and comparing it to an - // observation that is hardwired empty. A scenario that spells out `true` is still asking the - // target for something it cannot do, and still fails here by name. - if (scenario.assert?.diagnostics?.noNetworkErrors === true) { + // Desktop consumes the host fetch observation; the other device lanes still lack an observer. + if (scenario.assert?.diagnostics?.noNetworkErrors === true && target !== "desktop") { return unsupportedDiagnostic( "network assertions", `Run this assertion on --target browser; ${targetLabel(target)} device transport has no CDP network observer. Remove the explicit network assertion, or declare "diagnostics": { "noNetworkErrors": false, "networkErrorsOptOutReason": "..." } to record a scenario-owned waiver.`, @@ -1198,3 +1202,16 @@ function unreadableCoverageObservation(error: unknown): IPlaytestFramebufferCove function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } + + +function desktopNetworkEntries(snapshot: IPlaytestObservationSnapshot): Array<{ method: string; url: string }> { + const network = snapshot.network; + if (network === undefined || network === null || !Number.isSafeInteger(network.requests) + || network.requests < 0 || !Array.isArray(network.entries) || network.entries.length > 256 + || network.requests < network.entries.length || network.entries.some((entry) => + entry === null || typeof entry !== "object" || typeof entry.method !== "string" + || entry.method.length === 0 || typeof entry.url !== "string" || entry.url.length === 0)) { + throw new Error("TN_PLAYTEST_NETWORK_UNOBSERVED: desktop host network observation is missing or malformed."); + } + return network.entries; +} diff --git a/packages/playtest/src/runner/browser.ts b/packages/playtest/src/runner/browser.ts index 7473ebc906..b0fe9c4405 100644 --- a/packages/playtest/src/runner/browser.ts +++ b/packages/playtest/src/runner/browser.ts @@ -5,12 +5,13 @@ export const WEBGPU_BROWSER_ARGS = [ "--enable-unsafe-webgpu", "--disable-gpu-sandbox", "--ignore-gpu-blocklist", - // Without this Chromium never reaches the Vulkan driver on Linux and silently serves WebGPU - // from SwiftShader, its CPU rasteriser. Nothing errors: the adapter answers, the run - // proceeds, and the results are a software renderer's. Measured on an RTX 2080 under Xvfb, - // `adapter.info` reads `swiftshader / google` without the flag and `turing / nvidia` with it, - // which is the difference between blank captures and a rendered frame. + // Keep Vulkan available on both hardware and software adapters. "--enable-features=Vulkan", + // Forced ANGLE/Vulkan selects NVIDIA locally but breaks hosted software adapters, especially headed. + // Apply that workaround only when the caller explicitly requests the host display. + ...(process.env.TN_PLAYTEST_HOST_DISPLAY === "1" + ? ["--use-angle=vulkan", "--use-vulkan", "--disable-vulkan-surface"] + : []), ] as const; /** Allow performance probes to sample render work instead of Xvfb's compositor cadence. */ diff --git a/packages/playtest/src/runner/desktop.ts b/packages/playtest/src/runner/desktop.ts index 2f8e6fa15e..9f0d65bec5 100644 --- a/packages/playtest/src/runner/desktop.ts +++ b/packages/playtest/src/runner/desktop.ts @@ -87,7 +87,11 @@ export class DesktopPlaytestDriver implements IDevicePlaytestDriver { && this.child.signalCode === null; } - async prepare(_endpoint: string, mailboxRoot?: string): Promise { + async prepare( + _endpoint: string, + mailboxRoot?: string, + viewport?: { height: number; width: number }, + ): Promise { if (this.child !== undefined && await this.isAlive()) { throw new Error("Desktop playtest executable is already running."); } @@ -112,7 +116,12 @@ export class DesktopPlaytestDriver implements IDevicePlaytestDriver { ...this.options.env, TN_PLAYTEST_MAILBOX_ROOT: this.mailboxRoot, }; - const child = spawn(this.options.executable, [...(this.options.args ?? [])], { + const child = spawn(this.options.executable, [ + ...(this.options.args ?? []), + ...(viewport === undefined + ? [] + : ["--width", String(viewport.width), "--height", String(viewport.height), "--windowed"]), + ], { cwd: this.options.cwd, detached: process.platform !== "win32", env: environment, diff --git a/packages/playtest/src/runner/runner-support.ts b/packages/playtest/src/runner/runner-support.ts index 0808542604..a0d96d1589 100644 --- a/packages/playtest/src/runner/runner-support.ts +++ b/packages/playtest/src/runner/runner-support.ts @@ -229,6 +229,17 @@ export function buildReport( const beforeRotation = entityRotation(movementBeforeSnapshot, entity); const afterRotation = entityRotation(movementAfterSnapshot, entity); const components = componentObservations(beforeSnapshot, afterSnapshot); + for (const assertion of scenario.assert?.components ?? []) { + const observed = components?.[assertion.entity]?.[assertion.component]; + if (assertion.changed === undefined || observed === undefined || observed.before !== undefined) continue; + // Only temporal assertions use a spawned entity's measured lifetime; final-value guards keep the run baseline. + const baseline = labeledSamples.find(({ snapshot }) => + snapshot.components?.[assertion.entity]?.[assertion.component] !== undefined + && (snapshot.clock.tick ?? snapshot.clock.timeMs ?? Number.POSITIVE_INFINITY) + < (afterSnapshot?.clock.tick ?? afterSnapshot?.clock.timeMs ?? Number.NEGATIVE_INFINITY)) + ?.snapshot.components?.[assertion.entity]?.[assertion.component]; + if (baseline !== undefined) observed.before = baseline; + } const movementDelta = beforePosition === undefined || afterPosition === undefined ? undefined : subtract(afterPosition, beforePosition); diff --git a/packages/playtest/src/three/device.ts b/packages/playtest/src/three/device.ts index adcec2e82e..f7c750b88f 100644 --- a/packages/playtest/src/three/device.ts +++ b/packages/playtest/src/three/device.ts @@ -4,6 +4,7 @@ import { jsonByteLength, playtestDiagnostic, type IPlaytestBridgeV1, + type IPlaytestObservationSnapshot, type IPlaytestDeviceRequest, type IPlaytestDeviceResponse, type JsonValue, @@ -88,6 +89,7 @@ function connectNativeMailbox(bridge: IPlaytestBridgeV1): IDeviceBridgeInstallat TN_PLAYTEST_MAILBOX?: { request?: unknown; response?: unknown }; __THREENATIVE_NATIVE__?: { playtest?: { + network?(): NonNullable; receive?(path: string): string | undefined; respond?(path: string, payload: string, requestId?: string, method?: string, order?: number): boolean; }; @@ -101,6 +103,15 @@ function connectNativeMailbox(bridge: IPlaytestBridgeV1): IDeviceBridgeInstallat || typeof host?.receive !== "function" || typeof host.respond !== "function" ) return undefined; + if (typeof host.network === "function") { + const describe = bridge.describe.bind(bridge); + bridge.describe = async () => { + const description = await describe(); + return { ...description, capabilities: [...description.capabilities, "runtime.network"] }; + }; + const sample = bridge.sample.bind(bridge); + bridge.sample = async (request) => ({ ...await sample(request), network: host.network!() }); + } let closed = false; let frame: number | undefined; const nativePointers = new Map(); diff --git a/packages/runtime-native/__tests__/bundle-minify.spec.ts b/packages/runtime-native/__tests__/bundle-minify.spec.ts index 79380e80ed..0688c4df80 100644 --- a/packages/runtime-native/__tests__/bundle-minify.spec.ts +++ b/packages/runtime-native/__tests__/bundle-minify.spec.ts @@ -11,6 +11,28 @@ const bundlerScript = path.resolve("packages/runtime-native/scripts/bundle.mjs") // `bundle.mjs` builds with the *game's* pinned copy, so the fixture borrows the scaffolder's. const viteInstall = path.resolve("packages/create-threenative/node_modules/vite"); +test("packages the shared terrain game as a portable desktop entry", async () => { + const root = await makeTempDir("threenative-terrain-entry-"); + const output = path.join(root, "terrain.js"); + await run(process.execPath, [ + bundlerScript, + "--project", + path.resolve("examples/abyss-framework"), + "--entry", + "src/terrain-game.ts", + "--target", + "desktop", + "--output", + output, + ]); + const source = await readFile(output, "utf8"); + expect(source).toContain("TN_NATIVE_START_FAILED"); + expect(source).not.toContain("Missing #root element."); + expect( + JSON.parse(await readFile(path.join(root, "terrain-assets/assets.manifest.json"), "utf8")), + ).toEqual({ version: 1, entries: {} }); +}, 120_000); + /** * The shipped asset is minified, but core reads `backend.constructor.name` for the renderer's * backend stamp and the worker wire reads `value.constructor.name`. The minifier must keep the diff --git a/packages/runtime-native/src/runtime-scripts/fetch-polyfill.js b/packages/runtime-native/src/runtime-scripts/fetch-polyfill.js index 6d0ff309a9..07794ec076 100644 --- a/packages/runtime-native/src/runtime-scripts/fetch-polyfill.js +++ b/packages/runtime-native/src/runtime-scripts/fetch-polyfill.js @@ -463,7 +463,7 @@ globalThis.Request = Request; // Fetch function - supports file://, http://, and https:// // HTTP requests are now async via libuv (non-blocking) // Accepts either a URL string or a Request object (Three.js passes a Request). -async function fetch(input, options = {}) { +async function fetchRequest(input, options = {}) { let url; if (input && typeof input === "object" && typeof input.url === "string") { // Unwrap a Request object: pull url + per-request fields unless overridden. @@ -576,6 +576,27 @@ async function fetch(input, options = {}) { }); } +// Retain actual fetch failures from startup through the final playtest observation. +const networkEntries = []; +let networkRequests = 0; +async function fetch(input, options = {}) { + const url = input && typeof input.url === "string" ? input.url : String(input); + const method = options.method || input?.method || "GET"; + networkRequests++; + try { + const response = await fetchRequest(input, options); + if (!response.ok && networkEntries.length <= 256) networkEntries.push({ method, url }); + return response; + } catch (error) { + if (networkEntries.length <= 256) networkEntries.push({ method, url }); + throw error; + } +} +fetch.network = () => { + if (networkEntries.length > 256) throw new Error("TN_PLAYTEST_NETWORK_OVERFLOW: more than 256 failed requests."); + return { requests: networkRequests, entries: networkEntries.map((entry) => ({ ...entry })) }; +}; + // Also expose globally globalThis.fetch = fetch; globalThis.Response = Response; diff --git a/packages/runtime-native/src/runtime.cpp b/packages/runtime-native/src/runtime.cpp index acc313159f..2687d29f78 100644 --- a/packages/runtime-native/src/runtime.cpp +++ b/packages/runtime-native/src/runtime.cpp @@ -3781,6 +3781,8 @@ class RuntimeImpl : public Runtime { ); jsEngine_->setProperty(nativeHost, "playtestInput", playtestHost); auto playtestMailbox = jsEngine_->newObject(); + jsEngine_->setProperty(playtestMailbox, "network", + jsEngine_->getProperty(jsEngine_->getGlobalProperty("fetch"), "network")); jsEngine_->setProperty(playtestMailbox, "receive", jsEngine_->newFunction("receive", [this](void*, const std::vector& args) { if (args.empty()) return jsEngine_->newUndefined(); diff --git a/packages/runtime-native/tests/fetch-shim.test.mjs b/packages/runtime-native/tests/fetch-shim.test.mjs index 98336068e1..720f6995b4 100644 --- a/packages/runtime-native/tests/fetch-shim.test.mjs +++ b/packages/runtime-native/tests/fetch-shim.test.mjs @@ -340,3 +340,41 @@ test("unsupported URL schemes reject instead of falling through to file reads", /Unsupported URL scheme: ftp/u, ); }); + +test("native fetch network observation records real HTTP and asset failures", async () => { + const { context } = setupFetchContext({ + __httpRequestAsync: (url, _options, callback) => callback( + url.endsWith("/reject") ? { error: "connection refused" } + : { ok: !url.endsWith("/404"), status: url.endsWith("/404") ? 404 : 200, url }, + ), + __readFileAsync: (_path, callback) => callback(null, "missing asset"), + }); + const observation = await vm.runInContext(`(async () => { + await fetch("https://host/ok"); + await fetch(new Request("https://host/404", { method: "POST" })); + await fetch("https://host/reject").catch(() => {}); + await fetch("missing.bin").catch(() => {}); + return fetch.network(); + })()`, context); + assert.deepEqual(guest(observation), { requests: 4, entries: [ + { method: "POST", url: "https://host/404" }, + { method: "GET", url: "https://host/reject" }, + { method: "GET", url: "missing.bin" }, + ] }); + assert.match(readRuntimeCpp(), /setProperty\(playtestMailbox, "network"/u); +}); + +test("native fetch network observation fails closed on overflow and protects its retained entries", async () => { + const { context } = setupFetchContext({ + __httpRequestAsync: (_url, _options, callback) => callback({ ok: false, status: 500 }), + }); + await vm.runInContext(`(async () => { + await fetch("https://host/error"); + fetch.network().entries.length = 0; + })()`, context); + assert.equal(vm.runInContext("fetch.network().entries.length", context), 1); + await vm.runInContext(`(async () => { + for (let i = 0; i < 256; i++) await fetch("https://host/error"); + })()`, context); + assert.throws(() => vm.runInContext("fetch.network()", context), /NETWORK.*OVERFLOW/u); +}); diff --git a/packages/runtime-native/tests/runtime-next-contract.test.mjs b/packages/runtime-native/tests/runtime-next-contract.test.mjs index c78e6ba2a7..6d72121113 100644 --- a/packages/runtime-native/tests/runtime-next-contract.test.mjs +++ b/packages/runtime-native/tests/runtime-next-contract.test.mjs @@ -41,7 +41,7 @@ function implementedRows() { const RUNTIME_SCRIPT_HASHES = { 'storage-polyfill.js': '7e03f256b0e11b5370bf86ccb5ae286be221080a1baa8339efe2aa570dd3c25d', - 'fetch-polyfill.js': '7b4cf3551605af8d13175239f1f270722af5e709067ad451fa07790a0d58ec1a', + 'fetch-polyfill.js': '352dbc282ed5f8f4f389d1ec79c18fc0d7e18dbdf5e79c6509515fabdee54cd2', 'streams-polyfill.js': 'b57569ac2079bc5864eb7c8aefef6b03321acad8b6f6364b3de714e2bee64d83', 'url-worker-polyfill.js': 'fb709b56f527c82cc7ba4d2c3430a6f17c81e0550f736d145819b25463811af3', 'create-element-setup.js': '3891c716e3e7b8801f45306b50c5c8c5990042276524fbaac745b157389d1bee', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f34a270ad7..e3ef5e2948 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: f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f + hash: 21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@tailwindcss/vite': specifier: 'catalog:' @@ -245,7 +245,7 @@ importers: dependencies: three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: vite: specifier: 'catalog:' @@ -264,7 +264,7 @@ importers: version: link:../../packages/physics three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@threenative/playtest': specifier: workspace:* @@ -323,7 +323,7 @@ importers: version: link:../../packages/playtest three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@threenative/playtest': specifier: workspace:* @@ -410,7 +410,7 @@ importers: version: link:../../packages/playtest three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@types/three': specifier: 0.185.3 @@ -448,7 +448,7 @@ importers: dependencies: three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@tailwindcss/vite': specifier: 'catalog:' @@ -522,7 +522,7 @@ importers: version: link:../../packages/physics three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@types/react': specifier: 'catalog:' @@ -556,7 +556,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@threenative/playtest': specifier: 0.2.0 - version: 0.2.0(playwright@1.62.1)(three@0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f)) + version: 0.2.0(playwright@1.62.1)(three@0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d)) '@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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@threenative/assets': specifier: workspace:* @@ -838,7 +838,7 @@ importers: version: link:../../packages/playtest three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@threenative/assets': specifier: workspace:* @@ -872,7 +872,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@threenative/terrain': specifier: workspace:* @@ -928,7 +928,7 @@ importers: version: link:../../packages/core three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) three-mesh-bvh: specifier: 'catalog:' - version: 0.9.14(three@0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f)) + version: 0.9.14(three@0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d)) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) three-mesh-bvh: specifier: 'catalog:' - version: 0.9.14(three@0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f)) + version: 0.9.14(three@0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d)) threenative-asset-mcp: specifier: 0.11.0 version: 0.11.0(@types/node@20.19.43) @@ -1124,7 +1124,7 @@ importers: version: 1.1.1 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@threenative/assets': specifier: workspace:* @@ -1197,7 +1197,7 @@ importers: version: 0.3.22 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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) @@ -1228,7 +1228,7 @@ importers: version: 0.3.22 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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) @@ -1259,7 +1259,7 @@ importers: version: 0.3.22 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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) @@ -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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f)) + version: https://codeload.github.com/majidmanzarpour/threejs-procedural-animals/tar.gz/c95ae49346aa8e140a924376cec6cf0073d99512(three@0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d)) three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@threenative/assets': specifier: workspace:* @@ -1302,7 +1302,7 @@ importers: dependencies: three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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)) @@ -1376,7 +1376,7 @@ importers: version: 0.3.22 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) 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) @@ -1394,7 +1394,7 @@ importers: version: 0.8.3 three: specifier: 'catalog:' - version: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + version: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) devDependencies: '@types/node': specifier: 20.19.43 @@ -4749,8 +4749,8 @@ snapshots: '@threenative/core@0.2.0(@types/react@19.2.0)(react@19.2.0)': dependencies: - three: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) - three-mesh-bvh: 0.9.14(three@0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f)) + three: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) + three-mesh-bvh: 0.9.14(three@0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d)) zustand: 5.0.14(@types/react@19.2.0)(react@19.2.0) transitivePeerDependencies: - '@types/react' @@ -4758,19 +4758,19 @@ snapshots: - react - use-sync-external-store - '@threenative/playtest@0.2.0(playwright@1.62.1)(three@0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f))': + '@threenative/playtest@0.2.0(playwright@1.62.1)(three@0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d))': dependencies: pngjs: 7.0.0 optionalDependencies: playwright: 1.62.1 - three: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + three: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) - '@threenative/playtest@0.3.2(playwright@1.62.0)(three@0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f))': + '@threenative/playtest@0.3.2(playwright@1.62.0)(three@0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d))': dependencies: pngjs: 7.0.0 optionalDependencies: playwright: 1.62.0 - three: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + three: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) '@threenative/runtime-native@0.2.0': dependencies: @@ -5684,9 +5684,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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f)): + procedural-animals@https://codeload.github.com/majidmanzarpour/threejs-procedural-animals/tar.gz/c95ae49346aa8e140a924376cec6cf0073d99512(three@0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d)): dependencies: - three: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + three: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) proj4@2.22.0(geotiff@3.0.5): dependencies: @@ -5963,11 +5963,11 @@ snapshots: dependencies: any-promise: 1.3.0 - three-mesh-bvh@0.9.14(three@0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f)): + three-mesh-bvh@0.9.14(three@0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d)): dependencies: - three: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + three: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) - three@0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f): {} + three@0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d): {} threenative-asset-mcp@0.11.0(@types/node@20.19.43): dependencies: @@ -5975,11 +5975,11 @@ 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=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f)) + '@threenative/playtest': 0.3.2(playwright@1.62.0)(three@0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d)) '@zip.js/zip.js': 2.8.34 playwright: 1.62.0 sharp: 0.35.4(@types/node@20.19.43) - three: 0.185.1(patch_hash=f3c6b1fca2a168855ac231df8aea1b0ffd76a4a8bf228355a029ece9618b227f) + three: 0.185.1(patch_hash=21ed5f6632ee7188f9bc46fa5a3da4af3e44dd32a0d6da119f49f555e09ea38d) zod: 4.4.3 transitivePeerDependencies: - '@types/node' diff --git a/scripts/__tests__/fluid-collision-native-proof.spec.ts b/scripts/__tests__/fluid-collision-native-proof.spec.ts index 6ed99656e4..f2e9a53e10 100644 --- a/scripts/__tests__/fluid-collision-native-proof.spec.ts +++ b/scripts/__tests__/fluid-collision-native-proof.spec.ts @@ -222,7 +222,12 @@ test("preserves numerical predicates and passes native preflight while diagnosti async call(method: string): Promise { if (method === "describe") return { - capabilities: ["runtime.resources", "runtime.fixedStep", "runtime.diagnostics"], + capabilities: [ + "runtime.network", + "runtime.resources", + "runtime.fixedStep", + "runtime.diagnostics", + ], limits: PLAYTEST_PROTOCOL_LIMITS, name: "@threenative/playtest/three", protocolVersion: 1, diff --git a/scripts/__tests__/native-asset-capture-proof.spec.ts b/scripts/__tests__/native-asset-capture-proof.spec.ts index 719894c72e..630fc06cfa 100644 --- a/scripts/__tests__/native-asset-capture-proof.spec.ts +++ b/scripts/__tests__/native-asset-capture-proof.spec.ts @@ -157,7 +157,12 @@ test("the actual native bridge preflight accepts the fixture's assertion familie async call(method: string): Promise { if (method === "describe") return { - capabilities: ["runtime.resources", "runtime.fixedStep", "runtime.startup"], + capabilities: [ + "runtime.network", + "runtime.resources", + "runtime.fixedStep", + "runtime.startup", + ], limits: PLAYTEST_PROTOCOL_LIMITS, name: "@threenative/playtest/three", protocolVersion: 1, diff --git a/scripts/__tests__/native-loading-proof.spec.ts b/scripts/__tests__/native-loading-proof.spec.ts index 85b7d70c2f..73af4d3e09 100644 --- a/scripts/__tests__/native-loading-proof.spec.ts +++ b/scripts/__tests__/native-loading-proof.spec.ts @@ -84,6 +84,13 @@ function loadingFixture() { } describe("native loading stall fixture", () => { + it("ships the empty manifest requested during procedural smoke startup", () => { + const manifest = JSON.parse( + readFileSync(path.join(repo, "examples/native-smoke/assets.manifest.json"), "utf8"), + ); + expect(manifest).toEqual({ version: 1, entries: {} }); + }); + it("cannot end after three seconds with only 57 frame opportunities", async () => { const fixture = loadingFixture(); await fixture.advance(3_000, 57); diff --git a/scripts/__tests__/vq-fog-native-proof.spec.ts b/scripts/__tests__/vq-fog-native-proof.spec.ts index 313a300bfc..b1e8338761 100644 --- a/scripts/__tests__/vq-fog-native-proof.spec.ts +++ b/scripts/__tests__/vq-fog-native-proof.spec.ts @@ -169,7 +169,12 @@ it("passes the real native handshake preflight for every generated arm", async ( async call(method: string): Promise { if (method === "describe") return { - capabilities: ["runtime.resources", "runtime.fixedStep", "runtime.startup"], + capabilities: [ + "runtime.network", + "runtime.resources", + "runtime.fixedStep", + "runtime.startup", + ], limits: PLAYTEST_PROTOCOL_LIMITS, name: "@threenative/playtest/three", protocolVersion: 1, diff --git a/scripts/verify-volumetric-fog.ts b/scripts/verify-volumetric-fog.ts index 6aabb049d6..7573f9c4b9 100644 --- a/scripts/verify-volumetric-fog.ts +++ b/scripts/verify-volumetric-fog.ts @@ -726,6 +726,9 @@ async function main(): Promise { ); try { if (nativeRuntime !== undefined) { + // Stage the asset manifest and run from its root, as packaged native games do. + const { compileAssets } = await import("../packages/assets/dist/index.js"); + await compileAssets({ cwd: fixture, output: artifacts }); execFileSync( process.execPath, [ @@ -786,7 +789,7 @@ async function main(): Promise { }) : await runDesktopPlaytest({ artifactDirectory: directory, - projectPath: fixture, + projectPath: artifacts, scenarioPath, target: "desktop", desktop: {