Alembic: import and export of subdivision parameters

This adds support to import and to export subdivision parameters from
subsurface modifiers in Alembic.

As Alembic is using the OpenSubDiv 2.0 API to define the parameters, we
need to convert to and from the OpenSubDiv 3.0 API which Blender uses.
Since Alembic does not define proper enumerations for these values,
this adds relevant enumerations whose names derive from the USD Alembic
exporter to avoid using magic values.

On top of that we also export and import the subdivision levels for
viewport and render so those values get preserved when reimporting in
Blender.

For import, this also automatically adds a subdivision surface modifier
to subdivision meshes.

Pull Request: https://projects.blender.org/blender/blender/pulls/160260
This commit is contained in:
Kévin Dietrich 2026-06-18 15:30:25 +02:00 • committed by Kévin Dietrich
parent 9cb019e4be
commit f2a1c2848a
9 changed files with 271 additions and 3 deletions

View file

@ -9,6 +9,7 @@
#include "abc_writer_mesh.h"
#include "abc_hierarchy_iterator.h"
#include "intern/abc_axis_conversion.h"
#include "intern/abc_util.h"
#include "BKE_attribute.h"
#include "BKE_attribute.hh"
@ -45,6 +46,7 @@ using Alembic::AbcGeom::OBoolProperty;
using Alembic::AbcGeom::OCompoundProperty;
using Alembic::AbcGeom::OFaceSet;
using Alembic::AbcGeom::OFaceSetSchema;
using Alembic::AbcGeom::OInt32Property;
using Alembic::AbcGeom::ON3fGeomParam;
using Alembic::AbcGeom::OPolyMesh;
using Alembic::AbcGeom::OPolyMeshSchema;
@ -70,6 +72,44 @@ static void get_vert_creases(Mesh *mesh,
std::vector<float> &sharpnesses);
static void get_loop_normals(const Mesh *mesh, std::vector<Imath::V3f> &normals);
/* Get the last subdiv modifier ignoring subsequent particle systems modifiers, regardless of
* enable/disable status.
* TODO(kevindietrich) : deduplicate this with USD, but USD does not ignore particle systems. */
static const SubsurfModifierData *get_last_subdiv_modifier(eEvaluationMode eval_mode, Object *obj)
{
BLI_assert(obj);
/* Return the subdiv modifier if it is the last modifier and has
* the required mode enabled. */
ModifierData *md = static_cast<ModifierData *>(obj->modifiers.last);
while (md != nullptr) {
if (md->type != eModifierType_ParticleSystem) {
break;
}
md = md->prev;
}
if (!md) {
return nullptr;
}
/* Determine if the modifier is enabled for the current evaluation mode. */
ModifierMode mod_mode = (eval_mode == DAG_EVAL_RENDER) ? eModifierMode_Render :
eModifierMode_Realtime;
if ((md->mode & mod_mode) != mod_mode) {
return nullptr;
}
if (md->type == eModifierType_Subsurf) {
return reinterpret_cast<SubsurfModifierData *>(md);
}
return nullptr;
}
ABCGenericMeshWriter::ABCGenericMeshWriter(const ABCWriterConstructorArgs &args)
: ABCAbstractWriter(args), is_subd_(false)
{
@ -85,6 +125,12 @@ void ABCGenericMeshWriter::create_alembic_objects(const HierarchyContext *contex
CLOG_DEBUG(&LOG, "exporting OSubD %s", args_.abc_path.c_str());
abc_subdiv_ = OSubD(args_.abc_parent, args_.abc_name, timesample_index_);
abc_subdiv_schema_ = abc_subdiv_.getSchema();
abc_custom_data_container_ = abc_subdiv_schema_.getUserProperties();
abc_subdiv_render_levels_ = OInt32Property(
abc_custom_data_container_, "subdivRenderLevels", timesample_index_);
abc_subdiv_viewport_levels_ = OInt32Property(
abc_custom_data_container_, "subdivViewportLevels", timesample_index_);
}
else {
CLOG_DEBUG(&LOG, "exporting OPolyMesh %s", args_.abc_path.c_str());
@ -319,6 +365,53 @@ void ABCGenericMeshWriter::write_subd(HierarchyContext &context, Mesh *mesh)
subdiv_sample.setCornerSharpnesses(FloatArraySample(vert_crease_sharpness));
}
const SubsurfModifierData *subsurf_data = get_last_subdiv_modifier(
args_.export_params->evaluation_mode, context.object);
if (subsurf_data) {
AbcFaceVaryingInterpolateBoundary fvar_interpolate_boundary =
AbcFaceVaryingInterpolateBoundary::ALL;
AbcInterpolateBoundary interpolate_boundary = AbcInterpolateBoundary::NONE;
int propagate_corners = 0;
/* Confusingly, ALL is NONE and NONE is ALL. */
switch (subsurf_data->uv_smooth) {
case SUBSURF_UV_SMOOTH_NONE:
fvar_interpolate_boundary = AbcFaceVaryingInterpolateBoundary::ALL;
break;
case SUBSURF_UV_SMOOTH_PRESERVE_CORNERS:
case SUBSURF_UV_SMOOTH_PRESERVE_CORNERS_AND_JUNCTIONS:
fvar_interpolate_boundary = AbcFaceVaryingInterpolateBoundary::EDGE_AND_CORNERS;
break;
case SUBSURF_UV_SMOOTH_PRESERVE_CORNERS_JUNCTIONS_AND_CONCAVE:
fvar_interpolate_boundary = AbcFaceVaryingInterpolateBoundary::EDGE_AND_CORNERS;
propagate_corners = 1;
break;
case SUBSURF_UV_SMOOTH_PRESERVE_BOUNDARIES:
fvar_interpolate_boundary = AbcFaceVaryingInterpolateBoundary::BOUNDARIES;
break;
case SUBSURF_UV_SMOOTH_ALL:
fvar_interpolate_boundary = AbcFaceVaryingInterpolateBoundary::NONE;
break;
}
switch (subsurf_data->boundary_smooth) {
case SUBSURF_BOUNDARY_SMOOTH_PRESERVE_CORNERS:
interpolate_boundary = AbcInterpolateBoundary::EDGE_AND_CORNERS;
break;
case SUBSURF_BOUNDARY_SMOOTH_ALL:
interpolate_boundary = AbcInterpolateBoundary::EDGE_ONLY;
break;
}
subdiv_sample.setFaceVaryingInterpolateBoundary(int(fvar_interpolate_boundary));
subdiv_sample.setFaceVaryingPropagateCorners(propagate_corners);
subdiv_sample.setInterpolateBoundary(int(interpolate_boundary));
abc_subdiv_viewport_levels_.set(subsurf_data->levels);
abc_subdiv_render_levels_.set(subsurf_data->renderLevels);
}
update_bounding_box(context.object);
subdiv_sample.setSelfBounds(bounding_box_);
abc_subdiv_schema_.set(subdiv_sample);

View file

@ -30,6 +30,10 @@ class ABCGenericMeshWriter : public ABCAbstractWriter {
Alembic::AbcGeom::OSubD abc_subdiv_;
Alembic::AbcGeom::OSubDSchema abc_subdiv_schema_;
Alembic::AbcGeom::OCompoundProperty abc_custom_data_container_;
Alembic::AbcGeom::OInt32Property abc_subdiv_render_levels_;
Alembic::AbcGeom::OInt32Property abc_subdiv_viewport_levels_;
/* Determines whether a poly mesh or a subdivision surface is exported.
* The value is set by an export option but only true if there is a subdivision modifier on the
* exported object. */

View file

@ -31,6 +31,7 @@
#include "BKE_main.hh"
#include "BKE_material.hh"
#include "BKE_mesh.hh"
#include "BKE_modifier.hh"
#include "BKE_object.hh"
#include "BKE_subdiv.hh"
@ -50,6 +51,7 @@ using Alembic::AbcGeom::IC3fGeomParam;
using Alembic::AbcGeom::IC4fGeomParam;
using Alembic::AbcGeom::IFaceSet;
using Alembic::AbcGeom::IFaceSetSchema;
using Alembic::AbcGeom::IInt32Property;
using Alembic::AbcGeom::IN3fGeomParam;
using Alembic::AbcGeom::IObject;
using Alembic::AbcGeom::IPolyMesh;
@ -1147,6 +1149,8 @@ void AbcSubDReader::readObjectData(Main *bmain, const Alembic::Abc::ISampleSelec
if (m_settings->always_add_cache_reader || has_animations(m_schema, m_settings)) {
addCacheModifier();
}
add_subdiv_modifier();
}
bool AbcSubDReader::topology_changed(const Mesh *existing_mesh, const ISampleSelector &sample_sel)
@ -1227,5 +1231,85 @@ void AbcSubDReader::read_geometry(bke::GeometrySet &geometry_set,
geometry_set.replace_mesh(new_mesh);
}
void AbcSubDReader::add_subdiv_modifier()
{
ModifierData *md = BKE_modifier_new(eModifierType_Subsurf);
BLI_addtail(&m_object->modifiers, md);
BKE_modifiers_persistent_uid_init(*m_object, *md);
SubsurfModifierData *subdiv_data = reinterpret_cast<SubsurfModifierData *>(md);
try {
/* We only read the first sample as it is unlikely that those value are animated. */
ISampleSelector sample_selector = ISampleSelector(Alembic::Abc::index_t(0));
ISubDSchema::Sample sample = m_schema.getValue(sample_selector);
const AbcFaceVaryingInterpolateBoundary fvar_interpolate_boundary =
AbcFaceVaryingInterpolateBoundary(sample.getFaceVaryingInterpolateBoundary());
const AbcInterpolateBoundary interpolate_boundary = AbcInterpolateBoundary(
sample.getInterpolateBoundary());
/* FaceVaryingPropagateCorners should have a value of either 0 (false) or 1 (true). Consider
* only values that are greater than 0 as true to allow for bad values, but discard negative
* values for the potential set of true values are the value used by Alembic to tell that the
* property has not been initialized is negative. */
const bool propagate_corners = sample.getFaceVaryingPropagateCorners() > 0;
/* Confusingly, ALL is NONE and NONE is ALL. */
switch (fvar_interpolate_boundary) {
case AbcFaceVaryingInterpolateBoundary::ALL:
subdiv_data->uv_smooth = SUBSURF_UV_SMOOTH_NONE;
break;
case AbcFaceVaryingInterpolateBoundary::EDGE_AND_CORNERS: {
if (propagate_corners) {
subdiv_data->uv_smooth = SUBSURF_UV_SMOOTH_PRESERVE_CORNERS_JUNCTIONS_AND_CONCAVE;
}
else {
subdiv_data->uv_smooth = SUBSURF_UV_SMOOTH_PRESERVE_CORNERS;
}
break;
}
case AbcFaceVaryingInterpolateBoundary::NONE:
subdiv_data->uv_smooth = SUBSURF_UV_SMOOTH_ALL;
break;
case AbcFaceVaryingInterpolateBoundary::BOUNDARIES:
subdiv_data->uv_smooth = SUBSURF_UV_SMOOTH_PRESERVE_BOUNDARIES;
break;
}
switch (interpolate_boundary) {
case AbcInterpolateBoundary::NONE:
/* Blender has no concept for this. */
break;
case AbcInterpolateBoundary::EDGE_AND_CORNERS:
subdiv_data->boundary_smooth = SUBSURF_BOUNDARY_SMOOTH_PRESERVE_CORNERS;
break;
case AbcInterpolateBoundary::EDGE_ONLY:
subdiv_data->boundary_smooth = SUBSURF_BOUNDARY_SMOOTH_ALL;
break;
}
ICompoundProperty user_properties = m_schema.getUserProperties();
if (user_properties.valid() && user_properties.getPropertyHeader("subdivRenderLevels") &&
user_properties.getPropertyHeader("subdivViewportLevels"))
{
IInt32Property render_levels(user_properties, "subdivRenderLevels");
IInt32Property viewport_levels(user_properties, "subdivViewportLevels");
subdiv_data->renderLevels = short(render_levels.getValue(sample_selector));
subdiv_data->levels = short(viewport_levels.getValue(sample_selector));
}
}
catch (Alembic::Util::Exception &ex) {
CLOG_WARN(&LOG,
"Error reading subdivision parameter for '%s/%s' : %s",
m_iobject.getFullName().c_str(),
m_schema.getName().c_str(),
ex.what());
}
}
} // namespace io::alembic
} // namespace blender

