Source code for simvx.graphics.cook

"""``simvx cook``: offline texture cooking for shipped assets.

Pre-bakes textures so the runtime does no per-load transcode work: a UASTC
``.ktx2`` interchange texture (the recommended authoring format, see
``docs/graphics/compressed_textures.md``) is transcoded once, offline, to an
explicit GPU block format (BC7 by default; ASTC-4x4 / ETC2 via ``--target``)
with its full mip chain baked into the cooked container. Cooked files load
through the ordinary explicit-format KTX2 path with no native transcoder
present, which also makes them safe to ship to installs that never ran
``simvx build-textures``.

Inputs are individual texture files or a ``.gltf`` scene, in which case every
externally referenced buffer and image is carried across (relative layout
preserved) and each UASTC ``.ktx2`` among them is cooked in place of a copy.
Everything else (raw PNG/JPG, explicit-BC KTX2/DDS, ``.bin`` buffers) is copied
unchanged: raw colour maps get their mip chains from the runtime mip generator
(RM-A12a), and explicit-format containers are already cooked.

Degrades gracefully, mirroring ``simvx build-textures``: when the vendored
basis_universal transcoder is absent (no C++ compiler, or opted out via
``SIMVX_SKIP_TEXTURE_BUILD``) the source file passes through unchanged and the
report says so; the cook never fails the batch over a missing native extension.

Pure tooling: nothing in the engine imports this module.
"""

import json
import logging
import shutil
import struct
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import unquote, urlparse

# Container layout comes from the engine reader so writer and reader can never
# drift: the header (9 u32) ends at byte 48; the index block and level index
# live at the reader's ``_INDEX_OFFSET`` / ``_LEVEL_INDEX_OFFSET`` (the KTX2 2.0
# spec positions, byte 48 and byte 80), with any gap zero-padded.
from .assets.ktx2_loader import (
    _INDEX_OFFSET,
    _LEVEL_INDEX_OFFSET,
    KTX2_MAGIC,
)

log = logging.getLogger(__name__)

__all__ = ["CookError", "CookReport", "cook_file", "cook_ktx2_bytes", "full_mip_count", "write_ktx2"]

_HEADER_END = 12 + 9 * 4  # magic + 9 u32 header fields

# khr_df.h colour models for the Data Format Descriptor we write.
_KHR_DF_MODEL_UNSPECIFIED = 0
_KHR_DF_MODEL_BC7 = 134
_KHR_DF_MODEL_ETC2 = 161
_KHR_DF_MODEL_ASTC = 162
_KHR_DF_PRIMARIES_BT709 = 1
_KHR_DF_TRANSFER_LINEAR = 1
_KHR_DF_TRANSFER_SRGB = 2

# File suffixes the cook treats as image sources referenced by assets.
_RAW_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tga"}


