+1 (415) 993-7206

The USDZ Asset Pipeline: Getting CAD, glTF, and FBX Models Ready for ARKit and Reality Composer Pro

Written against Xcode 17, RealityKit, and Reality Composer Pro on iOS 26/27 and visionOS 26/27. Everything here assumes RealityKit, not SceneKit — see SceneKit Is Deprecated. Here's Your 12-Month Plan.

Every AR estimate we write has a line item that clients are surprised by, and it is never the ARKit code. It is the 3D content. The tracking work in a typical iPhone AR app is a few weeks; getting a client's 400 MB CAD assembly, a marketplace glTF, and an animated FBX character into content that loads in under two seconds and renders at 60 fps on a four-year-old iPhone is where projects actually slip.

This tutorial is the pipeline we hand to teams: the format path, the budgets, the material rules, and the validation step. It is deliberately tool-agnostic where it can be — the constraints come from RealityKit and USD, not from any one DCC package.

Why USDZ, and what USDZ actually is

USDZ is not a 3D format. It is an uncompressed zip archive of a USD file plus its textures and audio, laid out so a reader can memory-map the contents without unpacking. That single fact explains most of its behaviour:

  • Textures inside a USDZ are not compressed by the archive. Your file size is your texture size. A 200 MB USDZ almost always means somebody shipped 4K PNGs.
  • Only a subset of image formats is allowed for AR Quick Look — PNG and JPEG, plus OpenEXR for HDR environments. Ship JPEG for colour maps, PNG only when you genuinely need alpha.
  • The payload is USD, so everything USD supports — layering, references, variants, skeletal animation — is available in principle, and only a subset is supported by RealityKit's renderer in practice.

RealityKit loads USD (.usd, .usda, .usdc) and USDZ. Use loose .usda/.usdc plus a Reality Composer Pro package during development, and produce USDZ for anything that has to travel: AR Quick Look on the web, email attachments, or content downloaded at runtime.

Step 1: Get out of your source format cleanly

The three sources we see, and what each one needs:

CAD (STEP, IGES, JT, SolidWorks, Revit). These are analytic formats — NURBS surfaces and B-reps, not triangles. You cannot ship them. They must be tessellated, and the tessellation tolerance is a decision, not a default. A pump housing tessellated at CAD-inspection tolerance produces 8 million triangles; the same part at visual tolerance produces 40,000 and looks identical at arm's length. Also strip the internals: nobody sees the bolts inside a sealed enclosure, and every hidden part costs draw calls and memory.

glTF 2.0. The friendliest source. glTF and USD share a metallic-roughness PBR model, so materials survive conversion well. Watch two things: glTF is metres and Y-up (same as USD, good), and glTF's KHR_materials_* extensions — transmission, clearcoat, sheen — have no clean RealityKit equivalent and will silently drop.

FBX. Common for animated characters, and the messiest. FBX units are frequently centimetres, its up-axis may be Z, its materials are usually a legacy specular workflow, and its embedded textures are often unnamed blobs. Convert FBX through a DCC package (Blender, Maya) rather than a one-click tool, so you can inspect what came through.

Step 2: Fix units, up-axis, and origin before anything else

This is the single most common bug we see in client assets, and it is trivially avoidable.

USD's conventions for AR are:

  • metersPerUnit = 1 — one unit is one metre.
  • upAxis = "Y".
  • The model's origin sits where it should touch the world — usually the centre of its footprint on the floor, or the back plane for a wall-mounted object.

If a chair appears 100× too large in your scene, its authoring package exported centimetres. If it lies on its side, it came from a Z-up tool. If it floats or sinks when anchored to a plane, its pivot is at the bounding-box centre instead of the base.

You can check this in seconds on the file itself:

# Inspect the stage header of a USD or USDZ file
usdcat -f chair.usdz | head -20

# Look for:
#   metersPerUnit = 1
#   upAxis = "Y"

Fix it in the authoring tool and re-export. Do not fix it by scaling the entity at runtime — the moment you do, every future asset needs its own magic number, physics behaves oddly, and shadow and occlusion quality degrade.

Step 3: Budgets, written down before modelling starts

Budgets are a contract with whoever produces your art. Without them you will receive whatever the modeller felt like exporting. The numbers we use for handheld iPhone AR as a starting point:

ItemBudget for a hero object
Triangles, single object50k–150k
Triangles, whole visible sceneunder ~500k
Draw calls / distinct materials per objectunder 10
Texture resolution2048² hero, 1024² secondary, 512² small parts
USDZ file size, downloaded at runtimeunder 15 MB, ideally under 8 MB
Skinned character bonesunder 100

The two numbers people underestimate: materials and texture memory. Each distinct material is at least one draw call, so a CAD assembly that arrives with 240 per-part materials will destroy your frame rate long before its triangle count does. Merge parts that share an appearance and atlas their textures. And remember that a 2048×2048 texture is ~16 MB in memory once mipmapped and uncompressed — five of those on a mid-range iPhone with a camera feed, scene reconstruction, and your app already running is how you hit a memory-pressure termination.

If you are getting close to any of these limits, read ARKit Performance: Holding a Stable Frame Rate Without Cooking the iPhone next — asset budgets and thermals are the same problem measured in two places.

Step 4: Materials that actually survive

RealityKit's baseline is metallic-roughness PBR. Author to that and conversion is boring, which is what you want:

  • Base colour — JPEG, sRGB. Bake no lighting into it; RealityKit lights the scene.
  • Normal — PNG, tangent-space, linear.
  • Roughness / metallic / ambient occlusion — pack into a single texture's channels where your tool supports it, rather than shipping three greyscale maps.
  • Emissive — only where something genuinely emits light.

