OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / ifs_codegen.py

330 lines · python ref:
"""
Compiles the `.ifstf` transform-plugin library into a GLSL compute kernel.

This is a Python reimplementation of `IFSEngine/Model/TransformPlugin.cs`
(`FromString`) plus `RendererGL.InitComputeProgram`, so a Blender addon can build
the same kernel the .NET engine builds, from the same plugin files, with no C#
at runtime.

Byte-compatibility with the C# parser is the whole point: the plugin body is GLSL
whose `@Param` references are rewritten into indices into the iterator's parameter
block. If the rewrite differs by one index, the fractal renders differently rather
than failing, so `test_codegen.py` diffs every plugin against reference output
dumped from the real C# parser.

The behaviours below are deliberate copies of the C# and are easy to "tidy" into
bugs:

* Lines are split with `RemoveEmptyEntries` and THEN trimmed, so a whitespace-only
  line survives the split and becomes an empty line. Indentation is discarded.
* Field lines are removed by STRING EQUALITY against the whole line set, so a
  duplicated field line is removed everywhere it appears.
* Duplicate field keys keep the FIRST occurrence (`DistinctBy`).
* Parameter indices are the insertion order of the real/vec3 dictionaries — i.e.
  order of first appearance in the file, counted separately per kind — while the
  SUBSTITUTION runs longest-name-first, so `@A` cannot clobber `@AB`.
* A parameter whose default is neither a real nor a vec3 is silently dropped, and
  its `@Name` is then left unsubstituted in the GLSL.
"""

from __future__ import annotations

import os
import re
from dataclasses import dataclass, field

# `^(\s*)@.+:.+$` — TransformPlugin.cs:30. Lines are pre-trimmed, so the leading
# group never matches anything, but it is kept so the pattern reads as the original.
_FIELD_RE = re.compile(r"^(\s*)@.+:.+$")

# TransformPlugin.cs:27 — these field names are metadata, not parameters.
_RESERVED = ("Name", "Version", "Description", "Tags", "Reference", "Use")

_DEFAULT_DESCRIPTION = "Description not provided by the plugin developer"


class PluginError(Exception):
    """A plugin file could not be parsed. Mirrors the C# SerializationException."""


@dataclass
class TransformPlugin:
    name: str
    version: str
    description: str
    tags: list[str]
    include_uses: list[str]
    reference_url: str | None
    source_code: str
    real_params: dict[str, float]
    vec3_params: dict[str, tuple[float, float, float]]
    file_path: str | None = None

    def __str__(self) -> str:
        return f"{self.name} ({self.version})"


def _split_lines(text: str) -> list[str]:
    """Split, drop empties, then trim — in that order, as the C# does."""
    raw = re.split(r"\r\n|\r|\n", text)
    return [line.strip() for line in raw if line != ""]


def _parse_real(text: str) -> float | None:
    """
    Parse a real default the way `double.TryParse(.., NumberStyles.Any, Invariant)`
    does, minus the currency and thousands forms that cannot occur here — the value
    has already been split on ',' so no thousands separator can survive.
    """
    try:
        return float(text)
    except ValueError:
        return None


