Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 64 additions & 5 deletions packages/dev/serializers/src/USDZ/usdzExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Nullable<Blob>> {
const internalTexture = babylonTexture.getInternalTexture();
if (!internalTexture || internalTexture.source !== InternalTextureSource.Url) {
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetCachedImageAsync does not account for internal textures created with invertY=true. Reusing the cached bytes in that case can export a vertically flipped image compared to the existing GPU readback path. Consider mirroring the glTF exporter behavior by returning null when internalTexture.invertY is true (so the existing DumpTools fallback handles orientation correctly).

Suggested change
if (!internalTexture || internalTexture.source !== InternalTextureSource.Url) {
if (!internalTexture || internalTexture.source !== InternalTextureSource.Url || internalTexture.invertY) {

Copilot uses AI. Check for mistakes.
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)) {
Comment on lines +668 to +677
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the cached-buffer branches (ArrayBuffer / ArrayBufferView), mimeType is only taken from Texture.mimeType, which is often undefined for URL textures. That makes GetCachedImageAsync return null even when internalTexture.url has a recognizable extension, causing a fallback to DumpTools (and failing under NullEngine). Consider resolving mimeType from internalTexture.url via GetMimeType when mimeType is missing, regardless of which buffer shape is present.

Copilot uses AI. Check for mistakes.
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
Expand Down Expand Up @@ -745,14 +794,24 @@ export async function USDZExportAsync(scene: Scene, options: Partial<IUSDZExport
for (const id in textureToExports) {
const texture = textureToExports[id];

const size = texture.getSize();
// Try to get the image directly from the internal texture buffer (works without WebGL/canvas)
// eslint-disable-next-line no-await-in-loop
const textureData = await GetTextureDataAsync(texture);
const cachedImage = await GetCachedImageAsync(texture);
if (cachedImage) {
// eslint-disable-next-line no-await-in-loop
const arrayBuffer = await cachedImage.arrayBuffer();
files[`textures/Texture_${id}.png`] = new Uint8Array(arrayBuffer);
} else {
Comment on lines +797 to +804
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exporter always writes and references textures as textures/Texture_${id}.png, but the cached-image path will currently accept any detected mimeType (e.g. image/jpeg) and write those bytes into a .png entry. This can produce invalid USDZs (content/extension mismatch) and diverges from the existing behavior which always encodes PNG via DumpTools. Consider restricting the cached path to image/png only (return null otherwise so DumpTools can re-encode), or update both the archive filename and the USD material references to match the actual mime type.

Copilot uses AI. Check for mistakes.
// Fall back to GPU texture read + DumpTools (requires WebGL/canvas context)
const size = texture.getSize();
// eslint-disable-next-line no-await-in-loop
const textureData = await GetTextureDataAsync(texture);

// eslint-disable-next-line no-await-in-loop
const fileContent = await DumpTools.DumpDataAsync(size.width, size.height, textureData, "image/png", undefined, false, true);
// eslint-disable-next-line no-await-in-loop
const fileContent = await DumpTools.DumpDataAsync(size.width, size.height, textureData, "image/png", undefined, false, true);

files[`textures/Texture_${id}.png`] = new Uint8Array(fileContent as ArrayBuffer).slice(); // This is to avoid getting a link and not a copy
files[`textures/Texture_${id}.png`] = new Uint8Array(fileContent as ArrayBuffer).slice(); // This is to avoid getting a link and not a copy
}
}

// 64 byte alignment
Expand Down
159 changes: 159 additions & 0 deletions packages/dev/serializers/test/unit/USDZ/usdzExporter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { NullEngine } from "core/Engines/nullEngine";
import { InternalTextureSource } from "core/Materials/Textures/internalTexture";
import { Texture } from "core/Materials/Textures/texture";
import { PBRMaterial } from "core/Materials/PBR/pbrMaterial";
import { CreateBox } from "core/Meshes/Builders/boxBuilder";
import { DumpTools } from "core/Misc/dumpTools";
import { Tools } from "core/Misc/tools";
import { Scene } from "core/scene";
Comment thread
RaananW marked this conversation as resolved.

import { USDZExportAsync } from "serializers/USDZ/usdzExporter";

// Minimal 1x1 red PNG - lets us assert exact bytes round-trip into the archive
// without needing any real image decoding at test time.
const OnePixelRedPng = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0x99, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x5b, 0x82, 0x5c,
0x17, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
]);

/**
* The USDZ exporter expects `fflate` to be available on the global scope — in
* the browser it is loaded lazily via Tools.LoadScriptAsync. In Node.js there
* is no script loader, so we install a minimal stub that is enough for
* USDZExportAsync to finish and lets us observe the archive contents.
*/
function installFflateStub(): { capturedFiles: { value: { [key: string]: Uint8Array } | null } } {
const state = { value: null as { [key: string]: Uint8Array } | null };
(globalThis as any).fflate = {
strToU8: (s: string) => 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<typeof vi.spyOn>;
let loadScriptSpy: ReturnType<typeof vi.spyOn>;
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;
Comment on lines +100 to +107
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name says it uses a cached ArrayBuffer, but the setup assigns a Uint8Array (ArrayBufferView) to internal._buffer. Consider renaming the test to reflect the actual buffer shape (Uint8Array/ArrayBufferView) to avoid confusion.

Copilot uses AI. Check for mistakes.
(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();
}
});
});
Loading