"""Material: pure data (no GPU dependencies).
Backends (SDL3, Vulkan) extend this to add texture/GPU management.
"""
import copy
import logging
from collections.abc import Callable
from typing import Any, Literal
import numpy as np
from ._slots import slot_names
from .texture import Texture
log = logging.getLogger(__name__)
# Slots holding something with an identity of its own (a live SubViewport /
# RenderView feed node). A copy shares them rather than duplicating them: they
# are things the material points at, not values it holds.
_SHARED_SLOTS = frozenset({"_subviewport_albedo"})
_UNSET = object()
# Texture sources that are already hashable and usable as a key as they stand.
# A tuple rather than a ``|`` union: the renderer builds a content key per
# material per frame, and the union form rebuilds a type object on every check.
_PLAIN_KEY_TYPES = (str, bytes, int, float, bool, tuple)
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
# ``__weakref__`` is declared so a backend can hang a finalizer on a material
# and hand its GPU slot back when the material dies. Without it every
# ``weakref.finalize(material, ...)`` raises TypeError and the slot is held
# for the life of the process.
__slots__ = (
"__weakref__",
"_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)
# --- Identity across copies ---------------------------------------------
def _clone_into(self, clone: Material, copy_value: Callable[[Any], Any]) -> Material:
"""Fill *clone* from this material, giving it an identity of its own.
``_uid`` is this material's identity, and a backend keys its GPU slot and
that slot's reclamation on it. A copy that carried the original's uid
would be a second live material claiming one row: the two would render
each other's colour, and whichever died first would hand back a row the
other is still reading. Every copy gets a fresh identity.
A :class:`Texture`, and a live offscreen feed node, are shared rather
than duplicated for the same reason -- they have identities of their own,
so copying one mints an alias instead of a new resource.
A subclass that declares no ``__slots__`` of its own keeps its state in a
``__dict__`` instead, so both stores are carried across; pickling already
restores both, and the two paths must agree.
"""
def carried(name: str, value: Any) -> Any:
if name in _SHARED_SLOTS or isinstance(value, Texture):
return value
return copy_value(value)
for name in slot_names(type(self)):
if name == "_uid":
continue
value = getattr(self, name, _UNSET)
if value is _UNSET: # a subclass slot that was never assigned
continue
setattr(clone, name, carried(name, value))
state = getattr(self, "__dict__", None)
if state:
clone.__dict__.update({name: carried(name, value) for name, value in state.items()})
Material._next_uid += 1
clone._uid = Material._next_uid
return clone
[docs]
def __copy__(self) -> Material:
return self._clone_into(object.__new__(type(self)), lambda value: value)
[docs]
def __deepcopy__(self, memo: dict) -> Material:
clone = object.__new__(type(self))
memo[id(self)] = clone # one copy per material, however many times a structure holds it
return self._clone_into(clone, lambda value: copy.deepcopy(value, memo))
[docs]
def __setstate__(self, state: Any) -> None:
"""Restore a pickled material, minting a fresh identity.
A ``_uid`` is only meaningful in the process that minted it, so a
restored material takes a new one rather than a number that may already
belong to a live material here.
"""
attrs, slots = state if isinstance(state, tuple) else (state, None)
if attrs:
self.__dict__.update(attrs) # a subclass that also carries a __dict__
for name, value in (slots or {}).items():
if name != "_uid":
setattr(self, name, value)
Material._next_uid += 1
self._uid = Material._next_uid
[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. A :class:`Texture` is
fingerprinted by its stable identity; an unhashable raw source (a numpy
ndarray handed straight to the TextureManager) has no identity of its
own and falls back to ``id()``, which is exactly the gap wrapping it in
a ``Texture`` closes.
The texture's ``version`` deliberately stays OUT of the key. It drives
re-upload, not re-slotting: putting it in would mint a fresh material
slot on every pixel update of an otherwise unchanged material.
"""
def _h(v):
if v is None or isinstance(v, _PLAIN_KEY_TYPES):
return v
if isinstance(v, Texture):
return ("<texture>", v._uid)
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))