OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / test_layout.py

170 lines · python ref:
"""
Tests for the Blender resource-layout rewrite.

The rewrite is a set of regex substitutions over the engine's kernel, which is a
file we do not own — upstream can change it. So the risk is not that a substitution
does the wrong thing, it is that a substitution silently stops matching and leaves
the original construct in place. `rewrite()` reports that in `RewriteReport.missing`,
and these tests assert both halves: every substitution applied, and nothing that
Blender cannot bind is left behind.

The generated kernel COMPILES in Blender 5.2 (`gpu.shader.create_from_info`,
RTX 2070, OpenGL). That check needs a Blender binary so it is not part of this
suite; `test_compiles_in_blender` runs it when BLENDER is set and reports a skip
otherwise rather than passing quietly.

Run: python3 -m unittest discover -s blender
"""

import os
import subprocess
import sys
import tempfile
import unittest

from ifs_codegen import apply_portability_fixes, generate_kernel, load_library
from ifs_layout import LAYOUT, create_info_source, rewrite

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TRANSFORMS = os.path.join(REPO, "Transforms")
INCLUDES = os.path.join(REPO, "WpfDisplay", "Library", "Includes")
TEMPLATE = os.path.join(REPO, "IFSEngine", "Rendering", "Shaders", "ifs_kernel.comp.shader")


def build() -> tuple[str, str]:
    library = load_library(TRANSFORMS, INCLUDES)
    with open(TEMPLATE, "r", encoding="utf-8-sig") as handle:
        kernel = generate_kernel(handle.read(), library)
    kernel, _ = apply_portability_fixes(kernel)
    body, typedefs, report = rewrite(kernel)
    if not report.ok:
        raise AssertionError("rewrite incomplete: " + "; ".join(report.missing))
    return body, typedefs


class Rewrite(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        if not os.path.isfile(TEMPLATE):
            raise unittest.SkipTest("kernel template not found")
        cls.body, cls.typedefs = build()

    def test_every_substitution_applied(self):
        # build() raises if any is missing; this pins the count so a NEW construct
        # added upstream is noticed rather than quietly unhandled.
        library = load_library(TRANSFORMS, INCLUDES)
        with open(TEMPLATE, "r", encoding="utf-8-sig") as handle:
            kernel = generate_kernel(handle.read(), library)
        kernel, _ = apply_portability_fixes(kernel)
        _body, _typedefs, report = rewrite(kernel)

        self.assertEqual([], report.missing)
        self.assertEqual(14, len(report.applied), report.applied)

    def test_nothing_blender_cannot_bind_remains(self):
        for construct in (
            "buffer histogram_buffer",   # SSBO: GPUStorageBuf does not exist
            "buffer points_buffer",
            "layout(std140",             # Blender writes its own layout qualifiers
            "#version",
            "local_size_x",
            "state[gid]",
            "alias_tables[",
        ):
            self.assertNotIn(construct, self.body, construct)

        for line in self.body.splitlines():
            self.assertFalse(line.startswith("uniform "),
                             f"loose uniform survived: {line!r}")

    def test_accesses_were_rewritten(self):
        self.assertEqual(4, self.body.count("imageAtomicAdd(histogram_img"))
        self.assertIn("texelFetch(alias_tables_tex", self.body)
        self.assertIn("p = load_state(gid);", self.body)
        self.assertIn("store_state(gid, p);", self.body)

    def test_typedefs_are_self_contained(self):
        # typedef_source is emitted BEFORE compute_source, so anything the structs
        # reference must be in there too - the size defines and the shared structs.
        for needed in ("MAX_ITERATORS", "MAX_PALETTE_COLORS", "MAX_PARAMS",
                       "struct camera_params", "struct Iterator", "struct p_state",
                       "struct FrameParams", "struct SettingsParams"):
            self.assertIn(needed, self.typedefs, needed)

        # ...and must NOT be duplicated in the body, which would redefine them.
        self.assertNotIn("struct camera_params", self.body)

    def test_ubo_defines_preserve_the_original_spelling(self):
        # The body still says `iterators[i]`; the #define points it at the struct.
        for alias in ("#define iterators iterators_buf.data",
                      "#define palette palette_buf.data",
                      "#define real_params real_params_buf.data",
                      "#define vec3_params vec3_params_buf.data",
                      "#define seed frame.seed"):
            self.assertIn(alias, self.body, alias)

    def test_image_budget_is_within_blenders_static_limit(self):
        # max_images is 8 and a DECLARED-but-unused image still consumes a slot,
        # so the budget cannot be reclaimed by leaving one out.
        images = [r for r in LAYOUT if r.kind == "image"]
        self.assertLessEqual(len(images), 8)
        self.assertEqual(list(range(len(images))), sorted(r.slot for r in images))

    def test_host_declaration_requests_readable_writable_images(self):
        # Blender's default image qualifier is write-only, which makes imageLoad and
        # imageAtomicAdd fail to compile. Found by compiling, so pinned here.
        source = create_info_source()
        for name in ("histogram_img", "state_img"):
            line = next(l for l in source.splitlines() if name in l)
            self.assertIn("'READ'", line, name)
            self.assertIn("'WRITE'", line, name)

    def test_host_declaration_names_match_the_glsl(self):
        source = create_info_source()
        for res in LAYOUT:
            expected = res.instance or res.name
            self.assertIn(repr(expected), source, expected)


class BlenderCompile(unittest.TestCase):
    """The real gate: Blender's own compiler accepts the reshaped kernel."""

    def test_compiles_in_blender(self):
        blender = os.environ.get("BLENDER")
        if not blender or not os.path.isfile(blender):
            print("SKIPPED test_compiles_in_blender: set BLENDER=/path/to/blender",
                  file=sys.stderr)
            return
        if not os.path.isfile(TEMPLATE):
            raise unittest.SkipTest("kernel template not found")

        body, typedefs = build()
        with tempfile.TemporaryDirectory() as tmp:
            body_path = os.path.join(tmp, "body.glsl")
            type_path = os.path.join(tmp, "typedefs.glsl")
            script = os.path.join(tmp, "compile.py")
            for path, text in ((body_path, body), (type_path, typedefs)):
                with open(path, "w", encoding="utf-8") as handle:
                    handle.write(text)
            with open(script, "w", encoding="utf-8") as handle:
                handle.write(
                    "import sys, gpu\n"
                    "gpu.init()\n"
                    "BODY = open(sys.argv[-2]).read()\n"
                    "TYPEDEFS = open(sys.argv[-1]).read()\n"
                    + create_info_source() + "\n"
                    "print('COMPILE OK')\n")

            result = subprocess.run(
                [blender, "-b", "--factory-startup", "-noaudio", "--python", script,
                 "--", body_path, type_path],
                capture_output=True, text=True, timeout=600,
                env={**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")})

        self.assertIn("COMPILE OK", result.stdout,
                      (result.stdout + result.stderr)[-4000:])


if __name__ == "__main__":
    unittest.main()