diff --git a/CHANGELOG.md b/CHANGELOG.md index 563a913092..3a12f299fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ - Migrate `wp.array(dtype=X)` type annotations to `wp.array[X]` bracket syntax (Warp 1.12+). - Align articulated `State.body_qd` / FK / IK / Jacobian / mass-matrix linear velocity with COM-referenced motion. If you were comparing `body_qd[:3]` against finite-differenced body-origin motion, recover origin velocity via `v_origin = v_com - omega x r_com_world`. Descendant `FREE` / `DISTANCE` `joint_qd` remains parent-frame and `joint_f` remains a world-frame COM wrench. - Pin `mujoco` and `mujoco-warp` dependencies to `~=3.6.0` +- Store `Model.shape_color` in linear RGB instead of display/sRGB, honor USD-authored color-space metadata for imported material/display colors and textures, and let `SensorTiledCamera` keep packed color/albedo outputs in linear bytes when `RenderConfig.encode_output_srgb=False`. If you write colors directly into `Model.shape_color`, convert any display/sRGB values to linear first with `newton.utils.srgb_to_linear_rgb()`. ### Deprecated diff --git a/newton/_src/geometry/types.py b/newton/_src/geometry/types.py index 921822a4f3..decaec05a1 100644 --- a/newton/_src/geometry/types.py +++ b/newton/_src/geometry/types.py @@ -164,6 +164,8 @@ def __init__( self.color = color # Store texture lazily: strings/paths are kept as-is, arrays are normalized self._texture = _normalize_texture_input(texture) + self.texture_color_space: str = "auto" + """Source color space of :attr:`texture`: ``"auto"``, ``"srgb"``, or ``"raw"``.""" self._roughness = roughness self._metallic = metallic self.is_solid = is_solid @@ -689,6 +691,7 @@ def copy( roughness=self._roughness, metallic=self._metallic, ) + m.texture_color_space = self.texture_color_space if not recompute_inertia: m.inertia = self.inertia m.mass = self.mass @@ -841,7 +844,7 @@ def uvs(self): @property def color(self) -> Vec3 | None: - """Optional display RGB color with values in [0, 1].""" + """Optional linear RGB color with values in [0, 1].""" return self._color @color.setter @@ -857,9 +860,24 @@ def texture(self) -> str | np.ndarray | None: def texture(self, value: str | np.ndarray | None): # Store texture lazily: strings/paths are kept as-is, arrays are normalized self._texture = _normalize_texture_input(value) + self.texture_color_space = "auto" self._texture_hash = None self._cached_hash = None + @property + def texture_color_space(self) -> str: + """Source color space of the assigned texture. + + One of ``"auto"`` (assume sRGB for the raytracer), ``"raw"`` (linear / + data), or ``"srgb"`` (explicit sRGB). Reset to ``"auto"`` whenever + :attr:`texture` is reassigned. + """ + return self._texture_color_space + + @texture_color_space.setter + def texture_color_space(self, value: str): + self._texture_color_space = value + @property def texture_hash(self) -> int: """Content-based hash of the assigned texture. diff --git a/newton/_src/sensors/sensor_tiled_camera.py b/newton/_src/sensors/sensor_tiled_camera.py index cc043f5ac4..0be755ceef 100644 --- a/newton/_src/sensors/sensor_tiled_camera.py +++ b/newton/_src/sensors/sensor_tiled_camera.py @@ -37,10 +37,14 @@ class SensorTiledCamera(metaclass=_SensorTiledCameraMeta): Renders up to five image channels per (world, camera) pair: - - **color** -- RGBA shaded image (``uint32``). + - **color** -- RGBA shaded image packed into ``uint32``. By default these + bytes are display/sRGB-encoded; set + ``SensorTiledCamera.RenderConfig.encode_output_srgb=False`` to keep them + linear. - **depth** -- ray-hit distance [m] (``float32``); negative means no hit. - **normal** -- surface normal at hit point (``vec3f``). - - **albedo** -- unshaded surface color (``uint32``). + - **albedo** -- unshaded surface color packed into ``uint32`` using the + same output encoding convention as **color**. - **shape_index** -- shape id per pixel (``uint32``). All output arrays have shape ``(world_count, camera_count, height, width)``. Use the ``flatten_*`` helpers to @@ -48,6 +52,11 @@ class SensorTiledCamera(metaclass=_SensorTiledCameraMeta): Shapes without the ``VISIBLE`` flag are excluded. + Shape colors and texture lighting are evaluated in linear RGB internally. + Mesh textures authored as sRGB are converted to linear before shading, and + the packed ``color``/``albedo`` outputs are optionally encoded back to + display/sRGB at the end. + Example: :: @@ -119,7 +128,9 @@ def __init__(self, model: Model, *, config: Config | RenderConfig | None = None, config: Rendering configuration. Pass a :class:`RenderConfig` to control raytrace settings directly, or ``None`` to use defaults. The legacy :class:`Config` dataclass is still - accepted but deprecated. + accepted but deprecated. Use + ``RenderConfig.encode_output_srgb`` to control whether packed + ``color``/``albedo`` outputs are display-encoded or left linear. load_textures: Load texture data from the model. Set to ``False`` to skip texture loading when textures are not needed. """ @@ -196,11 +207,14 @@ def update( camera_transforms: Camera-to-world transforms, shape ``(camera_count, world_count)``. camera_rays: Camera-space rays from :meth:`compute_pinhole_camera_rays`, shape ``(camera_count, height, width, 2)``. - color_image: Output for RGBA color. None to skip. + color_image: Output for packed RGBA color. The bytes are sRGB by + default, or linear when + ``render_config.encode_output_srgb=False``. None to skip. depth_image: Output for ray-hit distance [m]. None to skip. shape_index_image: Output for per-pixel shape id. None to skip. normal_image: Output for surface normals. None to skip. - albedo_image: Output for unshaded surface color. None to skip. + albedo_image: Output for packed RGBA albedo. Uses the same output + encoding convention as ``color_image``. None to skip. refit_bvh: Refit the BVH before rendering. clear_data: Values to clear output buffers with. See :attr:`DEFAULT_CLEAR_DATA`, :attr:`GRAY_CLEAR_DATA`. diff --git a/newton/_src/sensors/warp_raytrace/render.py b/newton/_src/sensors/warp_raytrace/render.py index e36f180f9f..02c25e548d 100644 --- a/newton/_src/sensors/warp_raytrace/render.py +++ b/newton/_src/sensors/warp_raytrace/render.py @@ -8,6 +8,7 @@ import warp as wp from ...geometry import Gaussian, GeoType +from ...utils.color import linear_to_srgb_wp from . import lighting, raytrace, textures, tiling from .types import MeshData, RenderOrder, TextureData @@ -194,7 +195,10 @@ def render_megakernel( albedo_color = wp.cw_mul(albedo_color, tex_color) if wp.static(state.render_albedo): - out_albedo[out_index] = tiling.pack_rgba_to_uint32(albedo_color, 1.0) + packed_albedo = albedo_color + if wp.static(config.encode_output_srgb): + packed_albedo = linear_to_srgb_wp(packed_albedo) + out_albedo[out_index] = tiling.pack_rgba_to_uint32(packed_albedo, 1.0) if not wp.static(state.render_color): return @@ -243,6 +247,9 @@ def render_megakernel( ) shaded_color = shaded_color + albedo_color * light_contribution - out_color[out_index] = tiling.pack_rgba_to_uint32(shaded_color, 1.0) + packed_color = shaded_color + if wp.static(config.encode_output_srgb): + packed_color = linear_to_srgb_wp(packed_color) + out_color[out_index] = tiling.pack_rgba_to_uint32(packed_color, 1.0) return render_megakernel diff --git a/newton/_src/sensors/warp_raytrace/render_context.py b/newton/_src/sensors/warp_raytrace/render_context.py index 3522ec70c8..105178e9cf 100644 --- a/newton/_src/sensors/warp_raytrace/render_context.py +++ b/newton/_src/sensors/warp_raytrace/render_context.py @@ -14,6 +14,7 @@ from ...geometry import Gaussian, GeoType, Mesh, ShapeFlags from ...sim import Model, State from ...utils import load_texture, normalize_texture +from ...utils.color import texture_color_space_to_id from .bvh import ( compute_bvh_group_roots, compute_particle_bvh_bounds, @@ -697,7 +698,8 @@ def __load_texture_and_mesh_data(self, model: Model, load_textures: bool): for shape in model.shape_source: if isinstance(shape, Mesh): if shape.texture is not None and load_textures: - if shape.texture_hash not in texture_hashes: + texture_key = (shape.texture_hash, shape.texture_color_space) + if texture_key not in texture_hashes: pixels = load_texture(shape.texture) if pixels is None: raise ValueError(f"Failed to load texture: {shape.texture}") @@ -707,7 +709,7 @@ def __load_texture_and_mesh_data(self, model: Model, load_textures: bool): if pixels.dtype != np.uint8: pixels = pixels.astype(np.uint8, copy=False) - texture_hashes[shape.texture_hash] = len(self.__texture_data) + texture_hashes[texture_key] = len(self.__texture_data) data = TextureData() data.texture = wp.Texture2D( @@ -720,9 +722,10 @@ def __load_texture_and_mesh_data(self, model: Model, load_textures: bool): device=self.device, ) data.repeat = wp.vec2f(1.0, 1.0) + data.color_space = texture_color_space_to_id(shape.texture_color_space) self.__texture_data.append(data) - texture_data_ids.append(texture_hashes[shape.texture_hash]) + texture_data_ids.append(texture_hashes[texture_key]) else: texture_data_ids.append(-1) diff --git a/newton/_src/sensors/warp_raytrace/textures.py b/newton/_src/sensors/warp_raytrace/textures.py index 23fbc42fad..67bf7a6071 100644 --- a/newton/_src/sensors/warp_raytrace/textures.py +++ b/newton/_src/sensors/warp_raytrace/textures.py @@ -4,6 +4,7 @@ import warp as wp from ...geometry import GeoType +from ...utils.color import TEXTURE_COLOR_SPACE_RAW_ID, srgb_to_linear_wp from .types import MeshData, TextureData @@ -15,7 +16,10 @@ def flip_v(uv: wp.vec2f) -> wp.vec2f: @wp.func def sample_texture_2d(uv: wp.vec2f, texture_data: TextureData) -> wp.vec3f: color = wp.texture_sample(texture_data.texture, uv, dtype=wp.vec4f) - return wp.vec3f(color[0], color[1], color[2]) + rgb = wp.vec3f(color[0], color[1], color[2]) + if texture_data.color_space != TEXTURE_COLOR_SPACE_RAW_ID: + rgb = srgb_to_linear_wp(rgb) + return rgb @wp.func diff --git a/newton/_src/sensors/warp_raytrace/tiling.py b/newton/_src/sensors/warp_raytrace/tiling.py index 632d8d8ac9..611709bc39 100644 --- a/newton/_src/sensors/warp_raytrace/tiling.py +++ b/newton/_src/sensors/warp_raytrace/tiling.py @@ -69,7 +69,8 @@ def tid_to_coord_view_priority(tid: wp.int32, camera_count: wp.int32, width: wp. @wp.func def pack_rgba_to_uint32(rgb: wp.vec3f, alpha: wp.float32) -> wp.uint32: - """Pack RGBA values into a single uint32 for efficient memory access.""" + """Pack RGB bytes plus alpha into a single ``uint32``.""" + return ( (wp.clamp(wp.uint32(alpha * 255.0), wp.uint32(0), wp.uint32(255)) << wp.uint32(24)) | (wp.clamp(wp.uint32(rgb[2] * 255.0), wp.uint32(0), wp.uint32(255)) << wp.uint32(16)) diff --git a/newton/_src/sensors/warp_raytrace/types.py b/newton/_src/sensors/warp_raytrace/types.py index 51fb0bb5ac..183990a55e 100644 --- a/newton/_src/sensors/warp_raytrace/types.py +++ b/newton/_src/sensors/warp_raytrace/types.py @@ -60,6 +60,14 @@ class RenderConfig: enable_backface_culling: bool = True """Cull back-facing triangles.""" + encode_output_srgb: bool = True + """Encode packed color/albedo outputs to display/sRGB. + + When ``False``, :class:`SensorTiledCamera` writes packed linear RGB bytes + instead, which can be useful for training pipelines that want to avoid + display transfer functions. + """ + render_order: int = RenderOrder.PIXEL_PRIORITY """Render traversal order (see :class:`RenderOrder`).""" @@ -115,7 +123,9 @@ class TextureData: Attributes: texture: 2D Texture as ``wp.Texture2D``. repeat: UV tiling factors along U and V axes. + color_space: ``0`` for raw/linear textures, ``1`` for sRGB textures. """ texture: wp.Texture2D repeat: wp.vec2f + color_space: wp.int32 diff --git a/newton/_src/sensors/warp_raytrace/utils.py b/newton/_src/sensors/warp_raytrace/utils.py index 861b3318b4..100a1f02e5 100644 --- a/newton/_src/sensors/warp_raytrace/utils.py +++ b/newton/_src/sensors/warp_raytrace/utils.py @@ -160,7 +160,10 @@ def create_color_image_output(self, width: int, height: int, camera_count: int = camera_count: Number of cameras. Returns: - Array of shape ``(world_count, camera_count, height, width)``, dtype ``uint32``. + Array of shape ``(world_count, camera_count, height, width)``, + dtype ``uint32``. Each pixel stores packed RGBA bytes in either + display/sRGB or linear space depending on + ``render_config.encode_output_srgb``. """ return wp.zeros( (self.__render_context.world_count, camera_count, height, width), @@ -228,7 +231,10 @@ def create_albedo_image_output(self, width: int, height: int, camera_count: int camera_count: Number of cameras. Returns: - Array of shape ``(world_count, camera_count, height, width)``, dtype ``uint32``. + Array of shape ``(world_count, camera_count, height, width)``, + dtype ``uint32``. Each pixel stores packed RGBA bytes in either + display/sRGB or linear space depending on + ``render_config.encode_output_srgb``. """ return wp.zeros( (self.__render_context.world_count, camera_count, height, width), @@ -338,7 +344,10 @@ def flatten_color_image_to_rgba( Arranges ``(world_count * camera_count)`` tiles in a grid. Each tile shows one camera's view of one world. Args: - image: Color output from :meth:`~SensorTiledCamera.update`, shape ``(world_count, camera_count, height, width)``. + image: Color output from :meth:`~SensorTiledCamera.update`, shape + ``(world_count, camera_count, height, width)``. The packed + bytes are copied as-is; no additional color-space conversion is + performed here. out_buffer: Pre-allocated RGBA buffer. If None, allocates a new one. worlds_per_row: Tiles per row in the grid. If None, picks a square-ish layout. """ diff --git a/newton/_src/sim/builder.py b/newton/_src/sim/builder.py index 79807e853c..b09c0b68eb 100644 --- a/newton/_src/sim/builder.py +++ b/newton/_src/sim/builder.py @@ -49,6 +49,7 @@ from ..math import quat_between_vectors_robust from ..usd.schema_resolver import SchemaResolver from ..utils import compute_world_offsets +from ..utils.color import srgb_to_linear_rgb from ..utils.mesh import MeshAdjacency from .enums import ( BodyFlags, @@ -188,8 +189,15 @@ class ModelBuilder: @staticmethod def _shape_palette_color(index: int) -> tuple[float, float, float]: + """Return the default palette color converted from authored sRGB to linear.""" color = ModelBuilder._SHAPE_COLOR_PALETTE[index % len(ModelBuilder._SHAPE_COLOR_PALETTE)] - return (color[0] / 255.0, color[1] / 255.0, color[2] / 255.0) + return srgb_to_linear_rgb((color[0] / 255.0, color[1] / 255.0, color[2] / 255.0)) + + @staticmethod + def _normalize_shape_color(color: Vec3 | None) -> tuple[float, float, float] | None: + if color is None: + return None + return (float(color[0]), float(color[1]), float(color[2])) @dataclass class ActuatorEntry: @@ -787,6 +795,11 @@ def __init__(self, up_axis: AxisType = Axis.Z, gravity: float = -9.81): """Default shape configuration used when shape-creation methods are called with ``cfg=None``. Update this object before adding shapes to set default contact/material properties.""" + self.default_shape_color: tuple[float, float, float] | None = None + """Fallback linear RGB color for shapes without an explicit color. + If ``None``, shapes without an explicit or imported color use the + per-shape palette sequence, whose authored sRGB swatches are converted + to linear before storage.""" self.default_joint_cfg = ModelBuilder.JointDofConfig() """Default joint DoF configuration used when joint DoF configuration is omitted.""" @@ -896,7 +909,7 @@ def __init__(self, up_axis: AxisType = Axis.Z, gravity: float = -9.81): self.shape_source: list[Any] = [] """Source geometry objects accumulated for :attr:`Model.shape_source`.""" self.shape_color: list[Vec3] = [] - """Resolved display colors accumulated for :attr:`Model.shape_color`.""" + """Resolved linear colors accumulated for :attr:`Model.shape_color`.""" self.shape_is_solid: list[bool] = [] """Solid-vs-hollow flags accumulated for :attr:`Model.shape_is_solid`.""" self.shape_margin: list[float] = [] @@ -5200,7 +5213,10 @@ def add_shape( scale: The scale of the geometry. The interpretation depends on the shape type. Defaults to `(1.0, 1.0, 1.0)` if `None`. src: The source geometry data, e.g., a :class:`Mesh` object for `GeoType.MESH`. Defaults to `None`. is_static: If `True`, the shape will have zero mass, and its density property in `cfg` will be effectively ignored for mass calculation. Typically used for fixed, non-movable collision geometry. Defaults to `False`. - color: Optional display RGB color with values in [0, 1]. If `None`, uses the default per-shape display color. Mesh-backed shapes fall back to :attr:`~newton.Mesh.color`. + color: Optional linear RGB color with values in [0, 1]. If `None`, + uses :attr:`default_shape_color` when set. Mesh-backed shapes + fall back to :attr:`~newton.Mesh.color`, and if no fallback color + is configured the per-shape palette sequence is used. label: An optional unique label for identifying the shape. If `None`, a default label is automatically generated (e.g., "shape_N"). Defaults to `None`. custom_attributes: Dictionary of custom attribute names to values. @@ -5278,9 +5294,11 @@ def add_shape( ~ShapeFlags.HYDROELASTIC ) # Falling back to mesh/primitive collisions for plane and hfield shapes - resolved_color = color + resolved_color = ModelBuilder._normalize_shape_color(color) if resolved_color is None and src is not None: - resolved_color = getattr(src, "color", None) + resolved_color = ModelBuilder._normalize_shape_color(getattr(src, "color", None)) + if resolved_color is None: + resolved_color = ModelBuilder._normalize_shape_color(self.default_shape_color) if resolved_color is None: resolved_color = ModelBuilder._shape_palette_color(shape) @@ -5365,7 +5383,9 @@ def add_shape_plane( length: The visual/collision extent of the plane along its local Y-axis. If `0.0`, considered infinite for collision. Defaults to `10.0`. body: The index of the parent body this shape belongs to. Use -1 for world-static planes. Defaults to `-1`. cfg: The configuration for the shape's physical and collision properties. If `None`, :attr:`default_shape_cfg` is used. Defaults to `None`. - color: Optional display RGB color with values in [0, 1]. If `None`, uses the per-shape display color. + color: Optional linear RGB color with values in [0, 1]. If `None`, + uses :attr:`default_shape_color` when set, otherwise the + per-shape palette color. label: An optional unique label for identifying the shape. If `None`, a default label is automatically generated. Defaults to `None`. custom_attributes: Dictionary of custom attribute values for SHAPE frequency attributes. @@ -5412,7 +5432,9 @@ def add_ground_plane( Args: height: The vertical offset of the ground plane along the up-vector axis. Positive values raise the plane, negative values lower it. Defaults to `0.0`. cfg: The configuration for the shape's physical and collision properties. If `None`, :attr:`default_shape_cfg` is used. Defaults to `None`. - color: Optional display RGB color with values in [0, 1]. Defaults to the ground plane color ``(0.125, 0.125, 0.15)``. Pass ``None`` to use the per-shape palette color instead. + color: Optional linear RGB color with values in [0, 1]. Defaults to + the ground plane color ``(0.125, 0.125, 0.15)``. Pass ``None`` to + use the per-shape palette color instead. label: An optional unique label for identifying the shape. If `None`, a default label is automatically generated. Defaults to `None`. Returns: @@ -5446,7 +5468,9 @@ def add_shape_sphere( radius: The radius of the sphere. Defaults to `1.0`. cfg: The configuration for the shape's properties. If `None`, uses :attr:`default_shape_cfg` (or :attr:`default_site_cfg` when `as_site=True`). If `as_site=True` and `cfg` is provided, a copy is made and site invariants are enforced via `mark_as_site()`. Defaults to `None`. as_site: If `True`, creates a site (non-colliding reference point) instead of a collision shape. Defaults to `False`. - color: Optional display RGB color with values in [0, 1]. If `None`, uses the default per-shape display color. + color: Optional linear RGB color with values in [0, 1]. If `None`, + uses :attr:`default_shape_color` when set, otherwise the + per-shape palette color. label: An optional unique label for identifying the shape. If `None`, a default label is automatically generated. Defaults to `None`. custom_attributes: Dictionary of custom attribute names to values. @@ -5502,7 +5526,9 @@ def add_shape_ellipsoid( rz: The semi-axis of the ellipsoid along its local Z-axis [m]. Defaults to `0.5`. cfg: The configuration for the shape's properties. If `None`, uses :attr:`default_shape_cfg` (or :attr:`default_site_cfg` when `as_site=True`). If `as_site=True` and `cfg` is provided, a copy is made and site invariants are enforced via `mark_as_site()`. Defaults to `None`. as_site: If `True`, creates a site (non-colliding reference point) instead of a collision shape. Defaults to `False`. - color: Optional display RGB color with values in [0, 1]. If `None`, uses the default per-shape display color. + color: Optional linear RGB color with values in [0, 1]. If `None`, + uses :attr:`default_shape_color` when set, otherwise the + per-shape palette color. label: An optional unique label for identifying the shape. If `None`, a default label is automatically generated. Defaults to `None`. custom_attributes: Dictionary of custom attribute names to values. @@ -5591,7 +5617,9 @@ def add_shape_box( hz: The half-extent of the box along its local Z-axis. Defaults to `0.5`. cfg: The configuration for the shape's properties. If `None`, uses :attr:`default_shape_cfg` (or :attr:`default_site_cfg` when `as_site=True`). If `as_site=True` and `cfg` is provided, a copy is made and site invariants are enforced via `mark_as_site()`. Defaults to `None`. as_site: If `True`, creates a site (non-colliding reference point) instead of a collision shape. Defaults to `False`. - color: Optional display RGB color with values in [0, 1]. If `None`, uses the default per-shape display color. + color: Optional linear RGB color with values in [0, 1]. If `None`, + uses :attr:`default_shape_color` when set, otherwise the + per-shape palette color. label: An optional unique label for identifying the shape. If `None`, a default label is automatically generated. Defaults to `None`. custom_attributes: Dictionary of custom attribute names to values. @@ -5639,7 +5667,9 @@ def add_shape_capsule( half_height: The half-length of the capsule's central cylindrical segment (excluding the hemispherical ends). Defaults to `0.5`. cfg: The configuration for the shape's properties. If `None`, uses :attr:`default_shape_cfg` (or :attr:`default_site_cfg` when `as_site=True`). If `as_site=True` and `cfg` is provided, a copy is made and site invariants are enforced via `mark_as_site()`. Defaults to `None`. as_site: If `True`, creates a site (non-colliding reference point) instead of a collision shape. Defaults to `False`. - color: Optional display RGB color with values in [0, 1]. If `None`, uses the default per-shape display color. + color: Optional linear RGB color with values in [0, 1]. If `None`, + uses :attr:`default_shape_color` when set, otherwise the + per-shape palette color. label: An optional unique label for identifying the shape. If `None`, a default label is automatically generated. Defaults to `None`. custom_attributes: Dictionary of custom attribute names to values. @@ -5692,7 +5722,9 @@ def add_shape_cylinder( half_height: The half-length of the cylinder along the Z-axis. Defaults to `0.5`. cfg: The configuration for the shape's properties. If `None`, uses :attr:`default_shape_cfg` (or :attr:`default_site_cfg` when `as_site=True`). If `as_site=True` and `cfg` is provided, a copy is made and site invariants are enforced via `mark_as_site()`. Defaults to `None`. as_site: If `True`, creates a site (non-colliding reference point) instead of a collision shape. Defaults to `False`. - color: Optional display RGB color with values in [0, 1]. If `None`, uses the default per-shape display color. + color: Optional linear RGB color with values in [0, 1]. If `None`, + uses :attr:`default_shape_color` when set, otherwise the + per-shape palette color. label: An optional unique label for identifying the shape. If `None`, a default label is automatically generated. Defaults to `None`. custom_attributes: Dictionary of custom attribute values for SHAPE frequency attributes. @@ -5746,7 +5778,9 @@ def add_shape_cone( half_height: The half-height of the cone (distance from the geometric center to either the base or apex). The total height is 2*half_height. Defaults to `0.5`. cfg: The configuration for the shape's physical and collision properties. If `None`, :attr:`default_shape_cfg` is used. Defaults to `None`. as_site: If `True`, creates a site (non-colliding reference point) instead of a collision shape. Defaults to `False`. - color: Optional display RGB color with values in [0, 1]. If `None`, uses the default per-shape display color. + color: Optional linear RGB color with values in [0, 1]. If `None`, + uses :attr:`default_shape_color` when set, otherwise the + per-shape palette color. label: An optional unique label for identifying the shape. If `None`, a default label is automatically generated. Defaults to `None`. custom_attributes: Dictionary of custom attribute values for SHAPE frequency attributes. @@ -5795,7 +5829,8 @@ def add_shape_mesh( mesh: The :class:`Mesh` object containing the vertex and triangle data. Defaults to `None`. scale: The scale of the mesh. Defaults to `None`, in which case the scale is `(1.0, 1.0, 1.0)`. cfg: The configuration for the shape's physical and collision properties. If `None`, :attr:`default_shape_cfg` is used. Defaults to `None`. - color: Optional display RGB color with values in [0, 1]. If `None`, falls back to :attr:`~newton.Mesh.color` when available. + color: Optional linear RGB color with values in [0, 1]. If `None`, + falls back to :attr:`~newton.Mesh.color` when available. label: An optional unique label for identifying the shape. If `None`, a default label is automatically generated. Defaults to `None`. custom_attributes: Dictionary of custom attribute values for SHAPE frequency attributes. @@ -5836,7 +5871,8 @@ def add_shape_convex_hull( mesh: The :class:`Mesh` object containing the vertex data for the convex hull. Defaults to `None`. scale: The scale of the convex hull. Defaults to `None`, in which case the scale is `(1.0, 1.0, 1.0)`. cfg: The configuration for the shape's physical and collision properties. If `None`, :attr:`default_shape_cfg` is used. Defaults to `None`. - color: Optional display RGB color with values in [0, 1]. If `None`, falls back to :attr:`~newton.Mesh.color` when available. + color: Optional linear RGB color with values in [0, 1]. If `None`, + falls back to :attr:`~newton.Mesh.color` when available. label: An optional unique label for identifying the shape. If `None`, a default label is automatically generated. Defaults to `None`. custom_attributes: Dictionary of custom attribute values for SHAPE frequency attributes. @@ -5879,7 +5915,9 @@ def add_shape_heightfield( heightfield: The :class:`Heightfield` object containing the elevation grid data. Defaults to `None`. scale: The scale of the heightfield. Defaults to `None`, in which case the scale is `(1.0, 1.0, 1.0)`. cfg: The configuration for the shape's physical and collision properties. If `None`, :attr:`default_shape_cfg` is used. Defaults to `None`. - color: Optional display RGB color with values in [0, 1]. If `None`, uses the default per-shape display color. + color: Optional linear RGB color with values in [0, 1]. If `None`, + uses :attr:`default_shape_color` when set, otherwise the + per-shape palette color. label: An optional label for identifying the shape. If `None`, a default label is automatically generated. Defaults to `None`. custom_attributes: Dictionary of custom attribute values for SHAPE frequency attributes. @@ -5935,7 +5973,9 @@ def add_shape_gaussian( - ``None``: no collision (render-only). - ``"convex_hull"``: auto-generate convex hull from Gaussian positions. - A :class:`Mesh` instance: use the provided mesh as collision proxy. - color: Optional display RGB color with values in [0, 1]. If `None`, uses the default per-shape display color. + color: Optional linear RGB color with values in [0, 1]. If `None`, + uses :attr:`default_shape_color` when set, otherwise the + per-shape palette color. label: Optional unique label for identifying the shape. custom_attributes: Dictionary of custom attribute values for SHAPE frequency attributes. diff --git a/newton/_src/sim/model.py b/newton/_src/sim/model.py index 3cb783e8a7..8e1e25a38d 100644 --- a/newton/_src/sim/model.py +++ b/newton/_src/sim/model.py @@ -238,7 +238,11 @@ def __init__(self, device: Devicelike | None = None): self.shape_scale: wp.array[wp.vec3] | None = None """Shape 3D scale, shape [shape_count], vec3.""" self.shape_color: wp.array[wp.vec3] | None = None - """Shape display colors [0, 1], shape [shape_count], vec3.""" + """Shape linear RGB colors [0, 1], shape [shape_count], vec3. + + Renderers convert these linear values to display/sRGB only when they + need encoded image or UI output. + """ self.shape_filter: wp.array[wp.int32] | None = None """Shape filter group, shape [shape_count], int.""" diff --git a/newton/_src/usd/utils.py b/newton/_src/usd/utils.py index 4772459fbf..0ebf4353c2 100644 --- a/newton/_src/usd/utils.py +++ b/newton/_src/usd/utils.py @@ -14,6 +14,10 @@ from ..core.types import Axis, AxisType from ..geometry import Gaussian, Mesh from ..sim.model import Model +from ..utils.color import ( + TEXTURE_COLOR_SPACE_AUTO, + normalize_texture_color_space, +) AttributeAssignment = Model.AttributeAssignment AttributeFrequency = Model.AttributeFrequency @@ -1418,7 +1422,43 @@ def _resolve_asset_path( return asset_path -def _find_texture_in_shader(shader: UsdShade.Shader | None, prim: Usd.Prim) -> str | None: +def _resolve_texture_color_space( + shader: UsdShade.Shader | None, + file_attr: Usd.Attribute | None = None, +) -> str: + """Resolve a texture's authored source color space.""" + + source_color_space = None + if shader is not None: + inp = shader.GetInput("sourceColorSpace") + if inp: + try: + value = inp.Get() + except Exception: + value = None + if value is None: + try: + attrs = UsdShade.Utils.GetValueProducingAttributes(inp) + except Exception: + attrs = () + if attrs: + try: + value = attrs[0].Get() + except Exception: + value = None + if value is not None: + source_color_space = str(value) + + if source_color_space is None and file_attr is not None and Usd is not None: + try: + source_color_space = str(Usd.ColorSpaceAPI.ComputeColorSpaceName(file_attr, None) or "") + except Exception: + source_color_space = None + + return normalize_texture_color_space(source_color_space) + + +def _find_texture_in_shader(shader: UsdShade.Shader | None, prim: Usd.Prim) -> tuple[str | None, str]: """Search a shader network for a connected texture asset. Args: @@ -1426,10 +1466,11 @@ def _find_texture_in_shader(shader: UsdShade.Shader | None, prim: Usd.Prim) -> s prim: The prim providing stage context for asset resolution. Returns: - Resolved texture asset path, or ``None`` if not found. + Tuple of ``(texture_path, color_space)``. If no texture is found, returns + ``(None, "auto")``. """ if shader is None: - return None + return None, TEXTURE_COLOR_SPACE_AUTO shader_id = shader.GetIdAttr().Get() if shader_id == "UsdUVTexture": file_input = shader.GetInput("file") @@ -1437,8 +1478,13 @@ def _find_texture_in_shader(shader: UsdShade.Shader | None, prim: Usd.Prim) -> s attrs = UsdShade.Utils.GetValueProducingAttributes(file_input) if attrs: asset = attrs[0].Get() - return _resolve_asset_path(asset, prim, attrs[0]) - return None + return _resolve_asset_path(asset, prim, attrs[0]), _resolve_texture_color_space(shader, attrs[0]) + asset = file_input.Get() + if asset: + return _resolve_asset_path(asset, prim, file_input.GetAttr()), _resolve_texture_color_space( + shader, file_input.GetAttr() + ) + return None, TEXTURE_COLOR_SPACE_AUTO if shader_id == "UsdPreviewSurface": for input_name in ("diffuseColor", "baseColor"): shader_input = shader.GetInput(input_name) @@ -1446,21 +1492,25 @@ def _find_texture_in_shader(shader: UsdShade.Shader | None, prim: Usd.Prim) -> s source = shader_input.GetConnectedSource() if source: source_shader = UsdShade.Shader(source[0].GetPrim()) - texture = _find_texture_in_shader(source_shader, prim) + texture, color_space = _find_texture_in_shader(source_shader, prim) if texture: - return texture - return None + return texture, color_space + return None, TEXTURE_COLOR_SPACE_AUTO -def _get_input_value(shader: UsdShade.Shader | None, names: tuple[str, ...]) -> Any | None: - """Fetch the effective input value from a shader, following connections.""" +def _get_input_value_with_attr( + shader: UsdShade.Shader | None, + names: tuple[str, ...], +) -> tuple[Any | None, Usd.Attribute | None]: + """Fetch the effective input value from a shader and its source attribute.""" + if shader is None: - return None + return None, None try: if not shader.GetPrim().IsValid(): - return None + return None, None except Exception: - return None + return None, None for name in names: inp = shader.GetInput(name) @@ -1469,27 +1519,79 @@ def _get_input_value(shader: UsdShade.Shader | None, names: tuple[str, ...]) -> try: attrs = UsdShade.Utils.GetValueProducingAttributes(inp) except Exception: - continue + attrs = () if attrs: value = attrs[0].Get() if value is not None: - return value - return None + return value, attrs[0] + value = inp.Get() + if value is not None: + return value, inp.GetAttr() + return None, None + + +def _get_attr_color_space_name(attr: Usd.Attribute | None) -> str: + """Return the resolved source color-space name for a color attribute.""" + + if attr is None or Usd is None or Gf is None: + return "" + try: + token = attr.GetColorSpace() + except Exception: + token = None + if token: + return str(token) + try: + token = Usd.ColorSpaceAPI.ComputeColorSpaceName(attr, None) + except Exception: + return "" + return str(token or "") + + +def _get_input_value(shader: UsdShade.Shader | None, names: tuple[str, ...]) -> Any | None: + """Fetch the effective input value from a shader, following connections.""" + return _get_input_value_with_attr(shader, names)[0] def _empty_material_properties() -> dict[str, Any]: """Return an empty material properties dictionary.""" - return {"color": None, "metallic": None, "roughness": None, "texture": None} + return { + "color": None, + "metallic": None, + "roughness": None, + "texture": None, + "texture_color_space": TEXTURE_COLOR_SPACE_AUTO, + } -def _coerce_color(value: Any) -> tuple[float, float, float] | None: - """Coerce a value to an RGB color tuple, or None if not possible.""" +def _coerce_color(value: Any, attr: Usd.Attribute | None = None) -> tuple[float, float, float] | None: + """Coerce a value to a linear RGB color tuple, or None if not possible.""" + if value is None: return None color_np = np.array(value, dtype=np.float32).reshape(-1) - if color_np.size >= 3: - return (float(color_np[0]), float(color_np[1]), float(color_np[2])) - return None + if color_np.size < 3: + return None + + rgb = color_np[:3] + color_space_name = _get_attr_color_space_name(attr) + if color_space_name in ("", "lin_rec709_scene"): + return (float(rgb[0]), float(rgb[1]), float(rgb[2])) + if color_space_name in ("raw", "data", "identity", "unknown"): + return (float(rgb[0]), float(rgb[1]), float(rgb[2])) + if Gf is None: + return (float(rgb[0]), float(rgb[1]), float(rgb[2])) + + try: + source_space = Gf.ColorSpace(color_space_name) + linear_space = Gf.ColorSpace(Gf.ColorSpaceNames.LinearRec709) + converted = linear_space.Convert( + source_space, + Gf.Vec3f(float(rgb[0]), float(rgb[1]), float(rgb[2])), + ).GetRGB() + return (float(converted[0]), float(converted[1]), float(converted[2])) + except Exception: + return (float(rgb[0]), float(rgb[1]), float(rgb[2])) def _coerce_float(value: Any) -> float | None: @@ -1502,6 +1604,64 @@ def _coerce_float(value: Any) -> float | None: return None +def _multiply_colors( + color: tuple[float, float, float] | None, + tint: tuple[float, float, float] | None, +) -> tuple[float, float, float] | None: + """Multiply two RGB colors componentwise.""" + if tint is None: + return color + if color is None: + return tuple(t * 0.18 for t in tint) if tint is not None else None + return tuple(float(c * t) for c, t in zip(color, tint, strict=True)) + + +def _get_material_input_value(material: UsdShade.Material | None, names: Sequence[str]) -> Any | None: + """Return the first authored material input value matching any name in order.""" + if material is None: + return None + for name in names: + inp = material.GetInput(name) + if not inp: + continue + value = inp.Get() + if value is not None: + return value + return None + + +def _get_material_input_value_with_attr( + material: UsdShade.Material | None, + names: Sequence[str], +) -> tuple[Any | None, Usd.Attribute | None]: + """Return the first authored material input value and its source attribute.""" + + if material is None: + return None, None + for name in names: + inp = material.GetInput(name) + if not inp: + continue + value = inp.Get() + if value is not None: + return value, inp.GetAttr() + return None, None + + +def _resolve_diffuse_tint( + shader: UsdShade.Shader | None, + material: UsdShade.Material | None, +) -> tuple[float, float, float] | None: + """Resolve an authored OmniPBR-style diffuse tint color.""" + tint_attr = None + tint_value = None + if shader is not None: + tint_value, tint_attr = _get_input_value_with_attr(shader, ("diffuse_tint",)) + if tint_value is None: + tint_value, tint_attr = _get_material_input_value_with_attr(material, ("diffuse_tint",)) + return _coerce_color(tint_value, tint_attr) + + def _extract_preview_surface_properties(shader: UsdShade.Shader | None, prim: Usd.Prim) -> dict[str, Any]: """Extract material properties from a UsdPreviewSurface shader. @@ -1524,9 +1684,11 @@ def _extract_preview_surface_properties(shader: UsdShade.Shader | None, prim: Us source = color_input.GetConnectedSource() if source: source_shader = UsdShade.Shader(source[0].GetPrim()) - properties["texture"] = _find_texture_in_shader(source_shader, prim) + texture, texture_color_space = _find_texture_in_shader(source_shader, prim) + properties["texture"] = texture + properties["texture_color_space"] = texture_color_space if properties["texture"] is None: - color_value = _get_input_value( + color_value, color_attr = _get_input_value_with_attr( source_shader, ( "diffuseColor", @@ -1537,9 +1699,9 @@ def _extract_preview_surface_properties(shader: UsdShade.Shader | None, prim: Us "displayColor", ), ) - properties["color"] = _coerce_color(color_value) + properties["color"] = _coerce_color(color_value, color_attr) else: - properties["color"] = _coerce_color(color_input.Get()) + properties["color"] = _coerce_color(color_input.Get(), color_input.GetAttr()) metallic_input = shader.GetInput("metallic") if metallic_input: @@ -1585,7 +1747,11 @@ def _extract_preview_surface_properties(shader: UsdShade.Shader | None, prim: Us return properties -def _extract_shader_properties(shader: UsdShade.Shader | None, prim: Usd.Prim) -> dict[str, Any]: +def _extract_shader_properties( + shader: UsdShade.Shader | None, + prim: Usd.Prim, + material: UsdShade.Material | None = None, +) -> dict[str, Any]: """Extract common material properties from a shader node. This routine starts with UsdPreviewSurface parsing and then falls back to @@ -1594,6 +1760,8 @@ def _extract_shader_properties(shader: UsdShade.Shader | None, prim: Usd.Prim) - Args: shader: The shader node to inspect. prim: The prim providing stage context for asset resolution. + material: The bound material, used for fallback material inputs such as + OmniPBR diffuse tint. Returns: Dictionary with ``color``, ``metallic``, ``roughness``, and ``texture``. @@ -1608,7 +1776,7 @@ def _extract_shader_properties(shader: UsdShade.Shader | None, prim: Usd.Prim) - return properties if properties["color"] is None: - color_value = _get_input_value( + color_value, color_attr = _get_input_value_with_attr( shader, ( "diffuse_color_constant", @@ -1619,7 +1787,7 @@ def _extract_shader_properties(shader: UsdShade.Shader | None, prim: Usd.Prim) - "displayColor", ), ) - properties["color"] = _coerce_color(color_value) + properties["color"] = _coerce_color(color_value, color_attr) if properties["metallic"] is None: metallic_value = _get_input_value(shader, ("metallic_constant", "metallic")) properties["metallic"] = _coerce_float(metallic_value) @@ -1633,16 +1801,20 @@ def _extract_shader_properties(shader: UsdShade.Shader | None, prim: Usd.Prim) - if inp.HasConnectedSource(): source = inp.GetConnectedSource() source_shader = UsdShade.Shader(source[0].GetPrim()) - texture = _find_texture_in_shader(source_shader, prim) + texture, texture_color_space = _find_texture_in_shader(source_shader, prim) if texture: properties["texture"] = texture + properties["texture_color_space"] = texture_color_space break elif "file" in name or "texture" in name: asset = inp.Get() if asset: properties["texture"] = _resolve_asset_path(asset, prim, inp.GetAttr()) + properties["texture_color_space"] = _resolve_texture_color_space(None, inp.GetAttr()) break + properties["color"] = _multiply_colors(properties["color"], _resolve_diffuse_tint(shader, material)) + return properties @@ -1672,6 +1844,7 @@ def _extract_material_input_properties(material: UsdShade.Material | None, prim: texture = _resolve_asset_path(value, prim, inp.GetAttr()) if texture: properties["texture"] = texture + properties["texture_color_space"] = _resolve_texture_color_space(None, inp.GetAttr()) continue if properties["color"] is None and name_lower in ( @@ -1681,7 +1854,7 @@ def _extract_material_input_properties(material: UsdShade.Material | None, prim: "base_color", "displaycolor", ): - color = _coerce_color(value) + color = _coerce_color(value, inp.GetAttr()) if color is not None: properties["color"] = color continue @@ -1767,15 +1940,15 @@ def _resolve_prim_material_properties(target_prim: Usd.Prim) -> dict[str, Any] | # Always call _extract_shader_properties even if shader_id is None (e.g., for MDL shaders like OmniPBR) # because _extract_shader_properties has fallback logic for common input names - properties = _extract_shader_properties(source_shader, target_prim) + properties = _extract_shader_properties(source_shader, target_prim, material) material_props = _extract_material_input_properties(material, target_prim) - for key in ("texture", "color", "metallic", "roughness"): + for key in ("texture", "texture_color_space", "color", "metallic", "roughness"): if properties.get(key) is None and material_props.get(key) is not None: properties[key] = material_props[key] if properties["color"] is None and properties["texture"] is None: display_color = UsdGeom.PrimvarsAPI(target_prim).GetPrimvar("displayColor") if display_color: - properties["color"] = _coerce_color(display_color.Get()) + properties["color"] = _coerce_color(display_color.Get(), display_color.GetAttr()) return properties @@ -1833,6 +2006,13 @@ def resolve_material_properties_for_prim(prim: Usd.Prim) -> dict[str, Any]: if fallback_props is not None: return fallback_props + display_color = UsdGeom.PrimvarsAPI(prim).GetPrimvar("displayColor") + if display_color: + properties = _empty_material_properties() + properties["color"] = _coerce_color(display_color.Get(), display_color.GetAttr()) + if properties["color"] is not None: + return properties + return _empty_material_properties() diff --git a/newton/_src/utils/color.py b/newton/_src/utils/color.py new file mode 100644 index 0000000000..c5634eda20 --- /dev/null +++ b/newton/_src/utils/color.py @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 The Newton Developers +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np +import warp as wp + +TEXTURE_COLOR_SPACE_AUTO = "auto" +TEXTURE_COLOR_SPACE_RAW = "raw" +TEXTURE_COLOR_SPACE_SRGB = "srgb" + +TEXTURE_COLOR_SPACE_RAW_ID = 0 +TEXTURE_COLOR_SPACE_SRGB_ID = 1 + + +def _to_rgb_array(color: Sequence[float] | np.ndarray) -> np.ndarray: + rgb = np.asarray(color, dtype=np.float32).reshape(-1) + if rgb.size < 3: + raise ValueError("RGB colors require at least three components.") + return rgb[:3] + + +def srgb_to_linear_rgb(color: Sequence[float] | np.ndarray) -> tuple[float, float, float]: + """Convert an sRGB/display RGB triple to linear Rec.709.""" + + rgb = np.clip(_to_rgb_array(color), 0.0, None) + linear = np.where(rgb <= 0.04045, rgb / 12.92, np.power((rgb + 0.055) / 1.055, 2.4)) + return (float(linear[0]), float(linear[1]), float(linear[2])) + + +def linear_to_srgb_rgb(color: Sequence[float] | np.ndarray) -> tuple[float, float, float]: + """Convert a linear RGB triple to sRGB/display encoding.""" + + rgb = np.clip(_to_rgb_array(color), 0.0, None) + srgb = np.where(rgb <= 0.0031308, rgb * 12.92, 1.055 * np.power(rgb, 1.0 / 2.4) - 0.055) + return (float(srgb[0]), float(srgb[1]), float(srgb[2])) + + +def linear_rgb_to_srgb_uint8(color: Sequence[float] | np.ndarray) -> np.ndarray: + """Convert linear RGB floats to uint8 sRGB bytes.""" + + srgb = np.asarray(linear_to_srgb_rgb(color), dtype=np.float32) + return np.clip(np.round(srgb * 255.0), 0.0, 255.0).astype(np.uint8) + + +def linear_image_to_srgb_uint8(image: np.ndarray) -> np.ndarray: + """Convert a linear RGB/RGBA array to uint8 sRGB.""" + + img = np.asarray(image, dtype=np.float32) + if img.ndim < 2 or img.shape[-1] not in (3, 4): + raise ValueError("Expected an array with RGB or RGBA channels on the last axis.") + + out = img.copy() + rgb = np.clip(out[..., :3], 0.0, None) + out[..., :3] = np.where(rgb <= 0.0031308, rgb * 12.92, 1.055 * np.power(rgb, 1.0 / 2.4) - 0.055) + return np.clip(np.round(out * 255.0), 0.0, 255.0).astype(np.uint8) + + +def normalize_texture_color_space(color_space: str | None) -> str: + """Normalize texture color-space metadata to ``raw``, ``srgb``, or ``auto``.""" + + if color_space is None: + return TEXTURE_COLOR_SPACE_AUTO + + token = str(color_space).strip().lower() + if token in ("", TEXTURE_COLOR_SPACE_AUTO, "unknown", "identity"): + return TEXTURE_COLOR_SPACE_AUTO + if token in (TEXTURE_COLOR_SPACE_RAW, "data", "lin_rec709_scene") or token.startswith("lin_"): + return TEXTURE_COLOR_SPACE_RAW + if token in (TEXTURE_COLOR_SPACE_SRGB, "srgb_rec709_scene", "g22_rec709_scene") or token.startswith("srgb_"): + return TEXTURE_COLOR_SPACE_SRGB + return TEXTURE_COLOR_SPACE_AUTO + + +def texture_color_space_to_id(color_space: str | None) -> int: + """Map normalized texture color-space metadata to the raytracer enum.""" + + return ( + TEXTURE_COLOR_SPACE_RAW_ID + if normalize_texture_color_space(color_space) == TEXTURE_COLOR_SPACE_RAW + else TEXTURE_COLOR_SPACE_SRGB_ID + ) + + +@wp.func +def srgb_channel_to_linear_wp(value: float): + clamped = wp.max(value, 0.0) + if clamped <= 0.04045: + return clamped / 12.92 + return wp.pow((clamped + 0.055) / 1.055, 2.4) + + +@wp.func +def linear_channel_to_srgb_wp(value: float): + clamped = wp.max(value, 0.0) + if clamped <= 0.0031308: + return clamped * 12.92 + return 1.055 * wp.pow(clamped, 1.0 / 2.4) - 0.055 + + +@wp.func +def srgb_to_linear_wp(rgb: wp.vec3f): + return wp.vec3f( + srgb_channel_to_linear_wp(rgb[0]), + srgb_channel_to_linear_wp(rgb[1]), + srgb_channel_to_linear_wp(rgb[2]), + ) + + +@wp.func +def linear_to_srgb_wp(rgb: wp.vec3f): + return wp.vec3f( + linear_channel_to_srgb_wp(rgb[0]), + linear_channel_to_srgb_wp(rgb[1]), + linear_channel_to_srgb_wp(rgb[2]), + ) diff --git a/newton/_src/utils/import_usd.py b/newton/_src/utils/import_usd.py index 2e0eae9355..0b2508b504 100644 --- a/newton/_src/utils/import_usd.py +++ b/newton/_src/utils/import_usd.py @@ -429,6 +429,7 @@ def _get_mesh_with_visual_material(prim: Usd.Prim, *, path_name: str) -> Mesh: mesh = physics_mesh.copy(recompute_inertia=False) if texture: mesh.texture = texture + mesh.texture_color_space = material_props.get("texture_color_space", "auto") if mesh.texture is not None and mesh.uvs is None: warnings.warn( f"Warning: mesh {path_name} has a texture but no UVs; texture will be ignored.", @@ -524,6 +525,7 @@ def _load_visual_shapes_impl( return if path_name not in path_shape_map: + shape_color = _get_material_props_cached(prim).get("color") if type_name == "cube": size = usd.get_float(prim, "size", 2.0) side_lengths = scale * size @@ -535,6 +537,7 @@ def _load_visual_shapes_impl( hz=side_lengths[2] / 2, cfg=visual_shape_cfg, as_site=is_site, + color=shape_color, label=path_name, ) elif type_name == "sphere": @@ -547,6 +550,7 @@ def _load_visual_shapes_impl( radius, cfg=visual_shape_cfg, as_site=is_site, + color=shape_color, label=path_name, ) elif type_name == "plane": @@ -562,6 +566,7 @@ def _load_visual_shapes_impl( width=width, length=length, cfg=visual_shape_cfg, + color=shape_color, label=path_name, ) elif type_name == "capsule": @@ -577,6 +582,7 @@ def _load_visual_shapes_impl( half_height, cfg=visual_shape_cfg, as_site=is_site, + color=shape_color, label=path_name, ) elif type_name == "cylinder": @@ -592,6 +598,7 @@ def _load_visual_shapes_impl( half_height, cfg=visual_shape_cfg, as_site=is_site, + color=shape_color, label=path_name, ) elif type_name == "cone": @@ -607,6 +614,7 @@ def _load_visual_shapes_impl( half_height, cfg=visual_shape_cfg, as_site=is_site, + color=shape_color, label=path_name, ) elif type_name == "mesh": @@ -2173,6 +2181,7 @@ def _build_mass_info_from_shape_geometry( collision_group=collision_group, is_visible=collider_is_visible, ), + "color": _get_material_props_cached(prim).get("color"), "label": path, "custom_attributes": shape_custom_attrs, } diff --git a/newton/_src/utils/mesh.py b/newton/_src/utils/mesh.py index 0f8277d95c..233ea0a681 100644 --- a/newton/_src/utils/mesh.py +++ b/newton/_src/utils/mesh.py @@ -535,10 +535,9 @@ def strip(tag: str) -> str: values = [float(x) for x in col.text.strip().split()] if len(values) >= 3: # DAE diffuse colors are commonly authored in linear space. - # Convert to sRGB for the viewer shader (which converts to linear). + # Preserve that space so Model.shape_color can stay linear. diffuse = np.clip(values[:3], 0.0, 1.0) - srgb = np.power(diffuse, 1.0 / 2.2) - diffuse_color = (float(srgb[0]), float(srgb[1]), float(srgb[2])) + diffuse_color = (float(diffuse[0]), float(diffuse[1]), float(diffuse[2])) break continue if tag == "specular": diff --git a/newton/_src/viewer/gl/opengl.py b/newton/_src/viewer/gl/opengl.py index b2c4afc0c8..fe974a2f9c 100644 --- a/newton/_src/viewer/gl/opengl.py +++ b/newton/_src/viewer/gl/opengl.py @@ -228,7 +228,7 @@ def __init__(self, num_points, num_indices, device, hidden=False, backface_culli # albedo gl.glVertexAttrib3f(7, 0.7, 0.5, 0.3) - # material = (roughness, metallic, checker, texture_enable) + # material = (roughness, metallic, checker, texture_mode) gl.glVertexAttrib4f(8, 0.5, 0.0, 0.0, 0.0) gl.glBindVertexArray(0) diff --git a/newton/_src/viewer/gl/shaders.py b/newton/_src/viewer/gl/shaders.py index 2b26614103..e15ca952d2 100644 --- a/newton/_src/viewer/gl/shaders.py +++ b/newton/_src/viewer/gl/shaders.py @@ -284,21 +284,40 @@ return textureLod(env_map, vec2(u, v), lod).rgb; } +vec3 srgb_to_linear(vec3 color) +{ + bvec3 cutoff = lessThanEqual(color, vec3(0.04045)); + vec3 lower = color / 12.92; + vec3 upper = pow((color + 0.055) / 1.055, vec3(2.4)); + return mix(upper, lower, cutoff); +} + +vec3 linear_to_srgb(vec3 color) +{ + bvec3 cutoff = lessThanEqual(color, vec3(0.0031308)); + vec3 lower = color * 12.92; + vec3 upper = 1.055 * pow(color, vec3(1.0 / 2.4)) - 0.055; + return mix(upper, lower, cutoff); +} + void main() { // material properties from vertex shader float roughness = clamp(Material.x, 0.0, 1.0); float metallic = clamp(Material.y, 0.0, 1.0); float checker_enable = Material.z; - float texture_enable = Material.w; + float texture_mode = Material.w; float checker_scale = 1.0; - // convert to linear space - vec3 albedo = pow(ObjectColor, vec3(2.2)); - if (texture_enable > 0.5) + vec3 albedo = ObjectColor; + if (texture_mode > 0.5) { vec3 tex_color = texture(albedo_map, TexCoord).rgb; - albedo *= pow(tex_color, vec3(2.2)); + if (texture_mode < 1.5) + { + tex_color = srgb_to_linear(tex_color); + } + albedo *= tex_color; } // Optional checker pattern in object-space so it follows instance transforms @@ -381,7 +400,7 @@ // Environment / image-based lighting for metals vec3 R = reflect(-V, N); float env_lod = roughness * 8.0; - vec3 env_color = pow(sample_env_map(R, env_lod), vec3(2.2)); + vec3 env_color = srgb_to_linear(sample_env_map(R, env_lod)); vec3 env_F = F0 + (F_max - F0) * pow(1.0 - NdotV, 5.0); vec3 env_spec = env_color * env_F * env_intensity; color += env_spec * metallic; @@ -391,7 +410,7 @@ float fog_start = 20.0; float fog_end = 200.0; float fog_factor = clamp((dist - fog_start) / (fog_end - fog_start), 0.0, 1.0); - color = mix(color, pow(fogColor, vec3(2.2)), fog_factor); + color = mix(color, srgb_to_linear(fogColor), fog_factor); // ACES filmic tone mapping color = color * exposure; @@ -400,7 +419,7 @@ color = clamp(color, 0.0, 1.0); // gamma correction (sRGB) - color = pow(color, vec3(1.0 / 2.2)); + color = linear_to_srgb(color); FragColor = vec4(color, 1.0); } diff --git a/newton/_src/viewer/viewer.py b/newton/_src/viewer/viewer.py index 98ddd86fea..9d8ddbb69c 100644 --- a/newton/_src/viewer/viewer.py +++ b/newton/_src/viewer/viewer.py @@ -18,6 +18,7 @@ from newton.utils import compute_world_offsets, solidify_mesh from ..core.types import MAXVAL, Axis +from ..utils.color import srgb_to_linear_rgb from .kernels import ( build_active_particle_mask, compact, @@ -1529,7 +1530,7 @@ def _populate_shapes(self): # Use shape index for color to ensure each collision shape has a different color color = wp.vec3(self._shape_color_map(s)) - material = wp.vec4(0.5, 0.0, 0.0, 0.0) # roughness, metallic, checker, texture_enable + material = wp.vec4(0.5, 0.0, 0.0, 0.0) # roughness, metallic, checker, texture_mode if geo_type in (newton.GeoType.MESH, newton.GeoType.CONVEX_MESH): scale = np.asarray(geo_scale, dtype=np.float32) @@ -1543,7 +1544,9 @@ def _populate_shapes(self): if geo_src is not None and geo_src._uvs is not None: has_texture = getattr(geo_src, "texture", None) is not None if has_texture: - material = wp.vec4(material.x, material.y, material.z, 1.0) + texture_color_space = geo_src.texture_color_space + texture_mode = 2.0 if texture_color_space == "raw" else 1.0 + material = wp.vec4(material.x, material.y, material.z, texture_mode) # Planes keep their checkerboard material even when model.shape_color # is populated with resolved default colors. @@ -1691,7 +1694,7 @@ def _populate_sdf_isomesh_instances(self): # Use distinct collision color palette (different from visual shapes) color = wp.vec3(self._collision_color_map(s)) - material = wp.vec4(0.3, 0.0, 0.0, 0.0) # roughness, metallic, checker, texture_enable + material = wp.vec4(0.3, 0.0, 0.0, 0.0) # roughness, metallic, checker, texture_mode batch.add( parent=parent, @@ -1710,8 +1713,9 @@ def _populate_sdf_isomesh_instances(self): def update_shape_colors(self, shape_colors: dict[int, wp.vec3 | tuple[float, float, float]]): """ Set colors for a set of shapes at runtime. + Args: - shape_colors: mapping from shape index -> color + shape_colors: Mapping from shape index to linear RGB color. """ warnings.warn( "Viewer.update_shape_colors() is deprecated. Write to model.shape_color instead.", @@ -2137,7 +2141,7 @@ def _log_particles(self, state: newton.State): @staticmethod def _shape_color_map(i: int) -> list[float]: color = newton.ModelBuilder._SHAPE_COLOR_PALETTE[i % len(newton.ModelBuilder._SHAPE_COLOR_PALETTE)] - return [c / 255.0 for c in color] + return list(srgb_to_linear_rgb([c / 255.0 for c in color])) @staticmethod def _collision_color_map(i: int) -> list[float]: @@ -2156,7 +2160,7 @@ def _collision_color_map(i: int) -> list[float]: ] num_colors = len(colors) - return [c / 255.0 for c in colors[i % num_colors]] + return list(srgb_to_linear_rgb([c / 255.0 for c in colors[i % num_colors]])) def is_jupyter_notebook(): diff --git a/newton/_src/viewer/viewer_rerun.py b/newton/_src/viewer/viewer_rerun.py index ba33a6b592..b12269a7eb 100644 --- a/newton/_src/viewer/viewer_rerun.py +++ b/newton/_src/viewer/viewer_rerun.py @@ -13,6 +13,7 @@ import newton from ..core.types import override +from ..utils.color import linear_rgb_to_srgb_uint8 from ..utils.mesh import compute_vertex_normals from ..utils.texture import load_texture, normalize_texture from .viewer import ViewerBase, is_jupyter_notebook @@ -355,7 +356,7 @@ def log_instances( colors_np = self._to_numpy(colors).astype(np.float32) # Take the first instance's color and apply to all vertices first_color = colors_np[0] - color_rgb = np.array(first_color * 255, dtype=np.uint8) + color_rgb = linear_rgb_to_srgb_uint8(first_color) num_vertices = len(mesh_data["points"]) vertex_colors = np.tile(color_rgb, (num_vertices, 1)) diff --git a/newton/_src/viewer/viewer_viser.py b/newton/_src/viewer/viewer_viser.py index 27bb8f38d0..7dc617a376 100644 --- a/newton/_src/viewer/viewer_viser.py +++ b/newton/_src/viewer/viewer_viser.py @@ -15,6 +15,7 @@ import newton from ..core.types import override +from ..utils.color import linear_image_to_srgb_uint8 from ..utils.texture import load_texture, normalize_texture from .viewer import ViewerBase, is_jupyter_notebook @@ -532,7 +533,7 @@ def log_instances( # Prepare colors (convert from 0-1 float to 0-255 uint8) if colors_np is not None: - batched_colors = (colors_np * 255).astype(np.uint8) + batched_colors = linear_image_to_srgb_uint8(colors_np) else: batched_colors = None # Will use cached colors or default gray diff --git a/newton/tests/test_import_usd.py b/newton/tests/test_import_usd.py index 354b718100..2364b7d519 100644 --- a/newton/tests/test_import_usd.py +++ b/newton/tests/test_import_usd.py @@ -17,6 +17,7 @@ from newton import BodyFlags, JointType from newton._src.geometry.flags import ShapeFlags from newton._src.geometry.utils import transform_points +from newton._src.utils.color import srgb_to_linear_rgb from newton.math import quat_between_axes from newton.solvers import SolverMuJoCo from newton.tests.unittest_utils import USD_AVAILABLE, assert_np_equal, get_test_devices @@ -6452,7 +6453,14 @@ def Sphere "CollisionSphere" ( self.assertTrue(flags_no_load & ShapeFlags.VISIBLE) @staticmethod - def _create_stage_with_pbr_collision_mesh(color, roughness, metallic, *, add_visual_sphere=False): + def _create_stage_with_pbr_collision_mesh( + color, + roughness, + metallic, + *, + add_visual_sphere=False, + base_color_space=None, + ): """Create a stage with a rigid body containing a collision mesh with PBR material.""" from pxr import Sdf, Usd, UsdGeom, UsdPhysics, UsdShade @@ -6485,7 +6493,10 @@ def _create_stage_with_pbr_collision_mesh(color, roughness, metallic, *, add_vis material = UsdShade.Material.Define(stage, "/Materials/PBR") shader = UsdShade.Shader.Define(stage, "/Materials/PBR/PreviewSurface") shader.CreateIdAttr("UsdPreviewSurface") - shader.CreateInput("baseColor", Sdf.ValueTypeNames.Color3f).Set(color) + color_input = shader.CreateInput("baseColor", Sdf.ValueTypeNames.Color3f) + color_input.Set(color) + if base_color_space is not None: + color_input.GetAttr().SetColorSpace(base_color_space) shader.CreateInput("roughness", Sdf.ValueTypeNames.Float).Set(roughness) shader.CreateInput("metallic", Sdf.ValueTypeNames.Float).Set(metallic) material.CreateSurfaceOutput().ConnectToSource(shader.ConnectableAPI(), "surface") @@ -6493,6 +6504,152 @@ def _create_stage_with_pbr_collision_mesh(color, roughness, metallic, *, add_vis return stage + @unittest.skipUnless(USD_AVAILABLE, "Requires usd-core") + def test_omnipbr_diffuse_tint_multiplies_authored_base_color(self): + from pxr import Sdf, Usd, UsdGeom, UsdPhysics, UsdShade + + stage = Usd.Stage.CreateInMemory() + UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z) + UsdPhysics.Scene.Define(stage, "/physicsScene") + + body = UsdGeom.Xform.Define(stage, "/Body") + UsdPhysics.RigidBodyAPI.Apply(body.GetPrim()) + + cube = UsdGeom.Cube.Define(stage, "/Body/Cube") + cube_prim = cube.GetPrim() + UsdPhysics.CollisionAPI.Apply(cube_prim) + + material = UsdShade.Material.Define(stage, "/Materials/PBR") + shader = UsdShade.Shader.Define(stage, "/Materials/PBR/Shader") + shader.CreateIdAttr("OmniPBR") + shader.CreateInput("diffuse_color_constant", Sdf.ValueTypeNames.Color3f).Set((0.8, 0.6, 0.4)) + shader.CreateInput("diffuse_tint", Sdf.ValueTypeNames.Color3f).Set((0.5, 1.0, 0.25)) + material.CreateSurfaceOutput("mdl").ConnectToSource(shader.ConnectableAPI(), "out") + UsdShade.MaterialBindingAPI.Apply(cube_prim).Bind(material) + + builder = newton.ModelBuilder() + result = builder.add_usd(stage) + shape = result["path_shape_map"]["/Body/Cube"] + + np.testing.assert_allclose(builder.shape_color[shape], [0.4, 0.6, 0.1], atol=1e-6, rtol=1e-6) + + @unittest.skipUnless(USD_AVAILABLE, "Requires usd-core") + def test_preview_surface_srgb_base_color_converts_to_linear(self): + from pxr import Gf + + stage = self._create_stage_with_pbr_collision_mesh( + color=(0.5, 0.25, 0.1), + roughness=0.35, + metallic=0.75, + base_color_space=Gf.ColorSpaceNames.SRGBRec709, + ) + + builder = newton.ModelBuilder() + result = builder.add_usd(stage) + shape = result["path_shape_map"]["/Body/CollisionMesh"] + + np.testing.assert_allclose( + builder.shape_color[shape], + srgb_to_linear_rgb((0.5, 0.25, 0.1)), + atol=1e-6, + rtol=1e-6, + ) + + @unittest.skipUnless(USD_AVAILABLE, "Requires usd-core") + def test_display_color_srgb_metadata_converts_to_linear(self): + from pxr import Gf, Usd, UsdGeom, UsdPhysics + + stage = Usd.Stage.CreateInMemory() + UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z) + UsdPhysics.Scene.Define(stage, "/physicsScene") + + body = UsdGeom.Xform.Define(stage, "/Body") + UsdPhysics.RigidBodyAPI.Apply(body.GetPrim()) + + cube = UsdGeom.Cube.Define(stage, "/Body/Cube") + cube_prim = cube.GetPrim() + UsdPhysics.CollisionAPI.Apply(cube_prim) + display_color = cube.CreateDisplayColorPrimvar() + display_color.Set([Gf.Vec3f(0.5, 0.25, 0.1)]) + display_color.GetAttr().SetColorSpace(Gf.ColorSpaceNames.SRGBRec709) + + builder = newton.ModelBuilder() + result = builder.add_usd(stage) + shape = result["path_shape_map"]["/Body/Cube"] + + np.testing.assert_allclose( + builder.shape_color[shape], + srgb_to_linear_rgb((0.5, 0.25, 0.1)), + atol=1e-6, + rtol=1e-6, + ) + + @unittest.skipUnless(USD_AVAILABLE, "Requires usd-core") + def test_material_input_srgb_base_color_converts_to_linear(self): + from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics, UsdShade + + stage = Usd.Stage.CreateInMemory() + UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z) + UsdPhysics.Scene.Define(stage, "/physicsScene") + + body = UsdGeom.Xform.Define(stage, "/Body") + UsdPhysics.RigidBodyAPI.Apply(body.GetPrim()) + + cube = UsdGeom.Cube.Define(stage, "/Body/Cube") + cube_prim = cube.GetPrim() + UsdPhysics.CollisionAPI.Apply(cube_prim) + + material = UsdShade.Material.Define(stage, "/Materials/PBR") + material_color = material.CreateInput("baseColor", Sdf.ValueTypeNames.Color3f) + material_color.Set((0.5, 0.25, 0.1)) + material_color.GetAttr().SetColorSpace(Gf.ColorSpaceNames.SRGBRec709) + UsdShade.MaterialBindingAPI.Apply(cube_prim).Bind(material) + + builder = newton.ModelBuilder() + result = builder.add_usd(stage) + shape = result["path_shape_map"]["/Body/Cube"] + + np.testing.assert_allclose( + builder.shape_color[shape], + srgb_to_linear_rgb((0.5, 0.25, 0.1)), + atol=1e-6, + rtol=1e-6, + ) + + @unittest.skipUnless(USD_AVAILABLE, "Requires usd-core") + def test_omnipbr_diffuse_tint_srgb_inputs_convert_before_multiply(self): + from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics, UsdShade + + stage = Usd.Stage.CreateInMemory() + UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z) + UsdPhysics.Scene.Define(stage, "/physicsScene") + + body = UsdGeom.Xform.Define(stage, "/Body") + UsdPhysics.RigidBodyAPI.Apply(body.GetPrim()) + + cube = UsdGeom.Cube.Define(stage, "/Body/Cube") + cube_prim = cube.GetPrim() + UsdPhysics.CollisionAPI.Apply(cube_prim) + + material = UsdShade.Material.Define(stage, "/Materials/PBR") + shader = UsdShade.Shader.Define(stage, "/Materials/PBR/Shader") + shader.CreateIdAttr("OmniPBR") + base_input = shader.CreateInput("diffuse_color_constant", Sdf.ValueTypeNames.Color3f) + base_input.Set((0.8, 0.6, 0.4)) + base_input.GetAttr().SetColorSpace(Gf.ColorSpaceNames.SRGBRec709) + tint_input = shader.CreateInput("diffuse_tint", Sdf.ValueTypeNames.Color3f) + tint_input.Set((0.5, 1.0, 0.25)) + tint_input.GetAttr().SetColorSpace(Gf.ColorSpaceNames.SRGBRec709) + material.CreateSurfaceOutput("mdl").ConnectToSource(shader.ConnectableAPI(), "out") + UsdShade.MaterialBindingAPI.Apply(cube_prim).Bind(material) + + builder = newton.ModelBuilder() + result = builder.add_usd(stage) + shape = result["path_shape_map"]["/Body/Cube"] + + expected = np.asarray(srgb_to_linear_rgb((0.8, 0.6, 0.4))) * np.asarray(srgb_to_linear_rgb((0.5, 1.0, 0.25))) + np.testing.assert_allclose(builder.shape_color[shape], expected, atol=1e-6, rtol=1e-6) + @unittest.skipUnless(USD_AVAILABLE, "Requires usd-core") def test_visible_collision_mesh_inherits_visual_material_properties(self): """Visible fallback collider meshes should carry resolved visual material data.""" @@ -6563,10 +6720,60 @@ def _mock_get_mesh(_prim, *, load_uvs=False, load_normals=False): mesh = builder.shape_source[collision_shape] self.assertIsNotNone(mesh) self.assertEqual(mesh.texture, "dummy.png") + self.assertEqual(mesh.texture_color_space, "auto") self.assertIsNotNone(mesh.uvs) np.testing.assert_allclose(mesh.vertices, render_mesh.vertices, atol=1e-6, rtol=1e-6) self.assertAlmostEqual(mesh.mass, physics_mesh.mass, places=6) + @unittest.skipUnless(USD_AVAILABLE, "Requires usd-core") + def test_visible_collision_mesh_preserves_texture_color_space_metadata(self): + from pxr import Sdf, Usd, UsdGeom, UsdPhysics, UsdShade + + stage = Usd.Stage.CreateInMemory() + UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z) + UsdGeom.SetStageMetersPerUnit(stage, 1.0) + UsdPhysics.Scene.Define(stage, "/physicsScene") + + body = UsdGeom.Xform.Define(stage, "/Body") + UsdPhysics.RigidBodyAPI.Apply(body.GetPrim()) + + collision_mesh = UsdGeom.Mesh.Define(stage, "/Body/CollisionMesh") + collision_mesh_prim = collision_mesh.GetPrim() + UsdPhysics.CollisionAPI.Apply(collision_mesh_prim) + collision_mesh.CreatePointsAttr().Set( + [ + (-0.5, 0.0, 0.0), + (0.5, 0.0, 0.0), + (0.0, 0.5, 0.0), + (0.0, 0.0, 0.5), + ] + ) + collision_mesh.CreateFaceVertexCountsAttr().Set([3, 3, 3, 3]) + collision_mesh.CreateFaceVertexIndicesAttr().Set([0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3]) + UsdGeom.PrimvarsAPI(collision_mesh_prim).CreatePrimvar( + "st", Sdf.ValueTypeNames.TexCoord2fArray, UsdGeom.Tokens.vertex + ).Set([(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]) + + material = UsdShade.Material.Define(stage, "/Materials/PBR") + preview = UsdShade.Shader.Define(stage, "/Materials/PBR/PreviewSurface") + preview.CreateIdAttr("UsdPreviewSurface") + texture = UsdShade.Shader.Define(stage, "/Materials/PBR/Albedo") + texture.CreateIdAttr("UsdUVTexture") + texture.CreateInput("file", Sdf.ValueTypeNames.Asset).Set(Sdf.AssetPath("dummy.png")) + texture.CreateInput("sourceColorSpace", Sdf.ValueTypeNames.Token).Set("raw") + preview.CreateInput("baseColor", Sdf.ValueTypeNames.Color3f).ConnectToSource(texture.ConnectableAPI(), "rgb") + material.CreateSurfaceOutput().ConnectToSource(preview.ConnectableAPI(), "surface") + UsdShade.MaterialBindingAPI.Apply(collision_mesh_prim).Bind(material) + + builder = newton.ModelBuilder() + result = builder.add_usd(stage, hide_collision_shapes=True) + collision_shape = result["path_shape_map"]["/Body/CollisionMesh"] + mesh = builder.shape_source[collision_shape] + + self.assertIsNotNone(mesh) + self.assertEqual(mesh.texture, "dummy.png") + self.assertEqual(mesh.texture_color_space, "raw") + @unittest.skipUnless(USD_AVAILABLE, "Requires usd-core") def test_hide_collision_shapes_overrides_visual_material(self): """hide_collision_shapes=True hides colliders even when they have visual material data.""" diff --git a/newton/tests/test_sensor_tiled_camera.py b/newton/tests/test_sensor_tiled_camera.py index 5792eba900..7496a946f5 100644 --- a/newton/tests/test_sensor_tiled_camera.py +++ b/newton/tests/test_sensor_tiled_camera.py @@ -9,10 +9,30 @@ import warp as wp import newton +from newton._src.utils.color import linear_to_srgb_rgb from newton.sensors import SensorTiledCamera class TestSensorTiledCamera(unittest.TestCase): + @staticmethod + def _unpack_rgba(pixel: np.uint32) -> np.ndarray: + value = int(pixel) + return np.array( + [ + value & 0xFF, + (value >> 8) & 0xFF, + (value >> 16) & 0xFF, + (value >> 24) & 0xFF, + ], + dtype=np.uint8, + ) + + def _build_single_sphere_scene(self, color: tuple[float, float, float]): + builder = newton.ModelBuilder(up_axis=newton.Axis.Z) + body = builder.add_body(xform=wp.transform(p=wp.vec3(0.0, 0.0, -2.0), q=wp.quat_identity()), label="sphere") + builder.add_shape_sphere(body, radius=0.75, color=color) + return builder.finalize(device="cpu") + def __build_scene(self): from pxr import Usd, UsdGeom @@ -165,6 +185,35 @@ def test_output_image_parameters(self): self.assertFalse(np.any(color_image.numpy() != 0), "Color image should NOT contain rendered data") self.assertFalse(np.any(depth_image.numpy() != 0), "Depth image should NOT contain rendered data") + def test_albedo_output_can_remain_linear(self): + color = (0.25, 0.5, 0.75) + model = self._build_single_sphere_scene(color) + + camera_transforms = wp.array( + [[wp.transformf(wp.vec3f(0.0, 0.0, 0.0), wp.quatf(0.0, 0.0, 0.0, 1.0))]], + dtype=wp.transformf, + device="cpu", + ) + + for encode_output_srgb in (True, False): + sensor = SensorTiledCamera( + model=model, + config=SensorTiledCamera.RenderConfig(encode_output_srgb=encode_output_srgb), + ) + camera_rays = sensor.utils.compute_pinhole_camera_rays(1, 1, math.radians(30.0)) + albedo_image = sensor.utils.create_albedo_image_output(1, 1, camera_count=1) + sensor.update(model.state(), camera_transforms, camera_rays, albedo_image=albedo_image) + + packed = self._unpack_rgba(albedo_image.numpy()[0, 0, 0, 0]) + expected_rgb = ( + np.clip(np.asarray(linear_to_srgb_rgb(color)) * 255.0, 0.0, 255.0).astype(np.uint8) + if encode_output_srgb + else np.clip(np.asarray(color) * 255.0, 0.0, 255.0).astype(np.uint8) + ) + + np.testing.assert_array_equal(packed[:3], expected_rgb) + self.assertEqual(packed[3], 255) + if __name__ == "__main__": unittest.main() diff --git a/newton/tests/test_shape_colors.py b/newton/tests/test_shape_colors.py index 820b5b335f..2de926250c 100644 --- a/newton/tests/test_shape_colors.py +++ b/newton/tests/test_shape_colors.py @@ -58,6 +58,22 @@ def test_collision_shape_without_explicit_color_uses_default_palette(self): np.testing.assert_allclose(model.shape_color.numpy()[shape], expected, atol=1e-6, rtol=1e-6) + def test_collision_shape_without_explicit_color_uses_fallback_when_configured(self): + """Verify configuring a fallback color overrides the per-shape palette sequence.""" + builder = newton.ModelBuilder() + builder.default_shape_color = (0.18, 0.18, 0.18) + body = builder.add_body(mass=1.0) + shape = builder.add_shape_box(body=body, hx=0.1, hy=0.2, hz=0.3) + + model = builder.finalize(device=self.device) + + np.testing.assert_allclose( + model.shape_color.numpy()[shape], + (0.18, 0.18, 0.18), + atol=1e-6, + rtol=1e-6, + ) + def test_add_shape_mesh_uses_mesh_color_when_color_is_none(self): """Verify mesh shapes inherit embedded mesh colors when no override is given.""" mesh = self._make_tetra_mesh(color=(0.2, 0.4, 0.6)) @@ -67,7 +83,12 @@ def test_add_shape_mesh_uses_mesh_color_when_color_is_none(self): model = builder.finalize(device=self.device) - np.testing.assert_allclose(model.shape_color.numpy()[shape], [0.2, 0.4, 0.6], atol=1e-6, rtol=1e-6) + np.testing.assert_allclose( + model.shape_color.numpy()[shape], + (0.2, 0.4, 0.6), + atol=1e-6, + rtol=1e-6, + ) def test_explicit_shape_color_overrides_mesh_color(self): """Verify explicit shape colors override colors embedded in meshes.""" @@ -82,7 +103,12 @@ def test_explicit_shape_color_overrides_mesh_color(self): model = builder.finalize(device=self.device) - np.testing.assert_allclose(model.shape_color.numpy()[shape], [0.9, 0.1, 0.3], atol=1e-6, rtol=1e-6) + np.testing.assert_allclose( + model.shape_color.numpy()[shape], + (0.9, 0.1, 0.3), + atol=1e-6, + rtol=1e-6, + ) def test_ground_plane_keeps_checkerboard_material_with_resolved_shape_colors(self): """Verify the ground plane keeps its checkerboard material after color resolution.""" @@ -113,7 +139,12 @@ def test_viewer_syncs_runtime_shape_colors_from_model(self): viewer = _ShapeColorProbe() viewer.set_model(model) viewer.log_state(state) - np.testing.assert_allclose(viewer.last_colors[0], [0.1, 0.2, 0.3], atol=1e-6, rtol=1e-6) + np.testing.assert_allclose( + viewer.last_colors[0], + (0.1, 0.2, 0.3), + atol=1e-6, + rtol=1e-6, + ) viewer.last_colors = None model.shape_color[shape : shape + 1].fill_(wp.vec3(0.8, 0.2, 0.1)) @@ -208,12 +239,22 @@ def test_update_shape_colors_warns_and_writes_model_shape_color(self): viewer.update_shape_colors({shape: (0.7, 0.2, 0.9)}) self.assertTrue(any(item.category is DeprecationWarning for item in caught)) - np.testing.assert_allclose(model.shape_color.numpy()[shape], [0.7, 0.2, 0.9], atol=1e-6, rtol=1e-6) + np.testing.assert_allclose( + model.shape_color.numpy()[shape], + (0.7, 0.2, 0.9), + atol=1e-6, + rtol=1e-6, + ) viewer.last_colors = None viewer.log_state(state) self.assertIsNotNone(viewer.last_colors) - np.testing.assert_allclose(viewer.last_colors[0], [0.7, 0.2, 0.9], atol=1e-6, rtol=1e-6) + np.testing.assert_allclose( + viewer.last_colors[0], + (0.7, 0.2, 0.9), + atol=1e-6, + rtol=1e-6, + ) if __name__ == "__main__": diff --git a/newton/utils.py b/newton/utils.py index d01cb59dd2..9a03814631 100644 --- a/newton/utils.py +++ b/newton/utils.py @@ -104,3 +104,17 @@ "load_texture", "normalize_texture", ] + +# ================================================================================== +# color utils +# ================================================================================== + +from ._src.utils.color import ( # noqa: E402 + linear_to_srgb_rgb, + srgb_to_linear_rgb, +) + +__all__ += [ + "linear_to_srgb_rgb", + "srgb_to_linear_rgb", +]