Source code for simvx.graphics.assets.dds_loader

"""Pure-Python DDS (DirectDraw Surface) parser for block-compressed textures.

Parses a ``.dds`` container into a :class:`DDSTexture`: the target ``VkFormat``
int, dimensions, the format's block size (8 or 16 bytes), and the list of
tightly-packed mip-level block bytes ready for ``Engine.upload_texture_blocks``.

No new dependency: just ``struct`` + ``numpy`` (numpy only for the byte slicing
helper). Vulkan is referenced for its ``VK_FORMAT_BC*`` integer constants at
call time only, never at import, so this module imports cleanly with no device.

Scope (foundation phase): 2D single-array BC1-BC7. Cubemaps, texture arrays,
and non-BC formats raise ``ValueError`` (the caller converts that to a one-time
WARNING + a -1 "couldn't resolve" sentinel).
"""

import logging
import struct
from dataclasses import dataclass
from pathlib import Path

import vulkan as vk

__all__ = ["DDSTexture", "load_dds", "vk_format_block_size"]

log = logging.getLogger(__name__)

_DDS_MAGIC = b"DDS "

# Single source of truth for block sizes, keyed by VkFormat name (BC/ASTC/ETC2).
# Both the DDS format maps below and the int-keyed ``vk_format_block_size``
# helper (used by the KTX2 reader, which carries the VkFormat int directly)
# derive their block sizes from this table so the two never drift. BC1 and BC4
# use 8-byte blocks; every other BC variant, ASTC-4x4, and ETC2-RGBA use 16.
_BC_BLOCK_SIZE: dict[str, int] = {
    "VK_FORMAT_BC1_RGB_UNORM_BLOCK": 8,
    "VK_FORMAT_BC1_RGB_SRGB_BLOCK": 8,
    "VK_FORMAT_BC1_RGBA_UNORM_BLOCK": 8,
    "VK_FORMAT_BC1_RGBA_SRGB_BLOCK": 8,
    "VK_FORMAT_BC2_UNORM_BLOCK": 16,
    "VK_FORMAT_BC2_SRGB_BLOCK": 16,
    "VK_FORMAT_BC3_UNORM_BLOCK": 16,
    "VK_FORMAT_BC3_SRGB_BLOCK": 16,
    "VK_FORMAT_BC4_UNORM_BLOCK": 8,
    "VK_FORMAT_BC4_SNORM_BLOCK": 8,
    "VK_FORMAT_BC5_UNORM_BLOCK": 16,
    "VK_FORMAT_BC5_SNORM_BLOCK": 16,
    "VK_FORMAT_BC6H_UFLOAT_BLOCK": 16,
    "VK_FORMAT_BC6H_SFLOAT_BLOCK": 16,
    "VK_FORMAT_BC7_UNORM_BLOCK": 16,
    "VK_FORMAT_BC7_SRGB_BLOCK": 16,
    # ASTC 4x4 and ETC2 RGBA both pack 16 B/block over a 4x4 texel footprint,
    # so the memory.py block-row math (`blocks_w*4`, `blocks_h*4`) is correct for
    # them, same as BC7. SCOPE: 4x4 ASTC only. Larger ASTC footprints (5x4..12x12)
    # also pack 16 B/block but cover MORE texels, so the 4-texel block-row math
    # would compute the WRONG buffer size: do NOT add them here.
    "VK_FORMAT_ASTC_4x4_UNORM_BLOCK": 16,
    "VK_FORMAT_ASTC_4x4_SRGB_BLOCK": 16,
    "VK_FORMAT_ETC2_R8G8B8A8_UNORM_BLOCK": 16,
    "VK_FORMAT_ETC2_R8G8B8A8_SRGB_BLOCK": 16,
}

# DDS_HEADER.dwFlags / caps bits we care about.
_DDSCAPS2_CUBEMAP = 0x200
_DDSCAPS2_VOLUME = 0x200000

# DDS_HEADER.ddspf.dwFlags
_DDPF_FOURCC = 0x4

