USD: Add option to merge transform and shape on export

Adds the option `merge_parent_xform` to the USD export operator and
panels so the transform and shapes are merged into a single USD Prim.

Without the option (existing default), a top-level mesh would be
exported as a top-level `Xform` that has a `Mesh` child:
```
def Xform "MyBlenderMeshObject"
{
  matrix4d xformOp:transform = ...

  def Mesh "MyBlenderMeshData"
  {
  }
}
```

This matches the Blender data model, where a transformable object
contains a geometric shape (like a mesh). This structure is also very
valid in USD, where we don't want to directly instantiate geometric
primitives[1]

However, "since number of prims on a stage is one of the primary factors
that governs how USD scale"[2], to reduce the number of prims in a
stage, geometric primitives *are transformable* themselves (see the
inheritence diagram[3]).

As such, the new export option allows to export geometric primitives
without the parent transform:
```
def Mesh "MyBlenderMeshObject"
{
  matrix4d xformOp:transform = ...
}
```

This MR adds a the `is_object_data_context` flag to the
`HierarchyContext` context structure. The point of this change is to
make unambiguous in `USDHierarchyIterator::create_usd_export_context`
the fact that an `object` or a `data` is currently being exported: the
new `merge_parent_xform` option is meaningless for `object`. Only `data`
can be exported with a parent `Xform` or as a child of said `Xform`.

Ideally this flag would not be needed at all: the final USD prim path
*could* be computed in an override of the virtual
`AbstractHierarchyIterator::get_object_data_path` method. However, this
would mean that an `object` and a `data` would have the same export path.
This does not currently play well with
`AbstractHierarchyIterator::ensure_writer`, where `writers` are cached
*by their export path*: it would cache a transform writer, but will skip
the subsequent data writer.

Additionally, another new `is_parent` flag is added to handle the case
where merging the Xform is invalid: objects that are parents to other
objects should remain unmerged as otherwise that would yield invalid USD
files.

[1] https://openusd.org/release/glossary.html#usdglossary-gprim
[2] https://openusd.org/release/glossary.html#usdglossary-instancing
[3] https://openusd.org/release/api/class_usd_geom_xformable.html

Co-authored-by: Odréanne Breton <odreanne.breton@ubisoft.com>
Co-authored-by: Sttevan Carnali Joga <sttevan.carnali-joga@ubisoft.com>
Co-authored-by: Charles Flèche <charles.fleche@ubisoft.com>
This commit is contained in:
Charles Flèche
2024-11-19 19:18:53 +01:00
committed by Jesse Yurkovich
parent acf82cb711
commit 428ab699dc
6 changed files with 160 additions and 0 deletions

View File

@@ -299,6 +299,8 @@ static int wm_usd_export_exec(bContext *C, wmOperator *op)
const int usdz_downscale_custom_size = RNA_int_get(op->ptr, "usdz_downscale_custom_size");
const bool merge_parent_xform = RNA_boolean_get(op->ptr, "merge_parent_xform");
# if PXR_VERSION >= 2403
const bool allow_unicode = RNA_boolean_get(op->ptr, "allow_unicode");
# else
@@ -387,6 +389,8 @@ static int wm_usd_export_exec(bContext *C, wmOperator *op)
params.usdz_downscale_size = usdz_downscale_size;
params.usdz_downscale_custom_size = usdz_downscale_custom_size;
params.merge_parent_xform = merge_parent_xform;
STRNCPY(params.root_prim_path, root_prim_path);
STRNCPY(params.custom_properties_namespace, custom_properties_namespace);
RNA_string_get(op->ptr, "collection", params.collection);
@@ -460,6 +464,7 @@ static void wm_usd_export_draw(bContext *C, wmOperator *op)
uiItemR(col, ptr, "rename_uvmaps", UI_ITEM_NONE, nullptr, ICON_NONE);
uiItemR(col, ptr, "export_normals", UI_ITEM_NONE, nullptr, ICON_NONE);
uiItemR(col, ptr, "merge_parent_xform", UI_ITEM_NONE, nullptr, ICON_NONE);
uiItemR(col, ptr, "triangulate_meshes", UI_ITEM_NONE, nullptr, ICON_NONE);
if (RNA_boolean_get(ptr, "triangulate_meshes")) {
uiItemR(col, ptr, "quad_method", UI_ITEM_NONE, IFACE_("Method Quads"), ICON_NONE);
@@ -844,6 +849,14 @@ void WM_OT_usd_export(wmOperatorType *ot)
"Custom size for downscaling exported textures",
128,
8192);
RNA_def_boolean(ot->srna,
"merge_parent_xform",
false,
"Merge parent Xform",
"Merge USD primitives with their Xform parent if possible: "
"USD does not allow nested UsdGeomGprim. Intermediary Xform will "
"be defined to keep the USD file valid.");
}
/* ====== USD Import ====== */

