USD: Add support for Point Instancing during Export

Adds a Point Instancing exporter based on the existing
USDPointInstancerReader. Covers both round-trip and Blender-native
workflows. Exports 'Instance on Points' setups as USDGeomPointInstancer,
supporting objects, collections, and nested prototypes.

A warning is shown during export if invalid prototype references are
detected. These would occur if an instancer attempts to instance itself.

This feature is currently gated behind an off-by-default export option
(`use_instancing`) as there are still a few cases which can yield
incorrect results.

Further details in the PR.
Ref: #139758

Authored by Apple: Zili (Liz) Zhou

Pull Request: https://projects.blender.org/blender/blender/pulls/139760
This commit is contained in:
Michael B Johnson
2025-06-14 01:10:55 +02:00
committed by Jesse Yurkovich
parent fc0b659066
commit 07342407d3
15 changed files with 1097 additions and 10 deletions

View File

@@ -85,6 +85,18 @@ struct HierarchyContext {
/* When true this is duplisource object. This flag is used to identify instance prototypes. */
bool is_duplisource;
/* This flag tells whether an object is a valid point instance of other objects.
* If true, it means the object has a valid reference path and its value can be included
* in the instances data of UsdGeomPointInstancer. */
bool is_point_instance;
/* This flag tells if an object is a valid prototype of a point instancer. */
bool is_point_proto;
/* True if this context is a descendant of any context with is_point_instance set to true.
* This helps skip redundant instancing data during export. */
bool has_point_instance_ancestor;
/*********** 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". */
@@ -112,6 +124,9 @@ struct HierarchyContext {
void mark_as_not_instanced();
bool is_prototype() const;
/* For handling point instancing (Instance on Points geo node). */
bool is_point_instancer() const;
bool is_object_visible(enum eEvaluationMode evaluation_mode) const;
};

View File

@@ -10,7 +10,10 @@
#include "BKE_anim_data.hh"
#include "BKE_duplilist.hh"
#include "BKE_geometry_set_instances.hh"
#include "BKE_key.hh"
#include "BKE_modifier.hh"
#include "BKE_node_legacy_types.hh"
#include "BKE_object.hh"
#include "BKE_particle.h"
@@ -23,6 +26,7 @@
#include "DNA_ID.h"
#include "DNA_layer_types.h"
#include "DNA_modifier_types.h"
#include "DNA_node_types.h"
#include "DNA_object_types.h"
#include "DNA_particle_types.h"
#include "DNA_rigidbody_types.h"
@@ -155,6 +159,21 @@ bool AbstractHierarchyWriter::check_has_deforming_physics(const HierarchyContext
return rbo != nullptr && rbo->type == RBO_TYPE_ACTIVE && (rbo->flag & RBO_FLAG_USE_DEFORM) != 0;
}
bool HierarchyContext::is_point_instancer() const
{
if (!object) {
return false;
}
/* Collection instancers are handled elsewhere as part of Scene instancing. */
if (object->type == OB_EMPTY && object->instance_collection != nullptr) {
return false;
}
const bke::GeometrySet geometry_set = bke::object_get_evaluated_geometry_set(*object);
return geometry_set.has_instances();
}
AbstractHierarchyIterator::AbstractHierarchyIterator(Main *bmain, Depsgraph *depsgraph)
: bmain_(bmain), depsgraph_(depsgraph), export_subset_({true, true})
{
@@ -629,6 +648,12 @@ void AbstractHierarchyIterator::make_writers(const HierarchyContext *parent_cont
}
for (HierarchyContext *context : *children) {
if (parent_context) {
if (parent_context->is_point_instance || parent_context->has_point_instance_ancestor) {
context->has_point_instance_ancestor = true;
}
}
/* Update the context so that it is correct for this parent-child relation. */
copy_m4_m4(context->parent_matrix_inv_world, parent_matrix_inv_world);
if (parent_context != nullptr) {
@@ -645,15 +670,18 @@ void AbstractHierarchyIterator::make_writers(const HierarchyContext *parent_cont
return;
}
BLI_assert(DEG_is_evaluated(context->object));
if (transform_writer.is_newly_created() || export_subset_.transforms) {
const bool need_writers = context->is_point_proto || (!context->is_point_instance &&
!context->has_point_instance_ancestor);
BLI_assert(DEG_is_evaluated_id(&context->object->id));
if ((transform_writer.is_newly_created() || export_subset_.transforms) && need_writers) {
/* XXX This can lead to too many XForms being written. For example, a camera writer can
* refuse to write an orthographic camera. By the time that this is known, the XForm has
* already been written. */
transform_writer->write(*context);
}
if (!context->weak_export && include_data_writers(context)) {
if (!context->weak_export && include_data_writers(context) && need_writers) {
make_writers_particle_systems(context);
make_writer_object_data(context);
}

View File

@@ -83,6 +83,7 @@ set(SRC
intern/usd_writer_material.cc
intern/usd_writer_mesh.cc
intern/usd_writer_metaball.cc
intern/usd_writer_pointinstancer.cc
intern/usd_writer_points.cc
intern/usd_writer_text.cc
intern/usd_writer_transform.cc
@@ -134,6 +135,7 @@ set(SRC
intern/usd_writer_material.hh
intern/usd_writer_mesh.hh
intern/usd_writer_metaball.hh
intern/usd_writer_pointinstancer.hh
intern/usd_writer_points.hh
intern/usd_writer_text.hh
intern/usd_writer_transform.hh

View File

@@ -15,9 +15,11 @@
#include <pxr/base/tf/token.h>
#include <pxr/pxr.h>
#include <pxr/usd/sdf/assetPath.h>
#include <pxr/usd/sdf/path.h>
#include <pxr/usd/usd/primRange.h>
#include <pxr/usd/usd/stage.h>
#include <pxr/usd/usdGeom/metrics.h>
#include <pxr/usd/usdGeom/pointInstancer.h>
#include <pxr/usd/usdGeom/tokens.h>
#include <pxr/usd/usdGeom/xform.h>
#include <pxr/usd/usdGeom/xformCommonAPI.h>
@@ -386,6 +388,71 @@ std::string cache_image_color(const float color[4])
return file_path;
}
static void mark_point_instancer_prototypes_as_over(const pxr::UsdStageRefPtr &stage,
const pxr::SdfPath &wrapper_path,
std::set<pxr::SdfPath> &visited)
{
pxr::UsdPrim wrapper_prim = stage->GetPrimAtPath(wrapper_path);
if (!wrapper_prim || !wrapper_prim.IsValid()) {
return;
}
std::string real_path_str;
for (const pxr::SdfPrimSpecHandle &primSpec : wrapper_prim.GetPrimStack()) {
if (!primSpec || !primSpec->HasReferences()) {
continue;
}
for (const pxr::SdfReference &ref : primSpec->GetReferenceList().GetPrependedItems()) {
if (ref.GetAssetPath().empty() && !ref.GetPrimPath().IsEmpty()) {
real_path_str = ref.GetPrimPath().GetString();
break;
}
}
if (!real_path_str.empty()) {
break;
}
}
if (real_path_str.empty()) {
CLOG_WARN(&LOG, "No prototype reference found for: %s", wrapper_path.GetText());
return;
}
const pxr::SdfPath real_path(real_path_str);
pxr::UsdPrim proto_prim = stage->GetPrimAtPath(real_path);
if (visited.count(real_path)) {
return;
}
visited.insert(real_path);
if (!proto_prim || !proto_prim.IsValid()) {
CLOG_WARN(&LOG, "Referenced prototype not found at: %s", real_path.GetText());
return;
}
proto_prim.SetSpecifier(pxr::SdfSpecifierOver);
std::string doc_message = fmt::format(
"This prim is used as a prototype by the PointInstancer \"{}\" so we override the def "
"with an \"over\" so that it isn't imaged in the scene, but is available as a prototype "
"that can be referenced.",
wrapper_prim.GetName().GetString());
proto_prim.SetDocumentation(doc_message);
if (wrapper_prim.IsA<pxr::UsdGeomPointInstancer>()) {
pxr::UsdGeomPointInstancer nested_instancer(wrapper_prim);
pxr::SdfPathVector nested_targets;
if (nested_instancer.GetPrototypesRel().GetTargets(&nested_targets)) {
for (const pxr::SdfPath &nested_wrapper_path : nested_targets) {
mark_point_instancer_prototypes_as_over(stage, nested_wrapper_path, visited);
}
}
}
}
pxr::UsdStageRefPtr export_to_stage(const USDExportParams &params,
Depsgraph *depsgraph,
const char *filepath)
@@ -566,6 +633,22 @@ static void export_startjob(void *customdata, wmJobWorkerStatus *worker_status)
return;
}
/* Traverse the point instancer to make sure the prototype referenced by nested point instancers
* are also marked as over. */
std::set<pxr::SdfPath> visited;
for (const pxr::UsdPrim &prim : usd_stage->Traverse()) {
if (!prim.IsA<pxr::UsdGeomPointInstancer>()) {
continue;
}
pxr::UsdGeomPointInstancer instancer(prim);
pxr::SdfPathVector targets;
if (instancer.GetPrototypesRel().GetTargets(&targets)) {
for (const pxr::SdfPath &wrapper_path : targets) {
mark_point_instancer_prototypes_as_over(usd_stage, wrapper_path, visited);
}
}
}
usd_stage->GetRootLayer()->Save();
data->export_ok = true;

View File

@@ -35,6 +35,9 @@ struct USDExporterContext {
const USDExportParams &export_params;
std::string export_file_path;
std::function<std::string(Main *, Scene *, Image *, ImageUser *)> export_image_fn;
/** Optional callback for skel/shapekey path registration (used by USDPointInstancerWriter) */
std::function<void(const Object *, const pxr::SdfPath &)> add_skel_mapping_fn;
};
} // namespace blender::io::usd

View File

@@ -17,6 +17,7 @@
#include "usd_writer_light.hh"
#include "usd_writer_mesh.hh"
#include "usd_writer_metaball.hh"
#include "usd_writer_pointinstancer.hh"
#include "usd_writer_points.hh"
#include "usd_writer_text.hh"
#include "usd_writer_transform.hh"
@@ -24,7 +25,9 @@
#include <string>
#include "BKE_lib_id.hh"
#include "BKE_main.hh"
#include "BKE_report.hh"
#include "BLI_assert.h"
@@ -133,13 +136,120 @@ USDExporterContext USDHierarchyIterator::create_usd_export_context(const Hierarc
const std::string export_file_path = root_layer->GetRealPath();
auto get_time_code = [this]() { return this->export_time_; };
return USDExporterContext{
bmain_, depsgraph_, stage_, path, get_time_code, params_, export_file_path};
USDExporterContext exporter_context = USDExporterContext{
bmain_, depsgraph_, stage_, path, get_time_code, params_, export_file_path, nullptr};
/* Provides optional skel mapping hook. Now it's been used in USDPointInstancerWriter for write
* base layer. */
exporter_context.add_skel_mapping_fn = [this](const Object *obj, const pxr::SdfPath &path) {
this->add_usd_skel_export_mapping(obj, path);
};
return exporter_context;
}
void USDHierarchyIterator::determine_point_instancers(const HierarchyContext *context)
{
if (!context) {
return;
}
if (context->object->type == OB_ARMATURE) {
return;
}
if (context->is_point_instancer()) {
/* Mark the point instancer's children as a point instance. */
USDExporterContext usd_export_context = create_usd_export_context(context);
ExportChildren *children = graph_children(context);
bool is_referencing_self = false;
pxr::SdfPath instancer_path;
if (strlen(params_.root_prim_path) != 0) {
instancer_path = pxr::SdfPath(std::string(params_.root_prim_path) + context->export_path);
}
else {
instancer_path = pxr::SdfPath(context->export_path);
}
if (children != nullptr) {
for (HierarchyContext *child_context : *children) {
if (!child_context->original_export_path.empty()) {
const pxr::SdfPath parent_export_path(context->export_path);
const pxr::SdfPath children_original_export_path(child_context->original_export_path);
/* Detect if the parent is referencing itself via a prototype. */
if (parent_export_path.HasPrefix(children_original_export_path)) {
is_referencing_self = true;
break;
}
}
pxr::SdfPath prototype_path;
if (child_context->is_instance() && child_context->duplicator != nullptr) {
/* When the current child context is point instancer's instance, use reference path
* (original_export_path) as the prototype path. */
if (strlen(params_.root_prim_path) != 0) {
prototype_path = pxr::SdfPath(std::string(params_.root_prim_path) +
child_context->original_export_path);
}
else {
prototype_path = pxr::SdfPath(child_context->original_export_path);
}
prototype_paths[instancer_path].insert(
std::make_pair(prototype_path, child_context->object));
child_context->is_point_instance = true;
}
else {
/* When the current child context is point instancer's prototype, use its own export path
* (export_path) as the prototype path. */
if (strlen(params_.root_prim_path) != 0) {
prototype_path = pxr::SdfPath(std::string(params_.root_prim_path) +
child_context->export_path);
}
else {
prototype_path = pxr::SdfPath(child_context->export_path);
}
prototype_paths[instancer_path].insert(
std::make_pair(prototype_path, child_context->object));
child_context->is_point_proto = true;
}
}
}
/* MARK: If the "Instance on Points" node uses an Object as a prototype,
* but the "Object Info" node has not enabled the "As Instance" option,
* then the generated reference path is incorrect and refers to itself. */
if (is_referencing_self) {
BKE_reportf(
params_.worker_status->reports,
RPT_WARNING,
"One or more objects used as prototypes in 'Instance on Points' nodes either do not "
"have 'As Instance' enabled in their 'Object Info' nodes, or the prototype is the "
"base geometry input itself. Both cases prevent valid point instancer export. If it's "
"the former, enable 'As Instance' to avoid incorrect self-referencing.");
prototype_paths[instancer_path].clear();
for (HierarchyContext *child_context : *children) {
child_context->is_point_instance = false;
child_context->is_point_proto = false;
}
}
}
}
AbstractHierarchyWriter *USDHierarchyIterator::create_transform_writer(
const HierarchyContext *context)
{
/* transform writer is always called before data writers, so determin if the Xform's children is
* a point instancer before writing data */
if (params_.use_instancing) {
determine_point_instancers(context);
}
return new USDTransformWriter(create_usd_export_context(context));
}
@@ -147,11 +257,24 @@ AbstractHierarchyWriter *USDHierarchyIterator::create_data_writer(const Hierarch
{
USDExporterContext usd_export_context = create_usd_export_context(context);
USDAbstractWriter *data_writer = nullptr;
std::set<std::pair<pxr::SdfPath, Object *>> proto_paths =
prototype_paths[usd_export_context.usd_path.GetParentPath()];
switch (context->object->type) {
case OB_MESH:
if (usd_export_context.export_params.export_meshes) {
data_writer = new USDMeshWriter(usd_export_context);
if (params_.use_instancing && context->is_point_instancer() && !proto_paths.empty()) {
USDExporterContext mesh_context = create_point_instancer_context(context,
usd_export_context);
std::unique_ptr<USDMeshWriter> mesh_writer = std::make_unique<USDMeshWriter>(
mesh_context);
data_writer = new USDPointInstancerWriter(
usd_export_context, proto_paths, std::move(mesh_writer));
}
else {
data_writer = new USDMeshWriter(usd_export_context);
}
}
else {
return nullptr;
@@ -182,7 +305,18 @@ AbstractHierarchyWriter *USDHierarchyIterator::create_data_writer(const Hierarch
case OB_CURVES_LEGACY:
case OB_CURVES:
if (usd_export_context.export_params.export_curves) {
data_writer = new USDCurvesWriter(usd_export_context);
if (params_.use_instancing && context->is_point_instancer() && !proto_paths.empty()) {
USDExporterContext curves_context = create_point_instancer_context(context,
usd_export_context);
std::unique_ptr<USDCurvesWriter> curves_writer = std::make_unique<USDCurvesWriter>(
curves_context);
data_writer = new USDPointInstancerWriter(
usd_export_context, proto_paths, std::move(curves_writer));
}
else {
data_writer = new USDCurvesWriter(usd_export_context);
}
}
else {
return nullptr;
@@ -206,7 +340,18 @@ AbstractHierarchyWriter *USDHierarchyIterator::create_data_writer(const Hierarch
break;
case OB_POINTCLOUD:
if (usd_export_context.export_params.export_points) {
data_writer = new USDPointsWriter(usd_export_context);
if (params_.use_instancing && context->is_point_instancer() && !proto_paths.empty()) {
USDExporterContext point_cloud_context = create_point_instancer_context(
context, usd_export_context);
std::unique_ptr<USDPointsWriter> point_cloud_writer = std::make_unique<USDPointsWriter>(
point_cloud_context);
data_writer = new USDPointInstancerWriter(
usd_export_context, proto_paths, std::move(point_cloud_writer));
}
else {
data_writer = new USDPointsWriter(usd_export_context);
}
}
else {
return nullptr;
@@ -229,7 +374,7 @@ AbstractHierarchyWriter *USDHierarchyIterator::create_data_writer(const Hierarch
return nullptr;
}
if (!data_writer->is_supported(context)) {
if (data_writer && !data_writer->is_supported(context)) {
delete data_writer;
return nullptr;
}
@@ -286,4 +431,21 @@ void USDHierarchyIterator::add_usd_skel_export_mapping(const Object *obj, const
}
}
USDExporterContext USDHierarchyIterator::create_point_instancer_context(
const HierarchyContext *context, const USDExporterContext &usd_export_context)
{
BLI_assert(context && context->object);
std::string base_name = std::string(BKE_id_name(context->object->id)).append("_base");
std::string safe_name = make_safe_name(base_name,
usd_export_context.export_params.allow_unicode);
pxr::SdfPath base_path = usd_export_context.usd_path.GetParentPath().AppendChild(
pxr::TfToken(safe_name));
USDExporterContext new_context = usd_export_context;
*const_cast<pxr::SdfPath *>(&new_context.usd_path) = base_path;
return new_context;
}
} // namespace blender::io::usd

View File

@@ -33,6 +33,10 @@ class USDHierarchyIterator : public AbstractHierarchyIterator {
ObjExportMap skinned_mesh_export_map_;
ObjExportMap shape_key_mesh_export_map_;
/* prototype_paths[instancer path] = [(proto_path_1, proto_object_1), (proto_path_2,
* proto_object_2)...] */
std::map<pxr::SdfPath, std::set<std::pair<pxr::SdfPath, Object *>>> prototype_paths;
public:
USDHierarchyIterator(Main *bmain,
Depsgraph *depsgraph,
@@ -47,6 +51,7 @@ class USDHierarchyIterator : public AbstractHierarchyIterator {
protected:
bool mark_as_weak_export(const Object *object) const override;
void determine_point_instancers(const HierarchyContext *context);
AbstractHierarchyWriter *create_transform_writer(const HierarchyContext *context) override;
AbstractHierarchyWriter *create_data_writer(const HierarchyContext *context) override;
@@ -60,6 +65,8 @@ class USDHierarchyIterator : public AbstractHierarchyIterator {
private:
USDExporterContext create_usd_export_context(const HierarchyContext *context);
USDExporterContext create_point_instancer_context(const HierarchyContext *context,
const USDExporterContext &usd_export_context);
void add_usd_skel_export_mapping(const Object *obj, const pxr::SdfPath &usd_path);
};

View File

@@ -30,5 +30,4 @@ std::string make_safe_name(StringRef name, bool allow_unicode);
* \return A valid, and unique, USD `SdfPath`
*/
pxr::SdfPath get_unique_path(pxr::UsdStageRefPtr stage, const std::string &path);
} // namespace blender::io::usd

View File

@@ -0,0 +1,624 @@
/* SPDX-FileCopyrightText: 2025 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
#include "usd_writer_pointinstancer.hh"
#include "usd_attribute_utils.hh"
#include "usd_utils.hh"
#include "usd_writer_curves.hh"
#include "usd_writer_mesh.hh"
#include "usd_writer_points.hh"
#include "BKE_anonymous_attribute_id.hh"
#include "BKE_collection.hh"
#include "BKE_geometry_set.hh"
#include "BKE_geometry_set_instances.hh"
#include "BKE_instances.hh"
#include "BKE_lib_id.hh"
#include "BKE_node.hh"
#include "BKE_node_legacy_types.hh"
#include "BKE_node_runtime.hh"
#include "BKE_object.hh"
#include "BKE_report.hh"
#include "BLI_math_euler.hh"
#include "BLI_math_matrix.hh"
#include "DNA_collection_types.h"
#include "DNA_layer_types.h"
#include "DNA_mesh_types.h"
#include "DNA_node_types.h"
#include "DNA_object_types.h"
#include "DNA_pointcloud_types.h"
#include <pxr/base/gf/math.h>
#include <pxr/base/gf/matrix3f.h>
#include <pxr/base/gf/quatd.h>
#include <pxr/base/gf/quatf.h>
#include <pxr/base/gf/range3f.h>
#include <pxr/base/gf/rotation.h>
#include <pxr/base/gf/vec3d.h>
#include <pxr/base/gf/vec3f.h>
#include <pxr/base/vt/array.h>
#include <pxr/usd/usdGeom/pointInstancer.h>
#include <pxr/usd/usdGeom/primvarsAPI.h>
#include <pxr/usd/usdGeom/scope.h>
#include <pxr/usd/usdGeom/xform.h>
#include "DEG_depsgraph_query.hh"
#include "IO_abstract_hierarchy_iterator.h"
#include <fmt/format.h>
namespace blender::io::usd {
USDPointInstancerWriter::USDPointInstancerWriter(
const USDExporterContext &ctx,
std::set<std::pair<pxr::SdfPath, Object *>> &prototype_paths,
std::unique_ptr<USDAbstractWriter> base_writer)
: USDAbstractWriter(ctx),
base_writer_(std::move(base_writer)),
prototype_paths_(prototype_paths)
{
}
void USDPointInstancerWriter::do_write(HierarchyContext &context)
{
/* Write the base data first (e.g., mesh, curves, points) */
if (base_writer_) {
base_writer_->write(context);
if (usd_export_context_.add_skel_mapping_fn &&
(usd_export_context_.export_params.export_armatures ||
usd_export_context_.export_params.export_shapekeys))
{
usd_export_context_.add_skel_mapping_fn(context.object, base_writer_->usd_path());
}
}
const pxr::UsdStageRefPtr stage = usd_export_context_.stage;
Object *object_eval = context.object;
bke::GeometrySet instance_geometry_set = bke::object_get_evaluated_geometry_set(*object_eval);
const bke::GeometryComponent *component = instance_geometry_set.get_component(
bke::GeometryComponent::Type::Instance);
const bke::Instances *instances = static_cast<const bke::InstancesComponent &>(*component).get();
int instance_num = instances->instances_num();
const pxr::SdfPath &usd_path = usd_export_context_.usd_path;
const pxr::UsdGeomPointInstancer usd_instancer = pxr::UsdGeomPointInstancer::Define(stage,
usd_path);
const pxr::UsdTimeCode timecode = get_export_time_code();
Span<float4x4> transforms = instances->transforms();
BLI_assert(transforms.size() >= instance_num);
if (transforms.size() != instance_num) {
BKE_reportf(this->reports(),
RPT_ERROR,
"Instances number '%d' doesn't match transforms size '%d'",
instance_num,
int(transforms.size()));
return;
}
/* evaluated positions */
pxr::UsdAttribute position_attr = usd_instancer.CreatePositionsAttr();
pxr::VtArray<pxr::GfVec3f> positions(instance_num);
for (int i = 0; i < instance_num; i++) {
const float3 &pos = transforms[i].location();
positions[i] = pxr::GfVec3f(pos.x, pos.y, pos.z);
}
blender::io::usd::set_attribute(position_attr, positions, timecode, usd_value_writer_);
/* orientations */
pxr::UsdAttribute orientations_attr = usd_instancer.CreateOrientationsAttr();
pxr::VtArray<pxr::GfQuath> orientation(instance_num);
for (int i = 0; i < instance_num; i++) {
const float3 euler = float3(math::to_euler(math::normalize(transforms[i])));
const math::Quaternion quat = math::to_quaternion(math::EulerXYZ(euler));
orientation[i] = pxr::GfQuath(quat.w, pxr::GfVec3h(quat.x, quat.y, quat.z));
}
blender::io::usd::set_attribute(orientations_attr, orientation, timecode, usd_value_writer_);
/* scales */
pxr::UsdAttribute scales_attr = usd_instancer.CreateScalesAttr();
pxr::VtArray<pxr::GfVec3f> scales(instance_num);
for (int i = 0; i < instance_num; i++) {
const MatBase<float, 4, 4> &mat = transforms[i];
blender::float3 scale_vec = math::to_scale<true>(mat);
scales[i] = pxr::GfVec3f(scale_vec.x, scale_vec.y, scale_vec.z);
}
blender::io::usd::set_attribute(scales_attr, scales, timecode, usd_value_writer_);
/* other attr */
bke::AttributeAccessor attributes_eval = *component->attributes();
attributes_eval.foreach_attribute([&](const bke::AttributeIter &iter) {
if (iter.name[0] == '.' || blender::bke::attribute_name_is_anonymous(iter.name) ||
ELEM(iter.name, "instance_transform") || ELEM(iter.name, "scale") ||
ELEM(iter.name, "orientation") || ELEM(iter.name, "mask") ||
ELEM(iter.name, "proto_index") || ELEM(iter.name, "id"))
{
return;
}
this->write_attribute_data(iter, usd_instancer, timecode);
});
/* prototypes relations */
const pxr::SdfPath protoParentPath = usd_path.AppendChild(pxr::TfToken("Prototypes"));
pxr::UsdPrim prototypesOver = stage->DefinePrim(protoParentPath);
pxr::SdfPathVector proto_wrapper_paths;
std::map<std::string, int> proto_index_map;
std::map<std::string, pxr::SdfPath> proto_path_map;
if (!prototype_paths_.empty() && usd_instancer) {
int iter = 0;
for (const std::pair<pxr::SdfPath, Object *> &entry : prototype_paths_) {
const pxr::SdfPath &source_path = entry.first;
Object *obj = entry.second;
if (source_path.IsEmpty()) {
continue;
}
const pxr::SdfPath proto_path = protoParentPath.AppendChild(
pxr::TfToken(proto_name_ + "_" + std::to_string(iter)));
pxr::UsdPrim prim = stage->DefinePrim(proto_path);
/* To avoid USD error of Unresolved reference prim path, make sure the referenced path
* exists. */
stage->DefinePrim(source_path);
prim.GetReferences().AddReference(pxr::SdfReference("", source_path));
proto_wrapper_paths.push_back(proto_path);
std::string ob_name = BKE_id_name(obj->id);
proto_index_map[ob_name] = iter;
proto_path_map[ob_name] = proto_path;
++iter;
}
usd_instancer.GetPrototypesRel().SetTargets(proto_wrapper_paths);
prototypesOver.GetPrim().SetSpecifier(pxr::SdfSpecifierOver);
stage->GetRootLayer()->Save();
}
/* proto indices */
/* must be the last to populate */
pxr::UsdAttribute proto_indices_attr = usd_instancer.CreateProtoIndicesAttr();
pxr::VtArray<int> proto_indices;
std::vector<std::pair<int, int>> collection_instance_object_count_map;
Span<int> reference_handles = instances->reference_handles();
Span<bke::InstanceReference> references = instances->references();
std::map<std::string, int> final_proto_index_map;
for (int i = 0; i < instance_num; i++) {
bke::InstanceReference reference = references[reference_handles[i]];
process_instance_reference(reference,
i,
proto_index_map,
final_proto_index_map,
proto_path_map,
stage,
proto_indices,
collection_instance_object_count_map);
}
blender::io::usd::set_attribute(proto_indices_attr, proto_indices, timecode, usd_value_writer_);
/* Handle Collection Prototypes */
if (!collection_instance_object_count_map.empty()) {
handle_collection_prototypes(
usd_instancer, timecode, instance_num, collection_instance_object_count_map);
}
/* Clean unused prototype. When finding prototype paths under the context of a point instancer,
* all the prototypes are collected, even those used by lower-level nested child PointInstancers.
* It can happen that different levels in nested PointInstancers share the same prototypes, but
* if not, we need to clean the extra prototypes from the prototype relationship for a cleaner
* USD export. */
compact_prototypes(usd_instancer, timecode, proto_wrapper_paths);
stage->GetRootLayer()->Save();
}
void USDPointInstancerWriter::process_instance_reference(
const bke::InstanceReference &reference,
int instance_index,
std::map<std::string, int> &proto_index_map,
std::map<std::string, int> &final_proto_index_map,
std::map<std::string, pxr::SdfPath> &proto_path_map,
pxr::UsdStageRefPtr stage,
pxr::VtArray<int> &proto_indices,
std::vector<std::pair<int, int>> &collection_instance_object_count_map)
{
switch (reference.type()) {
case bke::InstanceReference::Type::Object: {
Object &object = reference.object();
std::string ob_name = BKE_id_name(object.id);
if (proto_index_map.find(ob_name) != proto_index_map.end()) {
proto_indices.push_back(proto_index_map[ob_name]);
final_proto_index_map[ob_name] = proto_index_map[ob_name];
/* If the reference is Object, clear prototype's local transform to identity to avoid
* double transforms. The PointInstancer will fully control instance placement. */
override_transform(stage, proto_path_map[ob_name], float4x4::identity());
}
break;
}
case bke::InstanceReference::Type::Collection: {
Collection &collection = reference.collection();
int object_num = 0;
FOREACH_COLLECTION_OBJECT_RECURSIVE_BEGIN (&collection, object) {
std::string ob_name = BKE_id_name(object->id);
if (proto_index_map.find(ob_name) != proto_index_map.end()) {
object_num += 1;
proto_indices.push_back(proto_index_map[ob_name]);
final_proto_index_map[ob_name] = proto_index_map[ob_name];
}
}
FOREACH_COLLECTION_OBJECT_RECURSIVE_END;
collection_instance_object_count_map.push_back(std::make_pair(instance_index, object_num));
break;
}
case bke::InstanceReference::Type::GeometrySet: {
bke::GeometrySet geometry_set = reference.geometry_set();
std::string set_name = geometry_set.name;
if (proto_index_map.find(set_name) != proto_index_map.end()) {
proto_indices.push_back(proto_index_map[set_name]);
final_proto_index_map[set_name] = proto_index_map[set_name];
}
Vector<const bke::GeometryComponent *> components = geometry_set.get_components();
for (const bke::GeometryComponent *comp : components) {
if (const bke::Instances *instances =
static_cast<const bke::InstancesComponent &>(*comp).get())
{
Span<int> ref_handles = instances->reference_handles();
Span<bke::InstanceReference> refs = instances->references();
/* If the top-level GeometrySet is not in proto_index_map, recursively traverse child
* InstanceReferences to resolve prototype indices. If the name matches proto_index_map,
* skip traversal to avoid duplicates, since GeometrySet names may overlap with object
* names. */
if (proto_index_map.find(set_name) == proto_index_map.end()) {
for (int index = 0; index < ref_handles.size(); ++index) {
const bke::InstanceReference &child_ref = refs[ref_handles[index]];
/* Recursively traverse nested GeometrySets to resolve prototype indices for all
* instances. */
process_instance_reference(child_ref,
instance_index,
proto_index_map,
final_proto_index_map,
proto_path_map,
stage,
proto_indices,
collection_instance_object_count_map);
}
}
/* If the reference is GeometrySet, then override the transform with the transform of the
* Instance inside this Geometryset. */
Span<float4x4> transforms = instances->transforms();
if (transforms.size() == 1) {
if (proto_path_map.find(set_name) != proto_path_map.end()) {
override_transform(stage, proto_path_map[set_name], transforms[0]);
}
}
}
}
break;
}
case bke::InstanceReference::Type::None:
default:
break;
}
}
void USDPointInstancerWriter::compact_prototypes(const pxr::UsdGeomPointInstancer &usd_instancer,
const pxr::UsdTimeCode timecode,
const pxr::SdfPathVector &proto_paths)
{
pxr::UsdAttribute proto_indices_attr = usd_instancer.GetProtoIndicesAttr();
pxr::VtArray<int> proto_indices;
if (!proto_indices_attr.Get(&proto_indices, timecode)) {
return;
}
///* Find actually used prototype indices */
std::set<int> used_proto_indices(proto_indices.begin(), proto_indices.end());
std::map<int, int> remap;
int new_index = 0;
for (int i = 0; i < proto_paths.size(); ++i) {
if (used_proto_indices.count(i)) {
remap[i] = new_index++;
}
}
///* Remap protoIndices */
for (int &idx : proto_indices) {
idx = remap[idx];
}
proto_indices_attr.Set(proto_indices, timecode);
pxr::SdfPathVector compact_proto_paths;
for (int i = 0; i < proto_paths.size(); ++i) {
if (used_proto_indices.count(i)) {
compact_proto_paths.push_back(proto_paths[i]);
}
}
usd_instancer.GetPrototypesRel().SetTargets(compact_proto_paths);
}
void USDPointInstancerWriter::override_transform(pxr::UsdStageRefPtr stage,
const pxr::SdfPath &proto_path,
const float4x4 &transform)
{
// Extract translation
const float3 &pos = transform.location();
pxr::GfVec3d override_position(pos.x, pos.y, pos.z);
// Extract rotation
const float3 euler = float3(math::to_euler(math::normalize(transform)));
pxr::GfVec3f override_rotation(euler.x, euler.y, euler.z);
// Extract scale
const float3 scale_vec = math::to_scale<true>(transform);
pxr::GfVec3f override_scale(scale_vec.x, scale_vec.y, scale_vec.z);
pxr::UsdPrim prim = stage->GetPrimAtPath(proto_path);
if (!prim) {
return;
}
pxr::UsdGeomXformable xformable(prim);
xformable.ClearXformOpOrder();
xformable.AddTranslateOp().Set(override_position);
xformable.AddRotateXYZOp().Set(override_rotation);
xformable.AddScaleOp().Set(override_scale);
}
template<typename T>
static pxr::VtArray<T> DuplicateArray(const pxr::VtArray<T> &original, size_t copies)
{
pxr::VtArray<T> newArray;
size_t originalSize = original.size();
newArray.resize(originalSize * copies);
for (size_t i = 0; i < copies; ++i) {
std::copy(original.begin(), original.end(), newArray.begin() + i * originalSize);
}
return newArray;
}
template<typename T, typename GetterFunc, typename CreatorFunc>
static void DuplicatePerInstanceAttribute(const GetterFunc &getter,
const CreatorFunc &creator,
size_t copies,
const pxr::UsdTimeCode &timecode)
{
pxr::VtArray<T> values;
if (getter().Get(&values, timecode) && !values.empty()) {
auto newValues = DuplicateArray(values, copies);
creator().Set(newValues, timecode);
}
}
template<typename T, typename GetterFunc, typename CreatorFunc>
static void ExpandAttributePerInstance(const GetterFunc &getter,
const CreatorFunc &creator,
const std::vector<std::pair<int, int>> &instance_object_map,
const pxr::UsdTimeCode &timecode)
{
// MARK: Handle Collection Prototypes
// -----------------------------------------------------------------------------
// In Blender, a Collection is not an actual Object type. When exporting, the iterator
// flattens the Collection hierarchy, treating each object inside the Collection as an
// individual prototype. However, all these prototypes share the same instance attributes
// (e.g., positions, orientations, scales).
//
// To ensure correct arrangement, reading, and drawing in OpenUSD, we need to explicitly
// duplicate the instance attributes across all prototypes derived from the Collection.
pxr::VtArray<T> original_values;
if (!getter().Get(&original_values, timecode) || original_values.empty()) {
return;
}
pxr::VtArray<T> expanded_values;
for (const auto &[instance_index, object_count] : instance_object_map) {
if (instance_index < static_cast<int>(original_values.size())) {
for (int i = 0; i < object_count; ++i) {
expanded_values.push_back(original_values[instance_index]);
}
}
}
creator().Set(expanded_values, timecode);
}
void USDPointInstancerWriter::handle_collection_prototypes(
const pxr::UsdGeomPointInstancer &usd_instancer,
const pxr::UsdTimeCode timecode,
int instance_num,
const std::vector<std::pair<int, int>> &collection_instance_object_count_map)
{
// Duplicate attributes
if (usd_instancer.GetPositionsAttr().HasAuthoredValue()) {
ExpandAttributePerInstance<pxr::GfVec3f>([&]() { return usd_instancer.GetPositionsAttr(); },
[&]() { return usd_instancer.CreatePositionsAttr(); },
collection_instance_object_count_map,
timecode);
}
if (usd_instancer.GetOrientationsAttr().HasAuthoredValue()) {
ExpandAttributePerInstance<pxr::GfQuath>(
[&]() { return usd_instancer.GetOrientationsAttr(); },
[&]() { return usd_instancer.CreateOrientationsAttr(); },
collection_instance_object_count_map,
timecode);
}
if (usd_instancer.GetScalesAttr().HasAuthoredValue()) {
ExpandAttributePerInstance<pxr::GfVec3f>([&]() { return usd_instancer.GetScalesAttr(); },
[&]() { return usd_instancer.CreateScalesAttr(); },
collection_instance_object_count_map,
timecode);
}
if (usd_instancer.GetVelocitiesAttr().HasAuthoredValue()) {
ExpandAttributePerInstance<pxr::GfVec3f>(
[&]() { return usd_instancer.GetVelocitiesAttr(); },
[&]() { return usd_instancer.CreateVelocitiesAttr(); },
collection_instance_object_count_map,
timecode);
}
if (usd_instancer.GetAngularVelocitiesAttr().HasAuthoredValue()) {
ExpandAttributePerInstance<pxr::GfVec3f>(
[&]() { return usd_instancer.GetAngularVelocitiesAttr(); },
[&]() { return usd_instancer.CreateAngularVelocitiesAttr(); },
collection_instance_object_count_map,
timecode);
}
// Duplicate Primvars
const pxr::UsdGeomPrimvarsAPI primvars_api(usd_instancer);
std::vector<pxr::UsdGeomPrimvar> primvars = primvars_api.GetPrimvars();
for (const pxr::UsdGeomPrimvar &primvar : primvars) {
if (!primvar.HasAuthoredValue()) {
continue;
}
const pxr::TfToken name = primvar.GetPrimvarName();
const pxr::SdfValueTypeName type = primvar.GetTypeName();
const pxr::TfToken interp = primvar.GetInterpolation();
auto create = [&]() { return primvars_api.CreatePrimvar(name, type, interp); };
if (type == pxr::SdfValueTypeNames->FloatArray) {
ExpandAttributePerInstance<float>(
[&]() { return primvar; }, create, collection_instance_object_count_map, timecode);
}
else if (type == pxr::SdfValueTypeNames->IntArray) {
ExpandAttributePerInstance<int>(
[&]() { return primvar; }, create, collection_instance_object_count_map, timecode);
}
else if (type == pxr::SdfValueTypeNames->UCharArray) {
ExpandAttributePerInstance<unsigned char>(
[&]() { return primvar; }, create, collection_instance_object_count_map, timecode);
}
else if (type == pxr::SdfValueTypeNames->Float2Array) {
ExpandAttributePerInstance<pxr::GfVec2f>(
[&]() { return primvar; }, create, collection_instance_object_count_map, timecode);
}
else if (type == pxr::SdfValueTypeNames->Float3Array ||
type == pxr::SdfValueTypeNames->Color3fArray ||
type == pxr::SdfValueTypeNames->Color4fArray)
{
ExpandAttributePerInstance<pxr::GfVec3f>(
[&]() { return primvar; }, create, collection_instance_object_count_map, timecode);
}
else if (type == pxr::SdfValueTypeNames->QuatfArray) {
ExpandAttributePerInstance<pxr::GfQuatf>(
[&]() { return primvar; }, create, collection_instance_object_count_map, timecode);
}
else if (type == pxr::SdfValueTypeNames->BoolArray) {
ExpandAttributePerInstance<bool>(
[&]() { return primvar; }, create, collection_instance_object_count_map, timecode);
}
else if (type == pxr::SdfValueTypeNames->StringArray) {
ExpandAttributePerInstance<std::string>(
[&]() { return primvar; }, create, collection_instance_object_count_map, timecode);
}
}
// MARK: Ensure Instance Indices Exist
// -----------------------------------------------------------------------------
// If the PointInstancer has no authored instance indices, manually generate a default
// sequence of indices to ensure the PointInstancer functions correctly in OpenUSD.
// This guarantees that each instance can correctly reference its prototype.
pxr::UsdAttribute proto_indices_attr = usd_instancer.GetProtoIndicesAttr();
if (!proto_indices_attr.HasAuthoredValue()) {
std::vector<int> index;
for (int i = 0; i < prototype_paths_.size(); i++) {
std::vector<int> current_proto_index(instance_num, i);
index.insert(index.end(), current_proto_index.begin(), current_proto_index.end());
}
proto_indices_attr.Set(pxr::VtArray<int>(index.begin(), index.end()));
}
}
void USDPointInstancerWriter::write_attribute_data(const bke::AttributeIter &attr,
const pxr::UsdGeomPointInstancer &usd_instancer,
const pxr::UsdTimeCode timecode)
{
const std::optional<pxr::SdfValueTypeName> pv_type = convert_blender_type_to_usd(attr.data_type);
if (!pv_type) {
BKE_reportf(this->reports(),
RPT_WARNING,
"Attribute '%s' (Blender domain %d, type %d) cannot be converted to USD",
attr.name.c_str(),
int(attr.domain),
attr.data_type);
return;
}
const GVArray attribute = *attr.get();
if (attribute.is_empty()) {
return;
}
if (attr.name == "mask") {
pxr::UsdAttribute idsAttr = usd_instancer.GetIdsAttr();
if (!idsAttr) {
idsAttr = usd_instancer.CreateIdsAttr();
}
pxr::UsdAttribute invisibleIdsAttr = usd_instancer.GetInvisibleIdsAttr();
if (!invisibleIdsAttr) {
invisibleIdsAttr = usd_instancer.CreateInvisibleIdsAttr();
}
const GVArray attribute = *attr.get();
/// Retrieve mask values, store as int8_t to avoid std::vector<bool>.data() issues
std::vector<int8_t> mask_values(attribute.size());
attribute.materialize(IndexMask(attribute.size()), mask_values.data());
pxr::VtArray<int64_t> ids;
pxr::VtArray<int64_t> invisibleIds;
ids.reserve(mask_values.size());
for (int64_t i = 0; i < static_cast<int64_t>(mask_values.size()); ++i) {
ids.push_back(i);
if (mask_values[i] == 0) {
invisibleIds.push_back(i);
}
}
blender::io::usd::set_attribute(idsAttr, ids, timecode, usd_value_writer_);
blender::io::usd::set_attribute(invisibleIdsAttr, invisibleIds, timecode, usd_value_writer_);
}
const pxr::TfToken pv_name(
make_safe_name(attr.name, usd_export_context_.export_params.allow_unicode));
const pxr::UsdGeomPrimvarsAPI pv_api = pxr::UsdGeomPrimvarsAPI(usd_instancer);
pxr::UsdGeomPrimvar pv_attr = pv_api.CreatePrimvar(pv_name, *pv_type);
copy_blender_attribute_to_primvar(
attribute, attr.data_type, timecode, pv_attr, usd_value_writer_);
}
} // namespace blender::io::usd

View File

@@ -0,0 +1,62 @@
/* SPDX-FileCopyrightText: 2025 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
#pragma once
#include "BLI_math_quaternion_types.hh"
#include "usd_attribute_utils.hh"
#include "usd_writer_abstract.hh"
#include <pxr/usd/usdGeom/pointInstancer.h>
#include <pxr/usd/usdGeom/points.h>
#include <vector>
struct USDExporterContext;
namespace blender::io::usd {
class USDPointInstancerWriter final : public USDAbstractWriter {
public:
USDPointInstancerWriter(const USDExporterContext &ctx,
std::set<std::pair<pxr::SdfPath, Object *>> &prototype_paths,
std::unique_ptr<USDAbstractWriter> base_writer);
~USDPointInstancerWriter() final = default;
protected:
virtual void do_write(HierarchyContext &context) override;
private:
std::unique_ptr<USDAbstractWriter> base_writer_;
std::set<std::pair<pxr::SdfPath, Object *>> prototype_paths_;
const std::string proto_name_ = "Prototype";
void write_attribute_data(const bke::AttributeIter &attr,
const pxr::UsdGeomPointInstancer &usd_instancer,
const pxr::UsdTimeCode timecode);
void process_instance_reference(
const bke::InstanceReference &reference,
int instance_index,
std::map<std::string, int> &proto_index_map,
std::map<std::string, int> &final_proto_index_map,
std::map<std::string, pxr::SdfPath> &proto_path_map,
pxr::UsdStageRefPtr stage,
pxr::VtArray<int> &proto_indices,
std::vector<std::pair<int, int>> &collection_instance_object_count_map);
void compact_prototypes(const pxr::UsdGeomPointInstancer &usd_instancer,
const pxr::UsdTimeCode timecode,
const pxr::SdfPathVector &proto_paths);
void override_transform(pxr::UsdStageRefPtr stage,
const pxr::SdfPath &proto_path,
const float4x4 &transform);
void handle_collection_prototypes(
const pxr::UsdGeomPointInstancer &usd_instancer,
const pxr::UsdTimeCode timecode,
int instance_num,
const std::vector<std::pair<int, int>> &collection_instance_object_count_map);
};
} // namespace blender::io::usd

View File

@@ -61,6 +61,10 @@ bool USDTransformWriter::should_apply_root_xform(const HierarchyContext &context
void USDTransformWriter::do_write(HierarchyContext &context)
{
if (context.is_point_proto || context.is_point_instance) {
return;
}
constexpr float UNIT_M4[4][4] = {
{1, 0, 0, 0},
{0, 1, 0, 0},

BIN
tests/files/usd/usd_point_instancer_collection_ref.blend (Stored with Git LFS) Normal file

Binary file not shown.

BIN
tests/files/usd/usd_point_instancer_nested.blend (Stored with Git LFS) Normal file

Binary file not shown.

BIN
tests/files/usd/usd_point_instancer_object_ref.blend (Stored with Git LFS) Normal file

Binary file not shown.

View File

@@ -1689,6 +1689,95 @@ class USDExportTest(AbstractUSDTest):
self.assertTupleEqual(expected, actual)
def test_point_instancing_export(self):
"""Test exporting scenes that use point instancing."""
def confirm_point_instancing_stats(stage, num_meshes, num_instancers, num_instances, num_prototypes):
mesh_count = 0
instancer_count = 0
instance_count = 0
prototype_count = 0
for prim in stage.TraverseAll():
prim_path = prim.GetPath()
prim_type_name = prim.GetTypeName()
if prim_type_name == "PointInstancer":
point_instancer = UsdGeom.PointInstancer(prim)
if point_instancer:
# get instance count
positions_attr = point_instancer.GetPositionsAttr()
if positions_attr:
positions = positions_attr.Get()
if positions:
instance_count += len(positions)
# get prototype count
prototypes_rel = point_instancer.GetPrototypesRel()
if prototypes_rel:
target_prims = prototypes_rel.GetTargets()
prototype_count += len(target_prims)
# show all prims and types
# output_string = f" Path: {prim_path}, Type: {prim_type_name}"
# print(output_string)
stats = UsdUtils.ComputeUsdStageStats(stage)
mesh_count = stats['primary']['primCountsByType']['Mesh']
instancer_count = stats['primary']['primCountsByType']['PointInstancer']
return mesh_count, instancer_count, instance_count, prototype_count
point_instance_test_scenarios = [
# object reference treated as geometry set
{'input_file': str(self.testdir / "usd_point_instancer_object_ref.blend"),
'output_file': self.tempdir / "usd_export_point_instancer_object_ref.usda",
'mesh_count': 3,
'instancer_count': 1,
'total_instances': 16,
'total_prototypes': 1},
# collection reference from single point instancer
{'input_file': str(self.testdir / "usd_point_instancer_collection_ref.blend"),
'output_file': self.tempdir / "usd_export_point_instancer_collection_ref.usda",
'mesh_count': 5,
'instancer_count': 1,
'total_instances': 32,
'total_prototypes': 2},
# collection references in nested point instancer
{'input_file': str(self.testdir / "usd_point_instancer_nested.blend"),
'output_file': self.tempdir / "usd_export_point_instancer_nested.usda",
'mesh_count': 9,
'instancer_count': 3,
'total_instances': 14,
'total_prototypes': 4},
# object reference coming from a collection with separate children
{'input_file': str(self.testdir / "../render/shader/texture_coordinate_camera.blend"),
'output_file': self.tempdir / "usd_export_point_instancer_separate_children.usda",
'mesh_count': 9,
'instancer_count': 1,
'total_instances': 4,
'total_prototypes': 2}
]
for scenario in point_instance_test_scenarios:
bpy.ops.wm.open_mainfile(filepath=scenario['input_file'])
export_path = scenario['output_file']
self.export_and_validate(
filepath=str(export_path),
use_instancing=True
)
stage = Usd.Stage.Open(str(export_path))
mesh_count, instancer_count, instance_count, proto_count = confirm_point_instancing_stats(
stage, scenario['mesh_count'], scenario['instancer_count'], scenario['total_instances'], scenario['total_prototypes'])
self.assertEqual(scenario['mesh_count'], mesh_count, "Unexpected number of primary meshes")
self.assertEqual(scenario['instancer_count'], instancer_count, "Unexpected number of point instancers")
self.assertEqual(scenario['total_instances'], instance_count, "Unexpected number of total instances")
self.assertEqual(scenario['total_prototypes'], proto_count, "Unexpected number of total prototypes")
class USDHookBase:
instructions = {}