usda模型的定制化解析

课程链接: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 的案例

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 的定制化解析。

相关推荐
怕浪猫9 小时前
Electron 开发实战(二):核心概念深度详解 | 进程通信+窗口高级+生命周期全掌握
前端·javascript·面试
李伟_Li慢慢9 小时前
解析URDF文件
前端·机器人·three.js
李伟_Li慢慢9 小时前
用form控制URDF模型
前端·机器人·three.js
雾酩9 小时前
深拷贝与浅拷贝:一篇彻底讲明白的入门博客
开发语言·前端·javascript
李伟_Li慢慢9 小时前
joint 拖拽变换辅助路径
前端
倔强的石头_9 小时前
零代码复刻 OpenAI DeepResearch:我用 Dify × EdgeOne 打造全球科技热点深度起底神器
前端
李伟_Li慢慢9 小时前
初始项目的搭建
前端·机器人·three.js
李伟_Li慢慢9 小时前
joint的拖拽旋转
前端·机器人·three.js
李伟_Li慢慢9 小时前
joint的拖拽推拉
前端·机器人·three.js