From f4c2f1e3f645d4fde970a78e7e0f394a7df14957 Mon Sep 17 00:00:00 2001 From: Arkadiusz_Szczerbinski Date: Mon, 13 Apr 2026 14:07:37 +0200 Subject: [PATCH] USDZ: allow texture export under NullEngine Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dev/serializers/src/USDZ/usdzExporter.ts | 69 +++++++- .../test/unit/USDZ/usdzExporter.test.ts | 159 ++++++++++++++++++ 2 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 packages/dev/serializers/test/unit/USDZ/usdzExporter.test.ts diff --git a/packages/dev/serializers/src/USDZ/usdzExporter.ts b/packages/dev/serializers/src/USDZ/usdzExporter.ts index 562c1ed4ab4..f4a820e0cc4 100644 --- a/packages/dev/serializers/src/USDZ/usdzExporter.ts +++ b/packages/dev/serializers/src/USDZ/usdzExporter.ts @@ -12,11 +12,13 @@ import { Matrix, Vector2 } from "core/Maths/math.vector"; import { type Geometry } from "core/Meshes/geometry"; import { type Mesh } from "core/Meshes/mesh"; import { DumpTools } from "core/Misc/dumpTools"; +import { GetMimeType } from "core/Misc/fileTools"; import { Tools } from "core/Misc/tools"; import { type Scene } from "core/scene"; import { type FloatArray, type Nullable } from "core/types"; import { IsNoopNode } from "../exportUtils"; import { GetTextureDataAsync } from "core/Misc/textureTools"; +import { InternalTextureSource } from "core/Materials/Textures/internalTexture"; /** * Ported from https://github.com/mrdoob/three.js/blob/master/examples/jsm/exporters/USDZExporter.js @@ -650,6 +652,53 @@ function ExtractMeshInformations(mesh: Mesh) { }; } +/** + * Gets cached image data from a texture's internal buffer, if available. + * This allows texture export without requiring a WebGL or canvas rendering context, + * enabling server-side (Node.js) export with NullEngine. + * @param babylonTexture texture to check for cached image data + * @returns PNG blob if found and directly usable; null otherwise + */ +async function GetCachedImageAsync(babylonTexture: BaseTexture): Promise> { + const internalTexture = babylonTexture.getInternalTexture(); + if (!internalTexture || internalTexture.source !== InternalTextureSource.Url) { + return null; + } + + const buffer = internalTexture._buffer; + + let data; + let mimeType = (babylonTexture as Texture).mimeType; + + try { + if (!buffer) { + data = await Tools.LoadFileAsync(internalTexture.url); + mimeType = GetMimeType(internalTexture.url) || mimeType; + } else if (ArrayBuffer.isView(buffer)) { + data = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer; + } else if (buffer instanceof ArrayBuffer) { + data = buffer; + } else if (buffer instanceof Blob) { + data = await buffer.arrayBuffer(); + mimeType = buffer.type || mimeType; + } else if (typeof buffer === "string") { + data = await Tools.LoadFileAsync(buffer); + mimeType = GetMimeType(buffer) || mimeType; + } else if (typeof HTMLImageElement !== "undefined" && buffer instanceof HTMLImageElement) { + data = await Tools.LoadFileAsync(buffer.src); + mimeType = GetMimeType(buffer.src) || mimeType; + } + } catch { + return null; + } + + if (data && mimeType) { + return new Blob([data], { type: mimeType }); + } + + return null; +} + /** * * @param scene scene to export @@ -745,14 +794,24 @@ export async function USDZExportAsync(scene: Scene, options: Partial new TextEncoder().encode(s), + zipSync: (files: { [key: string]: Uint8Array }) => { + state.value = files; + return new Uint8Array([0x50, 0x4b, 0x03, 0x04]); + }, + }; + return { capturedFiles: state }; +} + +describe("USDZ Exporter - NullEngine / Node.js environment", () => { + let engine: NullEngine; + let scene: Scene; + let captured: { value: { [key: string]: Uint8Array } | null }; + let dumpSpy: ReturnType; + let loadScriptSpy: ReturnType; + let previousFflate: unknown; + + beforeEach(() => { + engine = new NullEngine({ + renderHeight: 256, + renderWidth: 256, + textureSize: 256, + deterministicLockstep: false, + lockstepMaxSteps: 1, + }); + scene = new Scene(engine); + + previousFflate = (globalThis as any).fflate; + ({ capturedFiles: captured } = installFflateStub()); + + // If the exporter ever tries to network-load the fflate script we want + // the test to fail loudly rather than hit unpkg. + loadScriptSpy = vi.spyOn(Tools, "LoadScriptAsync").mockImplementation(async () => { + throw new Error("Tools.LoadScriptAsync must not be called when fflate is already on globalThis."); + }); + + // The whole point of the new GetCachedImageAsync path is to avoid the + // DumpTools route, which requires a WebGL / canvas context and is the + // exact failure mode reported on the Babylon.js forum. + dumpSpy = vi.spyOn(DumpTools, "DumpDataAsync").mockImplementation(async () => { + throw new Error("DumpTools.DumpDataAsync must not be invoked when a cached image is available."); + }); + }); + + afterEach(() => { + dumpSpy.mockRestore(); + loadScriptSpy.mockRestore(); + (globalThis as any).fflate = previousFflate; + scene.dispose(); + engine.dispose(); + }); + + function buildTexturedBox(): { texture: Texture } { + const box = CreateBox("box", { size: 1 }, scene); + const material = new PBRMaterial("mat", scene); + const texture = new Texture("red.png", scene); + material.albedoTexture = texture; + box.material = material; + return { texture }; + } + + // After the exporter's 64-byte-alignment pass, a file entry is either a + // raw Uint8Array or the tuple `[Uint8Array, { extra: ... }]` that fflate + // expects. Unwrap both shapes so we can assert on the actual bytes. + function unwrapFileBytes(entry: unknown): Uint8Array { + if (Array.isArray(entry)) { + return entry[0] as Uint8Array; + } + return entry as Uint8Array; + } + + it("exports a textured PBR mesh using the cached ArrayBuffer on the internal texture", async () => { + const { texture } = buildTexturedBox(); + + const internal = texture.getInternalTexture()!; + // NullEngine marks createTexture output as InternalTextureSource.Url, which + // is what GetCachedImageAsync requires before reading the cached buffer. + expect(internal.source).toBe(InternalTextureSource.Url); + internal._buffer = OnePixelRedPng; + (texture as any)._mimeType = "image/png"; + + const result = await USDZExportAsync(scene, {}); + expect(result).toBeInstanceOf(Uint8Array); + + const pngEntries = Object.keys(captured.value!).filter((k) => k.startsWith("textures/Texture_") && k.endsWith(".png")); + expect(pngEntries).toHaveLength(1); + + // Bytes land in the archive verbatim — no GPU round-trip, no re-encoding. + const exported = unwrapFileBytes(captured.value![pngEntries[0]]); + expect(exported).toBeInstanceOf(Uint8Array); + expect(Array.from(exported)).toEqual(Array.from(OnePixelRedPng)); + + expect(dumpSpy).not.toHaveBeenCalled(); + }); + + it("accepts a Blob cached on the internal texture buffer", async () => { + const { texture } = buildTexturedBox(); + + const internal = texture.getInternalTexture()!; + internal._buffer = new Blob([OnePixelRedPng], { type: "image/png" }); + + await USDZExportAsync(scene, {}); + + const pngEntries = Object.keys(captured.value!).filter((k) => k.startsWith("textures/Texture_") && k.endsWith(".png")); + expect(pngEntries).toHaveLength(1); + expect(unwrapFileBytes(captured.value![pngEntries[0]]).byteLength).toBe(OnePixelRedPng.byteLength); + expect(dumpSpy).not.toHaveBeenCalled(); + }); + + it("falls back to Tools.LoadFileAsync(url) when no buffer is cached, still avoiding DumpTools", async () => { + const loadFileSpy = vi.spyOn(Tools, "LoadFileAsync").mockImplementation(async () => OnePixelRedPng.buffer.slice(0) as ArrayBuffer); + + try { + const { texture } = buildTexturedBox(); + const internal = texture.getInternalTexture()!; + internal._buffer = null; + internal.url = "/assets/red.png"; + + await USDZExportAsync(scene, {}); + + expect(loadFileSpy).toHaveBeenCalledWith("/assets/red.png"); + expect(dumpSpy).not.toHaveBeenCalled(); + + const pngEntries = Object.keys(captured.value!).filter((k) => k.startsWith("textures/Texture_") && k.endsWith(".png")); + expect(pngEntries).toHaveLength(1); + expect(unwrapFileBytes(captured.value![pngEntries[0]]).byteLength).toBe(OnePixelRedPng.byteLength); + } finally { + loadFileSpy.mockRestore(); + } + }); +});