USD: Support colorspace conversions when reading and writing USD files

On export, apply the UsdColorSpaceAPI schema to the root prim, materials
lights, color primvras and shader prims for image textures. By default
the root prim would have been enough, but it is possible to export with
out a root prim and this keeps behavior consistent independent of that
setting.

On import, use UsdColorSpaceAPI::ComputeColorSpaceName to resolve
the colorspace for each color attribute through the USD hierarchy,
and convert from that colorspace to scene linear.

Pull Request: projects.blender.org/blender/blender/pulls/157196
This commit is contained in:
Brecht Van Lommel 2026-04-10 00:04:43 +02:00
parent cc83054d82
commit da852a9b79
13 changed files with 255 additions and 6 deletions

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later */
#include "usd_attribute_utils.hh"
#include "usd_colorspace_utils.hh"
#include "usd_hash_types.hh"
#include "BLI_map.hh"
@ -127,18 +128,18 @@ void copy_primvar_to_blender_attribute(const pxr::UsdGeomPrimvar &primvar,
break;
case bke::AttrType::ColorFloat: {
const pxr::SdfValueTypeName pv_type = primvar.GetTypeName();
MutableSpan<ColorGeometry4f> colors = attribute.span.typed<ColorGeometry4f>();
if (ELEM(pv_type,
pxr::SdfValueTypeNames->Color3fArray,
pxr::SdfValueTypeNames->Color3hArray,
pxr::SdfValueTypeNames->Color3dArray))
{
copy_primvar_to_blender_buffer<pxr::GfVec3f>(
primvar, time, face_indices, attribute.span.typed<ColorGeometry4f>());
copy_primvar_to_blender_buffer<pxr::GfVec3f>(primvar, time, face_indices, colors);
}
else {
copy_primvar_to_blender_buffer<pxr::GfVec4f>(
primvar, time, face_indices, attribute.span.typed<ColorGeometry4f>());
copy_primvar_to_blender_buffer<pxr::GfVec4f>(primvar, time, face_indices, colors);
}
colorspace_attr_to_scene_linear(primvar.GetAttr(), colors);
} break;
case bke::AttrType::Bool:
copy_primvar_to_blender_buffer<bool>(
@ -196,6 +197,7 @@ void copy_blender_attribute_to_primvar(const GVArray &attribute,
copy_blender_buffer_to_primvar<ColorGeometry4f, pxr::GfVec4f>(
attribute.typed<ColorGeometry4f>(), time, primvar, value_writer);
}
colorspace_apply_to_prim(primvar.GetAttr().GetPrim());
break;
case bke::AttrType::ColorByte:
if (primvar.GetTypeName() == pxr::SdfValueTypeNames->Color3fArray) {
@ -206,6 +208,7 @@ void copy_blender_attribute_to_primvar(const GVArray &attribute,
copy_blender_buffer_to_primvar<ColorGeometry4b, pxr::GfVec4f>(
attribute.typed<ColorGeometry4b>(), time, primvar, value_writer);
}
colorspace_apply_to_prim(primvar.GetAttr().GetPrim());
break;
case bke::AttrType::Quaternion:
copy_blender_buffer_to_primvar<math::Quaternion, pxr::GfQuatf>(

View file

@ -6,6 +6,7 @@
#include "IO_subdiv_disabler.hh"
#include "usd.hh"
#include "usd_colorspace_utils.hh"
#include "usd_hierarchy_iterator.hh"
#include "usd_hook.hh"
#include "usd_instancing_utils.hh"
@ -190,6 +191,10 @@ static void ensure_root_prim(pxr::UsdStageRefPtr stage, const USDExportParams &p
xf_api.SetRotate(pxr::GfVec3f(eul.x().degree(), eul.y().degree(), eul.z().degree()));
}
/* Colorspace on the root prim. It's also applied on all individual prims that need
* it, but perhaps this is useful to signal the overall colorspace of the file. */
colorspace_apply_to_prim(root_xf.GetPrim());
for (const auto &path : pxr::SdfPath(params.root_prim_path).GetPrefixes()) {
auto xform = pxr::UsdGeomXform::Define(stage, path);
/* Tag generated primitives to allow filtering on import. */

View file

@ -4,6 +4,9 @@
#include "usd_colorspace_utils.hh"
#include <pxr/usd/sdf/types.h>
#include <pxr/usd/usd/colorSpaceAPI.h>
#include "BLI_string_utf8.h"
#include "DNA_image_types.h"
@ -16,10 +19,64 @@ namespace usdtokens {
static const pxr::TfToken sourceColorSpace("sourceColorSpace", pxr::TfToken::Immortal);
static const pxr::TfToken auto_("auto", pxr::TfToken::Immortal);
static const pxr::TfToken sRGB("sRGB", pxr::TfToken::Immortal);
static const pxr::TfToken data("data", pxr::TfToken::Immortal);
static const pxr::TfToken raw("raw", pxr::TfToken::Immortal);
static const pxr::TfToken RAW("RAW", pxr::TfToken::Immortal);
} // namespace usdtokens
pxr::TfToken colorspace_scene_linear_interop_id()
{
const char *scene_linear_name = IMB_colormanagement_role_colorspace_name_get(
COLOR_ROLE_SCENE_LINEAR);
const ColorSpace *cs = IMB_colormanagement_space_get_named(scene_linear_name);
StringRefNull interop_id = (cs) ? IMB_colormanagement_space_get_interop_id(cs) : "";
return (interop_id.is_empty()) ? pxr::TfToken() : pxr::TfToken(interop_id);
}
void colorspace_apply_to_prim(const pxr::UsdPrim &prim)
{
const pxr::TfToken interop_id = colorspace_scene_linear_interop_id();
if (interop_id.IsEmpty()) {
return;
}
pxr::UsdColorSpaceAPI cs_api = pxr::UsdColorSpaceAPI::Apply(prim);
cs_api.CreateColorSpaceNameAttr(pxr::VtValue(interop_id));
}
static const ColorSpace *colorspace_from_attr(const pxr::UsdAttribute &attr)
{
pxr::TfToken cs_name = pxr::UsdColorSpaceAPI::ComputeColorSpaceName(attr);
if (cs_name.IsEmpty()) {
return nullptr;
}
const ColorSpace *cs = IMB_colormanagement_space_get_named(cs_name.GetText());
if (!cs || IMB_colormanagement_space_is_scene_linear(cs) ||
IMB_colormanagement_space_is_data(cs))
{
return nullptr;
}
return cs;
}
void colorspace_attr_to_scene_linear(const pxr::UsdAttribute &attr, pxr::GfVec3f &color)
{
const ColorSpace *cs = colorspace_from_attr(attr);
if (cs) {
IMB_colormanagement_colorspace_to_scene_linear_v3(color.data(), cs);
}
}
void colorspace_attr_to_scene_linear(const pxr::UsdAttribute &attr,
MutableSpan<ColorGeometry4f> colors)
{
const ColorSpace *cs = colorspace_from_attr(attr);
if (cs && !colors.is_empty()) {
IMB_colormanagement_colorspace_to_scene_linear(&colors[0].r, colors.size(), 1, 4, cs, false);
}
}
static pxr::TfToken get_source_color_space(const pxr::UsdShadeShader &usd_shader)
{
if (!usd_shader) {
@ -55,6 +112,10 @@ void colorspace_to_image_texture(const pxr::UsdShadeShader &usd_shader,
color_space = file_input.GetAttr().GetColorSpace();
}
if (color_space.IsEmpty()) {
color_space = pxr::UsdColorSpaceAPI::ComputeColorSpaceName(usd_shader.GetPrim());
}
if (color_space.IsEmpty()) {
/* At this point, assume the "auto" space and translate accordingly. */
color_space = usdtokens::auto_;
@ -74,10 +135,16 @@ void colorspace_to_image_texture(const pxr::UsdShadeShader &usd_shader,
/* Due to there being a lot of non-compliant USD assets out there, this is
* a special case where we need to check for different spellings here.
* On write, we are *only* using the correct, lower-case "raw" token. */
else if (ELEM(color_space, usdtokens::RAW, usdtokens::raw)) {
else if (ELEM(color_space, usdtokens::data, usdtokens::RAW, usdtokens::raw)) {
STRNCPY_UTF8(image->colorspace_settings.name,
IMB_colormanagement_role_colorspace_name_get(COLOR_ROLE_DATA));
}
else {
const ColorSpace *cs = IMB_colormanagement_space_get_named(color_space.GetText());
if (cs) {
STRNCPY_UTF8(image->colorspace_settings.name, IMB_colormanagement_colorspace_get_name(cs));
}
}
}
void colorspace_from_image_texture(const Image *image, pxr::UsdShadeShader &shader)
@ -95,6 +162,16 @@ void colorspace_from_image_texture(const Image *image, pxr::UsdShadeShader &shad
shader.CreateInput(usdtokens::sourceColorSpace, pxr::SdfValueTypeNames->Token)
.Set(usdtokens::sRGB);
}
/* Write ColorSpaceAPI with the interop ID, which supports any colorspace. */
const ColorSpace *cs = IMB_colormanagement_space_get_named(image->colorspace_settings.name);
if (cs) {
StringRefNull interop_id = IMB_colormanagement_space_get_interop_id(cs);
if (!interop_id.is_empty()) {
pxr::UsdColorSpaceAPI cs_api = pxr::UsdColorSpaceAPI::Apply(shader.GetPrim());
cs_api.CreateColorSpaceNameAttr(pxr::VtValue(pxr::TfToken(interop_id)));
}
}
}
} // namespace blender::io::usd

View file

@ -4,6 +4,13 @@
#pragma once
#include "BLI_color.hh"
#include "BLI_span.hh"
#include <pxr/base/gf/vec3f.h>
#include <pxr/base/tf/token.h>
#include <pxr/usd/usd/attribute.h>
#include <pxr/usd/usd/prim.h>
#include <pxr/usd/usdShade/shader.h>
namespace blender {
@ -12,6 +19,22 @@ struct Image;
namespace blender::io::usd {
/** Get the interop ID for tagging exported USD stages. */
pxr::TfToken colorspace_scene_linear_interop_id();
/** Tag a prim with the scene linear color space. */
void colorspace_apply_to_prim(const pxr::UsdPrim &prim);
/** Convert an imported USD color to scene linear. */
void colorspace_attr_to_scene_linear(const pxr::UsdAttribute &attr, pxr::GfVec3f &color);
/** Convert imported USD color array to scene linear. */
void colorspace_attr_to_scene_linear(const pxr::UsdAttribute &attr,
MutableSpan<ColorGeometry4f> colors);
/** Set the colorspace on an exported USD texture shader from a Blender image. */
void colorspace_from_image_texture(const Image *image, pxr::UsdShadeShader &shader);
/** Set the Blender image colorspace from an imported USD texture shader. */
void colorspace_to_image_texture(const pxr::UsdShadeShader &usd_shader,
const pxr::UsdShadeInput &file_input,

View file

@ -6,6 +6,7 @@
#include "usd.hh"
#include "usd_asset_utils.hh"
#include "usd_colorspace_utils.hh"
#include "usd_private.hh"
#include "usd_utils.hh"
#include "usd_writer_material.hh"
@ -171,6 +172,7 @@ void world_material_to_dome_light(const USDExportParams &params,
/* Create USD dome light. */
pxr::SdfPath env_light_path = get_unique_path(stage, params.root_prim_path + "/env_light");
pxr::UsdLuxDomeLight dome_light = pxr::UsdLuxDomeLight::Define(stage, env_light_path);
colorspace_apply_to_prim(dome_light.GetPrim());
if (res.image) {
/* Use existing image texture file. */

View file

@ -4,6 +4,7 @@
#include "usd_mesh_utils.hh"
#include "usd_attribute_utils.hh"
#include "usd_colorspace_utils.hh"
#include "usd_hash_types.hh"
#include "BKE_attribute.hh"
@ -54,6 +55,7 @@ static void read_face_display_color(Mesh *mesh,
}
}
colorspace_attr_to_scene_linear(primvar.GetAttr(), color_data.span);
color_data.finish();
}

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later */
#include "usd_reader_domelight.hh"
#include "usd_colorspace_utils.hh"
#include "usd_light_convert.hh"
#include <pxr/usd/usdLux/domeLight.h>
@ -80,6 +81,9 @@ static bool get_color(const T &dome_light, const pxr::UsdTimeCode time, pxr::GfV
{
bool has_color = get_authored_value(
dome_light.GetColorAttr(), time, dome_light.GetPrim(), usdtokens::color, color);
if (has_color) {
colorspace_attr_to_scene_linear(dome_light.GetColorAttr(), *color);
}
return has_color;
}

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later */
#include "usd_reader_light.hh"
#include "usd_colorspace_utils.hh"
#include "BLI_math_rotation.h"
@ -158,6 +159,7 @@ void USDLightReader::read_object_data(Main *bmain, const pxr::UsdTimeCode time)
if (pxr::UsdAttribute color_attr = light_api.GetColorAttr()) {
pxr::GfVec3f color;
if (color_attr.Get(&color, time)) {
colorspace_attr_to_scene_linear(color_attr, color);
blight->r = color[0];
blight->g = color[1];
blight->b = color[2];

View file

@ -322,6 +322,7 @@ static void set_viewport_material_props(Material *mtl, const pxr::UsdShadeShader
pxr::VtValue val;
if (attrs[0].Get(&val) && val.IsHolding<pxr::GfVec3f>()) {
pxr::GfVec3f color = val.UncheckedGet<pxr::GfVec3f>();
io::usd::colorspace_attr_to_scene_linear(attrs[0], color);
/* Note: The material is expected to be rendered by the Workbench render engine (Viewport
* Display), so no need to define a material node tree. */
mtl->r = color[0];
@ -721,6 +722,7 @@ bool USDMaterialReader::set_node_input(const pxr::UsdShadeInput &usd_input,
case SOCK_RGBA:
if (val.IsHolding<pxr::GfVec3f>()) {
pxr::GfVec3f v3f = val.UncheckedGet<pxr::GfVec3f>();
colorspace_attr_to_scene_linear(attrs[0], v3f);
copy_v3_v3(sock->default_value_typed<bNodeSocketValueRGBA>()->value, v3f.data());
return true;
}

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later */
#include "usd_writer_abstract.hh"
#include "usd_attribute_utils.hh"
#include "usd_colorspace_utils.hh"
#include "usd_hierarchy_iterator.hh"
#include "usd_utils.hh"
#include "usd_writer_material.hh"
@ -314,6 +315,7 @@ pxr::UsdShadeMaterial USDAbstractWriter::ensure_usd_material_created(
auto prim = usd_material.GetPrim();
add_to_prim_map(prim.GetPath(), &material->id);
write_id_properties(prim, material->id, get_export_time_code());
colorspace_apply_to_prim(prim);
return usd_material;
}

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later */
#include "usd_writer_light.hh"
#include "usd_attribute_utils.hh"
#include "usd_colorspace_utils.hh"
#include "usd_hierarchy_iterator.hh"
#include <pxr/usd/usdLux/diskLight.h>
@ -176,6 +177,7 @@ void USDLightWriter::do_write(HierarchyContext &context)
pxr::UsdPrim prim = usd_light_api.GetPrim();
add_to_prim_map(prim.GetPath(), &light->id);
write_id_properties(prim, light->id, time);
colorspace_apply_to_prim(prim);
/* Only a subset of light types are "boundable". */
if (auto boundable = pxr::UsdGeomBoundable(prim)) {

View file

@ -2164,6 +2164,49 @@ class USDExportTest(AbstractUSDTest):
# Check that the accessibility information is pulled from the export args.
verify_accessibility_api(root_prim, UsdUI.Tokens.default_, root_label, root_description)
def test_export_colorspace(self):
"""Validate that exported USD files have ColorSpaceAPI applied to the
relevant prims with scene linear interop ID."""
bpy.ops.wm.open_mainfile(filepath=str(self.testdir / "empty.blend"))
# Add a light with a specific color.
bpy.ops.object.light_add(type='POINT')
light_obj = bpy.context.active_object
light_obj.data.color = (0.5, 0.3, 0.1)
# Add a mesh with a color attribute and a material.
bpy.ops.mesh.primitive_plane_add()
mesh_obj = bpy.context.active_object
mesh_obj.data.color_attributes.new(name="Col", type='FLOAT_COLOR', domain='POINT')
mat = bpy.data.materials.new(name="Mat")
mat.use_nodes = True
mesh_obj.data.materials.append(mat)
export_path = self.tempdir / "colorspace_export.usda"
self.export_and_validate(
filepath=str(export_path),
evaluation_mode="RENDER",
)
expected = bpy.data.colorspace.working_space_interop_id
def check_colorspace(prim, label):
self.assertTrue(prim.IsValid(), f"{label} prim should exist")
self.assertTrue(prim.HasAPI(Usd.ColorSpaceAPI),
f"{label} prim should have ColorSpaceAPI applied")
cs_name = Usd.ColorSpaceAPI(prim).GetColorSpaceNameAttr().Get()
self.assertEqual(cs_name, expected,
f"{label} colorspace '{cs_name}' should match working space "
f"interop ID '{expected}'")
stage = Usd.Stage.Open(str(export_path))
light_name = light_obj.name
mesh_name = mesh_obj.name
check_colorspace(stage.GetPrimAtPath(f"/root/{light_name}/{light_name}"), "Light")
check_colorspace(stage.GetPrimAtPath(f"/root/_materials/{mat.name}"), "Material")
check_colorspace(stage.GetPrimAtPath(f"/root/{mesh_name}/{mesh_name}"), "Mesh")
class USDHookBase:
instructions = {}

View file

@ -8,9 +8,10 @@ import pathlib
import sys
import tempfile
import unittest
from pxr import Ar, Gf, Sdf, Usd, UsdGeom, UsdShade, UsdUI
from pxr import Ar, Gf, Sdf, Usd, UsdGeom, UsdLux, UsdShade, UsdUI
import bpy
import mathutils
sys.path.append(str(pathlib.Path(__file__).parent.absolute()))
from modules.colored_print import (print_message, use_message_colors)
@ -1847,6 +1848,87 @@ class USDImportTest(AbstractUSDTest):
self.assertIn(alt_description_key, xform, "Alternate description should be imported")
self.assertEqual(xform[alt_description_key], alt_description_attr.Get())
def test_import_colorspace(self):
"""Test colorspace conversion on import, including hierarchy and per-prim override."""
texfile = str(self.testdir / "textures/test_grid_1001.png")
usd_path = self.tempdir / "colorspace_test.usda"
light_color = mathutils.Color((0.8, 0.2, 0.1))
mesh_color = mathutils.Color((0.3, 0.5, 1.0))
stage = Usd.Stage.CreateNew(str(usd_path))
root = UsdGeom.Xform.Define(stage, "/root")
stage.SetDefaultPrim(root.GetPrim())
# Set ACEScg linear colorspace on the root, inherited by children.
cs_api = Usd.ColorSpaceAPI.Apply(root.GetPrim())
cs_api.CreateColorSpaceNameAttr("lin_ap1_scene")
# Light inherits lin_ap1_scene from root.
light = UsdLux.SphereLight.Define(stage, "/root/Light")
light.CreateColorAttr(Gf.Vec3f(*light_color))
light.CreateIntensityAttr(1.0)
# Mesh with displayColor overridden to sRGB.
mesh = UsdGeom.Mesh.Define(stage, "/root/Mesh")
mesh.CreatePointsAttr([Gf.Vec3f(0, 0, 0), Gf.Vec3f(1, 0, 0),
Gf.Vec3f(1, 1, 0), Gf.Vec3f(0, 1, 0)])
mesh.CreateFaceVertexCountsAttr([4])
mesh.CreateFaceVertexIndicesAttr([0, 1, 2, 3])
mesh_cs_api = Usd.ColorSpaceAPI.Apply(mesh.GetPrim())
mesh_cs_api.CreateColorSpaceNameAttr("srgb_rec709_scene")
pv_api = UsdGeom.PrimvarsAPI(mesh)
color_pv = pv_api.CreatePrimvar("displayColor",
Sdf.ValueTypeNames.Color3fArray,
UsdGeom.Tokens.constant)
color_pv.Set([Gf.Vec3f(*mesh_color)])
# Material with a texture that has ColorSpaceAPI set to Non-Color/data.
mat = UsdShade.Material.Define(stage, "/root/Material")
shader = UsdShade.Shader.Define(stage, "/root/Material/Surface")
shader.CreateIdAttr("UsdPreviewSurface")
mat.CreateSurfaceOutput().ConnectToSource(shader.ConnectableAPI(), "surface")
tex = UsdShade.Shader.Define(stage, "/root/Material/Texture")
tex.CreateIdAttr("UsdUVTexture")
tex.CreateInput('file', Sdf.ValueTypeNames.Asset).Set(texfile)
tex.CreateOutput("rgb", Sdf.ValueTypeNames.Float3)
shader.CreateInput("diffuseColor", Sdf.ValueTypeNames.Color3f).ConnectToSource(
tex.ConnectableAPI(), "rgb")
tex_cs_api = Usd.ColorSpaceAPI.Apply(tex.GetPrim())
tex_cs_api.CreateColorSpaceNameAttr("data")
# Bind material to mesh.
UsdShade.MaterialBindingAPI.Apply(mesh.GetPrim()).Bind(mat)
stage.Save()
res = bpy.ops.wm.usd_import(filepath=str(usd_path))
self.assertEqual({'FINISHED'}, res, f"Unable to import USD file {usd_path}")
# Light inherits lin_ap1_scene, gets converted to lin_rec709_scene.
light_data = bpy.data.lights.get("Light")
self.assertIsNotNone(light_data, "Light should be imported")
expected_light = light_color.from_acescg_to_scene_linear()
for i in range(3):
self.assertAlmostEqual(light_data.color[i], expected_light[i], places=2)
# Mesh displayColor has sRGB override, gets converted to lin_rec709_scene.
mesh_obj = bpy.data.objects.get("Mesh")
self.assertIsNotNone(mesh_obj, "Mesh should be imported")
color_attr = mesh_obj.data.color_attributes.get("displayColor")
self.assertIsNotNone(color_attr, "displayColor attribute should exist")
expected_mesh = mesh_color.from_srgb_to_scene_linear()
for sample in color_attr.data:
for i in range(3):
self.assertAlmostEqual(sample.color[i], expected_mesh[i], places=2)
# Texture with "data" colorspace should be imported as Non-Color.
tex_image = bpy.data.images.get("test_grid_1001.png")
self.assertIsNotNone(tex_image, "Texture image should be imported")
self.assertTrue(tex_image.colorspace_settings.name == "Non-Color",
f"Texture should be non-color, got '{tex_image.colorspace_settings.name}'")
class USDImportComparisonTest(unittest.TestCase):
@classmethod