Skip to content

브라우저에서 Three.js + USDC

Maya나 Houdini에서 나온 .usdc 파일이 있고, 이걸 Three.js 씬에 보여주고 싶을 겁니다. 저도 겪어본 일입니다. 최근까지만 해도 선택지가 마땅치 않았습니다. 오프라인에서 glTF로 변환하거나, OpenUSD의 WASM 빌드를 띄우거나, 아니면 포기하고 다른 포맷을 쓰는 정도였죠.

이 가이드는 @cinevva/usdjs로 자바스크립트에서 USDC를 직접 파싱하는 방법, 그리고 그 데이터를 Three.js로 가져오는 방법을 보여줍니다.

USDC가 뭔가요?

USD 파일에는 세 가지 형태가 있습니다. USDA는 텍스트 버전으로, 사람이 읽을 수 있고 디버깅하기 쉽지만 장황합니다. USDC("Crate"라고도 부릅니다)는 프로덕션 파이프라인이 실제로 쓰는 바이너리 포맷인데, 작고 네이티브 도구에서 빠르게 로드되기 때문입니다. USDZ는 USDC와 텍스처를 담은 zip 아카이브로, Apple이 AR Quick Look에 씁니다.

여기에 한 가지 함정이 있습니다. USDC는 독점 바이너리 포맷입니다. Pixar가 C++로 레퍼런스 구현을 작성했고, 지금까지는 순수 자바스크립트로 이를 읽을 방법이 없었습니다. 이제 상황이 바뀌기 시작했습니다. 2025년 12월, Alliance for OpenUSD가 Core Specification 1.0을 발표했습니다. OpenUSD 씬 데이터가 어떻게 구조화되고, 합성되고, 교환되는지를 문서화한 최초의 비준 표준이며, 1.1 개정 작업도 이미 진행 중입니다. 다만 레퍼런스 구현은 여전히 Pixar의 C++ 코드베이스에 있어서, 자바스크립트에서 이 포맷에 접근하는 현실적인 방법은 별도의 리더를 쓰는 것입니다.

왜 신경 써야 할까요

영화나 VFX 파이프라인의 콘텐츠를 다뤄야 하는 3D 웹 앱을 만든다면 USD를 마주치게 됩니다. 아티스트들이 Maya, Houdini, Blender에서 익스포트하는데, 그 결과물이 USDC인 경우가 많습니다.

@cinevva/usdjs 이전에는 세 가지 선택지가 있었습니다. usdcat을 돌려 USDC를 텍스트 포맷으로 변환할 수 있었지만, 빌드 단계가 추가되고 바이너리 포맷의 작은 크기를 잃습니다. OpenUSD나 TinyUSDZ를 WebAssembly로 컴파일할 수 있었지만, 번들에 메가바이트가 더해지고 스레딩을 위한 특수 서버 헤더가 필요합니다. 아니면 Three.js에 내장된 USDLoader를 쓸 수 있었는데, 이건 USDZ는 처리하지만 USDC 지원이 제한적이고 합성은 거의 안 됩니다.

이제 네 번째 선택지가 있습니다. 자바스크립트에서 USDC를 직접 파싱하는 것입니다.

어떻게 동작하나요

@cinevva/usdjs 라이브러리는 USD의 핵심 기능을 TypeScript로 다시 구현합니다. 저는 Pixar의 C++ 소스 코드를 읽고 Crate 포맷 명세를 자바스크립트로 옮겨서 USDC 파서를 만들었습니다. 동작에 모호한 부분이 있으면 OpenUSD가 하는 대로 맞춥니다.

실제로는 이렇게 생겼습니다.

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

// USDC 파일을 가져온다
const response = await fetch('/model.usdc');
const buffer = await response.arrayBuffer();

// 파싱한다
const layer = parseUsdcToLayer(buffer, { identifier: 'model.usdc' });

// 이제 prim, 프로퍼티, 메타데이터가 들어 있는 구조화된 layer를 갖게 된다
console.log(layer.pseudoRoot.children);

WASM 없음. 네이티브 코드 없음. 그저 바이트를 읽고 구조화된 표현을 만드는 자바스크립트뿐입니다.

Three.js에 연결하기

USD 파싱은 문제의 절반일 뿐입니다. USD 개념(prim, 트랜스폼, 메시 스키마)을 Three.js 객체로 변환하는 일도 필요합니다.

