"""``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)]