课程链接:www.bilibili.com/cheese/play...
代码链接:github.com/buglas/robo...
学习目标
- 认识usda 模型
- 理解three.js 的USDLoader 解析usdz 模型的逻辑
- 参考three.js 的USDLoader,定制化解析usda 模型
1-USD 简介
USD(Universal Scene Description,通用场景描述)模型文件是由皮克斯动画工作室开发的一种开源3D数据交换格式和场景描述框架,旨在支持复杂且大规模的3D图形数据交换与协作。
文件格式
USD支持多种文件格式,主要分为文本和二进制两种:
- .usd: 通用扩展名,可以是ASCII或二进制,通常通过文件头来区分。
- .usda (ASCII) : 人类可读的文本格式。适合手动编辑、版本控制(Git diff友好)和调试。文件内容清晰可见,但体积较大,读写速度相对较慢。
- .usdc (Binary/Crate) : 二进制格式。体积更小,读写速度极快,适合最终发布或大型场景数据,但不可直接肉眼阅读。
- .usdz: USD的压缩归档格式(类似ZIP),常用于AR(增强现实)和移动端分发,将纹理和资源打包在一个文件中。
示例:一个立方体
一个立方体的usda模型的内容如下:
python
#usda 1.0
# 变换节点
def Xform "World"
{
# 立方体
def Cube "MyCube"
{
# 尺寸
double size = 2.0
}
}
可以将上面的代码放进一个txt文本里,将其命名为cube.usda,然后用blender导入,效果如下:

示例:变换立方体
代码如下:
ini
#usda 1.0
def Xform "World"
{
def Cube "MyCube"
{
#位移
double3 xformOp:translate = (0, 1, 0)
#四元数旋转,绕 Y 轴旋转 45 度
quatf xformOp:orient = (0.92388,0, 0.38268, 0)
#缩放
float3 xformOp:scale = (2, 3, 4)
#变换顺序
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:orient", "xformOp:scale"]
double size = 2.0
}
}
在blender中导入的效果如下:

实际的usd 模型还会更复杂,比如会通过position 顶点自定义几何体,会有材质节点,而材质还会有更复杂的自定义材质。
这里主要是让大家认识一下usd模型语言,对于更多的内容,后面我们会遇到什么说什么。
2-three.js 中的USDLoader
在three.js 中有一个USDLoader 类,可以加载usdz。

