OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / test_points_layout.py

106 lines · python ref:
"""
Tests for the POINTS variant of the kernel rewrite.

Volume mode swaps the plot block for a voxel write; points mode swaps it for an
append to a flat list. The list is the interesting part, because Blender's GPU
module has no SSBOs (`ifs_layout`'s module docstring, reason 1) — so there is no
buffer to append to. The substitute is an RGBA32F image used as an array, one
texel per point, indexed by an atomic counter in a 1x1 R32UI image. That is the
same trick the viewport's auto-exposure max pass uses, turned from a reduction
into an allocator.

What can go wrong here is not that the arithmetic is subtly off; it is that a
substitution silently stops matching and leaves the 2D projection in place, so
the kernel still compiles and still runs and quietly plots to a histogram nobody
reads. So these assert what the body must and must NOT contain, and that the
three modes stay mutually exclusive — a kernel that tried to be two of them at
once would bind images under two different meanings.

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

import os
import unittest

from ifs_codegen import apply_portability_fixes, generate_kernel, load_library
from ifs_layout import POINTS_LAYOUT, 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")


class PointsRewrite(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        if not os.path.isfile(TEMPLATE):
            raise unittest.SkipTest("kernel template not found")
        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)
        cls.body, cls.typedefs, cls.report = rewrite(kernel, points=True)

    def test_every_substitution_applied(self):
        self.assertTrue(self.report.ok,
                        "rewrite incomplete: " + "; ".join(self.report.missing))

    def test_the_projection_is_gone_entirely(self):
        """
        Not bypassed — REPLACED. `project()` can `continue` on out-of-frame, so a
        points mode that merely ignored the result would still drop every sample
        outside the current view and emit a view-dependent cloud. That is the same
        reasoning volume mode is built on.
        """
        self.assertNotIn("projf = project(", self.body)
        self.assertNotIn("accumulate_hit", self.body)

    def test_points_are_appended_through_the_atomic_counter(self):
        self.assertIn("emit_point", self.body)
        self.assertIn("imageAtomicAdd(counter_img", self.body)
        self.assertIn("imageStore(points_img", self.body)
        self.assertIn("imageStore(colors_img", self.body)

    def test_the_emit_is_bounded_by_capacity(self):
        """
        The counter keeps counting past the end of the image on purpose, so the host
        can report how many points were WANTED. Without a capacity guard that would
        be an out-of-bounds imageStore on every sample past the end.
        """
        self.assertIn("point_capacity", self.body)
        self.assertIn("points_width", self.body)

    def test_colour_is_not_premultiplied(self):
        """
        The volume path multiplies colour by opacity because it accumulates a
        density-weighted sum and divides it back out. A point is not accumulated —
        it carries its own colour — so premultiplying would darken every point by
        its iterator's opacity and there is nothing to divide it out again.
        """
        self.assertNotIn("color.xyz *= color.w", self.body)

    def test_the_modes_are_mutually_exclusive(self):
        with self.assertRaises(ValueError):
            rewrite("", points=True, volume=True)
        with self.assertRaises(ValueError):
            rewrite("", points=True, depth=True)

    def test_the_layout_fits_blenders_image_budget(self):
        """Blender caps images at 8 and the budget is STATIC — see ifs_layout."""
        images = [r for r in POINTS_LAYOUT if r.kind == "image"]
        self.assertLessEqual(len(images), 8)
        self.assertEqual(len(images), len({r.slot for r in images}),
                         "two images share a binding slot")

    def test_the_layout_names_match_the_glsl(self):
        """A declared name that the body never uses binds nothing and wastes a slot."""
        for resource in POINTS_LAYOUT:
            if resource.kind == "image":
                self.assertIn(resource.name, self.body,
                              f"{resource.name} is declared but never used")


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