Source code for simvx.core.colour

"""Backend-agnostic sRGB colour-space conversion helpers.

Pure-numpy implementations of the exact piecewise sRGB electro-optical transfer
function (EOTF) and its inverse. They live in core (no rendering deps) so the
Vulkan desktop backend and the WebGPU web runtime decode *constant* material
colours identically, matching the hardware sRGB texture decode bit-for-bit.

Use these for COLOUR inputs (albedo / base-colour, emissive). Data maps (normal,
metallic-roughness, ambient occlusion) carry no colour and must stay linear.

The piecewise curve (not the ``pow(2.2)`` approximation) is required so a flat
constant colour matches a same-valued sRGB-decoded texture sample exactly.
"""

import numpy as np

__all__ = [
    "srgb_to_linear",
    "linear_to_srgb",
    "srgb_to_linear_rgb",
]


[docs] def srgb_to_linear(c: np.ndarray | float) -> np.ndarray | float: """Decode sRGB-encoded values in [0, 1] to linear light (exact piecewise EOTF). Scalar in / scalar out, or numpy array in / numpy array out (element-wise). """ arr = np.asarray(c, dtype=np.float64) out = np.where(arr <= 0.04045, arr / 12.92, ((arr + 0.055) / 1.055) ** 2.4) return float(out) if np.isscalar(c) or np.ndim(c) == 0 else out
[docs] def linear_to_srgb(c: np.ndarray | float) -> np.ndarray | float: """Encode linear-light values in [0, 1] to sRGB (exact piecewise inverse EOTF).""" arr = np.asarray(c, dtype=np.float64) out = np.where(arr <= 0.0031308, arr * 12.92, 1.055 * np.power(arr, 1.0 / 2.4) - 0.055) return float(out) if np.isscalar(c) or np.ndim(c) == 0 else out
[docs] def srgb_to_linear_rgb(colour: tuple[float, ...]) -> tuple[float, ...]: """Decode the RGB channels of an (R, G, B[, A]) tuple, leaving any 4th component as-is. The 4th component is alpha (linear) for albedo or emissive *intensity* for emissive packing: neither is a colour and both pass through untouched. """ rgb = srgb_to_linear(np.asarray(colour[:3], dtype=np.float64)) return (*(float(x) for x in rgb), *colour[3:])