Skip to main content

Compressed models

Three kinds of glTF extension make a model's meshes smaller. Meshopt and Draco compress the file, and the loader decodes it as it loads. Mesh quantization stores vertex data as small integers, which the GPU reads as they are. PixiJS 3D reads all three, so a compressed file loads with the same Assets.load call as any other:

const source = await Assets.load<Scene3DSource>('fox-meshopt.glb');
ExtensionWhat it makes smallerDecoder
EXT_meshopt_compression, KHR_meshopt_compressionThe downloadComes with @pixi/3d
KHR_draco_mesh_compressionThe downloadFetched from Google the first time a file needs it
KHR_mesh_quantizationThe download and GPU memoryNone

Quantization works with either of the others. None of the three compresses textures, so a file that's mostly images shrinks little. For files you compress yourself, gltfpack applies meshopt and quantization in one command, and can compress textures too.

Compression loads a different sample model compressed each of these ways.

meshopt​

The loader imports meshopt's decoder the first time a file needs it, so there's nothing to set up. Meshopt compresses a file's animation clips as well as its meshes.

Draco​

In Compression, the walking Cesium Man and the sunglasses are Draco files. The loader fetches the Draco decoder from Google's servers. To host the decoder yourself, for example in an app that works offline, copy the two files at the URLs in dracoDecoderUrls to your server. Then pass their new URLs to setDracoDecoderPath before the first Draco file loads, since the loader reads the URLs when it first decodes a Draco mesh:

import { setDracoDecoderPath } from '@pixi/3d';

setDracoDecoderPath({
jsUrl: '/draco/draco_wasm_wrapper.js',
wasmUrl: '/draco/draco_decoder.wasm',
});

Draco meshes load without their morph targets, the extra shapes a mesh can blend toward. For a model that has them, run gltfpack on the uncompressed original with the morph target flags, since gltfpack can't read a Draco file.

Mesh quantization​

KHR_mesh_quantization stores vertex data, such as positions, normals and texture coordinates, as 8- or 16-bit integers rather than 32-bit floats.

The default hit test checks a mesh's bounding box, and it works on a quantized mesh too. A per-triangle hit test finds the triangle under the pointer, but it never hits a mesh with integer positions (HitVolume3DInput). A hit's uv is a texture coordinate only on a mesh whose texture coordinates are floats. gltfpack can keep either as floats, as Compressing your own models shows.

gltfpack quantizes texture coordinates by default. When it does, it adds a texture transform to each textured material, which maps the quantized coordinates back onto the texture. The loader sets that transform as the material's uvTransform, so a textured material you build yourself needs the same transform, or it samples the wrong part of the texture. To give one model a material of its own, clone the file's material, which keeps the transform:

const blueFox = new Model3D({
source,
materials: (material) => material.clone({ baseColor: 0x99aaff }), // tints this fox alone
});

Compressing your own models​

gltfpack, from the meshoptimizer project, compresses a glTF or GLB file with meshopt and quantizes it in one command:

npx gltfpack -i model.glb -o model-packed.glb -cc

-c compresses with meshopt, and -cc compresses further. Quantization is on by default, and -noq turns it off. gltfpack also rearranges the file so it draws faster, and that changes what your code finds in it:

  • It merges meshes and materials, and drops the nodes it doesn't need. A node is the 3D counterpart of a 2D container. These changes can remove a name your code looks up with getChildByLabel or source.material. -kn keeps named nodes, and -km keeps named materials. gltfpack drops every mesh and texture name whatever the flags, so source.geometry and source.texture come back empty. Reach a mesh through its node and a texture through its material instead.
  • With integer positions, it can move a mesh to an unnamed child of the node -kn keeps, so getChildByLabel returns a Container3D rather than the Mesh3D. -vpf keeps positions as floats and each mesh on its named node. Per-triangle hit tests need -vpf too.
  • -vtf keeps texture coordinates as floats, for a hit's uv.
  • The loader skips morph target data stored as integers, so add -vpf -vnf for a model that has morph targets.

If your code looks up parts of a model by name or hit-tests it per triangle, compress it with a command like this one:

npx gltfpack -i car.glb -o car-packed.glb -cc -kn -km -vpf

-tc turns textures into KTX2, a format the GPU can keep compressed. The npm build of gltfpack can't write KTX2, so download a native build from its releases page. Those textures have no PNG or JPEG to fall back on, so the app needs PixiJS's KTX2 loader. That loader fetches the code that converts KTX2 for the GPU from jsDelivr, unless PixiJS's setKTXTranscoderPath points it elsewhere.

Next steps​