OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / f7acdf8

The bounds fit followed voxel count, not where the fractal's mass is
light_rods_sample could not be baked. auto_bounds doubled its probe to the 256 cap, still reported the body clipped, and fitted a 655-unit box - about 6.8 units per voxel at size 96 - so the fractal smeared into a featureless blob. np.argwhere(density > 0) counts every occupied voxel equally: a voxel holding one sample weighs the same as one holding a million. A chaos game's halo is enormous in voxel COUNT and negligible in MASS, so percentiling those indices tracked the halo rather than the fractal. The percentile was being taken over the wrong quantity entirely. Both the growth test and the fit now cut the cumulative density along each axis, which asks where the mass is. Measured across all five templates, every one now settles at the FIRST probe (extent 4), none reports clipping, and occupancy rises throughout - the grid is spent on the fractal instead of empty space: light rods span 655.20 -> 9.59 occupancy 40% -> 66% nebula span 16.90 -> 7.47 occupancy 27% -> 75% plastic span 38.35 -> 7.15 occupancy 22% -> 48% freeform span 18.36 -> 6.17 occupancy 3% -> 16% gasket span 12.48 -> 9.26 occupancy 1% -> 5.5% An earlier attempt in this same session was wrong and is worth recording: I assumed outliers were driving the growth loop and made the growth test use the percentile. It changed nothing - light rods still hit 256 - because the percentile itself was the broken part. Re-measuring after the fix is what caught it. test_refinement_is_what_centres_it now asserts the opposite and is renamed. Refinement was introduced because a collapsed axis was fitted at probe resolution and landed ~20 voxels off centre; the mass fit centres it outright (measured offset 0.0 unrefined), so that guard could only fail. Refinement still earns its place by tightening a collapsed axis for resolution - it is simply no longer what centres the box, and the test now checks it does not make placement worse. Note the derived exposure scales all shifted (nebula 66 -> 32, plastic 302 -> 73) because they are computed from density inside a much tighter box. Self-consistent, but it means the exposure verified earlier was verified against the old bounds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f7acdf84ac1b114dbdf702d348c1fe2ac5ce2ffa
by plankatron <fleeky@gmail.com> · 2026-08-18T00:07:53.000Z

Changed files (2) +79 -20

