OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / test_host.py

207 lines · python ref:
"""
Tests for the host-side packing and alias tables.

These defend against silent corruption. A UBO packed at the wrong stride or a
struct field in the wrong order does not raise — it renders a different fractal,
which looks like a fractal. So sizes are asserted against the GLSL declarations,
and the alias tables were diffed against the engine's own `AliasMethod` over 28
distributions including the degenerate ones (0 mismatches). That diff needs the
.NET SDK; the properties it established are pinned here in a form that runs
anywhere.

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

import struct
import unittest

from ifs_host import (
    MAX_ITERATORS,
    MAX_PALETTE_COLORS,
    MAX_PARAMS,
    Camera,
    Iterator,
    Settings,
    build_alias_tables,
    expected_sizes,
    flatten_real_params,
    flatten_vec3_params,
    generate_alias_table,
    pack_frame,
    pack_iterators,
    pack_palette,
    pack_real_params,
    pack_settings,
    pack_vec3_params,
    sierpinski,
)


class Std140Sizes(unittest.TestCase):
    """Each block must measure what its GLSL declaration implies."""

    def test_block_sizes(self):
        sizes = expected_sizes()
        self.assertEqual(sizes["FrameParams"], len(pack_frame(1, 2, 3, 4, 5, 6)))
        self.assertEqual(sizes["SettingsParams"], len(pack_settings(Camera(), Settings())))
        self.assertEqual(sizes["IteratorsParams"], len(pack_iterators([])))
        self.assertEqual(sizes["PaletteParams"], len(pack_palette([])))
        self.assertEqual(sizes["RealParams"], len(pack_real_params([])))
        self.assertEqual(sizes["Vec3Params"], len(pack_vec3_params([])))

    def test_all_blocks_fit_the_64kb_ubo_cap(self):
        # alias_tables was moved to a sampler precisely because it did not.
        for name, size in expected_sizes().items():
            self.assertLessEqual(size, 65536, f"{name} is {size} bytes")

    def test_real_params_use_the_std140_16_byte_stride(self):
        # THE trap: a std140 float[] strides by 16, not 4. Packing tightly would put
        # parameter N where the shader reads parameter 4N.
        packed = pack_real_params([1.0, 2.0, 3.0])

        self.assertEqual(1.0, struct.unpack_from("<f", packed, 0)[0])
        self.assertEqual(2.0, struct.unpack_from("<f", packed, 16)[0])
        self.assertEqual(3.0, struct.unpack_from("<f", packed, 32)[0])

    def test_iterator_stride_is_48_bytes(self):
        one = pack_iterators([Iterator()])
        two = pack_iterators([Iterator(), Iterator()])
        self.assertEqual(len(one), len(two))  # both padded to MAX_ITERATORS
        self.assertEqual(48 * MAX_ITERATORS, len(one))

    def test_iterator_fields_are_in_struct_order(self):
        # Field order is IteratorStruct.cs's. Reordering renders nonsense silently.
        packed = pack_iterators([Iterator(
            color_speed=1.5, color_index=0.25, opacity=0.75, reset_prob=0.125,
            reset_alias=7, tf_id=11, real_params_index=13, vec3_params_index=17,
            shading_mode=1, tf_mix=2.5, tf_add=3.5)])
        floats = struct.unpack_from("<4f", packed, 0)
        ints = struct.unpack_from("<4i", packed, 16)
        tail = struct.unpack_from("<i2fi", packed, 32)

        self.assertEqual((1.5, 0.25, 0.75, 0.125), floats)
        self.assertEqual((7, 11, 13, 17), ints)
        self.assertEqual((1, 2.5, 3.5, 0), tail)

    def test_oversized_input_is_rejected_rather_than_truncated(self):
        with self.assertRaises(ValueError):
            pack_iterators([Iterator()] * (MAX_ITERATORS + 1))
        with self.assertRaises(ValueError):
            pack_palette([(0, 0, 0, 0)] * (MAX_PALETTE_COLORS + 1))
        with self.assertRaises(ValueError):
            pack_real_params([0.0] * (MAX_PARAMS + 1))


class AliasTables(unittest.TestCase):
    def test_uniform_distribution_needs_no_aliasing(self):
        for u, _k in generate_alias_table([1 / 3, 1 / 3, 1 / 3]):
            self.assertAlmostEqual(1.0, u, places=12)

    def test_table_is_a_valid_sampler(self):
        # Every u in [0,1] and every alias a real index - the two properties the
        # kernel relies on when it does `y < prob ? i : alias`.
        p = [0.7, 0.2, 0.07, 0.03]
        table = generate_alias_table(p)

        self.assertEqual(len(p), len(table))
        for u, k in table:
            self.assertGreaterEqual(u, 0.0)
            self.assertLessEqual(u, 1.0 + 1e-12)
            self.assertIn(k, range(len(p)))

    def test_alias_table_reproduces_the_distribution(self):
        # The point of the structure: sampling it must recover p.
        p = [0.5, 0.3, 0.15, 0.05]
        table = generate_alias_table(p)

        recovered = [0.0] * len(p)
        for i, (u, k) in enumerate(table):
            recovered[i] += u / len(p)
            recovered[k] += (1.0 - u) / len(p)

        for expected, actual in zip(p, recovered):
            self.assertAlmostEqual(expected, actual, places=10)

    def test_zero_probability_entries_are_never_selected(self):
        table = generate_alias_table([0.0, 1.0])
        u0, _k0 = table[0]
        self.assertAlmostEqual(0.0, u0, places=12)

    def test_empty_distribution(self):
        self.assertEqual([], generate_alias_table([]))


class XaosTexture(unittest.TestCase):
    def test_texture_is_itnum_squared_rgba(self):
        iterators, _camera, _settings = sierpinski()
        texels = build_alias_tables(iterators)

        n = len(iterators)
        self.assertEqual(n * n * 4, len(texels))

    def test_a_row_with_no_outgoing_weight_is_marked_as_a_reset(self):
        # RendererGL marks it (-1, -1) rather than emitting a valid table, which is
        # how the kernel knows iteration restarts there.
        a = Iterator(base_weight=1.0, weight_to={})
        b = Iterator(base_weight=1.0, weight_to={0: 1.0, 1: 1.0})
        texels = build_alias_tables([a, b])

        self.assertEqual(-1.0, texels[0])  # row 0, texel 0, u
        self.assertEqual(-1.0, texels[1])  # k
        self.assertNotEqual(-1.0, texels[8])  # row 1 is a real table

    def test_base_weights_modulate_the_xaos_row(self):
        # A destination with twice the base weight should be twice as likely.
        a = Iterator(base_weight=1.0, weight_to={0: 1.0, 1: 1.0})
        b = Iterator(base_weight=3.0, weight_to={0: 1.0, 1: 1.0})
        texels = build_alias_tables([a, b])

        u0, k0 = texels[0], int(texels[1])
        u1 = texels[4]
        recovered = [0.0, 0.0]
        for i, u in enumerate((u0, u1)):
            recovered[i] += u / 2
            recovered[k0 if i == 0 else int(texels[5])] += (1.0 - u) / 2

        self.assertAlmostEqual(0.25, recovered[0], places=10)
        self.assertAlmostEqual(0.75, recovered[1], places=10)


class DemoDocument(unittest.TestCase):
    def test_sierpinski_supplies_all_three_affine_parameters(self):
        # Affine reads @Translate, @Scale AND @Rotate. Omitting Scale would scale
        # by zero and render nothing - a blank image reads as "broken pipeline".
        iterators, _camera, settings = sierpinski()

        self.assertEqual(3, settings.itnum)
        for it in iterators:
            self.assertEqual(3, len(it.vec3_params))
            self.assertEqual((0.5, 0.5, 0.5), it.vec3_params[1])

    def test_transform_mix_defaults_to_one(self):
        # At 0.0 the kernel's `mix(p0, p_ret, tf_mix)` throws the transform away and
        # renders a uniform scatter - which looks like a working pipeline, so it is
        # pinned here rather than left to be rediscovered.
        self.assertEqual(1.0, Iterator().tf_mix)
        self.assertEqual(0.0, Iterator().tf_add)
        for it in sierpinski()[0]:
            self.assertEqual(1.0, it.tf_mix)

    def test_parameter_indices_are_contiguous(self):
        iterators, _camera, _settings = sierpinski()
        flat = flatten_vec3_params(iterators)

        self.assertEqual(9, len(flat))
        self.assertEqual([0, 3, 6], [it.vec3_params_index for it in iterators])
        self.assertEqual([], flatten_real_params(iterators))

    def test_a_gap_in_the_indices_is_rejected(self):
        a = Iterator(vec3_params_index=0, vec3_params=[(0, 0, 0)])
        b = Iterator(vec3_params_index=5, vec3_params=[(0, 0, 0)])
        with self.assertRaises(ValueError):
            flatten_vec3_params([a, b])


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