Skip to content

Three.js + USDC in the Browser

So you've got a .usdc file from Maya or Houdini, and you want to show it in a Three.js scene. I've been there. Until recently, your choices weren't great. You could convert to glTF offline, spin up a WASM build of OpenUSD, or just give up and use a different format.

This guide shows you how to parse USDC directly in JavaScript using @cinevva/usdjs, and how to get that data into Three.js.

What's USDC?

USD files come in three flavors. USDA is the text version, human-readable and easy to debug but verbose. USDC (sometimes called "Crate") is the binary format that production pipelines actually use because it's compact and loads fast in native tools. USDZ is a zip archive containing USDC plus textures, which Apple uses for AR Quick Look.

Here's the catch: USDC is a proprietary binary format. Pixar wrote the reference implementation in C++, and until now there wasn't a pure JavaScript way to read it.

Why You Might Care

If you're building 3D web apps that need to work with content from film or VFX pipelines, you're going to run into USD. Artists export from Maya, Houdini, or Blender, and those exports are often USDC.

Before @cinevva/usdjs, you had three options. You could run usdcat to convert USDC to text format, which adds a build step and loses the binary format's compactness. You could compile OpenUSD or TinyUSDZ to WebAssembly, which adds megabytes to your bundle and needs special server headers for threading. Or you could use Three.js's built-in USDLoader, which handles USDZ but has limited USDC support and minimal composition.

Now there's a fourth option: parse USDC natively in JavaScript.

How It Works

The @cinevva/usdjs library reimplements USD's core functionality in TypeScript. I built the USDC parser by reading Pixar's C++ source code and translating the Crate format specification into JavaScript. When there's ambiguity in behavior, we match what OpenUSD does.

In practice, it looks like this:

typescript
import { parseUsdcToLayer } from '@cinevva/usdjs';

// Fetch the USDC file
const response = await fetch('/model.usdc');
const buffer = await response.arrayBuffer();

// Parse it
const layer = parseUsdcToLayer(buffer, { identifier: 'model.usdc' });

// Now you have a structured layer with prims, properties, and metadata
console.log(layer.pseudoRoot.children);

No WASM. No native code. Just JavaScript reading bytes and building a structured representation.

Wiring It Into Three.js

Parsing USD is only half the problem. You also need to convert USD concepts (prims, transforms, mesh schemas) into Three.js objects.

Here's a minimal example that loads a USDC file and creates Three.js meshes:

typescript
import * as THREE from 'three';
import { UsdStage, parseUsdcToLayer } from '@cinevva/usdjs';

async function loadUsdcToThree(url: string, scene: THREE.Scene) {
  // 1. Fetch and parse
  const buffer = await fetch(url).then(r => r.arrayBuffer());
  const layer = parseUsdcToLayer(buffer, { identifier: url });
  
  // 2. Create a stage (handles composition if there are references)
  const stage = UsdStage.open(layer);
  
  // 3. Walk the prim tree
  for (const prim of stage.traverse()) {
    // Skip non-geometry
    if (prim.typeName !== 'Mesh') continue;
    
    // Get geometry data
    const points = prim.getAttribute('points')?.value;
    const faceVertexIndices = prim.getAttribute('faceVertexIndices')?.value;
    const faceVertexCounts = prim.getAttribute('faceVertexCounts')?.value;
    
    if (!points || !faceVertexIndices || !faceVertexCounts) continue;
    
    // Convert to Three.js geometry
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', 
      new THREE.Float32BufferAttribute(points.flat(), 3)
    );
    
    // USD uses faceVertexCounts + faceVertexIndices, Three.js wants a flat index array
    // For triangulated meshes, this is straightforward:
    geometry.setIndex(Array.from(faceVertexIndices));
    geometry.computeVertexNormals();
    
    // Create mesh
    const material = new THREE.MeshStandardMaterial({ color: 0x888888 });
    const mesh = new THREE.Mesh(geometry, material);
    
    // Apply transform
    const xform = getWorldTransform(prim);
    mesh.matrix.fromArray(xform);
    mesh.matrixAutoUpdate = false;
    
    scene.add(mesh);
  }
}

function getWorldTransform(prim): number[] {
  // Simplified: in practice you'd compose parent transforms
  const xformOp = prim.getAttribute('xformOp:transform');
  if (xformOp?.value) {
    return xformOp.value.flat();
  }
  return [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1];
}