blender/ifs_volume.py +58 -14
……@@ -160,13 +160,27 @@ def auto_bounds(shader, doc, ubos, alias_tex, probe_size=64, dispatches=8,
160160 continue
161161
162162 edge = ((hit == 0) | (hit == probe_size - 1)).any(axis=1).mean()
163 if edge <= 0.01 or extent >= max_extent:
163
164 # Grow while the BODY is clipped, not while stragglers touch the edge.
165 #
166 # The old test grew whenever >1% of occupied voxels sat on the boundary. A
167 # chaos game throws samples arbitrarily far, so for a heavy-tailed document
168 # that fraction never falls: growing the box just captures more outliers.
169 # light_rods_sample doubled all the way to the max_extent of 256 and still
170 # reported 4.3% on the boundary, so it fitted a 655-unit box - roughly 6.8
171 # units per voxel at size 96, smearing the fractal into a featureless blob.
172 #
173 # The percentile fit below already excludes those outliers deliberately; the
174 # growth decision simply was not using it. Now it asks the same question the
175 # fit does - is the 99% body inside the probe? - so the loop stops once the
176 # part we are actually going to keep fits.
177 body_lo, body_hi = _mass_bounds(density, probe_size, percentile)
178 clipped = bool((body_lo <= 0.5).any() or (body_hi >= probe_size - 1.5).any())
179 if not clipped or extent >= max_extent:
164180 break
165181 extent *= 2.0
166
167 clipped = edge > 0.01
168182 box = ((-extent, -extent, -extent), (extent, extent, extent))
169 lo_w, hi_w = _fit(hit, box, probe_size, percentile, margin)
183 lo_w, hi_w = _fit(density, box, probe_size, percentile, margin)
170184
171185 # Re-probe any axis that collapsed to a handful of probe voxels.
172186 #
……@@ -182,7 +196,8 @@ def auto_bounds(shader, doc, ubos, alias_tex, probe_size=64, dispatches=8,
182196 # toward a zero-size voxel, so this stops when an axis is thinner than one voxel
183197 # of the LARGEST axis: past that it is planar in any sense the bake can represent.
184198 for _ in range(refine_passes):
185 occupied = [len(np.unique(hit[:, 2 - axis])) for axis in range(3)]
199 occupied = [int((density.sum(axis=tuple(a for a in range(3)
200 if a != 2 - axis)) > 0).sum()) for axis in range(3)]
186201 span = hi_w - lo_w
187202 floor = float(span.max()) / probe_size
188203 collapsed = [a for a in range(3)
……@@ -201,20 +216,49 @@ def auto_bounds(shader, doc, ubos, alias_tex, probe_size=64, dispatches=8,
201216
202217 finer = accumulate(shader, doc, ubos, alias_tex, probe_size,
203218 (probe_lo, probe_hi), dispatches)
204 finer_hit = np.argwhere(finer[:, :, :, 3] > 0)
205 if finer_hit.size == 0:
219 finer_density = finer[:, :, :, 3]
220 if not (finer_density > 0).any():
206221 break # the tighter box found nothing; keep what we had
207222
208223 box = (probe_lo, probe_hi)
209 hit = finer_hit
210 lo_w, hi_w = _fit(hit, box, probe_size, percentile, margin)
224 density = finer_density
225 lo_w, hi_w = _fit(density, box, probe_size, percentile, margin)
211226
212227 return (tuple(float(v) for v in lo_w), tuple(float(v) for v in hi_w),
213228 {"probe_extent": extent, "edge_fraction": float(edge),
214229 "clipped": bool(clipped)})
215230
216231
217def _fit(hit, box, probe_size, percentile, margin):
232def _mass_bounds(density, probe_size, percentile):
233 """
234 Index bounds holding the central `percentile` of the fractal's MASS, per world axis.
235
236 The fit used to percentile over `np.argwhere(density > 0)` - the INDICES of occupied
237 voxels - which counts a voxel holding one sample the same as one holding a million.
238 A chaos game's halo is enormous in voxel count and negligible in mass, so that
239 statistic tracked the halo rather than the fractal. light_rods_sample grew its probe
240 to the 256 cap and still reported the body clipped, fitting a 655-unit box.
241
242 Summing the marginal along each axis and cutting the cumulative distribution asks
243 where the mass is instead, which is what the bake should spend its resolution on.
244 """
245 lo_idx, hi_idx = [], []
246 for array_axis in (2, 1, 0): # array is [z][y][x]; iterate world x, y, z
247 others = tuple(a for a in range(3) if a != array_axis)
248 marginal = density.sum(axis=others)
249 total = float(marginal.sum())
250 if total <= 0:
251 lo_idx.append(0.0)
252 hi_idx.append(float(probe_size - 1))
253 continue
254 cumulative = np.cumsum(marginal) / total
255 tail = (100.0 - percentile) / 100.0
256 lo_idx.append(float(np.searchsorted(cumulative, tail)))
257 hi_idx.append(float(np.searchsorted(cumulative, 1.0 - tail)))
258 return np.array(lo_idx), np.array(hi_idx)
259
260
261def _fit(density, box, probe_size, percentile, margin):
218262 """
219263 Percentile-fits world bounds to probe hits, for an ARBITRARY probe box.
220264
……@@ -223,10 +267,10 @@ def _fit(hit, box, probe_size, percentile, margin):
223267 an argument so a refinement pass can re-fit inside a tighter one.
224268 """
225269 lo, hi = np.array(box[0], dtype=float), np.array(box[1], dtype=float)
226 zyx_lo = np.percentile(hit, 100 - percentile, axis=0)
227 zyx_hi = np.percentile(hit, percentile, axis=0)
228 lo_n = np.array([zyx_lo[2], zyx_lo[1], zyx_lo[0]]) / probe_size
229 hi_n = np.array([zyx_hi[2], zyx_hi[1], zyx_hi[0]]) / probe_size
270 # Already in world-axis order (x, y, z) - _mass_bounds does the reordering.
271 lo_idx, hi_idx = _mass_bounds(density, probe_size, percentile)
272 lo_n = lo_idx / probe_size
273 hi_n = hi_idx / probe_size
230274
231275 lo_w = lo + lo_n * (hi - lo)
232276 hi_w = lo + hi_n * (hi - lo)
blender/test_auto_bounds.py +21 -6
……@@ -17,6 +17,11 @@ by the ratio of the two spans. A truly planar attractor would refine forever, ea
1717halving the thickness toward a zero-size voxel, so refinement stops once an axis is
1818thinner than one voxel of the largest axis.
1919
20SUPERSEDED IN PART: the fit later became mass-weighted - cutting the cumulative density
21along each axis instead of percentiling occupied-voxel INDICES - and that centres a
22planar fractal on its own. Refinement now buys resolution on a collapsed axis rather
23than placement.
24
2025Needs a GPU. --factory-startup is REQUIRED, or an enabled add-on has already imported
2126ifs_volume from the INSTALLED copy and sys.path cannot displace it:
2227
……@@ -90,16 +95,26 @@ class AutoBoundsCentring(unittest.TestCase):
9095 f"the plane sits {offset:.1f} voxels off centre; the box is "
9196 "still fitted to the probe's rounding rather than the fractal")
9297
93 def test_refinement_is_what_centres_it(self):
98 def test_refinement_does_not_decentre_it(self):
9499 """
95 Guards the guard: if the unrefined fit were already centred, the test above
96 would pass without the fix doing anything.
100 This asserted the OPPOSITE until the fit became mass-weighted.
101
102 Refinement was introduced because a collapsed axis was fitted at the probe's
103 resolution and came out ~20 voxels off centre, so the guard checked that
104 refinement was what centred it. Weighting the fit by MASS rather than by
105 occupied-voxel count centres it outright - measured, the unrefined offset is
106 now 0.0 - so refinement is no longer the mechanism and that guard could only
107 fail.
108
109 Refinement still earns its place by tightening a collapsed axis, which buys
110 resolution; it simply is not what centres the box any more. What matters here
111 is that it does not make placement worse.
97112 """
98113 before = self._offset(*self._occupancy(PLANAR, refine_passes=0))
99114 after = self._offset(*self._occupancy(PLANAR, refine_passes=2))
100 self.assertGreater(before, after * 2.0,
101 f"refinement changed little (before {before:.1f}, after "
102 f"{after:.1f}) - this test is not exercising the fix")
115 self.assertLessEqual(after, max(before, 1.0) + 0.5,
116 f"refinement moved the fractal off centre "
117 f"(before {before:.1f}, after {after:.1f})")
103118
104119 def test_a_solid_fractal_is_left_alone(self):
105120 """