Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions genesis/ext/pyrender/shaders/mesh.frag
Original file line number Diff line number Diff line change
Expand Up @@ -476,12 +476,13 @@ void main()
color.xyz *= ao;
#endif

// Apply emissive map
// Apply emissive map. 'emissive' already folds in the factor, so add it directly rather than scaling by the
// factor a second time.
vec3 emissive = material.emissive_factor;
#ifdef HAS_EMISSIVE_TEX
emissive *= srgb_to_linear(texture(material.emissive_texture, uv_0)).rgb;
#endif
color.xyz += emissive * material.emissive_factor;
color.xyz += emissive;

vec3 floor_color = floor_flag != 0 ? texture(floor_tex, gl_FragCoord.xy/screen_size).rgb : vec3(0.0);

Expand Down
45 changes: 31 additions & 14 deletions genesis/options/surfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def requires_uv(self) -> bool:
return False

def get_rgba(self, batch: bool = False) -> BatchTexture | Texture:
return _make_rgba(self.texture, None, batch)
return _make_rgba(_resolve_albedo(self.texture, self.emission), None, batch)

def update_texture(
self,
Expand Down Expand Up @@ -214,6 +214,33 @@ def _extract_opacity_from(
_RGBA_CACHE = SizeCappedCache(max_bytes=512 * 1024 * 1024, max_entries=8192)


def _resolve_albedo(base_texture: Texture | None, emissive_texture: Texture | None) -> Texture | None:
# Packed RGBA feeds renderers that read it as base color, so prefer the base-color texture. Fall back to emissive
# only when the base is absent or fully black, which keeps zero-base-factor assets (visible imagery authored into
# emissive) renderable without letting an emissive map override an ordinary authored base color. Batched textures
# decide the fallback per entry: a mixed batch resolves each environment on its own base color rather than on
# whether any environment is black.
is_base_batched = isinstance(base_texture, BatchTexture)
is_emissive_batched = isinstance(emissive_texture, BatchTexture)
if is_base_batched or is_emissive_batched:
base_batch = base_texture.textures if is_base_batched else [base_texture]
emissive_batch = emissive_texture.textures if is_emissive_batched else [emissive_texture]
# Least common multiple keeps every base/emissive pairing, matching how _make_rgba combines batch dimensions.
count = math.lcm(len(base_batch), len(emissive_batch))
resolved = [
_resolve_albedo(base_batch[i % len(base_batch)], emissive_batch[i % len(emissive_batch)])
for i in range(count)
]
# Return the original batch object when no entry fell back, so its identity stays stable and _make_rgba's
# per-instance cache keeps hitting for shared batched textures instead of rebuilding a wrapper each call.
if is_base_batched and len(resolved) == len(base_batch) and all(r is b for r, b in zip(resolved, base_batch)):
return base_texture
return BatchTexture(textures=resolved)
if base_texture is not None and not base_texture.is_black:
return base_texture
Comment thread
duburcqa marked this conversation as resolved.
return emissive_texture if emissive_texture is not None else base_texture


def _make_rgba(color_texture: Texture | None, opacity_texture: Texture | None, batch: bool) -> "BatchTexture | Texture":
# Resolve a surface's color and opacity textures into a single RGBA texture. The result is memoized on the input
# texture instances: surfaces sharing the same textures (e.g. all textured submeshes of a GLB) then reuse a single
Expand Down Expand Up @@ -374,10 +401,6 @@ def requires_uv(self) -> bool:
)
)

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 _make_rgba(color, None, batch)

def update_texture(
self,
*,
Expand Down Expand Up @@ -453,8 +476,7 @@ def requires_uv(self) -> bool:
)

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 _make_rgba(color, self.opacity_texture, batch)
return _make_rgba(_resolve_albedo(self.texture, self.emission), self.opacity_texture, batch)

@model_validator(mode="after")
def _post_init(self) -> Self:
Expand Down Expand Up @@ -544,8 +566,7 @@ def requires_uv(self) -> bool:
)

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 _make_rgba(color, self.opacity_texture, batch)
return _make_rgba(_resolve_albedo(self.texture, self.emission), self.opacity_texture, batch)

