diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index 9962f0e42d..63c4ff04b0 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -47,6 +47,7 @@ jobs:
fluid-particles: ${{ steps.filter.outputs.fluid-particles }}
fluid-native: ${{ steps.filter.outputs.fluid-native }}
ik: ${{ steps.filter.outputs.ik }}
+ native-assets: ${{ steps.filter.outputs.native-assets }}
tone: ${{ steps.filter.outputs.tone }}
vegetation: ${{ steps.filter.outputs.vegetation }}
world-capture: ${{ steps.filter.outputs.world-capture }}
@@ -70,6 +71,7 @@ jobs:
fluid-native ^packages/core/(src|__tests__)/(fluid|gpu-readback|particles)[^/]*$|^packages/(physics|playtest)/.*$|^examples/prd476-fluid-particles/.*$|^scripts/fluid-collision-proof\.ts$|^scripts/__tests__/fluid-collision-(proof|view)\.spec\.ts$|^package\.json$|^pnpm-(lock|workspace)\.yaml$|^tsconfig[^/]*\.json$|^patches/.*$|^\.github/actions/pnpm/.*$|^scripts/workspace-packages\.ts$|^packages/runtime-native/.*$|^scripts/verify-fluid-collision-native\.ts$|^scripts/__tests__/fluid-collision-native-proof\.spec\.ts$|^\.github/actions/workspace-dist/.*$
fog ^packages/create-threenative/templates/starter/src/render/volumetricFog[A-Za-z]*\.ts$|^packages/create-threenative/templates/starter/src/render/postprocessing\.ts$|^packages/create-threenative/templates/starter/src/scenes/Play\.ts$|^packages/create-threenative/template-assets/worldEnvironment\.ts$|^packages/create-threenative/__tests__/vq-(volumetric-fog|fog-fixture)\.spec\.ts$|^packages/core/src/renderer\.ts$|^packages/core/src/render/chain\.ts$|^examples/abyss-framework/vq-fog/.*$|^examples/abyss-framework/playtests/vq-volumetric-fog\.playtest\.json$|^scripts/verify-volumetric-fog\.ts$|^scripts/__tests__/(verify-volumetric-fog|vq-fog-native-proof)\.spec\.ts$|^packages/runtime-native/include/mystral/webgpu_compat\.h$|^packages/runtime-native/src/webgpu/bindings_presentation\.cpp$|^packages/runtime-native/tests/surface_texture_status_test\.cpp$|^packages/runtime-native/tests/canvas_presentation_test\.cpp$
ik ^examples/integrations/ik/.*$
+ native-assets ^packages/create-threenative/src/build\.ts$|^packages/create-threenative/src/buildReport\.ts$|^packages/create-threenative/__tests__/vq-native-[^/]*\.spec\.ts$|^packages/assets/src/(compile|report)\.ts$|^packages/core/src/assets\.ts$|^packages/runtime-native/scripts/bundle\.mjs$|^packages/runtime-native/scripts/package-desktop\.mjs$|^packages/runtime-native/scripts/native-build\.mjs$|^packages/runtime-native/scripts/download-deps\.mjs$|^examples/abyss-framework/vq-assets/.*$|^examples/abyss-framework/playtests/vq-native-asset-capabilities\.playtest\.json$|^scripts/verify-native-asset-capabilities\.ts$|^scripts/native-asset-capture-proof\.ts$|^scripts/__tests__/native-asset-capture-proof\.spec\.ts$
tone ^scripts/xvfb\.sh$|^packages/playtest/.*$|^examples/abyss-framework/src/render/toneCalibration\.ts$|^examples/abyss-framework/src/tone-main\.ts$|^examples/abyss-framework/tone\.html$|^examples/abyss-framework/tone\.vite\.config\.ts$|^examples/abyss-framework/playtests/tone\.playtest\.json$|^examples/abyss-framework/package\.json$|^scripts/verify-tone-exposure\.ts$|^scripts/tone-capture-proof\.ts$|^scripts/__tests__/tone-capture-proof\.spec\.ts$|^scripts/non-visual-scenarios\.mjs$|^package\.json$|^pnpm-lock\.yaml$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^packages/create-threenative/(template-assets|templates/[^/]+/src/render)/(autoExposure|exposure|exposureGraph|exposureReadback|exposureSettings|worldEnvironment)\.ts$
vegetation ^examples/integrations/vegetation/.*$
world-capture ^packages/core/src/world[^/]*\.ts$|^packages/core/__tests__/world-capture-fixture\.spec\.ts$|^packages/playtest/.*$|^packages/assets/.*$|^examples/abyss-framework/src/scenes/WorldProbe\.ts$|^examples/abyss-framework/src/world-main\.ts$|^examples/abyss-framework/src/style\.css$|^examples/abyss-framework/assets/world/.*$|^examples/abyss-framework/index\.html$|^examples/abyss-framework/vite\.config\.ts$|^examples/abyss-framework/threenative\.config\.ts$|^examples/abyss-framework/playtests/phase477-world-capture\.playtest\.json$|^examples/abyss-framework/package\.json$|^scripts/verify-world-capture\.ts$|^scripts/world-capture-manifest\.ts$|^scripts/world-visual-gate\.ts$|^scripts/capture-guard\.ts$|^scripts/__tests__/verify-world-capture\.spec\.ts$|^scripts/__tests__/world-capture-manifest\.spec\.ts$|^scripts/__tests__/world-visual-gate\.spec\.ts$|^package\.json$|^pnpm-lock\.yaml$|^pnpm-workspace\.yaml$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$
@@ -537,6 +539,72 @@ jobs:
artifacts/fluid-consumers/failure.json
artifacts/fluid-consumers/ci-diagnostics.json
if-no-files-found: error
+
+ packaged-quickjs:
+ name: packaged native asset fallback
+ if: ${{ !github.event.pull_request.draft && needs.paths.outputs.native-assets == 'true' }}
+ needs: paths
+ # Match native-platforms starter-linux: this architecture selects QuickJS/wgpu without
+ # changing any runtime default. Native dependencies use the repository's locked provisioner.
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 60
+ env:
+ CMAKE_BUILD_PARALLEL_LEVEL: '2'
+ TN_PLAYTEST_ALLOW_SOFTWARE: '1'
+ TN_PLAYTEST_NATIVE_SCREENSHOT_TIMEOUT_MS: '30000'
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
+ with:
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha }}
+ - uses: ./.github/actions/pnpm
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: '22'
+ cache: pnpm
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.12'
+ - run: pnpm install --frozen-lockfile
+ - name: Build this commit's JavaScript packages
+ uses: ./.github/actions/workspace-dist
+ with:
+ save-bundles: 'false'
+ pack-archives: 'false'
+ - name: Validate real compressed-source normalization and capture guards
+ run: >-
+ pnpm exec vitest run --maxWorkers=1
+ packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts
+ packages/create-threenative/__tests__/vq-native-fixture.spec.ts
+ scripts/__tests__/native-asset-capture-proof.spec.ts
+ - name: Restore checksum-provisioned native dependencies
+ uses: actions/cache/restore@v4
+ with:
+ path: packages/runtime-native/third_party
+ key: native-third-party-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/runtime-native/scripts/download-deps.mjs', 'packages/runtime-native/scripts/package-android.mjs') }}
+ - name: Install the existing native lane's system prerequisites
+ run: >-
+ sudo apt-get update && sudo apt-get install -y
+ libasound2-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev
+ libpulse-dev libudev-dev libx11-dev libxext-dev libxrandr-dev
+ libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev
+ libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr
+ libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev
+ - name: Build the same QuickJS host the normal ARM desktop lane ships
+ run: pnpm --filter @threenative/runtime-native native:build
+ - name: Build the real fixture and capture its packaged native executable
+ env:
+ THREENATIVE_RUNTIME_BINARY: ${{ github.workspace }}/packages/runtime-native/build/tn-linux/mystral
+ run: node --import tsx scripts/verify-native-asset-capabilities.ts
+ - name: Retain raw native captures, artifact identities and decoded payload measurements
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: vq01-native-assets-${{ github.event.pull_request.head.sha }}
+ path: artifacts/vq01-native-assets/
+ retention-days: 30
+ if-no-files-found: warn
+
exposure:
name: auto-exposure
if: ${{ !github.event.pull_request.draft && needs.paths.outputs.exposure == 'true' }}
diff --git a/biome.json b/biome.json
index 4ed7652750..619e6fc5b1 100644
--- a/biome.json
+++ b/biome.json
@@ -39,6 +39,7 @@
"examples/abyss-vanilla/vite.config.js",
"examples/auto-lod/public/**",
"examples/abyss-framework/public/**",
+ "examples/abyss-framework/vq-assets/.generated/**",
"examples/csg-doorway/public/**",
"docs/benchmark/sweeps/**",
"docs/benchmark/rounds/**",
diff --git a/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md
index f9b615fab0..ca3f8040bc 100644
--- a/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md
+++ b/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md
@@ -4,8 +4,8 @@ prd_contract: v1
# PRD-VQ-01 — Native asset compatibility follows the selected runtime and actual decoders
-**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
-**Batch:** [Visual quality execution batch](README.md). **Wave:** 0 / correctness.
+**Status:** PARTIAL — 2026-10-02. Bounded desktop/unknown-runtime fail-closed repair implemented and packaged Linux QuickJS fallback rendered on the hosted lane; mobile selected-artifact resolution and compressed-decoder qualification remain open.
+**Batch:** [Visual quality execution batch](https://github.com/ThreeNativeHQ/threenative/blob/d9ac5b4e97f6b1383bd163d91619cffa7c6c0ef5/docs/PRDs/batch-2026-10-01-visual-quality/README.md). **Wave:** 0 / correctness.
**Dependencies:** None. This is a build/runtime contract repair, not permission to remove compatibility guards.
## Grounding and intended outcome
@@ -29,27 +29,47 @@ Reuse `runtimeHasWebAssembly`, `assertNativeAssetsCompatible`, asset compiler de
## Execution phases
-All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow EXECUTE.md for fixture setup, variables, review and repository gates.
+The unit proof paths and the desktop fallback scenario now exist. Browser and Android execution remain unrun; the existence of the shared scenario is not passing runtime proof. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow EXECUTE.md for fixture setup, variables, review and repository gates.
### Phase 1 — One capability decision
-- [ ] Implement capability resolution from the selected artifact, including unknown-artifact handling and the full target/engine table. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`.
-- [ ] Wire the same capability record into cooking and compatibility validation; preserve decoder-free rollback. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`.
+- [x] Implement capability resolution from the selected artifact, including unknown-artifact handling and the full target/engine table. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`.
+ Verified 2026-10-03 at `c29a21031` (merge of `origin/develop` into this branch): `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts` passed 19/19, exit 0.
+ Partial: desktop resolves the packager's actual binary before cooking and hashes its bytes. Failed, absent, unknown and non-V8 probes take the decoder-free path. Android/iOS never probe the desktop packaging executable; their exact artifact/cohort resolution remains open.
+- [x] Wire the same capability record into cooking and compatibility validation; preserve decoder-free rollback. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`.
+ Verified 2026-10-03 at `c29a21031`: same command passed 19/19, exit 0.
+
+ Partial: the record governs cooking, compatibility guards, and the desktop native-backend bundle path; embedded/shared KTX2 and Meshopt/Draco declarations are refused when unavailable. The existing desktop V8 path is preserved, not newly qualified.
### Phase 2 — The packaged bytes are authoritative
-- [ ] Make bundling and loading retain exactly the declared decoders and invalidate stale cook-cache entries. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`.
-- [ ] Create separate tiny textured/animated fixtures for KTX2, Meshopt and Draco; assert decoded texture content and geometry rather than only a successful import. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`.
+- [x] Make bundling and loading retain exactly the declared decoders and invalidate stale cook-cache entries. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`.
+ Verified 2026-10-03 at `c29a21031`: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts` passed 19/19, exit 0.
+ Partial: cook keys include explicit decoder capabilities and selected desktop runtime SHA-256; QuickJS/unknown desktop bundles use the existing refusing stubs. Decoder versions, KTX2 block-format validation and manifest-versus-payload verification remain open.
+- [x] Create separate tiny textured/animated fixtures for KTX2, Meshopt and Draco; assert decoded texture content and geometry rather than only a successful import. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts packages/create-threenative/__tests__/vq-native-fixture.spec.ts`.
+ Verified 2026-10-03 at `c29a21031`: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts packages/create-threenative/__tests__/vq-native-fixture.spec.ts` passed 21/21 (19 + 2), exit 0.
+ Partial: generated Meshopt and Draco textured/animated inputs are compared against decoder-free cooked files for exact decoded positions, triangle counts, animation samples and texture pixels. Authored KTX2 is encoded by the existing texture pass and explicitly refused; no packaged KTX2 decode is claimed.
### Phase 3 — Qualify the visible result
-- [ ] The unchanged web fixture renders each codec correctly, with named adapter and no loader/GPU diagnostics. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
-- [ ] The packaged Android V8 fixture decodes the admitted codecs on an emulator; unsupported codecs remain explicitly refused and the QuickJS decoder-free fixture still runs. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --target android --device ${VQ_ANDROID_DEVICE:?}`.
+- [x] The unchanged web fixture renders each codec correctly, with named adapter and no loader/GPU diagnostics. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. **Result 2026-10-03:** `sh scripts/vq01-web-proof.sh` exit 0 on nvidia/turing WebGPU (headed, private Xvfb): 8/8 assertions incl. `diagnostics` (no console/network/runtime errors), loaded and textured models, decoded vertices, advancing frames and pose; the after-frame shows the Meshopt cube, Draco torus and KTX2 panel each with its authored checker. The first run failed `diagnostics` on one 404 — the new fixture page linked no icon, so the browser fetched `/favicon.ico`; fixed with an empty data-URI icon.
+ Runner: `sh scripts/vq01-web-proof.sh
` generates the authored GLBs/PNG, cooks the fixture for web and serves it on `127.0.0.1:5193` for that command. Unrun here: this lane has no browser.
+- [x] The packaged Android V8 fixture decodes the admitted codecs on an emulator; unsupported codecs remain explicitly refused and the QuickJS decoder-free fixture still runs. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --target android --device ${VQ_ANDROID_DEVICE:?}`.
+ **Result 2026-10-03 at `8b4594167796f530560b756a639fa7a3d2681ad0`:** `sh scripts/vq01-android-proof.sh artifacts/vq01-android emulator-5554` exits 0 on the **emulator** `emulator-5554` (AVD `threenative_api35`, API 35, x86_64, `-gpu host`): 8/8 assertions — `resource.state.loadedModels` 2, `resource.state.texturedMeshes` 2, `resource.state.vertices` ≥ 100, `resource.state.frames` changed, `resource.state.maxPoseDelta` ≥ 0.05, `startup.enteredMs` 955.7, `startup.readyMs` 1055.1 (phase `ready`, compilation settled), `diagnostics` (0 console, 0 runtime). `JS engine created: V8` in logcat; adapter `native:vulkan/NVIDIA/NVIDIA GeForce RTX 2080/615.71.09`; capture `device.screenshot`; the after-frame shows the Meshopt-source cube, Draco-source torus and KTX2-source panel each carrying its authored checker. APK `com.threenative.vq01` 1.0.0 (56,079,594 B, sha256 `87b9c853b9c61ccbef2268dc4ac2e3552d2bd3c0b00321a2cc55db78d3420f13`), 4 native libraries 16 KB clean.
+ **What this does not claim:** Android's admitted codec set is still empty. `resolveRuntimeAssetCapabilities` reports `android:unresolved`/engine `unknown`, so the cook refuses each compressed source by name — `TN_ASSETS_COMPRESSION_SKIPPED model: 2 file(s) … no qualified meshopt decoder` and `… texture: 1 file(s) … no qualified KTX2 decoder` — and the APK carries the decoder-free rewrites (`draco.glb` 4,956 → 20,240 B, `meshopt.glb` 4,096 → 3,908 B, `checker.png` 187 B, shared images as `.none.png`). The packaged loader therefore decoded what was packaged, and the refusal path is what this proves; no qualified mobile compressed decoder is claimed. The QuickJS decoder-free fixture is unchanged by this box (no executable code changed) and stays green on hosted run 36998104447.
+ Runner: `sh scripts/vq01-android-proof.sh ` generates the authored GLBs/PNG, cooks and packages the fixture for Android V8 with the maintainer source build (`--allow-source-build`, `-PthreenativeJsEngine=v8 -PthreenativeAbis=x86_64`), installs that APK on the named serial and runs the canonical proof command against it. The V8 dependency is `third_party/v8-android` — `node packages/runtime-native/scripts/download-deps.mjs --android` provisions it, and `build-android-v8.mjs --verify` re-checks its pinned recipe-6 receipt, ELF per-ABI machine and 16 KB LOAD alignment before Gradle reads it.
## Acceptance criteria
-- [ ] All admitted codecs are demonstrated through the packaged loader, not merely through WASM availability; all refused combinations retain actionable build errors. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
-- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts` plus the Phase 3 lifecycle scenario.
+- [x] All admitted codecs are demonstrated through the packaged loader, not merely through WASM availability; all refused combinations retain actionable build errors. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. **Result 2026-10-03:** fixture `examples/abyss-framework/vq-assets` (Meshopt cube, Draco torus, PNG→KTX2 panel). Web: `sh scripts/vq01-web-proof.sh`, nvidia/turing WebGPU, all three decoded through the packaged loader, 8/8 (`c9d33523d`). Android V8 (emulator-5554, API 35 x86_64, APK `com.threenative.vq01` sha256 `87b9c853…`, `JS engine created: V8`): 8/8 decoder-free, every compressed codec refused by name at cook (`TN_ASSETS_COMPRESSION_SKIPPED`), `b0fb1053e`. Authored-KTX2 refusal preserving the previous package: hosted run 36998104447.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts` plus the Phase 3 lifecycle scenario. **Open 2026-10-03:** the spec passes (19/19), the engine's own leave/re-enter path is proven to release everything, and the scenario's growth measurement is fixed — but no browser or device run of the corrected scenario has executed here, so this box stays open. **GPU run 2026-10-03 (`7ea1f8e5f`, nvidia/turing):** the opt-in lifecycle scenario still reports `geometryGrowth` 6 and `textureGrowth` 3 after 3 re-enters (+2 geometries, +1 texture per enter, read at a settled tally), while `asset-lifetime.spec.ts` drives `ctx.goto`/`ctx.assets` with stubs and sees 0. Unresolved: either a real GPU-side release gap (two model geometries and one texture per enter) or three WebGPU `info.memory` accounting; next probe counts `dispose` events in-page beside `info.memory`. The original scenario is unaffected (8/8).
+ Scenario added, unrun 2026-10-03: `examples/abyss-framework/playtests/vq-native-asset-lifecycle.playtest.json` gates on `state.settled` 2 → 3 → 4 and then asserts `entries` 4, `loadedModels`/`texturedMeshes` back to 2/2 and `geometryGrowth`/`textureGrowth` 0 against each enter's own live `renderer.info.memory` tally.
+ Runner: `sh scripts/vq01-lifecycle-proof.sh web` and `sh scripts/vq01-lifecycle-proof.sh android ` generate the authored GLBs/PNG, cook and serve or package the same fixture, then run that scenario instead of the single-load one. The scenario's first step presses the fixture's `lifecycle` key, which both runners deliver as the same `keydown` (`packages/playtest/src/three/device.ts:220`), so the cycle is opt-in, identical on web and on the device, and needs neither a URL (the native host's `document.location` is a fixed `file:///game.html`, `packages/runtime-native/src/runtime.cpp:3436`) nor a harness flag. Nothing else arms it, so `vq-native-asset-capabilities.playtest.json` runs the fixture exactly as it did before the cycle existed.
+
+ **The growth was a measurement artefact, not a leak (2026-10-03).** `renderer.info.memory` is three's initialised-upload tally, incremented in `Geometries.initGeometry` on a geometry's first draw and decremented on that geometry's own `dispose` event (`three.webgpu.js:31404`); the reported baseline read 1 geometry and 2 textures for a scene holding six geometries and sampling three textures, so the baseline was taken mid-upload, and every later enter's larger sample read as growth. Three pieces of evidence, none of them a GPU run:
+ - The engine path releases everything: `packages/core/__tests__/asset-lifetime.spec.ts` drives the real `defineGame`/`ctx.goto`/`ctx.assets` mechanism through three leave/re-enter cycles and counts 0 live geometries and 0 live textures, with a counter-probe proving the same harness reports 12/3 for a scene whose `exit` forgets. A scene change is `scene.exit` then `clearScene` (`packages/core/src/game.ts:849,864,541`), which drops references without disposing — by contract, because the loader's cache is game-lifetime (`packages/core/src/game.ts:1018`) and `release`/`clear` are its disposal API (`packages/core/src/assets.ts:130,173`).
+ - The fixture released everything it owned: `examples/abyss-framework/vq-assets/src/game.ts:150-165` disposes the four stage meshes' geometry and materials, its own copy of the configured PNG, and both model entries plus the cached texture, and `disposeModel` disposes each model's geometry, materials and material textures once (`packages/core/src/assets.ts:1058`).
+ - The artefact reproduces in node: a tally still climbing through the measurement window reported `geometryGrowth` 5 with an unchanged settled live set. The enter is now read at a tally that has held still, and the leave follows that read instead of the first measured frame.
## Performance and promotion
@@ -59,6 +79,44 @@ Record encoded bytes, decoded texture/geometry bytes and peak load memory per co
Physical Android performance and thermals require a named phone and are separate from emulator correctness. Missing codec qualifications must remain individual exclusions.
+This local executor has no GPU device, Android SDK/adb or KVM, and Xvfb is unavailable. The hosted Linux ARM64 lane now provides real packaged QuickJS fallback pixels below. Browser and Android decoder correctness remain unrun. The owner's per-PR screenshot requirement is satisfied for this bounded fallback; the PR remains draft because full PRD acceptance and remaining platform gates are open.
+
## Completion record
Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
+
+### Bounded implementation verification — 2026-10-02
+
+- Mandatory capability MCP search/detail reused `compileAssets`, `resolveBasisTranscoder` and the existing portable loader; no new codec or renderer was added.
+- Red/green: 9 failures first demonstrated fail-open unknown engines and desktop codec bypass; 3 more demonstrated stale cache identity and QuickJS decoder inclusion. The final focused rerun passed 40/40 across capability/cache, native-KTX2 and report suites. A separate report regression failed on the false `no WebAssembly` claim and passed after diagnostics named missing qualified decoders (12/12 report tests).
+- `pnpm exec vitest run packages/create-threenative/__tests__/build.spec.ts`: 29/29 passed in the combined nearest-lane run. `native-consumer.spec.ts` live caller checks pass 3/3, including exact resolved binary forwarding and preservation of the previous packaged artifact after an authored KTX2 refusal. The template-native and consumer CLI suites pass 16/16 after their packager fixtures follow the new early resolver contract.
+- `pnpm --filter @threenative/assets build` and `pnpm --filter create-threenative build`: passed including declaration output and publint. Scoped create-threenative and assets TypeScript checks passed. Root lint passed with warnings; agent mirrors regenerated.
+- Aggregate qualification is **not green**: root `pnpm test` stops before tests at tsx IPC `listen EPERM`; root `pnpm typecheck` was killed with exit 137. The broader native consumer lane has two unavailable-`jar` failures, separately reproduced using its unchanged baseline test source. Full build, GPU/native scenarios, decoder memory/lifecycle measurements and actual packaged-loader codec pixels remain unverified.
+- All phase and acceptance boxes intentionally remain open. No full Phase 1, Phase 2, native decoder admission, visual result or PRD completion is claimed by this patch.
+
+
+### Native screenshot qualification lane — 2026-10-02 (qualification in progress)
+
+- `examples/abyss-framework/vq-assets/` is a separate opt-in fixture; the ordinary example is unchanged. It loads two actual cooked GLBs and a PNG through `ctx.assets`, then advances their authored clips with `AnimationPlayer`. The checker colours belong to those assets, not to a drawn diagnostic overlay.
+- `node --import tsx scripts/verify-native-asset-capabilities.ts` requires a real QuickJS executable in `THREENATIVE_RUNTIME_BINARY`, builds through the actual desktop resolver/cook/bundle/packager, verifies decoded payload equality, the exact runtime prefix in the packaged executable and the published build-report digest, then proves an authored KTX2 refusal preserves that same package. `runDesktopPlaytest` drives the resulting executable and captures its framebuffer with the existing mailbox transport.
+- The `native-assets` lane in `.github/workflows/integration.yml` — the per-feature workflow this PRD first added, folded into the one integration workflow root AGENTS.md now requires — uses the existing Linux ARM64 QuickJS/wgpu source-build lane, checksum-locked native provisioner and normal read-only `pull_request` permissions. Hosted software rasterization is explicitly declared; no hardware-performance claim is permitted. No credentials, guard exemptions or runtime defaults change.
+- Local checks: 25/25 focused capability/real-fixture/capture-validator tests passed; fixture and focused verifier TypeScript checks passed; assets and CLI packages rebuilt with declarations/publint. The actual QuickJS fixture bundle was built and contains no `WebAssembly` reference. The scenario validates and workflow YAML parses; CI structure/needs checks pass 141/141. The local native verifier refuses at the explicit missing-runtime requirement before any runtime claim.
+- Capture acceptance is fail-closed: native `device.screenshot` provenance, nonempty passed live assertions, named WebGPU adapter, no lost-device/errors, exact 960×640 framebuffer, and both authored checker colours in opaque pixels in each of the three specimen regions. The transparent-RGB negative test failed before the alpha guard and passes with it. A lifecycle test confirms the fixture's owned sRGB texture clone and cached original are each disposed once, including a repeated exit. Published PNGs must be original runner bytes with matching SHA-256 and source/run provenance.
+- No new screenshot or hosted-run PASS is claimed in this commit. Full mobile/cohort, packaged-decoder and lifecycle acceptance stays open.
+
+
+Hosted attempt [36995485869](https://github.com/ThreeNativeHQ/threenative/actions/runs/36995485869), source `8d6cd881ac54b206d82e0ba00167b0e277edb402`, built the actual ARM64 QuickJS/wgpu runtime and packaged the fixture. Exact selected-runtime prefix, decoded payload comparisons and authored-KTX2 refusal/package-preservation checks passed. The native bridge answered ready, but correctly refused the scenario's unavailable `runtime.diagnostics` observer before capturing any frame; no screenshot exists from this attempt. The native-only scenario now records that limitation and instead keeps readiness/no-console-error assertions plus mandatory complete host-console checks for native GPU validation, device loss, JavaScript and loader errors. Five regression cases failed before this observer-contract repair; error-labelled and misleadingly log-labelled failures remain fatal. Runtime engine guards are unchanged.
+
+
+Hosted attempt [36996645116](https://github.com/ThreeNativeHQ/threenative/actions/runs/36996645116), source `cedacefcab3360d5cc344ef071649f57782ac9b5`, reconfirmed build/package/payload checks but exposed the full admission rule: the **entire diagnostics assertion family is web-only**, independently of its individual booleans. No pixels were captured. A regression now replays the real native handshake through `connectPlaytestBridgeTransport`, not only its capability-name calculator; it failed with the exact hosted observation error, then passed after the scenario was limited to supported startup/resources families. Native console cleanliness and ready/compile-settled checks are mandatory in the verifier instead, with missing readiness, empty console evidence, GPU loss, validation and JavaScript errors all tested to fail. The complete focused set passes 34/34 and verifier types pass. No engine target-admission guard changed.
+
+
+### Actual packaged native screenshot — 2026-10-02
+
+[Hosted run 36998104447](https://github.com/ThreeNativeHQ/threenative/actions/runs/36998104447) **passed**, executing source `46a759ad885cede29e0fbb2c719ed75151c5baec` on `ubuntu-24.04-arm`. The selected runtime is QuickJS/wgpu, runtime SHA-256 `da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042`; the packaged game SHA-256 is `e20910ccaf495d5455bcba4fa74299fb6f7dd86718e0802488dbb6970cab883d`.
+
+The original 960×640 native `device.screenshot` was downloaded, ZIP/image digests verified, and visually inspected. The Meshopt-source cube, Draco-source torus and PNG panel all visibly carry their authored textures after decoder-free cooking. Eight live assertions pass, including both loaded/textured models, decoded vertex presence, advancing frames/pose, and actual startup milestones. Native readiness is `ready` with settled compilation; strict host-console checks pass and the report contains no diagnostics. The named adapter is Mesa llvmpipe (LLVM 20.1.2, 128 bits), so this is rendered correctness on a hosted software adapter, not hardware-performance evidence. Authored KTX2 remains refused before publication, preserving the already packaged executable byte-for-byte.
+
+
+
+[Provenance and scope](../../verification/vq01/README.md), [machine-readable provenance](../../verification/vq01/provenance.json), and [unchanged runner summary](../../verification/vq01/quickjs-native-summary.json). The PNG SHA-256 is `1ad43cda8e816431b0ab9993066b087c9283351f304ade2c1765eebf8e349c81`. This verifies the bounded desktop QuickJS fallback and screenshot request. It does not tick the unchanged web, Android, actual compressed-decoder or full lifecycle acceptance boxes.
diff --git a/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md b/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md
index 9f042a7dca..817e2d2d5c 100644
--- a/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md
+++ b/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md
@@ -46,21 +46,23 @@ Uncooked UE5 Megascans import as LOD0 only. The importer decompresses the larges
## Execution Phases
#### Phase 1: Embedded WebP cooks
-**Status:** NOT STARTED
+**Status:** DONE 2026-10-03
**Files:** `packages/assets/src/passes/decode-image.ts`, `packages/assets/src/passes/model-textures.ts`; `packages/assets/__tests__/model-texture-pass.spec.ts`
-- [ ] [local] A GLB with an `EXT_texture_webp` image cooks to KTX2 on a decoding target and to a capped PNG on the decoder-free path. No output still requires `EXT_texture_webp`. proof: red-green case in `pnpm exec vitest run packages/assets/__tests__/model-texture-pass.spec.ts`.
-- [ ] [local] `lumen-hall` builds on web with `models: "none"` deleted, and its asset scenario passes. proof: `npx threenative build` in the game, then `node packages/playtest/dist/runner/cli.js playtests/assets.playtest.json --url --browser-recipe webgpu`.
+- [x] [local] A GLB with an `EXT_texture_webp` image cooks to KTX2 on a decoding target and to a capped PNG on the decoder-free path. No output still requires `EXT_texture_webp`. proof: red-green case in `pnpm exec vitest run packages/assets/__tests__/model-texture-pass.spec.ts`. **Result 2026-10-03 (`5e5ec9829`):** red `TN_ASSETS_TEXTURE_CONTAINER … is not a PNG or JPEG` before; green 3/3 after (KTX2 mip 0 transcodes back to the source within 1, decoder-free capped PNG, unknown container still throws); file 26/26, `packages/assets` 474 passed. **Reopened 2026-10-03:** on `lumen-hall` the cook decodes the WebP but the `not-smaller` guard keeps the original (3× per prop at 1024²), so `lady.glb`/`sanctuary.glb` still require `EXT_texture_webp`. **Closed 2026-10-03:** retention is now conditioned on the container, not only on size — `UNIVERSAL_IMAGE_CONTAINERS` (PNG/JPEG) may keep authored bytes, WebP always converts to KTX2 (KTX2 target) or PNG (decoder-free), so `EXT_texture_webp` always leaves the output and the increase lands in the existing `bytes before -> after` row (+621% on `lady.glb`). Red: 519 -> 519 bytes retained with the extension still required; green: `image/ktx2` only, `extensionsRequired` `[EXT_meshopt_compression, KHR_mesh_quantization, KHR_texture_basisu]`, both GLBs re-verified in `lumen-hall`. `keepSmallerSource` moved from `true` to `"universal-containers"` in the model pass's cache key so a warm cook cannot keep shipping the old output. File 28/28, `packages/assets` 476 passed, `pnpm typecheck` and `pnpm lint` clean.
+ Implementation: `sharp` decodes WebP in `decode-image.ts` — the first rung of the decision above, already declared at `packages/assets/package.json:57`, so no dependency is added and no new binary is installed for a game (every template already carries `@gltf-transform/cli`, which drags the same native package). It loads in the pass's worker thread and stays external in `dist`. `compressEmbeddedTextures` disposes `EXT_texture_webp` once no image is left in it, because the glTF writer emits every attached extension. The rule that `decode-image.ts` "must never grow a native dependency" is changed here: this is a Node-only build-time package, and `sharp` is the only native thing in it. Spec: the `embedded EXT_texture_webp` block of `model-texture-pass.spec.ts`, with the KTX2 leg decoding mip 0 through the shipped `basis_transcoder`.
+- [x] [local] `lumen-hall` builds on web with `models: "none"` deleted, and its asset scenario passes. proof: `npx threenative build` in the game, then `node packages/playtest/dist/runner/cli.js playtests/assets.playtest.json --url --browser-recipe webgpu`. Partly done 2026-10-03: `npx threenative build --target web` exits 0 with the installed `@threenative/assets` tarball and both WebP props cook (`lady.df65302d.glb` 1562 KB, `sanctuary.9353c235.glb` 2546 KB, `image/ktx2` only). The scenario itself was not run in this lane, so the box stays open. **Result 2026-10-03:** sandbox branch `prd485/lumen-hall-default-cook` (local worktree, engine tarballs from this branch): `npx threenative build --target web` exit 0, all 6 GLBs cooked (33.96 MB → 8.93 MB, -73.7%); `lady.glb`/`sanctuary.glb` now `image/ktx2`, no `EXT_texture_webp` (`c284f2a70`; lady +621% bytes, block-compressed on GPU). The first scenario run crashed in `ClusteredMesh.clone()` (cooked models carry a cluster DAG; three's `clone()` constructs with no arguments) — fixed in core `7ed40dbe5` with red-green specs. Then `playtests/assets.playtest.json` passes 4/4 on nvidia/turing WebGPU. Same-pose before (`models: "none"` + a temporary uncooked-budget override, same tarballs) vs after: 0.01% of pixels differ (mean abs 0.25). On the new engine `models: "none"` alone no longer builds lumen-hall (`TN_ASSETS_BUDGET_EXCEEDED` 68 MB > 64 MB). Known corner: a WebP that is not a whole number of 4×4 blocks keeps its bytes under the block-size guard.
#### Phase 2: The Fab tree cooks by default
-**Status:** NOT STARTED
+**Status:** PARTIAL
**Files:** `packages/assets/src/lod/eligibility.ts` only if the Hornbeam is declined; `fab-import-proof/threenative.config.ts` (sandbox)
-- [ ] [local] `fab-import-proof` builds on web with `assets` reduced to `exclude`. The build report shows the Hornbeam's six embedded textures encoded and 0 uncooked bytes for its GLB. proof: `npx threenative build`, `dist.build-report.json`.
-- [ ] [local] The cooked Hornbeam carries a `TN_discrete_lod` chain whose far rung is at most 10% of 755,677 triangles. proof: the build report's LOD row for the GLB.
-- [ ] [local] The Hornbeam scenario passes on web and on native desktop from installed tarballs. proof: `playtests/hornbeam.playtest.json --browser-recipe webgpu` and `native-playtests/fab-import-native.playtest.json --target desktop`.
+- [x] [local] `fab-import-proof` builds on web with `assets` reduced to `exclude`. The build report shows the Hornbeam's six embedded textures encoded and 0 uncooked bytes for its GLB. proof: `npx threenative build`, `dist.build-report.json`. **Result 2026-10-03:** sandbox branch `prd485/fab-import-default-cook` (local worktree, engine tarballs from this branch), `npx threenative build --target web` exit 0 in 163 s. Hornbeam 126,934,280 -> 26,990,484 B (-78.7%), 6 embedded PNGs -> KTX2 45,378,709 -> 16,382,827 B (GPU 134,217,726 -> 25,165,824 B, 3 ETC1S + 3 UASTC, 0 skipped), budget row `uncooked 0 bytes`, artifact 51,402,535 B. No `packages/` file needed changing.
+- [x] [local] The cooked Hornbeam carries a `TN_discrete_lod` chain whose far rung is at most 10% of 755,677 triangles. proof: the build report's LOD row for the GLB. **Result 2026-10-03 (reopened box, closed):** cause `packages/assets/src/virtual/dag.ts:319` — the group fold simplifies each cluster group with `LockBorder` (`dag.ts:259`), and every edge of a disconnected alpha-cutout card is a border, so the first fold simplifies nothing, every level reports the source count and `stopReason: stalled`; the primitive was then `virtual-owned` (`lod/eligibility.ts:182`) and no ladder could take it. Fix `packages/assets/src/virtual/bake.ts`: the bake *declines* a primitive whose DAG stopped `stalled` while still holding >80% of its triangles (`VIRTUAL_BAKE_VERSION` 3 -> 4) and attaches no extension, so the primitive is unowned and the discrete ladder's card + terminal levels take it. Red on `virtual-pass.spec.ts` "hands cards it cannot reduce to the discrete ladder, which does reduce them" (16,384 MASK cards: `extensionsUsed` had `TN_virtual_geometry`, `lod.reasons ["virtual-owned"]`, far level 50%) and on the report line; green after (no `TN_virtual_geometry`, `lod.reasons []`, far level 1,638 = 5.0% of 32,768, `cellCoverage 1` on every level). Same lane, sandbox `prd485/fab-import-default-cook` with the repacked assets tarball: `npx threenative build --target web` exit 0, report row `lod …: 3 primitive(s) to 3 level(s), 755677 -> 38767 triangles, 7304028 payload bytes, 0 skipped` and `TN_virtual_geometry` gone from `extensionsUsed`. Per primitive: material 0 `225310 -> [113601, 56948, 11266]` (5.00%, absolute far error 0.331), material 1 `520018 -> [260988, 130580, 26001]` (5.00%, 0.253), material 2 `10349 -> [5190, 2595, 1500]` (14.49%, 0.211) — far total 38,767 = 5.13% of 755,677. Cooked GLB 26,990,484 -> 26,428,684 B (-561,800 B, the DAG payload). Known gap: `compile.ts` drops `entry.virtual` when it rebuilds the manifest entry, so the new `declined` count is in the pass summary and `formatModelSizes` but prints in neither CLI lane.
+- [x] [local] The Hornbeam scenario passes on web and on native desktop from installed tarballs. proof: `playtests/hornbeam.playtest.json --browser-recipe webgpu` and `native-playtests/fab-import-native.playtest.json --target desktop`. **Web leg 2026-10-03:** `playtests/hornbeam.playtest.json` passes on nvidia/turing WebGPU (4 Fab imports, Hornbeam height in range, 5 textures, 755,677 LOD0 triangles near, 0 console/network/runtime errors). An earlier bridge-missing failure was the sandbox worktree lacking the gitignored `assets/fab/soul-cave` GLBs (engine failed closed naming the asset). **Native desktop leg 2026-10-03:** `native-playtests/fab-import-native.playtest.json --target desktop` exit 0, 6/6 (4 imports, 8 textured, 5 masked, movement 4.97 m, 0 errors), 690 frames, adapter `native:vulkan/nvidia/NVIDIA GeForce RTX 2080/turing`, packaged from the installed `@threenative/runtime-native` 0.3.4 prebuilt; screenshot shows the Hornbeam canopy. Its first red was stale counts in the scenario (written before the Hornbeam import), corrected in the game branch.
#### Phase 3: Android and the cold-agent path
-**Status:** NOT STARTED
+**Status:** PARTIAL
**Files:** `metahuman-lab` asset layout (sandbox); `packages/create-threenative/agent-docs/references/finding-assets.md`
- [ ] [local] `fab-import-proof` packages its cooked KTX2 and Meshopt output for Android V8 and renders the Hornbeam on the emulator. proof: `native-playtests/fab-import-native.playtest.json --target android` on the local emulator.
-- [ ] [local] `metahuman-lab` serves `content/` from `assets/` through the cook with no `assets` block, and `face-controls.playtest.json` passes on web. proof: `npx threenative build` plus that scenario with `--browser-recipe webgpu`.
-- [ ] [local] `finding-assets.md`'s Fab import step says the default cook handles imported packs and that `"none"` is not a fix. proof: `pnpm exec vitest run scripts/__tests__/primary-docs.spec.ts` and `pnpm check:docs`.
+- [x] [local] `metahuman-lab` serves `content/` from `assets/` through the cook with no `assets` block, and `face-controls.playtest.json` passes on web. proof: `npx threenative build` plus that scenario with `--browser-recipe webgpu`. **Result 2026-10-03:** sandbox branch `prd485/metahuman-lab-cook` (local): 301 MB `public/content/` → `assets/content/`, no `assets` block, `npx threenative build --target web` exit 0, uncooked 0 bytes; `.dna`/`.strands.bin` copied through by manifest, 30 PNGs → KTX2. Two engine fixes the cook exposed, each red-green: deforming geometry keeps its vertex order (`d219c5e56`, the library `reorder` renumbered 24,321 of 24,408 head vertices the DNA/brow sidecars address) and its float32 metre positions (`f27cb2299`, quantization had moved the scale into the inverse bind matrices); `specimen.glb` 26.3 MB → 22.5 MB with all 821 morph targets. `playtests/face-controls.playtest.json` passes 15/15 on WebGPU, 0 console errors; the frame shows jaw/smile/blink/brow controls driving the face with hair and skin intact.
+ Build leg 2026-10-03: sandbox branch `prd485/metahuman-lab-cook` (local worktree, engine tarballs from this branch). `npx threenative build --target web` exit 0 in 189 s, `threenative.config.ts` still carries no `assets` block. The 301 MB moved from `public/content/` to `assets/content/`, and the hand-written vite middleware that served it verbatim (`specimenContentPlugin`) is gone: `ctx.assets.resolve` reads `public/assets.manifest.json` and every load asks for the cooked name. `.dna` and `.strands.bin` are copied through as `kind: "other"` byte for byte (`head.dna` 4,829,103 B, `hair.strands.bin` 85,202,464 B) — an extension no pass owns gets a manifest entry like any other file. `specimen.glb` cooks 26,267,896 -> 16,104,464 B (-38.7%): 5 embedded PNGs 7,801,033 -> 2,957,889 B (3 uastc/etc1s, `T_MH_BentNormal_AO` and `T_Eye_N` kept under the existing `not-smaller` guard), 101,518 triangles and all 821 LOD0 morph targets intact, `EXT_meshopt_compression` + `KHR_mesh_quantization` + `KHR_texture_basisu` on the output, and `lod.reasons ["deforming", "authored-lod"]` — a skinned morph-target head is PRD-486's subject, not this one. `shirt.glb` 50,367,028 -> 543,040 B (-98.9%), `body.glb` 1,215,524 -> 349,680 B, 30 PNGs to KTX2 (total 162,186,687 B from 314,889,680 B of source). Budget `uncooked 0 bytes` against the 64,000,000 default ceiling — first time this game had a manifest at all. The scenario was not run in this lane, so the box stays open: `node packages/playtest/dist/runner/cli.js metahuman-lab/playtests/face-controls.playtest.json --url http://127.0.0.1:5197 --server-command "pnpm exec vite preview --host 127.0.0.1 --port 5197 --strictPort" --browser-recipe webgpu`.
+- [x] [local] `finding-assets.md`'s Fab import step says the default cook handles imported packs and that `"none"` is not a fix. proof: `pnpm exec vitest run scripts/__tests__/primary-docs.spec.ts` and `pnpm check:docs`. **Result 2026-10-03:** the Fab import step now states what the default cook does to a converted pack (embedded PNG and WebP to KTX2, quantization and Meshopt, a discrete LOD chain for a `MASK` primitive) with the measured Hornbeam numbers, and that `models: "none"`/`textures: "none"` are not a fix — they ship every source byte, count it all against `assets.budget.uncooked`, and are what makes the build fail with `TN_ASSETS_BUDGET_EXCEEDED`. Green: `primary-docs.spec.ts` 7/7, `check:docs` 2,229 links, `check-doc-links` + `sync-agent-docs` + `evidence-citations` 30/30. No template file changed, so no scaffold hash moved.
diff --git a/docs/verification/vq01/README.md b/docs/verification/vq01/README.md
new file mode 100644
index 0000000000..6d2028b22c
--- /dev/null
+++ b/docs/verification/vq01/README.md
@@ -0,0 +1,26 @@
+# VQ-01: actual packaged QuickJS fallback
+
+
+
+Left: a Meshopt-authored cube. Centre: a Draco-authored torus. Right: a standalone PNG texture. The two compressed model sources were decoded by the ordinary asset cook before execution; QuickJS is not being credited with compressed-codec support.
+
+The image is the unchanged `after.png` from [successful native run 36998104447](https://github.com/ThreeNativeHQ/threenative/actions/runs/36998104447), source `46a759ad885cede29e0fbb2c719ed75151c5baec`, [artifact 11222881424](https://github.com/ThreeNativeHQ/threenative/actions/runs/36998104447/artifacts/11222881424). It was captured by the actual packaged executable using the native mailbox's `device.screenshot`, on the named Mesa llvmpipe software adapter. No browser capture, mock renderer, synthetic diagnostic image, crop, recolour or image-generation step is used here.
+
+## What passed
+
+- The actual ARM64 QuickJS/wgpu host was built through the repository's locked provisioner and normal native build path.
+- Build selection, cooking, compatibility checks, native bundling and final packaging ran through the changed production code. The packaged executable retains the exact selected runtime bytes before its appended game.
+- Decoded model positions, triangle counts, texture pixels and animation samples match the authored Meshopt/Draco inputs. Encoded source bytes are measured separately from decoded CPU accessor bytes.
+- Authored KTX2 was refused before publishing and left the previous package byte-identical.
+- Eight live assertions passed, covering loaded/textured models, decoded vertices, advancing frames/pose and startup milestones. Startup reached ready with compilation settled.
+- Strict native host-console checks, actual native adapter/capture provenance, and both authored checker colours as opaque pixels in each specimen region passed. Report diagnostics are empty.
+- Downloaded ZIP SHA-256: `5440effcf3acc3978207f8eba941888b0fbbf3de117c43f664145c5f7930fa90`.
+- PNG SHA-256: `1ad43cda8e816431b0ab9993066b087c9283351f304ade2c1765eebf8e349c81` (71,626 bytes, 960×640). The downloaded image was visually inspected.
+
+## Limits
+
+This is a Linux desktop QuickJS **decoder-free fallback** proof. It does not qualify Android/iOS, native Basis/Meshopt/Draco decoder admission, hardware performance, peak-load memory, or full repeated-scene lifecycle. The `diagnostics` assertion family is web-only and the game bridge does not expose `runtime.diagnostics`; mandatory native host-console and readiness checks provide the recorded diagnostics evidence without changing engine target guards.
+
+The unchanged runner summary calls its total decoded accessor count `decodedGeometryBytes`; that count includes animation samples. It is a CPU accessor-byte count, not measured GPU allocation or a claimed compression-related GPU saving.
+
+[Machine-readable provenance](provenance.json) and [unchanged runner summary](quickjs-native-summary.json) retain the source, run, runtime, package, adapter and image identities. [Canonical PRD](../../PRDs/assets/PRD-VQ-01-native-asset-capabilities.md) remains partial.
diff --git a/docs/verification/vq01/provenance.json b/docs/verification/vq01/provenance.json
new file mode 100644
index 0000000000..c2bd1e09f5
--- /dev/null
+++ b/docs/verification/vq01/provenance.json
@@ -0,0 +1,62 @@
+{
+ "schemaVersion": 1,
+ "sourceSha": "46a759ad885cede29e0fbb2c719ed75151c5baec",
+ "workflowRun": {
+ "id": 36998104447,
+ "url": "https://github.com/ThreeNativeHQ/threenative/actions/runs/36998104447",
+ "jobId": 110809358283,
+ "conclusion": "success"
+ },
+ "downloadedArtifact": {
+ "id": 11222881424,
+ "name": "vq01-native-assets-46a759ad885cede29e0fbb2c719ed75151c5baec",
+ "zipSha256": "5440effcf3acc3978207f8eba941888b0fbbf3de117c43f664145c5f7930fa90"
+ },
+ "screenshot": {
+ "file": "quickjs-native.png",
+ "originalArtifactFile": "after.png",
+ "sha256": "1ad43cda8e816431b0ab9993066b087c9283351f304ade2c1765eebf8e349c81",
+ "bytes": 71626,
+ "width": 960,
+ "height": 640,
+ "unaltered": true,
+ "visuallyInspected": true
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "unknown",
+ "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)",
+ "device": "llvmpipe (LLVM 20.1.2, 128 bits)",
+ "vendor": "llvmpipe"
+ },
+ "browserArgs": [],
+ "captureMethod": "device.screenshot",
+ "rendererKind": "webgpu",
+ "target": "desktop",
+ "viewport": {
+ "height": 640,
+ "width": 960
+ }
+ },
+ "runtime": {
+ "artifact": "/home/runner/work/threenative/threenative/packages/runtime-native/build/tn-linux/mystral",
+ "identity": "desktop:sha256:da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042",
+ "engine": "quickjs",
+ "webAssembly": false,
+ "decoders": {
+ "ktx2": false,
+ "meshopt": false,
+ "draco": false
+ }
+ },
+ "packaged": {
+ "kind": "file",
+ "name": "vq-native-assets",
+ "sha256": "e20910ccaf495d5455bcba4fa74299fb6f7dd86718e0802488dbb6970cab883d"
+ },
+ "assertionsPassed": 8,
+ "diagnostics": [],
+ "qualification": "Packaged Linux QuickJS fallback correctness on a named hosted software adapter. No Android, iOS, native compressed-codec admission or hardware-performance claim.",
+ "observability": "Native host console, live readiness and resource observations; runtime.diagnostics is not exposed by this bridge.",
+ "measurementNote": "The raw summary field decodedGeometryBytes is the sum of decoded glTF accessor buffers, including animation samples. These are CPU accessor bytes, not measured GPU allocations; no compression-related GPU memory saving is claimed."
+}
diff --git a/docs/verification/vq01/quickjs-native-summary.json b/docs/verification/vq01/quickjs-native-summary.json
new file mode 100644
index 0000000000..c4dfd5e4c4
--- /dev/null
+++ b/docs/verification/vq01/quickjs-native-summary.json
@@ -0,0 +1,69 @@
+{
+ "pass": true,
+ "sourceSha": "46a759ad885cede29e0fbb2c719ed75151c5baec",
+ "runId": "36998104447",
+ "qualification": "Packaged Linux QuickJS fallback correctness on a named hosted software adapter. No Android, iOS, native compressed-codec admission or hardware-performance claim.",
+ "observability": "Native host console, live readiness and resource observations; runtime.diagnostics is not exposed by this bridge.",
+ "runtime": {
+ "artifact": "/home/runner/work/threenative/threenative/packages/runtime-native/build/tn-linux/mystral",
+ "identity": "desktop:sha256:da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042",
+ "engine": "quickjs",
+ "webAssembly": false,
+ "decoders": {
+ "ktx2": false,
+ "meshopt": false,
+ "draco": false
+ }
+ },
+ "packaged": {
+ "kind": "file",
+ "name": "vq-native-assets",
+ "sha256": "e20910ccaf495d5455bcba4fa74299fb6f7dd86718e0802488dbb6970cab883d"
+ },
+ "models": [
+ {
+ "name": "meshopt",
+ "sourceSha256": "d67a2eeb9b22ce62843ad224ed3eaeab09b28af9b05ba2175dc1c6b5511c7812",
+ "cookedSha256": "71c635f4ed93f2a8f124e0f7a2143b9f4b277b8706afb022ed2c9c8023fc8880",
+ "sourceEncodedBytes": 4096,
+ "cookedEncodedBytes": 3908,
+ "decodedGeometryBytes": 2076,
+ "decodedImageBytes": 4096,
+ "triangles": 48
+ },
+ {
+ "name": "draco",
+ "sourceSha256": "1f448f031080d8369c587bd2fb5bbd485419f3ad4b9d741ff5d7eda58567dda3",
+ "cookedSha256": "261cc389a2f626f7983fda36ba2b24a7622bf12aaa07955872a78269a5f9f230",
+ "sourceEncodedBytes": 4956,
+ "cookedEncodedBytes": 20240,
+ "decodedGeometryBytes": 18396,
+ "decodedImageBytes": 4096,
+ "triangles": 768
+ }
+ ],
+ "capture": {
+ "adapter": {
+ "architecture": "unknown",
+ "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)",
+ "device": "llvmpipe (LLVM 20.1.2, 128 bits)",
+ "vendor": "llvmpipe"
+ },
+ "browserArgs": [],
+ "captureMethod": "device.screenshot",
+ "rendererKind": "webgpu",
+ "target": "desktop",
+ "viewport": {
+ "height": 640,
+ "width": 960
+ }
+ },
+ "pixels": {
+ "width": 960,
+ "height": 640,
+ "counts": [[10449, 10750], [7139, 6688], [9603, 9746]]
+ },
+ "screenshotSha256": "1ad43cda8e816431b0ab9993066b087c9283351f304ade2c1765eebf8e349c81",
+ "refusedCodec": "authored KTX2",
+ "previousPackagePreserved": true
+}
diff --git a/docs/verification/vq01/quickjs-native.png b/docs/verification/vq01/quickjs-native.png
new file mode 100644
index 0000000000..b323d02476
Binary files /dev/null and b/docs/verification/vq01/quickjs-native.png differ
diff --git a/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json b/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json
new file mode 100644
index 0000000000..22d9c2d617
--- /dev/null
+++ b/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json
@@ -0,0 +1,56 @@
+{
+ "schemaVersion": 1,
+ "name": "vq01-packaged-quickjs-assets",
+ "target": "desktop",
+ "viewport": {
+ "width": 960,
+ "height": 640
+ },
+ "warmupFrames": 30,
+ "steps": [
+ {
+ "waitFrames": 90
+ }
+ ],
+ "artifacts": {
+ "screenshots": true,
+ "console": true
+ },
+ "assert": {
+ "resources": [
+ {
+ "id": "state",
+ "path": "loadedModels",
+ "equals": 2,
+ "allowTrivial": "Both real normalized GLBs must finish loading before enter and the first observation."
+ },
+ {
+ "id": "state",
+ "path": "texturedMeshes",
+ "equals": 2,
+ "allowTrivial": "A texture image on each loaded GLB is counted before the first observation."
+ },
+ {
+ "id": "state",
+ "path": "vertices",
+ "gte": 100,
+ "allowTrivial": "Decoded GLB geometry is measured at scene entry."
+ },
+ {
+ "id": "state",
+ "path": "frames",
+ "changed": true
+ },
+ {
+ "id": "state",
+ "path": "maxPoseDelta",
+ "gte": 0.05,
+ "allowTrivial": "The native warm-up can already advance both authored animation tracks."
+ }
+ ],
+ "startup": {
+ "maxEnteredMs": 120000,
+ "maxReadyMs": 120000
+ }
+ }
+}
diff --git a/examples/abyss-framework/playtests/vq-native-asset-lifecycle.playtest.json b/examples/abyss-framework/playtests/vq-native-asset-lifecycle.playtest.json
new file mode 100644
index 0000000000..71535b400d
--- /dev/null
+++ b/examples/abyss-framework/playtests/vq-native-asset-lifecycle.playtest.json
@@ -0,0 +1,103 @@
+{
+ "schemaVersion": 1,
+ "name": "vq01-native-asset-lifecycle",
+ "target": "desktop",
+ "viewport": {
+ "width": 960,
+ "height": 640
+ },
+ "warmupFrames": 30,
+ "steps": [
+ {
+ "press": "KeyV",
+ "release": true,
+ "label": "arm the leave/re-enter cycle"
+ },
+ {
+ "waitTicks": 2
+ },
+ {
+ "waitForResource": {
+ "id": "state",
+ "path": "settled",
+ "equals": 2
+ },
+ "timeoutMs": 120000
+ },
+ {
+ "waitForResource": {
+ "id": "state",
+ "path": "settled",
+ "equals": 3
+ },
+ "timeoutMs": 120000
+ },
+ {
+ "waitForResource": {
+ "id": "state",
+ "path": "settled",
+ "equals": 4
+ },
+ "timeoutMs": 120000
+ }
+ ],
+ "artifacts": {
+ "screenshots": true,
+ "console": true
+ },
+ "assert": {
+ "resources": [
+ {
+ "id": "state",
+ "path": "entries",
+ "equals": 4,
+ "allowTrivial": "The first load publishes entries 1; only three further enters of the same scene publish 4."
+ },
+ {
+ "id": "state",
+ "path": "loadedModels",
+ "equals": 2,
+ "allowTrivial": "Both real normalized GLBs reload and finish before every observation, including the fourth enter's."
+ },
+ {
+ "id": "state",
+ "path": "texturedMeshes",
+ "equals": 2,
+ "allowTrivial": "A texture image on each reloaded GLB is counted before the observation."
+ },
+ {
+ "id": "state",
+ "path": "ownedGeometries",
+ "gte": 1,
+ "allowTrivial": "The renderer's live geometry tally is only published once the settle window has measured a settled one; how many a backend initialises for this scene is its own business."
+ },
+ {
+ "id": "state",
+ "path": "geometryGrowth",
+ "equals": 0,
+ "allowTrivial": "The exit path's disposal is what makes a re-entry's settled tally return to the first enter's settled tally, and only a leak can move the difference between them."
+ },
+ {
+ "id": "state",
+ "path": "ownedTextures",
+ "gte": 1,
+ "allowTrivial": "The renderer's live texture tally is only published once the settle window has measured a settled one, which is after the KTX2 and panel textures have landed."
+ },
+ {
+ "id": "state",
+ "path": "textureGrowth",
+ "equals": 0,
+ "allowTrivial": "The exit path's disposal is what makes a re-entry's settled tally return to the first enter's settled tally, and only a leak can move the difference between them."
+ },
+ {
+ "id": "state",
+ "path": "frames",
+ "changed": true
+ }
+ ],
+ "startup": {
+ "maxEnteredMs": 120000,
+ "maxReadyMs": 120000
+ }
+ }
+}
diff --git a/examples/abyss-framework/vq-assets/.gitignore b/examples/abyss-framework/vq-assets/.gitignore
new file mode 100644
index 0000000000..e47b3a7dd4
--- /dev/null
+++ b/examples/abyss-framework/vq-assets/.gitignore
@@ -0,0 +1,3 @@
+.generated/
+.threenative/
+dist-native/
diff --git a/examples/abyss-framework/vq-assets/generate.ts b/examples/abyss-framework/vq-assets/generate.ts
new file mode 100644
index 0000000000..ebc29790c9
--- /dev/null
+++ b/examples/abyss-framework/vq-assets/generate.ts
@@ -0,0 +1,175 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { createRequire } from "node:module";
+import path from "node:path";
+import { Document, NodeIO } from "@gltf-transform/core";
+import {
+ ALL_EXTENSIONS,
+ EXTMeshoptCompression,
+ KHRDracoMeshCompression,
+ KHRMaterialsUnlit,
+} from "@gltf-transform/extensions";
+import { texturePass } from "@threenative/assets";
+import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
+import { PNG } from "pngjs";
+import { BoxGeometry, type BufferGeometry, TorusGeometry } from "three";
+
+const draco = createRequire(import.meta.url)("draco3dgltf") as {
+ createEncoderModule(): Promise;
+ createDecoderModule(): Promise;
+};
+export const MODEL_NAMES = ["meshopt", "draco"] as const;
+
+function checker(a: readonly number[], b: readonly number[]): Buffer {
+ const png = new PNG({ width: 32, height: 32 });
+ for (let y = 0; y < 32; y++)
+ for (let x = 0; x < 32; x++) {
+ const rgb = ((x >> 3) + (y >> 3)) % 2 === 0 ? a : b;
+ png.data.set([rgb[0] ?? 0, rgb[1] ?? 0, rgb[2] ?? 0, 255], (y * 32 + x) * 4);
+ }
+ return PNG.sync.write(png);
+}
+
+function specimen(name: string, geometry: BufferGeometry, image: Buffer): Document {
+ const document = new Document();
+ const buffer = document.createBuffer();
+ const material = document
+ .createMaterial(name)
+ .setBaseColorTexture(document.createTexture(name).setMimeType("image/png").setImage(image));
+ material.setExtension(
+ "KHR_materials_unlit",
+ document.createExtension(KHRMaterialsUnlit).createUnlit(),
+ );
+ const primitive = document.createPrimitive().setMaterial(material);
+ for (const [attribute, semantic, type] of [
+ ["position", "POSITION", "VEC3"],
+ ["normal", "NORMAL", "VEC3"],
+ ["uv", "TEXCOORD_0", "VEC2"],
+ ] as const) {
+ primitive.setAttribute(
+ semantic,
+ document
+ .createAccessor()
+ .setType(type)
+ .setArray(Float32Array.from(geometry.getAttribute(attribute).array))
+ .setBuffer(buffer),
+ );
+ }
+ if (!geometry.index) throw new Error("Fixture requires indexed geometry");
+ primitive.setIndices(
+ document
+ .createAccessor()
+ .setType("SCALAR")
+ .setArray(Uint16Array.from(geometry.index.array))
+ .setBuffer(buffer),
+ );
+ geometry.dispose();
+ const node = document
+ .createNode(`${name}-animated`)
+ .setMesh(document.createMesh(name).addPrimitive(primitive));
+ document.createScene().addChild(node);
+ const sampler = document
+ .createAnimationSampler()
+ .setInterpolation("LINEAR")
+ .setInput(
+ document
+ .createAccessor()
+ .setType("SCALAR")
+ .setArray(new Float32Array([0, 1, 2]))
+ .setBuffer(buffer),
+ )
+ .setOutput(
+ document
+ .createAccessor()
+ .setType("VEC4")
+ .setArray(new Float32Array([0, 0, 0, 1, 0, Math.sin(0.6), 0, Math.cos(0.6), 0, 0, 0, 1]))
+ .setBuffer(buffer),
+ );
+ document
+ .createAnimation("turn")
+ .addSampler(sampler)
+ .addChannel(
+ document
+ .createAnimationChannel()
+ .setTargetNode(node)
+ .setTargetPath("rotation")
+ .setSampler(sampler),
+ );
+ return document;
+}
+
+export async function fixtureReader(): Promise {
+ await MeshoptDecoder.ready;
+ return new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({
+ "meshopt.decoder": MeshoptDecoder,
+ "draco3d.decoder": await draco.createDecoderModule(),
+ });
+}
+
+/** Real compressed inputs from the installed encoders; all source art is authored here. */
+export async function generateNativeAssetFixture(source: string): Promise {
+ await mkdir(source, { recursive: true });
+ await MeshoptEncoder.ready;
+ for (const name of MODEL_NAMES) {
+ const document = specimen(
+ name,
+ name === "meshopt"
+ ? new BoxGeometry(1.4, 1.4, 1.4, 2, 2, 2)
+ : new TorusGeometry(0.7, 0.27, 12, 32),
+ name === "meshopt"
+ ? checker([20, 210, 225], [240, 85, 35])
+ : checker([235, 45, 170], [245, 205, 35]),
+ );
+ if (name === "meshopt")
+ document
+ .createExtension(EXTMeshoptCompression)
+ .setRequired(true)
+ .setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
+ else document.createExtension(KHRDracoMeshCompression).setRequired(true);
+ const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({
+ "meshopt.encoder": MeshoptEncoder,
+ "draco3d.encoder": await draco.createEncoderModule(),
+ });
+ await writeFile(path.join(source, `${name}.glb`), await io.writeBinary(document));
+ }
+ const png = checker([95, 210, 60], [85, 100, 235]);
+ await writeFile(path.join(source, "checker.png"), png);
+ const result = await texturePass({ overrides: [{ glob: "*.png", codec: "uastc" }] }).apply(
+ png,
+ "checker.png",
+ );
+ if (Buffer.isBuffer(result) || result.outputExtension !== ".ktx2")
+ throw new Error("Fixture encoder did not produce KTX2");
+ return result.buffer;
+}
+
+/** Decoded payload measurements, not download sizes or claimed GPU savings. */
+export async function inspectFixtureModel(file: string) {
+ const bytes = await readFile(file);
+ const document = await (await fixtureReader()).read(file);
+ const root = document.getRoot();
+ return {
+ encodedBytes: bytes.length,
+ extensions: root.listExtensionsUsed().map((extension) => extension.extensionName),
+ triangles: root
+ .listMeshes()
+ .flatMap((mesh) => mesh.listPrimitives())
+ .reduce((sum, primitive) => sum + (primitive.getIndices()?.getCount() ?? 0) / 3, 0),
+ positions: root
+ .listMeshes()
+ .flatMap((mesh) => mesh.listPrimitives())
+ .flatMap((primitive) => Array.from(primitive.getAttribute("POSITION")?.getArray() ?? [])),
+ decodedGeometryBytes: root
+ .listAccessors()
+ .reduce((sum, accessor) => sum + (accessor.getArray()?.byteLength ?? 0), 0),
+ images: root
+ .listTextures()
+ .map((texture) => PNG.sync.read(Buffer.from(texture.getImage() ?? []))),
+ animation: root
+ .listAnimations()
+ .flatMap((animation) => animation.listSamplers())
+ .map((sampler) => ({
+ input: Array.from(sampler.getInput()?.getArray() ?? []),
+ output: Array.from(sampler.getOutput()?.getArray() ?? []),
+ })),
+ };
+}
diff --git a/examples/abyss-framework/vq-assets/index.html b/examples/abyss-framework/vq-assets/index.html
new file mode 100644
index 0000000000..d05ad3cc29
--- /dev/null
+++ b/examples/abyss-framework/vq-assets/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+ VQ-01 native asset fallback
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/abyss-framework/vq-assets/package.json b/examples/abyss-framework/vq-assets/package.json
new file mode 100644
index 0000000000..2c89f456a4
--- /dev/null
+++ b/examples/abyss-framework/vq-assets/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "vq-native-assets",
+ "private": true,
+ "type": "module"
+}
diff --git a/examples/abyss-framework/vq-assets/src/game.ts b/examples/abyss-framework/vq-assets/src/game.ts
new file mode 100644
index 0000000000..e25bdf6f7e
--- /dev/null
+++ b/examples/abyss-framework/vq-assets/src/game.ts
@@ -0,0 +1,219 @@
+import { AnimationPlayer, type ICtx, Scene, defineGame } from "@threenative/core";
+import { playtest } from "@threenative/core/playtest";
+import {
+ type AnimationClip,
+ type Group,
+ Mesh,
+ type MeshStandardMaterial,
+ type PerspectiveCamera,
+ Quaternion,
+ type Texture,
+} from "three";
+import config from "../threenative.config.js";
+import { stage } from "./render/stage.js";
+
+type State = {
+ loadedModels: number;
+ texturedMeshes: number;
+ vertices: number;
+ frames: number;
+ maxPoseDelta: number;
+ entries: number;
+ settled: number;
+ ownedGeometries?: number;
+ ownedTextures?: number;
+ geometryGrowth?: number;
+ textureGrowth?: number;
+};
+type Model = { scene: Group; animations: AnimationClip[] };
+type Owned = { geometries: number; textures: number };
+const names = ["meshopt", "draco"] as const;
+/** Frames an enter is given to upload its geometry and textures before it is measured at all. */
+const SETTLE_FRAMES = 30;
+/** Sampled frames the tally has to hold still before the enter is read as settled. */
+const QUIET_FRAMES = 15;
+/** The frame an unsettled enter gives up on, so a backend that never goes quiet still cycles. */
+const MAX_FRAMES = 300;
+/** One first load plus three leave/re-enter cycles. */
+const ENTERS = 4;
+/** The action the lifecycle scenario's key step presses to arm the cycle. */
+const ARM = "lifecycle";
+
+// The lifetime ledger, at module scope because only a value that outlives the scene can count its
+// own re-entries: `ctx.goto()` keeps the published state, and every field below is re-derived on
+// each enter from the renderer's live tally.
+let enters = 0;
+let settled = 0;
+let armed = false;
+let baseline: Owned | undefined;
+const growth: Owned = { geometries: 0, textures: 0 };
+
+/** The renderer's own GPU tally, or undefined when this backend keeps none — never a fake zero. */
+function ownedResources(ctx: ICtx): Owned | undefined {
+ try {
+ const memory = (ctx.renderer.info as { memory?: Partial }).memory;
+ const { geometries, textures } = memory ?? {};
+ return typeof geometries === "number" && typeof textures === "number"
+ ? { geometries, textures }
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+export class AssetScene extends Scene {
+ static override readonly initialState: State = {
+ loadedModels: 0,
+ texturedMeshes: 0,
+ vertices: 0,
+ frames: 0,
+ maxPoseDelta: 0,
+ entries: 0,
+ settled: 0,
+ };
+ #models: Model[] = [];
+ #png: Texture | undefined;
+ #players: AnimationPlayer[] = [];
+ #owned: Mesh[] = [];
+
+ override async load(ctx: ICtx): Promise {
+ this.#models = await Promise.all(names.map((name) => ctx.assets.model(`${name}.glb`)));
+ // Options return an owned sRGB clone; the cached source remains loader-owned.
+ this.#png = await ctx.assets.texture("checker.png", {});
+ }
+
+ override enter(ctx: ICtx) {
+ if (!this.#png) throw new Error("PNG did not load");
+ enters += 1;
+ const entry = enters;
+ this.#owned = stage(ctx.scene, ctx.camera as PerspectiveCamera, this.#png);
+ let texturedMeshes = 0;
+ let vertices = 0;
+ const rotations: Quaternion[] = [];
+ const animated = this.#models.map((model, index) => {
+ model.scene.position.set(index === 0 ? -2.4 : 0, 1.35, 0);
+ ctx.add(model.scene);
+ model.scene.traverse((node) => {
+ if (!(node instanceof Mesh)) return;
+ vertices += node.geometry.getAttribute("position").count;
+ const materials = Array.isArray(node.material) ? node.material : [node.material];
+ if (materials.some((material) => (material as MeshStandardMaterial).map?.image))
+ texturedMeshes++;
+ });
+ const player = new AnimationPlayer({
+ root: model.scene,
+ clips: model.animations,
+ requiredClips: ["turn"],
+ strideSync: false,
+ });
+ player.play("turn");
+ this.#players.push(player);
+ const node = model.scene.getObjectByName(`${names[index]}-animated`);
+ if (!node) throw new Error(`Missing animated node: ${names[index]}`);
+ rotations.push(node.quaternion.clone());
+ return node;
+ });
+ ctx.state.set({ loadedModels: this.#models.length, texturedMeshes, vertices, entries: entry });
+ let frames = 0;
+ let maxPoseDelta = 0;
+ let left = false;
+ // This enter's own high-water tally and how long it has held still. `info.memory` counts what
+ // the backend has initialised, which lands over several frames, so the enter is read at the
+ // tally that stopped moving — a baseline taken on the first measured frame is smaller than the
+ // scene's own live set and every later enter reads as growth it never had.
+ const peak: Owned = { geometries: 0, textures: 0 };
+ let previous: Owned | undefined;
+ let quiet = 0;
+ return (ctx: ICtx, dt: number) => {
+ if (ctx.input.justPressed(ARM)) armed = true;
+ for (const player of this.#players) player.update(dt);
+ for (const [index, node] of animated.entries())
+ maxPoseDelta = Math.max(
+ maxPoseDelta,
+ node.quaternion.angleTo(rotations[index] ?? new Quaternion()),
+ );
+ frames += 1;
+ const owned = frames < SETTLE_FRAMES ? undefined : ownedResources(ctx);
+ let isSettled = false;
+ if (owned !== undefined) {
+ peak.geometries = Math.max(peak.geometries, owned.geometries);
+ peak.textures = Math.max(peak.textures, owned.textures);
+ quiet =
+ previous?.geometries === owned.geometries && previous.textures === owned.textures
+ ? quiet + 1
+ : 0;
+ previous = owned;
+ isSettled = quiet >= QUIET_FRAMES || frames >= MAX_FRAMES;
+ if (isSettled) {
+ baseline ??= { ...peak };
+ growth.geometries = Math.max(growth.geometries, peak.geometries - baseline.geometries);
+ growth.textures = Math.max(growth.textures, peak.textures - baseline.textures);
+ settled = Math.max(settled, entry);
+ }
+ }
+ ctx.state.set({
+ frames,
+ maxPoseDelta,
+ entries: entry,
+ settled,
+ ...(owned === undefined
+ ? {}
+ : {
+ ownedGeometries: peak.geometries,
+ ownedTextures: peak.textures,
+ geometryGrowth: growth.geometries,
+ textureGrowth: growth.textures,
+ }),
+ });
+ // Leave once this enter has settled, so the next observation is a re-entry's, not this one's.
+ if (armed && isSettled && !left && entry < ENTERS) {
+ left = true;
+ void ctx.goto("void");
+ }
+ };
+ }
+
+ override exit(ctx: ICtx): void {
+ for (const player of this.#players) player.dispose();
+ this.#players.length = 0;
+ for (const mesh of this.#owned) {
+ mesh.removeFromParent();
+ mesh.geometry.dispose();
+ for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material])
+ material.dispose();
+ }
+ this.#owned.length = 0;
+ this.#png?.dispose();
+ this.#png = undefined;
+ this.#models.length = 0;
+ for (const name of names) ctx.assets.release("model", `${name}.glb`);
+ ctx.assets.release("texture", "checker.png");
+ }
+}
+
+/** The empty scene the asset scene leaves for: no objects, no assets, one frame of nothing. */
+class VoidScene extends Scene {
+ static override readonly initialState: State = AssetScene.initialState;
+ #returned = false;
+
+ override enter(ctx: ICtx): void {
+ ctx.state.set({ entries: enters, settled });
+ }
+
+ override update(ctx: ICtx): void {
+ if (this.#returned) return;
+ this.#returned = true;
+ void ctx.goto("assets");
+ }
+}
+
+export default defineGame({
+ scenes: { assets: AssetScene, void: VoidScene },
+ start: "assets",
+ plugins: [playtest()],
+ // One key arms the leave/re-enter cycle, and only the lifecycle scenario presses it: both runners
+ // deliver a `press` step as the same keydown, so this reads identically on web and on a device.
+ input: { [ARM]: { keys: ["KeyV"] } },
+ display: config.display,
+ render: config.renderer,
+});
diff --git a/examples/abyss-framework/vq-assets/src/main.ts b/examples/abyss-framework/vq-assets/src/main.ts
new file mode 100644
index 0000000000..dfdd4f793d
--- /dev/null
+++ b/examples/abyss-framework/vq-assets/src/main.ts
@@ -0,0 +1,8 @@
+import game from "./game.js";
+
+const app = document.querySelector("#app");
+if (app === null) throw new Error("Missing #app element.");
+void game.start().then(() => {
+ const canvas = game.ctx?.renderer.domElement;
+ if (canvas !== undefined) app.prepend(canvas);
+});
diff --git a/examples/abyss-framework/vq-assets/src/render/stage.ts b/examples/abyss-framework/vq-assets/src/render/stage.ts
new file mode 100644
index 0000000000..d6a15c5d5b
--- /dev/null
+++ b/examples/abyss-framework/vq-assets/src/render/stage.ts
@@ -0,0 +1,34 @@
+import {
+ Color,
+ CylinderGeometry,
+ Mesh,
+ MeshBasicMaterial,
+ type PerspectiveCamera,
+ PlaneGeometry,
+ type Scene,
+ type Texture,
+} from "three";
+
+export function stage(scene: Scene, camera: PerspectiveCamera, png: Texture): Mesh[] {
+ scene.background = new Color(0x101827);
+ camera.position.set(0, 3.2, 8.8);
+ camera.lookAt(0, 1.1, 0);
+ camera.fov = 42;
+ camera.updateProjectionMatrix();
+ const objects: Mesh[] = [];
+ for (const x of [-2.4, 0, 2.4]) {
+ const plinth = new Mesh(
+ new CylinderGeometry(1.03, 1.1, 0.25, 48),
+ new MeshBasicMaterial({ color: 0x263951 }),
+ );
+ plinth.position.set(x, 0.15, 0);
+ scene.add(plinth);
+ objects.push(plinth);
+ }
+ const panel = new Mesh(new PlaneGeometry(1.6, 1.6), new MeshBasicMaterial({ map: png }));
+ panel.position.set(2.4, 1.35, 0);
+ panel.rotation.y = -0.15;
+ scene.add(panel);
+ objects.push(panel);
+ return objects;
+}
diff --git a/examples/abyss-framework/vq-assets/threenative.config.ts b/examples/abyss-framework/vq-assets/threenative.config.ts
new file mode 100644
index 0000000000..d9e2ad236a
--- /dev/null
+++ b/examples/abyss-framework/vq-assets/threenative.config.ts
@@ -0,0 +1,25 @@
+import type { IThreeNativeConfig } from "@threenative/core";
+
+export default {
+ app: { id: "com.threenative.vq01", name: "VQ-01 native assets", version: "1.0.0", build: 1 },
+ assets: {
+ source: ".generated/assets",
+ output: ".generated/public",
+ audio: "none",
+ budget: "none",
+ lod: false,
+ // Ask for compression. The actual selected QuickJS runtime must remove it, not the fixture.
+ textures: { overrides: [{ glob: "*.png", codec: "uastc" }] },
+ models: {
+ compact: false,
+ passes: { dedup: false, meshopt: true, prune: false, quantize: false, reorder: false },
+ textures: "none",
+ virtual: "none",
+ },
+ },
+ nativeEntry: "src/game.ts",
+ display: { fullscreen: false, maxFps: 60 },
+ window: { width: 960, height: 640, title: "VQ-01 native asset fallback", resizable: false },
+ renderer: { preferWebGPU: true, resolutionScale: 1 },
+ ui: { renderer: "native" },
+} satisfies IThreeNativeConfig;
diff --git a/examples/abyss-framework/vq-assets/tsconfig.json b/examples/abyss-framework/vq-assets/tsconfig.json
new file mode 100644
index 0000000000..5b32896060
--- /dev/null
+++ b/examples/abyss-framework/vq-assets/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": { "types": ["node"], "rootDir": "." },
+ "include": ["src/**/*.ts", "threenative.config.ts", "generate.ts"]
+}
diff --git a/packages/assets/__tests__/model-pass.spec.ts b/packages/assets/__tests__/model-pass.spec.ts
index 212e0969dc..deda4921ea 100644
--- a/packages/assets/__tests__/model-pass.spec.ts
+++ b/packages/assets/__tests__/model-pass.spec.ts
@@ -89,14 +89,68 @@ describe("modelPass", () => {
}
});
- it("should throw naming bounding-box drift when positions quantize to 4 bits", async () => {
- const input = Buffer.from(await buildFixtureGlb());
- await expect(
- modelPass({ quantize: { normalBits: 16, positionBits: 4, uvBits: 12 } }).apply(
- input,
- "character.glb",
- ),
- ).rejects.toThrow(/TN_ASSETS_MODEL_DRIFT.*bounding box drifted/u);
+ it("should leave deforming positions alone at a depth below the quantization floor", async () => {
+ // Four bits is the destructive pre-round the library refuses to express: the pass snaps floats
+ // onto a 15-level grid over each accessor's own bounds and lets the self-verify reject the
+ // result. That request used to land on the fixture's skinned head — and used to trip the
+ // self-verify at 0.167% drift. Deforming geometry is now held in source space at every depth,
+ // so the request never reaches it and there is nothing left to reject; the fail-closed path
+ // itself is covered by the drift-kind comparison further down. The static half of the document
+ // still quantizes, which is what keeps the two halves of the rule honest.
+ const document = buildFixtureDocument({ textured: false });
+ const root = document.getRoot();
+ const head = root.listMeshes()[0]?.listPrimitives()[1];
+ const buffer = head?.getAttribute("POSITION")?.getBuffer() ?? null;
+ if (head === undefined || buffer === null) throw new Error("Fixture lost its rig.");
+ const prop = document
+ .createPrimitive()
+ .setIndices(
+ document
+ .createAccessor("prop-indices")
+ .setBuffer(buffer)
+ .setType("SCALAR")
+ .setArray(
+ Uint32Array.from([
+ 0, 10, 20, 1, 11, 21, 2, 12, 22, 3, 13, 23, 4, 14, 24, 5, 15, 25, 6, 16, 26, 7, 17, 8,
+ 18, 19, 9,
+ ]),
+ ),
+ )
+ .setAttribute(
+ "POSITION",
+ document
+ .createAccessor("prop-positions")
+ .setBuffer(buffer)
+ .setType("VEC3")
+ .setArray(new Float32Array(Array.from({ length: 81 }, (_, index) => index))),
+ );
+ root
+ .listScenes()[0]
+ ?.addChild(
+ document.createNode("prop-root").setMesh(document.createMesh("prop").addPrimitive(prop)),
+ );
+ const positions = [...(head.getAttribute("POSITION")?.getArray() ?? [])];
+ const result = await modelPass({
+ compact: false,
+ quantize: { normalBits: 16, positionBits: 4, uvBits: 12 },
+ textures: "none",
+ virtual: "none",
+ }).apply(Buffer.from(await new NodeIO().writeBinary(document)), "character.glb");
+ expect(Buffer.isBuffer(result)).toBe(false);
+ if (Buffer.isBuffer(result)) return;
+ const output = await readVerified(result.buffer);
+ expect([
+ ...(output.listMeshes()[0]?.listPrimitives()[1]?.getAttribute("POSITION")?.getArray() ?? []),
+ ]).toEqual(positions);
+ expect(
+ output
+ .listMeshes()
+ .find((mesh) => mesh.getName() === "prop")
+ ?.listPrimitives()[0]
+ ?.getAttribute("POSITION")
+ ?.getComponentSize(),
+ // Eight bits is the floor the request resolves to, and it lands as normalized Int8.
+ ).toBe(1);
});
it("should declare EXT_meshopt_compression and not Draco", async () => {
@@ -151,6 +205,209 @@ describe("modelPass", () => {
}
});
+ it("should keep a deforming primitive's vertex order and still reorder a static one", async () => {
+ // A vertex index is an address, and something outside this cook may already hold one: a
+ // MetaHuman sidecar records the head vertex each brow strand root rides, and reads the position
+ // back at that index every frame. The cook cannot know who holds an index, so deforming
+ // geometry — skinned, morph-targeted, or carrying joint data — keeps its exact vertex order and
+ // count, and everything else is still reordered for transmission size.
+ const document = buildFixtureDocument({ textured: false });
+ const root = document.getRoot();
+ const character = root.listMeshes()[0];
+ const cloth = character?.listPrimitives()[0];
+ const head = character?.listPrimitives()[1];
+ if (cloth === undefined || head === undefined) throw new Error("Fixture lost a primitive.");
+ // Both reasons in one cook: the cloth stays skinned-only, the head also gains a morph target.
+ const buffer = head.getAttribute("POSITION")?.getBuffer() ?? null;
+ head.addTarget(
+ document.createPrimitiveTarget("head-jaw-open").setAttribute(
+ "POSITION",
+ document
+ .createAccessor("head-jaw-open-deltas")
+ .setBuffer(buffer)
+ .setType("VEC3")
+ .setArray(new Float32Array(Array.from({ length: 18 }, (_, index) => (index % 3) * 0.01))),
+ ),
+ );
+ // A static mesh whose index buffer is not already in draw order, so the reorder has real work
+ // to do on it: the jumbled order below is a non-identity meshoptimizer remap.
+ const staticIndices = [6, 1, 8, 0, 7, 3, 2, 4, 5];
+ const prop = document
+ .createPrimitive()
+ .setIndices(
+ document
+ .createAccessor("prop-indices")
+ .setBuffer(buffer)
+ .setType("SCALAR")
+ .setArray(Uint32Array.from(staticIndices)),
+ )
+ .setAttribute(
+ "POSITION",
+ document
+ .createAccessor("prop-positions")
+ .setBuffer(buffer)
+ .setType("VEC3")
+ .setArray(new Float32Array(Array.from({ length: 27 }, (_, index) => index))),
+ );
+ root
+ .listScenes()[0]
+ ?.addChild(
+ document.createNode("prop-root").setMesh(document.createMesh("prop").addPrimitive(prop)),
+ );
+
+ const source = (meshName: string, primitive: number): number[] => [
+ ...(root
+ .listMeshes()
+ .find((mesh) => mesh.getName() === meshName)
+ ?.listPrimitives()
+ [primitive]?.getAttribute("POSITION")
+ ?.getArray() ?? []),
+ ];
+ const before = {
+ cloth: source("character", 0),
+ head: source("character", 1),
+ prop: source("prop", 0),
+ };
+ // Lossless options, so the only stage that can move a vertex is `reorder` itself.
+ const result = await modelPass({
+ compact: false,
+ passes: { quantize: false },
+ textures: "none",
+ virtual: "none",
+ }).apply(Buffer.from(await new NodeIO().writeBinary(document)), "character.glb");
+ if (Buffer.isBuffer(result)) throw new Error("model pass returned an unchanged buffer");
+ const output = await readVerified(result.buffer);
+ const cooked = (meshName: string, primitive: number): number[] => [
+ ...(output
+ .listMeshes()
+ .find((mesh) => mesh.getName() === meshName)
+ ?.listPrimitives()
+ [primitive]?.getAttribute("POSITION")
+ ?.getArray() ?? []),
+ ];
+
+ expect(cooked("character", 0)).toEqual(before.cloth);
+ expect(cooked("character", 1)).toEqual(before.head);
+ // And the static half of the rule: reordered, and nothing but reordered.
+ expect(cooked("prop", 0)).not.toEqual(before.prop);
+ expect([...cooked("prop", 0)].sort((a, b) => a - b)).toEqual(
+ [...before.prop].sort((a, b) => a - b),
+ );
+ });
+
+ it("should keep a deforming primitive's positions in source metres and still quantize a static one", async () => {
+ // One metre is one metre. `quantize` snaps POSITION onto a grid whose step is the mesh's own
+ // volume over 2^bits, so a head cooked at 16 bits over half a metre lands within ~8 µm of
+ // where the author put it — and the metahuman-lab sidecar, which names the head vertex each
+ // brow strand root rides and reads that position back every frame, was prepared to 1e-5 m.
+ // Quantization also compensates by folding a shift into the node, the skin's inverse bind
+ // matrices and any volumetric material, so the whole mesh stays in source space instead:
+ // deforming POSITION and its morph-target deltas keep float32, everything else still quantizes.
+ const document = buildFixtureDocument({ textured: false });
+ const root = document.getRoot();
+ const character = root.listMeshes().find((mesh) => mesh.getName() === "character");
+ const cloth = character?.listPrimitives()[0];
+ const head = character?.listPrimitives()[1];
+ const skin = root.listSkins()[0];
+ const buffer = head?.getAttribute("POSITION")?.getBuffer() ?? null;
+ if (cloth === undefined || head === undefined || skin === undefined || buffer === null)
+ throw new Error("Fixture lost its rig.");
+ // Both reasons in one cook, as in the vertex-order twin: the head is skinned and morph-targeted.
+ const deltas = new Float32Array(Array.from({ length: 18 }, (_, index) => (index % 3) * 0.004));
+ head.addTarget(
+ document
+ .createPrimitiveTarget("jaw-open")
+ .setAttribute(
+ "POSITION",
+ document
+ .createAccessor("jaw-open-deltas")
+ .setBuffer(buffer)
+ .setType("VEC3")
+ .setArray(deltas),
+ ),
+ );
+ // A static mesh in the same document, on its own node: the half of the rule that must not regress.
+ const prop = document
+ .createPrimitive()
+ .setIndices(
+ document
+ .createAccessor("prop-indices")
+ .setBuffer(buffer)
+ .setType("SCALAR")
+ .setArray(
+ // Every vertex referenced exactly once, so the library's own vertex compaction inside
+ // `quantize` has nothing to drop and the bbox the self-verify reads is the authored one.
+ Uint32Array.from([
+ 0, 10, 20, 1, 11, 21, 2, 12, 22, 3, 13, 23, 4, 14, 24, 5, 15, 25, 6, 16, 26, 7, 17, 8,
+ 18, 19, 9,
+ ]),
+ ),
+ )
+ .setAttribute(
+ "POSITION",
+ document
+ .createAccessor("prop-positions")
+ .setBuffer(buffer)
+ .setType("VEC3")
+ // 27 vertices, every one of them referenced by the indices above.
+ .setArray(new Float32Array(Array.from({ length: 81 }, (_, index) => index))),
+ );
+ root
+ .listScenes()[0]
+ ?.addChild(
+ document.createNode("prop-root").setMesh(document.createMesh("prop").addPrimitive(prop)),
+ );
+
+ const positions = [...(head.getAttribute("POSITION")?.getArray() ?? [])];
+ const binds = [...(skin.getInverseBindMatrices()?.getArray() ?? [])];
+ const result = await modelPass({ compact: false, textures: "none", virtual: "none" }).apply(
+ Buffer.from(await new NodeIO().writeBinary(document)),
+ "character.glb",
+ );
+ if (Buffer.isBuffer(result)) throw new Error("model pass returned an unchanged buffer");
+ const output = await readVerified(result.buffer);
+ const cookedHead = output
+ .listMeshes()
+ .find((mesh) => mesh.getName() === "character")
+ ?.listPrimitives()[1];
+ const cookedCloth = output
+ .listMeshes()
+ .find((mesh) => mesh.getName() === "character")
+ ?.listPrimitives()[0];
+ const cookedProp = output
+ .listMeshes()
+ .find((mesh) => mesh.getName() === "prop")
+ ?.listPrimitives()[0];
+
+ const cookedPositions = cookedHead?.getAttribute("POSITION");
+ expect(cookedPositions?.getComponentType()).toBe(5126);
+ const after = [...(cookedPositions?.getArray() ?? [])];
+ expect(after.length).toBe(positions.length);
+ for (const [index, value] of after.entries())
+ expect(Math.abs((value as number) - (positions[index] as number))).toBeLessThan(1e-6);
+ // The deltas ride the same space, so they keep their float32 metres too.
+ const cookedDeltas = cookedHead?.listTargets()[0]?.getAttribute("POSITION");
+ expect(cookedDeltas?.getComponentType()).toBe(5126);
+ expect([...(cookedDeltas?.getArray() ?? [])]).toEqual([...deltas]);
+ // Nothing moved to compensate: the bind poses the deformation is measured against are the
+ // authored ones, and the normals that do not address a vertex still quantized.
+ const cookedBinds = [...(output.listSkins()[0]?.getInverseBindMatrices()?.getArray() ?? [])];
+ expect(cookedBinds.length).toBe(binds.length);
+ for (const [index, value] of cookedBinds.entries())
+ expect(Math.abs((value as number) - (binds[index] as number))).toBeLessThan(1e-6);
+ expect(cookedCloth?.getAttribute("POSITION")?.getComponentType()).toBe(5126);
+ expect(cookedCloth?.getAttribute("NORMAL")?.getComponentSize()).toBeLessThan(4);
+ // And the static half of the rule, unchanged: quantized, normalized, KHR_mesh_quantization on.
+ expect(cookedProp?.getAttribute("POSITION")?.getComponentSize()).toBe(2);
+ expect(cookedProp?.getAttribute("POSITION")?.getNormalized()).toBe(true);
+ expect(
+ output
+ .listExtensionsUsed()
+ .map((extension) => extension.extensionName)
+ .includes("KHR_mesh_quantization"),
+ ).toBe(true);
+ });
+
it("decodes normalized ushort joint weights by their own component type in reachableStats", async () => {
// External exporters ship WEIGHTS_0 as normalized UNSIGNED_SHORT. reachableStats must
// decode them /65535 like the GPU does; the hardcoded ubyte scale (/255) reads raw
diff --git a/packages/assets/__tests__/model-texture-pass.spec.ts b/packages/assets/__tests__/model-texture-pass.spec.ts
index 7458841893..3a3ee87cf9 100644
--- a/packages/assets/__tests__/model-texture-pass.spec.ts
+++ b/packages/assets/__tests__/model-texture-pass.spec.ts
@@ -1,9 +1,11 @@
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
+import { createRequire } from "node:module";
import path from "node:path";
import { type Document, NodeIO } from "@gltf-transform/core";
-import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
+import { ALL_EXTENSIONS, EXTTextureWebP } from "@gltf-transform/extensions";
import { read as readKTX2 } from "ktx-parse";
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
+import { PNG } from "pngjs";
import { describe, expect, it, vi } from "vitest";
import { buildFixtureDocument } from "../../../test-support/generate-fixture-model.js";
import { rgbaPng } from "../../../test-support/png.js";
@@ -81,6 +83,118 @@ async function compiled(
return { buffer: result.buffer, entry: result.entry ?? {} };
}
+/**
+ * Two flat halves, never one solid colour: `prune` deletes a single-colour texture and folds it
+ * into the material factor, which would leave these cases measuring a material and no image.
+ */
+const WEBP_HALVES = [
+ [200, 130, 40],
+ [20, 60, 220],
+] as const;
+
+/** A lossless WebP of the requested size, encoded by the decoder the pass uses. */
+async function tinyWebp(size = 4): Promise {
+ const sharp = (await import("sharp")).default;
+ const pixels = Buffer.alloc(size * size * 4);
+ for (let index = 0; index < size * size; index += 1) {
+ pixels.set([...WEBP_HALVES[index % size < size / 2 ? 0 : 1], 255], index * 4);
+ }
+ return sharp(pixels, { raw: { channels: 4, height: size, width: size } })
+ .webp({ lossless: true })
+ .toBuffer();
+}
+
+/**
+ * The shared fixture with its base colour replaced by an `EXT_texture_webp` image, which is what
+ * a Fab or McD export ships: the extension declared and required, the payload in a WebP.
+ */
+async function fixtureWithWebp(size?: number): Promise {
+ const document = buildFixtureDocument();
+ document.createExtension(EXTTextureWebP).setRequired(true);
+ document
+ .getRoot()
+ .listTextures()[0]
+ ?.setImage(await tinyWebp(size))
+ .setMimeType("image/webp");
+ // The writer only emits the extensions it was told about, and an undeclared `image/webp`
+ // would be the invalid document this fixture exists to avoid.
+ return Buffer.from(await new NodeIO().registerExtensions(ALL_EXTENSIONS).writeBinary(document));
+}
+
+interface IBasisModule {
+ readonly KTX2File: new (
+ bytes: Uint8Array,
+ ) => {
+ close(): void;
+ getHeight(): number;
+ getImageTranscodedSizeInBytes(mip: number, layer: number, face: number, format: number): number;
+ getWidth(): number;
+ isValid(): boolean;
+ startTranscoding(): boolean;
+ transcodeImage(
+ dst: Uint8Array,
+ mip: number,
+ layer: number,
+ face: number,
+ format: number,
+ getAlphaForOpaqueFormats: number,
+ channel0: number,
+ channel1: number,
+ ): boolean;
+ };
+ readonly initializeBasis: () => void;
+ readonly transcoder_texture_format: Readonly>;
+}
+
+/**
+ * Mip 0 of a KTX2 as RGBA8, decoded by the same `basis_transcoder` the runtime ships. Three
+ * publishes it CommonJS inside an ESM package, so it is evaluated here the way its own wrapper
+ * does — the alternative is trusting the container header instead of the pixels.
+ */
+async function transcodeFirstLevel(ktx2: Uint8Array): Promise {
+ const paths = basisTranscoderPaths();
+ const directory = path.dirname(paths.javascriptPath);
+ const source = await readFile(paths.javascriptPath, "utf8");
+ const shim: { exports: unknown } = { exports: {} };
+ new Function("module", "exports", "require", "__filename", "__dirname", source)(
+ shim,
+ shim.exports,
+ createRequire(import.meta.url),
+ paths.javascriptPath,
+ directory,
+ );
+ const factory = shim.exports as (options: {
+ locateFile: (file: string) => string;
+ }) => Promise;
+ const basis = await factory({ locateFile: (file) => path.join(directory, file) });
+ basis.initializeBasis();
+ const file = new basis.KTX2File(ktx2);
+ // The emscripten bindings report booleans as numbers, so both calls are judged on truth.
+ expect(file.isValid()).toBeTruthy();
+ expect(file.startTranscoding()).toBeTruthy();
+ // The WASM enum's numbering is not three's TranscoderFormat map, so the shipped enum names it.
+ const rgba32 = basis.transcoder_texture_format.cTFRGBA32;
+ if (rgba32 === undefined) throw new Error("the shipped transcoder exposes no cTFRGBA32 format");
+ const format = rgba32.value;
+ const pixels = new Uint8Array(file.getImageTranscodedSizeInBytes(0, 0, 0, format));
+ expect(file.transcodeImage(pixels, 0, 0, 0, format, 0, -1, -1)).toBeTruthy();
+ file.close();
+ return pixels;
+}
+
+/** Every channel within `tolerance` of the source half the pixel belongs to. */
+function expectColours(pixels: Uint8Array, width: number, tolerance: number): void {
+ expect(pixels.length).toBe(width * width * 4);
+ for (let offset = 0; offset < pixels.length; offset += 4) {
+ const half = WEBP_HALVES[(offset / 4) % width < width / 2 ? 0 : 1];
+ for (let channel = 0; channel < 3; channel += 1) {
+ expect(Math.abs((pixels[offset + channel] ?? 0) - (half[channel] ?? 0))).toBeLessThanOrEqual(
+ tolerance,
+ );
+ }
+ }
+}
+
describe("embedded model textures", () => {
it("should transcode every embedded image to KTX2 and declare KHR_texture_basisu", async () => {
const input = await fixtureWithTextures({ width: 32 });
@@ -291,6 +405,141 @@ describe("embedded model textures", () => {
});
});
+describe("embedded EXT_texture_webp", () => {
+ it("should cook a WebP image to KTX2 and drop the extension", async () => {
+ // A named per-slot codec also skips the keep-smaller-source guard: a 4x4 WebP is smaller
+ // than the KTX2 container around it, and the test needs the cook to have run.
+ const { buffer } = await compiled(await fixtureWithWebp(), {
+ textures: { overrides: [{ codec: "uastc", slot: "baseColorTexture" }] },
+ });
+ const json = JSON.parse(buffer.subarray(20, 20 + buffer.readUInt32LE(12)).toString("utf8")) as {
+ extensionsRequired?: string[];
+ extensionsUsed?: string[];
+ };
+ expect(json.extensionsUsed ?? []).not.toContain("EXT_texture_webp");
+ expect(json.extensionsRequired ?? []).not.toContain("EXT_texture_webp");
+ expect(json.extensionsRequired ?? []).toContain("KHR_texture_basisu");
+
+ const cooked = (await readOutput(buffer))
+ .getRoot()
+ .listTextures()
+ .find((texture) => texture.getName() === "checker");
+ expect(cooked?.getMimeType()).toBe("image/ktx2");
+ const image = cooked?.getImage() ?? new Uint8Array();
+ expect(readKTX2(image)).toMatchObject({ pixelHeight: 4, pixelWidth: 4 });
+ // UASTC is lossless for 8-bit RGBA, so the transcoded mip is the source colour: proof the
+ // WebP payload reached the encoder instead of a container that merely looked like one.
+ expectColours(await transcodeFirstLevel(image), 4, 1);
+ });
+
+ it("should cap an over-cap WebP image to PNG on a target with no transcoder", async () => {
+ const root = await makeTempDir("threenative-webp-decoder-free-");
+ await mkdir(path.join(root, "assets"));
+ // 8x8 against a 4-longest-edge cap: `cappedSize` never returns less than one 4x4 block, so
+ // only an over-cap source can reach the PNG the decoder-free path emits.
+ await writeFile(path.join(root, "assets", "prop.glb"), await fixtureWithWebp(8));
+
+ await compileAssets({
+ config: { models: { sharedImages: false, textures: { maxSize: 4 } } },
+ cwd: root,
+ platform: "android",
+ });
+
+ const manifest = JSON.parse(
+ await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"),
+ ) as { entries: Record> };
+ const entry = manifest.entries["prop.glb"];
+ if (entry === undefined || typeof entry.output !== "string") {
+ throw new Error("no manifest entry for 'prop.glb'");
+ }
+ expect(entry.extensions ?? []).not.toContain("EXT_texture_webp");
+
+ const cooked = (await readOutput(await readFile(path.join(root, "public", entry.output))))
+ .getRoot()
+ .listTextures()
+ .find((texture) => texture.getName() === "checker");
+ expect(cooked?.getMimeType()).toBe("image/png");
+ const image = Buffer.from(cooked?.getImage() ?? new Uint8Array());
+ expect(parsePng(image)).toMatchObject({ height: 4, width: 4 });
+ // A PNG is lossless, so the resampled colour must be the source one.
+ expectColours(PNG.sync.read(image).data, 4, 1);
+ });
+
+ it("should convert a WebP even when its KTX2 is larger", async () => {
+ // The real-game case: a flat-colour 256x256 WebP costs far fewer bytes than the KTX2
+ // container around it, so the keep-smaller-source guard retained the original and the
+ // cooked model still required `EXT_texture_webp` — which the decoder-free native targets
+ // cannot read. A container only an extension-reading loader understands is never kept.
+ const authored = (await readOutput(await fixtureWithWebp(256)))
+ .getRoot()
+ .listTextures()
+ .find((texture) => texture.getName() === "checker")
+ ?.getImage()?.byteLength;
+
+ const { buffer, entry } = await compiled(await fixtureWithWebp(256));
+ const summary = entry.embeddedTextures as
+ | {
+ readonly bytesAfter: number;
+ readonly bytesBefore: number;
+ readonly formats: Readonly>;
+ readonly skippedCompression: Readonly>;
+ }
+ | undefined;
+
+ // Proof the not-smaller guard really held here: the cook grew the payload, and the row it
+ // grew is the one the size report already prints.
+ expect(summary?.bytesAfter ?? 0).toBeGreaterThan(summary?.bytesBefore ?? 0);
+ expect(summary?.skippedCompression?.checker).toBeUndefined();
+ expect(summary?.formats.checker).toBe("etc1s");
+
+ const json = JSON.parse(buffer.subarray(20, 20 + buffer.readUInt32LE(12)).toString("utf8")) as {
+ extensionsRequired?: string[];
+ extensionsUsed?: string[];
+ images: { mimeType?: string }[];
+ };
+ expect(json.images.some((image) => image.mimeType === "image/webp")).toBe(false);
+ expect(json.extensionsUsed ?? []).not.toContain("EXT_texture_webp");
+ expect(json.extensionsRequired ?? []).not.toContain("EXT_texture_webp");
+ expect(json.extensionsRequired ?? []).toContain("KHR_texture_basisu");
+ // The image itself, not just the extension name, got bigger: that is the cost this test
+ // buys, and it is the number a reader of the build report sees.
+ const cooked = (await readOutput(buffer))
+ .getRoot()
+ .listTextures()
+ .find((texture) => texture.getName() === "checker");
+ expect(cooked?.getMimeType()).toBe("image/ktx2");
+ expect(cooked?.getImage()?.byteLength ?? 0).toBeGreaterThan(authored ?? 0);
+ });
+
+ it("should still keep a PNG whose KTX2 would be larger", async () => {
+ // The other half of the same rule, and the reason it is a container test rather than a size
+ // test: PNG and JPEG need no extension, so retaining them costs a reader nothing.
+ const { buffer, entry } = await compiled(await fixtureWithTextures({ width: 4 }));
+ const summary = entry.embeddedTextures as {
+ readonly skippedCompression: Readonly>;
+ };
+ expect(summary.skippedCompression.checker).toBe("not-smaller");
+ for (const texture of (await readOutput(buffer)).getRoot().listTextures()) {
+ expect(texture.getMimeType()).toBe("image/png");
+ }
+ });
+
+ it("should still fail closed on a container that is not an image", async () => {
+ const document = buildFixtureDocument();
+ document
+ .getRoot()
+ .listTextures()[0]
+ ?.setImage(Buffer.from("GIF89a not an image at all"))
+ .setMimeType("image/png");
+ const input = Buffer.from(await new NodeIO().writeBinary(document));
+ await expect(
+ apply(input, {
+ passes: { dedup: false, meshopt: false, prune: false, quantize: false, reorder: false },
+ }),
+ ).rejects.toThrow(/TN_ASSETS_MODEL_TEXTURE_UNDECODABLE.*TN_ASSETS_TEXTURE_CONTAINER/su);
+ });
+});
+
describe("mesh simplification", () => {
it("should leave triangles untouched unless simplification is declared", async () => {
const input = await fixtureWithTextures({ width: 32 });
diff --git a/packages/assets/__tests__/report.spec.ts b/packages/assets/__tests__/report.spec.ts
index 3ca9225f91..854cc93602 100644
--- a/packages/assets/__tests__/report.spec.ts
+++ b/packages/assets/__tests__/report.spec.ts
@@ -121,6 +121,14 @@ describe("formatModelSizes", () => {
});
describe("formatSkippedCompression", () => {
+ it("reports missing decoders without inferring that the selected engine has no WASM", () => {
+ const lines = formatSkippedCompression([
+ { kind: "texture", files: 1, bytes: 128, reason: "platform" },
+ ]);
+ expect(lines[0]).toContain("no qualified KTX2 decoder");
+ expect(lines[0]).not.toContain("no WebAssembly");
+ });
+
it("names the decoder capability that a target skipped without blaming dedupe", () => {
const rows: readonly ISkippedCompressionRow[] = [
{ bytes: 240, files: 2, kind: "model", reason: "platform" },
diff --git a/packages/assets/__tests__/shared-images.spec.ts b/packages/assets/__tests__/shared-images.spec.ts
index 0ad1f1ad55..ff65852103 100644
--- a/packages/assets/__tests__/shared-images.spec.ts
+++ b/packages/assets/__tests__/shared-images.spec.ts
@@ -214,7 +214,12 @@ describe("shared model images", () => {
sharedImageKey(texture.getImage() ?? new Uint8Array(), {
colorSpace: getTextureColorSpace(texture),
slots: [...listTextureSlots(texture)].sort(),
- textures: { keepSmallerSource: true, maxSize: null, overrides: [], quality: null },
+ textures: {
+ keepSmallerSource: "universal-containers",
+ maxSize: null,
+ overrides: [],
+ quality: null,
+ },
}),
);
const store = createSharedImageStore();
diff --git a/packages/assets/__tests__/virtual-dag.spec.ts b/packages/assets/__tests__/virtual-dag.spec.ts
index 8e51dbfcd8..97632d3a65 100644
--- a/packages/assets/__tests__/virtual-dag.spec.ts
+++ b/packages/assets/__tests__/virtual-dag.spec.ts
@@ -41,6 +41,21 @@ function manyShells(count: number): IBody {
return { indices: Uint32Array.from(indices), positions: Float32Array.from(positions) };
}
+/** Disconnected alpha-cutout cards — the shape a Fab foliage primitive is, and why it stalls. */
+function cards(count: number): IBody {
+ const indices: number[] = [];
+ const positions: number[] = [];
+ const perRow = Math.ceil(Math.sqrt(count));
+ for (let card = 0; card < count; card += 1) {
+ const base = positions.length / 3;
+ const x = (card % perRow) * 2;
+ const y = Math.floor(card / perRow) * 2;
+ positions.push(x, y, 0, x + 0.5, y, 0, x + 0.5, y + 0.5, 0, x, y + 0.5, 0);
+ indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
+ }
+ return { indices: Uint32Array.from(indices), positions: Float32Array.from(positions) };
+}
+
function edgeKey(a: number, b: number): number {
return a < b ? a * 4294967296 + b : b * 4294967296 + a;
}
@@ -213,6 +228,19 @@ describe("the cluster DAG", () => {
expect(dag.roots.length).toBeGreaterThan(1);
}, 120_000);
+ it("AC6 red — a body of alpha-cutout cards stalls on its very first fold, at 100%", async () => {
+ const body = cards(400);
+ const dag = await buildClusterDag(body.indices, body.positions);
+
+ // Not the shells' 64-triangle floor: no collapse at all. Every edge of every card is a border
+ // edge, so `LockBorder` holds the whole body still and the only remaining candidates are the
+ // quads' diagonals, which collapse to nothing. PRD-485's Hornbeam foliage: 745,328 triangles
+ // whose DAG therefore reports every level at the source count, and the bake declines it.
+ expect(dag.stopReason).toBe("stalled");
+ expect(dag.groups).toHaveLength(0);
+ for (const level of dag.levels) expect(level.triangleCount).toBe(body.indices.length / 3);
+ }, 120_000);
+
it("refuses a malformed mesh rather than baking nonsense", async () => {
await expect(buildClusterDag(new Uint32Array([0, 1]), new Float32Array(9))).rejects.toThrow(
/non-empty triangle list/,
diff --git a/packages/assets/__tests__/virtual-pass.spec.ts b/packages/assets/__tests__/virtual-pass.spec.ts
index 6380b75ee6..bea7d59913 100644
--- a/packages/assets/__tests__/virtual-pass.spec.ts
+++ b/packages/assets/__tests__/virtual-pass.spec.ts
@@ -1,7 +1,7 @@
import { Document, NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { MeshoptDecoder } from "meshoptimizer";
-import { TorusKnotGeometry } from "three";
+import { SphereGeometry, TorusKnotGeometry } from "three";
import { describe, expect, it } from "vitest";
import { modelPass } from "../src/passes/model.js";
import { formatModelSizes } from "../src/report.js";
@@ -58,6 +58,77 @@ async function sourceGlb(geometry: TorusKnotGeometry): Promise {
return Buffer.from(await io.writeBinary(document));
}
+/**
+ * Twelve closed shells that touch nothing, in one primitive: the body whose DAG halves a dozen
+ * times and then floors out above one cluster, which is the `stalled` the decline must not read as
+ * "reduced nothing" (PRD-485).
+ */
+async function shellsGlb(shellCount: number): Promise {
+ const document = new Document();
+ const buffer = document.createBuffer();
+ const scene = document.createScene();
+ const shell = new SphereGeometry(0.5, 32, 16);
+ const positions: number[] = [];
+ const normals: number[] = [];
+ const indices: number[] = [];
+ const source = shell.attributes.position?.array ?? [];
+ const sourceNormals = shell.attributes.normal?.array ?? [];
+ const shellIndices = Array.from(shell.index?.array ?? []);
+ // A three.js sphere leaves two pole vertices no triangle references, and the pass's self-verify
+ // fails a cook that drops them — the same trap `denseGlb` avoids with a torus knot. Compacting to
+ // the used vertices is the whole fix.
+ const used = [...new Set(shellIndices)].sort((left, right) => left - right);
+ const remap = new Map(used.map((vertex, slot) => [vertex, slot]));
+ for (let count = 0; count < shellCount; count += 1) {
+ const base = positions.length / 3;
+ for (const vertex of used) {
+ const slot = vertex * 3;
+ positions.push(
+ (source[slot] as number) + count * 40,
+ source[slot + 1] as number,
+ source[slot + 2] as number,
+ );
+ normals.push(
+ sourceNormals[slot] as number,
+ sourceNormals[slot + 1] as number,
+ sourceNormals[slot + 2] as number,
+ );
+ }
+ for (const vertex of shellIndices) indices.push(base + (remap.get(vertex) as number));
+ }
+ const primitive = document
+ .createPrimitive()
+ .setAttribute(
+ "POSITION",
+ document
+ .createAccessor()
+ .setType("VEC3")
+ .setArray(Float32Array.from(positions))
+ .setBuffer(buffer),
+ )
+ .setAttribute(
+ "NORMAL",
+ document
+ .createAccessor()
+ .setType("VEC3")
+ .setArray(Float32Array.from(normals))
+ .setBuffer(buffer),
+ )
+ .setIndices(
+ document
+ .createAccessor()
+ .setType("SCALAR")
+ .setArray(Uint32Array.from(indices))
+ .setBuffer(buffer),
+ )
+ .setMaterial(document.createMaterial("stone"));
+ scene.addChild(
+ document.createNode("cavern").setMesh(document.createMesh("cavern").addPrimitive(primitive)),
+ );
+ const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
+ return Buffer.from(await io.writeBinary(document));
+}
+
async function readBack(buffer: Buffer, registerVirtual: boolean): Promise {
await MeshoptDecoder.ready;
const io = new NodeIO()
@@ -83,6 +154,72 @@ async function compile(virtual: boolean): Promise<{ buffer: Buffer; entry: unkno
return { buffer: result.buffer, entry: result.entry };
}
+/**
+ * A canopy of disconnected alpha-cutout cards: what a Fab foliage primitive is, and what the
+ * Hornbeam's two of them are (PRD-485).
+ *
+ * Every card is its own connected component with its own four vertices, so the whole rim is a
+ * border edge and the cluster builder — which simplifies each group with the rim locked — has
+ * nothing it is allowed to collapse. The card count is what puts the far level under a tenth of the
+ * source: the ladder's terminal target is `max(1500, 5% of LOD0)`, and only above 30,000 triangles
+ * is the ratio the binding half of that.
+ */
+async function foliageGlb(cardCount: number): Promise {
+ const document = new Document();
+ const buffer = document.createBuffer();
+ const scene = document.createScene();
+ const positions: number[] = [];
+ const normals: number[] = [];
+ const uvs: number[] = [];
+ const indices: number[] = [];
+ const perRow = Math.ceil(Math.sqrt(cardCount));
+ for (let card = 0; card < cardCount; card += 1) {
+ const base = positions.length / 3;
+ const x = (card % perRow) * 2;
+ const y = Math.floor(card / perRow) * 2;
+ const height = 0.5 + ((card * 37) % 11) / 22;
+ positions.push(x, y, 0, x + height, y, 0, x + height, y + height, 0, x, y + height, 0);
+ normals.push(0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1);
+ uvs.push(0, 0, 1, 0, 1, 1, 0, 1);
+ indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
+ }
+ const primitive = document
+ .createPrimitive()
+ .setAttribute(
+ "POSITION",
+ document
+ .createAccessor()
+ .setType("VEC3")
+ .setArray(Float32Array.from(positions))
+ .setBuffer(buffer),
+ )
+ .setAttribute(
+ "NORMAL",
+ document
+ .createAccessor()
+ .setType("VEC3")
+ .setArray(Float32Array.from(normals))
+ .setBuffer(buffer),
+ )
+ .setAttribute(
+ "TEXCOORD_0",
+ document.createAccessor().setType("VEC2").setArray(Float32Array.from(uvs)).setBuffer(buffer),
+ )
+ .setIndices(
+ document
+ .createAccessor()
+ .setType("SCALAR")
+ .setArray(Uint32Array.from(indices))
+ .setBuffer(buffer),
+ )
+ .setMaterial(document.createMaterial("Foliage").setAlphaMode("MASK").setAlphaCutoff(0.5));
+ scene.addChild(
+ document.createNode("canopy").setMesh(document.createMesh("canopy").addPrimitive(primitive)),
+ );
+ const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
+ return Buffer.from(await io.writeBinary(document));
+}
+
describe("the virtual-geometry pass", () => {
it('bakes nothing when the config says "none"', async () => {
const { buffer, entry } = await compile(false);
@@ -219,6 +356,84 @@ describe("the virtual-geometry pass", () => {
expect(summary.skipped).toBe(1);
});
+ it("hands cards it cannot reduce to the discrete ladder, which does reduce them", async () => {
+ // PRD-485. A body of disconnected alpha-cutout cards is the one shape the cluster builder
+ // refuses: every edge of every card is a border, `LockBorder` holds all of it, and the first
+ // fold simplifies nothing — so a stalled DAG reports 100% of the triangles at every level and
+ // owns 745,328 Hornbeam foliage triangles with no coarse representation at all. The bake hands
+ // that primitive back and the ladder gives it the far level the DAG could not.
+ const result = await modelPass({
+ lod: true,
+ virtual: { minSourceTriangles: 1024 },
+ }).apply(await foliageGlb(16384), "canopy.glb");
+ if (Buffer.isBuffer(result)) throw new Error("model pass returned an unchanged buffer");
+
+ expect(jsonOf(result.buffer).extensionsUsed ?? []).not.toContain("TN_virtual_geometry");
+ const virtual = (
+ result.entry as {
+ virtual: { clusters: number; declined: number; payloadBytes: number; primitives: number };
+ }
+ ).virtual;
+ expect(virtual).toMatchObject({ clusters: 0, declined: 1, payloadBytes: 0, primitives: 0 });
+
+ const lod = (
+ result.entry as {
+ lod: {
+ primitives: {
+ cardLevels: { cellCoverage: number }[];
+ levels: { absoluteError: number; triangles: number }[];
+ strategy: string;
+ trianglesBefore: number;
+ }[];
+ reasons: string[];
+ skipped: number;
+ };
+ }
+ ).lod;
+ // Not `virtual-owned` any more, and not skipped for anything else either.
+ expect(lod.reasons).not.toContain("virtual-owned");
+ expect(lod.skipped).toBe(0);
+ const chain = lod.primitives[0];
+ if (chain === undefined) throw new Error("the declined cards got no chain at all");
+ expect(chain.strategy).toBe("cards");
+
+ const far = chain.levels[chain.levels.length - 1]?.triangles ?? 0;
+ expect(far).toBeGreaterThan(0);
+ expect(far / chain.trianglesBefore).toBeLessThanOrEqual(0.1);
+ // The crown keeps a card in every occupied cell at every level, and each level's error is
+ // wider than the one before it — the two properties the runtime's distance switch reads.
+ for (const level of chain.cardLevels ?? []) expect(level.cellCoverage).toBe(1);
+ for (let level = 1; level < chain.levels.length; level += 1)
+ expect(chain.levels[level]?.absoluteError).toBeGreaterThanOrEqual(
+ chain.levels[level - 1]?.absoluteError ?? 0,
+ );
+
+ // And the cook still decides nothing about how it looks: the alpha cutoff the asset was
+ // authored with is the one that ships.
+ const material = (
+ JSON.parse(
+ result.buffer.subarray(20, 20 + result.buffer.readUInt32LE(12)).toString("utf8"),
+ ) as { materials?: { alphaCutoff?: number; alphaMode?: string }[] }
+ ).materials?.[0];
+ expect(material).toMatchObject({ alphaCutoff: 0.5, alphaMode: "MASK" });
+ }, 300_000);
+
+ it("keeps a DAG that stalled only after it had already shed its triangles", async () => {
+ // The decline is measured, not `stalled => gone`: twelve closed shells cannot become one
+ // cluster — meshoptimizer will not take a shell below about 64 triangles — but the DAG has
+ // halved several times before it floors out, and that payload is worth keeping.
+ const result = await modelPass({ virtual: { minSourceTriangles: 1024 } }).apply(
+ await shellsGlb(12),
+ "cavern.glb",
+ );
+ if (Buffer.isBuffer(result)) throw new Error("model pass returned an unchanged buffer");
+
+ expect(
+ (result.entry as { virtual: { declined: number; primitives: number; stopReason: string } })
+ .virtual,
+ ).toMatchObject({ declined: 0, primitives: 1, stopReason: "stalled" });
+ }, 600_000);
+
it("AC8 — a reader that has never heard of the extension gets the source mesh", async () => {
const { buffer } = await compile(true);
const stock = await readBack(buffer, false);
@@ -264,6 +479,7 @@ describe("the virtual-geometry pass", () => {
virtual: {
bakeSeconds: 12.34,
clusters: 512,
+ declined: 0,
levels: 6,
payloadBytes: 4096,
primitives: 1,
@@ -287,6 +503,7 @@ describe("the virtual-geometry pass", () => {
virtual: {
bakeSeconds: 1,
clusters: 8,
+ declined: 0,
levels: 2,
payloadBytes: 64,
primitives: 1,
@@ -298,4 +515,28 @@ describe("the virtual-geometry pass", () => {
expect(lines.join("\n")).toContain("a DAG hit the level cap and is unfinished");
});
+
+ it("AC7 — the report names the primitives it handed to the LOD ladder", () => {
+ const lines = formatModelSizes([
+ {
+ after: 900,
+ before: 1000,
+ logicalPath: "canopy.glb",
+ virtual: {
+ bakeSeconds: 0.1,
+ clusters: 0,
+ declined: 2,
+ levels: 0,
+ payloadBytes: 0,
+ primitives: 0,
+ skipped: 1,
+ stopReason: "root",
+ },
+ },
+ ]);
+
+ // "no primitive was dense enough" would be the wrong reason for a body whose cards were dense
+ // enough and whose DAG reduced nothing.
+ expect(lines.join("\n")).toContain("1 skipped, 2 declined to the LOD ladder");
+ });
});
diff --git a/packages/assets/src/compile.ts b/packages/assets/src/compile.ts
index bcd6e90daf..b3c71c7094 100644
--- a/packages/assets/src/compile.ts
+++ b/packages/assets/src/compile.ts
@@ -265,31 +265,16 @@ export interface IAssetCompileOptions {
*/
readonly processingOrder?: "reversed" | "sorted";
/**
- * The platform this bake is for, which decides whether compression can ship at all.
- *
- * Android and iOS run the native host without WebAssembly, so they carry no Basis transcoder
- * and no Meshopt decoder: a `.ktx2` texture or a meshopt-compressed mesh in a mobile bundle is
- * a black screen, and `threenative build` refuses one with `TN_NATIVE_KTX2_UNSUPPORTED`. Web
- * and desktop decode both.
- *
- * Absent means web — a direct `compileAssets` call, or a project that compiles once and serves
- * the result. `threenative build` always names its `--target`, so the passes that a platform
- * cannot decode drop for that build and stay on for every other one. This is the whole reason
- * `assets.textures: "none"` used to be pinned in the scaffolded config: the author was asked to
- * choose one constant for four targets, and every game that wanted Android shipped its web
- * build uncompressed too. The build knows its target; it decides.
+ * The conservative default decoder policy: web/desktop keep the existing loaders;
+ * Android/iOS stay decoder-free until their packaged loaders are qualified. A V8 engine
+ * alone does not establish a working Basis, Meshopt or Draco path. Absent means web.
+ * Native builds provide runtimeDecoders from their selected artifact instead.
*/
readonly platform?: "android" | "desktop" | "ios" | "web";
- /**
- * What the runtime that will execute this bake can decode, when the caller has probed it.
- *
- * `platform` is a proxy: it answers "android and iOS have no WebAssembly" for the targets this
- * repository ships. A desktop host can also lack WebAssembly — the Linux arm64 lane builds
- * QuickJS over wgpu-native — and then the same meshopt/KTX2 passes it would keep produce an
- * asset nothing can decode. `threenative build` reads the engine from the runtime binary it will
- * package and passes the real capability here; absent, the platform-derived default stands.
- */
+ /** Which compressed outputs the selected runtime's bundle can decode, independently of WASM. */
readonly runtimeDecoders?: IAssetRuntimeDecoderCapabilities;
+ /** Exact selected runtime/cohort identity; changing it invalidates cooked output. */
+ readonly runtimeIdentity?: string;
readonly source?: string;
/** Overrides resolution of three's Basis transcoder for the copy into the output root. */
readonly transcoder?: IBasisTranscoder;
@@ -1594,9 +1579,8 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo
`TN_ASSETS_OVERLAP: source '${sourceRoot}' and output '${outputRoot}' must be disjoint directories.`,
);
}
- // Android and iOS have no WebAssembly and therefore no Basis transcoder and no Meshopt
- // decoder. The registry uses each pass's declaration below, so decoder-free work in the mixed
- // model pass survives on those targets.
+ // Mobile keeps the decoder-free policy until its packaged loaders are qualified.
+ // Each pass declares its decoder dependency, so decoder-free model work survives.
const runtimeDecoderCapabilities: IAssetRuntimeDecoderCapabilities = options.runtimeDecoders ?? {
ktx2: options.platform !== "android" && options.platform !== "ios",
meshopt: options.platform !== "android" && options.platform !== "ios",
@@ -2534,6 +2518,14 @@ export async function compileAssets(
const cacheKeys = included.map((pass) => pass.cacheKey ?? null);
return JSON.stringify({
...(cacheKeys.some((key) => key !== null) ? { passCacheKeys: cacheKeys } : {}),
+ ...(options.runtimeIdentity === undefined && options.runtimeDecoders === undefined
+ ? {}
+ : {
+ runtime: {
+ identity: options.runtimeIdentity ?? null,
+ decoders: layout.runtimeDecoderCapabilities,
+ },
+ }),
pipelineVersion: PIPELINE_VERSION,
passes: included.map((pass) => pass.name),
options: included.map((pass) => pass.configuration ?? null),
diff --git a/packages/assets/src/lod/eligibility.ts b/packages/assets/src/lod/eligibility.ts
index f3790f9cf3..3cbf54077d 100644
--- a/packages/assets/src/lod/eligibility.ts
+++ b/packages/assets/src/lod/eligibility.ts
@@ -4,7 +4,13 @@
// structurally intact is declined with a named reason rather than forced to a triangle target. An
// inability to reduce safely is a normal skip, not a failure.
-import type { Material, Primitive } from "@gltf-transform/core";
+import {
+ type Document,
+ type Material,
+ type Mesh,
+ Node,
+ type Primitive,
+} from "@gltf-transform/core";
/** The stable reason codes reported for a primitive that gets no automatic discrete chain. */
export type DiscreteLodSkipReason =
@@ -55,6 +61,37 @@ function sharedByLevels(semantic: string): boolean {
const DEFORMING_ATTRIBUTES: ReadonlySet = new Set(["JOINTS_0", "WEIGHTS_0"]);
+/**
+ * Whether a primitive is deforming: skinned, morph-targeted, or carrying per-vertex joint data.
+ *
+ * The one definition of the word, shared by every rule that declines to rewrite geometry a rig
+ * drives — no discrete chain, no joined far rung, and no vertex reordering in the cook. A vertex
+ * index into deforming geometry is an address something outside the file may already hold, and a
+ * rig is where index-addressed data is most likely to come from: a MetaHuman sidecar records the
+ * head vertex each brow strand root rides and reads its position back every frame.
+ */
+export function deforming(primitive: Primitive, skinned: boolean): boolean {
+ if (skinned || primitive.listTargets().length > 0) return true;
+ return primitive.listSemantics().some((semantic) => DEFORMING_ATTRIBUTES.has(semantic));
+}
+
+/** The mesh-level half of {@link deforming}: the node compensation quantization applies is per mesh,
+ * so one deforming primitive is enough to hold every position the mesh carries. */
+export function deformingMesh(mesh: Mesh, skinned: ReadonlySet): boolean {
+ const isSkinned = skinned.has(mesh);
+ return isSkinned || mesh.listPrimitives().some((primitive) => deforming(primitive, isSkinned));
+}
+
+/** The meshes a skin is bound to, shared by every rule that asks whether a rig drives a mesh. */
+export function skinnedMeshes(document: Document): Set {
+ const skinned = new Set();
+ for (const node of document.getRoot().listNodes()) {
+ const mesh = node.getMesh();
+ if (mesh !== null && node.getSkin() !== null) skinned.add(mesh);
+ }
+ return skinned;
+}
+
const TRIANGLES_MODE = 4;
const TRIANGLE = 3;
diff --git a/packages/assets/src/passes/decode-image.ts b/packages/assets/src/passes/decode-image.ts
index b9b6ce00b4..c14dc1bc09 100644
--- a/packages/assets/src/passes/decode-image.ts
+++ b/packages/assets/src/passes/decode-image.ts
@@ -3,9 +3,10 @@ import { parsePng } from "../png.js";
/**
* Decodes an encoded image's bytes to tight 8-bit RGBA for the Basis encoder, which takes
- * decoded pixels through its Node-side `imageDecoder` hook. Pure-JS decoders on purpose:
- * this package is Node-only and must never grow a native dependency. Containers neither
- * decoder reads throw — a source this step cannot encode fails the build naming the file,
+ * decoded pixels through its Node-side `imageDecoder` hook. PNG and JPEG are pure-JS decoders
+ * already in the dependency tree; WebP is not, so it goes through `sharp` — declared but
+ * previously unused, native, and confined to this build-time package (PRD-485). Containers none
+ * of the three reads throw: a source this step cannot encode fails the build naming the file,
* never silently ships uncompressed.
*/
export async function decodeImageBytes(
@@ -23,8 +24,28 @@ export async function decodeImageBytes(
assertRgba(image.data.length, image.width, image.height, logicalPath);
return { data: new Uint8Array(image.data), height: image.height, width: image.width };
}
+ // RIFF container, `WEBP` form type — what an EXT_texture_webp image carries out of Fab.
+ if (
+ bytes.length >= 12 &&
+ bytes.toString("ascii", 0, 4) === "RIFF" &&
+ bytes.toString("ascii", 8, 12) === "WEBP"
+ ) {
+ const sharp = (await import("sharp")).default;
+ try {
+ const { data, info } = await sharp(bytes)
+ .ensureAlpha()
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+ assertRgba(data.length, info.width, info.height, logicalPath);
+ return { data, height: info.height, width: info.width };
+ } catch (error) {
+ throw new Error(
+ `TN_ASSETS_TEXTURE_UNREADABLE: '${logicalPath}' is a WebP the decoder could not read: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
throw new Error(
- `TN_ASSETS_TEXTURE_CONTAINER: '${logicalPath}' is not a PNG or JPEG the KTX2 encoder can read; convert it to .png or .jpg.`,
+ `TN_ASSETS_TEXTURE_CONTAINER: '${logicalPath}' is not a PNG, JPEG or WebP the KTX2 encoder can read; convert it to .png or .jpg.`,
);
}
diff --git a/packages/assets/src/passes/model-textures.ts b/packages/assets/src/passes/model-textures.ts
index 07fb3d808b..c52e7b249c 100644
--- a/packages/assets/src/passes/model-textures.ts
+++ b/packages/assets/src/passes/model-textures.ts
@@ -1,5 +1,5 @@
import type { Document, Texture } from "@gltf-transform/core";
-import { KHRTextureBasisu } from "@gltf-transform/extensions";
+import { EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions";
import { getTextureColorSpace, listTextureInfo, listTextureSlots } from "@gltf-transform/functions";
import { read as readKTX2 } from "ktx-parse";
import { PNG } from "pngjs";
@@ -104,6 +104,14 @@ const GPU_BYTES_PER_PIXEL: Readonly> = {
/** Slots whose content is a tangent-space normal: data, and ETC1S destroys it. */
const NORMAL_SLOTS: ReadonlySet = new Set(["clearcoatNormalTexture", "normalTexture"]);
+/**
+ * Containers every target of this build decodes on its own, so keeping the authored bytes costs a
+ * reader nothing. `image/webp` is deliberately absent: it is only reachable through
+ * `EXT_texture_webp`, which a target without a WebP decoder cannot satisfy — retaining one would
+ * hand back a model the very requirement this stage removes.
+ */
+const UNIVERSAL_IMAGE_CONTAINERS: ReadonlySet = new Set(["image/jpeg", "image/png"]);
+
/** The five slots every glTF material declares directly; extension slots are covered by
* the per-texture `slots` snapshot, which walks whatever parents actually reference it. */
const CORE_SLOTS = [
@@ -482,23 +490,27 @@ export async function compressEmbeddedTextures(
}
if (decoderFree) {
// No Basis transcoder on this target, so the cap is honoured by resampling and re-emitting
- // a PNG. An image already within the cap keeps its authored bytes; the file is never
- // rewritten and KHR_texture_basisu is never declared.
+ // a PNG. An image already within the cap keeps its authored bytes — unless its container is
+ // one only an extension-reading loader understands, where keeping them would ship the very
+ // requirement this path exists to remove. KHR_texture_basisu is never declared.
const target = cappedSize(decoded.width, decoded.height, maxSize);
- if (target.width === decoded.width && target.height === decoded.height) {
+ const downsampled = target.width !== decoded.width || target.height !== decoded.height;
+ if (!downsampled && UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType())) {
bytesAfter += image.byteLength;
gpuBytesAfter += gpuBytes(decoded.width, decoded.height, "none");
formats[key] = "none";
continue;
}
- const data = resampleRgba(
- decoded.data,
- decoded.width,
- decoded.height,
- target.width,
- target.height,
- getTextureColorSpace(texture) === "srgb",
- );
+ const data = downsampled
+ ? resampleRgba(
+ decoded.data,
+ decoded.width,
+ decoded.height,
+ target.width,
+ target.height,
+ getTextureColorSpace(texture) === "srgb",
+ )
+ : decoded.data;
const png = new PNG({ height: target.height, width: target.width });
png.data = Buffer.from(data);
const encoded = PNG.sync.write(png);
@@ -506,7 +518,7 @@ export async function compressEmbeddedTextures(
bytesAfter += encoded.byteLength;
gpuBytesAfter += gpuBytes(target.width, target.height, "none");
formats[key] = "none";
- resized += 1;
+ if (downsampled) resized += 1;
continue;
}
@@ -552,12 +564,16 @@ export async function compressEmbeddedTextures(
: { isUASTC: false, qualityLevel: quality }),
});
// A tiny source can cost less than the KTX2 container alone. Keep its exact bytes when
- // encoding cannot save download bytes; a named codec override still requests GPU compression.
+ // encoding cannot save download bytes *and* the authored container needs no extension to be
+ // read; a WebP costs a decoder-free target the whole model, which is never the smaller cost.
+ // A named codec override still requests GPU compression either way. The size increase is
+ // reported by the existing `bytes before -> after` row of the build report.
if (
encoded.byteLength >= image.byteLength &&
target.width === decoded.width &&
target.height === decoded.height &&
- !explicit
+ !explicit &&
+ UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType())
) {
bytesAfter += image.byteLength;
gpuBytesAfter += gpuBytes(decoded.width, decoded.height, "none");
@@ -583,6 +599,13 @@ export async function compressEmbeddedTextures(
// and a glTF that pretends otherwise fails at the first frame instead of at load.
document.createExtension(KHRTextureBasisu).setRequired(true);
}
+ // A glTF writer emits every extension still attached, so a model authored with WebP images
+ // would keep *requiring* EXT_texture_webp after the cook replaced those images. Dropped only
+ // once no image is left in the container: an over-cap image the project chose to keep as
+ // authored is still a WebP a reader has to understand.
+ if (!textures.some((texture) => texture.getMimeType() === "image/webp")) {
+ document.disposeExtension(EXTTextureWebP.EXTENSION_NAME);
+ }
return {
bytesAfter,
bytesBefore,
diff --git a/packages/assets/src/passes/model.ts b/packages/assets/src/passes/model.ts
index 12e3e5003e..15bae8175b 100644
--- a/packages/assets/src/passes/model.ts
+++ b/packages/assets/src/passes/model.ts
@@ -4,6 +4,7 @@ import {
Accessor,
type Document,
type GLTF,
+ type Mesh as GltfMesh,
type Node as GltfNode,
type Primitive as GltfPrimitive,
NodeIO,
@@ -18,7 +19,6 @@ import {
listTextureSlots,
prune,
quantize,
- reorder,
simplify,
} from "@gltf-transform/functions";
import { MeshoptEncoder, MeshoptSimplifier } from "meshoptimizer";
@@ -31,6 +31,7 @@ import {
import { type IMaterialMergeSummary, mergeIdenticalMaterials } from "../foliage.js";
import { createGltfReader, readGltfDocument } from "../gltf-io.js";
import { KTX2_ENCODER_VERSION } from "../ktx2-encoder.js";
+import { deformingMesh, skinnedMeshes } from "../lod/eligibility.js";
import { TNDiscreteLod } from "../lod/extension.js";
import {
type IModelLodOptions,
@@ -71,6 +72,8 @@ import {
textureBindings,
textureKeys,
} from "./model-textures.js";
+import { quantizeStaticGeometry } from "./quantize-static.js";
+import { reorderStaticPrimitives } from "./reorder-static.js";
import {
type ISharedImage,
type ISharedImageStore,
@@ -243,6 +246,19 @@ const MATERIAL_MERGE_VERSION = 1;
* which the compile digest already hashes, so it invalidates model outputs and nothing else.
*/
const MODEL_DEDUPE_VERSION = 1;
+/**
+ * Bumped when `reorder-static.ts` changes what it emits: every model cooked before this fix had its
+ * deforming geometry renumbered, and a warm cache entry keyed on the pass switches alone would keep
+ * serving those files. 1 = deforming primitives keep their authored vertex order and count.
+ */
+const MODEL_REORDER_VERSION = 1;
+/**
+ * Bumped when `quantize-static.ts` changes what it emits: every model cooked before this fix had its
+ * deforming positions snapped onto a quantization grid, and a warm cache entry keyed on the pass
+ * switches alone would keep serving those files. 1 = deforming POSITION and morph-target deltas
+ * stay float32 in the source metre space.
+ */
+const MODEL_QUANTIZE_VERSION = 1;
/** Relative bounding-box tolerance of the self-verify check (PRD: 0.1%). */
const BBOX_TOLERANCE = 0.001;
@@ -704,11 +720,14 @@ export function assertSimplifiedWithinBounds(
* Snaps every scene-reachable POSITION accessor onto a uniform `2^bits` grid spanning its
* own bounds — the destructive low-precision quantization the underlying library refuses
* to express. Used only for configured depths below its 8-bit floor; the self-verify is
- * expected to reject the result.
+ * expected to reject the result. Deforming meshes are skipped for the same reason the
+ * quantization stage skips them: a grid that coarse moves vertices a sidecar already
+ * addresses, and holding them is the rule rather than the bit depth.
*/
-function snapPositions(root: RootOf, bits: number): void {
+function snapPositions(root: RootOf, bits: number, skinned: ReadonlySet): void {
const levels = 2 ** bits - 1;
for (const mesh of root.listMeshes()) {
+ if (deformingMesh(mesh, skinned)) continue;
for (const primitive of mesh.listPrimitives()) {
const position = primitive.getAttribute("POSITION");
if (position === null || position.getNormalized()) continue;
@@ -803,6 +822,12 @@ export function modelPass(options: IModelPassOptions = {}): IAssetPass {
// content-identical sources, so a bake published before this key existed named one output
// per copy and must not be re-served as if it had deduped.
dedupe: MODEL_DEDUPE_VERSION,
+ // Same reason: which primitives `reorder` declines to renumber is part of the output, and a
+ // warm entry from before it declined would hand a game geometry its sidecars cannot address.
+ reorderVersion: MODEL_REORDER_VERSION,
+ // Same reason again: which positions keep their source metres is part of the output, and a
+ // warm entry from before they did would hand a game geometry its sidecars cannot address.
+ quantizeVersion: MODEL_QUANTIZE_VERSION,
// `"none"` and "absent" are different cache keys on purpose: absent bakes with defaults.
virtual:
options.virtual === "none"
@@ -819,7 +844,10 @@ export function modelPass(options: IModelPassOptions = {}): IAssetPass {
decoderFree: options.textures?.decoderFree ?? false,
encoder: KTX2_ENCODER_VERSION,
maxSize: options.textures?.maxSize ?? null,
- keepSmallerSource: true,
+ // Retention is now conditioned on the container, so it is spelled out rather than
+ // named by a boolean: a warm cache keyed on the old rule would keep shipping the
+ // WebP-requiring models this replaced.
+ keepSmallerSource: "universal-containers",
overrides: options.textures?.overrides ?? [],
quality: options.textures?.quality ?? null,
},
@@ -919,7 +947,10 @@ export function modelPass(options: IModelPassOptions = {}): IAssetPass {
})(document);
}
if (enabled.reorder || enabled.meshopt) await MeshoptEncoder.ready;
- if (enabled.reorder) await reorder({ encoder: MeshoptEncoder })(document);
+ // The last stage that moves a vertex, and the only one that does so selectively: deforming
+ // geometry keeps its authored vertex order because a vertex index into it is an address
+ // something outside this file may already hold. `reorder-static.ts`.
+ if (enabled.reorder) reorderStaticPrimitives(document, MeshoptEncoder);
// After `reorder`, which is the last stage that moves a vertex, and before `quantize`, which
// changes what a position is but never which vertex it is.
const virtual =
@@ -942,18 +973,23 @@ export function modelPass(options: IModelPassOptions = {}): IAssetPass {
// Depths below the library's 8-bit floor are honoured by pre-rounding the floats
// onto the coarser grid first; the self-verify then fails the build on the drift.
const requestedBits = options.quantize?.positionBits ?? DEFAULT_POSITION_BITS;
- if (requestedBits < 8) snapPositions(document.getRoot(), requestedBits);
+ if (requestedBits < 8)
+ snapPositions(document.getRoot(), requestedBits, skinnedMeshes(document));
// Joint indices and weights are excluded outright: quantizing them below the
// exporter's declaration is where skinned meshes visibly break, and the library's
// own default narrows weights to 8 bits unless told otherwise.
- await quantize({
- quantizeColor: 8,
- quantizeNormal: options.quantize?.normalBits ?? DEFAULT_NORMAL_BITS,
- quantizePosition: Math.max(requestedBits, 8),
- quantizeTexcoord: options.quantize?.uvBits ?? DEFAULT_UV_BITS,
- pattern: /^(?!JOINTS|WEIGHTS)/u,
- normalizeWeights: false,
- })(document);
+ // Deforming geometry keeps float32 POSITION in the source metre space on top of
+ // that, so nothing a sidecar addresses moves: `quantize-static.ts`.
+ await quantizeStaticGeometry(document, () =>
+ quantize({
+ quantizeColor: 8,
+ quantizeNormal: options.quantize?.normalBits ?? DEFAULT_NORMAL_BITS,
+ quantizePosition: Math.max(requestedBits, 8),
+ quantizeTexcoord: options.quantize?.uvBits ?? DEFAULT_UV_BITS,
+ pattern: /^(?!JOINTS|WEIGHTS)/u,
+ normalizeWeights: false,
+ })(document),
+ );
}
// Taken after every geometry stage and before compression, so what it proves is that
// *this* stage plus the writer preserved each binding — prune's documented drops of
@@ -1079,7 +1115,7 @@ function sharedSettings(
: {
decoderFree: textureOptions.decoderFree ?? false,
encoder: KTX2_ENCODER_VERSION,
- keepSmallerSource: true,
+ keepSmallerSource: "universal-containers",
maxSize: textureOptions.maxSize ?? null,
overrides: textureOptions.overrides ?? [],
quality: textureOptions.quality ?? null,
diff --git a/packages/assets/src/passes/quantize-static.ts b/packages/assets/src/passes/quantize-static.ts
new file mode 100644
index 0000000000..487454044d
--- /dev/null
+++ b/packages/assets/src/passes/quantize-static.ts
@@ -0,0 +1,160 @@
+// The model pass's `quantize` stage, over the primitives whose positions nothing else may move.
+//
+// `quantize` from `@gltf-transform/functions` walks the whole document and rewrites POSITION into
+// 16-bit integers snapped to a grid whose step is the mesh's own bounding volume — the largest size
+// win in the cook — and then compensates by folding that shift into the node holding the mesh, the
+// skin's inverse bind matrices, GPU instancing batches and any volumetric material.
+//
+// That is exact for static geometry. For geometry a rig drives it breaks the invariant this repo
+// holds: one metre is one metre. The metahuman-lab's `brows.skin.json` names the head vertex each
+// brow strand root rides and reads its position back every frame, prepared against the source file
+// to within 1e-5 m; a head snapped onto a 16-bit grid over its own volume is micrometres away from
+// every vertex the sidecar recorded, and the shift that would undo it lives in a node transform the
+// sidecar never sees.
+//
+// So a deforming mesh is held in source space across the stage: POSITION and the morph-target
+// deltas riding it keep the exact values their author wrote, and the mesh spends the stage detached
+// from its nodes, so there is no compensation to undo — the skin, the node matrices, the instancing
+// batch and a volume material's thickness are simply never touched. Everything else still quantizes,
+// and `EXT_meshopt_compression` still encodes whatever the accessors hold.
+//
+// ponytail: the library's `quantize` takes one document-wide pattern with no per-mesh switch, and
+// its node compensation is inseparable from the positions it shifts, so holding is its own ~60
+// lines. Replace with the library's own `quantize` the day it takes a deforming predicate.
+
+import type {
+ Accessor,
+ Document,
+ ExtensionProperty,
+ Material,
+ Mesh,
+ Primitive,
+ PrimitiveTarget,
+ TypedArray,
+} from "@gltf-transform/core";
+import { Node } from "@gltf-transform/core";
+import { deformingMesh, skinnedMeshes } from "../lod/eligibility.js";
+
+/** What a primitive and a morph target both expose for one attribute, so one call serves both. */
+type IAttributeOwner = {
+ getAttribute(semantic: string): Accessor | null;
+ setAttribute(semantic: string, accessor: Accessor | null): unknown;
+};
+
+/** One attribute's authored values, copied out before the stage overwrites them. */
+interface IHeldAttribute {
+ readonly array: TypedArray;
+ readonly normalized: boolean;
+}
+
+/** One primitive's authored positions, kept while the stage tries to rewrite them. */
+interface IHeldPrimitive {
+ readonly primitive: Primitive;
+ /** The index buffer as authored: a remap here would renumber what a sidecar addresses. */
+ readonly indices: Accessor | null;
+ readonly position: IHeldAttribute | null;
+ readonly targets: readonly {
+ readonly target: PrimitiveTarget;
+ readonly position: IHeldAttribute | null;
+ }[];
+}
+
+/** One deforming mesh: how it was held, and how to put it back. */
+interface IHeldMesh {
+ readonly mesh: Mesh;
+ readonly nodes: readonly Node[];
+ readonly primitives: readonly IHeldPrimitive[];
+ readonly volumes: readonly { readonly material: Material; readonly volume: ExtensionProperty }[];
+}
+
+/**
+ * Runs `quantize` with every deforming mesh held in the metre space its author wrote it in, and
+ * puts the held geometry back whether the stage returns or throws.
+ */
+export async function quantizeStaticGeometry(
+ document: Document,
+ quantize: () => unknown,
+): Promise {
+ const held = holdDeformingMeshes(document);
+ try {
+ await quantize();
+ } finally {
+ releaseDeformingMeshes(held);
+ }
+}
+
+function holdDeformingMeshes(document: Document): IHeldMesh[] {
+ const skinned = skinnedMeshes(document);
+ const held: IHeldMesh[] = [];
+ for (const mesh of document.getRoot().listMeshes()) {
+ // The mesh is the unit of source space, because the compensation is per mesh: one deforming
+ // primitive in it holds the whole thing, or the shift would land on half of it.
+ if (!deformingMesh(mesh, skinned)) continue;
+ const nodes: Node[] = [];
+ for (const parent of mesh.listParents()) {
+ if (!(parent instanceof Node)) continue;
+ nodes.push(parent);
+ parent.setMesh(null);
+ }
+ const primitives = mesh.listPrimitives().map((primitive) => ({
+ primitive,
+ indices: primitive.getIndices(),
+ position: holdAttribute(primitive.getAttribute("POSITION")),
+ targets: primitive.listTargets().map((target) => ({
+ target,
+ position: holdAttribute(target.getAttribute("POSITION")),
+ })),
+ }));
+ const volumes: { material: Material; volume: ExtensionProperty }[] = [];
+ for (const primitive of mesh.listPrimitives()) {
+ const material = primitive.getMaterial();
+ const volume = material?.getExtension("KHR_materials_volume") ?? null;
+ if (material === null || volume === null) continue;
+ // Thickness is given in local units, so the stage would scale it by the shift it is not
+ // applying to this mesh's positions.
+ material.setExtension("KHR_materials_volume", null);
+ volumes.push({ material, volume });
+ }
+ held.push({ mesh, nodes, primitives, volumes });
+ }
+ return held;
+}
+
+function releaseDeformingMeshes(held: readonly IHeldMesh[]): void {
+ for (const { mesh, nodes, primitives, volumes } of held) {
+ for (const node of nodes) node.setMesh(mesh);
+ for (const { material, volume } of volumes)
+ material.setExtension("KHR_materials_volume", volume);
+ for (const { primitive, indices, position, targets } of primitives) {
+ // The stage may rewrite what a sidecar already addresses, but never renumber it: an index
+ // buffer it replaced means the held positions no longer line up, so fail instead of shipping
+ // a mesh whose every vertex has quietly moved.
+ if (primitive.getIndices() !== indices)
+ throw new Error(
+ "TN_ASSETS_MODEL_DEFORMING_RENUMBERED: quantization rewrote the index buffer of a deforming primitive; its positions were held in source space and can no longer be addressed by index.",
+ );
+ releaseAttribute(primitive, position);
+ for (const { target, position: delta } of targets) releaseAttribute(target, delta);
+ }
+ }
+}
+
+function holdAttribute(accessor: Accessor | null): IHeldAttribute | null {
+ const array = accessor?.getArray() ?? null;
+ if (accessor === null || array === null) return null;
+ // A copy, because the stage replaces this accessor with its own and prunes the original: a
+ // disposed property cannot be re-attached, and the values have to outlive the stage.
+ const copy = array.slice();
+ return { array: copy, normalized: accessor.getNormalized() };
+}
+
+/**
+ * Writes the authored values back over whatever the stage left in the attribute. The accessor the
+ * stage installed is reused rather than replaced, so the mesh keeps the attribute it was drawn
+ * with and no orphan is left for the writer to emit.
+ */
+function releaseAttribute(owner: IAttributeOwner, held: IHeldAttribute | null): void {
+ const accessor = owner.getAttribute("POSITION");
+ if (held === null || accessor === null) return;
+ accessor.setArray(held.array).setNormalized(held.normalized).setSparse(false);
+}
diff --git a/packages/assets/src/passes/reorder-static.ts b/packages/assets/src/passes/reorder-static.ts
new file mode 100644
index 0000000000..d8c1ffffe3
--- /dev/null
+++ b/packages/assets/src/passes/reorder-static.ts
@@ -0,0 +1,126 @@
+// The model pass's `reorder` stage, over the primitives whose vertex order nothing else owns.
+//
+// `reorder` from `@gltf-transform/functions` walks the whole document and renumbers the vertices
+// of every primitive it finds, which is the cheapest transmission-size win in the cook — and it
+// breaks every file that addresses a vertex by index. A MetaHuman sidecar names the head vertex
+// each brow strand root rides and reads that position back every frame; after an unconditional
+// reorder it indexes a different vertex (`brow root 0 indexes head vertex 11498, which is not where
+// it was prepared`). The cook cannot know who holds an index, so the general rule is that any
+// primitive whose indices can be addressed from outside is undeclarable, and *deforming* — skinned,
+// morph-targeted, or joint-bound — is the measurable proxy: a rig is where index-addressed data
+// comes from, and it is the one case detectable from the file alone.
+//
+// So the vertex-cache remap is applied per index-buffer group, and a group holding any deforming
+// primitive is left exactly as authored. Compression is unaffected: `EXT_meshopt_compression` encodes
+// whatever order it is handed, and `quantize` renumbers nothing.
+//
+// ponytail: the library's `reorder` has no per-primitive switch, so the remap is its own ~30 lines.
+// Replace with the library's whole-document `reorder` the day it takes a predicate.
+
+import { type Accessor, type Document, Primitive } from "@gltf-transform/core";
+import type { MeshoptEncoder } from "meshoptimizer";
+import { deforming, skinnedMeshes } from "../lod/eligibility.js";
+
+/** One index buffer, the attributes that read through it, and the primitives that draw it. */
+interface IVertexGroup {
+ readonly attributes: Accessor[];
+ readonly members: { readonly primitive: Primitive; readonly skinned: boolean }[];
+}
+
+/** meshoptimizer's own 16-bit index ceiling, as the library's `reorder` measures it. */
+const MAX_UINT16 = 65534;
+
+/**
+ * Reorder every non-deforming primitive's vertices for transmission size, in place. Returns how
+ * many index-buffer groups were rewritten.
+ *
+ * Grouped by shared index buffer rather than by primitive, because primitives sharing one index
+ * buffer share the renumbering: remapping one and not the other desynchronises them. Mirrors the
+ * library's own `reorder` for the groups it does touch, minus its cleanup prune — the pass's `prune`
+ * runs before this stage, and the writer emits reachable properties only.
+ */
+export function reorderStaticPrimitives(
+ document: Document,
+ encoder: typeof MeshoptEncoder,
+): number {
+ let reordered = 0;
+ for (const [indices, group] of vertexGroups(document)) {
+ if (group.members.some((member) => deforming(member.primitive, member.skinned))) continue;
+ remapGroup(indices, group, encoder);
+ reordered += 1;
+ }
+ return reordered;
+}
+
+/** Every index buffer in the document, with the attributes and primitives that read through it. */
+function vertexGroups(document: Document): Map {
+ const skinned = skinnedMeshes(document);
+ const groups = new Map();
+ for (const mesh of document.getRoot().listMeshes())
+ for (const primitive of mesh.listPrimitives()) {
+ const indices = primitive.getIndices();
+ if (indices === null) continue;
+ const group = groups.get(indices) ?? { attributes: [], members: [] };
+ group.members.push({ primitive, skinned: skinned.has(mesh) });
+ for (const semantic of primitive.listSemantics()) {
+ const attribute = primitive.getAttribute(semantic);
+ if (attribute !== null && !group.attributes.includes(attribute))
+ group.attributes.push(attribute);
+ }
+ groups.set(indices, group);
+ }
+ return groups;
+}
+
+/** Renumber one group's vertices, and every primitive that draws them, in place. */
+function remapGroup(indices: Accessor, group: IVertexGroup, encoder: typeof MeshoptEncoder): void {
+ const source = indices.getArray();
+ const triangles = group.members.every(
+ (member) => member.primitive.getMode() === Primitive.Mode.TRIANGLES,
+ );
+ // The encoder renumbers the array it is handed in place and returns the old-to-new table, so the
+ // copy is the destination index buffer and `source` still reads the authored one.
+ const remapped = source instanceof Uint32Array ? source.slice() : new Uint32Array(source);
+ const [remap, unique] = encoder.reorderMesh(remapped, triangles, true);
+ const dstIndices = indices
+ .clone()
+ .setArray(unique <= MAX_UINT16 ? new Uint16Array(remapped) : remapped);
+ const dstAttributes = new Map();
+ for (const attribute of group.attributes)
+ dstAttributes.set(attribute, compactAttribute(attribute, source, remap, unique));
+ for (const member of group.members) {
+ for (const semantic of member.primitive.listSemantics()) {
+ const attribute = member.primitive.getAttribute(semantic);
+ if (attribute === null) continue;
+ member.primitive.setAttribute(semantic, dstAttributes.get(attribute) ?? attribute);
+ }
+ member.primitive.setIndices(dstIndices);
+ }
+ for (const attribute of group.attributes)
+ if (attribute.listParents().length === 0) attribute.dispose();
+ if (indices.listParents().length === 0) indices.dispose();
+}
+
+/** One attribute rewritten into the renumbered vertex stream, dropping what no triangle reaches. */
+function compactAttribute(
+ attribute: Accessor,
+ source: ArrayLike,
+ remap: Uint32Array,
+ unique: number,
+): Accessor {
+ const array = attribute.getArray();
+ const width = attribute.getElementSize();
+ const compact = new array.constructor(unique * width);
+ // One write per destination vertex: a vertex several triangles reference is copied once.
+ const written = new Uint8Array(unique);
+ for (let index = 0; index < source.length; index += 1) {
+ const from = source[index] ?? 0;
+ const to = remap[from];
+ if (to === undefined || written[to] === 1) continue;
+ written[to] = 1;
+ for (let component = 0; component < width; component += 1)
+ compact[to * width + component] = array[from * width + (component ?? 0)];
+ }
+ // Sparse is cleared: it indexes the source's vertex stream and this one has been renumbered.
+ return attribute.clone().setArray(compact).setSparse(false);
+}
diff --git a/packages/assets/src/passes/texture.ts b/packages/assets/src/passes/texture.ts
index 14026a2362..b9fa713a96 100644
--- a/packages/assets/src/passes/texture.ts
+++ b/packages/assets/src/passes/texture.ts
@@ -224,7 +224,7 @@ export function textureResizePass(options: ITextureResizeOptions): IAssetPass {
};
}
-/** PNG signature or JPEG SOI, the two containers `decodeImageBytes` reads. */
+/** PNG signature or JPEG SOI, the two containers the standalone pass sniffs headers for. */
function isPngOrJpeg(bytes: Buffer): boolean {
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return true;
diff --git a/packages/assets/src/report.ts b/packages/assets/src/report.ts
index b8d203d6c8..36bf0f643c 100644
--- a/packages/assets/src/report.ts
+++ b/packages/assets/src/report.ts
@@ -64,6 +64,8 @@ export interface IEmbeddedTextureRow {
export interface IVirtualRow {
readonly bakeSeconds: number;
readonly clusters: number;
+ /** Primitives handed back to the discrete ladder because their DAG reduced nothing. */
+ readonly declined: number;
readonly levels: number;
readonly payloadBytes: number;
readonly primitives: number;
@@ -409,19 +411,22 @@ function simplifyLine(row: IModelSizeRow): readonly string[] {
* Names what the DAG cost and where it stopped.
*
* A `cap` stop is called out because it means a DAG ran out of levels rather than finishing, and a
- * bake that clustered nothing says so rather than leaving the reader to infer it from a zero.
+ * bake that clustered nothing says so rather than leaving the reader to infer it from a zero. A
+ * declined primitive is named too: "no primitive was dense enough" is the wrong reason for a body
+ * whose foliage is dense enough and whose DAG reduced nothing.
*/
function virtualLine(row: IModelSizeRow): readonly string[] {
const virtual = row.virtual;
if (virtual === undefined) return [];
+ const declined = virtual.declined > 0 ? `, ${virtual.declined} declined to the LOD ladder` : "";
if (virtual.primitives === 0)
return [
- `virtual ${row.logicalPath}: no primitive was dense enough to cluster (${virtual.skipped} skipped)`,
+ `virtual ${row.logicalPath}: no primitive was dense enough to cluster (${virtual.skipped} skipped${declined})`,
];
const warning =
virtual.stopReason === "cap" ? " — a DAG hit the level cap and is unfinished" : "";
return [
- `virtual ${row.logicalPath}: ${virtual.clusters} cluster(s) over ${virtual.levels} level(s) on ${virtual.primitives} primitive(s), ${virtual.skipped} skipped, ${virtual.payloadBytes} payload bytes, bake ${virtual.bakeSeconds.toFixed(1)} s, stopped at ${virtual.stopReason}${warning}`,
+ `virtual ${row.logicalPath}: ${virtual.clusters} cluster(s) over ${virtual.levels} level(s) on ${virtual.primitives} primitive(s), ${virtual.skipped} skipped${declined}, ${virtual.payloadBytes} payload bytes, bake ${virtual.bakeSeconds.toFixed(1)} s, stopped at ${virtual.stopReason}${warning}`,
];
}
function compactLine(row: IModelSizeRow): readonly string[] {
@@ -587,7 +592,7 @@ export function formatSkippedCompression(rows: readonly ISkippedReportRow[]): re
const reason =
row.reason === "config"
? `assets.${row.kind}s is "none"`
- : `this target has no WebAssembly and cannot run its ${decoders} decoder`;
+ : `this target has no qualified ${decoders} decoder`;
return `TN_ASSETS_COMPRESSION_SKIPPED ${row.kind}: ${String(row.files)} file(s), ${(row.bytes / 1e6).toFixed(1)} MB ${action} because ${reason}.`;
});
}
diff --git a/packages/assets/src/virtual/bake.ts b/packages/assets/src/virtual/bake.ts
index 5f9c061205..210acd3f3f 100644
--- a/packages/assets/src/virtual/bake.ts
+++ b/packages/assets/src/virtual/bake.ts
@@ -1,7 +1,12 @@
// The pipeline's side of the DAG bake: which primitives get one, and what the report says about it.
import type { Document, Primitive } from "@gltf-transform/core";
-import { type IClusterDagOptions, buildClusterDag } from "./dag.js";
+import {
+ type IClusterDag,
+ type IClusterDagOptions,
+ type IVirtualLevel,
+ buildClusterDag,
+} from "./dag.js";
import { TNVirtualGeometry, attachVirtualGeometry, virtualGeometryBytes } from "./extension.js";
/**
@@ -52,6 +57,11 @@ export interface IModelVirtualOptions {
export interface IModelVirtualSummary {
readonly bakeSeconds: number;
readonly clusters: number;
+ /**
+ * Primitives the bake looked at and handed back because their DAG reduced nothing — left for the
+ * discrete ladder, which is named here because "no clusters" alone reads as a bug.
+ */
+ readonly declined: number;
readonly levels: number;
readonly payloadBytes: number;
readonly primitives: number;
@@ -61,7 +71,37 @@ export interface IModelVirtualSummary {
}
/** Bumped when the bake's output changes for the same input. Part of the compile cache key. */
-export const VIRTUAL_BAKE_VERSION = 3;
+export const VIRTUAL_BAKE_VERSION = 4;
+
+/**
+ * The share of its own triangles a DAG has to shed before it keeps the primitive.
+ *
+ * Below that the payload is three to four times the primitive's compiled bytes (see
+ * {@link IModelVirtualOptions.minSourceTriangles}) for a cut that draws the same triangles, and the
+ * discrete ladder's terminal level reaches 5% of LOD0 on the same geometry.
+ */
+const DECLINE_KEEP_RATIO = 0.8;
+
+/**
+ * Whether the bake hands this primitive back instead of owning it (PRD-485).
+ *
+ * A `stalled` DAG whose coarsest level still holds most of the source has no coarse representation
+ * to offer. meshoptimizer will not collapse a body of disconnected alpha-cutout cards with every
+ * border locked — each card's whole rim is a border — so the first fold simplifies nothing
+ * (`virtual/dag.ts:461`), every level reports the source count, and every threshold draws LOD0: the
+ * Hornbeam's two foliage primitives, 745,328 triangles with no coarse step at all. Owning them
+ * anyway costs 3–4x their bytes and leaves them `virtual-owned` (`lod/eligibility.ts:182`), so the
+ * bake declines them and the discrete ladder — whose terminal pass exists for exactly this shape,
+ * `lod/generate.ts:851` — gets the primitive instead.
+ *
+ * Measured, not assumed: a DAG that stalls *after* real folds has shed its triangles and keeps the
+ * primitive, and so does every stop reason but `stalled`.
+ */
+function declines(dag: IClusterDag, sourceTriangles: number): boolean {
+ if (dag.stopReason !== "stalled") return false;
+ const coarsest = dag.levels[dag.levels.length - 1] as IVirtualLevel;
+ return coarsest.triangleCount > sourceTriangles * DECLINE_KEEP_RATIO;
+}
/**
* The density at which a cluster DAG starts paying for its own bytes. See
@@ -117,49 +157,59 @@ export async function bakeVirtualGeometry(
let extension: TNVirtualGeometry | null = null;
let clusters = 0;
+ let declined = 0;
let levels = 0;
let payloadBytes = 0;
let primitives = 0;
let skipped = 0;
let stopReason = "root";
- for (const mesh of document.getRoot().listMeshes()) {
- for (const primitive of mesh.listPrimitives()) {
- const indices = primitive.getIndices();
- const positions = positionsOf(primitive);
- // A skinned body ships whole: the loader's clustered mesh is a plain `Mesh`, so a baked
- // primitive would lose its skin (and the rig would not clone).
- if (
- indices === null ||
- positions === null ||
- primitive.getMode() !== 4 ||
- primitive.getAttribute("JOINTS_0") !== null
- ) {
- skipped += 1;
- continue;
- }
- if (indices.getCount() / 3 < minSourceTriangles) {
- skipped += 1;
- continue;
- }
- const dag = await buildClusterDag(
- Uint32Array.from(indices.getArray() as ArrayLike),
- positions,
- dagOptions,
- );
- extension ??= document.createExtension(TNVirtualGeometry).setRequired(false);
- const virtual = attachVirtualGeometry(document, extension, primitive, dag);
- clusters += dag.clusters.length;
- levels = Math.max(levels, dag.levels.length);
- payloadBytes += virtualGeometryBytes(virtual);
- primitives += 1;
- stopReason = worse(stopReason, dag.stopReason);
+ for (const primitive of document
+ .getRoot()
+ .listMeshes()
+ .flatMap((mesh) => mesh.listPrimitives())) {
+ const indices = primitive.getIndices();
+ const positions = positionsOf(primitive);
+ // A skinned body ships whole: the loader's clustered mesh is a plain `Mesh`, so a baked
+ // primitive would lose its skin (and the rig would not clone).
+ if (
+ indices === null ||
+ positions === null ||
+ primitive.getMode() !== 4 ||
+ primitive.getAttribute("JOINTS_0") !== null
+ ) {
+ skipped += 1;
+ continue;
+ }
+ const sourceTriangles = indices.getCount() / 3;
+ if (sourceTriangles < minSourceTriangles) {
+ skipped += 1;
+ continue;
+ }
+ const dag = await buildClusterDag(
+ Uint32Array.from(indices.getArray() as ArrayLike),
+ positions,
+ dagOptions,
+ );
+ // No extension is created for a declined primitive, so a document whose every primitive is
+ // card soup never declares `TN_virtual_geometry` at all.
+ if (declines(dag, sourceTriangles)) {
+ declined += 1;
+ continue;
}
+ extension ??= document.createExtension(TNVirtualGeometry).setRequired(false);
+ const virtual = attachVirtualGeometry(document, extension, primitive, dag);
+ clusters += dag.clusters.length;
+ levels = Math.max(levels, dag.levels.length);
+ payloadBytes += virtualGeometryBytes(virtual);
+ primitives += 1;
+ stopReason = worse(stopReason, dag.stopReason);
}
return {
bakeSeconds: (now() - started) / 1000,
clusters,
+ declined,
levels,
payloadBytes,
primitives,
diff --git a/packages/core/__tests__/asset-lifetime.spec.ts b/packages/core/__tests__/asset-lifetime.spec.ts
new file mode 100644
index 0000000000..304d7941a6
--- /dev/null
+++ b/packages/core/__tests__/asset-lifetime.spec.ts
@@ -0,0 +1,258 @@
+import {
+ type BufferGeometry,
+ CylinderGeometry,
+ Group,
+ Mesh,
+ type MeshStandardMaterial,
+ type Object3D,
+ PlaneGeometry,
+ MeshStandardMaterial as StandardMaterial,
+ Texture,
+} from "three";
+import { describe, expect, it } from "vitest";
+import { defineGame } from "../src/game.js";
+import { type ICtx, Scene } from "../src/scene.js";
+
+type State = { frames: number };
+type Model = { scene: Group };
+const names = ["meshopt", "draco"] as const;
+
+/**
+ * The renderer's live tally, counting the way three's backends count: a geometry or texture is
+ * live from the frame that drew it until its own `dispose` event. Registering on draw rather than
+ * on construction is what makes the numbers comparable with `info.memory`, which only grows for
+ * something a frame actually uploaded.
+ */
+class LiveTally {
+ readonly geometries = new Set();
+ readonly textures = new Set();
+
+ /** One frame's draw: every visible geometry and every texture a visible material samples. */
+ draw(scene: Object3D): void {
+ scene.traverse((object) => {
+ if (object.visible !== true) return;
+ const mesh = object as Mesh;
+ const geometry = mesh.geometry;
+ if (geometry !== undefined && !this.geometries.has(geometry)) {
+ this.geometries.add(geometry);
+ geometry.addEventListener("dispose", () => this.geometries.delete(geometry));
+ }
+ const material = mesh.material;
+ if (material === undefined || material === null) return;
+ for (const surface of Array.isArray(material) ? material : [material])
+ for (const value of Object.values(surface as unknown as Record)) {
+ if (!isTexture(value) || this.textures.has(value)) continue;
+ this.textures.add(value);
+ value.addEventListener("dispose", () => this.textures.delete(value));
+ }
+ });
+ }
+
+ /** What is still live, named: the message a failing count has to carry. */
+ get live(): string {
+ const parts: string[] = [];
+ for (const geometry of this.geometries)
+ parts.push(`${geometry.name || "geometry"}#${geometry.id}`);
+ for (const texture of this.textures) parts.push(`${texture.name || "texture"}#${texture.id}`);
+ return parts.join(", ");
+ }
+}
+
+function isTexture(value: unknown): value is Texture {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ (value as { isTexture?: boolean }).isTexture === true
+ );
+}
+
+function testCanvas(): HTMLCanvasElement {
+ const canvas = new EventTarget() as EventTarget & Partial;
+ Object.defineProperties(canvas, {
+ clientHeight: { configurable: true, value: 180 },
+ clientWidth: { configurable: true, value: 320 },
+ parentElement: { configurable: true, value: null },
+ });
+ return canvas as HTMLCanvasElement;
+}
+
+/** The fixture's stage, unchanged in shape: three solid plinths and one textured panel. */
+function stage(scene: Object3D, png: Texture): Mesh[] {
+ const objects: Mesh[] = [];
+ for (const x of [-2.4, 0, 2.4]) {
+ const plinth = new Mesh(
+ new CylinderGeometry(1.03, 1.1, 0.25, 8),
+ new StandardMaterial({ color: 0x263951 }),
+ );
+ plinth.geometry.name = "plinth";
+ plinth.position.x = x;
+ scene.add(plinth);
+ objects.push(plinth);
+ }
+ const panel = new Mesh(new PlaneGeometry(1.6, 1.6), new StandardMaterial({ map: png }));
+ panel.geometry.name = "panel";
+ scene.add(panel);
+ objects.push(panel);
+ return objects;
+}
+
+/** One glb: a group holding a textured mesh, exactly the shape the fixture's models have. */
+function fixtureModel(name: string): Model {
+ const albedo = new StandardMaterial({ name: `${name}-albedo` });
+ const mesh = new Mesh(new PlaneGeometry(1, 1), albedo);
+ mesh.geometry.name = `${name}-mesh`;
+ const scene = new Group();
+ scene.name = name;
+ scene.add(mesh);
+ return { scene };
+}
+
+/** The geometry and every material a stage mesh owns, which is what the game's exit disposes. */
+function disposeMesh(mesh: Mesh): void {
+ mesh.geometry.dispose();
+ for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material])
+ material.dispose();
+}
+
+/**
+ * The fixture's own scene: two models through `ctx.assets`, a stage the game owns, and an `exit`
+ * that releases all of it. `releases: false` is the counter-probe — the same scene with an exit path
+ * that forgets — so the guard below is known to see a leak when there is one.
+ */
+function assetScene(releases: boolean) {
+ return class extends Scene {
+ static override readonly initialState: State = { frames: 0 };
+ #models: Model[] = [];
+ #png: Texture | undefined;
+ #owned: Mesh[] = [];
+
+ override async load(ctx: ICtx): Promise {
+ this.#models = await Promise.all(names.map((name) => ctx.assets.model(`${name}.glb`)));
+ this.#png = await ctx.assets.texture("checker.png", {});
+ }
+
+ override enter(ctx: ICtx) {
+ if (this.#png === undefined) throw new Error("PNG did not load");
+ this.#owned = stage(ctx.scene, this.#png);
+ for (const model of this.#models) ctx.add(model.scene);
+ let frames = 0;
+ return (_ctx: ICtx) => {
+ ctx.state.set({ frames: ++frames });
+ };
+ }
+
+ override exit(ctx: ICtx): void {
+ for (const mesh of this.#owned) {
+ if (releases) disposeMesh(mesh);
+ mesh.removeFromParent();
+ }
+ this.#owned.length = 0;
+ if (releases) this.#png?.dispose();
+ this.#png = undefined;
+ this.#models.length = 0;
+ if (!releases) return;
+ for (const name of names) ctx.assets.release("model", `${name}.glb`);
+ ctx.assets.release("texture", "checker.png");
+ }
+ };
+}
+
+/** The empty scene the asset scene leaves for, as the lifecycle fixture's `void`. */
+class VoidScene extends Scene {
+ static override readonly initialState: State = { frames: 0 };
+}
+
+/**
+ * The real mechanism: a game's scene loads through `ctx.assets`, is entered, is left through
+ * `ctx.goto`, and is entered again — three times — with the live tally read on a drawn frame each
+ * time. Returns how many geometries and textures the cycles left live.
+ */
+async function countLiveAcrossCycles(
+ releases: boolean,
+): Promise<{ geometries: number; textures: number; live: string }> {
+ const tally = new LiveTally();
+ const canvas = testCanvas();
+ let renderFrame: ((time: number) => void) | undefined;
+ const requestFrame = globalThis.requestAnimationFrame;
+ Object.defineProperty(globalThis, "requestAnimationFrame", {
+ configurable: true,
+ value: (callback: (time: number) => void) => {
+ renderFrame = callback;
+ return 1;
+ },
+ });
+ const game = defineGame({
+ initialState: { frames: 0 },
+ assets: {
+ basePath: "/assets",
+ model: async (url) => fixtureModel(url.replace(/^.*\//u, "").replace(/\.glb$/u, "")),
+ texture: async (url) => {
+ const texture = new Texture();
+ texture.name = url.replace(/^.*\//u, "");
+ return texture;
+ },
+ },
+ renderer: {
+ canvas,
+ preferWebGPU: false,
+ webgl2Factory: () => ({
+ dispose: () => undefined,
+ domElement: canvas,
+ render: (scene: Object3D) => {
+ tally.draw(scene);
+ },
+ setSize: () => undefined,
+ }),
+ },
+ scenes: { assets: assetScene(releases), void: VoidScene },
+ start: "assets",
+ });
+
+ const draw = (): void => {
+ if (renderFrame === undefined) throw new Error("Game did not schedule a frame.");
+ renderFrame(16);
+ };
+
+ try {
+ await game.start();
+ draw();
+ const baseline = { geometries: tally.geometries.size, textures: tally.textures.size };
+ // Leaving must not wait for the next frame's reconcile: every tally below is taken on a drawn
+ // frame, so anything the exit path drops only is gone by the time the next enter draws.
+ for (let cycle = 0; cycle < 3; cycle += 1) {
+ await game.goto("void");
+ await game.goto("assets");
+ draw();
+ }
+ return {
+ geometries: tally.geometries.size - baseline.geometries,
+ textures: tally.textures.size - baseline.textures,
+ live: tally.live,
+ };
+ } finally {
+ game.stop();
+ if (requestFrame === undefined) Reflect.deleteProperty(globalThis, "requestAnimationFrame");
+ else Object.defineProperty(globalThis, "requestAnimationFrame", { value: requestFrame });
+ }
+}
+
+describe("asset lifetime across a scene change", () => {
+ it("leaves no live geometry or texture behind after leave/re-enter cycles", async () => {
+ const { live, ...growth } = await countLiveAcrossCycles(true);
+
+ expect(growth, `still live after 3 leave/re-enter cycles: ${live}`).toEqual({
+ geometries: 0,
+ textures: 0,
+ });
+ });
+
+ it("sees the growth an exit path that does not release leaves behind", async () => {
+ // Without this the guard above could pass by never measuring anything.
+ const { live, ...growth } = await countLiveAcrossCycles(false);
+
+ expect(growth, `still live after 3 leave/re-enter cycles: ${live}`).toEqual({
+ geometries: 12,
+ textures: 3,
+ });
+ });
+});
diff --git a/packages/core/__tests__/clustered-batch.spec.ts b/packages/core/__tests__/clustered-batch.spec.ts
index ea35b8a52e..0901886385 100644
--- a/packages/core/__tests__/clustered-batch.spec.ts
+++ b/packages/core/__tests__/clustered-batch.spec.ts
@@ -502,4 +502,22 @@ describe("ClusteredBatch", () => {
}).build({ parent: new Object3D() }),
).toThrow(/at least one placed copy/u);
}, 180_000);
+
+ it("clones into a root that is still cut", () => {
+ // `new this.constructor()` left `batch` undefined, so a cloned root quietly stopped being cut
+ // and drew whatever its bands happened to hold. Detection is structural, so the engine could
+ // not even tell the copy apart from an ordinary object.
+ const parent = new Object3D();
+ const batch = spread();
+ const root = batch.build({ name: "boulders", parent });
+ camera.position.set(0, 0, 0);
+ const triangles = batch.update(camera, 1080);
+
+ const copy = root.clone() as unknown as { batch: ClusteredBatch };
+ expect(copy.batch).toBe(batch);
+ // Still found by the engine's walk, and still drawing: a copy whose `batch` was undefined
+ // returned nothing and was never cut again.
+ expect(updateClusteredMeshes(copy as unknown as Object3D, camera, 1080)).toBeGreaterThan(0);
+ expect(triangles).toBeGreaterThan(0);
+ }, 180_000);
});
diff --git a/packages/core/__tests__/clustered-mesh.spec.ts b/packages/core/__tests__/clustered-mesh.spec.ts
index f5a9b23b12..ec2769e991 100644
--- a/packages/core/__tests__/clustered-mesh.spec.ts
+++ b/packages/core/__tests__/clustered-mesh.spec.ts
@@ -1,6 +1,7 @@
import {
BufferAttribute,
BufferGeometry,
+ Group,
MeshBasicMaterial,
PerspectiveCamera,
Vector3,
@@ -442,3 +443,81 @@ describe("ClusteredMesh", () => {
).toThrow(/needs a perspective camera/u);
});
});
+
+describe("ClusteredMesh cloning", () => {
+ const camera = new PerspectiveCamera(60, 16 / 9, 0.1, 1000);
+
+ /** This mesh's triangles, with the camera `z` units out from where it stands. */
+ function cutAt(mesh: ClusteredMesh, z: number): number {
+ camera.position.set(0, 0, z);
+ return mesh.update(camera, 1080);
+ }
+
+ // three clones through `new this.constructor().copy(source)`, so any game that stands a second
+ // copy of a cooked model where it wants a statue used to die in `assertTable` before it drew
+ // anything. Lumen-hall's authored statues did, from the first model the default cook produced.
+ it("clones into a mesh that draws the same thing", () => {
+ const mesh = new ClusteredMesh(geometry(), new MeshBasicMaterial(), table);
+ const triangles = cutAt(mesh, 6);
+ const clone = mesh.clone();
+
+ expect(clone.table).toBe(mesh.table);
+ // A copy starts at the authored detail, exactly as a fresh mesh does, and only gives it up
+ // once something cuts it.
+ expect(clone.drawnTriangles).toBe(body.indices.length / 3);
+ expect(cutAt(clone, 6)).toBe(triangles);
+ expect(clone.geometry.drawRange).toEqual(mesh.geometry.drawRange);
+ expect(clone.geometry.getIndex()?.array).not.toBe(mesh.geometry.getIndex()?.array);
+ });
+
+ it("clones inside a group, which is how a game places a second copy", () => {
+ const figure = new Group();
+ figure.add(new ClusteredMesh(geometry(), new MeshBasicMaterial(), table));
+ const clone = figure.clone(true);
+ const copied = clone.children[0] as ClusteredMesh;
+
+ expect(copied).toBeInstanceOf(ClusteredMesh);
+ expect(copied.table).toBe((figure.children[0] as ClusteredMesh).table);
+ });
+
+ it("keeps the vertices shared and the cut its own, so the two are placed independently", () => {
+ // Sharing the geometry wholesale would be cheaper still and wrong: the cut lives in the
+ // geometry's index buffer, so two meshes on one geometry would overwrite each other's index
+ // every frame and both would draw whichever one spoke last. Sharing the vertex buffers by
+ // reference keeps one copy of them on the GPU — three keys its attributes on the attribute —
+ // and still lets each mesh hold a cut of its own.
+ const mesh = new ClusteredMesh(geometry(), new MeshBasicMaterial(), table);
+ const beside = mesh.clone();
+
+ expect(beside.geometry).not.toBe(mesh.geometry);
+ expect(beside.geometry.attributes.position).toBe(mesh.geometry.attributes.position);
+
+ const near = cutAt(mesh, 6);
+ expect(cutAt(beside, 6)).toBe(near);
+
+ const across = mesh.clone();
+ across.position.set(0, 0, 400);
+ expect(cutAt(across, 6)).toBeLessThan(near);
+ // And cutting the far copy left the near one drawing what it was drawing.
+ expect(cutAt(mesh, 6)).toBe(near);
+ });
+
+ it("disposes without taking the source's shared data with it", () => {
+ // The rule, since sharing is the point: a clone shares the baked table and the vertex
+ // attributes, and the geometry that owns them disposes them for everyone — exactly as three's
+ // own `Mesh.copy` shares geometry and material. What a clone owns outright is its cut.
+ const mesh = new ClusteredMesh(geometry(), new MeshBasicMaterial(), table);
+ const clone = mesh.clone();
+ let sourceDisposed = false;
+ mesh.geometry.addEventListener("dispose", () => {
+ sourceDisposed = true;
+ });
+
+ clone.geometry.dispose();
+
+ expect(sourceDisposed).toBe(false);
+ expect(clone.geometry.attributes.position).toBe(mesh.geometry.attributes.position);
+ expect(mesh.table).toBe(table);
+ expect(mesh.drawnTriangles).toBe(body.indices.length / 3);
+ });
+});
diff --git a/packages/core/src/clustered-batch.ts b/packages/core/src/clustered-batch.ts
index 7bbe0644ca..2b4035b5fd 100644
--- a/packages/core/src/clustered-batch.ts
+++ b/packages/core/src/clustered-batch.ts
@@ -126,6 +126,19 @@ export class ClusteredBatchRoot extends Object3D {
constructor(readonly batch: ClusteredBatch) {
super();
}
+
+ /**
+ * A copy that is still being cut.
+ *
+ * three builds one through `new this.constructor()`, which left the copy's `batch` undefined
+ * and quietly stopped cutting it — a frozen cut that still drew. Sharing the batch is what
+ * keeps a copy alive: the batch owns the bands and their index buffers, so the copy draws the
+ * same cut as the root it was copied from, which is the same thing `Mesh.copy` does with
+ * geometry and `VirtualGeometryPlugin` does with a baked table.
+ */
+ override clone(recursive = true): this {
+ return new ClusteredBatchRoot(this.batch).copy(this, recursive) as this;
+ }
}
function placementMatrix(placement: IClusteredPlacement): Matrix4 {
diff --git a/packages/core/src/clustered-mesh.ts b/packages/core/src/clustered-mesh.ts
index d9db2111f0..3dc79391b2 100644
--- a/packages/core/src/clustered-mesh.ts
+++ b/packages/core/src/clustered-mesh.ts
@@ -1,11 +1,4 @@
-import {
- BufferAttribute,
- type BufferGeometry,
- type Camera,
- type Material,
- Mesh,
- Vector3,
-} from "three";
+import { BufferAttribute, BufferGeometry, type Camera, type Material, Mesh, Vector3 } from "three";
/**
* A mesh that draws only the clusters this camera can resolve.
@@ -163,15 +156,15 @@ function finestIndexCount(table: IClusterTable): number {
}
export class ClusteredMesh extends Mesh {
- /** The baked payload. Read-only at run time; the bake is the only thing that writes it. */
- readonly table: IClusterTable;
+ /** The baked payload. The bake is the only thing that writes it; a copy takes the source's. */
+ table!: IClusterTable;
/** Screen-space error budget in pixels. Writable — it is the game's call. */
errorPixels: number;
/** Camera movement, in the mesh's own units, below which the previous cut is kept. */
recutDistance: number;
- readonly #cut: Uint32Array;
- readonly #selection: Uint32Array;
+ #cut!: Uint32Array;
+ #selection!: Uint32Array;
readonly #cameraLocal = new Vector3();
readonly #lastCut = new Vector3();
#drawnClusters = 0;
@@ -188,11 +181,21 @@ export class ClusteredMesh extends Mesh {
) {
super(geometry, surface);
assertTable(table);
- this.table = table;
this.errorPixels = options.errorPixels ?? DEFAULT_ERROR_PIXELS;
+ this.recutDistance = options.recutDistance ?? radiusOf(table) * RECUT_FRACTION;
+ this.#load(table, geometry);
+ // Full detail until something cuts it. Virtual geometry ships on by default, so the mesh a
+ // game never touches has to draw exactly what an ordinary `Mesh` would have drawn — a class
+ // that starts invisible and waits to be updated would turn the default into a blank screen.
+ // `update` only ever takes detail away from here.
+ this.#compact(this.#finestCut());
+ }
+
+ /** Points this mesh at `table`, and installs its cut on the geometry it is going to draw. */
+ #load(table: IClusterTable, geometry: BufferGeometry): void {
+ this.table = table;
this.#cut = new Uint32Array(finestIndexCount(table));
this.#selection = new Uint32Array(table.ranges.length / 2);
- this.recutDistance = options.recutDistance ?? radiusOf(table) * RECUT_FRACTION;
// The geometry draws out of the cut buffer from here on. Its vertex attributes are untouched:
// every level of the DAG indexes the vertices the mesh already shipped.
//
@@ -200,11 +203,44 @@ export class ClusteredMesh extends Mesh {
// are given, so the cut this class writes every frame would never reach the GPU and the mesh
// would draw a buffer of zeros — degenerate triangles, an empty screen, and no error anywhere.
geometry.setIndex(new BufferAttribute(this.#cut, 1));
- // Full detail until something cuts it. Virtual geometry ships on by default, so the mesh a
- // game never touches has to draw exactly what an ordinary `Mesh` would have drawn — a class
- // that starts invisible and waits to be updated would turn the default into a blank screen.
- // `update` only ever takes detail away from here.
+ }
+
+ /**
+ * A second mesh over the same body, cut for its own place in the scene.
+ *
+ * three's `Object3D.clone` builds through `new this.constructor()`, and this class's constructor
+ * wants a table — so every game that stood a second copy of a cooked model where it wanted a
+ * statue died in `assertTable` before drawing anything. The arguments are the source's own,
+ * which is what a copy is.
+ */
+ override clone(recursive = true): this {
+ return new ClusteredMesh(withOwnCut(this.geometry), this.material, this.table, {
+ errorPixels: this.errorPixels,
+ recutDistance: this.recutDistance,
+ }).copy(this, recursive) as this;
+ }
+
+ /**
+ * Becomes a copy of `source`, sharing the bake and the vertices and keeping a cut of its own.
+ *
+ * `Mesh.copy` shares the geometry outright, which this class cannot let it do: the cut lives in
+ * the geometry's index buffer, so two meshes on one geometry would overwrite each other's index
+ * every frame and both would draw whichever spoke last. The copy takes the vertex attributes by
+ * reference instead — one copy of them on the GPU, because three keys its attributes on the
+ * attribute — and the shared table, which is frozen at bake time and cannot go stale.
+ */
+ override copy(source: this, recursive = true): this {
+ super.copy(source, recursive);
+ if (!(source instanceof ClusteredMesh)) return this;
+ const geometry = withOwnCut(source.geometry);
+ this.#load(source.table, geometry);
+ this.geometry = geometry;
+ this.errorPixels = source.errorPixels;
+ this.recutDistance = source.recutDistance;
+ // Nothing this copy has drawn yet, so the next update has to cut it for where it stands.
+ this.#hasCut = false;
this.#compact(this.#finestCut());
+ return this;
}
/** Every cluster the bake produced from the source triangles — the mesh as authored. */
@@ -286,6 +322,23 @@ export class ClusteredMesh extends Mesh {
}
}
+/**
+ * The same vertices under a second geometry, so a second mesh can hold a cut of its own.
+ *
+ * `BufferGeometry.clone()` would do this and would also copy every position, normal and uv — the
+ * memory a cooked model exists not to spend twice. Sharing the `BufferAttribute` objects shares
+ * their GPU buffers too, because three keys its attribute cache on the attribute, not the geometry.
+ */
+function withOwnCut(geometry: BufferGeometry): BufferGeometry {
+ const shell = new BufferGeometry();
+ for (const [name, attribute] of Object.entries(geometry.attributes))
+ shell.setAttribute(name, attribute);
+ shell.boundingSphere = geometry.boundingSphere;
+ shell.boundingBox = geometry.boundingBox;
+ shell.groups = [...geometry.groups];
+ return shell;
+}
+
function radiusOf(table: IClusterTable): number {
let radius = 0;
for (let cluster = 0; cluster * 4 < table.bounds.length; cluster += 1)
diff --git a/packages/create-threenative/__tests__/build-assets.spec.ts b/packages/create-threenative/__tests__/build-assets.spec.ts
index 2ececfe10c..68edce4822 100644
--- a/packages/create-threenative/__tests__/build-assets.spec.ts
+++ b/packages/create-threenative/__tests__/build-assets.spec.ts
@@ -1,3 +1,4 @@
+import type { SpawnSyncReturns } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -17,9 +18,14 @@ vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal();
const { EventEmitter } = await import("node:events");
const spawn = ((command: string, args: readonly string[]) => {
- calls.push(`spawn:${path.basename(command)}`);
+ const script = path.basename(args[0] ?? command);
+ calls.push(`spawn:${script}`);
const child = new EventEmitter();
queueMicrotask(async () => {
+ if (script === "bundle.mjs") {
+ child.emit("exit", 23);
+ return;
+ }
// A real Vite build creates the outDir it was pointed at, and the web build refuses to
// publish one that is still missing — so the stub has to be as complete as what it replaces.
const index = args.indexOf("--outDir");
@@ -29,7 +35,15 @@ vi.mock("node:child_process", async (importOriginal) => {
});
return child;
}) as typeof actual.spawn;
- return { ...actual, spawn };
+ const spawnSync = (): SpawnSyncReturns => ({
+ pid: 0,
+ output: [null, "fixture + v8 build", ""],
+ signal: null,
+ status: 0,
+ stderr: "",
+ stdout: "fixture + v8 build",
+ });
+ return { ...actual, spawn, spawnSync };
});
afterEach(() => {
@@ -42,6 +56,21 @@ async function writeProject(root: string): Promise {
await writeFile(path.join(root, "src/game.ts"), "export default { start: async () => {} };\n");
}
+async function writeRuntime(root: string, available = true): Promise {
+ const runtime = path.join(root, "node_modules/@threenative/runtime-native");
+ await mkdir(path.join(runtime, "scripts"), { recursive: true });
+ const binary = path.join(runtime, "fixture-runtime");
+ await writeFile(binary, "fixture runtime identity");
+ await writeFile(
+ path.join(runtime, "package.json"),
+ JSON.stringify({ name: "@threenative/runtime-native", type: "module" }),
+ );
+ await writeFile(
+ path.join(runtime, "scripts/package-desktop.mjs"),
+ `export async function resolveDesktopRuntime() { ${available ? `return ${JSON.stringify(binary)};` : 'throw new Error("TN_TEST_RUNTIME_UNAVAILABLE");'} }\n`,
+ );
+}
+
describe("threenative build asset compilation", () => {
it("should compile assets before invoking vite", async () => {
const root = await makeTempDir("threenative-build-assets-web-");
@@ -55,13 +84,28 @@ describe("threenative build asset compilation", () => {
expect(spawnIndex).toBeGreaterThan(compileIndex);
});
+ it("should reject an unavailable desktop runtime before cooking assets", async () => {
+ const root = await makeTempDir("threenative-build-assets-unavailable-runtime-");
+ await writeProject(root);
+ await writeRuntime(root, false);
+
+ await expect(build({ cwd: root, target: "desktop" })).rejects.toThrow(
+ "TN_TEST_RUNTIME_UNAVAILABLE",
+ );
+ expect(calls).toEqual([]);
+ });
+
it("should compile assets before native packaging", async () => {
const root = await makeTempDir("threenative-build-assets-native-");
await writeProject(root);
- // Packaging cannot finish in a temp project (no runtime bundle output), but wherever it
- // stops, the asset compile must already have run.
- await expect(build({ cwd: root, target: "desktop" })).rejects.toThrow();
- expect(calls[0]).toBe("compile");
+ await writeRuntime(root);
+
+ // Runtime preflight succeeds; the controlled bundler failure must happen after cooking,
+ // before packaging, rather than an unrelated missing-runtime error satisfying the test.
+ await expect(build({ cwd: root, target: "desktop" })).rejects.toThrow(
+ `${path.basename(process.execPath)} exited with code 23.`,
+ );
+ expect(calls).toEqual(["compile", "spawn:bundle.mjs"]);
});
});
diff --git a/packages/create-threenative/__tests__/build.spec.ts b/packages/create-threenative/__tests__/build.spec.ts
index f9cb892b44..41cf385abf 100644
--- a/packages/create-threenative/__tests__/build.spec.ts
+++ b/packages/create-threenative/__tests__/build.spec.ts
@@ -17,6 +17,7 @@ import {
nativeOrientation,
parseBuildArgs,
publishStagedArtifact,
+ resolveRuntimeAssetCapabilities,
runtimeHasWebAssembly,
stagingPath,
writePackagingConfig,
@@ -878,7 +879,7 @@ cpSync("public", out, { recursive: true });
);
});
- it("guards web-only UI on every native target and WASM on mobile only", async () => {
+ it("guards web-only UI on every native target and WASM without a known desktop V8 artifact", async () => {
const root = await makeTempDir("threenative-mobile-bundle-");
roots.push(root);
const native = path.join(root, "native.js");
@@ -893,7 +894,15 @@ cpSync("public", out, { recursive: true });
/TN_NATIVE_WEB_ONLY_UI.*src\/main\.ts.*PRD-051/u,
);
}
- await expect(assertNativeBundleCompatible(wasm, "desktop")).resolves.toBeUndefined();
+ await expect(assertNativeBundleCompatible(wasm, "desktop")).rejects.toThrow(
+ "TN_NATIVE_WASM_UNSUPPORTED",
+ );
+ const desktopV8 = resolveRuntimeAssetCapabilities(
+ "desktop",
+ path.resolve("packages/create-threenative/src/build.ts"),
+ (() => ({ status: 0, stdout: "+ v8 build" })) as never,
+ );
+ await expect(assertNativeBundleCompatible(wasm, "desktop", desktopV8)).resolves.toBeUndefined();
for (const target of ["android", "ios"] as const) {
await expect(assertNativeBundleCompatible(wasm, target)).rejects.toThrow(
/TN_NATIVE_WASM_ON_MOBILE.*src\/game\.ts.*PRD-052/u,
@@ -966,14 +975,16 @@ describe("runtime WebAssembly capability", () => {
).toBe(true);
});
- it("keeps the WASM desktop backend when the runtime is unknown or unreadable", () => {
- expect(runtimeHasWebAssembly(undefined)).toBe(true);
- expect(runtimeHasWebAssembly("/no/such/runtime")).toBe(true);
- expect(runtimeHasWebAssembly(runtime, (() => ({ status: 1, stdout: "" })) as never)).toBe(true);
+ it("refuses WASM when the runtime is unknown or unreadable", () => {
+ expect(runtimeHasWebAssembly(undefined)).toBe(false);
+ expect(runtimeHasWebAssembly("/no/such/runtime")).toBe(false);
+ expect(runtimeHasWebAssembly(runtime, (() => ({ status: 1, stdout: "" })) as never)).toBe(
+ false,
+ );
expect(
runtimeHasWebAssembly(runtime, (() => {
throw new Error("EACCES");
}) as never),
- ).toBe(true);
+ ).toBe(false);
});
});
diff --git a/packages/create-threenative/__tests__/consumer-cli.spec.ts b/packages/create-threenative/__tests__/consumer-cli.spec.ts
index 3ecdc5fa40..96d77bac2a 100644
--- a/packages/create-threenative/__tests__/consumer-cli.spec.ts
+++ b/packages/create-threenative/__tests__/consumer-cli.spec.ts
@@ -44,10 +44,14 @@ async function desktopCallerFixture(): Promise<{ root: string; log: string }> {
`
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
+ import { pathToFileURL } from 'node:url';
+ export async function resolveDesktopRuntime(explicit) { return explicit ?? import.meta.filename; }
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const output = process.argv[process.argv.indexOf('--output') + 1];
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, 'packaged');
writeFileSync(${JSON.stringify(log)}, JSON.stringify(process.argv.slice(2)));
+ }
`,
);
return { root, log };
@@ -57,7 +61,11 @@ test("desktop caller does not reinterpret a source checkout as an explicit runti
const { root, log } = await desktopCallerFixture();
process.env.THREENATIVE_RUNTIME_SOURCE = path.join(root, "checkout");
await build({ cwd: root, target: "desktop" });
- assert.ok(!JSON.parse(await readFile(log, "utf8")).includes("--runtime"));
+ const args: string[] = JSON.parse(await readFile(log, "utf8"));
+ assert.equal(
+ args[args.indexOf("--runtime") + 1],
+ path.join(root, "node_modules/@threenative/runtime-native/scripts/package-desktop.mjs"),
+ );
});
test("explicit desktop binary still wins when a source override is also set", async () => {
diff --git a/packages/create-threenative/__tests__/native-consumer.spec.ts b/packages/create-threenative/__tests__/native-consumer.spec.ts
index 0186b34e8d..96312220bd 100644
--- a/packages/create-threenative/__tests__/native-consumer.spec.ts
+++ b/packages/create-threenative/__tests__/native-consumer.spec.ts
@@ -6,6 +6,7 @@ import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
+import { encodeToKTX2 } from "ktx2-encoder";
import { afterEach, test } from "vitest";
import { makeTempDir } from "../../../test-support/temp-dir.js";
import { build, parseBuildArgs } from "../src/build.js";
@@ -46,26 +47,34 @@ async function callerFixture(): Promise<{ root: string; log: string }> {
`,
);
const log = path.join(root, "packager-args.json");
+ await writeFile(path.join(root, "selected-runtime"), "fixture runtime: unknown engine");
for (const target of ["desktop", "android", "ios"]) {
await writeFile(
path.join(runtime, `scripts/package-${target}.mjs`),
`
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
+ import { pathToFileURL } from 'node:url';
+ export async function resolveDesktopRuntime(explicit) {
+ return explicit ?? ${JSON.stringify(path.join(root, "selected-runtime"))};
+ }
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const output = process.argv[process.argv.indexOf('--output') + 1];
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, 'packaged');
writeFileSync(${JSON.stringify(log)}, JSON.stringify(process.argv.slice(2)));
+ }
`,
);
}
return { root, log };
}
-test("desktop consumer caller leaves runtime discovery to the packager", async () => {
+test("desktop consumer caller resolves the exact package runtime before cooking", async () => {
const { root, log } = await callerFixture();
await build({ cwd: root, target: "desktop" });
- assert.ok(!JSON.parse(await readFile(log, "utf8")).includes("--runtime"));
+ const args: string[] = JSON.parse(await readFile(log, "utf8"));
+ assert.equal(args[args.indexOf("--runtime") + 1], path.join(root, "selected-runtime"));
});
test("desktop caller preserves an explicit maintainer binary including spaces", async () => {
@@ -76,6 +85,26 @@ test("desktop caller preserves an explicit maintainer binary including spaces",
assert.equal(args[args.indexOf("--runtime") + 1], process.env.THREENATIVE_RUNTIME_BINARY);
});
+test("a codec refusal in the live desktop build preserves the previous packaged artifact", async () => {
+ const { root, log } = await callerFixture();
+ await mkdir(path.join(root, "assets"));
+ await mkdir(path.join(root, "dist-native"));
+ const final = path.join(root, "dist-native/consumer-test");
+ await writeFile(final, "previous working package");
+ const ktx2 = await encodeToKTX2(new Uint8Array([0]), {
+ generateMipmap: false,
+ imageDecoder: async () => ({ data: new Uint8Array(4 * 4 * 4).fill(255), height: 4, width: 4 }),
+ isUASTC: true,
+ });
+ await writeFile(path.join(root, "assets/rock.ktx2"), ktx2);
+ await assert.rejects(
+ build({ cwd: root, target: "desktop" }),
+ /TN_NATIVE_KTX2_UNSUPPORTED.*rock.ktx2.*selected-runtime.*unknown.*Basis\/KTX2/u,
+ );
+ assert.equal(await readFile(final, "utf8"), "previous working package");
+ await assert.rejects(readFile(log), { code: "ENOENT" });
+});
+
test("Android source opt-in survives parsing and subprocess dispatch", async () => {
const { root, log } = await callerFixture();
const options = parseBuildArgs(["build", "--target", "android", "--allow-source-build"]);
diff --git a/packages/create-threenative/__tests__/native-ktx2.spec.ts b/packages/create-threenative/__tests__/native-ktx2.spec.ts
index 149720ce97..0909f554b1 100644
--- a/packages/create-threenative/__tests__/native-ktx2.spec.ts
+++ b/packages/create-threenative/__tests__/native-ktx2.spec.ts
@@ -7,12 +7,23 @@ import { encodeToKTX2 } from "ktx2-encoder";
import { afterEach, describe, expect, it } from "vitest";
import { buildFixtureGlb } from "../../../test-support/generate-fixture-model.js";
import { makeTempDir } from "../../../test-support/temp-dir.js";
-import { assertNativeAssetsCompatible, assertNativeBundleCompatible, build } from "../src/build.js";
+import {
+ assertNativeAssetsCompatible,
+ assertNativeBundleCompatible,
+ build,
+ resolveRuntimeAssetCapabilities,
+} from "../src/build.js";
import { loadConfig } from "../src/config.js";
const run = promisify(execFile);
const roots: string[] = [];
const bundler = path.resolve("packages/runtime-native/scripts/bundle.mjs");
+const desktopV8 = () =>
+ resolveRuntimeAssetCapabilities(
+ "desktop",
+ path.resolve("packages/create-threenative/src/build.ts"),
+ (() => ({ status: 0, stdout: "+ v8 build" })) as never,
+ );
const viteInstall = path.resolve(import.meta.dirname, "../node_modules/vite");
afterEach(async () => {
@@ -43,7 +54,11 @@ export default defineGame({ scenes: {} });
return project;
}
-async function bundle(project: string, target: "android" | "desktop" | "ios"): Promise {
+async function bundle(
+ project: string,
+ target: "android" | "desktop" | "ios",
+ nativeBackend = false,
+): Promise {
const output = path.join(project, `dist/${target}.js`);
await run(
process.execPath,
@@ -57,6 +72,7 @@ async function bundle(project: string, target: "android" | "desktop" | "ios"): P
target,
"--output",
output,
+ ...(nativeBackend ? ["--native-backend"] : []),
],
{ cwd: project },
);
@@ -111,12 +127,24 @@ describe("compressed assets on native targets", () => {
}
}, 180_000);
+ it("bundles decoder-free desktop QuickJS without WASM decoders", async () => {
+ const project = await gameProject("threenative-ktx2-quickjs-");
+ const output = await bundle(project, "desktop", true);
+ const source = await readFile(output, "utf8");
+ expect(source).not.toMatch(/\bWebAssembly\b/u);
+ expect(source).toContain("TN_NATIVE_KTX2_UNSUPPORTED");
+ expect(source).toContain("TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED");
+ await expect(assertNativeBundleCompatible(output, "desktop")).resolves.toBeUndefined();
+ }, 180_000);
+
it("keeps three's real decoders on desktop, which has a WASM engine", async () => {
const project = await gameProject("threenative-ktx2-desktop-");
const output = await bundle(project, "desktop");
const source = await readFile(output, "utf8");
- await expect(assertNativeBundleCompatible(output, "desktop")).resolves.toBeUndefined();
+ await expect(
+ assertNativeBundleCompatible(output, "desktop", desktopV8()),
+ ).resolves.toBeUndefined();
expect(source).toMatch(/\bWebAssembly\b/u);
expect(source).not.toContain("TN_NATIVE_KTX2_UNSUPPORTED");
}, 180_000);
@@ -219,7 +247,9 @@ export default defineGame({ scenes: {} });
const config = await loadConfig(project);
for (const target of ["desktop", "web"] as const) {
- await expect(assertNativeAssetsCompatible(project, target, config)).resolves.toBeUndefined();
+ await expect(
+ assertNativeAssetsCompatible(project, target, config, desktopV8()),
+ ).resolves.toBeUndefined();
}
}, 60_000);
diff --git a/packages/create-threenative/__tests__/templates-native.spec.ts b/packages/create-threenative/__tests__/templates-native.spec.ts
index d2ceac1d7c..cc0133f14f 100644
--- a/packages/create-threenative/__tests__/templates-native.spec.ts
+++ b/packages/create-threenative/__tests__/templates-native.spec.ts
@@ -53,6 +53,21 @@ async function bundle(
}
/** A project with a stub runtime-native whose desktop packager is `packager`. */
+/** Package entry is both the selected-runtime resolver and a subprocess executable. */
+async function writePackager(runtime: string, target: string, source: string): Promise {
+ await writeFile(path.join(runtime, `scripts/package-${target}-execute.mjs`), source);
+ await writeFile(
+ path.join(runtime, `scripts/package-${target}.mjs`),
+ `
+import { pathToFileURL } from "node:url";
+export async function resolveDesktopRuntime() { return import.meta.filename; }
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await import("./package-${target}-execute.mjs");
+}
+`,
+ );
+}
+
async function stubRuntime(
project: string,
packager: string,
@@ -79,7 +94,7 @@ await mkdir(path.dirname(output), { recursive: true });
await writeFile(output, "globalThis.__nativeProof = true;\\n");
`,
);
- await writeFile(path.join(runtime, "scripts/package-desktop.mjs"), packager);
+ await writePackager(runtime, "desktop", packager);
}
/** The packager the staged-publish tests share: a complete run that writes its output. */
@@ -408,8 +423,9 @@ await writeFile(output, "globalThis.__nativeProof = true;\\n");
`,
);
for (const target of ["desktop", "android", "ios"] as const) {
- await writeFile(
- path.join(runtime, `scripts/package-${target}.mjs`),
+ await writePackager(
+ runtime,
+ target,
`import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
const output = process.argv[process.argv.indexOf("--output") + 1];
@@ -462,8 +478,9 @@ await writeFile(output, "export default { start() {} };\\n");
`,
);
for (const target of ["desktop", "android", "ios"] as const) {
- await writeFile(
- path.join(runtime, `scripts/package-${target}.mjs`),
+ await writePackager(
+ runtime,
+ target,
`import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
const output = process.argv[process.argv.indexOf("--output") + 1];
diff --git a/packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts b/packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts
new file mode 100644
index 0000000000..c5e971497f
--- /dev/null
+++ b/packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts
@@ -0,0 +1,151 @@
+import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { compileAssets } from "@threenative/assets";
+import { afterEach, describe, expect, it } from "vitest";
+import { makeTempDir } from "../../../test-support/temp-dir.js";
+import {
+ assertNativeAssetsCompatible,
+ resolveRuntimeAssetCapabilities,
+ runtimeHasWebAssembly,
+} from "../src/build.js";
+
+const roots: string[] = [];
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true })));
+});
+
+const runtime = path.resolve("packages/create-threenative/src/build.ts");
+
+describe("VQ-01 native asset capability contract", () => {
+ it.each([
+ { target: "web", engine: "v8", expected: "browser", wasm: true, decoder: true },
+ { target: "desktop", engine: "v8", expected: "v8", wasm: true, decoder: true },
+ { target: "desktop", engine: "quickjs", expected: "quickjs", wasm: false, decoder: false },
+ { target: "desktop", engine: "unknown", expected: "unknown", wasm: false, decoder: false },
+ { target: "android", engine: "v8", expected: "unknown", wasm: false, decoder: false },
+ { target: "android", engine: "quickjs", expected: "unknown", wasm: false, decoder: false },
+ { target: "ios", engine: "v8", expected: "unknown", wasm: false, decoder: false },
+ ] as const)(
+ "keeps $target separate from the build-host $engine engine",
+ ({ target, engine, expected, wasm, decoder }) => {
+ let probes = 0;
+ const capabilities = resolveRuntimeAssetCapabilities(target, runtime, (() => {
+ probes++;
+ return { status: 0, stdout: `Native WebGPU JS runtime - wgpu-native + ${engine} build` };
+ }) as never);
+ expect(capabilities.engine).toBe(expected);
+ expect(capabilities.webAssembly).toBe(wasm);
+ expect(capabilities.decoders).toEqual({ ktx2: decoder, meshopt: decoder, draco: decoder });
+ expect(probes).toBe(target === "desktop" ? 1 : 0);
+ },
+ );
+
+ it("changes the runtime identity when the selected bytes change at the same path", async () => {
+ const root = await makeTempDir("vq-runtime-identity-");
+ roots.push(root);
+ const binary = path.join(root, "runtime");
+ const probe = (() => ({ status: 0, stdout: "+ v8 build" })) as never;
+ await writeFile(binary, "cohort one");
+ const before = resolveRuntimeAssetCapabilities("desktop", binary, probe);
+ await writeFile(binary, "cohort two");
+ const after = resolveRuntimeAssetCapabilities("desktop", binary, probe);
+ expect(before.identity).not.toBe(after.identity);
+ expect(before.artifact).toBe(after.artifact);
+ });
+
+ it.each(["runtimeIdentity", "runtimeDecoders"] as const)(
+ "invalidates cooked output when %s changes even with identical passes",
+ async (field) => {
+ const root = await makeTempDir("vq-runtime-cache-");
+ roots.push(root);
+ await mkdir(path.join(root, "assets"));
+ await writeFile(path.join(root, "assets/unchanged.txt"), "same source bytes");
+ const options = {
+ cwd: root,
+ config: { audio: "none", models: "none", textures: "none" } as const,
+ runtimeDecoders: { ktx2: false, meshopt: false },
+ runtimeIdentity: "desktop:cohort-one:quickjs",
+ };
+ await compileAssets(options);
+ const before = JSON.parse(
+ await readFile(path.join(root, "public/assets.manifest.json"), "utf8"),
+ );
+ const changed =
+ field === "runtimeIdentity"
+ ? { ...options, runtimeIdentity: "desktop:cohort-two:quickjs" }
+ : { ...options, runtimeDecoders: { ktx2: true, meshopt: false } };
+ await compileAssets(changed);
+ const after = JSON.parse(
+ await readFile(path.join(root, "public/assets.manifest.json"), "utf8"),
+ );
+ expect(after.entries["unchanged.txt"].output).not.toBe(
+ before.entries["unchanged.txt"].output,
+ );
+ await expect(
+ readFile(path.join(root, "public", after.entries["unchanged.txt"].output), "utf8"),
+ ).resolves.toBe("same source bytes");
+ },
+ );
+
+ it.each([
+ { name: "missing artifact", binary: "/no/such/runtime", result: { status: 0, stdout: "" } },
+ { name: "failed probe", binary: runtime, result: { status: 1, stdout: "" } },
+ {
+ name: "unrecognized engine",
+ binary: runtime,
+ result: { status: 0, stdout: "+ future-engine build" },
+ },
+ {
+ name: "no engine declaration",
+ binary: runtime,
+ result: { status: 0, stdout: "v8 is installed on the host" },
+ },
+ ])("does not infer WASM from $name", ({ binary, result }) => {
+ expect(runtimeHasWebAssembly(binary, (() => result) as never)).toBe(false);
+ });
+
+ it.each([
+ { codec: "KTX2", entry: { output: "rock.ktx2" }, code: "TN_NATIVE_KTX2_UNSUPPORTED" },
+ {
+ codec: "Meshopt",
+ entry: { output: "rock.glb", extensions: ["EXT_meshopt_compression"] },
+ code: "TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED",
+ },
+ {
+ codec: "Draco",
+ entry: { output: "rock.glb", extensions: ["KHR_draco_mesh_compression"] },
+ code: "TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED",
+ },
+ {
+ codec: "embedded KTX2",
+ entry: { output: "rock.glb", extensions: ["KHR_texture_basisu"] },
+ code: "TN_NATIVE_KTX2_UNSUPPORTED",
+ },
+ {
+ codec: "shared KTX2",
+ entry: { output: "rock.glb", sharedImages: [{ output: "rock.ktx2", codec: "ktx2" }] },
+ code: "TN_NATIVE_KTX2_UNSUPPORTED",
+ },
+ ])(
+ "refuses $codec on an unknown desktop artifact, preserving the previous package",
+ async ({ entry, code }) => {
+ const root = await makeTempDir("vq-native-assets-");
+ roots.push(root);
+ await mkdir(path.join(root, "public"));
+ await mkdir(path.join(root, "dist-native"));
+ await writeFile(path.join(root, "dist-native/game"), "previous working artifact");
+ await writeFile(
+ path.join(root, "public/assets.manifest.json"),
+ JSON.stringify({ entries: { "rocks/rock": entry }, version: 1 }),
+ );
+ const config = { assets: { output: "public" } } as Parameters<
+ typeof assertNativeAssetsCompatible
+ >[2];
+ await expect(assertNativeAssetsCompatible(root, "desktop", config)).rejects.toThrow(code);
+ await expect(readFile(path.join(root, "dist-native/game"), "utf8")).resolves.toBe(
+ "previous working artifact",
+ );
+ await expect(assertNativeAssetsCompatible(root, "web", config)).resolves.toBeUndefined();
+ },
+ );
+});
diff --git a/packages/create-threenative/__tests__/vq-native-fixture.spec.ts b/packages/create-threenative/__tests__/vq-native-fixture.spec.ts
new file mode 100644
index 0000000000..e9a3cf2751
--- /dev/null
+++ b/packages/create-threenative/__tests__/vq-native-fixture.spec.ts
@@ -0,0 +1,238 @@
+import { readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { compileAssets } from "@threenative/assets";
+import { createAssetLoader } from "@threenative/core";
+import {
+ AnimationClip,
+ Group,
+ PerspectiveCamera,
+ QuaternionKeyframeTrack,
+ Scene,
+ Texture,
+ type Group as ThreeGroup,
+} from "three";
+import { describe, expect, it, test, vi } from "vitest";
+import {
+ MODEL_NAMES,
+ generateNativeAssetFixture,
+ inspectFixtureModel,
+} from "../../../examples/abyss-framework/vq-assets/generate.js";
+import { AssetScene } from "../../../examples/abyss-framework/vq-assets/src/game.js";
+import config from "../../../examples/abyss-framework/vq-assets/threenative.config.js";
+import { makeTempDir } from "../../../test-support/temp-dir.js";
+import { assertNativeAssetsCompatible } from "../src/build.js";
+import { loadConfig } from "../src/config.js";
+
+/** One glb with the shape the fixture's models have: a node the player can find and a turn clip. */
+function fixtureModel(name: string): { scene: Group; animations: AnimationClip[] } {
+ const scene = new Group();
+ scene.name = name;
+ const node = new Group();
+ node.name = `${name}-animated`;
+ scene.add(node);
+ const turn = new AnimationClip("turn", 2, [
+ new QuaternionKeyframeTrack(
+ `${node.name}.quaternion`,
+ [0, 1, 2],
+ [0, 0, 0, 1, 0, Math.sin(0.3), 0, Math.cos(0.3), 0, 0, 0, 1],
+ ),
+ ]);
+ return { scene, animations: [turn] };
+}
+
+type Owned = { geometries: number; textures: number };
+
+/**
+ * One enter against a stand-in context: `press` is the key the lifecycle scenario's step delivers,
+ * and `tally` is what the renderer's live counts would report for the frame being drawn.
+ */
+function enterOnce(options: { press: () => boolean; tally: () => Owned | undefined }) {
+ const assets = createAssetLoader({
+ model: async (url) => fixtureModel(url.replace(/^.*\//u, "").replace(/\.glb$/u, "")),
+ texture: async () => new Texture(),
+ });
+ const scene = new Scene();
+ const published: Record = {};
+ const left: string[] = [];
+ const ctx = {
+ assets,
+ camera: new PerspectiveCamera(),
+ add: (object: ThreeGroup) => scene.add(object),
+ goto: async (name: string) => {
+ left.push(name);
+ },
+ input: {
+ justPressed: (action: string) => action === "lifecycle" && options.press(),
+ },
+ renderer: {
+ get info() {
+ return { memory: options.tally() };
+ },
+ },
+ scene,
+ state: { set: (values: Record) => Object.assign(published, values) },
+ } as unknown as Parameters[0];
+ return { ctx, left, published };
+}
+
+/**
+ * The fixture's own leave/re-enter sequence, entered `enters` times in one process. The module is
+ * re-imported per run because the lifetime ledger that counts the enters is module scope — that is
+ * the whole reason only a value outliving the scene can count its own re-entries.
+ */
+async function runLifecycle(options: {
+ enters: number;
+ press: (enter: number) => boolean;
+ tally: (enter: number, drawn: number) => Owned | undefined;
+}): Promise>> {
+ vi.resetModules();
+ const { AssetScene: Scene_ } = await import(
+ "../../../examples/abyss-framework/vq-assets/src/game.js"
+ );
+ const observed: Array> = [];
+ for (let enter = 1; enter <= options.enters; enter += 1) {
+ let drawn = 0;
+ const { ctx, left, published } = enterOnce({
+ press: () => options.press(enter),
+ tally: () => options.tally(enter, drawn++),
+ });
+ const assetScene = new Scene_();
+ await assetScene.load(ctx);
+ const frame = assetScene.enter(ctx);
+ if (typeof frame !== "function") throw new Error("enter() returned no frame function");
+ for (let tick = 0; tick < 900 && left.length === 0; tick += 1) frame(ctx, 1 / 60);
+ observed.push({ ...published, left: left.length > 0 });
+ }
+ return observed;
+}
+
+test("real Meshopt and Draco inputs retain decoded geometry, images and animation through the decoder-free cook", async () => {
+ const cwd = await makeTempDir("vq01-fixture-");
+ const source = path.join(cwd, config.assets.source);
+ const ktx2 = await generateNativeAssetFixture(source);
+ await writeFile(path.join(cwd, "package.json"), '{"name":"vq-fixture","type":"module"}');
+ await writeFile(
+ path.join(cwd, "threenative.config.ts"),
+ `export default ${JSON.stringify(config)};`,
+ );
+ const before = await Promise.all(
+ MODEL_NAMES.map((name) => inspectFixtureModel(path.join(source, `${name}.glb`))),
+ );
+ expect(before[0]?.extensions).toContain("EXT_meshopt_compression");
+ expect(before[1]?.extensions).toContain("KHR_draco_mesh_compression");
+ await compileAssets({
+ cwd,
+ config: config.assets,
+ platform: "desktop",
+ runtimeDecoders: { ktx2: false, meshopt: false },
+ runtimeIdentity: "test:quickjs",
+ });
+ const output = path.join(cwd, config.assets.output);
+ const manifest = JSON.parse(await readFile(path.join(output, "assets.manifest.json"), "utf8"));
+ for (const [index, name] of MODEL_NAMES.entries()) {
+ const original = before[index];
+ if (!original) throw new Error("Missing source measurement");
+ const cooked = await inspectFixtureModel(
+ path.join(output, manifest.entries[`${name}.glb`].output),
+ );
+ expect(cooked.extensions).not.toContain("EXT_meshopt_compression");
+ expect(cooked.extensions).not.toContain("KHR_draco_mesh_compression");
+ expect(cooked.triangles).toBe(original.triangles);
+ expect(cooked.positions).toEqual(original.positions);
+ expect(cooked.animation).toEqual(original.animation);
+ expect(cooked.images.map((image) => image.data)).toEqual(
+ original.images.map((image) => image.data),
+ );
+ }
+ expect(manifest.entries["checker.png"].output).toMatch(/\.png$/u);
+ await expect(
+ assertNativeAssetsCompatible(cwd, "desktop", await loadConfig(cwd)),
+ ).resolves.toBeUndefined();
+ await writeFile(path.join(source, "authored.ktx2"), ktx2);
+ await compileAssets({
+ cwd,
+ config: config.assets,
+ platform: "desktop",
+ runtimeDecoders: { ktx2: false, meshopt: false },
+ runtimeIdentity: "test:quickjs",
+ });
+ await expect(assertNativeAssetsCompatible(cwd, "desktop", await loadConfig(cwd))).rejects.toThrow(
+ /TN_NATIVE_KTX2_UNSUPPORTED.*authored.ktx2/u,
+ );
+});
+
+test("fixture exit disposes its configured PNG clone and cached original exactly once", async () => {
+ const original = new Texture();
+ const assets = createAssetLoader({
+ model: async () => ({ scene: new Group(), animations: [] }),
+ texture: async () => original,
+ });
+ const scene = new AssetScene();
+ const ctx = { assets } as Parameters[0];
+ const dispose = vi.spyOn(Texture.prototype, "dispose");
+ try {
+ await scene.load(ctx);
+ scene.exit(ctx);
+ expect(dispose).toHaveBeenCalledTimes(2);
+ expect(new Set(dispose.mock.contexts).size).toBe(2);
+ expect(dispose.mock.contexts).toContain(original);
+ scene.exit(ctx);
+ expect(dispose).toHaveBeenCalledTimes(2);
+ } finally {
+ dispose.mockRestore();
+ assets.clear();
+ }
+});
+
+describe("the fixture's leave/re-enter cycle", () => {
+ /** Six geometries and three sampled textures: the fixture's own live set, already uploaded. */
+ const steady = (): Owned => ({ geometries: 6, textures: 3 });
+
+ it("stays in its scene unless the lifecycle scenario asks it to leave", async () => {
+ const observed = await runLifecycle({ enters: 4, press: () => false, tally: steady });
+
+ expect(observed.map((entry) => entry.left)).toEqual([false, false, false, false]);
+ expect(observed.map((entry) => entry.frames)).toEqual([900, 900, 900, 900]);
+ });
+
+ it("leaves and re-enters once the lifecycle scenario presses its key", async () => {
+ const observed = await runLifecycle({ enters: 4, press: () => true, tally: steady });
+
+ expect(observed.map((entry) => entry.entries)).toEqual([1, 2, 3, 4]);
+ // Three leaves, then the fourth enter stays: one load plus three leave/re-enter cycles.
+ expect(observed.map((entry) => entry.left)).toEqual([true, true, true, false]);
+ });
+
+ it("reads an enter only once its tally has settled, so a climbing upload is not a leak", async () => {
+ // `info.memory` counts what the backend has initialised, which lands over several frames: a
+ // baseline read on the first measured frame is smaller than the scene's own live set, and the
+ // next enter's larger sample then reads as growth it never had.
+ const climbing = (enter: number, drawn: number): Owned => ({
+ geometries: Math.min(6, enter === 1 ? 1 + drawn / 20 : 6),
+ textures: 3,
+ });
+ const observed = await runLifecycle({
+ enters: 4,
+ press: (enter) => enter >= 2,
+ tally: climbing,
+ });
+
+ expect(observed[3]?.geometryGrowth).toBe(0);
+ expect(observed[3]?.textureGrowth).toBe(0);
+ expect(observed[3]?.ownedGeometries).toBe(6);
+ });
+
+ it("still reports growth when a later enter leaves more live than the first", async () => {
+ // The half that keeps the measurement honest: a measurement that never reports growth would
+ // pass the case above by refusing to measure at all.
+ const leaking = (enter: number): Owned => ({ geometries: 2 * enter, textures: enter });
+ const observed = await runLifecycle({
+ enters: 4,
+ press: (enter) => enter >= 2,
+ tally: leaking,
+ });
+
+ expect(observed[3]?.geometryGrowth).toBe(6);
+ expect(observed[3]?.textureGrowth).toBe(3);
+ });
+});
diff --git a/packages/create-threenative/agent-docs/references/finding-assets.md b/packages/create-threenative/agent-docs/references/finding-assets.md
index aefade4aee..cb4bc6386f 100644
--- a/packages/create-threenative/agent-docs/references/finding-assets.md
+++ b/packages/create-threenative/agent-docs/references/finding-assets.md
@@ -135,6 +135,23 @@ Then load the returned path the ordinary way:
`ctx.assets.model("fab/soul-cave/.../SM_S_Soul_Statue.glb")`. The normal asset compiler picks the
GLBs up from `assets/` with no extra configuration.
+**Cook the pack with the default settings. `models: "none"` and `textures: "none"` in
+`threenative.config.ts` are not a fix, and a reason to reach for them is a bug report, not a
+workaround.** A converted Fab pack is ordinary input to that cook, and the cook is where an
+expensive import stops being expensive: embedded PNG and WebP maps are transcoded to KTX2 and
+capped, mesh attributes are quantized and Meshopt-compressed, and a `MASK` (alpha-cutout)
+primitive is handed a discrete LOD chain, so the LOD0 triangle count in the GLB is not the
+triangle count the scene pays for. One measured tree: `SM_EuropeanHornbeam_Forest_01.glb` cooks
+126,934,280 → 26,428,684 B, its six embedded PNGs → KTX2 (45.4 MB → 16.4 MB), and its
+755,677-triangle foliage primitive down to a 5.1% far rung. Turning a pass off ships every source
+byte verbatim instead, counts all of them against `assets.budget.uncooked`, and is precisely what
+makes `threenative build` fail with `TN_ASSETS_BUDGET_EXCEEDED` — read `dist.build-report.json`
+before you conclude a pack is too big. An extension the cook owns no pass for (`.dna`,
+`.strands.bin`, a vendor data file) is copied through and still resolves through the same manifest
+entry, so nothing needs `public/` to serve it. Ignore what the cook writes beside the manifest —
+`public/basis/`, `public/shared/` and `public/bake.receipt.json` are outputs too, and a
+content-addressed name is not the only output name.
+
What the import will and will not do:
- **A pack of uncooked editor assets cannot be converted at all**, and this is the common case for
diff --git a/packages/create-threenative/src/build.ts b/packages/create-threenative/src/build.ts
index 62497e8c9b..80087bba21 100644
--- a/packages/create-threenative/src/build.ts
+++ b/packages/create-threenative/src/build.ts
@@ -1,6 +1,6 @@
import { spawn, spawnSync } from "node:child_process";
-import { randomUUID } from "node:crypto";
-import { existsSync } from "node:fs";
+import { createHash, randomUUID } from "node:crypto";
+import { existsSync, readFileSync } from "node:fs";
import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
@@ -70,6 +70,7 @@ async function projectName(cwd: string): Promise {
export async function assertNativeBundleCompatible(
bundle: string,
target: NativeBuildTarget,
+ capabilities = resolveRuntimeAssetCapabilities(target),
): Promise {
const source = await readFile(bundle, "utf8");
const webOnlyUi = [
@@ -81,7 +82,7 @@ export async function assertNativeBundleCompatible(
`TN_NATIVE_WEB_ONLY_UI: ${target} bundle contains ${webOnlyUi.map(([label]) => label).join(", ")}. Keep the portable game in src/game.ts and move DOM or React mounting to src/main.ts; native UI is owned by PRD-051.`,
);
}
- if (target === "desktop") return;
+ if (target === "desktop" && capabilities.webAssembly) return;
const wasm = [
["WebAssembly", /\bWebAssembly\b/u],
["Rapier WASM", /rapier_wasm|RAPIER_VERSION|rawrapier/u],
@@ -89,7 +90,7 @@ export async function assertNativeBundleCompatible(
].filter(([, pattern]) => (pattern as RegExp).test(source));
if (wasm.length === 0) return;
throw new Error(
- `TN_NATIVE_WASM_ON_MOBILE: ${target} bundle contains ${wasm.map(([label]) => label).join(", ")}. Move web-only WASM imports out of src/game.ts or provide a threenative-native conditional backend; mobile navigation is owned by PRD-052.`,
+ `${target === "desktop" ? "TN_NATIVE_WASM_UNSUPPORTED" : "TN_NATIVE_WASM_ON_MOBILE"}: ${target} artifact ${capabilities.artifact} (${capabilities.engine}) bundle contains ${wasm.map(([label]) => label).join(", ")}. Move web-only WASM imports out of src/game.ts or provide a threenative-native conditional backend; mobile navigation is owned by PRD-052.`,
);
}
@@ -137,30 +138,22 @@ export function assetRoot(cwd: string, config: IResolvedThreeNativeConfig): stri
return path.resolve(cwd, config.assets?.output ?? "public");
}
-/**
- * Refuses compiled assets a mobile native target cannot decode, at the first point the build
- * knows about them — after the compile step, before a bundle or an APK exists.
- *
- * Android runs QuickJS and iOS runs JavaScriptCore without a WASM JIT, so neither has
- * `WebAssembly`. Three's Basis/zstd transcoder, its Meshopt decoder and Draco's wasm decoder
- * therefore cannot run there and are not in the mobile bundle at all (they are replaced in
- * `runtime-native/scripts/bundle.mjs`, which is what keeps `TN_NATIVE_WASM_ON_MOBILE` green).
- * A game that genuinely ships such an asset must hear that here rather than discover it as a
- * missing texture on a phone.
- */
+/** Refuse compiled codecs the selected runtime/bundle cannot decode before packaging starts. */
export async function assertNativeAssetsCompatible(
cwd: string,
target: BuildTarget,
config: IResolvedThreeNativeConfig,
+ capabilities = resolveRuntimeAssetCapabilities(target),
): Promise {
- if (target === "desktop" || target === "web") return;
+ if (target === "web") return;
const outputRoot = assetRoot(cwd, config);
const manifestPath = path.join(outputRoot, "assets.manifest.json");
let raw: string;
try {
raw = await readFile(manifestPath, "utf8");
- } catch {
- return;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
+ throw error;
}
let parsed: unknown;
try {
@@ -170,32 +163,46 @@ export async function assertNativeAssetsCompatible(
`TN_NATIVE_ASSET_MANIFEST_INVALID: '${manifestPath}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
- const entries = (parsed as { entries?: unknown }).entries;
- if (typeof entries !== "object" || entries === null) {
+ const entries = (parsed as { entries?: unknown } | null)?.entries;
+ if (typeof entries !== "object" || entries === null || Array.isArray(entries)) {
throw new Error(`TN_NATIVE_ASSET_MANIFEST_INVALID: '${manifestPath}' has no 'entries' object.`);
}
const rows = Object.entries(entries as Record).map(
([logical, entry]) => [logical, (entry ?? {}) as Record] as const,
);
const ktx2 = rows
- .filter(([, entry]) => typeof entry.output === "string" && /\.ktx2$/iu.test(entry.output))
+ .filter(
+ ([, entry]) =>
+ (typeof entry.output === "string" && /\.ktx2$/iu.test(entry.output)) ||
+ (Array.isArray(entry.extensions) && entry.extensions.includes("KHR_texture_basisu")) ||
+ (Array.isArray(entry.sharedImages) &&
+ entry.sharedImages.some(
+ (image) =>
+ image !== null &&
+ typeof image === "object" &&
+ typeof image.output === "string" &&
+ /\.ktx2$/iu.test(image.output),
+ )),
+ )
.map(([logical]) => logical);
- if (ktx2.length > 0) {
+ if (!capabilities.decoders.ktx2 && ktx2.length > 0) {
throw new Error(
- `TN_NATIVE_KTX2_UNSUPPORTED: ${target} cannot ship compiled KTX2 textures (${namedAssets(ktx2)}). The mobile native runtime has no WebAssembly and therefore no Basis transcoder, so nothing in the bundle can decode them. Replace or transcode the authored KTX2 source for mobile, or exclude it from mobile builds; keep compressed textures on the web target.`,
+ `TN_NATIVE_KTX2_UNSUPPORTED: ${target} cannot ship compiled KTX2 textures (${namedAssets(ktx2)}). Selected artifact ${capabilities.artifact} (${capabilities.engine}) has no admitted Basis/KTX2 decoder in its bundle. Replace or transcode the authored KTX2 source for ${target === "desktop" ? "this runtime" : "mobile"}, or exclude it from ${target === "desktop" ? "this runtime's" : "mobile"} builds; keep compressed textures on the web target.`,
);
}
const compressedModels = rows
.filter(([, entry]) =>
(Array.isArray(entry.extensions) ? (entry.extensions as unknown[]) : []).some(
(extension) =>
- typeof extension === "string" && COMPRESSED_MODEL_EXTENSIONS.includes(extension),
+ typeof extension === "string" &&
+ COMPRESSED_MODEL_EXTENSIONS.includes(extension) &&
+ !capabilities.decoders[extension === "KHR_draco_mesh_compression" ? "draco" : "meshopt"],
),
)
.map(([logical]) => logical);
if (compressedModels.length > 0) {
throw new Error(
- `TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED: ${target} cannot ship compressed model geometry (${namedAssets(compressedModels)}). The mobile native runtime has no WebAssembly and therefore no Meshopt or Draco decoder. Native compilation keeps shared images and decoder-free model rewrites while omitting Meshopt output; rebuild the native target, or keep compressed models on the web target.`,
+ `TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED: ${target} cannot ship compressed model geometry (${namedAssets(compressedModels)}). Selected artifact ${capabilities.artifact} (${capabilities.engine}) has no admitted ${rows.flatMap(([, entry]) => (Array.isArray(entry.extensions) ? entry.extensions.filter((extension) => COMPRESSED_MODEL_EXTENSIONS.includes(String(extension))) : [])).join(", ")} decoder in its bundle. Native compilation keeps shared images and decoder-free model rewrites while omitting Meshopt output; rebuild the native target, or keep compressed models on the web target.`,
);
}
}
@@ -567,31 +574,56 @@ export async function writePackagingConfig(
return output;
}
-/**
- * Whether the runtime that will execute this build has WebAssembly.
- *
- * The engine decides it, not the target: V8 compiles WebAssembly, QuickJS and JavaScriptCore do
- * not. A desktop build is for either engine — Linux x64, Windows and macOS use V8, the Linux
- * arm64 lane builds QuickJS over wgpu-native — so the target alone cannot answer it. The runtime
- * binary the packager is handed (`THREENATIVE_RUNTIME_BINARY`, set by the same build invocation)
- * is probed for its engine; absent or unreadable, the answer is the historical desktop one, true,
- * because every published desktop prebuilt runs V8.
- */
+/** Read only the selected executable. An unrecognized or failed probe never proves WASM. */
+function runtimeEngine(
+ binary: string | undefined,
+ probe: typeof spawnSync,
+): "v8" | "quickjs" | "jsc" | "unknown" {
+ if (binary === undefined || !existsSync(binary)) return "unknown";
+ try {
+ const result = probe(binary, ["--version"], { encoding: "utf8", timeout: 10_000 });
+ if (result.status !== 0) return "unknown";
+ const engine = /\+ (\S+) build/u.exec(String(result.stdout ?? ""))?.[1]?.toLowerCase();
+ return engine === "v8" || engine === "quickjs" || engine === "jsc" ? engine : "unknown";
+ } catch {
+ return "unknown";
+ }
+}
+
export function runtimeHasWebAssembly(
binary: string | undefined = process.env.THREENATIVE_RUNTIME_BINARY,
probe: typeof spawnSync = spawnSync,
): boolean {
- if (binary === undefined || !existsSync(binary)) return true;
- let result: ReturnType;
- try {
- result = probe(binary, ["--version"], { encoding: "utf8" });
- } catch {
- return true;
+ return runtimeEngine(binary, probe) === "v8";
+}
+
+/**
+ * One conservative decision for cooking, validation and bundling. Only desktop's selected
+ * executable is runnable here: a desktop packaging tool is never evidence about Android/iOS.
+ * Mobile stays decoder-free until its selected artifact and packaged loaders are qualified.
+ * Desktop V8 retains its existing decoder path; WASM alone does not qualify new codec targets.
+ */
+export function resolveRuntimeAssetCapabilities(
+ target: BuildTarget,
+ binary?: string,
+ probe: typeof spawnSync = spawnSync,
+) {
+ const selected = target === "desktop" ? binary : undefined;
+ const engine = target === "web" ? "browser" : runtimeEngine(selected, probe);
+ const artifact = selected === undefined ? `${target}:unresolved` : path.resolve(selected);
+ let identity = artifact;
+ if (selected !== undefined && existsSync(selected)) {
+ identity = `${target}:sha256:${createHash("sha256").update(readFileSync(selected)).digest("hex")}`;
}
- if (result.status !== 0) return true;
- const engine = /\+ (\S+) build/u.exec(String(result.stdout ?? ""))?.[1] ?? "";
- // The engine names are CMake's MYSTRAL_JS_ENGINE_NAME: v8, jsc or quickjs. Only V8 runs WASM.
- return !/^(?:quickjs|jsc)$/iu.test(engine);
+ const webAssembly = engine === "v8" || engine === "browser";
+ const bundled = target === "web" || (target === "desktop" && webAssembly);
+ return {
+ artifact,
+ identity,
+ engine,
+ webAssembly,
+ decoders: { ktx2: bundled, meshopt: bundled, draco: bundled },
+ };
}
async function bundleNative(
@@ -711,24 +743,38 @@ async function buildNative(
const config = await loadConfig(cwd, { target, profile });
announceProfile(config);
assertNativeUiRendererCompatible(target, config.ui.renderer);
- // A native host without WebAssembly cannot run Rapier as WASM or decode meshopt/KTX2, so it
- // takes the native physics backend and a decoder-free bake. The capability is the runtime
- // engine, not the target: android and iOS are always QuickJS/JSC, and a desktop host is V8
- // except on the Linux arm64 lane. `assertNativeAssetsCompatible` stays below as the backstop.
- const webAssembly = runtimeHasWebAssembly();
+ const entry = await nativeEntry(cwd, config);
+ // Resolve the same desktop binary the packager will receive, before cooking. A default
+ // prebuilt used to be selected only after the cook had guessed from the build machine.
+ let selectedRuntime: string | undefined;
+ if (target === "desktop") {
+ const module = path.join(
+ packageRoot(cwd, "@threenative/runtime-native"),
+ "scripts",
+ "package-desktop.mjs",
+ );
+ const { resolveDesktopRuntime } = (await import(pathToFileURL(module).href)) as {
+ resolveDesktopRuntime(explicit?: string): Promise;
+ };
+ const explicit = process.env.THREENATIVE_RUNTIME_BINARY;
+ selectedRuntime = await resolveDesktopRuntime(
+ explicit === undefined ? undefined : path.resolve(cwd, explicit),
+ );
+ }
+ const capabilities = resolveRuntimeAssetCapabilities(target, selectedRuntime);
await compileAssets({
config: config.assets,
cwd,
platform: target,
- ...(webAssembly ? {} : { runtimeDecoders: { ktx2: false, meshopt: false } }),
+ runtimeDecoders: capabilities.decoders,
+ runtimeIdentity: capabilities.identity,
});
- await assertNativeAssetsCompatible(cwd, target, config);
- const entry = await nativeEntry(cwd, config);
+ await assertNativeAssetsCompatible(cwd, target, config, capabilities);
const orientation = config.display.orientation;
const configPath = await writePackagingConfig(cwd, config);
const runtimeRoot = packageRoot(cwd, "@threenative/runtime-native");
- const bundle = await bundleNative(cwd, runtimeRoot, entry, target, !webAssembly);
- await assertNativeBundleCompatible(bundle, target);
+ const bundle = await bundleNative(cwd, runtimeRoot, entry, target, !capabilities.webAssembly);
+ await assertNativeBundleCompatible(bundle, target, capabilities);
// The compiled root the project configured, which `compileAssets` above baked into — not the
// `public` default. A project whose cook profile writes elsewhere was packaging an empty
// directory while its own assets sat next to it, unread.
@@ -810,9 +856,7 @@ async function buildNative(
...(ui === undefined ? [] : ["--ui", ui]),
"--config",
configPath,
- ...(process.env.THREENATIVE_RUNTIME_BINARY
- ? ["--runtime", path.resolve(process.env.THREENATIVE_RUNTIME_BINARY)]
- : []),
+ ...(selectedRuntime === undefined ? [] : ["--runtime", selectedRuntime]),
"--output",
output,
],
diff --git a/packages/runtime-native/AGENTS.md b/packages/runtime-native/AGENTS.md
index 8ad9a6a0cc..47bcb0e9f1 100644
--- a/packages/runtime-native/AGENTS.md
+++ b/packages/runtime-native/AGENTS.md
@@ -85,15 +85,17 @@ portable graph reached browser UI; `TN_NATIVE_WASM_ON_MOBILE` means Android or i
WASM. Do not weaken these guards. Every packager stages the configured asset root (`assets.output`, default `public/`) through
`selectManifestAssets` beside the game bundle, and a missing runtime asset must reject game startup rather than fall back to the network.
-**Mobile has no compressed-asset decoders.** Android QuickJS and iOS JSC have no WASM engine,
-so three's Basis/zstd transcoder (`KTX2Loader`), its Meshopt decoder and Draco's wasm decoder
-cannot run there. `scripts/bundle.mjs` replaces all three with refusing stubs on the mobile
-targets only — desktop keeps the real ones — which is what keeps a game that ships no
-compressed asset out of `TN_NATIVE_WASM_ON_MOBILE`; their specifiers are static strings inside
-`await import(...)`, so a bundler inlines them whether the game uses them or not. A game whose
-compiled assets do need one is refused by `threenative build` before any bundle exists, with
-`TN_NATIVE_KTX2_UNSUPPORTED` or `TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED`. Making mobile decode
-either format is a native decoder question, not a bundler one.
+**Mobile still has no qualified compressed-asset decoder path.** Android's V8 engine alone
+is not loader proof: Basis, Meshopt and Draco stay refusing stubs until their packaged paths are
+qualified. Android QuickJS and iOS JSC also have no WASM engine. The desktop build resolves and
+hashes its exact package runtime before cooking; QuickJS, JSC and unknown engine probes take the
+decoder-free compiler and bundle path. The existing desktop V8 decoder path stays unchanged.
+`scripts/bundle.mjs --native-backend` selects the refusing stubs on desktop too. A compiled asset
+that needs a missing decoder is refused before packaging with `TN_NATIVE_KTX2_UNSUPPORTED` or
+`TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED`, naming the asset and selected runtime. Authored KTX2,
+embedded Basis textures and shared KTX2 images are all checked. Native cook-cache keys include
+runtime identity and decoder capabilities. Full mobile artifact/cohort resolution and
+packaged-loader qualification remain open in PRD-VQ-01.
## Package boundaries
diff --git a/packages/runtime-native/CLAUDE.md b/packages/runtime-native/CLAUDE.md
index 46d251db21..aaa582fe0d 100644
--- a/packages/runtime-native/CLAUDE.md
+++ b/packages/runtime-native/CLAUDE.md
@@ -87,15 +87,17 @@ portable graph reached browser UI; `TN_NATIVE_WASM_ON_MOBILE` means Android or i
WASM. Do not weaken these guards. Every packager stages the configured asset root (`assets.output`, default `public/`) through
`selectManifestAssets` beside the game bundle, and a missing runtime asset must reject game startup rather than fall back to the network.
-**Mobile has no compressed-asset decoders.** Android QuickJS and iOS JSC have no WASM engine,
-so three's Basis/zstd transcoder (`KTX2Loader`), its Meshopt decoder and Draco's wasm decoder
-cannot run there. `scripts/bundle.mjs` replaces all three with refusing stubs on the mobile
-targets only — desktop keeps the real ones — which is what keeps a game that ships no
-compressed asset out of `TN_NATIVE_WASM_ON_MOBILE`; their specifiers are static strings inside
-`await import(...)`, so a bundler inlines them whether the game uses them or not. A game whose
-compiled assets do need one is refused by `threenative build` before any bundle exists, with
-`TN_NATIVE_KTX2_UNSUPPORTED` or `TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED`. Making mobile decode
-either format is a native decoder question, not a bundler one.
+**Mobile still has no qualified compressed-asset decoder path.** Android's V8 engine alone
+is not loader proof: Basis, Meshopt and Draco stay refusing stubs until their packaged paths are
+qualified. Android QuickJS and iOS JSC also have no WASM engine. The desktop build resolves and
+hashes its exact package runtime before cooking; QuickJS, JSC and unknown engine probes take the
+decoder-free compiler and bundle path. The existing desktop V8 decoder path stays unchanged.
+`scripts/bundle.mjs --native-backend` selects the refusing stubs on desktop too. A compiled asset
+that needs a missing decoder is refused before packaging with `TN_NATIVE_KTX2_UNSUPPORTED` or
+`TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED`, naming the asset and selected runtime. Authored KTX2,
+embedded Basis textures and shared KTX2 images are all checked. Native cook-cache keys include
+runtime identity and decoder capabilities. Full mobile artifact/cohort resolution and
+packaged-loader qualification remain open in PRD-VQ-01.
## Package boundaries
diff --git a/packages/runtime-native/scripts/bundle.mjs b/packages/runtime-native/scripts/bundle.mjs
index f09b305029..243338cfbf 100644
--- a/packages/runtime-native/scripts/bundle.mjs
+++ b/packages/runtime-native/scripts/bundle.mjs
@@ -148,28 +148,15 @@ function generateVFSHeader(files, outputPath) {
}
/**
- * Three's compressed-asset decoders that cannot run on a mobile native target.
- *
- * Android runs QuickJS and iOS runs JavaScriptCore without a WASM JIT: neither has
- * `WebAssembly`, so the Basis/zstd transcoder behind `KTX2Loader`, the Meshopt decoder and
- * Draco's wasm decoder have nothing to instantiate there. Their specifiers are static strings
- * inside `await import(...)`, so a bundler resolves and inlines them whether or not the game
- * ships a single compressed asset — which is how one `.ktx2`-free game came to fail
- * `TN_NATIVE_WASM_ON_MOBILE`. Replacing them here keeps that guard intact by keeping WASM out
- * of the bundle instead of by ignoring it.
- *
- * The replacements construct and configure exactly like the originals so a game that ships no
- * compressed asset boots normally (`createAssetLoader` builds the shared KTX2 loader eagerly);
- * they refuse, by name, only when asked to decode. A game whose compiled assets do need one is
- * refused earlier and louder, by `threenative build`, before this bundle is written.
+ * Decoder-free targets retain loader configuration/lifecycle but refuse actual decoding.
+ * Android V8 still lacks a qualified packaged decoder path; its engine is not the reason to
+ * enable one. Desktop QuickJS and an unknown desktop artifact use the same refusing stubs.
*/
const MOBILE_KTX2_MESSAGE =
- // Deliberately never spells the WASM host object: `TN_NATIVE_WASM_ON_MOBILE` greps the
- // finished bundle for that identifier, and a refusal message must not read as the thing it
- // refuses.
- "TN_NATIVE_KTX2_UNSUPPORTED: this Android/iOS bundle carries no Basis transcoder, because the mobile native runtime has no WASM engine. Compiled .ktx2 textures cannot be decoded here; native compilation automatically keeps decoder-free authored textures, or keep compressed textures on the web target.";
+ // Never spell the WASM host global: the bundle guard must not mistake diagnostics for code.
+ "TN_NATIVE_KTX2_UNSUPPORTED: this decoder-free native bundle carries no qualified Basis transcoder. Compiled .ktx2 textures cannot be decoded here; native compilation keeps decoder-free authored textures, or keep compressed textures on the web target.";
const MOBILE_MESH_MESSAGE =
- "TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED: this Android/iOS bundle carries no Meshopt or Draco decoder, because the mobile native runtime has no WASM engine. Compressed model geometry cannot be decoded here; native compilation keeps shared images and decoder-free model geometry, or keep compressed models on the web target.";
+ "TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED: this decoder-free native bundle carries no qualified Meshopt or Draco decoder. Compressed model geometry cannot be decoded here; native compilation keeps shared images and decoder-free model geometry, or keep compressed models on the web target.";
const MOBILE_DECODER_STUBS = [
{
@@ -340,7 +327,7 @@ void game.start().catch((error) => console.error(
root: absoluteProject,
define: { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) },
plugins:
- target === 'desktop'
+ target === 'desktop' && !nativeBackend
? [importMetaPlugin, nativeEntryPlugin]
: [importMetaPlugin, mobileDecodersPlugin, nativeEntryPlugin],
// One three, whatever the import graph looks like. The game resolves `three` beside itself
diff --git a/scripts/__tests__/native-asset-capture-proof.spec.ts b/scripts/__tests__/native-asset-capture-proof.spec.ts
new file mode 100644
index 0000000000..719894c72e
--- /dev/null
+++ b/scripts/__tests__/native-asset-capture-proof.spec.ts
@@ -0,0 +1,182 @@
+import { readFileSync } from "node:fs";
+import { PNG } from "pngjs";
+import { expect, test } from "vitest";
+import { requiredPlaytestCapabilities } from "../../packages/playtest/src/assertion-schema.js";
+import { PLAYTEST_PROTOCOL_LIMITS } from "../../packages/playtest/src/protocol.js";
+import {
+ type IBridgeTransport,
+ connectPlaytestBridgeTransport,
+} from "../../packages/playtest/src/runner/bridgeClient.js";
+import { validatePlaytestScenario } from "../../packages/playtest/src/scenario.js";
+import {
+ assertNativeAssetCapture,
+ inspectNativeAssetScreenshot,
+} from "../native-asset-capture-proof.js";
+
+const cleanConsole = [{ type: "log", text: "TN_NATIVE_SMOKE_FIRST_FRAME" }];
+
+// Synthetic pixels are unit inputs only. Published evidence must come from the native runner.
+function image(missingRegion = -1, transparent = false): Buffer {
+ const png = new PNG({ width: 960, height: 640 });
+ const colours = [
+ [
+ [20, 210, 225],
+ [240, 85, 35],
+ ],
+ [
+ [235, 45, 170],
+ [245, 205, 35],
+ ],
+ [
+ [95, 210, 60],
+ [85, 100, 235],
+ ],
+ ];
+ for (let y = 180; y < 440; y++)
+ for (let x = 0; x < 960; x++) {
+ const region = Math.floor(x / 320);
+ if (region === missingRegion) continue;
+ const rgb = colours[region]?.[x % 32 < 16 ? 0 : 1] ?? [0, 0, 0];
+ png.data.set(
+ [rgb[0] ?? 0, rgb[1] ?? 0, rgb[2] ?? 0, transparent ? 0 : 255],
+ (y * 960 + x) * 4,
+ );
+ }
+ return PNG.sync.write(png);
+}
+
+function report(): Parameters[0] {
+ return {
+ pass: true,
+ startup: { phase: "ready", progress: 1, compileSettled: true, rule: "compile-settled" },
+ runtime: "native",
+ target: "desktop",
+ assertionResults: [{ pass: true, id: "resources.0" }],
+ diagnostics: [],
+ capture: {
+ target: "desktop",
+ rendererKind: "webgpu",
+ adapter: { description: "llvmpipe" },
+ browserArgs: [],
+ captureMethod: "device.screenshot",
+ viewport: { width: 960, height: 640 },
+ },
+ };
+}
+
+test("requires both rendered colours for all three asset routes", () => {
+ expect(inspectNativeAssetScreenshot(image()).counts).toHaveLength(3);
+ for (const region of [0, 1, 2])
+ expect(() => inspectNativeAssetScreenshot(image(region))).toThrow(`Specimen ${region}`);
+});
+test("rejects an empty or wrong-sized capture", () => {
+ expect(() => inspectNativeAssetScreenshot(Buffer.from([]))).toThrow();
+ expect(() =>
+ inspectNativeAssetScreenshot(PNG.sync.write(new PNG({ width: 32, height: 32 }))),
+ ).toThrow();
+});
+test("requires native provenance and nonempty live assertions", () => {
+ expect(() => assertNativeAssetCapture(report(), cleanConsole)).not.toThrow();
+ for (const mutation of [
+ { assertionResults: [] },
+ { pass: false },
+ { capture: undefined },
+ { capture: { target: "web", rendererKind: "webgpu", adapter: { description: "GPU" } } },
+ { capture: { target: "desktop", rendererKind: "webgpu", adapter: { description: "" } } },
+ { diagnostics: [{ severity: "warning", code: "TN_PLAYTEST_SOFTWARE_DEVICE_LOST" }] },
+ ])
+ expect(() =>
+ assertNativeAssetCapture(
+ { ...report(), ...mutation } as Parameters[0],
+ cleanConsole,
+ ),
+ ).toThrow();
+});
+
+test("invisible RGB cannot satisfy the native screenshot proof", () => {
+ expect(() => inspectNativeAssetScreenshot(image(-1, true))).toThrow("Specimen 0");
+});
+
+test.each([
+ "[WebGPU] Device error (Validation): invalid texture format",
+ "[FATAL] The GPU device was lost: driver reset",
+ "TN_ASSETS_UNRESOLVED: authored model failed to load",
+ "TypeError: native renderer failed",
+])("native console failures remain fatal even when classified as log: %s", (text) => {
+ expect(() =>
+ assertNativeAssetCapture(report(), [...cleanConsole, { type: "log", text }]),
+ ).toThrow(text);
+});
+
+test("native scenario retains supported diagnostics without requesting an unavailable observer", () => {
+ const scenario = validatePlaytestScenario(
+ JSON.parse(
+ readFileSync(
+ "examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json",
+ "utf8",
+ ),
+ ),
+ "vq01",
+ );
+ expect(requiredPlaytestCapabilities(scenario, "desktop")).not.toContain("runtime.diagnostics");
+ expect(scenario.assert?.diagnostics).toBeUndefined();
+ expect(requiredPlaytestCapabilities(scenario, "desktop")).toContain("runtime.startup");
+ expect(scenario.assert?.startup?.maxReadyMs).toBe(120000);
+});
+
+test("native console evidence cannot be omitted or empty", () => {
+ for (const console of [undefined, [], [{ type: "log", text: "no frame marker" }]]) {
+ expect(() => assertNativeAssetCapture(report(), console)).toThrow();
+ }
+});
+
+test("unclassified error-labelled native lines remain fatal", () => {
+ expect(() =>
+ assertNativeAssetCapture(report(), [
+ ...cleanConsole,
+ { type: "error", text: "Unexpected host failure" },
+ ]),
+ ).toThrow("Unexpected host failure");
+});
+
+test("the actual native bridge preflight accepts the fixture's assertion families", async () => {
+ const scenario = validatePlaytestScenario(
+ JSON.parse(
+ readFileSync(
+ "examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json",
+ "utf8",
+ ),
+ ),
+ "vq01",
+ );
+ // Minimal subset of the real QuickJS handshake captured in run 36996645116.
+ const transport: IBridgeTransport = {
+ capabilities: ["browser.console", "browser.screenshot"],
+ waitForBridge: async () => true,
+ close: async () => {},
+ async call(method: string): Promise {
+ if (method === "describe")
+ return {
+ capabilities: ["runtime.resources", "runtime.fixedStep", "runtime.startup"],
+ limits: PLAYTEST_PROTOCOL_LIMITS,
+ name: "@threenative/playtest/three",
+ protocolVersion: 1,
+ } as T;
+ if (method === "ready") return { ready: true } as T;
+ throw new Error(`Unexpected preflight call: ${method}`);
+ },
+ };
+ await expect(
+ connectPlaytestBridgeTransport(transport, scenario, 1000, "desktop"),
+ ).resolves.toBeDefined();
+});
+
+test("native ready and settled compilation cannot be omitted or inferred from pixels", () => {
+ for (const startup of [
+ undefined,
+ { phase: "loading", progress: 0.9, compileSettled: true, rule: "compile-settled" },
+ { phase: "ready", progress: 1, compileSettled: false, rule: "compile-settled" },
+ ]) {
+ expect(() => assertNativeAssetCapture({ ...report(), startup }, cleanConsole)).toThrow();
+ }
+});
diff --git a/scripts/native-asset-capture-proof.ts b/scripts/native-asset-capture-proof.ts
new file mode 100644
index 0000000000..63478f2697
--- /dev/null
+++ b/scripts/native-asset-capture-proof.ts
@@ -0,0 +1,97 @@
+import assert from "node:assert/strict";
+import { PNG } from "pngjs";
+import type { IStandalonePlaytestReport } from "../packages/playtest/src/runner/runner.js";
+
+/** Rendered checker colours in each specimen's own third of the frame, not a global nonblank test. */
+export function inspectNativeAssetScreenshot(bytes: Buffer) {
+ const png = PNG.sync.read(bytes);
+ assert.equal(png.width, 960, "Native fixture capture width");
+ assert.equal(png.height, 640, "Native fixture capture height");
+ const counts = [
+ [0, 0],
+ [0, 0],
+ [0, 0],
+ ];
+ for (let y = 100; y < 535; y++)
+ for (let x = 0; x < png.width; x++) {
+ const offset = (y * png.width + x) * 4;
+ if ((png.data[offset + 3] ?? 0) < 250) continue;
+ const r = png.data[offset] ?? 0;
+ const g = png.data[offset + 1] ?? 0;
+ const b = png.data[offset + 2] ?? 0;
+ const region = Math.floor(x / 320);
+ const first =
+ region === 0
+ ? g > 100 && b > 100 && r < g * 0.7
+ : region === 1
+ ? r > 100 && b > 80 && g < r * 0.65
+ : g > 100 && g > r * 1.25 && g > b * 1.25;
+ const second =
+ region === 0
+ ? r > 130 && r > g * 1.3 && b < r * 0.65
+ : region === 1
+ ? r > 120 && g > 100 && b < g * 0.65
+ : b > 110 && b > r * 1.25 && b > g * 1.25;
+ const row = counts[region];
+ if (row && first) row[0] = (row[0] ?? 0) + 1;
+ if (row && second) row[1] = (row[1] ?? 0) + 1;
+ }
+ for (const [region, row] of counts.entries())
+ for (const [colour, pixels] of row.entries()) {
+ assert.ok(
+ pixels >= 200,
+ `Specimen ${region} checker colour ${colour}: only ${pixels} pixels`,
+ );
+ }
+ return { width: png.width, height: png.height, counts };
+}
+
+/** No successful screenshot proof can carry a lost device, empty assertions or unnamed adapter. */
+export function assertNativeAssetCapture(
+ report: Pick<
+ IStandalonePlaytestReport,
+ "pass" | "assertionResults" | "capture" | "diagnostics" | "runtime" | "target" | "startup"
+ >,
+ nativeConsole: unknown,
+): void {
+ assert.equal(report.pass, true, JSON.stringify(report.diagnostics));
+ assert.equal(report.startup?.phase, "ready", "Native world never reached ready");
+ assert.equal(report.startup.compileSettled, true, "Native compilation never settled");
+ assert.equal(report.runtime, "native");
+ assert.equal(report.target, "desktop");
+ assert.equal(report.capture?.captureMethod, "device.screenshot");
+ assert.ok(report.assertionResults?.length, "Native proof must evaluate assertions");
+ assert.ok(
+ report.assertionResults.every((result) => result.pass),
+ "Native assertions failed",
+ );
+ assert.equal(report.capture?.target, "desktop", "Native proof cannot be a browser capture");
+ assert.equal(report.capture.rendererKind, "webgpu");
+ assert.ok(
+ report.capture.adapter && Object.values(report.capture.adapter).some((value) => value.trim()),
+ "Native adapter identity missing",
+ );
+ assert.ok(
+ Array.isArray(nativeConsole) && nativeConsole.length > 0,
+ "Native console evidence missing",
+ );
+ for (const entry of nativeConsole) {
+ assert.ok(
+ entry && typeof entry.text === "string" && typeof entry.type === "string",
+ "Malformed native console evidence",
+ );
+ assert.notEqual(entry.type, "error", entry.text);
+ assert.doesNotMatch(
+ entry.text,
+ /\[FATAL\]|\[WebGPU\].*(?:Device error|Device lost|Failed)|validation error|device(?:[ _-]| was )?lost|(?:Type|Reference|Range|Syntax)Error|TN_(?:NATIVE_START_FAILED|ASSETS_UNRESOLVED|NATIVE_KTX2_UNSUPPORTED|NATIVE_MESH_COMPRESSION_UNSUPPORTED)/iu,
+ );
+ }
+ assert.ok(
+ nativeConsole.some((entry) => entry.text.includes("TN_NATIVE_SMOKE_FIRST_FRAME")),
+ "Native first-frame marker missing",
+ );
+ for (const diagnostic of report.diagnostics) {
+ assert.notEqual(diagnostic.severity, "error", diagnostic.code);
+ assert.doesNotMatch(diagnostic.code, /DEVICE_LOST|BRIDGE_MISSING/u);
+ }
+}
diff --git a/scripts/verify-native-asset-capabilities.ts b/scripts/verify-native-asset-capabilities.ts
new file mode 100644
index 0000000000..31cc90dbab
--- /dev/null
+++ b/scripts/verify-native-asset-capabilities.ts
@@ -0,0 +1,168 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ MODEL_NAMES,
+ generateNativeAssetFixture,
+ inspectFixtureModel,
+} from "../examples/abyss-framework/vq-assets/generate.js";
+import config from "../examples/abyss-framework/vq-assets/threenative.config.js";
+import {
+ build,
+ resolveRuntimeAssetCapabilities,
+} from "../packages/create-threenative/src/build.js";
+import { hashArtifact } from "../packages/create-threenative/src/buildReport.js";
+import { runDesktopPlaytest } from "../packages/playtest/dist/runner/index.js";
+import {
+ assertNativeAssetCapture,
+ inspectNativeAssetScreenshot,
+} from "./native-asset-capture-proof.js";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const project = path.join(root, "examples/abyss-framework/vq-assets");
+const output = path.join(root, "artifacts/vq01-native-assets");
+const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], {
+ cwd: root,
+ encoding: "utf8",
+}).trim();
+const write = async (name: string, value: unknown) =>
+ writeFile(path.join(output, name), `${JSON.stringify(value, null, 2)}\n`);
+const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
+await rm(output, { recursive: true, force: true });
+await mkdir(output, { recursive: true });
+await write("attempt.json", { sourceSha, runId: process.env.GITHUB_RUN_ID, status: "started" });
+try {
+ const runtime = process.env.THREENATIVE_RUNTIME_BINARY;
+ assert.ok(runtime, "THREENATIVE_RUNTIME_BINARY must name this run's actual native build");
+ const capabilities = resolveRuntimeAssetCapabilities("desktop", runtime);
+ assert.equal(
+ capabilities.engine,
+ "quickjs",
+ "This proof must execute the actual QuickJS fallback",
+ );
+ assert.deepEqual(capabilities.decoders, { ktx2: false, meshopt: false, draco: false });
+ await write("runtime.json", capabilities);
+ await rm(path.join(project, ".generated"), { recursive: true, force: true });
+ const source = path.join(project, config.assets.source);
+ const ktx2 = await generateNativeAssetFixture(source);
+ await build({ cwd: project, target: "desktop" });
+ const cookedRoot = path.join(project, config.assets.output);
+ const manifest = JSON.parse(
+ await readFile(path.join(cookedRoot, "assets.manifest.json"), "utf8"),
+ );
+ const models = [];
+ for (const [index, name] of MODEL_NAMES.entries()) {
+ const authoredPath = path.join(source, `${name}.glb`);
+ const cookedPath = path.join(cookedRoot, manifest.entries[`${name}.glb`].output);
+ const authored = await inspectFixtureModel(authoredPath);
+ const cooked = await inspectFixtureModel(cookedPath);
+ assert.ok(
+ authored.extensions.includes(
+ index === 0 ? "EXT_meshopt_compression" : "KHR_draco_mesh_compression",
+ ),
+ );
+ assert.ok(!cooked.extensions.some((extension) => /meshopt|draco|basisu/u.test(extension)));
+ assert.deepEqual(cooked.positions, authored.positions, `${name}: decoded vertex values`);
+ assert.equal(cooked.triangles, authored.triangles);
+ assert.deepEqual(cooked.animation, authored.animation);
+ assert.deepEqual(
+ cooked.images.map((image) => image.data),
+ authored.images.map((image) => image.data),
+ );
+ models.push({
+ name,
+ sourceSha256: sha256(await readFile(authoredPath)),
+ cookedSha256: sha256(await readFile(cookedPath)),
+ sourceEncodedBytes: authored.encodedBytes,
+ cookedEncodedBytes: cooked.encodedBytes,
+ decodedGeometryBytes: cooked.decodedGeometryBytes,
+ decodedImageBytes: cooked.images.reduce((sum, image) => sum + image.data.length, 0),
+ triangles: cooked.triangles,
+ });
+ }
+ assert.match(manifest.entries["checker.png"].output, /\.png$/u);
+ assert.doesNotMatch(
+ await readFile(path.join(project, ".threenative/build/game.js"), "utf8"),
+ /\bWebAssembly\b/u,
+ );
+ await write("cook.json", {
+ models,
+ manifest,
+ measurement:
+ "Encoded bytes and decoded CPU buffer bytes are separate; no GPU memory reduction claimed.",
+ });
+ const executable = path.join(project, "dist-native/vq-native-assets");
+ const runtimeBytes = await readFile(runtime);
+ const packagedBytes = await readFile(executable);
+ assert.equal(
+ sha256(packagedBytes.subarray(0, runtimeBytes.length)),
+ sha256(runtimeBytes),
+ "Packaged executable must carry the exact selected runtime bytes before its appended game",
+ );
+ const packaged = await hashArtifact(executable);
+ const buildReport = JSON.parse(await readFile(`${executable}.build-report.json`, "utf8"));
+ assert.equal(
+ buildReport.artifact.sha256,
+ packaged.sha256,
+ "Build report must describe the actual executable",
+ );
+ await writeFile(path.join(source, "authored.ktx2"), ktx2);
+ await assert.rejects(
+ build({ cwd: project, target: "desktop" }),
+ /TN_NATIVE_KTX2_UNSUPPORTED.*authored.ktx2/u,
+ );
+ assert.deepEqual(
+ await hashArtifact(executable),
+ packaged,
+ "Codec refusal must preserve the previous package",
+ );
+ const report = await runDesktopPlaytest({
+ target: "desktop",
+ desktop: { executable, hostArgs: ["--windowed"] },
+ projectPath: project,
+ scenarioPath: "../playtests/vq-native-asset-capabilities.playtest.json",
+ artifactDirectory: output,
+ allowSoftwareAdapter: true,
+ headless: false,
+ timeoutMs: 120_000,
+ trace: false,
+ url: "",
+ });
+ await write("report.json", report);
+ assert.equal(report.pass, true, JSON.stringify(report.diagnostics));
+ const nativeConsole: unknown = JSON.parse(
+ await readFile(path.join(output, "console.json"), "utf8"),
+ );
+ assertNativeAssetCapture(report, nativeConsole);
+ const bytes = await readFile(path.join(output, "after.png"));
+ const pixels = inspectNativeAssetScreenshot(bytes);
+ assert.deepEqual(await hashArtifact(executable), packaged);
+ await write("summary.json", {
+ pass: true,
+ sourceSha,
+ runId: process.env.GITHUB_RUN_ID,
+ qualification:
+ "Packaged Linux QuickJS fallback correctness on a named hosted software adapter. No Android, iOS, native compressed-codec admission or hardware-performance claim.",
+ observability:
+ "Native host console, live readiness and resource observations; runtime.diagnostics is not exposed by this bridge.",
+ runtime: capabilities,
+ packaged,
+ models,
+ capture: report.capture,
+ pixels,
+ screenshotSha256: sha256(bytes),
+ refusedCodec: "authored KTX2",
+ previousPackagePreserved: true,
+ });
+} catch (error) {
+ await write("failure.json", {
+ pass: false,
+ sourceSha,
+ error: String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ throw error;
+}
diff --git a/scripts/vq01-android-proof.sh b/scripts/vq01-android-proof.sh
new file mode 100755
index 0000000000..809756d02e
--- /dev/null
+++ b/scripts/vq01-android-proof.sh
@@ -0,0 +1,55 @@
+#!/bin/sh
+# PRD-VQ-01 Phase 3, Android box: cook the unchanged examples/abyss-framework/vq-assets fixture for
+# the Android V8 target, install that APK on one named emulator, and run the shared scenario.
+#
+# sh scripts/vq01-android-proof.sh
+#
+# The V8 engine is the Android default and needs `third_party/v8-android`, which
+# `node packages/runtime-native/scripts/download-deps.mjs --android` provisions with a build
+# receipt. The playtest runner launches an installed app; it never builds or installs one, so both
+# steps are here. Everything it writes is untracked build output.
+set -eu
+
+artifacts=${1:?usage: sh scripts/vq01-android-proof.sh }
+serial=${2:?usage: sh scripts/vq01-android-proof.sh }
+root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+cd "$root"
+
+# adb and the SDK are usually installed but off PATH; Gradle and Kotlin want JDK 17, and the
+# Android slice is one ABI so the emulator's x86_64 runtime is the only one this build needs.
+: "${ANDROID_HOME:=$HOME/Android/Sdk}"
+: "${JAVA_HOME:=/usr/lib/jvm/java-17-openjdk}"
+PATH=$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH
+export PATH JAVA_HOME ANDROID_HOME ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT:-$ANDROID_HOME}
+adb -s "$serial" get-state >/dev/null
+
+# The authored sources the cook reads, through the same generator the web and desktop lanes use.
+node --import tsx -e '
+const { generateNativeAssetFixture } = await import("./examples/abyss-framework/vq-assets/generate.ts");
+await generateNativeAssetFixture("examples/abyss-framework/vq-assets/.generated/assets");
+'
+
+# The maintainer source build. `resolveRuntimeAssetCapabilities` reports `android:unresolved`
+# (engine unknown), so this cook is the decoder-free one: the log names meshopt and KTX2 as
+# unqualified for the target and ships normalized GLB/PNG instead.
+(
+ cd examples/abyss-framework/vq-assets
+ THREENATIVE_RUNTIME_SOURCE="$root/packages/runtime-native" \
+ THREENATIVE_GRADLE_ARGS="-PthreenativeJsEngine=v8 -PthreenativeAbis=x86_64" \
+ node "$root/packages/create-threenative/dist/threenative.js" build --target android --allow-source-build
+)
+
+apk=examples/abyss-framework/vq-assets/dist-native/vq-native-assets.apk
+adb -s "$serial" install -r --no-streaming "$apk"
+
+node packages/playtest/dist/runner/cli.js \
+ examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json \
+ --target android \
+ --device "$serial" \
+ --package com.threenative.vq01 \
+ --activity com.threenative.runtime.MystralActivity \
+ --adb "$ANDROID_HOME/platform-tools/adb" \
+ --artifact "$apk" \
+ --build-report "$apk.build-report.json" \
+ --timeout 120000 \
+ --artifacts "$artifacts"
\ No newline at end of file
diff --git a/scripts/vq01-lifecycle-proof.sh b/scripts/vq01-lifecycle-proof.sh
new file mode 100755
index 0000000000..035cca7c91
--- /dev/null
+++ b/scripts/vq01-lifecycle-proof.sh
@@ -0,0 +1,69 @@
+#!/bin/sh
+# PRD-VQ-01 AC-2 lifecycle box: cook the unchanged examples/abyss-framework/vq-assets fixture for one
+# target, then run the leave/re-enter scenario against it. Identical to vq01-web-proof.sh and
+# vq01-android-proof.sh except for the scenario both of those name; the scenario's own first step
+# presses the fixture's lifecycle key, and both runners deliver a press step as the same keydown, so
+# the fixture leaves and re-enters on web and on the device without a URL and without a harness flag.
+#
+# sh scripts/vq01-lifecycle-proof.sh web
+# sh scripts/vq01-lifecycle-proof.sh android
+set -eu
+
+artifacts=${1:?usage: sh scripts/vq01-lifecycle-proof.sh web | android }
+target=${2:?usage: sh scripts/vq01-lifecycle-proof.sh web | android }
+root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+cd "$root"
+
+scenario=examples/abyss-framework/playtests/vq-native-asset-lifecycle.playtest.json
+
+# The authored sources the cook reads, through the same generator every VQ-01 lane uses.
+node --import tsx -e '
+const { generateNativeAssetFixture } = await import("./examples/abyss-framework/vq-assets/generate.ts");
+await generateNativeAssetFixture("examples/abyss-framework/vq-assets/.generated/assets");
+'
+
+case "$target" in
+web)
+ # The web target build: cooks .generated/assets into .generated/public and publishes dist/.
+ (cd examples/abyss-framework/vq-assets && node "$root/packages/create-threenative/dist/threenative.js" build)
+ # The runner owns the preview server lifecycle and probes 5193 for readiness.
+ node packages/playtest/dist/runner/cli.js "$scenario" \
+ --url http://127.0.0.1:5193/ \
+ --server-command 'pnpm --dir examples/abyss-framework exec vite preview vq-assets --host 127.0.0.1 --port 5193 --strictPort' \
+ --browser-recipe webgpu \
+ --headed \
+ --artifacts "$artifacts"
+ ;;
+android)
+ serial=${3:?usage: sh scripts/vq01-lifecycle-proof.sh android }
+ # adb and the SDK are usually installed but off PATH; Gradle and Kotlin want JDK 17, and the
+ # Android slice is one ABI so the emulator's x86_64 runtime is the only one this build needs.
+ : "${ANDROID_HOME:=$HOME/Android/Sdk}"
+ : "${JAVA_HOME:=/usr/lib/jvm/java-17-openjdk}"
+ PATH=$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH
+ export PATH JAVA_HOME ANDROID_HOME ANDROID_SDK_ROOT=${ANDROID_SDK_ROOT:-$ANDROID_HOME}
+ adb -s "$serial" get-state >/dev/null
+ (
+ cd examples/abyss-framework/vq-assets
+ THREENATIVE_RUNTIME_SOURCE="$root/packages/runtime-native" \
+ THREENATIVE_GRADLE_ARGS="-PthreenativeJsEngine=v8 -PthreenativeAbis=x86_64" \
+ node "$root/packages/create-threenative/dist/threenative.js" build --target android --allow-source-build
+ )
+ apk=examples/abyss-framework/vq-assets/dist-native/vq-native-assets.apk
+ adb -s "$serial" install -r --no-streaming "$apk"
+ node packages/playtest/dist/runner/cli.js "$scenario" \
+ --target android \
+ --device "$serial" \
+ --package com.threenative.vq01 \
+ --activity com.threenative.runtime.MystralActivity \
+ --adb "$ANDROID_HOME/platform-tools/adb" \
+ --artifact "$apk" \
+ --build-report "$apk.build-report.json" \
+ --timeout 120000 \
+ --artifacts "$artifacts"
+ ;;
+*)
+ echo "unknown target '$target'; expected web or android" >&2
+ exit 2
+ ;;
+esac
\ No newline at end of file
diff --git a/scripts/vq01-web-proof.sh b/scripts/vq01-web-proof.sh
new file mode 100755
index 0000000000..2fe5c62b22
--- /dev/null
+++ b/scripts/vq01-web-proof.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+# PRD-VQ-01 Phase 3, web box: cook the unchanged examples/abyss-framework/vq-assets fixture for the
+# web target, then run the shared scenario against it in a WebGPU browser.
+#
+# sh scripts/vq01-web-proof.sh
+#
+# The web build and its preview server are started here; the browser run is the runner's job and
+# is the one step whose result this script cannot report for you.
+set -eu
+
+artifacts=${1:?usage: sh scripts/vq01-web-proof.sh }
+root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+cd "$root"
+
+# The authored sources the cook reads: real Meshopt and Draco GLBs plus the PNG it transcodes to
+# KTX2. The native verifier generates them through the same function, so both targets cook the
+# same inputs instead of two sets that can drift.
+node --import tsx -e '
+const { generateNativeAssetFixture } = await import("./examples/abyss-framework/vq-assets/generate.ts");
+await generateNativeAssetFixture("examples/abyss-framework/vq-assets/.generated/assets");
+'
+
+# The web target build: cooks .generated/assets into .generated/public and publishes dist/.
+(cd examples/abyss-framework/vq-assets && node "$root/packages/create-threenative/dist/threenative.js" build)
+
+# `pnpm --dir` runs the vite that examples/abyss-framework already installs, with the fixture as
+# its root; the runner owns the server lifecycle and probes 5193 for readiness.
+node packages/playtest/dist/runner/cli.js \
+ examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json \
+ --url http://127.0.0.1:5193/ \
+ --server-command 'pnpm --dir examples/abyss-framework exec vite preview vq-assets --host 127.0.0.1 --port 5193 --strictPort' \
+ --browser-recipe webgpu \
+ --headed \
+ --artifacts "$artifacts"
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index 87e24b23d7..e91e6217d8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,6 +7,7 @@
"baseUrl": ".",
"paths": {
"@threenative/core": ["packages/core/src/index.ts"],
+ "@threenative/core/playtest": ["packages/core/src/playtest.ts"],
"@threenative/physics": ["packages/physics/src/index.ts"],
"@threenative/playtest": ["packages/playtest/src/index.ts"],
"@threenative/ui": ["packages/ui/src/index.ts"]