diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 5076a0c4dc..051659b76a 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -1,7 +1,7 @@ # PRD-568 — Mobile builds ship GPU-ready ASTC, so no phone transcodes or uploads RGBA8 -**Status:** NOT STARTED -**Priority:** P2 — Mobile ASTC cook, decoder-free admission and the emulator proof are all unbuilt. +**Status:** PARTIAL +**Priority:** P2 — Standalone mobile ASTC cooking is implemented; embedded/lightmap cooking, admission and native proof remain open. **Complexity:** 4 (MEDIUM) — 1–5 implementation files (`packages/assets` texture pass, `create-threenative` build gate, the asset loader route) (+1); the cook output crosses into the native package (+2); one new output variant (+1); risk override: none **Owner:** João **Depends on:** [PRD-VQ-01](../done/PRD-VQ-01-native-asset-capabilities.md) (owns runtime-aware decoder admission; this PRD adds a format that needs no decoder) @@ -28,7 +28,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Solution -1. **Cook (packages/assets, mechanism).** For `--target android|ios`, the texture pass transcodes each cooked Basis texture once, at build time in Node, to ASTC 4×4 blocks. It writes a KTX2 with the matching `vkFormat` (sRGB for colour, UNORM for data), the full mip chain and no supercompression. Basis already targets ASTC 4×4 (`TRANSCODE_TARGETS`), so the bytes match what V8 makes at runtime. The manifest records the format. +1. **Cook (packages/assets, mechanism).** For `--target android|ios`, the texture pass transcodes each cooked Basis texture once, at build time in Node, to ASTC 4×4 blocks. It writes a KTX2 with the matching `vkFormat` (sRGB for colour, UNORM for data), the full mip chain and no supercompression. The installed Basis transcoder supports direct ASTC 4×4 output for both codecs. V8 currently selects BC1/ETC2 for ETC1S, so this mobile output does not claim byte identity with that runtime selection. The manifest records the format. 2. **Admission (create-threenative).** The build gate separates Basis KTX2, which needs a decoder, from raw-block KTX2, which needs none. Raw ASTC is admitted on every mobile runtime. Basis KTX2 keeps today's refusal on QuickJS and JSC. 3. **Device check (fail closed).** At startup, a game whose package holds ASTC on an adapter without `texture-compression-astc` stops with a named error that names the first texture. No silent RGBA8 fallback is shipped. 4. **Block size.** Only 4×4, because ETC1S and UASTC transcode to 4×4 and to no larger block. Unreal's 6×6 size default would need a new encoder and is out of scope. @@ -36,9 +36,19 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Risks:** - **Package size.** Raw ASTC 4×4 is 8 bits per pixel before the APK's deflate. ETC1S Basis is far smaller. Phase 2 records the bytes and the APK size. -- **Quality.** ETC1S transcoded to ASTC keeps ETC1S quality. The PRD-351 compression floor still applies to the source. +- **Quality.** The existing PRD-351 ladder measures the Basis candidate before the offline transcode. ASTC rendering and final visual quality remain unverified until the native and visual proof lanes run. - **Emulator.** ASTC support on an emulator may differ from hardware (as recorded in PRD-097). The emulator proves the path. Only a device proves VRAM. +## Decisions + +- **2026-10-10, implementation lane:** reuse the installed Three Basis transcoder directly, with no extra encoder or dependency. The local two-colour 8×8 ETC1S probe reported `isETC1S=true`, `cTFASTC_4x4_RGBA=10` and successful full-mip ASTC lengths `[64,16,16,16]` (exit 0). `packages/assets/__tests__/image-quality.spec.ts` retains that invocation as an asserted ETC1S/UASTC test, comparing every output mip to the shipped transcoder. This proves block production, not rendering, VQ-01 completion or visual quality. +- **2026-10-10, mobile caller contract:** the starter scaffold test now requires at least one emitted ASTC KTX2 before validating every KTX2's raw ASTC format and full mip chain, while retaining Meshopt refusal. `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts -t "keeps the starter's shipped assets mobile-shippable" --silent` passed (1 passed, 65 skipped, exit 0, 6.12 s); focused Biome and `git diff --check` passed. The earlier full assets suite failed (551 passed, 2 failed, 6 skipped, exit 1, 294.32 s), with an unexpected worker exit whose code/signal was not reported: `bake-receipt.spec.ts:301` resolved instead of rejecting undeclared `stray.bin`, and `worker-pool.spec.ts:44` produced empty output instead of `empty-chain-exited`. Isolating the bake-receipt case on the same Node 20.19.6 repeated the failure (1 failed, 17 skipped, exit 1, 616 ms). Work on these failures stopped pending Opus diagnosis; their cause remains unknown, and the worker-pool case has not been isolated. All phase and acceptance boxes remain open at 0%; no native or visual claim is added. +- **2026-10-10, receipt guard regression:** the engine guard used only `mtimeMs + 1 >= runStart`, so a newly copied undeclared file retaining an old timestamp could escape it. The existing stray-writer test now also preserves an old timestamp with `utimes`. On local Node 20.19.6, `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts -t 'preserved old timestamp: true' --silent` failed before the fix (1 failed, 18 skipped, exit 1, 10.00 s: resolved instead of rejecting), then passed (1 passed, 18 skipped, exit 0, 4.40 s). The compiler now snapshots output paths before its first write, rejects every new undeclared path regardless of timestamp, retains existing modification-time and receipt/journal exemptions, and propagates output-walk errors except `ENOENT`. `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts --silent` passed all 19 tests (exit 0, 59.98 s), including authored-static, cache, auxiliary, stale-output and failed-bake ownership cases. Assets typecheck, focused Biome, focused doc links (12 passed, exit 0, 1.15 s) and `git diff --check` passed. At this receipt-only checkpoint, the full assets suite was still at its earlier failed result; it had not been rerun. The worker-pool failure and unexpected worker exit remain undiagnosed, with no established exit signal, OOM or timer cause. No filesystem/kernel timing claim is made. +- **2026-10-10, open ASTC limits:** `maxBytes` currently gates the Basis candidate's bytes, not the emitted raw ASTC bytes; the quality floor measures the Basis decode, not the shipped ASTC decode. Mobile explicit `runtimeDecoders` overrides and `codec: "none"` behavior remain unverified. The initial 12 focused tests do not establish preservation of final ASTC size caps, shipped quality or those override contracts; these claims remain open alongside embedded/shared textures, lightmaps, admission, native and visual proof. +- **2026-10-10, worker-exit diagnostics:** only the disabled-pass compiler test's child collector changed: it now waits for stdio `close`, records code/signal, whether its own timer fired, PID and monotonic elapsed time, and includes that data plus child startup/import/compile markers in assertion failure messages. Spawn errors reject and clear the timer. The existing 5 s child-kill and 10 s test budgets remain unchanged. On the original Debian laptop with Node 20.19.6 (status: 58 °C/load 0.28 before isolation; 57 °C/load 0.14 before the nearest file), one `pnpm exec vitest run packages/assets/__tests__/worker-pool.spec.ts -t "exits the real compiler with all passes disabled and no workers" --silent` passed (1 passed, 5 skipped, exit 0, 509 ms), then one `pnpm exec vitest run packages/assets/__tests__/worker-pool.spec.ts --silent` passed (6 passed, exit 0, 1.62 s). The first case asserted code 0, no signal and no timer firing in both runs. Assets typecheck (including tests), focused Biome and `git diff --check` passed. Neither run reproduced the earlier empty-output failure, so its cause and the separate unexpected Vitest worker exit remain unproven; other child collectors were not changed. This diagnostic component performed no full assets suite, timeout relaxation, build or ASTC source change. All phase and acceptance boxes remain open at 0%, including the final ASTC size, quality and override limits above. + + Full assets follow-up at committed source `90383cb3de930f0cc2a848728c482d45405a2614`: one `pnpm exec vitest run packages/assets/__tests__ --silent` on the original Debian laptop/Node 20.19.6 passed (39 files passed, 1 file skipped; 566 tests passed, 6 skipped; exit 0, 255.29 s). Existing test parallelism and budgets were unchanged; a 900 s supervisor deadline bounded the job, and inherited `NODE_OPTIONS` were preserved while appending `--report-on-fatalerror` and an ignored report directory. No unexpected Vitest worker exit or fatal-error report occurred; the instrumented compiler-child case passed its code-0/no-signal/no-own-timeout assertions. Saved before/after resources and cgroup memory events show `oom`/`oom_kill`/`oom_group_kill` stayed at 0. Read-only kernel captures were unavailable (`dmesg`: operation not permitted); no OOM or original-failure cause is inferred. Laptop status was 62 °C/load 0.51 before the job and 74 °C/load 49.24 after completion. Logs remain in ignored `artifacts/prd568-bootstrap/assets-rerun/` and the adjacent supervisor/status logs. This supersedes the earlier failed full-suite result; it does not establish the cause of that worker exit or qualify final ASTC byte caps, shipped quality, mobile overrides/codec-none, complete cook coverage, admission, native or visual behavior. All phase and acceptance boxes remain open at 0%. + ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: pending. @@ -60,14 +70,17 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Execution Phases #### Phase 1: Cook and admit raw ASTC -**Status:** NOT STARTED -**Files:** `packages/assets/src/passes/texture.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` +**Status:** PARTIAL +**Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` + Partial: standalone Android/iOS output now transcodes the selected Basis result to raw ASTC, retaining the Basis selection flow and target cache identity; final ASTC size caps, shipped quality and mobile override contracts remain open (see Decisions). Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The initial focused proof did not run the full assets suite; the subsequent failure, receipt fix and successful full-suite rerun are recorded in Decisions. Native bundling, emulator, timing, GPU and visual judge remain unrun. - [ ] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` #### Phase 2: Load and check on the host **Status:** NOT STARTED -**Files:** `packages/core/src/assets.ts` (route raw ASTC through `createRawTexture`; adapter check), `packages/core/__tests__/` +**Files:** `packages/runtime-native/scripts/bundle.mjs` (generates the decoder-free raw KTX2 route in `native-bundle.mjs`), `packages/core/src/assets.ts` (route raw ASTC through `createRawTexture`; adapter check), `packages/core/__tests__/` **Verification:** AC-1 and AC-2 close this phase. - [ ] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts` - [ ] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing + +**Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, owner `/root/prd568_support`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active implementation/review. No push, merge or cleanup yet. diff --git a/packages/assets/__tests__/bake-receipt.spec.ts b/packages/assets/__tests__/bake-receipt.spec.ts index ad54e10dcb..ac40fcef08 100644 --- a/packages/assets/__tests__/bake-receipt.spec.ts +++ b/packages/assets/__tests__/bake-receipt.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { buildFixtureGlb } from "../../../test-support/generate-fixture-model.js"; @@ -278,28 +278,34 @@ describe("bake receipt", () => { ); }); - it("should throw when a pass writes a file under the output root it did not declare", async () => { - const root = await makeTempDir("threenative-receipt-stray-"); - await mkdir(path.join(root, "assets")); - await writeFile(path.join(root, "assets", "rock.png"), rgbaPng({ height: 32, width: 32 })); - - await expect( - compileAssets({ - cwd: root, - transcoder: TRANSCODER, - passes: [ - { - name: "stray-writer", - apply: async (input: Buffer) => { - await mkdir(path.join(root, "public"), { recursive: true }); - await writeFile(path.join(root, "public", "stray.bin"), "undeclared"); - return input; + it.each([false, true])( + "should throw when a pass writes a file under the output root it did not declare (preserved old timestamp: %s)", + async (preserveTimestamp) => { + const root = await makeTempDir("threenative-receipt-stray-"); + await mkdir(path.join(root, "assets")); + await writeFile(path.join(root, "assets", "rock.png"), rgbaPng({ height: 32, width: 32 })); + + await expect( + compileAssets({ + cwd: root, + transcoder: TRANSCODER, + passes: [ + { + name: "stray-writer", + apply: async (input: Buffer) => { + await mkdir(path.join(root, "public"), { recursive: true }); + const strayPath = path.join(root, "public", "stray.bin"); + await writeFile(strayPath, "undeclared"); + // A copied output can retain its source's timestamp despite being new to public/. + if (preserveTimestamp) await utimes(strayPath, new Date(0), new Date(0)); + return input; + }, }, - }, - ], - }), - ).rejects.toThrow(/TN_ASSETS_UNDECLARED_OUTPUT.*stray\.bin/su); - }); + ], + }), + ).rejects.toThrow(/TN_ASSETS_UNDECLARED_OUTPUT.*stray\.bin/su); + }, + ); it("should leave a static file the project authored alone", async () => { const root = await makeTempDir("threenative-receipt-static-"); diff --git a/packages/assets/__tests__/compile.spec.ts b/packages/assets/__tests__/compile.spec.ts index 277b98118a..7d2e272229 100644 --- a/packages/assets/__tests__/compile.spec.ts +++ b/packages/assets/__tests__/compile.spec.ts @@ -194,11 +194,8 @@ describe("compileAssets", () => { ).toBe(true); }); - it("should drop compression for a platform that cannot decode it, and say which", async () => { - // Android and iOS run the native host without WebAssembly, so a `.ktx2` in the bundle is a - // black screen. Before this the author had to pin `textures: "none"` in the config to satisfy - // that one target, and the pin then followed their web build — a scaffolded game shipped - // 2,003 MB of uncompressed output for it. The build names the target; the engine decides. + it("should cook decoder-free ASTC on mobile while web keeps Basis", async () => { + // ASTC needs no runtime decoder; model Basis/Meshopt remain unavailable. const root = await makeTempDir("threenative-compile-platform-"); await mkdir(path.join(root, "assets")); const source = rgbaPng({ @@ -210,12 +207,17 @@ describe("compileAssets", () => { }); await writeFile(path.join(root, "assets", "rock.png"), source); - const mobile = await compileAssets({ cwd: root, platform: "android", transcoder: TRANSCODER }); + const mobile = await compileAssets({ + cwd: root, + platform: "android", + transcoder: TRANSCODER, + config: { textures: { floor: { ssim: 0, meanDeltaE00: 100 } } }, + }); const mobileManifest = JSON.parse( await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), ) as { entries: Record }; - expect(mobileManifest.entries["rock.png"]?.output).toMatch(/\.png$/u); + expect(mobileManifest.entries["rock.png"]?.output).toMatch(/\.ktx2$/u); expect(mobile.skippedCompression).toEqual([ { bytes: 0, @@ -224,7 +226,6 @@ describe("compileAssets", () => { kind: "model", reason: "platform", }, - { bytes: source.length, files: 1, kind: "texture", reason: "platform" }, ]); // The same config, the target that can decode it: compression ships. diff --git a/packages/assets/__tests__/image-quality.spec.ts b/packages/assets/__tests__/image-quality.spec.ts index 23eab86286..5f21556aaf 100644 --- a/packages/assets/__tests__/image-quality.spec.ts +++ b/packages/assets/__tests__/image-quality.spec.ts @@ -5,7 +5,7 @@ import { read as readKTX2 } from "ktx-parse"; import { describe, expect, it } from "vitest"; import { basisTranscoderPaths } from "../../../test-support/three-basis.js"; import { deltaE00, imageQuality, ssim } from "../src/image-quality.js"; -import { measureKtx2 } from "../src/image-quality.js"; +import { measureKtx2, transcodeKtx2ToAstc } from "../src/image-quality.js"; import { encodeToKTX2 } from "../src/ktx2-encoder.js"; import { decodeImageBytes } from "../src/passes/decode-image.js"; @@ -23,6 +23,90 @@ function pixels(noise = false): Uint8Array { } describe("image quality instrument", () => { + it.each([false, true])( + "transcodes every ETC1S/UASTC mip directly to raw ASTC (UASTC=%s)", + async (isUASTC) => { + const data = new Uint8Array(8 * 8 * 4); + for (let i = 0; i < 64; i += 1) + data.set(i % 8 < 4 ? [200, 130, 40, 255] : [20, 60, 220, 255], i * 4); + const encoded = await encodeToKTX2(data, { + imageDecoder: async () => ({ data, height: 8, width: 8 }), + isUASTC, + isSetKTX2SRGBTransferFunc: !isUASTC, + }); + const paths = basisTranscoderPaths(); + const shim = { exports: {} as unknown }; + new Function( + "module", + "exports", + "require", + "__filename", + "__dirname", + await readFile(paths.javascriptPath, "utf8"), + )( + shim, + shim.exports, + createRequire(import.meta.url), + paths.javascriptPath, + path.dirname(paths.javascriptPath), + ); + const basis = await ( + shim.exports as (options: { wasmBinary: Uint8Array }) => Promise<{ + initializeBasis(): void; + transcoder_texture_format: { cTFASTC_4x4_RGBA: { value: number } }; + KTX2File: new ( + bytes: Uint8Array, + ) => { + isETC1S(): boolean; + startTranscoding(): boolean; + getLevels(): number; + getImageTranscodedSizeInBytes(...args: number[]): number; + transcodeImage(dst: Uint8Array, ...args: number[]): boolean; + close(): void; + delete(): void; + }; + }> + )({ wasmBinary: await readFile(paths.wasmPath) }); + basis.initializeBasis(); + const file = new basis.KTX2File(encoded); + try { + expect(Boolean(file.isETC1S())).toBe(!isUASTC); + expect(Boolean(file.startTranscoding())).toBe(true); + const format = basis.transcoder_texture_format.cTFASTC_4x4_RGBA.value; + expect(format).toBe(10); + const raw = readKTX2(await transcodeKtx2ToAstc(encoded)); + expect(raw.vkFormat).toBe(isUASTC ? 157 : 158); + expect(raw.supercompressionScheme).toBe(0); + expect(raw.globalData).toBeNull(); + expect(raw.levelCount).toBe(4); + expect(raw.levels.map((level) => level.levelData.byteLength)).toEqual([64, 16, 16, 16]); + for (let mip = 0; mip < file.getLevels(); mip += 1) { + const expected = new Uint8Array(file.getImageTranscodedSizeInBytes(mip, 0, 0, format)); + expect(Boolean(file.transcodeImage(expected, mip, 0, 0, format, 0, -1, -1))).toBe(true); + expect(expected.some((byte) => byte !== 0)).toBe(true); + expect(raw.levels[mip]?.levelData).toEqual(expected); + expect(raw.levels[mip]?.uncompressedByteLength).toBe(expected.byteLength); + } + expect(raw.dataFormatDescriptor[0]).toMatchObject({ + colorModel: 162, + texelBlockDimension: [3, 3, 0, 0], + bytesPlane: [16, 0, 0, 0, 0, 0, 0, 0], + }); + } finally { + file.close(); + file.delete(); + } + }, + ); + it("rejects invalid or incomplete Basis input for the mobile cook", async () => { + await expect(transcodeKtx2ToAstc(new Uint8Array([0]))).rejects.toThrow(); + const data = new Uint8Array(8 * 8 * 4).fill(128); + const encoded = await encodeToKTX2(data, { + imageDecoder: async () => ({ data, width: 8, height: 8 }), + generateMipmap: false, + }); + await expect(transcodeKtx2ToAstc(encoded)).rejects.toThrow(/TN_ASSETS_ASTC_INPUT/); + }); it("encodes both RDO lambdas through the current vendored encoder with Zstd", async () => { const fixture = "examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_diff.jpg"; const input = await readFile(new URL(`../../../${fixture}`, import.meta.url)); diff --git a/packages/assets/__tests__/texture-pass.spec.ts b/packages/assets/__tests__/texture-pass.spec.ts index 7de23298b9..c31e733904 100644 --- a/packages/assets/__tests__/texture-pass.spec.ts +++ b/packages/assets/__tests__/texture-pass.spec.ts @@ -59,6 +59,102 @@ function compressiblePng(alpha?: (x: number, y: number) => number): Buffer { } describe("the ktx2 texture pass", () => { + it.each(["android", "ios"] as const)( + "cooks raw ASTC on %s without admitting decoder-backed custom passes", + async (platform) => { + const root = await makeTempDir("threenative-mobile-astc-"); + await mkdir(path.join(root, "assets")); + await writeFile(path.join(root, "assets", "colour.png"), compressiblePng()); + await writeFile(path.join(root, "assets", "map_normal.png"), compressiblePng()); + const config = { + textures: { + maxSize: 32, + overrides: [ + { glob: "colour.png", codec: "etc1s" }, + { glob: "map_normal.png", codec: "uastc" }, + ], + floor: { ssim: 0, meanDeltaE00: 100 }, + }, + } satisfies IAssetSourceConfig; + const readOutputs = async () => { + const manifest = JSON.parse( + await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), + ) as { + entries: Record< + string, + { + output: string; + format: string; + transcodeTargets?: string[]; + quality: { codec: string }; + } + >; + }; + return Promise.all( + Object.values(manifest.entries).map(async (entry) => ({ + entry, + bytes: await readFile(path.join(root, "public", entry.output)), + })), + ); + }; + await compileAssets({ + cwd: root, + config, + platform: "web", + transcoder: TRANSCODER, + concurrency: 1, + }); + const web = await readOutputs(); + await compileAssets({ + cwd: root, + config, + platform: "desktop", + transcoder: TRANSCODER, + concurrency: 1, + }); + expect(await readOutputs()).toEqual(web); + const options = { + cwd: root, + config, + platform, + runtimeDecoders: { ktx2: false, meshopt: false }, + concurrency: 1, + }; + const mobile = await compileAssets(options); + expect(mobile.written).toBe(2); + const outputs = await readOutputs(); + expect(outputs.map(({ entry }) => entry.format)).toEqual(["astc4x4", "astc4x4"]); + for (const { entry, bytes } of outputs) { + const raw = readKTX2(bytes); + expect(raw.vkFormat).toBe(entry.quality.codec === "etc1s" ? 158 : 157); + expect(raw.supercompressionScheme).toBe(0); + expect([raw.pixelWidth, raw.pixelHeight, raw.levelCount]).toEqual([32, 32, 6]); + expect(entry.transcodeTargets).toBeUndefined(); + } + await expect( + stat(path.join(root, "public", "basis", "basis_transcoder.wasm")), + ).rejects.toThrow(); + expect((await compileAssets(options)).written).toBe(0); + expect(await readOutputs()).toEqual(outputs); + await compileAssets({ + cwd: root, + config, + platform: "web", + transcoder: TRANSCODER, + concurrency: 1, + }); + expect(await readOutputs()).toEqual(web); + const apply = vi.fn(async (input: Buffer) => input); + const custom = await compileAssets({ + ...options, + passes: [{ name: "basis-only", appliesTo: ["texture"], needsRuntimeDecoder: true, apply }], + }); + expect(apply).not.toHaveBeenCalled(); + expect(custom.skippedCompression).toContainEqual( + expect.objectContaining({ pass: "basis-only", reason: "platform" }), + ); + }, + ); it("preserves pure noise when every compressed rung falls below the quality floor", async () => { const channel = (x: number, y: number, shift: number): number => { let value = Math.imul(x + 1, 0x45d9f3b) ^ Math.imul(y + 1, 0x27d4eb2d); @@ -499,12 +595,13 @@ describe("the decoder-free size cap", () => { await writeFile(path.join(root, "assets", "decal.png"), alpha); await writeFile(path.join(root, "assets", "small.png"), small); - // Android is the decoder-free case: no WebAssembly, so the KTX2 pass drops and the cap is - // applied by the resize pass. Read back the emitted bytes, not the resolved config. + // A decoder-free desktop runtime retains the PNG resize path. Mobile cooks raw ASTC. + // Read back the emitted bytes, not the resolved config. await compileAssets({ config: { textures: { maxSize: 1024 } }, cwd: root, - platform: "android", + platform: "desktop", + runtimeDecoders: { ktx2: false, meshopt: false }, }); const manifest = JSON.parse( @@ -567,7 +664,8 @@ describe("the decoder-free size cap", () => { await compileAssets({ config: { textures: { maxSize: 64, overrides: [{ codec: "none", glob: "ui/**" }] } }, cwd: root, - platform: "android", + platform: "desktop", + runtimeDecoders: { ktx2: false, meshopt: false }, }); const manifest = JSON.parse( @@ -594,7 +692,8 @@ describe("the decoder-free size cap", () => { compileAssets({ config: { textures: { maxSize: 1024 } }, cwd: root, - platform: "android", + platform: "desktop", + runtimeDecoders: { ktx2: false, meshopt: false }, }), ).rejects.toThrow(/TN_ASSETS_TEXTURE_UNDECODABLE.*broken\.png/su); }); diff --git a/packages/assets/__tests__/worker-pool.spec.ts b/packages/assets/__tests__/worker-pool.spec.ts index a271fd74e9..a82b35353f 100644 --- a/packages/assets/__tests__/worker-pool.spec.ts +++ b/packages/assets/__tests__/worker-pool.spec.ts @@ -14,12 +14,25 @@ describe("asset worker pool lifetime", () => { await mkdir(path.join(root, "assets")); await writeFile(path.join(root, "assets/proof.txt"), "unchanged"); const moduleUrl = pathToFileURL(path.resolve("packages/assets/src/compile.ts")).href; - const script = `const {compileAssets} = await import(${JSON.stringify(moduleUrl)}); + const script = `const started = performance.now(); + const mark = (phase) => console.error("TN_ASSETS_WORKER_STAGE " + JSON.stringify({phase, elapsedMs:performance.now()-started})); + mark("startup"); + const {compileAssets} = await import(${JSON.stringify(moduleUrl)}); + mark("imported"); const result = await compileAssets({cwd:${JSON.stringify(root)}, concurrency:2, config:{audio:"none",models:"none",textures:"none"}}); + mark("compiled"); if(result.concurrencyUsed!==1 || result.passCosts.length!==0) throw new Error("unexpected worker/pass"); console.log("empty-chain-exited");`; - const result = await new Promise<{ output: string; code: number | null }>((resolve, reject) => { + const result = await new Promise<{ + output: string; + code: number | null; + signal: NodeJS.Signals | null; + timedOut: boolean; + pid: number | undefined; + elapsedMs: number; + }>((resolve, reject) => { + const started = performance.now(); const child = spawn(process.execPath, [ "--import", "tsx", @@ -28,21 +41,38 @@ describe("asset worker pool lifetime", () => { script, ]); let output = ""; + let timedOut = false; child.stdout.on("data", (chunk) => { output += String(chunk); }); child.stderr.on("data", (chunk) => { output += String(chunk); }); - child.once("error", reject); - const timer = setTimeout(() => child.kill("SIGKILL"), 5000); - child.once("exit", (code) => { + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, 5000); + child.once("close", (code, signal) => { clearTimeout(timer); - resolve({ output, code }); + resolve({ + output, + code, + signal, + timedOut, + pid: child.pid, + elapsedMs: performance.now() - started, + }); }); }); - expect(result.output).toContain("empty-chain-exited"); - expect(result.code).toBe(0); + const diagnostic = JSON.stringify(result); + expect(result.output, diagnostic).toContain("empty-chain-exited"); + expect(result.code, diagnostic).toBe(0); + expect(result.signal, diagnostic).toBeNull(); + expect(result.timedOut, diagnostic).toBe(false); }, 10000); it("terminates every worker when disposed", async () => { diff --git a/packages/assets/src/compile.ts b/packages/assets/src/compile.ts index 2b4327026c..f6e51a95d9 100644 --- a/packages/assets/src/compile.ts +++ b/packages/assets/src/compile.ts @@ -1644,7 +1644,13 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo }), ...embeddedTexturesFor(configuredModels.textures, runtimeDecoderCapabilities.ktx2), }; - const textures = configuredTextures; + const mobileAstc = options.platform === "android" || options.platform === "ios"; + const textures = + configuredTextures === undefined + ? undefined + : mobileAstc + ? { ...configuredTextures, astc: true } + : configuredTextures; const lightmap = (models as (IModelPassOptions & { lightmap?: ILightmapPassOptions }) | undefined) ?.lightmap; // The built-in registry runs only when the caller did not replace it wholesale; each @@ -1669,13 +1675,14 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo if (textures !== undefined) { registerBuiltin(texturePass(textures), { kind: "texture", - needsRuntimeDecoder: true, + needsRuntimeDecoder: !mobileAstc, options: textures, }); } if ( textures !== undefined && !runtimeDecoderCapabilities.ktx2 && + !mobileAstc && textures.maxSize !== undefined ) { // The KTX2 pass dropped for this target, but the declared cap is still honoured: the @@ -1780,11 +1787,14 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo ? undefined : configuredTextures === undefined ? "config" - : runtimeDecoderCapabilities.ktx2 + : runtimeDecoderCapabilities.ktx2 || mobileAstc ? undefined : "platform", texturesActive: - textures !== undefined && options.passes === undefined && runtimeDecoderAvailable, + textures !== undefined && + options.passes === undefined && + runtimeDecoderAvailable && + !mobileAstc, }; } @@ -2252,8 +2262,9 @@ async function walkOutputFiles(root: string, prefix = ""): Promise { let entries: Dirent[]; try { entries = await readdir(path.join(root, prefix), { withFileTypes: true }); - } catch { - return []; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; } const files: string[] = []; for (const entry of entries) { @@ -2269,14 +2280,16 @@ async function walkOutputFiles(root: string, prefix = ""): Promise { * * Without this, a pass that writes a file it does not report is invisible: the delete-test would * delete less than the bake produced, the second run would still find the undeleted file, and the - * gate would pass while proving nothing. Only files touched during this run are considered — a - * project's own hand-authored `public/icon.png` is not a bake output and is left alone. + * gate would pass while proving nothing. New paths are checked regardless of their timestamps; + * existing paths are checked when touched during this run. An untouched hand-authored + * `public/icon.png` is not a bake output and is left alone. */ async function assertNoUndeclaredOutputs( outputRoot: string, declared: ReadonlySet, since: number, writtenBefore: ReadonlySet, + existingBefore: ReadonlySet, ): Promise { const undeclared: string[] = []; for (const relative of await walkOutputFiles(outputRoot)) { @@ -2292,7 +2305,7 @@ async function assertNoUndeclaredOutputs( // millisecond apart, and the previous bake's now-stale output is not this bake's leak. if (writtenBefore.has(relative)) continue; const info = await stat(path.join(outputRoot, relative)); - if (info.mtimeMs + 1 >= since) undeclared.push(relative); + if (!existingBefore.has(relative) || info.mtimeMs + 1 >= since) undeclared.push(relative); } if (undeclared.length > 0) { throw new Error( @@ -2400,13 +2413,10 @@ export async function compileAssets( } const previous = await readExistingManifest(manifestPath); - // Read before the first write *and before the source walk*, so the undeclared-output guard can - // tell this run's files from the project's own static ones. Taking it after the walk shrinks the - // margin by however long the walk took, and `TN_ASSETS_UNDECLARED_OUTPUT` then misses a stray - // file whose mtime the filesystem truncated below it — which is exactly how - // `bake-receipt.spec.ts` went red on a loaded CI shard while passing locally. It never reaches - // the receipt: that stays deterministic. + // Snapshot before the first write and source walk: new outputs can retain old timestamps when + // copied, so modification time alone cannot distinguish them from authored static files. const runStart = Date.now(); + const existingBefore = new Set(await walkOutputFiles(layout.outputRoot)); const sources = await walkSources(layout.sourceRoot); const excluded = sources.filter((logical) => @@ -3028,7 +3038,13 @@ export async function compileAssets( ); } const currentReceiptPaths = new Set(receiptOutputs.map((output) => output.path)); - await assertNoUndeclaredOutputs(layout.outputRoot, currentReceiptPaths, runStart, writtenBefore); + await assertNoUndeclaredOutputs( + layout.outputRoot, + currentReceiptPaths, + runStart, + writtenBefore, + existingBefore, + ); const staleOutputs = await resolveStaleReceiptOutputs( layout.outputRoot, new Set([...writtenBefore, ...pendingPaths]), diff --git a/packages/assets/src/image-quality.ts b/packages/assets/src/image-quality.ts index bc20a621c6..63efb63c95 100644 --- a/packages/assets/src/image-quality.ts +++ b/packages/assets/src/image-quality.ts @@ -2,6 +2,15 @@ import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + KHR_DF_MODEL_ASTC, + KHR_DF_TRANSFER_SRGB, + KHR_SUPERCOMPRESSION_NONE, + VK_FORMAT_ASTC_4x4_SRGB_BLOCK, + VK_FORMAT_ASTC_4x4_UNORM_BLOCK, + read as readKTX2, + write as writeKTX2, +} from "ktx-parse"; import { resolveBasisTranscoder } from "./compile.js"; // The sculpt MCP math is not present in this repository. Minimal SSIM port: Rec.709 @@ -290,6 +299,7 @@ interface IBasisFile { startTranscoding(): boolean; getWidth(): number; getHeight(): number; + getLevels(): number; getImageTranscodedSizeInBytes(mip: number, layer: number, face: number, format: number): number; transcodeImage( dst: Uint8Array, @@ -305,7 +315,10 @@ interface IBasisFile { interface IBasisModule { readonly KTX2File: new (bytes: Uint8Array) => IBasisFile; initializeBasis(): void; - readonly transcoder_texture_format: { readonly cTFRGBA32: { readonly value: number } }; + readonly transcoder_texture_format: { + readonly cTFRGBA32: { readonly value: number }; + readonly cTFASTC_4x4_RGBA: { readonly value: number }; + }; } let basisPromise: Promise | undefined; @@ -331,6 +344,73 @@ async function basisModule(): Promise { return basisPromise; } +/** Cook the measured Basis result to decoder-free ASTC without re-encoding its pixels. */ +export async function transcodeKtx2ToAstc(encoded: Uint8Array): Promise { + const container = readKTX2(encoded); + const descriptor = container.dataFormatDescriptor[0]; + if ( + container.vkFormat !== 0 || + container.pixelWidth <= 0 || + container.pixelHeight <= 0 || + container.pixelDepth !== 0 || + container.layerCount !== 0 || + container.faceCount !== 1 || + container.levelCount !== + Math.floor(Math.log2(Math.max(container.pixelWidth, container.pixelHeight))) + 1 || + descriptor === undefined + ) + throw new Error("TN_ASSETS_ASTC_INPUT: expected a 2D Basis KTX2 with its full mip chain."); + const basis = await basisModule(); + const file = new basis.KTX2File(encoded); + try { + if (!file.isValid() || !file.startTranscoding() || file.getLevels() !== container.levelCount) + throw new Error("TN_ASSETS_ASTC_INPUT: the Basis transcoder rejected the cooked texture."); + const format = basis.transcoder_texture_format.cTFASTC_4x4_RGBA.value; + container.levels = container.levels.map((_, mip) => { + const width = Math.max(1, container.pixelWidth >> mip); + const height = Math.max(1, container.pixelHeight >> mip); + const bytes = Math.ceil(width / 4) * Math.ceil(height / 4) * 16; + const levelData = new Uint8Array(bytes); + if ( + file.getImageTranscodedSizeInBytes(mip, 0, 0, format) !== bytes || + !file.transcodeImage(levelData, mip, 0, 0, format, 0, -1, -1) + ) + throw new Error( + `TN_ASSETS_ASTC_TRANSCODE: mip ${String(mip)} could not transcode to ASTC 4x4.`, + ); + return { levelData, uncompressedByteLength: bytes }; + }); + container.vkFormat = + descriptor.transferFunction === KHR_DF_TRANSFER_SRGB + ? VK_FORMAT_ASTC_4x4_SRGB_BLOCK + : VK_FORMAT_ASTC_4x4_UNORM_BLOCK; + container.supercompressionScheme = KHR_SUPERCOMPRESSION_NONE; + container.globalData = null; + container.dataFormatDescriptor = [ + { + ...descriptor, + colorModel: KHR_DF_MODEL_ASTC, + texelBlockDimension: [3, 3, 0, 0], + bytesPlane: [16, 0, 0, 0, 0, 0, 0, 0], + samples: [ + { + bitOffset: 0, + bitLength: 127, + channelType: 0, + samplePosition: [0, 0, 0, 0], + sampleLower: 0, + sampleUpper: 0xffffffff, + }, + ], + }, + ]; + return writeKTX2(container); + } finally { + file.close(); + file.delete(); + } +} + /** Decode only mip zero to RGBA32 using the shipped Basis transcoder; never re-encode. */ export async function measureKtx2( source: Uint8Array, diff --git a/packages/assets/src/passes/texture.ts b/packages/assets/src/passes/texture.ts index 488b4152bd..e8ab99f8fb 100644 --- a/packages/assets/src/passes/texture.ts +++ b/packages/assets/src/passes/texture.ts @@ -7,6 +7,7 @@ import { IMAGE_QUALITY_IDENTITY, imageQuality, resolveImageQualityFloor, + transcodeKtx2ToAstc, } from "../image-quality.js"; import { KTX2_ENCODER_VERSION, encodeToKTX2 } from "../ktx2-encoder.js"; import { decodeImageBytes } from "./decode-image.js"; @@ -54,6 +55,8 @@ export interface ITextureOverride { } export interface ITexturePassOptions { + /** Compiler-selected mobile output; the quality ladder still measures Basis first. */ + readonly astc?: boolean; readonly floor?: Partial; /** Internal reporting control; selection still measures and enforces the floor. */ readonly measureQuality?: boolean; @@ -109,6 +112,7 @@ export function texturePass(options: ITexturePassOptions = {}): IAssetPass { appliesTo: ["texture"], configuration: { encoder: KTX2_ENCODER_VERSION, + ...(options.astc === true ? { gpuFormat: "astc4x4-v1" } : {}), instrument: IMAGE_QUALITY_IDENTITY, floor: resolveImageQualityFloor(options.floor), keepSmallerSource: true, @@ -211,10 +215,15 @@ export function texturePass(options: ITexturePassOptions = {}): IAssetPass { ); } return { - buffer: Buffer.from(encoded), + buffer: Buffer.from(options.astc === true ? await transcodeKtx2ToAstc(encoded) : encoded), entry: { - format: selected.codec, - transcodeTargets: TRANSCODE_TARGETS[selected.codec as Exclude], + ...(options.astc === true + ? { format: "astc4x4" } + : { + format: selected.codec, + transcodeTargets: + TRANSCODE_TARGETS[selected.codec as Exclude], + }), ...(options.measureQuality === false ? {} : { diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index e1ef4417c9..1f2556cd6f 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -16,6 +16,7 @@ import { import path from "node:path"; import { promisify } from "node:util"; import { compileAssets } from "@threenative/assets"; +import { read as readKTX2 } from "ktx-parse"; import { describe, expect, it } from "vitest"; import { rgbaPng } from "../../../test-support/png.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; @@ -816,16 +817,8 @@ describe("create-threenative", () => { ); it("keeps the starter's shipped assets mobile-shippable", async () => { - // Mobile has no WebAssembly, so neither Basis-decoded textures nor Meshopt-decoded geometry - // can ship there — the red hit on 2026-08-27: `build:android` on a machine with the Basis - // encoder refused TN_NATIVE_KTX2_UNSUPPORTED on a starter scaffold that had built clean the - // week before, purely because the encoder got installed in between. - // - // The template used to pin `models`/`textures` to `"none"` to prevent it, which held for - // android and followed the same game onto web: one shipped 2,003 MB of manifest output with - // no compressed texture in it. The build names its `--target` and the compile step now drops - // the passes that target cannot decode, so this asserts what a mobile bake *produces* rather - // than what the config file says — and the web bake of the same config stays compressed. + // Decoder-free mobile cooking may emit raw ASTC, while Basis and Meshopt still need decoders. + // Check the produced KTX2 headers rather than treating every KTX2 as Basis. const root = await makeTempDir("threenative-scaffold-mobile-"); try { await mkdir(path.join(root, "assets"), { recursive: true }); @@ -849,14 +842,38 @@ describe("create-threenative", () => { }), ); - await compileAssets({ cwd: root, platform: "android" }); + await compileAssets({ + cwd: root, + platform: "android", + runtimeDecoders: { ktx2: false, meshopt: false }, + }); const android = JSON.parse( await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), - ) as { entries: Record }; + ) as { entries: Record }; const mobileOutputs = Object.values(android.entries); expect(mobileOutputs.length).toBeGreaterThan(0); + expect( + mobileOutputs.some((entry) => /\.ktx2$/u.test(entry.output) && entry.format === "astc4x4"), + ).toBe(true); for (const entry of mobileOutputs) { - expect(entry.output).not.toMatch(/\.ktx2$/u); + if (/\.ktx2$/u.test(entry.output)) { + const raw = readKTX2(await readFile(path.join(root, "public", entry.output))); + expect([157, 158]).toContain(raw.vkFormat); + expect(raw.supercompressionScheme).toBe(0); + expect(raw.globalData).toBeNull(); + const levels = Math.floor(Math.log2(Math.max(raw.pixelWidth, raw.pixelHeight))) + 1; + expect(raw.levelCount).toBe(levels); + expect(raw.levels).toHaveLength(levels); + for (const [mip, level] of raw.levels.entries()) { + const bytes = + Math.ceil(Math.max(1, raw.pixelWidth >> mip) / 4) * + Math.ceil(Math.max(1, raw.pixelHeight >> mip) / 4) * + 16; + expect(level.levelData.byteLength).toBe(bytes); + expect(level.uncompressedByteLength).toBe(bytes); + } + expect(entry.format).toBe("astc4x4"); + } expect(entry.extensions ?? []).not.toContain("EXT_meshopt_compression"); }