@model_validator(mode="after")
def _post_init(self) -> Self:
Expand Down Expand Up @@ -641,8 +662,7 @@ def requires_uv(self) -> bool:
)

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 _make_rgba(color, self.opacity_texture, batch)
return _make_rgba(_resolve_albedo(self.texture, self.emission), self.opacity_texture, batch)

@model_validator(mode="after")
def _post_init(self) -> Self:
Expand Down Expand Up @@ -709,9 +729,6 @@ def emission(self) -> Texture | None:
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 _make_rgba(self.emissive_texture, None, batch)

@model_validator(mode="after")
def _post_init(self) -> Self:
if self.emissive_texture is not None:
Expand Down
11 changes: 6 additions & 5 deletions genesis/options/textures.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,13 @@ def _validate_and_load(cls, data: dict) -> dict:
def is_black(self) -> bool:
assert gs.EPS is not None
assert self.image_color is not None
if all(c < gs.EPS for c in self.image_color):
return True
assert self.image_array is not None
if np.max(self.image_array) == 0:
return True
return False
# Black when every channel's effective value (texel x factor) is zero. Testing the factor and the array
# independently would miss a per-channel factor that masks the only nonzero channels (e.g. a green texture
# scaled by a red-only factor).
channels = self.image_array.shape[-1] if self.image_array.ndim == 3 else 1
channel_peaks = self.image_array.reshape(-1, channels).max(axis=0)
return all(factor < gs.EPS or peak == 0 for factor, peak in zip(self.image_color, channel_peaks))

@computed_field
@cached_property
Expand Down
12 changes: 9 additions & 3 deletions genesis/utils/gltf.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,17 +203,23 @@ def parse_glb_material(glb, material_index, surface):
opacity_texture.apply_cutoff(alpha_cutoff)

if "KHR_materials_unlit" in material.extensions:
# No unlit material implemented in renderers. Use emissive texture.
# No unlit material implemented in renderers, so surface the base color through emissive and give the base a
# black factor. get_rgba then falls back to that emissive as the albedo, instead of the white base that
# update_texture installs for an absent color, which would otherwise hide the unlit imagery.
if color_texture is not None:
emissive_texture = color_texture
color_texture = None
color_texture = mu.create_texture(None, (0.0, 0.0, 0.0), "srgb")
material.extensions.pop("KHR_materials_unlit")
else:
# parse emissive
emissive_image = None
if material.emissiveTexture is not None:
texture = glb.textures[material.emissiveTexture.index]
if material.emissiveTexture.texCoord is not None:
# The single baked UV set follows whichever texture actually samples it. The base color owns it only
# when it is an atlas that requires UVs and is not black; otherwise (absent, black, or a flat factor) the
# emissive atlas owns it, so its texCoord is not silently replaced by the base's unused one.
base_owns_uvs = color_texture is not None and not color_texture.is_black and color_texture.requires_uv
if material.emissiveTexture.texCoord is not None and not base_owns_uvs:
uvs_used = material.emissiveTexture.texCoord
emissive_image = get_glb_image(glb, texture.source, "RGB")

Expand Down
56 changes: 45 additions & 11 deletions genesis/utils/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,25 +299,59 @@ def compute_sdf_data(mesh, res):
def surface_uvs_to_trimesh_visual(surface, uvs=None, n_verts=None):
texture = surface.get_rgba()

# 'trimesh' uses uvs starting from the top-left corner, so flip them to Genesis' convention.
flipped_uvs = None
if uvs is not None:
flipped_uvs = uvs.copy()
flipped_uvs[:, 1] = 1.0 - flipped_uvs[:, 1]

