Skip to content

浏览器中的 Three.js + USDC

你从Maya或Houdini拿到了一个 .usdc 文件,想把它显示在Three.js场景里。我也走过这条路。直到不久之前,你的选择都不太好。你可以离线转成glTF,或者起一个OpenUSD的WASM构建,或者干脆放弃换格式。

这份指南讲怎么用 @cinevva/usdjs 直接在JavaScript里解析USDC,以及怎么把数据塞进Three.js。

USDC是什么?

USD文件有三种风味。USDA是文本版,可读、好调试,但啰嗦。USDC(有时叫"Crate")是二进制格式,生产管线实际在用的就是它,因为它紧凑、在原生工具里加载快。USDZ是包含USDC加贴图的zip包,Apple用它做AR Quick Look。

问题是:USDC是专有二进制格式。Pixar用C++写了参考实现,到现在为止都没有纯JavaScript的方式去读它。

你为什么会在意

如果你在做要对接电影或VFX管线内容的3D Web应用,你迟早会碰到USD。美术师从Maya、Houdini或Blender导出,导出的常常就是USDC。

@cinevva/usdjs 出现之前你有三个选项。你可以跑 usdcat 把USDC转成文本格式,多一步构建,丢掉二进制的紧凑性。你可以把OpenUSD或TinyUSDZ编到WebAssembly,给你的Bundle加几MB,并且要为线程开启特殊的服务器头。或者你可以用Three.js自带的USDLoader,它处理USDZ但对USDC支持有限,组合能力也少。

现在有第四个选项:在JavaScript里原生解析USDC。

它怎么工作

@cinevva/usdjs 库用TypeScript重新实现了USD的核心功能。我读了Pixar的C++源码并把Crate格式规范翻译到JavaScript,做出了USDC解析器。行为有歧义时,我们和OpenUSD保持一致。

实际用起来是这样:

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);

没有WASM,没有原生代码。只是JavaScript在读字节、构建结构化表示。

接到Three.js里

解析USD只解决了一半问题。你还得把USD概念(Prim、变换、Mesh Schema)转成Three.js对象。

下面是一个最小示例,加载USDC文件并创建Three.js Mesh:

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];
}

这个例子是简化的。真实代码要处理变换叠加(USD的Prim可以有多个 xformOp 属性,它们组合到一起)、细分曲面(带 catmullClark 细分的Mesh需要真正做细分)、材质(UsdPreviewSurface 参数映射到Three.js的 MeshStandardMaterial)、纹理(资源路径要解析并加载)、骨骼动画(UsdSkel 绑定要转成 SkinnedMesh)。

要看这一切是怎么结合在一起的,看 @cinevva/usdjs-viewer。它是一个完整的参考实现。

组合比你想的更重要

解析单个USDC文件是简单的部分。真实USD场景用组合,这意味着子层(sublayers)、引用(references)、payload、变体(variants)一起协作。

想象一辆汽车模型。车身引用 body.usdc,车轮引用 wheel.usdc,还有"运动"和"轿车"两种变体配置。最终场景是在运行时由这些片段拼装出来的。

这里是怎么处理:

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
}

Resolver按需取被引用的USDC文件。组合在JavaScript里发生,产生你用 usdcat --flatten 会得到的扁平化场景。

性能

实话说:在JavaScript里解析USDC比原生C++慢。这是绕过WASM和构建步骤的代价。

实际情况下,对于典型的Web用例——10MB以下、不到10万三角形的模型——在现代硬件上解析耗时50-200ms。如果你显示一个加载指示器,作为初次加载是没问题的。

你能让它更快。不要一开始就加载整个场景。加载根层,能渲染什么先渲染,然后按需取payload。把解析挪到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 });
  
  // 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
};

什么能用,什么不能

大多数常见的东西都能用。几何(Mesh、点、曲线)能正确解析,包括压缩的顶点数组。变换支持完整的 xformOp 叠加。材质从 UsdPreviewSurface 映射到PBR。组合处理子层、引用、payload、变体和继承。如果你提供Resolver,纹理能解析并加载。常见角色绑定的基础骨骼动画能用。

但也有缺口。这不是Hydra实现,所以你要自己把USD数据转到你用的渲染引擎里去。没有像 UsdGeomMesh 那样带便捷方法的类型化Schema API,你要处理的是通用Prim和属性。某些组合特性比如specializes、relocates和value clips还没实现。超出简单 UsdPreviewSurface 的复杂材质网络需要自己处理。

什么时候用这套

当你需要在Web应用里加载USD文件但又不想要WASM构建步骤、你的管线输出USDC而你又不想转成glTF、你在为浏览器做USD查看器或编辑器、或者你想检视USD结构而不只是渲染它——这套就有意义。

别用它,如果你需要和OpenUSD每个边界情况都对齐、你的场景巨大(100MB+)需要原生性能、或者你已经在用WASM构建并且接受了那种复杂度。

资源

三个包:@cinevva/usdjs 做核心解析和组合,@cinevva/usdjs-viewer 是基于Three.js的浏览器查看器,@cinevva/usdjs-renderer 在测试里做无头PNG渲染。

文档可看 usdjs API 参考Pixar OpenUSD 规范Three.js 文档

想了解USDC解析器怎么对齐Pixar实现,看usdjs仓库里的 src/usdc/PIXAR_PARITY.md

试试看

最快的方式是访问 usdjs-viewer demo 然后把USDC文件拖进去。

把它集成到你自己的项目里,安装包然后从上面的代码示例开始:

bash
npm install @cinevva/usdjs

USD能在浏览器里跑。它不一定总是对的选择,但当你需要它时,纯JavaScript解析意味着少一步构建、少一个依赖要操心。

相关阅读