[docs] class CookError(RuntimeError): """A cook cannot proceed (bad input path, output would clobber a source)."""
[docs] @dataclass(frozen=True) class CookReport: """One input file's cook outcome. Attributes: source: input file path. output: written file path (equal content to source for copies). action: ``"cooked"`` (transcoded + mips baked), ``"copied"`` (already cooked / not cookable, content unchanged), ``"pass-through"`` (cookable but degraded, content unchanged), or ``"missing"``. detail: human-readable reason / summary for the action. """ source: Path output: Path | None action: str detail: str
[docs] def full_mip_count(width: int, height: int) -> int: """Number of levels in a full mip chain down to 1x1 (RM-A12a semantics).""" return max(width, height).bit_length()
def _dfd_fields(vk_format: int) -> tuple[int, bool]: """Map a ``VkFormat`` int to (khr_df colour model, is_srgb) for the DFD.""" import vulkan as vk # noqa: PLC0415 (call-time only, never at import) table = { int(vk.VK_FORMAT_BC7_UNORM_BLOCK): (_KHR_DF_MODEL_BC7, False), int(vk.VK_FORMAT_BC7_SRGB_BLOCK): (_KHR_DF_MODEL_BC7, True), int(vk.VK_FORMAT_ASTC_4x4_UNORM_BLOCK): (_KHR_DF_MODEL_ASTC, False), int(vk.VK_FORMAT_ASTC_4x4_SRGB_BLOCK): (_KHR_DF_MODEL_ASTC, True), int(vk.VK_FORMAT_ETC2_R8G8B8A8_UNORM_BLOCK): (_KHR_DF_MODEL_ETC2, False), int(vk.VK_FORMAT_ETC2_R8G8B8A8_SRGB_BLOCK): (_KHR_DF_MODEL_ETC2, True), } return table.get(int(vk_format), (_KHR_DF_MODEL_UNSPECIFIED, False)) def _dfd(colour_model: int, srgb: bool, block_size: int) -> bytes: """Build a 44-byte basic Data Format Descriptor for a 4x4 block format. dfdTotalSize (4) + basic descriptor block header (24) + one sample (16). Field packing follows khr_df.h; the engine reader only consumes the colorModel / transferFunction byte pair, but external tools get a well-formed descriptor. """ transfer = _KHR_DF_TRANSFER_SRGB if srgb else _KHR_DF_TRANSFER_LINEAR block = struct.pack( "<II4B4B8B", 0, # vendorId (Khronos) | descriptorType (basic) 2 | (40 << 16), # versionNumber 2 | descriptorBlockSize 40 colour_model, _KHR_DF_PRIMARIES_BT709, transfer, 0, # model/primaries/transfer/flags 3, 3, 0, 0, # texelBlockDimension: 4x4x1x1 (stored minus one) block_size, 0, 0, 0, 0, 0, 0, 0, # bytesPlane0..7 ) sample = struct.pack("<IIII", (127 << 16), 0, 0, 0xFFFFFFFF) # 128-bit sample, full range body = block + sample return struct.pack("<I", 4 + len(body)) + body
[docs] def write_ktx2(vk_format: int, width: int, height: int, mips: list[bytes], *, block_size: int = 16) -> bytes: """Serialize explicit-format block-compressed mips into a KTX2 container. ``mips`` is level 0 (largest) first, matching :class:`KTX2Texture`. Writes an uncompressed (``supercompressionScheme = 0``) container in the engine reader's layout (see the module-level layout note): level payloads stored smallest level first, each aligned to ``block_size``, with a well-formed basic DFD. Round-trips through ``assets.ktx2_loader.load_ktx2`` byte-for-byte on every level. """ if not mips: raise ValueError("write_ktx2: at least one mip level is required") level_count = len(mips) colour_model, srgb = _dfd_fields(vk_format) dfd = _dfd(colour_model, srgb, block_size) dfd_offset = _LEVEL_INDEX_OFFSET + level_count * 24 cursor = dfd_offset + len(dfd) offsets: list[int] = [0] * level_count padded: list[bytes] = [] for i in reversed(range(level_count)): # smallest level first in the file pad = (-cursor) % block_size padded.append(bytes(pad)) cursor += pad offsets[i] = cursor padded.append(mips[i]) cursor += len(mips[i]) out = bytearray(KTX2_MAGIC) out += struct.pack("<9I", int(vk_format), 1, width, height, 0, 0, 1, level_count, 0) out += bytes(_INDEX_OFFSET - _HEADER_END) # zero pad up to the reader's index block out += struct.pack("<4I", dfd_offset, len(dfd), 0, 0) # index block: dfd/kvd offsets + lengths out += struct.pack("<2Q", 0, 0) # sgd offset/length for i in range(level_count): # level index, level 0 first out += struct.pack("<3Q", offsets[i], len(mips[i]), len(mips[i])) out += dfd for chunk in padded: out += chunk return bytes(out)
[docs] def cook_ktx2_bytes(data: bytes, *, target: str = "bc7") -> tuple[bytes, str, str]: """Cook one KTX2 file's bytes; returns ``(out_bytes, action, detail)``. A UASTC (vkFormat 0) container is transcoded level-by-level to ``target`` (``"bc7"`` / ``"astc4x4"`` / ``"etc2"``) via the vendored basis_universal transcoder and rewritten as an explicit-format KTX2 with the source's mip chain baked in. An explicit-format container is already cooked and copies unchanged. Any degradation (transcoder absent or missing the target, unsupported flavour such as ETC1S) passes the input through unchanged with the reason in ``detail``: cooking never raises over a bad texture. """ if data[:12] != KTX2_MAGIC or len(data) < _INDEX_OFFSET: return data, "pass-through", "not a KTX2 file (bad magic)" (vk_format,) = struct.unpack_from("<I", data, 12) if vk_format != 0: return data, "copied", "already an explicit-format KTX2" from ._native import basis_transcoder as bt # noqa: PLC0415 (lazy: optional native ext) if not bt.is_available(): bt.ensure_built() if not bt.is_available(): return data, "pass-through", "native transcoder unavailable (run `simvx build-textures`)" target_shipped = {"astc4x4": bt.astc_available, "etc2": bt.etc2_available} if target in target_shipped and not target_shipped[target](): return data, "pass-through", f"{target} target not in the built transcoder (run `simvx build-textures`)" from .assets.ktx2_loader import load_ktx2 # noqa: PLC0415 (lazy: imports vulkan at call time) try: tex = load_ktx2(data, target=target) except ValueError as exc: return data, "pass-through", str(exc) out = write_ktx2(tex.vk_format, tex.width, tex.height, tex.mips, block_size=tex.block_size) n = len(tex.mips) detail = f"uastc->{target}, {n} mip{'' if n == 1 else 's'}" expected = full_mip_count(tex.width, tex.height) if n < expected: detail += f" (partial chain {n}/{expected}: author with `toktx --genmipmap`)" return out, "cooked", detail
def _gltf_asset_uris(gltf_path: Path) -> list[str]: """External file URIs referenced by a .gltf: buffers first, then images. Skips ``data:`` URIs (embedded payloads) and remote http(s) references. Returned URIs are percent-decoded, relative, in document order, deduped. """ doc = json.loads(gltf_path.read_text(encoding="utf-8")) uris: dict[str, None] = {} for section in ("buffers", "images"): for entry in doc.get(section, []): uri = entry.get("uri") if not uri or uri.startswith("data:") or urlparse(uri).scheme in ("http", "https"): continue uris[unquote(uri)] = None return list(uris) def _cook_one(src: Path, dst: Path, *, target: str, transcode: bool) -> CookReport: """Cook or copy a single file to ``dst``, creating parent directories.""" if not src.is_file(): return CookReport(src, None, "missing", "input file not found") if dst.resolve() == src.resolve(): raise CookError(f"output would overwrite the source file: {src} (choose a different --output)") dst.parent.mkdir(parents=True, exist_ok=True) suffix = src.suffix.lower() if suffix == ".ktx2": if not transcode: shutil.copyfile(src, dst) return CookReport(src, dst, "pass-through", "transcode disabled (--no-transcode)") data, action, detail = cook_ktx2_bytes(src.read_bytes(), target=target) dst.write_bytes(data) return CookReport(src, dst, action, detail) shutil.copyfile(src, dst) if suffix in _RAW_IMAGE_SUFFIXES: detail = "raw image copied (runtime mip generation applies; pre-compress with `toktx --uastc`)" elif suffix == ".dds": detail = "already an explicit-format DDS" else: detail = "copied unchanged" return CookReport(src, dst, "copied", detail)
[docs] def cook_file(src: Path, out_dir: Path, *, target: str = "bc7", transcode: bool = True) -> list[CookReport]: """Cook one input (texture file or .gltf scene) into ``out_dir``. A ``.gltf`` input is copied along with every externally referenced buffer and image (relative layout preserved, so the copied scene stays loadable), cooking each UASTC ``.ktx2`` reference along the way. A ``.glb`` copies unchanged (its images live inside the binary chunk). Any other file cooks or copies per :func:`_cook_one`. Raises :class:`CookError` only for hard misuse (missing input scene, output clobbering a source). """ src = Path(src) out_dir = Path(out_dir) suffix = src.suffix.lower() if suffix == ".gltf": if not src.is_file(): raise CookError(f"glTF input not found: {src}") scene = _cook_one(src, out_dir / src.name, target=target, transcode=False) reports = [CookReport(scene.source, scene.output, scene.action, "scene copied unchanged")] for uri in _gltf_asset_uris(src): rel = Path(uri) if rel.is_absolute() or ".." in rel.parts: reports.append( CookReport( src.parent / rel, None, "pass-through", "URI escapes the scene directory; not carried across" ) ) continue reports.append(_cook_one(src.parent / rel, out_dir / rel, target=target, transcode=transcode)) return reports if suffix == ".glb": report = _cook_one(src, out_dir / src.name, target=target, transcode=False) if report.action != "missing": report = CookReport( report.source, report.output, "copied", "GLB copied unchanged (embedded images are not cooked; use .gltf + external textures)", ) return [report] return [_cook_one(src, out_dir / src.name, target=target, transcode=transcode)]