Things that do not survive: procedural node graphs from Blender or Substance (bake them to textures), specular-glossiness workflows (convert to metallic-roughness), and most exotic glTF extensions.

For anything beyond static PBR — a highlight pulse on a selected part, an X-ray effect on a machine, a dissolve on placement — use ShaderGraphMaterial authored in Reality Composer Pro. You expose parameters in the graph and drive them from Swift:

guard var material = entity.components[ModelComponent.self]?
    .materials.first as? ShaderGraphMaterial else { return }

try material.setParameter(name: "HighlightIntensity", value: .float(1.0))
try material.setParameter(name: "TintColor", value: .color(.systemBlue))

entity.components[ModelComponent.self]?.materials = [material]

The parameter names are the exposed inputs on the graph; a typo throws rather than failing silently, so wrap the calls and log. This is the sanctioned replacement for the custom SCNProgram shaders people wrote in the SceneKit era — see Migrating an ARSCNView App to RealityKit if you are porting effects.

Step 5: Animation

Two kinds, with different rules.

Transform animation (a door swinging, a part sliding out of an assembly) travels well in USD and can also be driven from code, which is usually more useful because you can react to app state:

let open = FromToByAnimation(
    to: Transform(rotation: simd_quatf(angle: .pi / 2, axis: [0, 1, 0])),
    duration: 0.6,
    timing: .easeInOut,
    bindTarget: .transform
)
if let resource = try? AnimationResource.generate(with: open) {
    doorEntity.playAnimation(resource)
}

Skeletal animation must come baked into the asset. Keep the rig under ~100 joints, bake to a fixed frame rate (30 fps is plenty for AR), and export each clip as a named animation so you can select it at runtime:

if let idle = characterEntity.availableAnimations.first(where: { $0.name == "idle" }) {
    characterEntity.playAnimation(idle.repeat())
}

If your animations arrive nameless, they came through a converter that dropped the metadata — go back to the DCC export rather than indexing into availableAnimations by position, which will break the first time an artist reorders clips.

Step 6: Assemble in Reality Composer Pro, not in code

Reality Composer Pro ships with Xcode and is the assembly stage of the pipeline. Individual models come in as USD; the scene, materials, and components are authored there and consumed as a Swift package by your app.

What belongs in Reality Composer Pro:

  • Scene composition — grouping, relative placement, variants of the same product.
  • ShaderGraph materials and their exposed parameters.
  • Particle emitters, spatial audio, and built-in components.
  • Custom components, which appear in the inspector and let non-engineers tag entities with app-specific data.

What belongs in code: anything that depends on runtime state — anchoring, gestures, physics you configure per-session, and network sync.

Loading is a one-liner against the generated bundle:

import RealityKit
import MyAssets   // the Reality Composer Pro package

let scene = try await Entity(named: "ProductScene", in: myAssetsBundle)
content.add(scene)

The practical benefit is not the editor, it is the boundary: artists iterate on the package without touching Swift, and the app has one place to look when something renders wrong.

Step 7: Validate before it reaches a device

Run every asset through usdchecker with the AR Quick Look ruleset. It catches the things that render fine on your Mac and fail on someone's iPhone:

# ARKit-compatibility rules, including the USDZ package layout checks
usdchecker --arkit -v product.usdz

Typical findings: unsupported texture formats, absolute file paths that only exist on the artist's machine, missing metersPerUnit, unsupported prim or shader types, and archives whose alignment breaks memory-mapping.

Wire this into CI. A five-line script that fails the build when a checked-in USDZ does not pass, or exceeds your size budget, saves more time than any amount of downstream debugging:

for f in Assets/*.usdz; do
  usdchecker --arkit "$f" || exit 1
  size=$(stat -f%z "$f")
  [ "$size" -gt 15728640 ] && { echo "$f over 15 MB budget"; exit 1; }
done

Then test on real hardware, and specifically on the oldest device you support — not the newest one on your desk.

Rights, provenance, and the boring stuff that stops shipping

Two issues we have watched delay launches:

  • Marketplace model licences. Many stock 3D models are licensed for renders and prototypes, not for redistribution inside a shipped app. USDZ ships the geometry to the user's device. Check the licence before the model reaches the repo.
  • CAD confidentiality. A tessellated USDZ of a client's unreleased product, hosted for AR Quick Look on a public URL, is a leak. Put runtime-downloaded assets behind authentication, and keep unreleased geometry out of anything anonymously reachable.

The order we run this in

  1. Agree budgets and the device floor before any modelling starts.
  2. Convert one representative asset end to end and test it on the oldest supported device. Do not batch-convert 200 parts on the strength of a Mac preview.
  3. Fix units, up-axis, and pivot at the source.
  4. Decimate, merge materials, atlas textures, bake procedural shading.
  5. Assemble in Reality Composer Pro; expose ShaderGraph parameters the app needs.
  6. usdchecker --arkit and a size gate in CI.
  7. Re-measure frame rate and memory on device after every batch of new content.

Next steps

Most AR pilots that stall on "it feels slow and the models look wrong" have a content pipeline problem, not an ARKit problem. RealityRogue's ARKit consultants audit client asset libraries, set the budgets, and build the conversion and validation tooling so your art team can iterate without an engineer in the loop. Get in touch with a sample asset and the devices you need to support, and we will tell you what it takes to get it running at frame rate.