다음은 USDC 파일을 로드해서 Three.js 메시를 만드는 최소 예제입니다.

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

async function loadUsdcToThree(url: string, scene: THREE.Scene) {
  // 1. 가져와서 파싱한다
  const buffer = await fetch(url).then(r => r.arrayBuffer());
  const layer = parseUsdcToLayer(buffer, { identifier: url });
  
  // 2. stage를 생성한다 (참조가 있으면 합성을 처리한다)
  const stage = UsdStage.open(layer);
  
  // 3. prim 트리를 순회한다
  for (const prim of stage.traverse()) {
    // 지오메트리가 아니면 건너뛴다
    if (prim.typeName !== 'Mesh') continue;
    
    // 지오메트리 데이터를 가져온다
    const points = prim.getAttribute('points')?.value;
    const faceVertexIndices = prim.getAttribute('faceVertexIndices')?.value;
    const faceVertexCounts = prim.getAttribute('faceVertexCounts')?.value;
    
    if (!points || !faceVertexIndices || !faceVertexCounts) continue;
    
    // Three.js 지오메트리로 변환한다
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', 
      new THREE.Float32BufferAttribute(points.flat(), 3)
    );
    
    // USD는 faceVertexCounts + faceVertexIndices를 쓰고, Three.js는 평탄한 인덱스 배열을 원한다
    // 삼각형으로 분할된 메시라면 이건 간단하다:
    geometry.setIndex(Array.from(faceVertexIndices));
    geometry.computeVertexNormals();
    
    // 메시를 생성한다
    const material = new THREE.MeshStandardMaterial({ color: 0x888888 });
    const mesh = new THREE.Mesh(geometry, material);
    
    // 트랜스폼을 적용한다
    const xform = getWorldTransform(prim);
    mesh.matrix.fromArray(xform);
    mesh.matrixAutoUpdate = false;
    
    scene.add(mesh);
  }
}

function getWorldTransform(prim): number[] {
  // 단순화: 실제로는 부모 트랜스폼을 합성해야 한다
  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];
}

이 예제는 단순화한 것입니다. 실제 코드는 트랜스폼 스태킹(USD prim은 함께 합성되는 여러 xformOp 속성을 가질 수 있습니다), 세분화 표면(catmullClark 세분화를 가진 메시는 실제 세분화가 필요합니다), 머티리얼(UsdPreviewSurface 파라미터가 Three.js MeshStandardMaterial로 매핑됩니다), 텍스처(애셋 경로는 해석과 로딩이 필요합니다), 스켈레탈 애니메이션(UsdSkel 바인딩은 SkinnedMesh로의 변환이 필요합니다)을 처리해야 합니다.

이 모든 게 어떻게 맞물려 동작하는지 보고 싶다면 @cinevva/usdjs-viewer를 확인하세요. 완전한 레퍼런스 구현입니다.

합성은 생각보다 더 중요합니다

단일 USDC 파일을 파싱하는 건 쉬운 부분입니다. 실제 USD 씬은 합성을 쓰는데, 이는 서브레이어, 참조, 페이로드, 베리언트가 모두 함께 동작한다는 뜻입니다.

자동차 모델을 떠올려 보세요. 차체는 body.usdc를 참조하고, 바퀴는 wheel.usdc를 참조하며, "sport"와 "sedan" 구성을 위한 베리언트가 있습니다. 최종 씬은 이 모든 조각들로부터 런타임에 조립됩니다.

이렇게 처리합니다.

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

// 참조된 파일을 가져오는 방법을 아는 resolver를 생성한다
const resolver = new FetchResolver({
  baseUrl: '/assets/',
});

// 합성과 함께 연다
const stage = UsdStage.open(rootLayer, { resolver });

// 베리언트 선택을 설정한다
stage.setVariantSelection('/Car', 'bodyStyle', 'sport');

// 이제 합성된 씬을 순회한다
for (const prim of stage.traverse()) {
  // 해석된 참조와 선택된 베리언트를 보게 된다
}

resolver는 참조된 USDC 파일을 필요할 때 가져옵니다. 합성은 자바스크립트에서 일어나며, usdcat --flatten으로 얻는 것과 같은 평탄화된 씬을 만들어냅니다.

성능

솔직히 말하죠. 자바스크립트에서 USDC를 파싱하는 건 네이티브 C++보다 느립니다. WASM과 빌드 단계를 건너뛰는 대가입니다.

