"""
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()