Source code for simvx.core.graphics.material

"""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"), )