Source code for simvx.core.colour
"""Backend-agnostic sRGB colour-space conversion helpers.
The exact piecewise sRGB electro-optical transfer function (EOTF) and its
inverse, in a numpy form for buffers and a cached scalar form for the 2D vertex
path. 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.
This module is the single home of the EOTF, and that is the point: colour
crosses from author space to GPU space at a lot of seams, and a seam that
hand-rolls its own decode (or forgets one) is only findable if every other seam
is greppable from here.
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.
"""
from functools import lru_cache
import numpy as np
__all__ = [
"srgb_to_linear",
"srgb_channel_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]
@lru_cache(maxsize=4096)
def srgb_channel_to_linear(c: float) -> float:
"""Decode one sRGB-encoded colour channel to linear light.
The same curve :func:`srgb_to_linear` implements, scalar and cached because
the 2D drawing surfaces decode the same handful of palette values every
frame and going through numpy per channel is not worth it. Values above 1.0
(HDR) follow the extended curve; values at or below the 0.04045 knee use the
linear segment, which also covers negatives.
2D vertex colours are authored in sRGB space (``Colour.hex`` components),
but everything downstream of the vertex buffer works in linear light: the
shaders pass vertex colour through, blending happens in linear, and the
sRGB render target encodes on write. Uploading the authored value verbatim
therefore encodes an already-encoded colour, which is exactly one sRGB
encode too bright. Decoding here makes a solid fill of a given hex match a
textured sprite of that hex, whose sRGB texture the sampler decodes in
hardware.
"""
if c <= 0.04045:
return c / 12.92
# float ** float is Any to a type checker (a negative base can give a
# complex), and the knee above already rules that out here.
return float(((c + 0.055) / 1.055) ** 2.4)
[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:])