# Composite emissive additively on top of the base color, but only when the base color is the packed albedo
# (present, nonblack, and distinct from the emissive). Otherwise get_rgba already returned the emissive as the
# albedo and re-adding it would double it. Baking it into the material makes every renderer path pick it up through
# from_trimesh (rigid and deformable alike), and forces a PBRMaterial since SimpleMaterial has no emissive channel.
# An image emissive samples UVs, so it is composited only when the mesh has them (else the shader references an
# undeclared uv_0); a flat emissive color needs none.
base = surface.texture
emission = surface.emission
emissive_kwargs = {}
if emission is not None and base is not None and base is not emission and not base.is_black:
if (
isinstance(emission, gs.textures.ImageTexture)
and emission.image_array is not None
and flipped_uvs is not None
):
emissive_kwargs = dict(
emissiveTexture=Image.fromarray(emission.image_array), emissiveFactor=emission.image_color
)
elif isinstance(emission, gs.textures.ColorTexture):
emissive_kwargs = dict(emissiveFactor=emission.color)

if isinstance(texture, gs.textures.ImageTexture):
if uvs is not None:
uvs = uvs.copy()
uvs[:, 1] = 1.0 - uvs[:, 1]
if flipped_uvs is not None:
assert texture.image_array.dtype == np.uint8
visual = trimesh.visual.TextureVisuals(
uv=uvs,
material=trimesh.visual.material.SimpleMaterial(
image=Image.fromarray(texture.image_array), diffuse=(1.0, 1.0, 1.0, 1.0)
),
)
image = Image.fromarray(texture.image_array)
if emissive_kwargs:
material = trimesh.visual.material.PBRMaterial(
baseColorTexture=image, baseColorFactor=(255, 255, 255, 255), **emissive_kwargs
)
else:
material = trimesh.visual.material.SimpleMaterial(image=image, diffuse=(1.0, 1.0, 1.0, 1.0))
visual = trimesh.visual.TextureVisuals(uv=flipped_uvs, material=material)
else:
# fall back to color texture
visual = trimesh.visual.ColorVisuals(vertex_colors=np.tile(texture.mean_color, [n_verts, 1]))
elif isinstance(texture, gs.textures.ColorTexture):
if n_verts is None:
gs.raise_exception("n_verts is required for color texture.")
visual = trimesh.visual.ColorVisuals(vertex_colors=np.tile(np.array(texture.color), [n_verts, 1]))
assert visual.defined
if emissive_kwargs:
# The flat base color applies through a factor, but an image emissive still samples the UVs, so pass them.
material = trimesh.visual.material.PBRMaterial(
baseColorFactor=color_f32_to_u8(texture.color), **emissive_kwargs
)
visual = trimesh.visual.TextureVisuals(uv=flipped_uvs, material=material)
Comment thread
duburcqa marked this conversation as resolved.
else:
visual = trimesh.visual.ColorVisuals(vertex_colors=np.tile(np.array(texture.color), [n_verts, 1]))
assert visual.defined
else:
gs.raise_exception("Cannot get texture when generating trimesh visual.")

Expand Down
51 changes: 0 additions & 51 deletions tests/core/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,12 @@
import numpy as np
import pytest
import trimesh
from pydantic import BaseModel

import quadrants as qd

import genesis as gs
import genesis.utils.geom as gu
import genesis.utils.point_cloud as pc
from genesis.options.surfaces import Surface
from genesis.options.textures import ColorTexture
from genesis.utils.misc import tensor_to_array

from ..utils import assert_allclose, assert_equal
Expand Down Expand Up @@ -237,54 +234,6 @@ def test_urdf_mjcf_names_from_file():
assert urdf_entity.name != urdf_entity2.name


@pytest.mark.required
def test_surface_shortcut_resolution():
# Plastic family: color resolves to diffuse_texture; the Rough subclass roughness default (1.0) feeds
# roughness_texture and default_roughness.
rough = gs.surfaces.Rough(color=(0.4, 0.4, 0.4))
assert rough.color == (0.4, 0.4, 0.4)
assert rough.roughness == 1.0
assert rough.diffuse_texture.color == (0.4, 0.4, 0.4)
assert rough.roughness_texture.color == (1.0,)
assert rough.default_roughness == 1.0

