Skip to content

USDZ: allow texture export under NullEngine#18296

Open
arek-3d wants to merge 1 commit intoBabylonJS:masterfrom
arek-3d:usdz-nullengine-cached-image
Open

USDZ: allow texture export under NullEngine#18296
arek-3d wants to merge 1 commit intoBabylonJS:masterfrom
arek-3d:usdz-nullengine-cached-image

Conversation

@arek-3d
Copy link
Copy Markdown

@arek-3d arek-3d commented Apr 13, 2026

Summary

Allow USDZExportAsync to run under NullEngine (Node.js / server-side)
by reading cached image bytes from InternalTexture._buffer before
falling back to DumpTools.DumpDataAsync, which requires a WebGL or
canvas rendering context and therefore throws in a headless environment.

This mirrors the existing optimization in the glTF material exporter
(GetCachedImageAsync in glTFMaterialExporter.ts), which already
handles this case for GLB export. Before this change, glTF export works
under NullEngine but USDZ export does not — even though the texture data
is already in memory and ready to be written.

Context

Forum discussion: https://forum.babylonjs.com/t/server-side-usdz-export-support-in-babylon-js/60872

Related precedent in this repo:
packages/dev/serializers/src/glTF/2.0/glTFMaterialExporter.ts (GetCachedImageAsync)

Implementation

New internal helper GetCachedImageAsync in
packages/dev/serializers/src/USDZ/usdzExporter.ts inspects
InternalTexture._buffer for a texture with InternalTextureSource.Url
and returns a Blob for any of these supported shapes:

  • ArrayBuffer
  • ArrayBufferView (sliced to avoid shared-buffer leaks)
  • Blob
  • URL string (loaded via Tools.LoadFileAsync)
  • HTMLImageElement (src loaded via Tools.LoadFileAsync)
  • null / no buffer → falls back to Tools.LoadFileAsync(internalTexture.url)

Mime type resolution: texture.mimeTypeGetMimeType(url)null.
If no mime type can be determined, returns null and the existing
DumpTools.DumpDataAsync fallback runs (preserves current behavior for
procedural / render-target textures that have no source image).

The helper is not exported — API surface is unchanged.

Tests

Added packages/dev/serializers/test/unit/USDZ/usdzExporter.test.ts
covering three NullEngine paths:

  • ArrayBuffer cache on _buffer
  • Blob cache on _buffer
  • URL fallback via Tools.LoadFileAsync when _buffer is null

All three assert that DumpTools.DumpDataAsync is never invoked,
proving the new code path is taken.

Validation

  • npm run lint:check — 0 errors
  • npm run test:unit — 3/3 added tests pass
  • npm run build:dev — 0 errors

Side note (not addressed in this PR)

Independently of this change, USDZExportAsync also loads fflate from
https://unpkg.com/fflate@0.8.2 via Tools.LoadScriptAsync, which fails
under Node.js. For now consumers can work around it by assigning
fflate to globalThis before the export call. Happy to address in a
follow-up if desired.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 13, 2026 14:05
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Enables USDZExportAsync to run under NullEngine (Node.js / headless) by attempting to export textures from InternalTexture._buffer/URL bytes before falling back to GPU readback + DumpTools, and adds unit tests to validate the new headless path.

Changes:

  • Add an internal GetCachedImageAsync helper to read cached image bytes from InternalTexture._buffer / URL and build a Blob for export.
  • Update the USDZ texture export loop to prefer cached image bytes and avoid DumpTools.DumpDataAsync when possible.
  • Add Vitest unit tests covering NullEngine behavior for _buffer and URL fallback cases.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
packages/dev/serializers/src/USDZ/usdzExporter.ts Adds cached-image extraction and uses it to support USDZ export without WebGL/canvas contexts.
packages/dev/serializers/test/unit/USDZ/usdzExporter.test.ts Adds unit tests validating cached-buffer and URL fallback paths under NullEngine.

*/
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.
Comment on lines +668 to +677
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)) {
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.
Comment on lines +797 to +804
// 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 {
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.
Comment thread packages/dev/serializers/test/unit/USDZ/usdzExporter.test.ts
Comment on lines +100 to +107
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;
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.
@RaananW
Copy link
Copy Markdown
Member

RaananW commented Apr 13, 2026

/azp run

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented Apr 13, 2026

Please make sure to label your PR with "bug", "new feature" or "breaking change" label(s).
To prevent this PR from going to the changelog marked it with the "skip changelog" label.

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented Apr 13, 2026

Snapshot stored with reference name:
refs/pull/18296/merge

Test environment:
https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/18296/merge/index.html

To test a playground add it to the URL, for example:

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/18296/merge/index.html#WGZLGJ#4600

Links to test your changes to core in the published versions of the Babylon tools (does not contain changes you made to the tools themselves):

https://playground.babylonjs.com/?snapshot=refs/pull/18296/merge
https://sandbox.babylonjs.com/?snapshot=refs/pull/18296/merge
https://gui.babylonjs.com/?snapshot=refs/pull/18296/merge
https://nme.babylonjs.com/?snapshot=refs/pull/18296/merge

To test the snapshot in the playground with a playground ID add it after the snapshot query string:

https://playground.babylonjs.com/?snapshot=refs/pull/18296/merge#BCU1XR#0

If you made changes to the sandbox or playground in this PR, additional comments will be generated soon containing links to the dev versions of those tools.

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented Apr 13, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented Apr 13, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented Apr 13, 2026

@sebavan
Copy link
Copy Markdown
Member

sebavan commented Apr 13, 2026

/azp run

@azure-pipelines
Copy link
Copy Markdown

Azure Pipelines successfully started running 1 pipeline(s).

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented Apr 13, 2026

Reviewer - this PR has made changes to one or more package.json files.

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented Apr 13, 2026

Reviewer - this PR has made changes to the build configuration file.

This build will release a new package on npm

If that was unintentional please make sure to revert those changes or close this PR.

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented Apr 13, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented Apr 13, 2026

@bjsplat
Copy link
Copy Markdown
Collaborator

bjsplat commented Apr 13, 2026

@RaananW
Copy link
Copy Markdown
Member

RaananW commented Apr 14, 2026

Hey @arek-3d , could you address copilot's comments? they might be non-issues, so feel free to resolve if they make no sense, but please go over them. thanks :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants