OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / test_points_splat_attributes.py

387 lines · python ref:
"""
The emitted PointCloud carries the attributes a gaussian splat needs.

Blender's own splat work (design task #159470, opened 2026-06-02) represents a
splat as a POINT CLOUD carrying attributes — `position`, `scale`,
`rotation` (quaternion), `opacity`, and spherical harmonics as float3 named
`sh_<m>_<l>` — with a "Render As: Points / Splats" toggle. That is the same
container `to_pointcloud` already produces, so the gap is only the attributes.
Writing them now costs almost nothing and makes our output readable by the
native path when it lands, and by the addons that exist today.

Two of the three are not new information, they are information we were THROWING
AWAY:

* **opacity.** The kernel already emits a per-point opacity in the colour's w
  channel — `to_pointcloud` forced `rgba[:, 3] = 1.0` and lost it. Measured on
  light_rods_sample that w spans 0.5 to 1.0, so it is real per-point data, not a
  constant. (On nebula_sample it is uniformly 1.0, which is exactly why a test
  document has to be chosen for VARIATION — see below.)
* **scale.** The radius is already derived from the cloud's own measured
  nearest-neighbour spacing. A splat wants the same quantity as a 3-vector.

`rotation` is genuinely new, and genuinely trivial: our splats are isotropic, so
the identity quaternion is not a placeholder standing in for something better —
it is the correct value for a sphere, and an anisotropic splat would need a
covariance we do not compute.

Deliberately NOT added yet: `sh_0_0`. Degree-0 spherical harmonics are not raw
colour — the 3DGS convention stores a coefficient related to colour by an offset
and the SH basis constant C0. Writing that mapping from memory is exactly the
kind of guess that produces a file which loads and looks wrong, so the colour
stays in `ifs_color` until the encoding is confirmed against a primary source.

    blender -b --factory-startup -noaudio --python blender/test_points_splat_attributes.py
"""

import os
import sys
import unittest

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

N = 64


def _sample_arrays(alpha=None):
    """Synthetic positions/colours. No GPU needed — this is about plumbing."""
    import numpy as np
    rng = np.random.default_rng(0)
    positions = rng.normal(0.0, 1.0, (N, 3)).astype(np.float32)
    colors = np.ones((N, 4), dtype=np.float32)
    colors[:, :3] = rng.random((N, 3)).astype(np.float32)
    # Varying w by default: a constant would let a hard-coded 1.0 pass.
    colors[:, 3] = (np.linspace(0.25, 1.0, N).astype(np.float32)
                    if alpha is None else alpha)
    return positions, colors


def _read(cloud, name, prop, width):
    import numpy as np
    data = cloud.attributes[name].data
    flat = np.empty(len(data) * width, dtype=np.float32)
    data.foreach_get(prop, flat)
    return flat.reshape(-1, width) if width > 1 else flat


