Skip to content

feat: Decode EXT_meshopt_compression and KHR_meshopt_compression glTF buffer views - #9738

Merged
willeastcott merged 5 commits into
mainfrom
feat/meshopt-compression
Oct 19, 2026
Merged

willeastcott merged 5 commits into
mainfrom
feat/meshopt-compression

Conversation

@willeastcott

Copy link
Copy Markdown
Contributor

Adds support for glTF files compressed with EXT_meshopt_compression and KHR_meshopt_compression. gltfpack (-c, -cc, -cz) and glTF-Transform both produce these files. Today they fail with RangeError: Invalid typed array length.

Usage

The decoder is meshoptimizer's meshopt_decoder.mjs (about 7.7 KB gzipped, with both WASM builds embedded). It is not bundled with the engine, in the same way the Draco decoder isn't. Pass either the decoder object or the URL of its module:

import { MeshoptDecoder } from 'meshoptimizer';

pc.meshoptInitialize({ decoder: MeshoptDecoder });
// or, imported the first time a compressed file loads:
pc.meshoptInitialize({ url: 'lib/meshopt_decoder.mjs' });

Decoding runs on the main thread. numWorkers hands it to the decoder's own workers for very large assets.

Changes

  • meshoptInitialize (new public API) stores the decoder or URL. A URL is resolved against the page, as ESM scripts are, and imported on first use. If the import fails, a later load retries it.
  • The glTF parser decodes compressed buffer views where it builds buffer views. This runs after any bufferView.processAsync hook, so apps that decode meshopt through that hook today keep working. EXT_ and KHR_ share one code path; the decoder handles the KHR v1 bitstream and the COLOR filter.
  • Fallback buffers, which hold uncompressed data for loaders without the extension:
    • A fallback buffer without a uri is no longer read as the GLB binary chunk. That aliasing is what causes the current RangeError.
    • A fallback buffer with a uri is not downloaded when a decoder is set. Without a decoder it still loads, so optional-extension files work as before.
  • A compressed file with neither a decoder nor a fallback now fails with glTF buffer view N is meshopt compressed, call meshoptInitialize to load it.
  • The meshoptimizer 1.3 decoder and encoder are added under examples/assets/wasm/meshopt, next to Draco and Basis. The tests use the encoder to build their fixtures.

Tests

Seven new GlbParser tests, with fixtures encoded at test time:

  • EXT with the v0 bitstream: vertex, index and animation data decode exactly (fails on main)
  • KHR with the v1 bitstream and the octahedral and quaternion filters: quantized normals and rotations decode within tolerance (fails on main)
  • the decoder imported from a URL (fails on main)
  • a failed decoder import reports the URL (fails on main)
  • no decoder and no fallback gives the meshoptInitialize error (fails on main)
  • no decoder with a fallback loads the uncompressed data (passes on main and with the change)
  • a decoder skips downloading the fallback (fails on main)

The full suite, the type build and type tests, and the docs build pass locally. I also loaded a real glTF-Transform file (4 meshes, 11,944 triangles, quantized with KHR_mesh_quantization) headlessly and in the examples app on WebGL2. It decoded in about 80 ms and rendered correctly.

Not covered: the numWorkers path, because the decoder's workers need a browser Worker. There is no example yet, as it needs a compressed model with a known license.

🤖 Generated with Claude Code

… buffer views

Adds meshoptInitialize, which takes the meshoptimizer decoder object or the URL of its
module. The module is imported on first use, decoding runs on the main thread unless
numWorkers is set, and the decoder is not bundled with the engine.

Compressed buffer views are decoded where the parser builds buffer views, after any
user bufferView hook. Fallback buffers are no longer read as the GLB binary chunk or
downloaded when a decoder is available, and a compressed file without a decoder or a
fallback now fails with a message naming meshoptInitialize instead of a RangeError.

