Source code for simvx.core.graphics.material

"""Material: pure data (no GPU dependencies).

Backends (SDL3, Vulkan) extend this to add texture/GPU management.
"""

import logging
from typing import Literal

import numpy as np

log = logging.getLogger(__name__)


def _coerce_texture_source(source, field: str):
    """Coerce an ndarray texture source to ``uint8`` (warn on lossy converts).

    String / bytes / ``None`` / ``Resource`` / ``Traversable`` sources pass
    through unchanged: they are resolved later by the backend's texture
    loader. Float arrays in ``[0, 1]`` are scaled to ``[0, 255]`` and cast
    to ``uint8``; values outside that range trigger a one-line WARNING so
    callers spot a stale gamma assumption.
    """
    if not isinstance(source, np.ndarray):
        return source
    if source.dtype == np.uint8:
        return source
    if np.issubdtype(source.dtype, np.floating):
        lo, hi = float(source.min()), float(source.max())
        if lo < -1e-3 or hi > 1.0 + 1e-3:
            log.warning(
                "%s ndarray (dtype=%s) outside [0, 1] (min=%.3f, max=%.3f): "
                "clipping before uint8 conversion drops detail.",
                field, source.dtype, lo, hi,
            )
        return (np.clip(source, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8)
    # Integer types other than uint8: clip to byte range and cast.
    if np.issubdtype(source.dtype, np.integer):
        log.warning(
            "%s ndarray (dtype=%s): coercing to uint8; values outside [0, 255] are clipped.",
            field, source.dtype,
        )
        return np.clip(source, 0, 255).astype(np.uint8)
    raise TypeError(f"Unsupported {field} ndarray dtype: {source.dtype}")


[docs] class Material: """Pure material data for rendering. Backend-agnostic. Every map kwarg (``albedo_map``, ``normal_map``, ``metallic_roughness_map``, ``emissive_map``, ``ao_map``) accepts three forms: - ``str``: filesystem path or asset URI; backend loads from disk via ``TextureManager``. - ``bytes``: raw image bytes (PNG / JPEG / etc.) decoded by the backend's image loader. - ``numpy.ndarray``: an in-memory texture, shape ``(H, W, C)`` where C is 1, 3, or 4. Coerced to ``uint8`` at construction time so the GPU always sees byte-per-channel data; ``float32`` arrays in ``[0, 1]`` are scaled, out-of-range floats log a WARNING and clip, and unsupported dtypes raise ``TypeError``. Used by Procedural Planets and Q1K3 to bake gradient ramps without shipping PNGs. Example: mat = Material(colour=(1, 0, 0, 1)) # Red mat = Material(colour=(0, 1, 0), blend="alpha") # Translucent green mat = Material(albedo_map="textures/brick.png") # On-disk texture mat = Material(albedo_map=numpy_rgba_uint8) # Numpy texture """ _next_uid: int = 0 __slots__ = ( "_uid", "colour", "metallic", "roughness", "blend", "alpha_cutoff", "wireframe", "double_sided", "unlit", "albedo_uri", "normal_uri", "metallic_roughness_uri", "emissive_uri", "ao_uri", "albedo_tex_index", "emissive_colour", "_subviewport_albedo", "needs_scene_colour", "needs_scene_depth", "uv_offset", "uv_scale", "uv_rotation", "wetness_affected", "receives_decals", ) def __init__( self, colour: tuple[float, ...] | np.ndarray = (1.0, 1.0, 1.0, 1.0), metallic: float = 0.0, roughness: float = 0.5, blend: Literal["opaque", "alpha", "additive", "cutoff"] = "opaque", alpha_cutoff: float = 0.5, wireframe: bool = False, double_sided: bool = False, unlit: bool = False, albedo_map: str | bytes | None = None, normal_map: str | bytes | None = None, metallic_roughness_map: str | bytes | None = None, emissive_map: str | bytes | None = None, ao_map: str | bytes | None = None, emissive_colour: tuple[float, ...] | None = None, emissive_strength: float | None = None, needs_scene_colour: bool = False, needs_scene_depth: bool = False, uv_offset: tuple[float, float] = (0.0, 0.0), uv_scale: tuple[float, float] = (1.0, 1.0), uv_rotation: float = 0.0, wetness_affected: bool = False, receives_decals: bool = True, ): """Initialize material with colour and properties. Args: colour: RGBA (or RGB auto-expanded to 1.0 alpha) in [0-1] metallic: [0-1] metallic factor roughness: [0-1] roughness factor blend: "opaque", "alpha", "additive", or "cutoff" (alpha-tested cutout: fragments with albedo alpha below ``alpha_cutoff`` are discarded; renders in the opaque pass, no sorting) alpha_cutoff: [0-1] alpha-test threshold for ``blend="cutoff"`` wireframe: Render as wireframe double_sided: Disable backface culling unlit: Disable lighting (flat colour) albedo_map: Path or embedded bytes for albedo/diffuse texture, or a SubViewport / RenderView node whose live offscreen feed to sample. A PlanarReflection3D node is sampled with a mirrored projective UV from the fragment's clip position (planar reflection) instead of the mesh UV. normal_map: Path or embedded bytes for normal map texture (optional) metallic_roughness_map: Path or embedded bytes for metallic-roughness texture (optional) emissive_map: Path or embedded bytes for emissive texture (optional) ao_map: Path or embedded bytes for ambient occlusion texture (optional) emissive_colour: ``(R, G, B)`` or ``(R, G, B, intensity)`` packing. If a 4-tuple, the fourth component is the intensity multiplier (legacy/round-trip form). Prefer the 3-tuple form with the separate ``emissive_strength`` kwarg. emissive_strength: Scalar multiplier applied to the emissive RGB. Stored as the 4th component of ``emissive_colour``. May be combined with a 3-tuple ``emissive_colour`` or used alone (an opaque-white default is supplied when ``emissive_colour`` is ``None``). needs_scene_colour: The material samples the copied opaque scene colour (screen-space refraction). Schedules the desktop opaque/transparent pass split so the transparent draw can read the scene behind it. Only meaningful for a transparent (``blend="alpha"``) material. needs_scene_depth: The material samples the copied opaque scene depth. Rides the same pass split as ``needs_scene_colour``. uv_offset: UV-space offset applied to every material texture sample (KHR_texture_transform). Applied after scale/rotation: ``uv' = rotate(uv * uv_scale, uv_rotation) + uv_offset``. uv_scale: UV-space scale factor per axis. ``(1, 1)`` is identity. uv_rotation: UV rotation in radians, counter-clockwise about the UV origin. Identity transforms (all defaults) are free: the shader path is gated by a feature bit set only when any of the three deviates from identity. wetness_affected: The surface responds to global weather. Under rain (WorldEnvironment ``wetness`` / ``rain_intensity`` > 0, delivered via FrameGlobals) the shader darkens the albedo, drops the roughness (wet surfaces are glossier), and overlays an animated ripple normal on near-horizontal faces. Free when dry or unset: the shader block is gated on both this feature bit and ``wetness > 0``, so a dry frame is byte-identical to the dry path. receives_decals: Whether ``Decal3D`` projectors composite onto this surface. True by default (every surface receives, as before); set False to exclude a surface (glass, water, skybox proxies). Combined with each decal's ``cull_mask``, which selects receiving render layers. """ Material._next_uid += 1 self._uid = Material._next_uid # Normalize colour to 4-component RGBA (as Python floats) c = np.asarray(colour, dtype=np.float32).ravel() if len(c) == 3: c = np.append(c, 1.0) self.colour = tuple(float(x) for x in c[:4]) self.metallic = float(metallic) self.roughness = float(roughness) self.blend = blend self.alpha_cutoff = float(alpha_cutoff) self.wireframe = bool(wireframe) self.double_sided = bool(double_sided) self.unlit = bool(unlit) # Texture URIs (backend loads actual GPU textures). ndarray sources # are coerced to uint8 RGBA at construction time so backends never # silently drop a float32 array because they only know how to # upload one byte per channel. # A SubViewport or RenderView albedo (first-class # ``texture=subviewport``, extended to RenderView): the material # samples the target's live rendered offscreen image. Stored as a direct # reference; the backend reads ``target.texture`` into # ``albedo_tex_index`` each frame (assign-once-track-forever, like Godot # ViewportTexture / Unity RenderTexture). Replacing the node simply # reassigns ``albedo_map``. Kept out of ``albedo_uri`` (which is a # TextureManager-loadable source) so it never hits the path/bytes loader. if getattr(albedo_map, "_is_subviewport", False) or getattr(albedo_map, "_is_renderview", False): self._subviewport_albedo = albedo_map self.albedo_uri = None else: self._subviewport_albedo = None self.albedo_uri = _coerce_texture_source(albedo_map, "albedo_map") self.normal_uri = _coerce_texture_source(normal_map, "normal_map") self.metallic_roughness_uri = _coerce_texture_source( metallic_roughness_map, "metallic_roughness_map", ) self.emissive_uri = _coerce_texture_source(emissive_map, "emissive_map") self.ao_uri = _coerce_texture_source(ao_map, "ao_map") # Direct GPU texture index (set by backend, overrides albedo_uri) self.albedo_tex_index: int = -1 # Screen-read declarations. A truthy flag makes the backend # schedule the opaque/transparent pass split + scene colour/depth copy # so the transparent draw can sample what is behind it. Zero-cost when # both are False: no split, no copy, feature-off frames are unchanged. self.needs_scene_colour = bool(needs_scene_colour) self.needs_scene_depth = bool(needs_scene_depth) # UV transform (KHR_texture_transform). Identity by default; the GPU # packer sets a feature bit only for non-identity values, keeping the # common path untouched. self.uv_offset = (float(uv_offset[0]), float(uv_offset[1])) self.uv_scale = (float(uv_scale[0]), float(uv_scale[1])) self.uv_rotation = float(uv_rotation) # Global wetness response. Off by default so the common # path is untouched; the GPU packer sets the WETNESS_AFFECTED feature bit # only when this is True, and the shader further gates on wetness > 0. self.wetness_affected = bool(wetness_affected) # Decal receiver. True by default so every surface # receives projected decals exactly as before; set False to opt a surface # out (skybox proxies, glass, water). The GPU packer flips the NO_DECALS # feature bit only when this is False, so the common path is byte-identical. self.receives_decals = bool(receives_decals) # Emissive colour packed as ``(R, G, B, intensity)``. ``None`` means no # emissive contribution. ``emissive_strength`` is the explicit scalar # form: if supplied, it folds into the 4th slot. When the caller # passes a 3-tuple plus a strength we reuse the strength as the # intensity; a 4-tuple plus a strength multiplies the two so both # legacy and new call sites compose cleanly. if emissive_colour is None and emissive_strength is None: self.emissive_colour = None else: if emissive_colour is None: rgb = (1.0, 1.0, 1.0) intensity = float(emissive_strength) else: ec = tuple(float(x) for x in emissive_colour) if len(ec) == 3: rgb = ec intensity = 1.0 if emissive_strength is None else float(emissive_strength) elif len(ec) == 4: rgb = ec[:3] intensity = ec[3] * (1.0 if emissive_strength is None else float(emissive_strength)) else: raise ValueError( f"emissive_colour must be a 3- or 4-tuple, got length {len(ec)}", ) self.emissive_colour = (*rgb, intensity)
[docs] @property def content_key(self) -> tuple: """Hashable key representing all rendering-relevant properties. Two materials with the same content_key are visually identical and can share a single GPU material slot. Texture fields that hold an unhashable source (e.g. a numpy ndarray passed via TextureManager) are fingerprinted by ``id()`` so the key stays hashable without mutating the source. """ def _h(v): if v is None or isinstance(v, str | bytes | int | float | bool | tuple): return v try: hash(v) return v except TypeError: return ("<unhashable>", type(v).__name__, id(v)) # An offscreen-feed albedo (SubViewport / RenderView / PlanarReflection3D) # is identified by node id: two materials showing different feeds are # not interchangeable even while both slots are still unpublished (-1), # and a planar-reflection feed additionally samples with a projective UV. svp = self._subviewport_albedo feed_key = None if svp is None else (id(svp), bool(getattr(svp, "_is_planar_reflection", False))) return ( self.colour, self.metallic, self.roughness, self.blend, self.alpha_cutoff, self.wireframe, self.double_sided, self.unlit, _h(self.albedo_uri), _h(self.normal_uri), _h(self.metallic_roughness_uri), _h(self.emissive_uri), _h(self.ao_uri), self.albedo_tex_index, feed_key, self.emissive_colour, self.needs_scene_colour, self.needs_scene_depth, self.uv_offset, self.uv_scale, self.uv_rotation, self.wetness_affected, self.receives_decals, )
[docs] @property def colour_bytes(self) -> bytes: """RGBA as 16 bytes (4x float32) for GPU upload.""" return np.array(self.colour, dtype=np.float32).tobytes()
@property def emissive_strength(self) -> float: """Scalar emissive intensity (the 4th slot of ``emissive_colour``). Returns ``0.0`` when no emissive colour is configured. Setting this mutates the intensity component without changing the RGB; a default of opaque white is supplied when no colour is present yet. """ return 0.0 if self.emissive_colour is None else float(self.emissive_colour[3])
[docs] @emissive_strength.setter def emissive_strength(self, value: float) -> None: if self.emissive_colour is None: self.emissive_colour = (1.0, 1.0, 1.0, float(value)) else: self.emissive_colour = (*self.emissive_colour[:3], float(value))