def parse_plugin(text: str, file_path: str | None = None) -> TransformPlugin:
    """Parses one `.ifstf` source string. Mirrors `TransformPlugin.FromString`."""
    lines = _split_lines(text)
    field_lines = [line for line in lines if _FIELD_RE.match(line)]

    kvps: list[tuple[str, str]] = []
    for line in field_lines:
        key = line.split(":")[0].lstrip("@").strip()
        value = line.split(":", 1)[1].strip()
        kvps.append((key, value))

    include_uses = [v for k, v in kvps if k == "Use"]

    fields: dict[str, str] = {}
    for key, value in kvps:
        fields.setdefault(key, value)  # DistinctBy: first occurrence wins

    if "Name" not in fields or "Version" not in fields:
        raise PluginError("The plugin must define the @Name and @Version fields.")

    real_params: dict[str, float] = {}
    vec3_params: dict[str, tuple[float, float, float]] = {}
    for param_name, param_def in fields.items():
        if param_name in _RESERVED:
            continue
        default = param_def.split(",")[0].strip()

        real = _parse_real(default)
        if real is not None:
            real_params[param_name] = real
        elif default.count(" ") == 2:
            parts = [p for p in default.split(" ") if p != ""]
            try:
                vec3_params[param_name] = (float(parts[0]), float(parts[1]), float(parts[2]))
            except (ValueError, IndexError) as ex:
                raise PluginError(f"Bad vec3 default for @{param_name}: {default!r}") from ex
        # else: silently dropped, exactly as the C# does

    body_lines = [line for line in lines if line not in field_lines]
    source = "\n".join(body_lines)

    # Longest name first, so @A cannot clobber @AB. The INDEX is insertion order,
    # which is not the same ordering — do not "simplify" these into one loop.
    real_order = list(real_params.keys())
    for name in sorted(real_order, key=len, reverse=True):
        source = source.replace(
            "@" + name,
            f"(real_params[iter.real_params_index + {real_order.index(name)}])",
        )

    vec3_order = list(vec3_params.keys())
    for name in sorted(vec3_order, key=len, reverse=True):
        source = source.replace(
            "@" + name,
            f"(vec3_params[iter.vec3_params_index + {vec3_order.index(name)}].xyz)",
        )

    tags: list[str] = []
    if "Tags" in fields:
        tags = [t.strip().lower() for t in fields["Tags"].split(",")]

    return TransformPlugin(
        name=fields["Name"],
        version=fields["Version"],
        description=fields.get("Description", _DEFAULT_DESCRIPTION),
        tags=tags,
        include_uses=include_uses,
        reference_url=fields.get("Reference"),
        source_code=source,
        real_params=real_params,
        vec3_params=vec3_params,
        file_path=file_path,
    )


def parse_plugin_file(path: str) -> TransformPlugin:
    with open(path, "r", encoding="utf-8-sig") as handle:
        return parse_plugin(handle.read(), path)


@dataclass
class PluginLibrary:
    """The loaded, filtered plugin set and the includes they compile against."""

    plugins: list[TransformPlugin] = field(default_factory=list)
    include_sources: list[str] = field(default_factory=list)
    skipped: list[tuple[str, str]] = field(default_factory=list)  # (name, why)

    @property
    def tf_ids(self) -> dict[str, int]:
        """
        Transform name -> `tfId`, the index the kernel branches on.

        Documents reference transforms by NAME (`IFS.UpdatePlugins`), so this index
        is internal to one compiled kernel. It must be used to fill the iterator
        buffer for the SAME kernel it was generated with.
        """
        return {p.name: i for i, p in enumerate(self.plugins)}


def load_library(transforms_dir: str, includes_dir: str | None = None) -> PluginLibrary:
    """
    Loads every `.ifstf` under `transforms_dir`, dropping any whose `@Use` includes
    are unavailable — the filter at `RenderService.cs:136`.

    Files are sorted by path. The C# uses `Directory.GetFiles`, whose order is the
    filesystem's and therefore unstable; since `tfId` is internal and documents key
    on the transform NAME, imposing a stable order here is strictly better and makes
    a generated kernel reproducible.
    """
    include_sources: list[str] = []
    include_names: list[str] = []
    if includes_dir and os.path.isdir(includes_dir):
        for name in sorted(os.listdir(includes_dir)):
            path = os.path.join(includes_dir, name)
            if os.path.isfile(path):
                include_names.append(name)
                with open(path, "r", encoding="utf-8-sig") as handle:
                    include_sources.append(handle.read())

    paths: list[str] = []
    for root, _dirs, files in os.walk(transforms_dir):
        paths.extend(os.path.join(root, f) for f in files if f.endswith(".ifstf"))
    paths.sort()

    library = PluginLibrary(include_sources=include_sources)
    for path in paths:
        plugin = parse_plugin_file(path)
        missing = [u for u in plugin.include_uses if u not in include_names]
        if missing:
            library.skipped.append((plugin.name, "missing include(s): " + ", ".join(missing)))
            continue
        library.plugins.append(plugin)
    return library


