import math
from typing import Any, ClassVar, Literal
from typing_extensions import Self
import numpy as np
from pydantic import Field, StrictBool, model_validator
import genesis as gs
from genesis.typing import FArrayType, UnitInterval, ValidFloat
from genesis.utils import mesh as mu
from .misc import FoamOptions
from .options import Options
from .textures import Texture, ColorTexture, ImageTexture, BatchTexture
MetalType = Literal["aluminium", "gold", "copper", "brass", "iron", "titanium", "vanadium", "lithium"]
############################ Base ############################
[docs]class Surface(Options):
"""
Base class for all surfaces types in Genesis.
A ``Surface`` object encapsulates all visual information used for rendering an entity or its sub-components (links,
geoms, ...). The surface contains different types of textures depending on the surface type (e.g. diffuse, specular,
roughness, metallic, normal, emissive). Each one of them is a `gs.textures.Texture` object.
Tip
---
If any of the textures only has single value (instead of a map), you can use the shortcut parameter (e.g., `color`,
`roughness`, `metallic`, `emissive`) instead of creating a texture object.
Note
----
This class should *not* be instantiated directly.
Parameters
----------
color : tuple | None, optional
Color of the surface. Shortcut for the primary texture with a single color.
opacity : float | None, optional
Opacity of the surface. Shortcut for `opacity_texture` with a single value.
roughness : float | None, optional
Roughness of the surface. Shortcut for `roughness_texture` with a single value.
metallic : float | None, optional
Metalness of the surface. Shortcut for `metallic_texture` with a single value.
emissive : tuple | None, optional
Emissive color of the surface. Shortcut for `emissive_texture` with a single color.
ior : float, optional
Index of Refraction.
default_roughness : float, optional
Default roughness value when `roughness` is not set and the asset does not have a roughness texture. Defaults
to 1.0.
vis_mode : str | None, optional
How the entity should be visualized, e.g.
- 'visual': Render the entity's visual geometry.
- 'collision': Render the entity's collision geometry.
- 'particle': Render the entity's particle representation (if applicable).
- 'sdf': Render the reconstructed surface mesh of the entity's sdf.
- 'recon': Render the reconstructed surface mesh of the entity's particle representation.
smooth : bool, optional
Whether to smooth face normals by interpolating vertex normals.
double_sided : bool | None, optional
Whether to render both sides of the surface. Useful for non-watertight 2D objects. Defaults to True for Cloth
material and False for others.
cutoff : float
The cutoff angle of emission. Defaults to 180.0.
normal_diff_clamp : float, optional
Controls the threshold for computing surface normals by interpolating vertex normals.
recon_backend : str, optional
Backend for surface reconstruction. Possible values are ['splashsurf', 'openvdb']. Defaults to 'splashsurf'.
generate_foam : bool, optional
Whether to generate foam particles for visual effects for particle-based entities.
foam_options : gs.options.FoamOptions, optional
Options for foam generation.
"""
_color_target: ClassVar[str] = "diffuse_texture"
# Shortcut fields — resolved to texture fields by _resolve_shortcuts, excluded from serialization.
color: FArrayType | None = Field(default=None, exclude=True, repr=False)
opacity: UnitInterval | None = Field(default=None, exclude=True, repr=False)
roughness: UnitInterval | None = Field(default=None, exclude=True, repr=False)
metallic: UnitInterval | None = Field(default=None, exclude=True, repr=False)
emissive: FArrayType | None = Field(default=None, exclude=True, repr=False)
ior: float | None = None
default_roughness: UnitInterval = 1.0
vis_mode: Literal["visual", "collision", "particle", "sdf", "recon"] | None = None
smooth: StrictBool = True
double_sided: StrictBool | None = None
cutoff: float = 180.0
normal_diff_clamp: float = 180.0
recon_backend: Literal["splashsurf", "openvdb"] = "splashsurf"
generate_foam: StrictBool = False
foam_options: FoamOptions = Field(default_factory=FoamOptions)
@model_validator(mode="before")
@classmethod
def _resolve_shortcuts(cls, data: Any) -> Any:
# Route each shortcut into its texture counterpart. Subclasses that don't expose a given texture (e.g. Glass has
# no opacity_texture) are skipped via the model_fields guard, and class defaults like `Rough.roughness = 1.0`
# are honored.
for shortcut, texture_field in (
("color", cls._color_target),
("opacity", "opacity_texture"),
("roughness", "roughness_texture"),
("metallic", "metallic_texture"),
("thickness", "thickness_texture"),
("emissive", "emissive_texture"),
):
if texture_field not in cls.model_fields:
continue
field = cls.model_fields.get(shortcut)
value = data.get(shortcut, field.default if field is not None else None)
if value is None:
continue
if data.get(texture_field) is not None:
gs.raise_exception(f"'{shortcut}' and '{texture_field}' cannot both be set.")
data[texture_field] = ColorTexture(color=value)
# Mirror the roughness shortcut into default_roughness unless the user passed an explicit value.
if "default_roughness" not in data and "roughness" in cls.model_fields:
roughness = data.get("roughness", cls.model_fields["roughness"].default)
if roughness is not None:
data["default_roughness"] = float(roughness)
return data
@property
def texture(self) -> Texture | None:
raise NotImplementedError
@texture.setter
def texture(self, value: Texture | None) -> None:
raise NotImplementedError
@property
def emission(self) -> Texture | None:
return None
@property
def requires_uv(self) -> bool:
return False
def get_rgba(self, batch: bool = False) -> BatchTexture | Texture:
return self._make_rgba(self.texture, None, batch)
def update_texture(
self,
*,
color_texture: Texture | None = None,
ior: float | None = None,
double_sided: bool | None = None,
force: bool = False,
**kwargs,
) -> None:
"""
Update the surface textures using given attributes.
If the surface already contains corresponding textures, the existing ones have higher priority and won't be
overridden. Force overriding can be enabled by setting force=True.
"""
# update primary texture
if self.texture is None or force:
if color_texture is not None:
self.texture = color_texture
elif not force:
self.texture = ColorTexture()
# update ior
if self.ior is None or force:
if ior is not None:
self.ior = ior
elif not force:
self.ior = 1.5
# update double sided
if self.double_sided is None or force:
if double_sided is not None:
self.double_sided = double_sided
@staticmethod
def _update_field(
current: Texture | None, new: Texture | None, default: Texture | None, force: bool
) -> Texture | None:
if current is None or force:
if new is not None:
return new
elif not force and default is not None:
return default
return current
@staticmethod
def _extract_opacity_from(
texture: Texture | None, emissive: Texture | None, opacity: Texture | None
) -> Texture | None:
if texture is not None:
tex = texture.check_dim(3)
if opacity is None and tex is not None:
opacity = tex
if emissive is not None:
tex = emissive.check_dim(3)
if opacity is None and tex is not None:
opacity = tex
return opacity
@staticmethod
def _make_rgba(
color_texture: Texture | None, opacity_texture: Texture | None, batch: bool
) -> BatchTexture | Texture:
all_textures = []
for texture in (color_texture, opacity_texture):
textures = texture.textures if isinstance(texture, BatchTexture) else [texture]
all_textures.append(textures if batch else textures[:1])
color_textures, opacity_textures = all_textures
rgba_textures = []
num_colors = len(color_textures)
num_opacities = len(opacity_textures)
num_rgba = num_colors * num_opacities // math.gcd(num_colors, num_opacities)
for i in range(num_rgba):
color_texture = color_textures[i % num_colors]
opacity_texture = opacity_textures[i % num_opacities]
if isinstance(color_texture, ColorTexture):
if isinstance(opacity_texture, ColorTexture):
rgba_texture = ColorTexture(color=(*color_texture.color, *opacity_texture.color))
elif isinstance(opacity_texture, ImageTexture) and opacity_texture.image_array is not None:
rgb_color = mu.color_f32_to_u8(color_texture.color)
rgb_array = np.full((*opacity_texture.image_array.shape[:2], 3), rgb_color, dtype=np.uint8)
rgba_array = np.dstack((rgb_array, opacity_texture.image_array))
rgba_scale = (1.0, 1.0, 1.0, *opacity_texture.image_color)
rgba_texture = ImageTexture(image_array=rgba_array, image_color=rgba_scale)
else:
rgba_texture = ColorTexture(color=(*color_texture.color, 1.0))
elif isinstance(color_texture, ImageTexture) and color_texture.image_array is not None:
if isinstance(opacity_texture, ColorTexture):
a_color = mu.color_f32_to_u8(opacity_texture.color)
a_array = np.full((*color_texture.image_array.shape[:2],), a_color, dtype=np.uint8)
rgba_array = np.dstack((color_texture.image_array, a_array))
rgba_scale = (*color_texture.image_color, 1.0)
elif (
isinstance(opacity_texture, ImageTexture)
and opacity_texture.image_array is not None
and opacity_texture.image_array.shape[:2] == color_texture.image_array.shape[:2]
):
rgba_array = np.dstack((color_texture.image_array, opacity_texture.image_array))
rgba_scale = (*color_texture.image_color, *opacity_texture.image_color)
else:
if isinstance(opacity_texture, ImageTexture) and opacity_texture.image_array is not None:
gs.logger.warning(
"Color and opacity image shapes do not match. Fall back to fully opaque texture."
)
a_array = np.full(color_texture.image_array.shape[:2], 255, dtype=np.uint8)
rgba_array = np.dstack((color_texture.image_array, a_array))
rgba_scale = (*color_texture.image_color, 1.0)
rgba_texture = ImageTexture(image_array=rgba_array, image_color=rgba_scale)
else:
rgba_texture = ColorTexture(color=(1.0, 1.0, 1.0, 1.0))
rgba_textures.append(rgba_texture)
return BatchTexture(textures=rgba_textures) if batch else rgba_textures[0]
############################ Surface types ############################
[docs]class Glass(Surface):
"""
Glass surface with specular reflection and transmission.
Parameters
----------
color : tuple | None, optional
Specular color of the surface. Shortcut for `specular_texture` with a single color.
roughness : float, optional
Roughness of the surface. Defaults to 0.0.
ior : float, optional
Index of Refraction. Defaults to 1.5.
subsurface : bool
Whether to apply a simple BSSRDF subsurface to the glass material.
thickness : float | None, optional
The thickness of the top surface when 'subsurface' is set to True. Shortcut for `thickness_texture`.
specular_texture : gs.textures.Texture | None, optional
Specular texture of the surface.
diffuse_texture : gs.textures.Texture | None, optional
Diffuse texture of the surface.
transmission_texture : gs.textures.Texture | None, optional
Transmission texture of the surface.
thickness_texture : gs.textures.Texture | None, optional
The thickness texture of the top surface.
roughness_texture : gs.textures.Texture | None, optional
Roughness texture of the surface.
normal_texture : gs.textures.Texture | None, optional
Normal texture of the surface.
emissive_texture : gs.textures.Texture | None, optional
Emissive texture of the surface.
"""
_color_target: ClassVar[str] = "specular_texture"
roughness: UnitInterval | None = Field(default=0.0, exclude=True, repr=False)
ior: float | None = 1.5
thickness: ValidFloat | None = Field(default=None, exclude=True, repr=False)
subsurface: StrictBool = False
specular_texture: Texture | None = None
diffuse_texture: Texture | None = None
transmission_texture: Texture | None = None
thickness_texture: Texture | None = None
roughness_texture: Texture | None = None
normal_texture: Texture | None = None
emissive_texture: Texture | None = None
@model_validator(mode="after")
def _post_init(self) -> Self:
# Truncate specular/emissive textures to 3 channels (discard alpha for Glass which has no opacity_texture)
if self.specular_texture is not None:
self.specular_texture.check_dim(3)
if self.emissive_texture is not None:
self.emissive_texture.check_dim(3)
if self.specular_texture is not None and self.transmission_texture is None:
self.transmission_texture = self.specular_texture
return self
@property
def texture(self) -> Texture | None:
return self.specular_texture
@texture.setter
def texture(self, value: Texture | None) -> None:
self.specular_texture = value
self.transmission_texture = value
@property
def emission(self) -> Texture | None:
return self.emissive_texture
@property
def requires_uv(self) -> bool:
return any(
t is not None and t.requires_uv
for t in (
self.specular_texture,
self.diffuse_texture,
self.transmission_texture,
self.thickness_texture,
self.roughness_texture,
self.normal_texture,
self.emissive_texture,
)
)
def get_rgba(self, batch: bool = False) -> BatchTexture | Texture:
color = self.emissive_texture if self.emissive_texture is not None else self.specular_texture
return self._make_rgba(color, None, batch)
def update_texture(
self,
*,
roughness_texture: Texture | None = None,
normal_texture: Texture | None = None,
emissive_texture: Texture | None = None,
force: bool = False,
**kwargs,
) -> None:
super().update_texture(force=force, **kwargs)
self.roughness_texture = self._update_field(
self.roughness_texture, roughness_texture, ColorTexture(color=(self.default_roughness,)), force
)
self.normal_texture = self._update_field(self.normal_texture, normal_texture, None, force)
self.emissive_texture = self._update_field(self.emissive_texture, emissive_texture, None, force)
[docs]class Plastic(Surface):
"""
Plastic surface is the most basic type of surface.
Parameters
----------
color : tuple | None, optional
Diffuse color of the surface. Shortcut for `diffuse_texture` with a single color.
ior : float, optional
Index of Refraction. Defaults to 1.0.
diffuse_texture : gs.textures.Texture | None, optional
Diffuse (basic color) texture of the surface.
specular_texture : gs.textures.Texture | None, optional
Specular texture of the surface.
opacity_texture : gs.textures.Texture | None, optional
Opacity texture of the surface.
roughness_texture : gs.textures.Texture | None, optional
Roughness texture of the surface.
normal_texture : gs.textures.Texture | None, optional
Normal texture of the surface.
emissive_texture : gs.textures.Texture | None, optional
Emissive texture of the surface.
"""
ior: float | None = 1.0
diffuse_texture: Texture | None = None
specular_texture: Texture | None = None
opacity_texture: Texture | None = None
roughness_texture: Texture | None = None
normal_texture: Texture | None = None
emissive_texture: Texture | None = None
@property
def texture(self) -> Texture | None:
return self.diffuse_texture
@texture.setter
def texture(self, value: Texture | None) -> None:
self.diffuse_texture = value
@property
def emission(self) -> Texture | None:
return self.emissive_texture
@property
def requires_uv(self) -> bool:
return any(
t is not None and t.requires_uv
for t in (
self.diffuse_texture,
self.specular_texture,
self.opacity_texture,
self.roughness_texture,
self.normal_texture,
self.emissive_texture,
)
)
def get_rgba(self, batch: bool = False) -> BatchTexture | Texture:
color = self.emissive_texture if self.emissive_texture is not None else self.diffuse_texture
return self._make_rgba(color, self.opacity_texture, batch)
@model_validator(mode="after")
def _post_init(self) -> Self:
self.opacity_texture = self._extract_opacity_from(
self.diffuse_texture, self.emissive_texture, self.opacity_texture
)
return self
def update_texture(
self,
*,
opacity_texture: Texture | None = None,
roughness_texture: Texture | None = None,
normal_texture: Texture | None = None,
emissive_texture: Texture | None = None,
force: bool = False,
**kwargs,
) -> None:
super().update_texture(force=force, **kwargs)
self.opacity_texture = self._update_field(
self.opacity_texture, opacity_texture, ColorTexture(color=(1.0,)), force
)
self.roughness_texture = self._update_field(
self.roughness_texture, roughness_texture, ColorTexture(color=(self.default_roughness,)), force
)
self.normal_texture = self._update_field(self.normal_texture, normal_texture, None, force)
self.emissive_texture = self._update_field(self.emissive_texture, emissive_texture, None, force)
class BSDF(Surface):
"""
Disney BSDF surface with principled shading.
Parameters
----------
color : tuple | None, optional
Diffuse color of the surface. Shortcut for `diffuse_texture` with a single color.
ior : float, optional
Index of Refraction. Defaults to 1.0.
specular_trans : float, optional
Specular transmission. Defaults to 0.0.
diffuse_trans : float, optional
Diffuse transmission. Defaults to 0.0.
diffuse_texture : gs.textures.Texture | None, optional
Diffuse (basic color) texture of the surface.
opacity_texture : gs.textures.Texture | None, optional
Opacity texture of the surface.
roughness_texture : gs.textures.Texture | None, optional
Roughness texture of the surface.
metallic_texture : gs.textures.Texture | None, optional
Metallic texture of the surface.
normal_texture : gs.textures.Texture | None, optional
Normal texture of the surface.
emissive_texture : gs.textures.Texture | None, optional
Emissive texture of the surface.
"""
ior: float | None = 1.0
diffuse_texture: Texture | None = None
opacity_texture: Texture | None = None
roughness_texture: Texture | None = None
metallic_texture: Texture | None = None
normal_texture: Texture | None = None
emissive_texture: Texture | None = None
specular_trans: float = 0.0
diffuse_trans: float = 0.0
@property
def texture(self) -> Texture | None:
return self.diffuse_texture
@texture.setter
def texture(self, value: Texture | None) -> None:
self.diffuse_texture = value
@property
def emission(self) -> Texture | None:
return self.emissive_texture
@property
def requires_uv(self) -> bool:
return any(
t is not None and t.requires_uv
for t in (
self.diffuse_texture,
self.opacity_texture,
self.roughness_texture,
self.metallic_texture,
self.normal_texture,
self.emissive_texture,
)
)
def get_rgba(self, batch: bool = False) -> BatchTexture | Texture:
color = self.emissive_texture if self.emissive_texture is not None else self.diffuse_texture
return self._make_rgba(color, self.opacity_texture, batch)
@model_validator(mode="after")
def _post_init(self) -> Self:
self.opacity_texture = self._extract_opacity_from(
self.diffuse_texture, self.emissive_texture, self.opacity_texture
)
return self
def update_texture(
self,
*,
opacity_texture: Texture | None = None,
roughness_texture: Texture | None = None,
metallic_texture: Texture | None = None,
normal_texture: Texture | None = None,
emissive_texture: Texture | None = None,
force: bool = False,
**kwargs,
) -> None:
super().update_texture(force=force, **kwargs)
self.opacity_texture = self._update_field(
self.opacity_texture, opacity_texture, ColorTexture(color=(1.0,)), force
)
self.roughness_texture = self._update_field(
self.roughness_texture, roughness_texture, ColorTexture(color=(self.default_roughness,)), force
)
self.metallic_texture = self._update_field(self.metallic_texture, metallic_texture, None, force)
self.normal_texture = self._update_field(self.normal_texture, normal_texture, None, force)
self.emissive_texture = self._update_field(self.emissive_texture, emissive_texture, None, force)
[docs]class Emission(Surface):
"""
Emission surface. This surface emits light. Note that in Genesis's ray tracing pipeline, lights are not a special
type of objects, but simply entities with emission surfaces.
Parameters
----------
color : tuple | None, optional
Emissive color. Shortcut for `emissive_texture` with a single color.
emissive : tuple | None, optional
Emissive color. Shortcut for `emissive_texture` with a single color.
emissive_texture : gs.textures.Texture | None, optional
Emissive texture of the surface.
"""
_color_target: ClassVar[str] = "emissive_texture"
emissive_texture: Texture | None = None
@property
def texture(self) -> Texture | None:
return self.emissive_texture
@texture.setter
def texture(self, value: Texture | None) -> None:
self.emissive_texture = value
@property
def emission(self) -> Texture | None:
return self.emissive_texture
@property
def requires_uv(self) -> bool:
return self.emissive_texture is not None and self.emissive_texture.requires_uv
def get_rgba(self, batch: bool = False) -> BatchTexture | Texture:
return self._make_rgba(self.emissive_texture, None, batch)
@model_validator(mode="after")
def _post_init(self) -> Self:
if self.emissive_texture is not None:
self.emissive_texture.check_dim(3)
return self
def update_texture(self, *, emissive_texture: Texture | None = None, force: bool = False, **kwargs) -> None:
super().update_texture(force=force, **kwargs)
self.emissive_texture = self._update_field(self.emissive_texture, emissive_texture, None, force)
############################ Handy shortcuts ############################
[docs]class Default(BSDF):
"""
The default surface type used in Genesis. This is an alias for `BSDF`.
"""
pass
[docs]class Rough(Plastic):
"""
Shortcut for a rough plastic surface.
"""
roughness: UnitInterval | None = Field(default=1.0, exclude=True, repr=False)
ior: float | None = 1.5
[docs]class Smooth(Plastic):
"""
Shortcut for a smooth plastic surface.
"""
roughness: UnitInterval | None = Field(default=0.1, exclude=True, repr=False)
ior: float | None = 1.5
[docs]class Reflective(Plastic):
"""
Shortcut for a reflective (smoother than `Smooth`) plastic surface.
"""
roughness: UnitInterval | None = Field(default=0.01, exclude=True, repr=False)
ior: float | None = 2.0
[docs]class Collision(Plastic):
"""
Default surface type for collision geometry with a grey color by default.
"""
color: FArrayType | None = Field(default=(0.5, 0.5, 0.5), exclude=True, repr=False)
[docs]class Water(Glass):
"""
Shortcut for a water surface (using Glass surface with proper values).
"""
color: FArrayType | None = Field(default=(0.61, 0.98, 0.93), exclude=True, repr=False)
roughness: UnitInterval | None = Field(default=0.2, exclude=True, repr=False)
ior: float | None = 1.2
[docs]class Iron(Metal):
"""
Shortcut for a metallic surface with `metal_type = 'iron'`.
"""
pass
[docs]class Aluminium(Metal):
"""
Shortcut for a metallic surface with `metal_type = 'aluminium'`.
"""
metal_type: MetalType = "aluminium"
[docs]class Copper(Metal):
"""
Shortcut for a metallic surface with `metal_type = 'copper'`.
"""
metal_type: MetalType = "copper"
[docs]class Gold(Metal):
"""
Shortcut for a metallic surface with `metal_type = 'gold'`.
"""
metal_type: MetalType = "gold"