View file

@ -80,6 +80,8 @@ class AbcSubDReader final : public AbcObjectReader {
const Alembic::Abc::ISampleSelector &sample_sel,
const AbcReadGeometryParams &read_params,
const char **r_err_str);
void add_subdiv_modifier();
};
void read_mverts(Mesh &mesh,

View file

@ -80,5 +80,31 @@ std::optional<SampleInterpolationSettings> get_sample_interpolation_settings(
AbcObjectReader *create_reader(const AbcReaderConstructorArgs &args);
/* Alembic does not have specific enumerations for FaceVaryingInterpolateBoundary and
* InterpolateBoundary, rather it uses the values from OpenSubDiv 2.x and RenderMan
* directly respectively.
*
* The identifiers used in the following enumerations are based on their USD equivalent
* as used in USD's Alembic writer.
*/
/* See https://opensubdiv.org/docs/compatibility.html for how to convert values from
* OpenSubDiv 2.x to 3.x. */
enum class AbcFaceVaryingInterpolateBoundary : int {
ALL = 0,
EDGE_AND_CORNERS = 1,
NONE = 2,
BOUNDARIES = 3,
};
/* See the RenderMan documentation for those values:
* https://rmanwiki-27.pixar.com/space/REN27/542213016/Subdivision+Surfaces#Boundary-Interpolation
*/
enum class AbcInterpolateBoundary : int {
NONE = 0,
EDGE_AND_CORNERS = 1,
EDGE_ONLY = 2,
};
} // namespace io::alembic
} // namespace blender

