GLB to USDZ in the Browser: How It Works and Why Your Files Never Leave Your Machine
If you have ever tried to put a 3D model on an iPhone, you have hit this wall: iOS AR Quick Look — and Vision Pro, and Apple's AR tooling generally — only opens USDZ. Meanwhile, almost every 3D asset you download or generate arrives as GLB.
The usual options are:
- Fire up Blender, import, export as USDZ, hope the materials survive.
- Use a cloud converter — and upload your (possibly client-owned) model to somebody else's server.
I spent last week shipping a third option: a converter that runs entirely in the browser at renderivo.com/convert. No uploads, no accounts, works offline once the page loads. This post is the technical walkthrough — and the honest list of things that bit me along the way.
The pipeline
Three pieces do all the work, all client-side:
-
@gltf-transform— parses the GLB and cleans the geometry:weld(),dedup(),prune(),resample(). -
OffscreenCanvas— optional texture downsizing. -
three.js
GLTFLoader+USDZExporter— builds the AR-ready USDZ archive.
Condensed, the core of it looks like this:
import { WebIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { weld, dedup, prune, resample } from "@gltf-transform/functions";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { USDZExporter } from "three/examples/jsm/exporters/USDZExporter.js";
async function convert(buffer: ArrayBuffer) {
const io = new WebIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.readBinary(new Uint8Array(buffer));
// 1. Geometry cleanup
await doc.transform(weld(), dedup(), prune(), resample());
// 2. (optional) texture resize — see gotcha #1
// 3. Write the optimized GLB
const optimizedGlb = await io.writeBinary(doc);
// 4. USDZ via three.js
const loader = new GLTFLoader();
const gltf = await loader.parseAsync(optimizedGlb.slice().buffer, "");
const usdz = await new USDZExporter().parseAsync(gltf.scene, { maxTextureSize: 2048 });
return { usdz, optimizedGlb };
}
WebIO is the browser build of the I/O service — same API as the Node version, no fs involved.
Gotchas I hit shipping this
1. Never re-encode a texture into a different container than its declared mime type. GLB textures can be WebP (declared via EXT_texture_webp). My first texture-resize pass drew the bitmap to a canvas and re-encoded everything to JPEG for size — while leaving the texture's mime type as image/webp. The output declared WebP but contained JPEG bytes. Everything looked fine until you decoded it elsewhere. Keep the re-encode in the same container:
const blob = await canvas.convertToBlob({ type: texture.getMimeType(), quality: 0.85 });
2. Some Chromium builds default canvas WebP to lossless. I watched a 1024×1024 WebP texture shrink to 512×512 and grow from 146 KB to 426 KB. Passing an explicit quality (e.g. 0.85) forces the lossy encoder. Also: keep the "if the re-encode came out bigger, keep the original bytes" guard — it costs three lines and has saved every weird edge case so far.
3. Geometry cleanup preserves KHR_mesh_quantization — good, but know it. A quantized model has Int16Array positions instead of float32. weld()/dedup()/prune() operate fine on quantized data and the output stays quantized, so your 900 KB quantized model does not suddenly become a 2.4 MB float blob. If you were expecting float blowups from cleanup, you will not get them — which is exactly what you want.
4. Draco-compressed inputs are the one thing I left out of v1. The draco3dgltf npm package is an Emscripten build that require("fs") at the top level, which no browser bundler enjoys. Serving the WASM decoder properly is a v1.1 item; for now such files fail with a clear parse error and a "re-export without Draco" message. Honest limitation beats a broken bundle.
Real numbers
From the sample models I test with:
| Model | Input | Output GLB (half-size textures) | Reduction |
|---|---|---|---|
| Textured owl (WebP, quantized) | 962 KB | 790 KB | 18% |
| Clean geometry crate | 8.5 KB | 8.4 KB | ~2% |
Already-clean models barely shrink — that is correct behavior, not a failure. The wins come from texture-heavy marketplace assets and duplicated-vertex exports, which routinely shed 40–80%.
One more property of this architecture worth stating: the server never touches your files. Conversion happens on your device with WebAssembly; the only network call the tool ever makes is the license check against Gumroad's API when you unlock it. For anyone converting client deliverables, that is the actual selling point.
The tool
renderivo.com/convert is live:
- Drop a
.glb(up to 150 MB), get an AR-ready USDZ plus the cleaned GLB, with before/after sizes shown every run. - The first conversion is free, no account, no email.
- Unlimited conversions is a $19 one-time license — you get a key on your Gumroad receipt, paste it once, done. No subscription, and the unlock lives in your own browser.
The full pipeline (gltf-transform + three.js + the texture-resize guard) is what ships to production — if you run into one of the gotchas above with your own models, I would genuinely like to hear about it.