usdz 可以包含纹理数据,纹理数据会以二级制的形式存在,在前端可以基于此数据创建Blob对象。
USDLoader存在局限性:
- 只能加载usdz,没有封装加载usda 的方法,虽然它已经写了解析usda 的逻辑。
- 对usda 的解析并不全面,比如它没解析纹理的方法。
usda 中自定义材质的灵活多变,也注定了难以被统一解析,这就需要定制解析方法。
USDLoader 的运行逻辑如下:
1.使用FileLoader 加载usdz 文件。
scss
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new FileLoader( scope.manager );
loader.setPath( scope.path );
loader.setResponseType( 'arraybuffer' );
loader.setRequestHeader( scope.requestHeader );
loader.setWithCredentials( scope.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
FileLoader 是three.js 封装的基于 Fetch API 的底层资源加载类,其核心作用是:
- 基于浏览器原生 Fetch API 加载任意类型的文件资源(文本、二进制、JSON、文档等);
- 支持请求缓存、重复请求合并、加载进度回调、请求中断;
- 作为 Three.js 其他专用加载器(如 GLTFLoader、TextureLoader)的底层依赖;
- 遵循 Three.js 的
LoadingManager生命周期管理规范。
parse( text ) 是解析文件资源的方法。
2.在parse( text )方法中,解压usdz文件,将其转化为可读文件,使用USDAParser 解析此文件。
ini
parse( buffer ) {
const usda = new USDAParser();
//...
// USDZ
const zip = fflate.unzipSync( new Uint8Array( buffer ) );
const assets = parseAssets( zip );
const file = findUSD( zip );
const text = fflate.strFromU8( file );
return usda.parse( text, assets );
}
3.在USDAParser的 parse() 中,首先会使用其parseText(text) 方法将usda 文件转成json 对象。
arduino
parse( text, assets ) {
const root = this.parseText( text );
//...
}
4.用buildGroup( root )方法,从usda 文件转成的json 对象中提起关键信息,创建相应的three.js 对象。
arduino
parse( text, assets ) {
const root = this.parseText( text );
//...
return buildGroup( root );
}
buildGroup( root ) 方法便是解析usda 的关键,当前此方法可以解析模型的顶点和法线。但是在材质纹理的解析方面并不完善,所以需我们根据具体需求做定制化处理。
3-usda 的定制化解析
基本需求:
- 将usda 模型和漫反射贴图渲染到前端。
- 材质接近塑料即可。
开发思路:基于three.js 的USDLoader 和USDAParser,根据实际需求,创建一个USDALoader 类。
USDALoader 类
创建一个适应于当前需求的USDALoader 类。
typescript
class USDALoader extends Loader {
constructor( manager:LoadingManager = DefaultLoadingManager ) {
super( manager );
}
load(
url:string,
onLoad:( data:any )=>void,
onProgress?:( event:ProgressEvent<EventTarget> )=>void,
onError?:( event:any )=>void
){
const loader = new FileLoader( this.manager ).setPath(this.path);
loader.load(
url,
( text )=>{
try {
onLoad( this.parse( text as string ) );
} catch ( e:any ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
this.manager.itemError( url );
}
},
onProgress,
onError
);
}
parse( text:string ){
//纹理链接集合
let textureURLMap:Map<string,string>=new Map()
const json=this.parseText( text );
const group=buildGroup( json,textureURLMap );
// 设置材质的漫反射贴图
group.traverse(async (child)=>{
if(!(child instanceof Mesh)){return}
const {userData:{materialName}}=child
if(!materialName){return}
const textureURL=textureURLMap.get(materialName)
if(!textureURL){return}
const textureLoader=new TextureLoader().setPath(this.path)
textureLoader.load(textureURL,(texture)=>{
(child.material as MeshBasicMaterial).map=texture
child.material.needsUpdate=true
})
})
return group;
}
parseText( text:string ) {
const root:{[key:string]:any} = {};
const lines = text.split( '\n' );
let string:string|null = null;
let target = root;
const stack = [ root ];
// Parse USDA file
for ( const line of lines ) {
if ( line.includes( '=' ) ) {
const assignment = line.split( '=' );
const lhs = assignment[ 0 ].trim();
const rhs = assignment[ 1 ].trim();
if ( rhs.endsWith( '{' ) ) {
const group = {};
stack.push( group );
target[ lhs ] = group;
target = group;
} else if ( rhs.endsWith( '(' ) ) {
const values = rhs.slice( 0, - 1 );
target[ lhs ] = values;
const meta = {};
stack.push( meta );
target = meta;
} else {
target[ lhs ] = rhs;
}
} else if ( line.endsWith( '{' ) ) {
const group = target[ string as string] || {};
stack.push( group );
target[ string as string] = group;
target = group;
} else if ( line.endsWith( '}' ) ) {
stack.pop();
if ( stack.length === 0 ) continue;
target = stack[ stack.length - 1 ];
} else if ( line.endsWith( '(' ) ) {
const meta = {};
stack.push( meta );
string = line.split( '(' )[ 0 ].trim() || string;
target[ string as string] = meta;
target = meta;
} else if ( line.endsWith( ')' ) ) {
stack.pop();
target = stack[ stack.length - 1 ];
} else {
string = line.trim();
}
}
return root;
}
}
上面的代码参考three.js的USDLoader,在parse() 方法里增加了贴图逻辑。
贴图逻辑如下:
1.建立纹理路径集合textureURLMap。
typescript
parse( text:string ){
//纹理链接集合
let textureURLMap:Map<string,string>=new Map()
const json=this.parseText( text );
const group=buildGroup( json,textureURLMap );
// ...
}
2.在用buildGroup() 解析usda 的过程中,会以材质名为键,将纹理链接存储到textureURLMap中。
typescript
function findDiffuseTexture(data:{[k:string]:any}, matName:string,textureURLMap:Map<string,string> ) {
if(!(data instanceof Object)){return undefined}
for ( const name in data ) {
if(isTextureKey(name)){
const str = data[ name ]
if(str){
const path = str.trim().slice(1, -1)
textureURLMap.set(matName, path)
}
}else{
findDiffuseTexture( data[ name ] ,matName,textureURLMap)
}
}
}
3.材质的的名称也会标记到相应的Meshs 对象上。
ini
function buildMesh( data:{[k:string]:any} ) {
const geometry = buildGeometry(data);
const material=new MeshStandardMaterial({
color:0xdddddd,
side:DoubleSide,
metalness:0.1,
roughness:0.4
})
const mesh= new Mesh(geometry,material)
transformObject3D( mesh, data );
const matName=findMaterialName( data )
matName&&(mesh.userData.materialName=matName)
return mesh;
}
4.在解析完usda后,遍历Mesh对象,根据绑定在Mesh对象的材质名,从textureURLMap中寻找相应的纹理链接,然后赋给相应材质名的材质。
typescript
parse( text:string ){
//纹理链接集合
let textureURLMap:Map<string,string>=new Map()
const json=this.parseText( text );
const group=buildGroup( json,textureURLMap );
// 设置材质的漫反射贴图
group.traverse(async (child)=>{
if(!(child instanceof Mesh)){return}
const {userData:{materialName}}=child
if(!materialName){return}
const textureURL=textureURLMap.get(materialName)
if(!textureURL){return}
const textureLoader=new TextureLoader().setPath(this.path)
textureLoader.load(textureURL,(texture)=>{
(child.material as MeshBasicMaterial).map=texture
child.material.needsUpdate=true
})
})
return group;
}
buildGroup 方法
1.创建Group 对象,用于包裹usda 的所有图形。
vbnet
function buildGroup( data:{[k:string]:any},textureURLMap:Map<string,string> ) {
const group = new Group();
buildHierarchy( data, group,textureURLMap );
return group;
}
2.解析usda 模型的节点。
ini
function buildHierarchy( data:{[k:string]:any}, group:Group,textureURLMap:Map<string,string> ) {
for ( const name in data ) {
if ( name.startsWith( 'def Scope' ) ) {
buildHierarchy( data[ name ], group,textureURLMap );
} else if ( name.startsWith( 'def Xform' ) ) {
const curGroup = new Group;
const arr=/def Xform "(\w+)"/.exec( name )
arr&&(curGroup.name = arr[ 1 ]);
transformObject3D( curGroup, data[ name ] );
group.add( curGroup );
buildHierarchy( data[ name ], curGroup,textureURLMap );
}else if ( name.startsWith( 'def Mesh' ) ) {
const mesh = buildMesh( data[ name ] );
const arr=/def Xform "(\w+)"/.exec( name )
arr&&(mesh.name = arr[ 1 ]);
group.add( mesh );
}else if ( name.startsWith( 'def Material' ) ) {
const arr=/def Material "(\w+)"/.exec( name )
if(arr){
const matName=arr[1]
matName&&findDiffuseTexture( data[ name ] ,matName,textureURLMap)
}
}
}
}
在上面的代码里,对4种节点进行了解析:
- Scope:纯分组节点,用于归类整理节点。在解析的过程中可以略过此节点,解析其子节点。
- Xform:负责空间变换的核心节点,对应three.js 的Group。
- Mesh:网格几何体模型
- Material:材质
3.解析Xform 的方法。
- 解析Xform 的name
ini
const curGroup = new Group;
const arr=/def Xform "(\w+)"/.exec( name )
arr&&(curGroup.name = arr[ 1 ]);
- 解析Xform 的变换信息
ini
transformObject3D( curGroup, data[ name ] );
group.add( curGroup );
ini
function transformObject3D( obj: Object3D, data:{[k:string]:any} ) {
// 位移
if ( 'double3 xformOp:translate' in data ) {
const translate= parseTuple( data[ 'double3 xformOp:translate'] );
translate&&obj.position.set(translate[0],translate[1],translate[2]);
}
// 缩放
if ( 'double3 xformOp:scale' in data ) {
const scale= parseTuple( data[ 'double3 xformOp:scale'] );
scale&&obj.scale.set(scale[0],scale[1],scale[2]);
}else if('float3 xformOp:scale' in data){
const scale= parseTuple( data[ 'float3 xformOp:scale'] );
scale&&obj.scale.set(scale[0],scale[1],scale[2]);
}
// 旋转
if ( 'quatf xformOp:orient' in data ) {
// 四元数
const quaternion= parseTuple( data[ 'quatf xformOp:orient'] );
quaternion&&obj.quaternion.set(quaternion[1],quaternion[2],quaternion[3],quaternion[0]);
}else if('double3 xformOp:rotateZYX' in data){
// 欧拉
const rotateZYX=parseTuple( data[ 'double3 xformOp:rotateZYX'] );
rotateZYX&&obj.rotation.set(
angleToRadians(rotateZYX[2]),
angleToRadians(rotateZYX[1]),
angleToRadians(rotateZYX[0])
);
}
}
- 解析Xform的子元素
ini
buildHierarchy( data[ name ], curGroup,textureURLMap );
4.解析Mesh 的方法
ini
function buildMesh( data:{[k:string]:any} ) {
const geometry = buildGeometry(data);
const material=new MeshStandardMaterial({
color:0xdddddd,
side:DoubleSide,
metalness:0.1,
roughness:0.4
})
const mesh= new Mesh(geometry,material)
transformObject3D( mesh, data );
const matName=findMaterialName( data )
matName&&(mesh.userData.materialName=matName)
return mesh;
}
- buildGeometry() 是解析几何体的方法。
typescript
function buildGeometry( data:{[k:string]:any} ) {
if ( ! data ) return undefined;
const geometry = new BufferGeometry();
let indices = null;
let uvs = null;
// index
if ( 'int[] faceVertexIndices' in data ) {
indices = JSON.parse( data[ 'int[] faceVertexIndices' ] );
}
// position
if ( 'point3f[] points' in data ) {
const positions = JSON.parse( data[ 'point3f[] points' ].replace( /[()]*/g, '' ) );
let attribute = new BufferAttribute( new Float32Array( positions ), 3 );
if ( indices !== null ) attribute = toFlatBufferAttribute( attribute, indices );
geometry.setAttribute( 'position', attribute );
}
// uv
if ( 'texCoord2f[] primvars:st' in data ) {
uvs = JSON.parse( data[ 'texCoord2f[] primvars:st' ].replace( /[()]*/g, '' ) );
let attribute = new BufferAttribute( new Float32Array( uvs ), 2 );
geometry.setAttribute( 'uv', attribute );
}
// normal
geometry.computeVertexNormals();
return geometry;
}
上面的代码解析的是特定模型,不面向所有模型。
在position 的解析中,我只解析了三角面,usd 模型中还有四边形面。
在normal 的解析中,我用的是three.js 自动计算法线的方法,有的usd 模型会自带normal 数据。
- 在材质方面,统一用了一个接近塑料的MeshStandardMaterial。
less
const material=new MeshStandardMaterial({
color:0xdddddd,
side:DoubleSide,
metalness:0.1,
roughness:0.4
})
usd 中的材质解析有点复杂,尤其是自定义材质,很难写一个通用的解析方法。
所以在对材质要求不高的情况小,就定义一个比较大众化的材质。
5.解析Material 的方法.
在此方法里,我只干了一件事,找到材质的漫反射的贴图,然后把它放到textureURLMap里。
scss
const arr=/def Material "(\w+)"/.exec( name )
if(arr){
const matName=arr[1]
matName&&findDiffuseTexture( data[ name ] ,matName,textureURLMap)
}
findDiffuseTexture() 是寻找漫反射贴图的方法。
typescript
//漫反射贴图的节点
const textureFilterList=['asset inputs:diffuse_texture','asset inputs:texture']
function findDiffuseTexture(data:{[k:string]:any}, matName:string,textureURLMap:Map<string,string> ) {
if(!(data instanceof Object)){return undefined}
for ( const name in data ) {
if(isTextureKey(name)){
const str = data[ name ]
if(str){
const path = str.trim().slice(1, -1)
textureURLMap.set(matName, path)
}
}else{
findDiffuseTexture( data[ name ] ,matName,textureURLMap)
}
}
}
function isTextureKey( key:string ) {
for ( const filterKey of textureFilterList ) {
if ( key.includes( filterKey ) ) {
return true;
}
}
return false;
}
4-USDALoader 的实例化
代码如下:
ini
// 加载USDA模型
const usdaLoader = new USDALoader().setPath('/models/usda/bbq/');
const usdaURL = 'bbq.usda';
usdaLoader.load(usdaURL, (usda: Group) => {
// 旋转usda模型
usda.rotation.x=Math.PI
// 将usda模型添加到场景中
scene.add(usda);
// 根据usda模型的尺寸,调整相机位置,使其恰到好处的显示在视口中
const box3 = new Box3().setFromObject(usda);
box3.getCenter(controls.target);
camera.position.z=box3.max.clone().sub(box3.min).length()
controls.update()
})
setPath('/models/usda/bbq/') 很重要,它指定了usda模型的主目录,usda文件和纹理文件都会从这里面找。
typescript
load(
url:string,
onLoad:( data:any )=>void,
onProgress?:( event:ProgressEvent<EventTarget> )=>void,
onError?:( event:any )=>void
){
const loader = new FileLoader( this.manager ).setPath(this.path);
//...
}
parse( text:string ){
//...
// 设置材质的漫反射贴图
group.traverse(async (child)=>{
//...
const textureLoader=new TextureLoader().setPath(this.path)
//...
})
return group;
}
效果如下:

总结
这一章我们对usd模型做了一个简单的介绍,说了three.js 解析usdz 的逻辑,最后参考three.js ,对USDLoader 和buildGroup 方法进行了改装,实现usda 的定制化加载。
这一章主要是给大家提供一个解析usda 的思路和示例,它并不能的解析所有的usda,因为usda本身是可以很复杂的。
所以大家后面若有所需,可以根据此思路,做usda 的定制化解析。