# Legacy four-character code -> VkFormat name. Block size comes from
# ``_BC_BLOCK_SIZE`` so there is a single authority for the 8/16 split.
_FOURCC_MAP: dict[bytes, str] = {
    b"DXT1": "VK_FORMAT_BC1_RGBA_UNORM_BLOCK",
    b"DXT2": "VK_FORMAT_BC2_UNORM_BLOCK",
    b"DXT3": "VK_FORMAT_BC2_UNORM_BLOCK",
    b"DXT4": "VK_FORMAT_BC3_UNORM_BLOCK",
    b"DXT5": "VK_FORMAT_BC3_UNORM_BLOCK",
    b"ATI1": "VK_FORMAT_BC4_UNORM_BLOCK",
    b"BC4U": "VK_FORMAT_BC4_UNORM_BLOCK",
    b"BC4S": "VK_FORMAT_BC4_SNORM_BLOCK",
    b"ATI2": "VK_FORMAT_BC5_UNORM_BLOCK",
    b"BC5U": "VK_FORMAT_BC5_UNORM_BLOCK",
    b"BC5S": "VK_FORMAT_BC5_SNORM_BLOCK",
}

# DXGI_FORMAT enum value -> VkFormat name. SRGB variants chosen for albedo,
# UNORM for data maps; the file's own flag decides which.
_DXGI_MAP: dict[int, str] = {
    70: "VK_FORMAT_BC1_RGBA_UNORM_BLOCK",   # BC1_TYPELESS
    71: "VK_FORMAT_BC1_RGBA_UNORM_BLOCK",   # BC1_UNORM
    72: "VK_FORMAT_BC1_RGBA_SRGB_BLOCK",    # BC1_UNORM_SRGB
    73: "VK_FORMAT_BC2_UNORM_BLOCK",        # BC2_TYPELESS
    74: "VK_FORMAT_BC2_UNORM_BLOCK",        # BC2_UNORM
    75: "VK_FORMAT_BC2_SRGB_BLOCK",         # BC2_UNORM_SRGB
    76: "VK_FORMAT_BC3_UNORM_BLOCK",        # BC3_TYPELESS
    77: "VK_FORMAT_BC3_UNORM_BLOCK",        # BC3_UNORM
    78: "VK_FORMAT_BC3_SRGB_BLOCK",         # BC3_UNORM_SRGB
    79: "VK_FORMAT_BC4_UNORM_BLOCK",        # BC4_TYPELESS
    80: "VK_FORMAT_BC4_UNORM_BLOCK",        # BC4_UNORM
    81: "VK_FORMAT_BC4_SNORM_BLOCK",        # BC4_SNORM
    82: "VK_FORMAT_BC5_UNORM_BLOCK",        # BC5_TYPELESS
    83: "VK_FORMAT_BC5_UNORM_BLOCK",        # BC5_UNORM
    84: "VK_FORMAT_BC5_SNORM_BLOCK",        # BC5_SNORM
    94: "VK_FORMAT_BC6H_UFLOAT_BLOCK",      # BC6H_TYPELESS
    95: "VK_FORMAT_BC6H_UFLOAT_BLOCK",      # BC6H_UF16
    96: "VK_FORMAT_BC6H_SFLOAT_BLOCK",      # BC6H_SF16
    97: "VK_FORMAT_BC7_UNORM_BLOCK",        # BC7_TYPELESS
    98: "VK_FORMAT_BC7_UNORM_BLOCK",        # BC7_UNORM
    99: "VK_FORMAT_BC7_SRGB_BLOCK",         # BC7_UNORM_SRGB
}


