USD: Support additional animated Basis Curves data during import/export

Export
Like we do for Mesh and PointCloud, export any "velocity" attribute on
the Point domain as native USD "velocities". While testing, a few
additional blender-internal attributes were discovered being exported
which are now excluded during export.

Import
Add the cache modifier as appropriate when we detect that UsdBasisCurve
data is animated. This includes time-varying positions, widths,
velocities, and general attribute values. Before this PR, only the
positions were considered. And like Export, the native USD "velocities"
attribute is now processed.

Adds test coverage as well.

Pull Request: https://projects.blender.org/blender/blender/pulls/133027
This commit is contained in:
Jesse Yurkovich
2025-01-15 23:29:42 +01:00
committed by Jesse Yurkovich
parent 7456709b69
commit 49ae7ffc9c
7 changed files with 202 additions and 41 deletions

View File

@@ -141,15 +141,33 @@ void USDCurvesReader::create_object(Main *bmain, const double /*motionSampleTime
void USDCurvesReader::read_object_data(Main *bmain, double motionSampleTime)
{
Curves *cu = (Curves *)object_->data;
read_curve_sample(cu, motionSampleTime);
this->read_curve_sample(cu, motionSampleTime);
if (is_animated()) {
add_cache_modifier();
if (this->is_animated()) {
this->add_cache_modifier();
}
USDXformReader::read_object_data(bmain, motionSampleTime);
}
void USDCurvesReader::read_velocities(bke::CurvesGeometry &curves,
const pxr::UsdGeomCurves &usd_curves,
const double motionSampleTime) const
{
pxr::VtVec3fArray velocities;
usd_curves.GetVelocitiesAttr().Get(&velocities, motionSampleTime);
if (!velocities.empty()) {
bke::MutableAttributeAccessor attributes = curves.attributes_for_write();
bke::SpanAttributeWriter<float3> velocity =
attributes.lookup_or_add_for_write_only_span<float3>("velocity", bke::AttrDomain::Point);
Span<pxr::GfVec3f> usd_data(velocities.data(), velocities.size());
velocity.span.copy_from(usd_data.cast<float3>());
velocity.finish();
}
}
void USDCurvesReader::read_custom_data(bke::CurvesGeometry &curves,
const double motionSampleTime) const
{
@@ -196,7 +214,21 @@ void USDCurvesReader::read_geometry(bke::GeometrySet &geometry_set,
bool USDBasisCurvesReader::is_animated() const
{
return curve_prim_.GetPointsAttr().ValueMightBeTimeVarying();
if (curve_prim_.GetPointsAttr().ValueMightBeTimeVarying() ||
curve_prim_.GetWidthsAttr().ValueMightBeTimeVarying() ||
curve_prim_.GetVelocitiesAttr().ValueMightBeTimeVarying())
{
return true;
}
pxr::UsdGeomPrimvarsAPI pv_api(curve_prim_);
for (const pxr::UsdGeomPrimvar &pv : pv_api.GetPrimvarsWithValues()) {
if (pv.ValueMightBeTimeVarying()) {
return true;
}
}
return false;
}
void USDBasisCurvesReader::read_curve_sample(Curves *curves_id, const double motionSampleTime)
@@ -287,13 +319,10 @@ void USDBasisCurvesReader::read_curve_sample(Curves *curves_id, const double mot
}
if (!usdWidths.empty()) {
bke::MutableAttributeAccessor attributes = curves.attributes_for_write();
bke::SpanAttributeWriter<float> radii = attributes.lookup_or_add_for_write_only_span<float>(
"radius", bke::AttrDomain::Point);
MutableSpan<float> radii = curves.radius_for_write();
pxr::TfToken widths_interp = curve_prim_.GetWidthsInterpolation();
if (widths_interp == pxr::UsdGeomTokens->constant) {
radii.span.fill(usdWidths[0] / 2.0f);
radii.fill(usdWidths[0] / 2.0f);
}
else {
const bool is_bezier_vertex_interp = (type == pxr::UsdGeomTokens->cubic &&
@@ -310,7 +339,7 @@ void USDBasisCurvesReader::read_curve_sample(Curves *curves_id, const double mot
int cp_offset = 0;
for (const int cp : IndexRange(point_count)) {
radii.span[point_offset + cp] = usdWidths[usd_point_offset + cp_offset] / 2.0f;
radii[point_offset + cp] = usdWidths[usd_point_offset + cp_offset] / 2.0f;
cp_offset += 3;
}
@@ -320,15 +349,14 @@ void USDBasisCurvesReader::read_curve_sample(Curves *curves_id, const double mot
}
else {
for (const int i_point : curves.points_range()) {
radii.span[i_point] = usdWidths[i_point] / 2.0f;
radii[i_point] = usdWidths[i_point] / 2.0f;
}
}
}
radii.finish();
}
read_custom_data(curves, motionSampleTime);
this->read_velocities(curves, curve_prim_, motionSampleTime);
this->read_custom_data(curves, motionSampleTime);
}
} // namespace blender::io::usd

View File

@@ -37,10 +37,13 @@ class USDCurvesReader : public USDGeomReader {
USDMeshReadParams params,
const char **r_err_str) override;
void read_velocities(bke::CurvesGeometry &curves,
const pxr::UsdGeomCurves &usd_curves,
const double motionSampleTime) const;
void read_custom_data(bke::CurvesGeometry &curves, const double motionSampleTime) const;
virtual void read_curve_sample(Curves *curves_id, double motionSampleTime) = 0;
virtual bool is_animated() const = 0;
virtual void read_curve_sample(Curves *curves_id, double motionSampleTime) = 0;
};
class USDBasisCurvesReader : public USDCurvesReader {
@@ -60,8 +63,8 @@ class USDBasisCurvesReader : public USDCurvesReader {
return bool(curve_prim_);
}
void read_curve_sample(Curves *curves_id, double motionSampleTime) override;
bool is_animated() const override;
void read_curve_sample(Curves *curves_id, double motionSampleTime) override;
};
} // namespace blender::io::usd

View File

@@ -2,8 +2,8 @@
*
* SPDX-License-Identifier: GPL-2.0-or-later */
#include <cstdint>
#include <numeric>
#include <string>
#include <pxr/usd/usdGeom/basisCurves.h>
#include <pxr/usd/usdGeom/curves.h>
@@ -21,6 +21,7 @@
#include "BLI_array_utils.hh"
#include "BLI_generic_virtual_array.hh"
#include "BLI_set.hh"
#include "BLI_span.hh"
#include "BLI_virtual_array.hh"
@@ -73,14 +74,11 @@ pxr::UsdGeomBasisCurves USDCurvesWriter::DefineUsdGeomBasisCurves(pxr::VtValue c
static void populate_curve_widths(const bke::CurvesGeometry &curves, pxr::VtArray<float> &widths)
{
const bke::AttributeAccessor curve_attributes = curves.attributes();
const bke::AttributeReader<float> radii = curve_attributes.lookup<float>("radius",
bke::AttrDomain::Point);
const VArray<float> radii = curves.radius();
widths.resize(radii.varray.size());
for (const int i : radii.varray.index_range()) {
widths[i] = radii.varray[i] * 2.0f;
widths.resize(radii.size());
for (const int i : radii.index_range()) {
widths[i] = radii[i] * 2.0f;
}
}
@@ -348,15 +346,15 @@ void USDCurvesWriter::set_writer_attributes(pxr::UsdGeomCurves &usd_curves,
const pxr::TfToken interpolation)
{
pxr::UsdAttribute attr_points = usd_curves.CreatePointsAttr(pxr::VtValue(), true);
usd_value_writer_.SetAttribute(attr_points, pxr::VtValue(verts), timecode);
set_attribute(attr_points, verts, timecode, usd_value_writer_);
pxr::UsdAttribute attr_vertex_counts = usd_curves.CreateCurveVertexCountsAttr(pxr::VtValue(),
true);
usd_value_writer_.SetAttribute(attr_vertex_counts, pxr::VtValue(control_point_counts), timecode);
set_attribute(attr_vertex_counts, control_point_counts, timecode, usd_value_writer_);
if (!widths.empty()) {
pxr::UsdAttribute attr_widths = usd_curves.CreateWidthsAttr(pxr::VtValue(), true);
usd_value_writer_.SetAttribute(attr_widths, pxr::VtValue(widths), timecode);
set_attribute(attr_widths, widths, timecode, usd_value_writer_);
usd_curves.SetWidthsInterpolation(interpolation);
}
@@ -376,6 +374,33 @@ static std::optional<pxr::TfToken> convert_blender_domain_to_usd(
}
}
/* Excluded attributes are those which are handled through native USD concepts
* and should not be exported as generic attributes. */
static bool is_excluded_attr(StringRefNull name)
{
static const Set<StringRefNull> excluded_attrs = []() {
Set<StringRefNull> set;
set.add_new("position");
set.add_new("radius");
set.add_new("resolution");
set.add_new("id");
set.add_new("cyclic");
set.add_new("curve_type");
set.add_new("normal_mode");
set.add_new("handle_left");
set.add_new("handle_right");
set.add_new("handle_type_left");
set.add_new("handle_type_right");
set.add_new("knots_mode");
set.add_new("nurbs_order");
set.add_new("nurbs_weight");
set.add_new("velocity");
return set;
}();
return excluded_attrs.contains(name);
}
void USDCurvesWriter::write_generic_data(const bke::CurvesGeometry &curves,
const bke::AttributeIter &attr,
const pxr::UsdGeomCurves &usd_curves)
@@ -432,6 +457,25 @@ void USDCurvesWriter::write_uv_data(const bke::AttributeIter &attr,
copy_blender_buffer_to_primvar<float2, pxr::GfVec2f>(buffer, timecode, pv_uv, usd_value_writer_);
}
void USDCurvesWriter::write_velocities(const bke::CurvesGeometry &curves,
const pxr::UsdGeomCurves &usd_curves)
{
const VArraySpan velocity = *curves.attributes().lookup<float3>("velocity",
blender::bke::AttrDomain::Point);
if (velocity.is_empty()) {
return;
}
/* Export per-vertex velocity vectors. */
Span<pxr::GfVec3f> data = velocity.cast<pxr::GfVec3f>();
pxr::VtVec3fArray usd_velocities;
usd_velocities.assign(data.begin(), data.end());
pxr::UsdTimeCode timecode = get_export_time_code();
pxr::UsdAttribute attr_vel = usd_curves.CreateVelocitiesAttr(pxr::VtValue(), true);
set_attribute(attr_vel, usd_velocities, timecode, usd_value_writer_);
}
void USDCurvesWriter::write_custom_data(const bke::CurvesGeometry &curves,
const pxr::UsdGeomCurves &usd_curves)
{
@@ -440,16 +484,7 @@ void USDCurvesWriter::write_custom_data(const bke::CurvesGeometry &curves,
attributes.foreach_attribute([&](const bke::AttributeIter &iter) {
/* Skip "internal" Blender properties and attributes dealt with elsewhere. */
if (iter.name[0] == '.' || bke::attribute_name_is_anonymous(iter.name) ||
ELEM(iter.name,
"position",
"radius",
"resolution",
"id",
"curve_type",
"handle_left",
"handle_right",
"handle_type_left",
"handle_type_right"))
is_excluded_attr(iter.name))
{
return;
}
@@ -591,11 +626,13 @@ void USDCurvesWriter::do_write(HierarchyContext &context)
BLI_assert_unreachable();
}
set_writer_attributes(*usd_curves, verts, control_point_counts, widths, timecode, interpolation);
this->set_writer_attributes(
*usd_curves, verts, control_point_counts, widths, timecode, interpolation);
assign_materials(context, *usd_curves);
this->assign_materials(context, *usd_curves);
write_custom_data(curves, *usd_curves);
this->write_velocities(curves, *usd_curves);
this->write_custom_data(curves, *usd_curves);
auto prim = usd_curves->GetPrim();
write_id_properties(prim, curves_id->id, timecode);

View File

@@ -50,6 +50,8 @@ class USDCurvesWriter final : public USDAbstractWriter {
void write_uv_data(const bke::AttributeIter &attr, const pxr::UsdGeomCurves &usd_curves);
void write_velocities(const bke::CurvesGeometry &curves, const pxr::UsdGeomCurves &usd_curves);
void write_custom_data(const blender::bke::CurvesGeometry &curves,
const pxr::UsdGeomCurves &usd_curves);
};

View File

@@ -684,6 +684,35 @@ class USDExportTest(AbstractUSDTest):
self.assertEqual(UsdGeom.Boundable(points3).GetExtentAttr().GetTimeSamples(), sparse_frames)
self.assertEqual(UsdGeom.Boundable(points4).GetExtentAttr().GetTimeSamples(), [])
#
# Validate BasisCurve data
#
curves1 = UsdGeom.BasisCurves(stage.GetPrimAtPath("/root/curves_plane1/curves1/Curves"))
curves2 = UsdGeom.BasisCurves(stage.GetPrimAtPath("/root/curves_plane2/curves2/Curves"))
curves3 = UsdGeom.BasisCurves(stage.GetPrimAtPath("/root/curves_plane3/curves3/Curves"))
curves4 = UsdGeom.BasisCurves(stage.GetPrimAtPath("/root/curves_plane4/curves4/Curves"))
# Positions (should be sparsely written)
self.assertEqual(curves1.GetPointsAttr().GetTimeSamples(), sparse_frames)
self.assertEqual(curves2.GetPointsAttr().GetTimeSamples(), [])
self.assertEqual(curves3.GetPointsAttr().GetTimeSamples(), [])
self.assertEqual(curves4.GetPointsAttr().GetTimeSamples(), [])
# Velocity (should be sparsely written)
self.assertEqual(curves1.GetVelocitiesAttr().GetTimeSamples(), [])
self.assertEqual(curves2.GetVelocitiesAttr().GetTimeSamples(), sparse_frames)
self.assertEqual(curves3.GetVelocitiesAttr().GetTimeSamples(), [])
self.assertEqual(curves4.GetVelocitiesAttr().GetTimeSamples(), [])
# Radius (should be sparsely written)
self.assertEqual(curves1.GetWidthsAttr().GetTimeSamples(), [])
self.assertEqual(curves2.GetWidthsAttr().GetTimeSamples(), [])
self.assertEqual(curves3.GetWidthsAttr().GetTimeSamples(), sparse_frames)
self.assertEqual(curves4.GetWidthsAttr().GetTimeSamples(), [])
# Regular primvar (should be sparsely written)
self.assertEqual(UsdGeom.PrimvarsAPI(curves1).GetPrimvar("test").GetTimeSamples(), [])
self.assertEqual(UsdGeom.PrimvarsAPI(curves2).GetPrimvar("test").GetTimeSamples(), [])
self.assertEqual(UsdGeom.PrimvarsAPI(curves3).GetPrimvar("test").GetTimeSamples(), [])
self.assertEqual(UsdGeom.PrimvarsAPI(curves4).GetPrimvar("test").GetTimeSamples(), sparse_frames)
def test_export_mesh_subd(self):
"""Test exporting Subdivision Surface attributes and values"""
bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "usd_mesh_subd.blend"))

View File

@@ -1391,6 +1391,68 @@ class USDImportTest(AbstractUSDTest):
usd_test_data,
f"Frame {frame}: {name} test attributes do not match")
#
# Validate Curves data
#
blender_curves = [
bpy.data.objects["Curves"],
bpy.data.objects["Curves.001"],
bpy.data.objects["Curves.002"],
bpy.data.objects["Curves.003"]]
usd_curves = [UsdGeom.BasisCurves(stage.GetPrimAtPath("/root/curves_plane1/curves1/Curves")),
UsdGeom.BasisCurves(stage.GetPrimAtPath("/root/curves_plane2/curves2/Curves")),
UsdGeom.BasisCurves(stage.GetPrimAtPath("/root/curves_plane3/curves3/Curves")),
UsdGeom.BasisCurves(stage.GetPrimAtPath("/root/curves_plane4/curves4/Curves"))]
curves_num = len(blender_curves)
# Workaround: GeometrySet processing loses the data-block name on export. This is why the
# .001 etc. names are being used above. Since we need the order of Blender objects to match
# the order of USD prims, sort by the Y location to make them match in our test setup.
blender_curves.sort(key=lambda ob: ob.parent.location.y)
# A MeshSequenceCache modifier should be present on every imported object
for i in range(0, curves_num):
self.assertTrue(len(blender_curves[i].modifiers) == 1 and blender_curves[i].modifiers[0].type ==
'MESH_SEQUENCE_CACHE', f"{blender_curves[i].name} has incorrect modifiers")
# Compare Blender and USD data against each other for every frame
for frame in range(1, 16):
bpy.context.scene.frame_set(frame)
depsgraph = bpy.context.evaluated_depsgraph_get()
for i in range(0, mesh_num):
blender_curves[i] = blender_curves[i].evaluated_get(depsgraph)
# Check positions, velocity, radius, and test data
for i in range(0, mesh_num):
blender_pos_data = [self.round_vector(d.vector)
for d in blender_curves[i].data.attributes["position"].data]
blender_vel_data = [self.round_vector(d.vector)
for d in blender_curves[i].data.attributes["velocity"].data]
blender_radius_data = [round(d.value, 5) for d in blender_curves[i].data.attributes["radius"].data]
blender_test_data = [round(d.value, 5) for d in blender_curves[i].data.attributes["test"].data]
usd_pos_data = [self.round_vector(d) for d in usd_curves[i].GetPointsAttr().Get(frame)]
usd_vel_data = [self.round_vector(d) for d in usd_curves[i].GetVelocitiesAttr().Get(frame)]
usd_radius_data = [round(d / 2, 5) for d in usd_curves[i].GetWidthsAttr().Get(frame)]
usd_test_data = [round(d, 5) for d in UsdGeom.PrimvarsAPI(usd_curves[i]).GetPrimvar("test").Get(frame)]
name = usd_curves[i].GetPath().GetParentPath().name
self.assertEqual(
blender_pos_data,
usd_pos_data,
f"Frame {frame}: {name} positions do not match")
self.assertEqual(
blender_vel_data,
usd_vel_data,
f"Frame {frame}: {name} velocities do not match")
self.assertEqual(
blender_radius_data,
usd_radius_data,
f"Frame {frame}: {name} radii do not match")
self.assertEqual(
blender_test_data,
usd_test_data,
f"Frame {frame}: {name} test attributes do not match")
def test_import_shapes(self):
"""Test importing USD Shape prims with time-varying attributes."""