The meshoptimizer 1.3 decoder and encoder are vendored in examples/assets/wasm/meshopt;
the tests encode their fixtures with the encoder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Public API report

This PR changes the public API surface (+1 / −0), per the docs' rules (@ignore / @Private / undocumented are excluded).

Show API diff
+function meshoptInitialize(config: { decoder: any; numWorkers: number; url: string }): void

Informational only — this never fails the build.

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Build size report

This PR changes the size of the minified bundles.

Bundle Minified Gzip Brotli
playcanvas.min.js 2673.2 KB (+1.6 KB, +0.06%) 693.2 KB (+721 B, +0.10%) 537.6 KB (+793 B, +0.14%)
playcanvas.min.mjs 2669.9 KB (+1.6 KB, +0.06%) 691.9 KB (+698 B, +0.10%) 536.9 KB (+440 B, +0.08%)

WebGPU has no three component 8-bit vertex format, so the engine pads byte normals
to four components and getNormals returns four values per vertex there. The KHR
filter test now compares the xyz of each normal whatever the component count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@willeastcott

willeastcott commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

@slimbuck This mirrors the dracoInitialize naming - happy with the approach?

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated PR review by Codex (OpenAI AI agent), posted through the account holder's authorized automated review process.

Reviewed the full diff at 880262c, including extension fallback rules, buffer-view hooks, decoder initialization, worker dispatch, and error handling. Two P2 decoder-loading issues are detailed inline: relative URL resolution and recovery after a failed module download. Both were reproduced in Chrome with successful control cases.

Validation: 4,188 unit tests passing (33 pending); 4,177 real Metal WebGPU tests passing (39 pending); declaration build, consumer type tests, affected-file lint, and diff checks passed. Supplemental browser tests successfully loaded EXT v0 and KHR v1/filter fixtures through the full parser with two decoder workers on both WebGL2 and WebGPU, with exact decoded vertex positions. No additional actionable issues found in the compression or parser integration paths reviewed.

Comment thread src/framework/parsers/meshopt-decoder.js Outdated
Comment thread src/framework/parsers/meshopt-decoder.js
A relative decoder URL now resolves against document.baseURI, so <base href> is
honored, and against the worker location in a worker, where the old code had no
base at all and threw. Node still resolves against the working directory.

Browsers keep a failed module import for its URL, so a retry after a failed
download never sent a new request. Each retry now imports the URL with a
distinct fragment, which forces a fresh request without changing the request
sent to the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated PR review by Codex (OpenAI AI agent), posted through the account holder's authorized automated review process.

Re-reviewed bcaacb6 and the replies to the previous review. Both previous P2 findings are addressed; no new actionable issues found in the follow-up.

Re-ran the original Chrome reproductions: relative decoder URLs now load correctly with a document <base href> and inside a real module worker. After an initial HTTP 503, the next decode successfully imports the decoder using the retry fragment, without requiring reinitialization or a caller-supplied URL change.

Validation: 4,191 unit tests passing (33 pending); 4,180 real Metal WebGPU tests passing (39 pending); declaration build, consumer type tests, changed-file lint, and diff checks passed. Full-parser browser checks with two decoder workers still load EXT v0 and KHR v1/filter fixtures correctly on both WebGL2 and WebGPU, with exact vertex positions.

@MAG-AdrianMeredith

Copy link
Copy Markdown
Contributor

Great, our fork gets smaller by the hour ;). I actually forgot this wasn't a part of normal playcanvas, sorry

@willeastcott
willeastcott merged commit 0bfd079 into main Oct 19, 2026
11 checks passed
@willeastcott
willeastcott deleted the feat/meshopt-compression branch October 19, 2026 12:27

This branch was successfully deployed

2 active deployments
Preview – engine — 8d2c9058 Deployed Oct 19, 2026 by vercel[bot]
Preview – engine-api-docs — 8d2c9058 Deployed Oct 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: gltf enhancement Request for a new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants