Writing
Preparing 3D Models for AR Try-On: Millimetres, Origins and glTF Export
How to model, orient and compress glasses, hats, earrings, watches and rings for web AR try-on: real-world units, anchor origins, Blender export, compression.
Arbab Naseer Published 7 min read
- 3D
- glTF
- Blender
- Web AR
- three.js
Makeup and stickers in TryOnIt are described with a few numbers. 3D products are different: glasses, hats, earrings, watches and rings depend on a model file, and a model that is a few millimetres off ends up floating in front of the face or sinking into it.
The good news is that the rules are simple and the same for every category. If a model is built at real-world size, with its origin and axes where the engine expects them, it fits every face or hand without per-product tuning. This article covers those rules and the workflow I use to prepare models.
Why real-world units matter
@tryonit/web places 3D products in real-world units. It renders with a perspective camera that matches MediaPipe’s face geometry, and it estimates a metric scale for each face using the iris, whose diameter is roughly constant across adults (the default reference is 11.7 mm). With that scale known, the engine can put a model authored in millimetres at the correct size on any face, at any distance from the camera.
That only works if the model itself is at real size. A 140 mm wide glasses frame should be 140 units wide in the file. Get that right and the rest of the pipeline takes care of fitting.
The rules every model follows
- Format: glTF 2.0, preferably binary
.glb(.gltfalso works). - Units: millimetres, modelled at real-world size.
- Weight: under about 50,000 triangles and under 1 MB per product is a good target for mobile.
- Materials: glTF PBR metallic-roughness. Lenses can use alpha blending, a transparent material with an opacity.
- No cameras or lights in the file. TryOnIt adds image-based lighting automatically.
Origins and axes per category
All categories use +Y up, and “left” always means the wearer’s left. What changes per category is where the origin sits, because that is the point the engine attaches to the tracked face or hand:
| Category | Origin (0, 0, 0) | +X | +Y | +Z |
|---|---|---|---|---|
| Glasses | Centre of the bridge, where the frame rests on the nose | Wearer’s left | Up | Forward, out of the face. Temples extend toward -Z |
| Hat | Top centre of the forehead, at the hairline | Wearer’s left | Up | Forward. The crown sits behind, about -90 mm on Z |
| Earrings | The piercing point of one earring | Away from the head (left ear) | Up | Forward |
| Watch | Centre of the wrist cross-section | Across the wrist | Toward the fingers | Out of the back of the hand, the dial side |
| Ring | Centre of the ring | Across the finger | Along the finger, toward the tip | Toward the top of the finger, the stone side |
Earrings are modelled once, for the left ear; the right ear gets a mirrored copy automatically.
Glasses in Blender, step by step
Blender is free and handles the whole workflow. The same steps apply to every category, with the origins from the table above.
- Set the units. In Scene Properties, choose Metric with a Unit Scale of 0.001 and Length in Millimeters.
- Model at real size. Real frames print their measurements inside the temple, for example 52-18-140: lens width, bridge width and temple length.
- Set the origin. Place the 3D cursor at the middle of the bridge where it touches the nose, then use Object > Set Origin > Origin to 3D Cursor.
- Orient the frame. Make it face -Y in Blender, so Front view (numpad 1) looks straight at it.
- Apply transforms with Ctrl+A > All Transforms.
- Export with File > Export > glTF 2.0: format glTF Binary, Selected Objects only, +Y Up enabled.
- Check it in a test page with the debug overlay enabled.
Step 4 confuses almost everyone the first time, so it is worth explaining. Blender is Z-up, while glTF is Y-up. On export, Blender’s +Z becomes glTF’s +Y (up), and Blender’s -Y becomes glTF’s +Z (forward). Blender’s +X stays +X, the wearer’s left. Modelling the frame facing -Y in Blender is what makes it face forward in glTF.
If you do not model, there are other sources: export from the manufacturer’s CAD files (STEP, OBJ or FBX) and convert to GLB in Blender, buy a model from a 3D marketplace (check that the licence allows commercial AR use), or use photogrammetry or a 3D scanning service for exact replicas.
The manifest for a 3D product
Once the model is right, the manifest is short:
{
"version": 1,
"id": "aviator",
"type": "glasses",
"name": "Aviator",
"model": "aviator.glb",
"thumbnail": "aviator.svg",
"occlusion": "head",
"variants": [
{ "id": "gold", "name": "Gold" },
{ "id": "black", "name": "Black", "overrides": { "model": "aviator-black.glb" } }
]
}
Variants can swap the whole model, so each frame colour can be its own GLB. Manifests in general, including hosting and validation, are covered in How to Create Virtual Try-On Products with JSON Manifests.
For lenses, use a separate mesh with a transparent material: an opacity of 0.6 to 0.8 with a dark colour for sunglasses, and about 0.1 for clear lenses.
How occlusion hides what should be behind you
Real glasses temples disappear behind your head when you turn. In a camera image there is no head geometry to hide them, so the engine adds invisible, depth-only occluders: a head ellipsoid for face products, and wrist and finger cylinders for hand products. They write to the depth buffer but draw no colour, so anything behind them is hidden while the camera image shows through.
- Glasses and hats use
"occlusion": "head"by default, which hides the temples or the back of the cap. Set"none"to turn it off. - Earrings hide the far ear automatically when the head turns. One earring disappearing is expected behaviour, not a bug.
- Watches and rings use the wrist and finger occluders, so a strap appears to wrap around the wrist.
Fine-tuning without re-exporting
Small adjustments do not need a trip back to Blender. A transform in the manifest is applied in anchor space, after the category defaults:
"transform": { "position": [0, -2, 1.5], "rotation": [4, 0, 0], "scale": 1.02 }
positionis in millimetres (+X wearer’s left, +Y up, +Z out of the face or the back of the hand).rotationis in degrees, in XYZ order.scaleis a multiplier, either one number or[x, y, z].
Some categories have their own fields too:
{ "version": 1, "id": "classic-watch", "type": "watch", "model": "watch.glb", "wristWidthMm": 60 }
{ "version": 1, "id": "solitaire", "type": "ring", "model": "ring.glb", "finger": "ring", "sizeMm": 18 }
- Watches are shifted 22 mm toward the forearm by default, before your transform.
wristWidthMm(default 60) is the wrist width the model was made for. - Rings sit between the base of the finger and the first joint.
fingerchoosesindex,middle,ringorpinky, andsizeMmis the inner diameter (default 18). - Earrings take
side:both,leftorright.
Compressing models
Under 1 MB per product matters on mobile connections. The gltf-transform CLI handles every common compression:
# Meshopt geometry compression (the decoder is bundled with TryOnIt)
npx @gltf-transform/cli meshopt input.glb output.glb
# Draco geometry compression (set three.dracoDecoderPath in the engine options)
npx @gltf-transform/cli draco input.glb output.glb
# KTX2 / Basis textures (set three.ktx2TranscoderPath in the engine options)
npx @gltf-transform/cli etc1s input.glb output.glb
npx @gltf-transform/cli uastc input.glb output.glb # higher quality
# Everything at once with sensible defaults
npx @gltf-transform/cli optimize input.glb output.glb --compress meshopt --texture-compress webp
Meshopt is the simplest choice because its decoder already ships with TryOnIt. Draco and KTX2 need their decoder files hosted and their paths set:
<TryOnProvider engineOptions={{ three: { dracoDecoderPath: '/draco/', ktx2TranscoderPath: '/basis/' } }}>
<TryOnButton asset="/tryon/aviator/aviator.json" />
</TryOnProvider>
Lighter models also make try-on open faster, especially on mobile data. I cover the rest of the loading strategy in Keeping Web AR Fast.
Validate and preview
Before uploading, run the validator from @tryonit/core. For 3D products it checks that each referenced GLB exists, is a real GLB file and is not too large:
npx -p @tryonit/core tryonit-validate public/tryon
Then preview on a real face with the debug overlay on (engineOptions={{ debug: true }}), which draws the tracking landmarks and an FPS HUD. Check the model from the front and while turning the head, since occlusion problems only show up at an angle.
Troubleshooting 3D products
| Problem | Cause and fix |
|---|---|
| The model is tiny or huge | Not modelled in millimetres. Rescale in Blender and apply transforms, or set transform.scale. |
| Glasses float in front of or inside the face | The origin is not at the nose bridge. Move it, or adjust transform.position (+Z is forward). |
| The model faces backwards or sideways | Wrong axes. The front must point to +Z in glTF (face -Y in Blender), or use transform.rotation, for example [0, 180, 0]. |
| Textures are black or missing | Textures are not embedded. Export as glTF Binary with textures included. |
| Temples show through the head | Keep "occlusion": "head", the default. |
| An earring is missing on one side | Expected when the head turns: the far ear is hidden. Check side. |
| A watch or ring jitters | Keep the hand steady and well lit, or tune engineOptions.smoothing.hand with a lower minCutoff. |
| The model does not load, with a CORS error | Add Access-Control-Allow-Origin on the server hosting the GLB. |
The 3D renderer is part of @tryonit/web and loads three.js only when a 3D product is shown, so stores that sell only makeup never download it.