View File

@@ -68,6 +68,18 @@ struct HierarchyContext {
* it's animated. This is necessary when a parent object in Blender is not part of the export. */
bool animation_check_include_parent;
/* The flag makes unambiguous the fact that the current context targets object or data. This is
* notably used in USDHierarchyIterator::create_usd_export_context: options like
* merge_parent_xform option is meaningless for object, it only makes sense for data. */
bool is_object_data_context;
/* This flag tells, within a object data context, if an object is the parent of other objects.
* This is useful when exporting UsdGeomGprim: those cannot be nested into each other. For
* example, an UsdGeomMesh cannot have other UsdGeomMesh as descendants and other hierarchy
* strategies need to be adopted.
*/
bool is_parent;
/*********** Determined during writer creation: ***************/
float parent_matrix_inv_world[4][4]; /* Inverse of the parent's world matrix. */
std::string export_path; /* Hierarchical path, such as "/grandparent/parent/object_name". */

View File

@@ -406,6 +406,7 @@ void AbstractHierarchyIterator::visit_object(Object *object,
{
HierarchyContext *context = new HierarchyContext();
context->object = object;
context->is_object_data_context = false;
context->export_name = get_object_name(object);
context->export_parent = export_parent;
context->duplicator = nullptr;
@@ -446,6 +447,7 @@ void AbstractHierarchyIterator::visit_dupli_object(DupliObject *dupli_object,
{
HierarchyContext *context = new HierarchyContext();
context->object = dupli_object->ob;
context->is_object_data_context = false;
context->duplicator = duplicator;
context->persistent_id = PersistentID(dupli_object);
context->weak_export = false;
@@ -619,6 +621,12 @@ HierarchyContext AbstractHierarchyIterator::context_for_object_data(
const HierarchyContext *object_context) const
{
HierarchyContext data_context = *object_context;
data_context.is_object_data_context = true;
ExportGraph::key_type object_key = ObjectIdentifier::for_real_object(data_context.object);
auto iter = export_graph_.find(object_key);
data_context.is_parent = iter->second.size() > 0;
data_context.higher_up_export_path = object_context->export_path;
data_context.export_name = get_object_data_name(data_context.object);
data_context.export_path = path_concatenate(data_context.higher_up_export_path,

View File

@@ -110,6 +110,17 @@ USDExporterContext USDHierarchyIterator::create_usd_export_context(const Hierarc
path = pxr::SdfPath(context->export_path);
}
if (params_.merge_parent_xform && context->is_object_data_context && !context->is_parent) {
bool can_merge_with_xform = true;
if (params_.export_shapekeys && is_mesh_with_shape_keys(context->object)) {
can_merge_with_xform = false;
}
if (can_merge_with_xform) {
path = path.GetParentPath();
}
}
/* Returns the same path that was passed to `stage_` object during it's creation (via
* `pxr::UsdStage::CreateNew` function). */
const pxr::SdfLayerHandle root_layer = stage_->GetRootLayer();

View File

@@ -168,6 +168,8 @@ struct USDExportParams {
char collection[MAX_IDPROP_NAME] = "";
char custom_properties_namespace[MAX_IDPROP_NAME] = "";
bool merge_parent_xform = false;
/** Communication structure between the wmJob management code and the worker code. Currently used
* to generate safely reports from the worker thread. */
wmJobWorkerStatus *worker_status = nullptr;

View File

@@ -965,6 +965,120 @@ class USDExportTest(AbstractUSDTest):
self.assertEqual(USDHookBase.responses["on_export"], [])
self.assertEqual(USDHookBase.responses["on_import"], [])
def test_merge_parent_xform_false(self):
bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "usd_hierarchy_export_test.blend"))
test_path = self.tempdir / "test_merge_parent_xform_false.usda"
self.export_and_validate(filepath=str(test_path), merge_parent_xform=False)
expected = (
("/root", "Xform"),
("/root/Dupli1", "Xform"),
("/root/Dupli1/GEO_Head_0", "Xform"),
("/root/Dupli1/GEO_Head_0/Face", "Mesh"),
("/root/Dupli1/GEO_Head_0/GEO_Ear_R_2", "Xform"),
("/root/Dupli1/GEO_Head_0/GEO_Ear_R_2/Ear", "Mesh"),
("/root/Dupli1/GEO_Head_0/GEO_Ear_L_1", "Xform"),
("/root/Dupli1/GEO_Head_0/GEO_Ear_L_1/Ear", "Mesh"),
("/root/Dupli1/GEO_Head_0/GEO_Nose_3", "Xform"),
("/root/Dupli1/GEO_Head_0/GEO_Nose_3/Nose", "Mesh"),
("/root/_materials", "Scope"),
("/root/_materials/Head", "Material"),
("/root/_materials/Head/Principled_BSDF", "Shader"),
("/root/_materials/Nose", "Material"),
("/root/_materials/Nose/Principled_BSDF", "Shader"),
("/root/ParentOfDupli2", "Xform"),
("/root/ParentOfDupli2/Icosphere", "Mesh"),
("/root/ParentOfDupli2/Dupli2", "Xform"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0", "Xform"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0/Face", "Mesh"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0/GEO_Ear_L_1", "Xform"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0/GEO_Ear_L_1/Ear", "Mesh"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0/GEO_Ear_R_2", "Xform"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0/GEO_Ear_R_2/Ear", "Mesh"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0/GEO_Nose_3", "Xform"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0/GEO_Nose_3/Nose", "Mesh"),
("/root/Ground_plane", "Xform"),
("/root/Ground_plane/Plane", "Mesh"),
("/root/Ground_plane/OutsideDupliGrandParent", "Xform"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent", "Xform"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head", "Xform"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head/Face", "Mesh"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head/GEO_Ear_R", "Xform"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head/GEO_Ear_R/Ear", "Mesh"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head/GEO_Nose", "Xform"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head/GEO_Nose/Nose", "Mesh"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head/GEO_Ear_L", "Xform"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head/GEO_Ear_L/Ear", "Mesh"),
("/root/Camera", "Xform"),
("/root/Camera/Camera", "Camera"),
("/root/env_light", "DomeLight")
)
def key(el):
return el[0]
expected = tuple(sorted(expected, key=key))
stage = Usd.Stage.Open(str(test_path))
actual = ((str(p.GetPath()), p.GetTypeName()) for p in stage.Traverse())
actual = tuple(sorted(actual, key=key))
self.assertTupleEqual(expected, actual)
def test_merge_parent_xform_true(self):
bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "usd_hierarchy_export_test.blend"))
test_path = self.tempdir / "test_merge_parent_xform_true.usda"
self.export_and_validate(filepath=str(test_path), merge_parent_xform=True)
expected = (
("/root", "Xform"),
("/root/Dupli1", "Xform"),
("/root/Dupli1/GEO_Head_0", "Xform"),
("/root/Dupli1/GEO_Head_0/Face", "Mesh"),
("/root/Dupli1/GEO_Head_0/GEO_Ear_R_2", "Mesh"),
("/root/Dupli1/GEO_Head_0/GEO_Ear_L_1", "Mesh"),
("/root/Dupli1/GEO_Head_0/GEO_Nose_3", "Mesh"),
("/root/_materials", "Scope"),
("/root/_materials/Head", "Material"),
("/root/_materials/Head/Principled_BSDF", "Shader"),
("/root/_materials/Nose", "Material"),
("/root/_materials/Nose/Principled_BSDF", "Shader"),
("/root/ParentOfDupli2", "Xform"),
("/root/ParentOfDupli2/Icosphere", "Mesh"),
("/root/ParentOfDupli2/Dupli2", "Xform"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0", "Xform"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0/Face", "Mesh"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0/GEO_Ear_L_1", "Mesh"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0/GEO_Ear_R_2", "Mesh"),
("/root/ParentOfDupli2/Dupli2/GEO_Head_0/GEO_Nose_3", "Mesh"),
("/root/Ground_plane", "Xform"),
("/root/Ground_plane/Plane", "Mesh"),
("/root/Ground_plane/OutsideDupliGrandParent", "Xform"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent", "Xform"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head", "Xform"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head/Face", "Mesh"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head/GEO_Ear_R", "Mesh"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head/GEO_Nose", "Mesh"),
("/root/Ground_plane/OutsideDupliGrandParent/OutsideDupliParent/GEO_Head/GEO_Ear_L", "Mesh"),
("/root/Camera", "Camera"),
("/root/env_light", "DomeLight")
)
def key(el):
return el[0]
expected = tuple(sorted(expected, key=key))
stage = Usd.Stage.Open(str(test_path))
actual = ((str(p.GetPath()), p.GetTypeName()) for p in stage.Traverse())
actual = tuple(sorted(actual, key=key))
self.assertTupleEqual(expected, actual)
class USDHookBase():
instructions = {}