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