"""Material — pure data (no GPU dependencies).
Backends (SDL3, Vulkan) extend this to add texture/GPU management.
"""
from __future__ import annotations
import logging
from typing import Literal
import numpy as np
log = logging.getLogger(__name__)
[docs]
class Material:
"""Pure material data for rendering. Backend-agnostic.
Example:
mat = Material(colour=(1, 0, 0, 1)) # Red
mat = Material(colour=(0, 1, 0), blend="alpha")
mat = Material(albedo_map="textures/brick.png")
"""
_next_uid: int = 0
__slots__ = (
"_uid",
"colour",
"metallic",
"roughness",
"blend",
"wireframe",
"double_sided",
"unlit",
"albedo_uri",
"normal_uri",
"metallic_roughness_uri",
"emissive_uri",
"ao_uri",
"albedo_tex_index",
"emissive_colour",
)
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"] = "opaque",
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,
):
"""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"
wireframe: Render as wireframe
double_sided: Disable backface culling
unlit: Disable lighting (flat colour)
albedo_map: Path or embedded bytes for albedo/diffuse texture (optional)
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)
"""
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.wireframe = bool(wireframe)
self.double_sided = bool(double_sided)
self.unlit = bool(unlit)
# Texture URIs (backend loads actual GPU textures)
self.albedo_uri = albedo_map
self.normal_uri = normal_map
self.metallic_roughness_uri = metallic_roughness_map
self.emissive_uri = emissive_map
self.ao_uri = ao_map
# Direct GPU texture index (set by backend, overrides albedo_uri)
self.albedo_tex_index: int = -1
# Emissive colour: (R, G, B, intensity) — None means no emissive colour
self.emissive_colour = emissive_colour
@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.
"""
return (
self.colour, self.metallic, self.roughness, self.blend,
self.wireframe, self.double_sided, self.unlit,
self.albedo_uri, self.normal_uri, self.metallic_roughness_uri,
self.emissive_uri, self.ao_uri, self.albedo_tex_index,
self.emissive_colour,
)
@property
def colour_bytes(self) -> bytes:
"""RGBA as 16 bytes (4x float32) for GPU upload."""
return np.array(self.colour, dtype=np.float32).tobytes()
[docs]
def to_dict(self) -> dict:
"""Serialize to dict for JSON/pickle."""
d = {
"__type__": "Material",
"colour": list(self.colour),
"metallic": self.metallic,
"roughness": self.roughness,
"blend": self.blend,
"wireframe": self.wireframe,
"double_sided": self.double_sided,
"unlit": self.unlit,
}
# Only include texture URIs if they exist
if self.albedo_uri:
d["albedo_uri"] = self.albedo_uri
if self.normal_uri:
d["normal_uri"] = self.normal_uri
if self.metallic_roughness_uri:
d["metallic_roughness_uri"] = self.metallic_roughness_uri
if self.emissive_uri:
d["emissive_uri"] = self.emissive_uri
if self.ao_uri:
d["ao_uri"] = self.ao_uri
if self.emissive_colour is not None:
d["emissive_colour"] = list(self.emissive_colour)
return d
[docs]
@classmethod
def from_dict(cls, d: dict) -> Material:
"""Deserialize from dict."""
return cls(
colour=d.get("colour", (1, 1, 1, 1)),
metallic=d.get("metallic", 0.0),
roughness=d.get("roughness", 0.5),
blend=d.get("blend", "opaque"),
wireframe=d.get("wireframe", False),
double_sided=d.get("double_sided", False),
unlit=d.get("unlit", False),
albedo_map=d.get("albedo_uri"),
normal_map=d.get("normal_uri"),
metallic_roughness_map=d.get("metallic_roughness_uri"),
emissive_map=d.get("emissive_uri"),
ao_map=d.get("ao_uri"),
emissive_colour=d.get("emissive_colour"),
)