USD: Export normals based on Blender internal data model

Most game engines use and store normal data per-point. Blender's default
USD export always converts them to face-varying/corner. This PR exports
normals following Blender internal data model (point, corner or face),
thus making import in engines more straightforward for meshes that
already use per-point normals without additional conversion steps.

USD export tests have also been updated to reflect this change.

Pull Request: https://projects.blender.org/blender/blender/pulls/159858
This commit is contained in:
Marc Bourny 2026-06-12 22:22:14 +02:00 • committed by Jesse Yurkovich
parent 70898c5508
commit 46d0a7ccd4
4 changed files with 79 additions and 13 deletions

View file

@ -702,36 +702,36 @@ void USDGenericMeshWriter::write_normals(const Mesh *mesh, pxr::UsdGeomMesh &usd
{
pxr::UsdTimeCode time = get_export_time_code();
Span<float3> src_normals;
pxr::VtVec3fArray loop_normals;
loop_normals.resize(mesh->corners_num);
MutableSpan dst_normals(reinterpret_cast<float3 *>(loop_normals.data()), loop_normals.size());
pxr::TfToken interpolation;
switch (mesh->normals_domain()) {
case bke::MeshNormalDomain::Point: {
array_utils::gather(mesh->vert_normals(), mesh->corner_verts(), dst_normals);
src_normals = mesh->vert_normals();
interpolation = pxr::UsdGeomTokens->vertex;
break;
}
case bke::MeshNormalDomain::Face: {
const OffsetIndices faces = mesh->faces();
const Span<float3> face_normals = mesh->face_normals();
for (const int i : faces.index_range()) {
dst_normals.slice(faces[i]).fill(face_normals[i]);
}
src_normals = mesh->face_normals();
interpolation = pxr::UsdGeomTokens->uniform;
break;
}
case bke::MeshNormalDomain::Corner: {
array_utils::copy(mesh->corner_normals(), dst_normals);
src_normals = mesh->corner_normals();
interpolation = pxr::UsdGeomTokens->faceVarying;
break;
}
}
loop_normals.resize(src_normals.size());
MutableSpan dst_normals(reinterpret_cast<float3 *>(loop_normals.data()), loop_normals.size());
array_utils::copy(src_normals, dst_normals);
pxr::UsdAttribute attr_normals = usd_mesh.CreateNormalsAttr(pxr::VtValue(), true);
if (!attr_normals.HasValue()) {
attr_normals.Set(loop_normals, pxr::UsdTimeCode::Default());
}
usd_value_writer_.SetAttribute(attr_normals, pxr::VtValue(loop_normals), time);
usd_mesh.SetNormalsInterpolation(pxr::UsdGeomTokens->faceVarying);
usd_mesh.SetNormalsInterpolation(interpolation);
}
void USDGenericMeshWriter::write_surface_velocity(const Mesh *mesh,

View file

@ -22,6 +22,7 @@
#include "BKE_context.hh"
#include "BKE_lib_id.hh"
#include "BKE_main.hh"
#include "BKE_mesh_types.hh"
#include "BLI_fileops.h"
#include "BLI_listbase.h"
@ -192,7 +193,23 @@ class UsdExportTest : public BlendfileLoadingBaseTest {
EXPECT_EQ(mesh->verts_num, positions.size());
EXPECT_EQ(mesh->faces_num, face_counts.size());
EXPECT_EQ(mesh->corners_num, face_indices.size());
EXPECT_EQ(mesh->corners_num, normals.size());
switch (mesh->normals_domain()) {
case bke::MeshNormalDomain::Point: {
EXPECT_EQ(mesh_prim.GetNormalsInterpolation(), pxr::UsdGeomTokens->vertex);
EXPECT_EQ(mesh->verts_num, normals.size());
break;
}
case bke::MeshNormalDomain::Face: {
EXPECT_EQ(mesh_prim.GetNormalsInterpolation(), pxr::UsdGeomTokens->uniform);
EXPECT_EQ(mesh->faces_num, normals.size());
break;
}
case bke::MeshNormalDomain::Corner: {
EXPECT_EQ(mesh_prim.GetNormalsInterpolation(), pxr::UsdGeomTokens->faceVarying);
EXPECT_EQ(mesh->corners_num, normals.size());
break;
}
}
}
};

View file

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

View file

@ -2209,6 +2209,52 @@ class USDExportTest(AbstractUSDTest):
check_colorspace(stage.GetPrimAtPath(f"/root/_materials/{mat.name}"), "Material")
check_colorspace(stage.GetPrimAtPath(f"/root/{mesh_name}/{mesh_name}"), "Mesh")
def test_export_mesh_normals(self):
"""Test that each exported USD normal interpolation and number matches
Blender mesh normal domain"""
bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "usd_mesh_normals.blend"))
export_path = self.tempdir / "usd_mesh_normals.usda"
self.export_and_validate(
filepath=str(export_path),
evaluation_mode="RENDER",
)
stage = Usd.Stage.Open(str(export_path))
# validate face normals (uniform)
faceMesh = UsdGeom.Mesh(stage.GetPrimAtPath("/root/shade_flat/shade_flat"))
self.assertEqual(
len(faceMesh.GetNormalsAttr().Get()),
len(faceMesh.GetFaceVertexCountsAttr().Get()),
"Number of normals should equal number of faces")
self.assertEqual(
faceMesh.GetNormalsInterpolation(),
UsdGeom.Tokens.uniform,
"Normals should be uniform interpolated")
# validate corner normals (face-varying)
cornerMesh = UsdGeom.Mesh(stage.GetPrimAtPath("/root/shade_auto22/shade_auto22"))
self.assertEqual(
len(cornerMesh.GetNormalsAttr().Get()),
len(cornerMesh.GetFaceVertexIndicesAttr().Get()),
"Number of normals should equal number of indices")
self.assertEqual(
cornerMesh.GetNormalsInterpolation(),
UsdGeom.Tokens.faceVarying,
"Normals should be faceVarying interpolated")
# validate point normals (vertex)
pointMesh = UsdGeom.Mesh(stage.GetPrimAtPath("/root/shade_smooth/shade_smooth"))
self.assertEqual(
len(pointMesh.GetNormalsAttr().Get()),
len(pointMesh.GetPointsAttr().Get()),
"Number of normals should equal number of points")
self.assertEqual(
pointMesh.GetNormalsInterpolation(),
UsdGeom.Tokens.vertex,
"Normals should be vertex interpolated")
class USDHookBase:
instructions = {}