실제로 10MB 미만에 삼각형이 10만 개 미만인 모델을 다루는 일반적인 웹 사용 사례라면, 최신 하드웨어에서 파싱에 50~200ms가 걸립니다. 로딩 표시를 보여준다면 초기 로드에는 괜찮은 수준입니다.

더 빠르게 만들 수 있습니다. 전체 씬을 미리 다 로드하지 마세요. 루트 레이어를 로드하고, 가능한 부분을 렌더링한 다음, 페이로드를 필요할 때 가져오세요. 파싱을 Web Worker로 옮겨서 UI가 반응성을 유지하게 하세요. 파싱된 레이어를 IndexedDB에 캐싱해서 재방문 시 즉시 뜨게 하세요. 전체 씬이 스트리밍되는 동안 저해상도 프리뷰를 보여주세요.

다음은 Web Worker 설정입니다.

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

self.onmessage = async (e) => {
  const { buffer, identifier } = e.data;
  const layer = parseUsdcToLayer(buffer, { identifier });
  
  // 레이어 데이터를 직렬화한다 (메서드는 제외)
  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;
  // layerData로 Three.js 씬을 만든다
};

되는 것과 안 되는 것

흔히 쓰는 것은 대부분 됩니다. 지오메트리(메시, 포인트, 커브)는 압축된 정점 배열을 포함해 정확히 파싱됩니다. 트랜스폼은 완전한 xformOp 스태킹과 함께 동작합니다. 머티리얼은 UsdPreviewSurface에서 PBR로 매핑됩니다. 합성은 서브레이어, 참조, 페이로드, 베리언트, 상속을 처리합니다. 텍스처는 resolver를 제공하면 해석되고 로드됩니다. 기본적인 스켈레탈 애니메이션은 흔한 캐릭터 리그에서 동작합니다.

다만 빈틈도 있습니다. 이건 Hydra 구현이 아니므로, USD 데이터를 어떤 렌더링 엔진을 쓰든 그에 맞게 변환하는 건 여러분의 몫입니다. 편의 메서드를 갖춘 UsdGeomMesh 같은 타입드 스키마 API는 없습니다. 일반 prim과 속성으로 작업합니다. specializes, relocates, value clips 같은 일부 합성 기능은 아직 구현되지 않았습니다. 단순한 UsdPreviewSurface를 넘어서는 복잡한 머티리얼 네트워크는 직접 처리해야 합니다.

언제 쓰면 좋을까요

WASM 빌드 단계 없이 웹 앱에서 USD 파일을 로드해야 할 때, 파이프라인이 USDC를 출력하는데 glTF로 변환하고 싶지 않을 때, 브라우저용 USD 뷰어나 에디터를 만들 때, 또는 단순히 렌더링이 아니라 USD 구조를 들여다보고 싶을 때 적합합니다.

모든 엣지 케이스까지 OpenUSD와 완전히 동일한 동작이 필요하거나, 씬이 매우 크고(100MB 이상) 네이티브 성능이 필요하거나, 이미 WASM 빌드를 쓰고 있고 그 복잡성이 프로젝트에 받아들일 만하다면 다른 것을 쓰세요.

자료

세 가지 패키지가 있습니다. 핵심 파싱과 합성을 위한 @cinevva/usdjs, Three.js 기반 브라우저 뷰어인 @cinevva/usdjs-viewer, 테스트에서 헤드리스 PNG 렌더링을 하는 @cinevva/usdjs-renderer입니다.

문서는 usdjs API Reference, Pixar OpenUSD Specification, Three.js Documentation를 확인하세요.

USDC 파서가 Pixar의 구현에 어떻게 매핑되는지 이해하고 싶다면, usdjs 저장소의 src/usdc/PIXAR_PARITY.md를 보세요.

직접 해보기

이게 동작하는 걸 가장 빠르게 보는 방법은 usdjs-viewer 데모에 접속해서 USDC 파일을 끌어다 놓는 것입니다.

직접 프로젝트에 통합하려면, 패키지를 설치하고 위의 코드 예제로 시작하세요.

bash
npm install @cinevva/usdjs

브라우저에서 USD는 가능합니다. 항상 옳은 선택은 아니지만, 필요할 때 순수 자바스크립트 파싱은 신경 쓸 빌드 단계 하나와 의존성 하나를 줄여줍니다.

관련 글