View file

@ -6,4 +6,6 @@
- pos 0.000, 0.000, 0.000
- rot 0.000, 0.000, 0.000 (XYZ)
- scl 1.000, 1.000, 1.000
- 1 modifiers
- SUBSURF 'Subdivision' levels:1/2 type:CATMULL_CLARK crease:True

View file

@ -26,4 +26,6 @@
- pos 0.000, 0.000, 0.000
- rot 0.000, 0.000, 0.000 (XYZ)
- scl 1.000, 1.000, 1.000
- 1 modifiers
- SUBSURF 'Subdivision' levels:1/2 type:CATMULL_CLARK crease:True

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7854b8e39041b7fa750061c02b78d2579b30fa07ac282ae65f989e23b564d030
size 105460

View file

@ -305,7 +305,7 @@ class CameraExportImportTest(unittest.TestCase):
# A MeshSequenceCache modifier should be present on every imported object
for blender_mesh in [blender_mesh1, blender_mesh2]:
self.assertTrue(len(blender_mesh.modifiers) == 1 and blender_mesh.modifiers[0].type ==
self.assertTrue(len(blender_mesh.modifiers) >= 1 and blender_mesh.modifiers[0].type ==
'MESH_SEQUENCE_CACHE', f"{blender_mesh.name} has incorrect modifiers")
# Conversion from USD to Blender convention
@ -320,8 +320,10 @@ class CameraExportImportTest(unittest.TestCase):
blender_mesh2_eval = bpy.data.objects["mesh_vert_crease"].evaluated_get(depsgraph)
# The file was written using a simple formula for each frame's crease value
expected_edge_creases = [round(frame / 24.0, 3)] * 12
expected_vert_creases = [round(frame / 24.0, 3)] * 4
expected_edge_creases = [round(frame / 24.0, 3)] * 48
# Creases are not propagated to edges that do not come from a coarse edge
expected_edge_creases.extend([0.0] * 144)
expected_vert_creases = [round(frame / 24.0, 3)] * 25
# Check crease values
blender_crease_data = [round(d.value, 3) for d in blender_mesh1_eval.data.attributes["crease_edge"].data]
@ -336,6 +338,56 @@ class CameraExportImportTest(unittest.TestCase):
expected_vert_creases,
f"Frame {frame}: {blender_mesh2_eval.name} crease values do not match")
def test_mesh_subd_params(self):
"""Test export of subdivision parameters."""
abc_path = str(self.tempdir / "mesh_subd_params.abc")
# Export
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "mesh_subd_params.blend"))
self.assertIn('FINISHED', bpy.ops.wm.alembic_export(
filepath=abc_path,
subdiv_schema=True
))
# Re-import what we just exported into an empty file.
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "empty.blend"))
bpy.ops.wm.alembic_import(
filepath=abc_path,
as_background_job=False)
uv_smooth_options = [
'NONE',
'PRESERVE_CORNERS',
# NOTE: in Blender this should be PRESERVE_CORNERS_AND_JUNCTIONS, however
# Alembic does not have this concept.
'PRESERVE_CORNERS',
'PRESERVE_CORNERS_JUNCTIONS_AND_CONCAVE',
'PRESERVE_BOUNDARIES',
'SMOOTH_ALL'
]
boundary_smooth_options = [
'PRESERVE_CORNERS',
'ALL'
]
for uv_smooth_index in range(len(uv_smooth_options)):
uv_smooth = uv_smooth_options[uv_smooth_index]
for boundary_smooth_index in range(len(boundary_smooth_options)):
boundary_smooth = boundary_smooth_options[boundary_smooth_index]
obj_name = 'obj{}{}'.format(uv_smooth_index, boundary_smooth_index)
obj = bpy.data.objects[obj_name]
modifier = obj.modifiers['Subdivision']
self.assertEqual(modifier.uv_smooth, uv_smooth)
self.assertEqual(modifier.boundary_smooth, boundary_smooth)
self.assertEqual(modifier.levels, 1 + uv_smooth_index)
self.assertEqual(modifier.render_levels, 2 + uv_smooth_index)
def do_export_import_test(self, *, flatten: bool):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "camera_transforms.blend"))