| … | … | @@ -0,0 +1,197 @@ |
| 1 | """ |
| 2 | The emitted PointCloud carries the attributes a gaussian splat needs. |
| 3 | |
| 4 | Blender's own splat work (design task #159470, opened 2026-06-02) represents a |
| 5 | splat as a POINT CLOUD carrying attributes — `position`, `scale`, |
| 6 | `rotation` (quaternion), `opacity`, and spherical harmonics as float3 named |
| 7 | `sh_<m>_<l>` — with a "Render As: Points / Splats" toggle. That is the same |
| 8 | container `to_pointcloud` already produces, so the gap is only the attributes. |
| 9 | Writing them now costs almost nothing and makes our output readable by the |
| 10 | native path when it lands, and by the addons that exist today. |
| 11 | |
| 12 | Two of the three are not new information, they are information we were THROWING |
| 13 | AWAY: |
| 14 | |
| 15 | * **opacity.** The kernel already emits a per-point opacity in the colour's w |
| 16 | channel — `to_pointcloud` forced `rgba[:, 3] = 1.0` and lost it. Measured on |
| 17 | light_rods_sample that w spans 0.5 to 1.0, so it is real per-point data, not a |
| 18 | constant. (On nebula_sample it is uniformly 1.0, which is exactly why a test |
| 19 | document has to be chosen for VARIATION — see below.) |
| 20 | * **scale.** The radius is already derived from the cloud's own measured |
| 21 | nearest-neighbour spacing. A splat wants the same quantity as a 3-vector. |
| 22 | |
| 23 | `rotation` is genuinely new, and genuinely trivial: our splats are isotropic, so |
| 24 | the identity quaternion is not a placeholder standing in for something better — |
| 25 | it is the correct value for a sphere, and an anisotropic splat would need a |
| 26 | covariance we do not compute. |
| 27 | |
| 28 | Deliberately NOT added yet: `sh_0_0`. Degree-0 spherical harmonics are not raw |
| 29 | colour — the 3DGS convention stores a coefficient related to colour by an offset |
| 30 | and the SH basis constant C0. Writing that mapping from memory is exactly the |
| 31 | kind of guess that produces a file which loads and looks wrong, so the colour |
| 32 | stays in `ifs_color` until the encoding is confirmed against a primary source. |
| 33 | |
| 34 | blender -b --factory-startup -noaudio --python blender/test_points_splat_attributes.py |
| 35 | """ |
| 36 | |
| 37 | import os |
| 38 | import sys |
| 39 | import unittest |
| 40 | |
| 41 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
| 42 | |
| 43 | N = 64 |
| 44 | |
| 45 | |
| 46 | def _sample_arrays(alpha=None): |
| 47 | """Synthetic positions/colours. No GPU needed — this is about plumbing.""" |
| 48 | import numpy as np |
| 49 | rng = np.random.default_rng(0) |
| 50 | positions = rng.normal(0.0, 1.0, (N, 3)).astype(np.float32) |
| 51 | colors = np.ones((N, 4), dtype=np.float32) |
| 52 | colors[:, :3] = rng.random((N, 3)).astype(np.float32) |
| 53 | # Varying w by default: a constant would let a hard-coded 1.0 pass. |
| 54 | colors[:, 3] = (np.linspace(0.25, 1.0, N).astype(np.float32) |
| 55 | if alpha is None else alpha) |
| 56 | return positions, colors |
| 57 | |
| 58 | |
| 59 | def _read(cloud, name, prop, width): |
| 60 | import numpy as np |
| 61 | data = cloud.attributes[name].data |
| 62 | flat = np.empty(len(data) * width, dtype=np.float32) |
| 63 | data.foreach_get(prop, flat) |
| 64 | return flat.reshape(-1, width) if width > 1 else flat |
| 65 | |
| 66 | |
| 67 | class SplatAttributes(unittest.TestCase): |
| 68 | @classmethod |
| 69 | def setUpClass(cls): |
| 70 | try: |
| 71 | import bpy # noqa: F401 |
| 72 | import numpy # noqa: F401 |
| 73 | import ifs_points # noqa: F401 |
| 74 | except ImportError as ex: |
| 75 | raise unittest.SkipTest(f"needs Blender's bpy module ({ex})") |
| 76 | |
| 77 | def _cloud(self, radius=0.01, alpha=None): |
| 78 | import ifs_points |
| 79 | positions, colors = _sample_arrays(alpha) |
| 80 | return ifs_points.to_pointcloud(positions, colors, radius=radius, |
| 81 | name="splat-attrs"), positions, colors |
| 82 | |
| 83 | # ------------------------------------------------------------ they exist |
| 84 | |
| 85 | def test_the_splat_attributes_are_present_with_the_right_types(self): |
| 86 | """ |
| 87 | Names and types are the contract with #159470. A `scale` stored as a |
| 88 | single float, or a `rotation` stored as a vector, would carry the same |
| 89 | information and still not be readable as a splat. |
| 90 | """ |
| 91 | cloud, _pos, _col = self._cloud() |
| 92 | expected = { |
| 93 | 'scale': ('FLOAT_VECTOR', 'POINT'), |
| 94 | 'rotation': ('QUATERNION', 'POINT'), |
| 95 | 'opacity': ('FLOAT', 'POINT'), |
| 96 | } |
| 97 | for name, (dtype, domain) in expected.items(): |
| 98 | self.assertIn(name, cloud.attributes, f"`{name}` attribute is missing") |
| 99 | attr = cloud.attributes[name] |
| 100 | self.assertEqual(dtype, attr.data_type, f"`{name}` has the wrong type") |
| 101 | self.assertEqual(domain, attr.domain, f"`{name}` is on the wrong domain") |
| 102 | |
| 103 | def test_the_existing_attributes_still_work(self): |
| 104 | """Regression guard: the splat additions must not disturb what renders today.""" |
| 105 | import ifs_points |
| 106 | cloud, positions, colors = self._cloud() |
| 107 | import numpy as np |
| 108 | self.assertEqual(N, len(cloud.points)) |
| 109 | self.assertLess(float(np.abs(_read(cloud, 'position', 'vector', 3) - positions).max()), 1e-6) |
| 110 | self.assertIn(ifs_points.COLOR_ATTRIBUTE, cloud.attributes) |
| 111 | self.assertNotIn('color', [a.name for a in cloud.attributes], |
| 112 | '`color` is poisoned for points — it renders black') |
| 113 | self.assertLess( |
| 114 | float(np.abs(_read(cloud, ifs_points.COLOR_ATTRIBUTE, 'color', 4)[:, :3] |
| 115 | - colors[:, :3]).max()), 1e-6) |
| 116 | self.assertAlmostEqual(0.01, float(_read(cloud, 'radius', 'value', 1).min()), places=6) |
| 117 | |
| 118 | # --------------------------------------------------------------- opacity |
| 119 | |
| 120 | def test_opacity_carries_the_per_point_value_rather_than_a_constant(self): |
| 121 | """ |
| 122 | The whole point. `rgba[:, 3] = 1.0` threw this away, and a cloud of |
| 123 | uniformly-opaque splats is indistinguishable from a working one until you |
| 124 | compare it against the still render. |
| 125 | """ |
| 126 | import numpy as np |
| 127 | cloud, _pos, colors = self._cloud() |
| 128 | opacity = _read(cloud, 'opacity', 'value', 1) |
| 129 | |
| 130 | self.assertLess(float(np.abs(opacity - colors[:, 3]).max()), 1e-6, |
| 131 | 'opacity does not match the w channel the kernel emitted') |
| 132 | self.assertGreater(float(opacity.std()), 0.01, |
| 133 | 'opacity is constant across the cloud — it is being ' |
| 134 | 'synthesised, not carried') |
| 135 | |
| 136 | def test_a_uniformly_opaque_document_still_round_trips(self): |
| 137 | """ |
| 138 | The control for the test above. nebula_sample really does emit w == 1.0 |
| 139 | everywhere, so "opacity varies" must not become the definition of correct. |
| 140 | """ |
| 141 | import numpy as np |
| 142 | cloud, _pos, _col = self._cloud(alpha=1.0) |
| 143 | opacity = _read(cloud, 'opacity', 'value', 1) |
| 144 | self.assertLess(float(np.abs(opacity - 1.0).max()), 1e-6, |
| 145 | 'a fully-opaque document must survive unchanged') |
| 146 | |
| 147 | def test_the_colour_attribute_keeps_its_alpha_at_one(self): |
| 148 | """ |
| 149 | Opacity moved to its own attribute; it did NOT move INTO the colour. The |
| 150 | material multiplies nothing by ifs_color's alpha, so letting w leak in |
| 151 | there would dim the emission for reasons no slider explains. |
| 152 | """ |
| 153 | import numpy as np |
| 154 | import ifs_points |
| 155 | cloud, _pos, _col = self._cloud() |
| 156 | alpha = _read(cloud, ifs_points.COLOR_ATTRIBUTE, 'color', 4)[:, 3] |
| 157 | self.assertLess(float(np.abs(alpha - 1.0).max()), 1e-6) |
| 158 | |
| 159 | # ----------------------------------------------------------------- scale |
| 160 | |
| 161 | def test_scale_is_isotropic_and_tracks_the_radius(self): |
| 162 | """ |
| 163 | Differential on the radius argument, because asserting "scale > 0" would |
| 164 | pass against a hard-coded constant. |
| 165 | """ |
| 166 | import numpy as np |
| 167 | cloud_a, _p, _c = self._cloud(radius=0.01) |
| 168 | cloud_b, _p, _c = self._cloud(radius=0.02) |
| 169 | a = _read(cloud_a, 'scale', 'vector', 3) |
| 170 | b = _read(cloud_b, 'scale', 'vector', 3) |
| 171 | |
| 172 | self.assertLess(float(a.std(axis=1).max()), 1e-9, |
| 173 | 'scale is not isotropic — the three components disagree') |
| 174 | self.assertLess(float(np.abs(a - 0.01).max()), 1e-6, 'scale does not equal the radius') |
| 175 | self.assertLess(float(np.abs(b - 0.02).max()), 1e-6, 'scale did not follow the radius') |
| 176 | |
| 177 | # -------------------------------------------------------------- rotation |
| 178 | |
| 179 | def test_rotation_is_the_identity_quaternion(self): |
| 180 | """ |
| 181 | Isotropic splats have no orientation, and the identity is the correct |
| 182 | value rather than a stand-in. Asserted as (w, x, y, z) = (1, 0, 0, 0) |
| 183 | because Blender's quaternion order is w-first, and a viewer reading it |
| 184 | x-first would silently get a 180-degree rotation out of an all-zeros-plus-one. |
| 185 | """ |
| 186 | import numpy as np |
| 187 | cloud, _pos, _col = self._cloud() |
| 188 | rot = _read(cloud, 'rotation', 'value', 4) |
| 189 | self.assertLess(float(np.abs(rot[:, 0] - 1.0).max()), 1e-6, 'w component is not 1') |
| 190 | self.assertLess(float(np.abs(rot[:, 1:]).max()), 1e-6, 'x, y, z are not 0') |
| 191 | norms = np.linalg.norm(rot, axis=1) |
| 192 | self.assertLess(float(np.abs(norms - 1.0).max()), 1e-6, 'quaternion is not unit length') |
| 193 | |
| 194 | |
| 195 | if __name__ == "__main__": |
| 196 | result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result |
| 197 | sys.exit(0 if result.wasSuccessful() else 1) |