OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 7264a5c

Carry scale, rotation and opacity, so the cloud is readable as a splat
Blender's native gaussian-splat work (design task #159470, opened 2026-06-02) represents a splat as a POINT CLOUD carrying attributes — position, scale, rotation as a quaternion, opacity, and spherical harmonics as float3 sh_<m>_<l>. That is the container to_pointcloud already builds, so the whole gap was the attributes. Writing them costs one array each, changes nothing about how the cloud renders as points today, and makes it readable by that path when it lands and by the addons that exist now. Two of the three are not new measurements. They are data we were DISCARDING: * opacity is the w the kernel already emits. `rgba[:, 3] = 1.0` threw it away. Measured across light_rods_sample it spans 0.5 to 1.0, so it is genuinely per-point; on nebula_sample it is uniformly 1.0, which is why the test asserts variation on a varying document AND round-tripping on a uniform one — "opacity varies" must not quietly become the definition of correct. * scale is the radius, which is already derived from the cloud's own measured nearest-neighbour spacing rather than typed in. rotation is the identity, and that is the right value rather than a placeholder: these splats are isotropic. An oriented splat needs a covariance we do not compute — the chaos game gives sample positions, not local anisotropy. Asserted as (w,x,y,z) = (1,0,0,0) because Blender's quaternion order is w-first, and a reader assuming x-first would silently get a 180-degree rotation out of it. 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 — the alpha stays 1 and a test pins it. NOT written: sh_0_0. Degree-0 SH is not raw colour; the 3DGS convention stores a coefficient offset and scaled by the SH basis constant, and writing that mapping from memory is how you get a file that loads and looks wrong. Colour stays in ifs_color until the encoding is confirmed against a primary source. Attribute types verified against Blender 5.2 rather than assumed: QUATERNION exists and its settable property is `value`, FLOAT_VECTOR uses `vector`. 7 new tests, confirmed RED first (all three attributes missing). test_points_emit 10/10, test_points_operator 7/7, test_points_layout 8/8 unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7264a5c15d4106c00e31b5b6db773d960686b046
by plankatron <fleeky@gmail.com> · 2026-08-18T14:10:36.000Z

Changed files (2) +248 -8

blender/ifs_points.py +51 -8
……@@ -338,18 +338,61 @@ def to_pointcloud(positions, colors, radius, name="IFS Points"):
338338 cloud.attributes['position'].data.foreach_set(
339339 'vector', np.ascontiguousarray(positions, dtype=np.float32).ravel())
340340
341 colour = cloud.attributes.get(COLOR_ATTRIBUTE)
342 if colour is None:
343 colour = cloud.attributes.new(
344 name=COLOR_ATTRIBUTE, type='FLOAT_COLOR', domain='POINT')
341 def attribute(name, type_):
342 existing = cloud.attributes.get(name)
343 if existing is not None:
344 return existing
345 return cloud.attributes.new(name=name, type=type_, domain='POINT')
346
347 colour = attribute(COLOR_ATTRIBUTE, 'FLOAT_COLOR')
345348 rgba = np.ones((count, 4), dtype=np.float32)
346349 rgba[:, :colors.shape[1]] = colors[:, :4]
347 rgba[:, 3] = 1.0 # opacity rides in the colour's w on the GPU side
350 # The colour's alpha stays 1. Opacity rides in the w channel on the GPU side,
351 # but it belongs in its own attribute (below) rather than in the colour: the
352 # material multiplies nothing by this alpha, so letting w leak in here would
353 # dim the emission for reasons no slider explains.
354 opacity = (colors[:, 3].astype(np.float32).copy() if colors.shape[1] > 3
355 else np.ones(count, dtype=np.float32))
356 rgba[:, 3] = 1.0
348357 colour.data.foreach_set('color', rgba.ravel())
349358
350 radii = cloud.attributes.get('radius')
351 if radii is None:
352 radii = cloud.attributes.new(name='radius', type='FLOAT', domain='POINT')
359 radii = attribute('radius', 'FLOAT')
353360 radii.data.foreach_set('value', np.full(count, radius, dtype=np.float32))
354361
362 # ── the gaussian-splat attributes ───────────────────────────────────────
363 #
364 # Blender's native splat work (design task #159470) represents a splat as a
365 # POINT CLOUD carrying `position`, `scale`, `rotation` (quaternion) and
366 # `opacity` — the container this function already builds. Writing them costs
367 # one array each and makes the cloud readable as a splat by that path when it
368 # lands, and by the addons that exist now, while changing nothing about how
369 # it renders as points today.
370 #
371 # Two of the three are not new measurements, they are data we were DISCARDING:
372 # `opacity` is the w the kernel already emits (0.5..1.0 across
373 # light_rods_sample, so genuinely per-point), and `scale` is the radius that
374 # was already derived from the cloud's own measured nearest-neighbour spacing.
375 #
376 # `rotation` is the identity, and that is the correct value rather than a
377 # placeholder: these splats are isotropic. An oriented splat would need a
378 # covariance we do not compute — the chaos game gives us sample positions,
379 # not local anisotropy.
380 #
381 # NOT written here: `sh_0_0`. Degree-0 spherical harmonics are not raw colour
382 # — the 3DGS convention stores a coefficient offset and scaled by the SH basis
383 # constant — and writing that mapping from memory is how you get a file that
384 # loads and looks wrong. Colour stays in `ifs_color` until that encoding is
385 # confirmed against a primary source.
386 attribute('opacity', 'FLOAT').data.foreach_set('value', opacity)
387
388 scale = np.empty((count, 3), dtype=np.float32)
389 scale[:] = radius
390 attribute('scale', 'FLOAT_VECTOR').data.foreach_set('vector', scale.ravel())
391
392 # (w, x, y, z) — Blender's quaternion order is w-first, and so is the 3DGS
393 # PLY convention, so the identity is 1 followed by three zeros in both.
394 rotation = np.zeros((count, 4), dtype=np.float32)
395 rotation[:, 0] = 1.0
396 attribute('rotation', 'QUATERNION').data.foreach_set('value', rotation.ravel())
397
355398 return cloud
blender/test_points_splat_attributes.py +197 -0
……@@ -0,0 +1,197 @@
1"""
2The emitted PointCloud carries the attributes a gaussian splat needs.
3
4Blender's own splat work (design task #159470, opened 2026-06-02) represents a
5splat 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
8container `to_pointcloud` already produces, so the gap is only the attributes.
9Writing them now costs almost nothing and makes our output readable by the
10native path when it lands, and by the addons that exist today.
11
12Two of the three are not new information, they are information we were THROWING
13AWAY:
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
24the identity quaternion is not a placeholder standing in for something better —
25it is the correct value for a sphere, and an anisotropic splat would need a
26covariance we do not compute.
27
28Deliberately NOT added yet: `sh_0_0`. Degree-0 spherical harmonics are not raw
29colour — the 3DGS convention stores a coefficient related to colour by an offset
30and the SH basis constant C0. Writing that mapping from memory is exactly the
31kind of guess that produces a file which loads and looks wrong, so the colour
32stays 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
37import os
38import sys
39import unittest
40
41sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
42
43N = 64
44
45
46def _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
59def _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
67class 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
195if __name__ == "__main__":
196 result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result
197 sys.exit(0 if result.wasSuccessful() else 1)