# Glass: color resolves to specular_texture and the thickness shortcut is honored on the same path.
glass = gs.surfaces.Glass(color=(0.6, 0.8, 1.0), thickness=0.02)
assert glass.specular_texture.color == (0.6, 0.8, 1.0)
assert glass.thickness_texture.color == (0.02,)

# BSDF exercises multiple shortcuts at once.
bsdf = gs.surfaces.BSDF(color=(0.2, 0.3, 0.4), roughness=0.3, metallic=0.5)
assert bsdf.diffuse_texture.color == (0.2, 0.3, 0.4)
assert bsdf.roughness_texture.color == (0.3,)
assert bsdf.metallic_texture.color == (0.5,)
assert bsdf.default_roughness == 0.3

# Emission: color resolves to emissive_texture.
emit = gs.surfaces.Emission(color=(1.0, 1.0, 0.0))
assert emit.emissive_texture.color == (1.0, 1.0, 0.0)

# Explicit default_roughness wins over the roughness shortcut.
override = gs.surfaces.Rough(roughness=0.7, default_roughness=0.5)
assert override.default_roughness == 0.5

# Nesting an already-resolved surface in another Pydantic model must not re-trigger resolution.
class Wrapper(BaseModel):
surface: Surface

for surface in (rough, glass, bsdf, emit):
Wrapper(surface=surface)
Wrapper(surface=rough)
assert rough.diffuse_texture.color == (0.4, 0.4, 0.4)
assert rough.roughness_texture.color == (1.0,)

# Passing both the shortcut and its resolved texture at construction is a user error.
with pytest.raises(Exception, match="'color' and 'diffuse_texture' cannot both be set"):
gs.surfaces.Rough(color=(1.0, 0.0, 0.0), diffuse_texture=ColorTexture(color=(0.0, 1.0, 0.0)))
with pytest.raises(Exception, match="'thickness' and 'thickness_texture' cannot both be set"):
gs.surfaces.Glass(thickness=0.02, thickness_texture=ColorTexture(color=(0.05,)))


@pytest.mark.required
def test_morph_orientation_offset_resolution():
quat_90z = gu.xyz_to_quat(np.array((0.0, 0.0, 90.0)), rpy=True, degrees=True)
Expand Down
99 changes: 99 additions & 0 deletions tests/core/test_surface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import numpy as np
import pytest
import trimesh
from pydantic import BaseModel

import genesis as gs
import genesis.utils.mesh as mu
from genesis.options.surfaces import Surface
from genesis.options.textures import ColorTexture

from ..utils import assert_equal


@pytest.mark.required
def test_surface_shortcut_resolution():
# Plastic family: color resolves to diffuse_texture; the Rough subclass roughness default (1.0) feeds
# roughness_texture and default_roughness.
rough = gs.surfaces.Rough(color=(0.4, 0.4, 0.4))
assert rough.color == (0.4, 0.4, 0.4)
assert rough.roughness == 1.0
assert rough.diffuse_texture.color == (0.4, 0.4, 0.4)
assert rough.roughness_texture.color == (1.0,)
assert rough.default_roughness == 1.0

# Glass: color resolves to specular_texture and the thickness shortcut is honored on the same path.
glass = gs.surfaces.Glass(color=(0.6, 0.8, 1.0), thickness=0.02)
assert glass.specular_texture.color == (0.6, 0.8, 1.0)
assert glass.thickness_texture.color == (0.02,)

# BSDF exercises multiple shortcuts at once.
bsdf = gs.surfaces.BSDF(color=(0.2, 0.3, 0.4), roughness=0.3, metallic=0.5)
assert bsdf.diffuse_texture.color == (0.2, 0.3, 0.4)
assert bsdf.roughness_texture.color == (0.3,)
assert bsdf.metallic_texture.color == (0.5,)
assert bsdf.default_roughness == 0.3

