反思反思

通用场景描述(USD)是一个场景图。 它是在皮克斯(Pixar)开发的,旨在构建大规模的生产渲染管道,能够支持许多并行工作的用户。 我最初在Pixar从事USD的核心工作,但是最近我一直在与Unity进行USD集成。 不仅是“用美元代替FBX”,而且还以一流的方式将其真正集成到Unity和C#中。 基于反射的C#序列化系统是此集成的核心。

背景

我可以听到您在思考一个问题,“但是,美元到底是什么?”要具体说明,下面是一个有效的USD-ASCII(USDA)文件,该文件声明了一个带有父转换的多维数据集:

  #usda 1.0 
  def Xform“ World” { 
def Cube“ MyCube” {
两倍大小= 1.0
}
}

我故意使用“声明”一词,因为USD是声明性场景图,您可以将其视为XML。 上面的示例不是要执行的代码,而是场景中存在的对象的声明。 它使用父变换定义一个多维数据集。 在USD中,诸如Cube和Xform之类的对象称为“ Prims”,是基元的简称。

所有对象均可通过路径寻址。 多维数据集的路径是 。 尖括号不是路径的一部分,而是表示文本中路径的常规方式。 表示“大小”的对象称为“属性”,它也具有路径

使用USD API

在USD API中,可在运行时通过“阶段”来访问Prims,在其他系统中,该阶段可称为场景。 下面的C#代码将创建上面的场景:

  var stage = UsdStage.CreateInMemory(); 
var xform = UsdGeomXform.Define(stage,new SdfPath(“ / World”));
var cube = UsdGeomCube.Define(stage,new SdfPath(“ / World / Cube”));
cube.GetSizeAttr()。Set(新VtValue(1.0));

这段代码不是特别冗长,但是让我们看一个稍微复杂和现实的示例,这次设置网格的顶点位置:

  var stage = UsdStage.CreateInMemory(); 
var mesh = UsdGeomMesh.Define(stage,new SdfPath(“ / World / Mesh”));;
  Vector3 []点=新Vector3 [] {新Vector3(1,2,3)} 
VtVec3fArray vtPoints =新的VtVec3fArray(points.Length);
  for(int i = 0; i <points.Length; i ++){ 
var p = points [i];
vtPoints [i] =新的GfVec3f(p [0],p [1],p [2]);
}
  mesh.GetPointsAttr()。Set(new VtValue(vtPoints)); 

为什么不将点存储在VtVec3fArray中呢? 因为这些值通常来自Unity,并且Unity使用Vector3表示网格顶点,所以始终需要进行转换。 该代码现在变得越来越冗长。 此外,对于不熟悉美元的C#或Unity开发人员来说,美元类型不是特别容易理解。 因此,我介绍了一个基于C#序列化最佳实践的高级序列化系统。

基于反射的序列化

反射是C#的运行时类型信息版本。 它具有在运行时检查C#对象的结构的能力,例如发现公共类成员及其类型。 如果我们仅创建C#类并将其自动转换为USD,这会很棒吗? 这正是USD Unity SDK所提供的。 这又是网格代码,使用反射进行了序列化:

  var mesh = new MeshSample(); 
mesh.points = new Vector3 [] {new Vector3(1,2,3)}
scene.Write(“ / World / Mesh”,mesh);

在幕后,对Write()的调用使用反射来检查MeshSample类的成员,发现它为public points属性持有非空值,从Vector3[]VtVec3fArray的类型转换器进行VtVec3fArray ,然后写入该值传递给USD属性。 编写更多的值只会增加几行代码:

  var mesh = new MeshSample(); 
mesh.points = new Vector3 [] {new Vector3(1,2,3)}
mesh.faceVertexIndices = new int [] {0,0,0}
mesh.orientation = Orientation.RightHanded;
mesh.extents =新的UnityEngine.Bounds();
scene.Write(“ / World / Mesh”,mesh);

除了USD Unity SDK提供的内置类之外,用户还可以定义用于C#序列化的自定义类:

  [UsdSchema(“ GameEntity”)] 
私人类GameEntity:SampleBase {
公众持股量= 1.0;
公共字符串名称=“玩家”;
}

使用反射系统将此类写入会导致以下USD文件:

  #usda 1.0 
  def GameEntity“ SomeEntity” { 
浮力强度= 1.0
字符串名称=“玩家”
}

善良

代码的效率:尽管上面的原始USD示例非常简洁,但支持复杂的场景结构会导致大量转换代码和USD API调用。 反射模式将这种复杂性降低到了反射系统,该系统已经对许多所需信息进行了编码。 较小的代码也更具可读性。

利用现有知识:用户能够使用他们已经理解的类型,例如int[]UnityEngine.Bounds 。 当然,必须学习序列化模式,但是由于它遵循C#标准,因此如果用户碰巧有使用其他C#系统的经验,也可能会很熟悉。

数据访问编译:读取数据时的常见模式是系统将进入稳定状态,在此状态下,每帧都读取相同的属性。 例如,在阅读动画超时时会发生这种情况。 反射系统可以在较低级别上编译此列表。 此外,通过编译要在帧与帧之间重用的列表,可以减少或消除反射的开销。

并行化:上述数据访问列表为并行执行IO提供了明确的入口点。 对于USD Unity SDK,我正在使用Unity的C#作业系统和突发编译器构建此并行数据访问范例(此工作正在进行中)。

数据稳定性:池数据分配器可用于减少内存流失。 这在C#中尤其重要,因为C#中的内存流失会导致垃圾回收,从而导致帧速率上升。 同样,序列化系统可以创建低级功能(池分配),而使用原始USD API很难实现和维护该功能。

坏人

听起来不错,有什么收获?

两个API:也许最大的未解决问题是有两个与USD交互的API:基于反射的序列化系统和直接USD API。 尽管我试图通过使用命名空间和仔细命名对象来使边界清晰,但我并未找到解决该问题的好方法。

执行开销:使用反射系统会有开销。 虽然编译数据访问列表可以解决此问题,但通常对于首次读取无法解决。 在测量了开销之后,我得出的结论是,使用反射系统的效率和清晰度非常值得。 开销也随着读取数据的大小而减少,因此对于复杂的场景,该开销几乎消失了。

单个属性序列化:在单个调用中序列化整个对象的功能很强大,但是有时最好隔离地序列化单个属性。 在此系统中,编写单个值意味着要么用该值定义一个自定义类,要么使用专用的API(这是另一个要学习的范式)。

异常和失败:当值无法序列化或类型转换系统引发错误时,可能很难理解出了什么问题。 我在使用API​​时会密切注意这些错误,并尝试使其尽可能清晰。

反向转换:序列化系统支持从USD中读取一个值到通用C#System.Object中,该值实际上是无类型的(从序列化系统的角度来看)。 在这种情况下,将使用对类型转换表的反向查询来“猜测” USD值应为哪种C#类型,但是这种转换并不完美,因为任何单个USD类型都有许多C#类型。 实际上,这不是一个普遍的问题,因为C#最佳实践鼓励使用强类型成员变量。

未来的工作

我正在积极地致力于IO编译系统以及并行数据访问。 虽然强大的资产导入者具有阻止大规模并行性的数据依赖关系,但初步结果令人鼓舞。 单属性序列化和使序列化可预测是两个领域,我也希望在下一个版本中进行改进。