diff --git a/genesis/ext/pyrender/shaders/mesh.frag b/genesis/ext/pyrender/shaders/mesh.frag index d8dc3ce235..fc8e99f559 100644 --- a/genesis/ext/pyrender/shaders/mesh.frag +++ b/genesis/ext/pyrender/shaders/mesh.frag @@ -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); diff --git a/genesis/options/surfaces.py b/genesis/options/surfaces.py index 789e1c644a..4ba3116a3b 100644 --- a/genesis/options/surfaces.py +++ b/genesis/options/surfaces.py @@ -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, @@ -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 + 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 @@ -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, *, @@ -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: @@ -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: @@ -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: @@ -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: diff --git a/genesis/options/textures.py b/genesis/options/textures.py index 70c83eb87e..7efb459dc0 100644 --- a/genesis/options/textures.py +++ b/genesis/options/textures.py @@ -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 diff --git a/genesis/utils/gltf.py b/genesis/utils/gltf.py index 2e187f8bfb..be63528b31 100644 --- a/genesis/utils/gltf.py +++ b/genesis/utils/gltf.py @@ -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") diff --git a/genesis/utils/mesh.py b/genesis/utils/mesh.py index 12a4281063..3557d7f3a9 100644 --- a/genesis/utils/mesh.py +++ b/genesis/utils/mesh.py @@ -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) + 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.") diff --git a/tests/core/test_misc.py b/tests/core/test_misc.py index e15b7c0dd4..a34763d916 100644 --- a/tests/core/test_misc.py +++ b/tests/core/test_misc.py @@ -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 @@ -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) diff --git a/tests/core/test_surface.py b/tests/core/test_surface.py new file mode 100644 index 0000000000..8f6ba91e5c --- /dev/null +++ b/tests/core/test_surface.py @@ -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) diff --git a/tests/parsers/conftest.py b/tests/parsers/conftest.py index 7b853948c5..40432bd5ef 100644 --- a/tests/parsers/conftest.py +++ b/tests/parsers/conftest.py @@ -1,7 +1,9 @@ +import io import os import xml.etree.ElementTree as ET import numpy as np +import pygltflib import pytest import trimesh from PIL import Image @@ -486,6 +488,56 @@ def usd_scene(request, model_name, scale, fixed): return build_usd_scene(request.getfixturevalue(model_name), scale=scale, fixed=fixed) +@pytest.fixture(scope="session") +def emissive_material_variants_glb(asset_tmp_path): + """Path to a GLB with three materials, each on distinct base/emissive texCoord sets: a base-color atlas (red) on + texCoord 0 with an emissive atlas on texCoord 1, a flat base color with an emissive atlas on texCoord 1, and a + KHR_materials_unlit material whose red base atlas stands in for the unlit imagery. The red base atlas is index 0.""" + images = [] + for color in (np.array([220, 30, 30], np.uint8), np.array([30, 220, 30], np.uint8)): + buffer = io.BytesIO() + Image.fromarray(np.broadcast_to(color, (8, 8, 3)).copy()).save(buffer, format="PNG") + images.append(buffer.getvalue()) + + blob = b"" + buffer_views = [] + for data in images: + blob += b"\x00" * ((4 - len(blob) % 4) % 4) + buffer_views.append(pygltflib.BufferView(buffer=0, byteOffset=len(blob), byteLength=len(data))) + blob += data + + gltf = pygltflib.GLTF2( + materials=[ + pygltflib.Material( + pbrMetallicRoughness=pygltflib.PbrMetallicRoughness( + baseColorTexture=pygltflib.TextureInfo(index=0, texCoord=0) + ), + emissiveTexture=pygltflib.TextureInfo(index=1, texCoord=1), + emissiveFactor=[1.0, 1.0, 1.0], + ), + pygltflib.Material( + pbrMetallicRoughness=pygltflib.PbrMetallicRoughness(baseColorFactor=[0.5, 0.5, 0.5, 1.0]), + emissiveTexture=pygltflib.TextureInfo(index=1, texCoord=1), + emissiveFactor=[1.0, 1.0, 1.0], + ), + pygltflib.Material( + pbrMetallicRoughness=pygltflib.PbrMetallicRoughness( + baseColorTexture=pygltflib.TextureInfo(index=0, texCoord=0) + ), + extensions={"KHR_materials_unlit": {}}, + ), + ], + textures=[pygltflib.Texture(source=0), pygltflib.Texture(source=1)], + images=[pygltflib.Image(bufferView=i, mimeType="image/png") for i in range(2)], + bufferViews=buffer_views, + buffers=[pygltflib.Buffer(byteLength=len(blob))], + ) + gltf.set_binary_blob(blob) + path = asset_tmp_path / "emissive_material_variants.glb" + gltf.save_binary(str(path)) + return str(path) + + @pytest.fixture def material_mjcf(tmp_path): """Generate an MJCF model with materials and geom-level colors.""" diff --git a/tests/parsers/test_mesh.py b/tests/parsers/test_mesh.py index dc9f0a1c99..a73ef78ed8 100644 --- a/tests/parsers/test_mesh.py +++ b/tests/parsers/test_mesh.py @@ -470,6 +470,26 @@ def test_glb_shared_texture_not_duplicated(tmp_path): assert len(static_args["tex_widths"]) == 1 +@pytest.mark.required +def test_glb_uv_set_and_unlit_albedo_resolution(emissive_material_variants_glb): + # A single UV set is baked per mesh, following whichever texture actually samples it, and unlit imagery must not be + # hidden by the white base that a missing color installs. parse_glb_material returns the chosen texCoord and surface. + glb = pygltflib.GLTF2().load(emissive_material_variants_glb) + glb.convert_images(pygltflib.ImageFormat.DATAURI) + + # A base-color atlas owns the UV set; an emissive on a different texCoord does not replace it. + _, base_atlas_uvs, _ = gltf_utils.parse_glb_material(glb, 0, gs.surfaces.Default()) + assert base_atlas_uvs == 0 + # A flat base color needs no UVs, so the emissive atlas' texCoord is baked instead of the unused base one. + _, factor_base_uvs, _ = gltf_utils.parse_glb_material(glb, 1, gs.surfaces.Default()) + assert factor_base_uvs == 1 + # An unlit material renders its imagery (mapped to emissive) as the albedo, not the default white base. + unlit_surface, _, _ = gltf_utils.parse_glb_material(glb, 2, gs.surfaces.Default()) + assert_equal( + unlit_surface.get_rgba().image_array[..., :3], np.broadcast_to(np.array([220, 30, 30], np.uint8), (8, 8, 3)) + ) + + @pytest.mark.required def test_glb_multi_primitive_distinct_materials(tmp_path): # A single glTF mesh node may hold several primitives with distinct materials/textures. With the default diff --git a/tests/rendering/conftest.py b/tests/rendering/conftest.py index 3767a00b52..8041b99a56 100644 --- a/tests/rendering/conftest.py +++ b/tests/rendering/conftest.py @@ -1,6 +1,9 @@ import enum +import numpy as np import pytest +import trimesh +from PIL import Image import genesis as gs @@ -67,3 +70,20 @@ def skip_if_not_installed(renderer_type): import LuisaRenderPy except ImportError: pytest.skip(SKIP_NO_LUISA) + + +@pytest.fixture(scope="session") +def base_plus_emissive_glb(asset_tmp_path): + """Path to a GLB quad whose material pairs a red base-color atlas with a blue emissive atlas.""" + base = Image.fromarray(np.broadcast_to(np.array([230, 20, 20], np.uint8), (16, 16, 3)).copy()) + material = trimesh.visual.material.PBRMaterial(baseColorTexture=base, doubleSided=True) + material.emissiveTexture = Image.fromarray(np.broadcast_to(np.array([20, 20, 200], np.uint8), (16, 16, 3)).copy()) + material.emissiveFactor = [1.0, 1.0, 1.0] + path = asset_tmp_path / "base_plus_emissive.glb" + trimesh.Trimesh( + vertices=[[-1.0, -1.0, 0.0], [1.0, -1.0, 0.0], [1.0, 1.0, 0.0], [-1.0, 1.0, 0.0]], + faces=[[0, 1, 2], [0, 2, 3]], + visual=trimesh.visual.TextureVisuals(uv=[[0, 0], [1, 0], [1, 1], [0, 1]], material=material), + process=False, + ).export(path) + return str(path) diff --git a/tests/rendering/test_offscreen.py b/tests/rendering/test_offscreen.py index 33f3c81946..b1e5852447 100644 --- a/tests/rendering/test_offscreen.py +++ b/tests/rendering/test_offscreen.py @@ -94,6 +94,87 @@ def test_render_api(show_viewer, renderer_type, renderer): raise +@pytest.mark.required +def test_emissive_composites_over_base_color_without_double_counting(base_plus_emissive_glb, show_viewer, renderer): + # The rasterizer must composite emissive on top of the base color under flat ambient light, checked on one scene + # whose entities are separated by segmentation: + # - image_quad: a rigid GLB pairing a red base atlas with a blue emissive atlas keeps red dominant (the base is + # neither dropped to black nor replaced by the emissive map) while blue is lifted well above the base's own blue. + # - fem_quad: a deformable entity with a red base and a blue emissive behaves the same, exercising the non-rigid + # render path (which builds its material identically through surface_uvs_to_trimesh_visual). + # - base_green vs emissive_green: a plain green surface and a black-base surface whose green imagery lives in + # emissive render at the same brightness. get_rgba already packs that emissive as the albedo, so adding it again + # would double it; the two must match. + scene = gs.Scene( + sim_options=gs.options.SimOptions( + dt=1e-3, + ), + fem_options=gs.options.FEMOptions(), + vis_options=gs.options.VisOptions( + ambient_light=(1.0, 1.0, 1.0), + shadow=False, + ), + renderer=renderer, + show_viewer=show_viewer, + show_FPS=False, + ) + image_quad = scene.add_entity( + morph=gs.morphs.Mesh( + file=base_plus_emissive_glb, + pos=(-4.5, 0.0, 0.0), + fixed=True, + collision=False, + file_meshes_are_zup=True, + ), + ) + base_green = scene.add_entity( + morph=gs.morphs.Box( + size=(2.0, 2.0, 0.1), + pos=(-1.5, 0.0, 0.0), + fixed=True, + ), + surface=gs.surfaces.BSDF(color=(0.0, 0.6, 0.0)), + ) + emissive_green = scene.add_entity( + morph=gs.morphs.Box( + size=(2.0, 2.0, 0.1), + pos=(1.5, 0.0, 0.0), + fixed=True, + ), + surface=gs.surfaces.BSDF(color=(0.0, 0.0, 0.0), emissive=(0.0, 0.6, 0.0)), + ) + fem_quad = scene.add_entity( + morph=gs.morphs.Box( + size=(2.0, 2.0, 0.5), + pos=(4.5, 0.0, 0.0), + ), + material=gs.materials.FEM.Elastic(), + surface=gs.surfaces.BSDF(color=(0.9, 0.0, 0.0), emissive=(0.0, 0.0, 0.6)), + ) + camera = scene.add_camera( + pos=(0.0, 0.0, 16.0), + lookat=(0.0, 0.0, 0.0), + res=(256, 96), + GUI=show_viewer, + ) + scene.build() + + rgb, _, segmentation, _ = camera.render(rgb=True, segmentation=True) + # Segmentation labels background 0 and each entity by its index plus one, giving exact per-entity pixel masks. + entities = (image_quad, base_green, emissive_green, fem_quad) + means = {e: rgb[segmentation == e.idx + 1].mean(axis=0) for e in entities} + assert all(np.isfinite(mean).all() for mean in means.values()) + + # Base color preserved and emissive added on top, on both a rigid and a deformable entity: red stays dominant + # (not replaced by the blue emissive) while blue is lifted well above the base's own blue (which renders near 40). + for e in (image_quad, fem_quad): + assert means[e][0] > means[e][2] + assert means[e][2] > 120.0 + # The emissive that get_rgba already packed as albedo renders once, matching the plain green surface. + assert means[emissive_green][1] > 120.0 + assert abs(means[emissive_green][1] - means[base_green][1]) < 30.0 + + @pytest.mark.required @pytest.mark.parametrize( "renderer_type",