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

相关推荐
yuanyxh19 小时前
Mac 软件推荐
前端·javascript·程序员
万少19 小时前
AtomCode开发微信小程序《谁去呀》 全流程
前端·javascript·后端
某人辛木19 小时前
Web自动化测试
前端·python·pycharm·pytest
云和数据.ChenGuang19 小时前
T5大模型
人工智能·机器人·pandas·数据预处理·数据训练
Kagol20 小时前
Superpowers GSD gstack AgentSkills深度测评
前端·人工智能
excel21 小时前
JavaScript 字符串与模板字面量:从表象到本质理解
前端
京东云开发者21 小时前
当AI成为导演-如何用AI创作动漫短剧
前端
李白的天不白1 天前
使用 SmartAdmin 进行前后端开发
java·前端
乘风gg1 天前
🤡PUA AI Coding 工具 的 10 条终极语录
前端·ai编程·claude
学Linux的语莫1 天前
Vue 3 入门教程
前端·javascript·vue.js