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