Source code for simvx.graphics.assets.block_decode

"""CPU decode of BC block-compressed mip 0 to RGBA8, the universal fallback.

When the GPU lacks ``textureCompressionBC`` (or a specific BC format's SAMPLED
feature) the compressed-upload path cannot run. Rather than drop the texture,
:func:`decode_blocks_to_rgba8` decodes mip 0 on the CPU via the optional
``texture2ddecoder`` package and returns RGBA8 pixels the standard
``upload_texture_pixels`` path can ship as ``VK_FORMAT_R8G8B8A8_UNORM``.

``texture2ddecoder`` returns **BGRA** bytes (probe-confirmed: a solid-red BC1
block decodes to ``[0, 0, 255, 255]``), so the channels are swizzled to RGBA
before upload. A missed swizzle would render red as blue.

The package is imported LAZILY inside the function: missing it returns ``None``
so the caller degrades to warn + -1, never a crash, and this module imports
cleanly without it (the web/Pyodide path is unaffected).

Notes / known gaps:
  * BC2 has no decoder in ``texture2ddecoder`` (no ``decode_bc2``); it maps to
    ``None`` -> warn + -1. It is NOT aliased to ``decode_bc3`` because the alpha
    layouts differ and that would corrupt the image. BC2 is rare.
  * BC6H decodes to an 8-bit approximation of an HDR format, and BC*_SRGB
    sources lose sRGB-on-sample (the upload format is hard-coded UNORM). Both
    are acceptable for a degraded fallback; the GPU-native path stays correct.
"""

import logging

import numpy as np

__all__ = ["decode_blocks_to_rgba8"]

log = logging.getLogger(__name__)

# Resolved lazily (vulkan only touched at call time): VkFormat int -> the
# texture2ddecoder function NAME that decodes it. BC2 is absent on purpose.
_DECODE_FN_BY_INT: dict[int, str] = {}

# VkFormat name -> texture2ddecoder function name.
_DECODE_FN_NAMES: dict[str, str] = {
    "VK_FORMAT_BC1_RGB_UNORM_BLOCK": "decode_bc1",
    "VK_FORMAT_BC1_RGB_SRGB_BLOCK": "decode_bc1",
    "VK_FORMAT_BC1_RGBA_UNORM_BLOCK": "decode_bc1",
    "VK_FORMAT_BC1_RGBA_SRGB_BLOCK": "decode_bc1",
    "VK_FORMAT_BC3_UNORM_BLOCK": "decode_bc3",
    "VK_FORMAT_BC3_SRGB_BLOCK": "decode_bc3",
    "VK_FORMAT_BC4_UNORM_BLOCK": "decode_bc4",
    "VK_FORMAT_BC4_SNORM_BLOCK": "decode_bc4",
    "VK_FORMAT_BC5_UNORM_BLOCK": "decode_bc5",
    "VK_FORMAT_BC5_SNORM_BLOCK": "decode_bc5",
    "VK_FORMAT_BC6H_UFLOAT_BLOCK": "decode_bc6",
    "VK_FORMAT_BC6H_SFLOAT_BLOCK": "decode_bc6",
    "VK_FORMAT_BC7_UNORM_BLOCK": "decode_bc7",
    "VK_FORMAT_BC7_SRGB_BLOCK": "decode_bc7",
}


def _decode_fn_name(vk_format: int) -> str | None:
    """Map a ``VkFormat`` int to a texture2ddecoder function name, or None."""
    if not _DECODE_FN_BY_INT:
        import vulkan as vk  # noqa: PLC0415  (call-time only, never at import)
        for name, fn in _DECODE_FN_NAMES.items():
            try:
                _DECODE_FN_BY_INT[int(getattr(vk, name))] = fn
            except AttributeError:
                continue
    return _DECODE_FN_BY_INT.get(int(vk_format))


[docs] def decode_blocks_to_rgba8( vk_format: int, width: int, height: int, level0_blocks: bytes, ) -> np.ndarray | None: """Decode mip 0 of a BC texture to a ``(height, width, 4)`` uint8 RGBA array. Returns ``None`` when there is no decoder for ``vk_format`` (e.g. BC2) or the ``texture2ddecoder`` package is not installed: the caller then warns + -1. """ fn_name = _decode_fn_name(vk_format) if fn_name is None: return None try: import texture2ddecoder # noqa: PLC0415 (lazy: optional dep, fallback path only) except ImportError: return None fn = getattr(texture2ddecoder, fn_name) raw = fn(bytes(level0_blocks), width, height) # BGRA bytes, len == width*height*4 arr = np.frombuffer(raw, dtype=np.uint8).reshape(height, width, 4) rgba = np.ascontiguousarray(arr[:, :, [2, 1, 0, 3]]) # BGRA -> RGBA log.debug("Decoded BC fmt=%d %dx%d to RGBA8 (CPU fallback)", vk_format, width, height) return rgba