Source code for simvx.graphics.materials.shader_compiler

"""GLSL to SPIR-V compilation via glslc, with include resolution and optional reflection."""


from __future__ import annotations

import logging
import re
import subprocess
from pathlib import Path

__all__ = ["compile_shader", "resolve_includes"]

log = logging.getLogger(__name__)

_INCLUDE_RE = re.compile(r'^\s*#include\s+"([^"]+)"\s*$', re.MULTILINE)


[docs] def resolve_includes(source: str, base_dir: Path, _seen: set[str] | None = None) -> str: """Recursively resolve ``#include "file.glsl"`` directives in GLSL source. Args: source: GLSL source text. base_dir: Directory to resolve relative include paths against. _seen: Internal set tracking already-included files to prevent cycles. Returns: Processed GLSL source with all includes inlined. Raises: FileNotFoundError: If an included file does not exist. RuntimeError: If a circular include is detected. """ if _seen is None: _seen = set() def _replace(match: re.Match) -> str: include_path = base_dir / match.group(1) resolved = str(include_path.resolve()) if resolved in _seen: raise RuntimeError(f"Circular #include detected: {match.group(1)} (from {base_dir})") if not include_path.exists(): raise FileNotFoundError( f"Included shader file not found: {match.group(1)}\n" f" resolved to: {include_path}\n" f" included from: {base_dir}" ) _seen.add(resolved) included_source = include_path.read_text() return resolve_includes(included_source, include_path.parent, _seen) return _INCLUDE_RE.sub(_replace, source)
def _collect_deps(src: Path, _seen: set[str] | None = None) -> list[Path]: """Return *src* plus all recursively ``#include``d files.""" if _seen is None: _seen = set() resolved = str(src.resolve()) if resolved in _seen: return [] _seen.add(resolved) deps: list[Path] = [src] try: text = src.read_text() except OSError: return deps for m in _INCLUDE_RE.finditer(text): inc = src.parent / m.group(1) if inc.exists(): deps.extend(_collect_deps(inc, _seen)) return deps def _is_cached(src: Path, out: Path) -> bool: """Return True if *out* exists and is newer than *src* and all its includes.""" if not out.exists(): return False spv_mtime = out.stat().st_mtime return all(dep.stat().st_mtime <= spv_mtime for dep in _collect_deps(src))
[docs] def compile_shader(src: Path, out: Path | None = None, *, force: bool = False) -> Path: """Compile a GLSL file to SPIR-V using glslc. Returns the output path. Skips recompilation when the cached ``.spv`` is newer than the source and all its ``#include`` dependencies. Pass ``force=True`` to always recompile. Args: src: Path to the GLSL source file. out: Optional output path for the SPIR-V binary. Defaults to ``src.spv``. force: Always recompile, ignoring the cache. Returns: Path to the compiled SPIR-V file. Raises: RuntimeError: If glslc compilation fails, with annotated error details. """ if out is None: out = src.parent / (src.name + ".spv") if not force and _is_cached(src, out): return out result = subprocess.run( ["glslc", str(src), "-o", str(out)], capture_output=True, text=True, ) if result.returncode != 0: stderr = result.stderr.strip() raise RuntimeError(f"glslc compilation failed for {src.name}:\n" f" file: {src}\n" f"{stderr}") return out