def generate_transforms_block(plugins: list[TransformPlugin]) -> str:
    """
    Builds the `if (iter.tfId == N) { ... }` chain.

    Byte-identical to `RendererGL.InitComputeProgram`'s interpolated string,
    including its leading and trailing newlines — a generated kernel should diff
    cleanly against one the engine would have produced.
    """
    out = ""
    for tf_index, plugin in enumerate(plugins):
        out += f"\nif (iter.tfId == {tf_index})\n{{\n{plugin.source_code}\n}}\n"
    return out


def generate_kernel(template: str, library: PluginLibrary) -> str:
    """Fills the kernel template's `@includes` and `@transforms` placeholders."""
    source = template.replace("@includes", "\n".join(library.include_sources))
    return source.replace("@transforms", generate_transforms_block(library.plugins))


# `const vec3 x = vec3(intBitsToFloat(...))` is rejected by glslang as a non-constant
# global initializer; NVIDIA's GL compiler accepts it, which is why the engine ships
# with it. A #define is equivalent and portable.
_DISCARDED_POINT_CONST = "const vec3 discarded_point = vec3(intBitsToFloat(0x7fc00000));"
_DISCARDED_POINT_DEFINE = "#define discarded_point vec3(intBitsToFloat(0x7fc00000))"


def apply_portability_fixes(source: str) -> tuple[str, list[str]]:
    """
    Rewrites the constructs a stricter compiler than NVIDIA's GL front-end rejects.

    VERIFIED with `glslangValidator -G -S comp`: with this fix the full 46-plugin
    kernel compiles to SPIR-V; without it, it fails on the global const.

    Returns the source and a list of what was applied. What is deliberately NOT
    fixed here: the six loose `uniform` declarations. glslang wants explicit
    locations for them, and the Blender port has to move them into a `uniform_buf`
    anyway (Blender warns they exceed the 128-byte portable push-constant limit).
    That is a resource-layout decision, not a textual patch.
    """
    applied: list[str] = []
    if _DISCARDED_POINT_CONST in source:
        source = source.replace(_DISCARDED_POINT_CONST, _DISCARDED_POINT_DEFINE)
        applied.append("discarded_point: global const -> #define")
    return source, applied


def _default_paths(repo_root: str) -> tuple[str, str, str]:
    return (
        os.path.join(repo_root, "Transforms"),
        os.path.join(repo_root, "WpfDisplay", "Library", "Includes"),
        os.path.join(repo_root, "IFSEngine", "Rendering", "Shaders", "ifs_kernel.comp.shader"),
    )


def main(argv: list[str] | None = None) -> int:
    import argparse
    import json

    repo_default = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
    parser.add_argument("--repo", default=repo_default, help="IFSRenderer repo root")
    parser.add_argument("--out", help="write the assembled kernel here")
    parser.add_argument("--dump-json", help="write parsed plugin data here")
    parser.add_argument("--portable", action="store_true",
                        help="apply the fixes a stricter compiler than NVIDIA's GL needs")
    args = parser.parse_args(argv)

    transforms_dir, includes_dir, template_path = _default_paths(args.repo)
    library = load_library(transforms_dir, includes_dir)

    for name, why in library.skipped:
        print(f"skipped {name}: {why}")
    print(f"{len(library.plugins)} plugins, "
          f"{sum(len(p.real_params) for p in library.plugins)} real params, "
          f"{sum(len(p.vec3_params) for p in library.plugins)} vec3 params")

    if args.dump_json:
        payload = [
            {
                "name": p.name,
                "version": p.version,
                "realParams": p.real_params,
                "vec3Params": {k: list(v) for k, v in p.vec3_params.items()},
                "sourceCode": p.source_code,
            }
            for p in library.plugins
        ]
        with open(args.dump_json, "w", encoding="utf-8") as handle:
            json.dump(payload, handle, indent=1, sort_keys=False)
        print(f"wrote {args.dump_json}")

    if args.out:
        with open(template_path, "r", encoding="utf-8-sig") as handle:
            template = handle.read()
        kernel = generate_kernel(template, library)
        if args.portable:
            kernel, applied = apply_portability_fixes(kernel)
            for fix in applied:
                print(f"fix: {fix}")
        with open(args.out, "w", encoding="utf-8") as handle:
            handle.write(kernel)
        print(f"wrote {args.out}")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())