class SplatAttributes(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        try:
            import bpy       # noqa: F401
            import numpy     # noqa: F401
            import ifs_points  # noqa: F401
        except ImportError as ex:
            raise unittest.SkipTest(f"needs Blender's bpy module ({ex})")

    def _cloud(self, radius=0.01, alpha=None):
        import ifs_points
        positions, colors = _sample_arrays(alpha)
        return ifs_points.to_pointcloud(positions, colors, radius=radius,
                                        name="splat-attrs"), positions, colors

    # ------------------------------------------------------------ they exist

    def test_the_splat_attributes_are_present_with_the_right_types(self):
        """
        Names and types are the contract with #159470. A `scale` stored as a
        single float, or a `rotation` stored as a vector, would carry the same
        information and still not be readable as a splat.
        """
        cloud, _pos, _col = self._cloud()
        expected = {
            'scale': ('FLOAT_VECTOR', 'POINT'),
            'rotation': ('QUATERNION', 'POINT'),
            'opacity': ('FLOAT', 'POINT'),
        }
        for name, (dtype, domain) in expected.items():
            self.assertIn(name, cloud.attributes, f"`{name}` attribute is missing")
            attr = cloud.attributes[name]
            self.assertEqual(dtype, attr.data_type, f"`{name}` has the wrong type")
            self.assertEqual(domain, attr.domain, f"`{name}` is on the wrong domain")

    def test_the_existing_attributes_still_work(self):
        """Regression guard: the splat additions must not disturb what renders today."""
        import ifs_points
        cloud, positions, colors = self._cloud()
        import numpy as np
        self.assertEqual(N, len(cloud.points))
        self.assertLess(float(np.abs(_read(cloud, 'position', 'vector', 3) - positions).max()), 1e-6)
        self.assertIn(ifs_points.COLOR_ATTRIBUTE, cloud.attributes)
        # `color` IS present now (see the splat-contract tests below). The
        # invariant that matters is that our MATERIAL keeps reading `ifs_color`,
        # which test_points_operator pins — merely carrying `color` was measured
        # to change nothing about the points render.
        self.assertLess(
            float(np.abs(_read(cloud, ifs_points.COLOR_ATTRIBUTE, 'color', 4)[:, :3]
                         - colors[:, :3]).max()), 1e-6)
        self.assertAlmostEqual(0.01, float(_read(cloud, 'radius', 'value', 1).min()), places=6)

    # --------------------------------------------------------------- opacity

    def test_opacity_carries_the_per_point_value_rather_than_a_constant(self):
        """
        The whole point. `rgba[:, 3] = 1.0` threw this away, and a cloud of
        uniformly-opaque splats is indistinguishable from a working one until you
        compare it against the still render.
        """
        import numpy as np
        cloud, _pos, colors = self._cloud()
        opacity = _read(cloud, 'opacity', 'value', 1)

        self.assertLess(float(np.abs(opacity - colors[:, 3]).max()), 1e-6,
                        'opacity does not match the w channel the kernel emitted')
        self.assertGreater(float(opacity.std()), 0.01,
                           'opacity is constant across the cloud — it is being '
                           'synthesised, not carried')

    def test_a_uniformly_opaque_document_still_round_trips(self):
        """
        The control for the test above. nebula_sample really does emit w == 1.0
        everywhere, so "opacity varies" must not become the definition of correct.
        """
        import numpy as np
        cloud, _pos, _col = self._cloud(alpha=1.0)
        opacity = _read(cloud, 'opacity', 'value', 1)
        self.assertLess(float(np.abs(opacity - 1.0).max()), 1e-6,
                        'a fully-opaque document must survive unchanged')

    def test_the_colour_attribute_keeps_its_alpha_at_one(self):
        """
        Opacity moved to its own attribute; it did NOT move INTO the colour. The
        material multiplies nothing by ifs_color's alpha, so letting w leak in
        there would dim the emission for reasons no slider explains.
        """
        import numpy as np
        import ifs_points
        cloud, _pos, _col = self._cloud()
        alpha = _read(cloud, ifs_points.COLOR_ATTRIBUTE, 'color', 4)[:, 3]
        self.assertLess(float(np.abs(alpha - 1.0).max()), 1e-6)

    # ----------------------------------------------------------------- scale

    def test_scale_is_isotropic_and_tracks_the_radius(self):
        """
        Differential on the radius argument, because asserting "scale > 0" would
        pass against a hard-coded constant.
        """
        import numpy as np
        cloud_a, _p, _c = self._cloud(radius=0.01)
        cloud_b, _p, _c = self._cloud(radius=0.02)
        a = _read(cloud_a, 'scale', 'vector', 3)
        b = _read(cloud_b, 'scale', 'vector', 3)

        self.assertLess(float(a.std(axis=1).max()), 1e-9,
                        'scale is not isotropic — the three components disagree')
        self.assertLess(float(np.abs(a - 0.01).max()), 1e-6, 'scale does not equal the radius')
        self.assertLess(float(np.abs(b - 0.02).max()), 1e-6, 'scale did not follow the radius')

    # -------------------------------------------------------------- rotation

    def test_rotation_is_the_identity_quaternion(self):
        """
        Isotropic splats have no orientation, and the identity is the correct
        value rather than a stand-in. Asserted as (w, x, y, z) = (1, 0, 0, 0)
        because Blender's quaternion order is w-first, and a viewer reading it
        x-first would silently get a 180-degree rotation out of an all-zeros-plus-one.
        """
        import numpy as np
        cloud, _pos, _col = self._cloud()
        rot = _read(cloud, 'rotation', 'value', 4)
        self.assertLess(float(np.abs(rot[:, 0] - 1.0).max()), 1e-6, 'w component is not 1')
        self.assertLess(float(np.abs(rot[:, 1:]).max()), 1e-6, 'x, y, z are not 0')
        norms = np.linalg.norm(rot, axis=1)
        self.assertLess(float(np.abs(norms - 1.0).max()), 1e-6, 'quaternion is not unit length')


    # ------------------------------------- Blender's own splat contract (opt-in)

    def test_the_sh_encoding_round_trips(self):
        """
        Degree-0 SH is NOT raw colour. Blender's EEVEE splat shader evaluates
        `0.282094792 * color.rgb + 0.5` (and the reference PLY writer agrees), so a
        desired colour has to be encoded as `(c - 0.5) / C0` going in. Asserted as a
        round trip plus one hand-checked value, because an encoder that forgot the
        0.5 offset still round-trips against its own decoder.
        """
        import numpy as np
        import ifs_points
        self.assertAlmostEqual(0.28209479177387814, ifs_points.SH_C0, places=12)
        for c in (0.0, 0.25, 0.5, 1.0):
            self.assertAlmostEqual(
                c, ifs_points.decode_sh_dc(ifs_points.encode_sh_dc(c)), places=6)
        # 0.5 is the neutral point: it encodes to exactly zero.
        self.assertAlmostEqual(0.0, float(ifs_points.encode_sh_dc(0.5)), places=9)

    def test_the_splat_contract_is_on_by_default(self):
        """
        `color` used to be forbidden here on the grounds that the name is poisoned
        for points. That was inherited, not measured, and the measurement says
        otherwise: rendering a cloud that DOES render, with the same material
        reading `ifs_color`, is byte-identical with and without the attribute
        (lit=69, max=0.8157, mean=0.00103 both ways). `color` renders black when a
        MATERIAL READS it, which is a different claim. So both contracts are
        written and the cloud is renderable by the points path today and by the
        splat path when it ships.
        """
        cloud, _pos, _col = self._cloud()
        self.assertIn('color', [a.name for a in cloud.attributes],
                      'the splat contract is meant to be on by default now')

    def test_the_splat_contract_can_still_be_turned_off(self):
        """The opt-out remains, for anything that needs a cloud with no `color`."""
        import ifs_points
        positions, colors = _sample_arrays()
        cloud = ifs_points.to_pointcloud(positions, colors, radius=0.01,
                                         name='no-splat', splat_contract=False)
        self.assertNotIn('color', [a.name for a in cloud.attributes])

    def test_the_splat_contract_writes_colour_and_opacity_together(self):
        """
        Blender's implemented contract has NO `opacity` attribute: degree-0 SH is in
        `color.rgb` as the RAW coefficient and opacity is `color.a`, already
        sigmoid-activated. This is the shape both its EEVEE and Cycles paths read.
        """
        import numpy as np
        import ifs_points
        positions, colors = _sample_arrays()
        cloud = ifs_points.to_pointcloud(positions, colors, radius=0.01,
                                         name='splat-contract', splat_contract=True)
        self.assertIn('color', cloud.attributes)
        got = _read(cloud, 'color', 'color', 4)
        want_rgb = ifs_points.encode_sh_dc(colors[:, :3])
        self.assertLess(float(np.abs(got[:, :3] - want_rgb).max()), 1e-5,
                        'color.rgb is not the raw degree-0 SH coefficient')
        self.assertLess(float(np.abs(got[:, 3] - colors[:, 3]).max()), 1e-6,
                        'color.a is not the activated opacity')

    def test_the_splat_contract_leaves_the_points_path_intact(self):
        """Opting in must ADD to what renders today, not replace it."""
        import ifs_points
        positions, colors = _sample_arrays()
        cloud = ifs_points.to_pointcloud(positions, colors, radius=0.01,
                                         name='splat-both', splat_contract=True)
        for name in (ifs_points.COLOR_ATTRIBUTE, 'radius', 'scale', 'rotation'):
            self.assertIn(name, cloud.attributes, f'{name} was lost')

    def test_a_per_point_radius_reaches_the_cloud(self):
        """
        Wiring. `to_pointcloud` took a scalar; a per-point array has to survive
        into BOTH the `radius` attribute Cycles draws spheres from and the `scale`
        attribute the splat contract carries — asserting one would let the other
        silently keep a constant.
        """
        import numpy as np
        import ifs_points
        positions, colors = _sample_arrays()
        radius = np.linspace(0.01, 0.05, len(positions)).astype(np.float32)
        cloud = ifs_points.to_pointcloud(positions, colors, radius=radius,
                                         name='per-point-radius')

        got = _read(cloud, 'radius', 'value', 1)
        self.assertLess(float(np.abs(got - radius).max()), 1e-6,
                        'the radius attribute did not take the per-point array')
        scale = _read(cloud, 'scale', 'vector', 3)
        for axis in range(3):
            self.assertLess(float(np.abs(scale[:, axis] - radius).max()), 1e-6,
                            f'scale axis {axis} is not the per-point radius')

    def test_a_scalar_radius_still_works(self):
        """The old call shape is used by the operator and the tests; keep it."""
        import numpy as np
        import ifs_points
        positions, colors = _sample_arrays()
        cloud = ifs_points.to_pointcloud(positions, colors, radius=0.02,
                                         name='scalar-radius')
        got = _read(cloud, 'radius', 'value', 1)
        self.assertLess(float(np.abs(got - 0.02).max()), 1e-6)


class LocalRadius(unittest.TestCase):
    """
    A radius per point, from local density.

    Measured on the shipped templates, the nearest-neighbour distance inside ONE
    document spans 16-18x between its p10 and p90 point (light_rods 0.90 -> 14.19,
    nebula 0.0175 -> 0.322). So no single radius can be right: pick one that makes
    the dense filaments continuous and the sparse halo becomes bloated blobs; pick
    one that suits the halo and the filaments stay as separate beads. That is why
    the cloud reads as fuzzy dots rather than structure.
    """

    @classmethod
    def setUpClass(cls):
        try:
            import numpy  # noqa: F401
        except ImportError as ex:
            raise unittest.SkipTest(f"needs numpy ({ex})")
        import ifs_points  # noqa: F401  — ours, unguarded

    def _two_density_cloud(self):
        """A tight core inside a sparse halo, so the right answer is known."""
        import numpy as np
        rng = np.random.default_rng(0)
        core = rng.normal(0.0, 0.02, (6000, 3)).astype(np.float32)
        halo = rng.normal(0.0, 1.0, (1500, 3)).astype(np.float32)
        return core, halo, np.concatenate([core, halo])

    def test_the_dense_region_gets_a_smaller_radius(self):
        """
        THE claim. A function returning varying-but-arbitrary values would satisfy
        "it varies", so this asserts the DIRECTION against a cloud whose answer is
        known by construction.
        """
        import numpy as np
        import ifs_points
        core, halo, points = self._two_density_cloud()
        radius = ifs_points.local_radius(points)

        self.assertEqual(len(points), len(radius), 'one radius per point')
        core_r = float(np.median(radius[:len(core)]))
        halo_r = float(np.median(radius[len(core):]))
        print(f"[local_radius] core {core_r:.6f}  halo {halo_r:.6f}  "
              f"ratio {halo_r / max(core_r, 1e-12):.1f}x")
        self.assertGreater(halo_r, core_r * 2.0,
                           f'the sparse halo got {halo_r:.6f} and the dense core '
                           f'{core_r:.6f} — density is not driving the radius')

    def test_a_uniform_cloud_gets_a_near_uniform_radius(self):
        """
        The control. Without it, a function that simply returned noise would pass
        the direction test half the time.
        """
        import numpy as np
        import ifs_points
        rng = np.random.default_rng(1)
        points = rng.uniform(-1.0, 1.0, (20000, 3)).astype(np.float32)
        radius = ifs_points.local_radius(points)
        spread = float(np.percentile(radius, 90) / max(np.percentile(radius, 10), 1e-12))
        print(f"[local_radius] uniform cloud p90/p10 = {spread:.2f}x")
        self.assertLess(spread, 2.5,
                        f'a uniform cloud produced a {spread:.1f}x radius spread — '
                        'the estimate is reading noise, not density')

    def test_every_radius_is_finite_and_positive(self):
        """A zero or NaN radius is a splat that renders as nothing, silently."""
        import numpy as np
        import ifs_points
        _core, _halo, points = self._two_density_cloud()
        radius = ifs_points.local_radius(points)
        self.assertTrue(bool(np.isfinite(radius).all()), 'non-finite radius')
        self.assertGreater(float(radius.min()), 0.0, 'a zero radius draws nothing')

    def test_it_survives_a_degenerate_cloud(self):
        """Every sample in one spot — a document that produces nothing must not
        crash the draw handler."""
        import numpy as np
        import ifs_points
        points = np.zeros((500, 3), dtype=np.float32)
        radius = ifs_points.local_radius(points)
        self.assertEqual(500, len(radius))
        self.assertTrue(bool(np.isfinite(radius).all()))


if __name__ == "__main__":
    result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result
    sys.exit(0 if result.wasSuccessful() else 1)