[docs] @dataclass(frozen=True) class DDSTexture: """A parsed block-compressed DDS texture. Attributes: vk_format: ``VkFormat`` integer (a ``VK_FORMAT_BC*`` constant). width / height: top-level mip dimensions in texels. block_size: bytes per 4x4 block (8 for BC1/BC4, 16 otherwise). mips: tightly-packed block bytes per mip level, level 0 first. """ vk_format: int width: int height: int block_size: int mips: list[bytes]
def _resolve_format(name: str) -> int: """Map a ``VK_FORMAT_BC*`` name to its int, only touching vulkan at call time.""" return int(getattr(vk, name)) # int(VkFormat) -> block_size, resolved lazily from ``_BC_BLOCK_SIZE`` the first # time it is asked for (vulkan is only touched at call time, never at import). _BC_BLOCK_SIZE_BY_INT: dict[int, int] = {}
[docs] def vk_format_block_size(vk_format: int) -> int: """Return the 4x4 block size (8 or 16 bytes) for a BC/ASTC-4x4/ETC2 ``VkFormat`` int. Shared by both the DDS reader (which maps a name first) and the KTX2 reader (which carries the ``VkFormat`` int directly). Covers the BC family plus ASTC-4x4 and ETC2-RGBA (all 4-texel footprint @ 16 B/block). Raises ``ValueError`` for any other int (e.g. non-4x4 ASTC, whose larger footprint would break the 4-texel block-row math) so the caller degrades to warn + -1. """ if not _BC_BLOCK_SIZE_BY_INT: for name, size in _BC_BLOCK_SIZE.items(): _BC_BLOCK_SIZE_BY_INT[int(getattr(vk, name))] = size try: return _BC_BLOCK_SIZE_BY_INT[int(vk_format)] except KeyError: raise ValueError(f"VkFormat {vk_format} is not a supported BC block format") from None
[docs] def load_dds(source: str | Path | bytes) -> DDSTexture: """Parse a DDS file path or raw bytes into a :class:`DDSTexture`. Raises ``ValueError`` for a bad magic, an unknown FourCC/DXGI format, or an unsupported container shape (cubemap / volume / array). The caller converts that to a one-time WARNING + -1 sentinel. """ data = Path(source).read_bytes() if isinstance(source, str | Path) else bytes(source) if data[:4] != _DDS_MAGIC: raise ValueError("Not a DDS file: bad magic") if len(data) < 128: raise ValueError("DDS file truncated: header incomplete") dw_size = struct.unpack_from("<I", data, 4)[0] if dw_size != 124: raise ValueError(f"Unexpected DDS_HEADER size {dw_size} (expected 124)") height = struct.unpack_from("<I", data, 12)[0] width = struct.unpack_from("<I", data, 16)[0] mip_count = struct.unpack_from("<I", data, 28)[0] or 1 caps2 = struct.unpack_from("<I", data, 112)[0] if caps2 & (_DDSCAPS2_CUBEMAP | _DDSCAPS2_VOLUME): raise ValueError("DDS cubemap/volume textures are not supported in this phase") pf_flags = struct.unpack_from("<I", data, 80)[0] four_cc = data[84:88] if not (pf_flags & _DDPF_FOURCC): raise ValueError("Uncompressed DDS (no FourCC) is not supported; use PNG/JPG") if four_cc == b"DX10": if len(data) < 148: raise ValueError("DDS DX10 header truncated") dxgi_format = struct.unpack_from("<I", data, 128)[0] array_size = struct.unpack_from("<I", data, 140)[0] if array_size > 1: raise ValueError("DDS texture arrays are not supported in this phase") if dxgi_format not in _DXGI_MAP: raise ValueError(f"Unsupported DXGI format {dxgi_format}") fmt_name = _DXGI_MAP[dxgi_format] data_offset = 148 else: if four_cc not in _FOURCC_MAP: raise ValueError(f"Unsupported DDS FourCC {four_cc!r}") fmt_name = _FOURCC_MAP[four_cc] data_offset = 128 block_size = _BC_BLOCK_SIZE[fmt_name] vk_format = _resolve_format(fmt_name) mips: list[bytes] = [] off = data_offset for i in range(mip_count): mw = max(1, width >> i) mh = max(1, height >> i) blocks_w = max(1, (mw + 3) // 4) blocks_h = max(1, (mh + 3) // 4) size = blocks_w * blocks_h * block_size chunk = data[off:off + size] if len(chunk) < size: raise ValueError( f"DDS data truncated at mip {i}: need {size} bytes, have {len(chunk)}" ) mips.append(chunk) off += size log.debug("DDS parsed: %dx%d fmt=%d block=%d mips=%d", width, height, vk_format, block_size, len(mips)) return DDSTexture(vk_format=vk_format, width=width, height=height, block_size=block_size, mips=mips)