This example is simplified. Real code needs to handle transform stacking (USD prims can have multiple xformOp attributes that compose together), subdivision surfaces (meshes with catmullClark subdivision need actual subdivision), materials (UsdPreviewSurface parameters map to Three.js MeshStandardMaterial), textures (asset paths need resolution and loading), and skeletal animation (UsdSkel bindings need conversion to SkinnedMesh).

If you want to see how all of this works together, check out @cinevva/usdjs-viewer. It's a complete reference implementation.

Composition Matters More Than You'd Think

Parsing a single USDC file is the easy part. Real USD scenes use composition, which means sublayers, references, payloads, and variants all working together.

Picture a car model. The body references body.usdc, the wheels reference wheel.usdc, and there are variants for "sport" and "sedan" configurations. The final scene is assembled from all these pieces at runtime.

Here's how you handle that:

typescript
import { UsdStage, parseUsdcToLayer, FetchResolver } from '@cinevva/usdjs';

// Create a resolver that knows how to fetch referenced files
const resolver = new FetchResolver({
  baseUrl: '/assets/',
});

// Open with composition
const stage = UsdStage.open(rootLayer, { resolver });

// Set a variant selection
stage.setVariantSelection('/Car', 'bodyStyle', 'sport');

// Now traverse the composed scene
for (const prim of stage.traverse()) {
  // You'll see resolved references and the selected variant
}

The resolver fetches referenced USDC files on demand. Composition happens in JavaScript, producing the flattened scene you'd get from usdcat --flatten.

Performance

Let's be honest: parsing USDC in JavaScript is slower than native C++. That's the trade-off for skipping WASM and build steps.

In practice, for typical web use cases with models under 10MB and less than 100K triangles, parsing takes 50-200ms on modern hardware. That's fine for initial load if you show a loading indicator.

You can make it faster. Don't load the entire scene upfront. Load the root layer, render what you can, then fetch payloads on demand. Move parsing to a Web Worker so the UI stays responsive. Cache parsed layers in IndexedDB so return visits are instant. Show a low-res preview while the full scene streams in.

Here's a Web Worker setup:

typescript
// worker.ts
import { parseUsdcToLayer } from '@cinevva/usdjs';

self.onmessage = async (e) => {
  const { buffer, identifier } = e.data;
  const layer = parseUsdcToLayer(buffer, { identifier });
  
  // Serialize layer data (not the methods)
  const data = serializeLayer(layer);
  self.postMessage(data);
};

// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url));

worker.postMessage({ buffer, identifier: 'model.usdc' });
worker.onmessage = (e) => {
  const layerData = e.data;
  // Build Three.js scene from layerData
};

What Works and What Doesn't

Most of the common stuff works. Geometry (meshes, points, curves) parses correctly, including compressed vertex arrays. Transforms work with full xformOp stacking. Materials map from UsdPreviewSurface to PBR. Composition handles sublayers, references, payloads, variants, and inherits. Textures resolve and load if you provide a resolver. Basic skeletal animation works for common character rigs.

There are gaps though. This isn't a Hydra implementation, so you're responsible for converting USD data to whatever rendering engine you're using. There are no typed schema APIs like UsdGeomMesh with convenience methods. You work with generic prims and attributes. Some composition features like specializes, relocates, and value clips aren't implemented yet. Complex material networks beyond simple UsdPreviewSurface need custom handling.

When to Use This

This makes sense when you need to load USD files in a web app without a WASM build step, when your pipeline outputs USDC and you don't want to convert to glTF, when you're building a USD viewer or editor for the browser, or when you want to inspect USD structure rather than just render it.

Use something else if you need full OpenUSD parity with every edge case, if your scenes are huge (100MB+) and need native performance, or if you're already using a WASM build and the complexity is acceptable for your project.

Resources

The three packages are @cinevva/usdjs for core parsing and composition, @cinevva/usdjs-viewer for a Three.js-based browser viewer, and @cinevva/usdjs-renderer for headless PNG rendering in tests.

For documentation, check the usdjs API Reference, the Pixar OpenUSD Specification, and the Three.js Documentation.

If you want to understand how the USDC parser maps to Pixar's implementation, look at src/usdc/PIXAR_PARITY.md in the usdjs repo.

Try It

The fastest way to see this working is to visit the usdjs-viewer demo and drop a USDC file onto it.

To integrate into your own project, install the package and start with the code examples above:

bash
npm install @cinevva/usdjs

USD in the browser is possible. It's not always the right choice, but when you need it, pure JavaScript parsing means one less build step and one less dependency to think about.