# Emission: color resolves to emissive_texture.
emit = gs.surfaces.Emission(color=(1.0, 1.0, 0.0))
assert emit.emissive_texture.color == (1.0, 1.0, 0.0)

# Explicit default_roughness wins over the roughness shortcut.
override = gs.surfaces.Rough(roughness=0.7, default_roughness=0.5)
assert override.default_roughness == 0.5

# Nesting an already-resolved surface in another Pydantic model must not re-trigger resolution.
class Wrapper(BaseModel):
surface: Surface

for surface in (rough, glass, bsdf, emit):
Wrapper(surface=surface)
Wrapper(surface=rough)
assert rough.diffuse_texture.color == (0.4, 0.4, 0.4)
assert rough.roughness_texture.color == (1.0,)

# Passing both the shortcut and its resolved texture at construction is a user error.
with pytest.raises(Exception, match="'color' and 'diffuse_texture' cannot both be set"):
gs.surfaces.Rough(color=(1.0, 0.0, 0.0), diffuse_texture=ColorTexture(color=(0.0, 1.0, 0.0)))
with pytest.raises(Exception, match="'thickness' and 'thickness_texture' cannot both be set"):
gs.surfaces.Glass(thickness=0.02, thickness_texture=ColorTexture(color=(0.05,)))


@pytest.mark.required
def test_packed_rgba_resolves_batched_fallback_per_environment():
# Base-over-emissive selection and its black-base fallback are exercised end to end by the rasterizer emissive
# test. Per-environment (batched) textures, however, are consumed only by the batch renderer, so their behaviour is
# asserted here on the packed RGBA directly. A black base entry defers to its emissive while a nonblack entry in
# the same batch keeps its base; blackness is the effective texel x factor, so a green texture masked to black by
# a red-only factor still defers. A batch with no fallback keeps a stable identity so the packed-RGBA cache hits.
base = gs.textures.ImageTexture(image_array=np.full((4, 4, 3), (201, 166, 105), dtype=np.uint8))
emissive = gs.textures.ImageTexture(image_array=np.full((4, 4, 3), (76, 122, 64), dtype=np.uint8))
masked_black = gs.textures.ImageTexture(
image_array=np.full((4, 4, 3), (0, 255, 0), dtype=np.uint8),
image_color=(1.0, 0.0, 0.0),
)
base_batch = gs.textures.BatchTexture(textures=[masked_black, base])
emissive_batch = gs.textures.BatchTexture(textures=[emissive, emissive])
rgba = gs.surfaces.BSDF(diffuse_texture=base_batch, emissive_texture=emissive_batch).get_rgba(batch=True)
assert_equal(rgba.textures[0].image_array[..., :3], emissive.image_array)
assert_equal(rgba.textures[1].image_array[..., :3], base.image_array)

stable_surface = gs.surfaces.BSDF(diffuse_texture=gs.textures.BatchTexture(textures=[base, base]))
assert stable_surface.get_rgba(batch=True) is stable_surface.get_rgba(batch=True)


@pytest.mark.required
def test_flat_base_with_image_emissive_uv_handling():
# A flat base color plus an image emissive: with UVs the built visual must carry them so the emissive atlas is
# composited, but without UVs it must fall back to a plain color visual, otherwise a material with an emissive
# texture but no texcoords makes the shader reference an undeclared uv_0 and fail to compile.
surface = gs.surfaces.BSDF(
color=(0.9, 0.0, 0.0),
emissive_texture=gs.textures.ImageTexture(image_array=np.full((4, 4, 3), (20, 20, 200), dtype=np.uint8)),
)
with_uvs = mu.surface_uvs_to_trimesh_visual(surface, uvs=np.zeros((3, 2), dtype=np.float32), n_verts=3)
assert with_uvs.uv is not None
assert with_uvs.material.emissiveTexture is not None

without_uvs = mu.surface_uvs_to_trimesh_visual(surface, uvs=None, n_verts=3)
assert isinstance(without_uvs, trimesh.visual.ColorVisuals)
Loading
Loading