USDZ: allow texture export under NullEngine#18296
USDZ: allow texture export under NullEngine#18296arek-3d wants to merge 1 commit intoBabylonJS:masterfrom
Conversation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
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
GetCachedImageAsynchelper to read cached image bytes fromInternalTexture._buffer/ URL and build aBlobfor export. - Update the USDZ texture export loop to prefer cached image bytes and avoid
DumpTools.DumpDataAsyncwhen possible. - Add Vitest unit tests covering NullEngine behavior for
_bufferand 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) { |
There was a problem hiding this comment.
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).
| if (!internalTexture || internalTexture.source !== InternalTextureSource.Url) { | |
| if (!internalTexture || internalTexture.source !== InternalTextureSource.Url || internalTexture.invertY) { |
| 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)) { |
There was a problem hiding this comment.
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.
| // 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 { |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
Please make sure to label your PR with "bug", "new feature" or "breaking change" label(s). |
|
Snapshot stored with reference name: Test environment: 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 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. |
|
WebGL2 visualization test reporter: |
|
Visualization tests for WebGPU |
|
/azp run |
|
Azure Pipelines successfully started running 1 pipeline(s). |
|
Reviewer - this PR has made changes to one or more package.json files. |
|
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. |
|
WebGL2 visualization test reporter: |
|
Visualization tests for WebGPU |
|
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 :) |
Summary
Allow
USDZExportAsyncto run underNullEngine(Node.js / server-side)by reading cached image bytes from
InternalTexture._bufferbeforefalling back to
DumpTools.DumpDataAsync, which requires a WebGL orcanvas rendering context and therefore throws in a headless environment.
This mirrors the existing optimization in the glTF material exporter
(
GetCachedImageAsyncinglTFMaterialExporter.ts), which alreadyhandles 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
GetCachedImageAsyncinpackages/dev/serializers/src/USDZ/usdzExporter.tsinspectsInternalTexture._bufferfor a texture withInternalTextureSource.Urland returns a
Blobfor any of these supported shapes:ArrayBufferArrayBufferView(sliced to avoid shared-buffer leaks)Blobstring(loaded viaTools.LoadFileAsync)HTMLImageElement(srcloaded viaTools.LoadFileAsync)null/ no buffer → falls back toTools.LoadFileAsync(internalTexture.url)Mime type resolution:
texture.mimeType→GetMimeType(url)→null.If no mime type can be determined, returns
nulland the existingDumpTools.DumpDataAsyncfallback runs (preserves current behavior forprocedural / 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.tscovering three NullEngine paths:
ArrayBuffercache on_bufferBlobcache on_bufferTools.LoadFileAsyncwhen_bufferis nullAll three assert that
DumpTools.DumpDataAsyncis never invoked,proving the new code path is taken.
Validation
npm run lint:check— 0 errorsnpm run test:unit— 3/3 added tests passnpm run build:dev— 0 errorsSide note (not addressed in this PR)
Independently of this change,
USDZExportAsyncalso loadsfflatefromhttps://unpkg.com/fflate@0.8.2viaTools.LoadScriptAsync, which failsunder Node.js. For now consumers can work around it by assigning
fflatetoglobalThisbefore the export call. Happy to address in afollow-up if desired.