| … | … | @@ -128,7 +128,8 @@ def accumulate(shader, doc, ubos, alias_tex, size, bounds, dispatches, |
| 128 | 128 | |
| 129 | 129 | |
| 130 | 130 | def auto_bounds(shader, doc, ubos, alias_tex, probe_size=64, dispatches=8, |
| 131 | percentile=99.5, margin=0.15, start_extent=4.0, max_extent=256.0): | |
| 131 | percentile=99.5, margin=0.15, start_extent=4.0, max_extent=256.0, | |
| 132 | refine_passes=2, collapse_voxels=8): | |
| 132 | 133 | """ |
| 133 | 134 | Fits the bake box to where the fractal actually is. |
| 134 | 135 | |
| … | … | @@ -164,19 +165,73 @@ def auto_bounds(shader, doc, ubos, alias_tex, probe_size=64, dispatches=8, |
| 164 | 165 | extent *= 2.0 |
| 165 | 166 | |
| 166 | 167 | clipped = edge > 0.01 |
| 168 | box = ((-extent, -extent, -extent), (extent, extent, extent)) | |
| 169 | lo_w, hi_w = _fit(hit, box, probe_size, percentile, margin) | |
| 170 | ||
| 171 | # Re-probe any axis that collapsed to a handful of probe voxels. | |
| 172 | # | |
| 173 | # The fit can only resolve an axis to the PROBE's voxel size, so a flat fractal - | |
| 174 | # spherical_gasket_base is planar, 2 voxels thick out of 64 - gets a box barely | |
| 175 | # thicker than one probe voxel AND offset by up to one, because both edges are | |
| 176 | # rounding artifacts. That put the plane at ~88% of the box height instead of the | |
| 177 | # middle, so the bake spent its resolution on empty space and pressed the fractal | |
| 178 | # against a face. Re-probing inside the fitted box gives that axis a voxel size | |
| 179 | # smaller by the ratio of the two spans, which is where the precision comes from. | |
| 180 | # | |
| 181 | # A truly planar attractor would refine forever, each pass halving the thickness | |
| 182 | # toward a zero-size voxel, so this stops when an axis is thinner than one voxel | |
| 183 | # of the LARGEST axis: past that it is planar in any sense the bake can represent. | |
| 184 | for _ in range(refine_passes): | |
| 185 | occupied = [len(np.unique(hit[:, 2 - axis])) for axis in range(3)] | |
| 186 | span = hi_w - lo_w | |
| 187 | floor = float(span.max()) / probe_size | |
| 188 | collapsed = [a for a in range(3) | |
| 189 | if occupied[a] < collapse_voxels and span[a] > floor] | |
| 190 | if not collapsed: | |
| 191 | break | |
| 192 | ||
| 193 | # Pad the collapsed axes by a probe voxel of the box we just used: the fitted | |
| 194 | # edges are themselves quantised, so re-probing exactly the fit would clip | |
| 195 | # whatever sits in the rounding. | |
| 196 | pad = np.zeros(3) | |
| 197 | for a in collapsed: | |
| 198 | pad[a] = (box[1][a] - box[0][a]) / probe_size | |
| 199 | probe_lo = tuple(float(lo_w[a] - pad[a]) for a in range(3)) | |
| 200 | probe_hi = tuple(float(hi_w[a] + pad[a]) for a in range(3)) | |
| 201 | ||
| 202 | finer = accumulate(shader, doc, ubos, alias_tex, probe_size, | |
| 203 | (probe_lo, probe_hi), dispatches) | |
| 204 | finer_hit = np.argwhere(finer[:, :, :, 3] > 0) | |
| 205 | if finer_hit.size == 0: | |
| 206 | break # the tighter box found nothing; keep what we had | |
| 207 | ||
| 208 | box = (probe_lo, probe_hi) | |
| 209 | hit = finer_hit | |
| 210 | lo_w, hi_w = _fit(hit, box, probe_size, percentile, margin) | |
| 211 | ||
| 212 | return (tuple(float(v) for v in lo_w), tuple(float(v) for v in hi_w), | |
| 213 | {"probe_extent": extent, "edge_fraction": float(edge), | |
| 214 | "clipped": bool(clipped)}) | |
| 215 | ||
| 216 | ||
| 217 | def _fit(hit, box, probe_size, percentile, margin): | |
| 218 | """ | |
| 219 | Percentile-fits world bounds to probe hits, for an ARBITRARY probe box. | |
| 220 | ||
| 221 | `hit` columns are (z, y, x) - the readback order - so they are reversed here into | |
| 222 | world xyz. This was inline against a symmetric +/-extent cube; it takes the box as | |
| 223 | an argument so a refinement pass can re-fit inside a tighter one. | |
| 224 | """ | |
| 225 | lo, hi = np.array(box[0], dtype=float), np.array(box[1], dtype=float) | |
| 167 | 226 | zyx_lo = np.percentile(hit, 100 - percentile, axis=0) |
| 168 | 227 | zyx_hi = np.percentile(hit, percentile, axis=0) |
| 169 | 228 | lo_n = np.array([zyx_lo[2], zyx_lo[1], zyx_lo[0]]) / probe_size |
| 170 | 229 | hi_n = np.array([zyx_hi[2], zyx_hi[1], zyx_hi[0]]) / probe_size |
| 171 | 230 | |
| 172 | lo_w = -extent + lo_n * 2.0 * extent | |
| 173 | hi_w = -extent + hi_n * 2.0 * extent | |
| 231 | lo_w = lo + lo_n * (hi - lo) | |
| 232 | hi_w = lo + hi_n * (hi - lo) | |
| 174 | 233 | span = np.maximum(hi_w - lo_w, 1e-3) |
| 175 | lo_w -= span * margin | |
| 176 | hi_w += span * margin | |
| 177 | return (tuple(float(v) for v in lo_w), tuple(float(v) for v in hi_w), | |
| 178 | {"probe_extent": extent, "edge_fraction": float(edge), | |
| 179 | "clipped": bool(clipped)}) | |
| 234 | return lo_w - span * margin, hi_w + span * margin | |
| 180 | 235 | |
| 181 | 236 | |
| 182 | 237 | def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"): |
| … | … | @@ -0,0 +1,116 @@ |
| 1 | """ | |
| 2 | Proves the bake box is fitted to the fractal, not to the probe's rounding. | |
| 3 | ||
| 4 | `auto_bounds` can only resolve an axis to the PROBE's voxel size. When a fractal is | |
| 5 | flat - spherical_gasket_base is planar, 2 probe voxels thick out of 64 - both edges of | |
| 6 | that axis are rounding artifacts, so the fitted box came out barely thicker than one | |
| 7 | probe voxel AND offset by up to one. Measured, the plane ended up at ~88% of the box | |
| 8 | height: the bake spent its z resolution on empty space and pressed the fractal against | |
| 9 | a face. The arithmetic reproduces it exactly, so it was never random. | |
| 10 | ||
| 11 | That is invisible in review, because a fractal squashed against one side of its box is | |
| 12 | still a fractal. It shows only as a bake whose proportions and placement disagree with | |
| 13 | the still render. | |
| 14 | ||
| 15 | The fix re-probes collapsed axes inside the fitted box, where the voxel size is smaller | |
| 16 | by the ratio of the two spans. A truly planar attractor would refine forever, each pass | |
| 17 | halving the thickness toward a zero-size voxel, so refinement stops once an axis is | |
| 18 | thinner than one voxel of the largest axis. | |
| 19 | ||
| 20 | Needs a GPU. --factory-startup is REQUIRED, or an enabled add-on has already imported | |
| 21 | ifs_volume from the INSTALLED copy and sys.path cannot displace it: | |
| 22 | ||
| 23 | blender -b --factory-startup -noaudio --python blender/test_auto_bounds.py | |
| 24 | """ | |
| 25 | ||
| 26 | import os | |
| 27 | import sys | |
| 28 | import unittest | |
| 29 | ||
| 30 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| 31 | ||
| 32 | REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| 33 | TPL = os.path.join(REPO, "WpfDisplay", "Library", "Templates") | |
| 34 | ||
| 35 | PLANAR = "spherical_gasket_base.ifsjson" # 2 probe voxels thick in z | |
| 36 | SOLID = "nebula_sample.ifsjson" # fully 3D in all axes | |
| 37 | SIZE = 32 | |
| 38 | DISPATCHES = 10 | |
| 39 | ||
| 40 | ||
| 41 | class AutoBoundsCentring(unittest.TestCase): | |
| 42 | @classmethod | |
| 43 | def setUpClass(cls): | |
| 44 | try: | |
| 45 | import gpu | |
| 46 | import numpy # noqa: F401 | |
| 47 | import ifs_volume | |
| 48 | except ImportError as ex: | |
| 49 | raise unittest.SkipTest(f"needs Blender's gpu module ({ex})") | |
| 50 | if not os.path.isdir(TPL): | |
| 51 | raise unittest.SkipTest("templates not found") | |
| 52 | gpu.init() | |
| 53 | cls.shader, cls.library = ifs_volume.build_shader() | |
| 54 | ||
| 55 | def _occupancy(self, template, refine_passes): | |
| 56 | """Returns the z extent of the baked density, in bake voxels.""" | |
| 57 | import gpu | |
| 58 | import numpy as np | |
| 59 | import ifs_document | |
| 60 | import ifs_host | |
| 61 | import ifs_volume | |
| 62 | ||
| 63 | doc = ifs_document.load(os.path.join(TPL, template), self.library) | |
| 64 | ubos = ifs_volume._document_buffers(doc) | |
| 65 | alias = ifs_host.build_alias_tables(doc.iterators) | |
| 66 | n = len(doc.iterators) | |
| 67 | tex = gpu.types.GPUTexture((n, n), format='RGBA32F', | |
| 68 | data=gpu.types.Buffer('FLOAT', len(alias), alias)) | |
| 69 | lo, hi, _fit = ifs_volume.auto_bounds( | |
| 70 | self.shader, doc, ubos, tex, refine_passes=refine_passes) | |
| 71 | grid = ifs_volume.accumulate(self.shader, doc, ubos, tex, SIZE, | |
| 72 | (lo, hi), DISPATCHES) | |
| 73 | hit = np.argwhere(grid[:, :, :, 3] > 0) # columns are (z, y, x) | |
| 74 | self.assertGreater(hit.size, 0, f"{template} produced an empty bake") | |
| 75 | z = hit[:, 0] | |
| 76 | return int(z.min()), int(z.max()) | |
| 77 | ||
| 78 | def _offset(self, lo, hi): | |
| 79 | """How far the fractal's centre sits from the box's, in bake voxels.""" | |
| 80 | return abs((lo + hi) / 2.0 - (SIZE - 1) / 2.0) | |
| 81 | ||
| 82 | def test_a_planar_fractal_is_centred_in_its_box(self): | |
| 83 | """ | |
| 84 | The one that matters. Without refinement the gasket's plane sat 20 voxels off | |
| 85 | centre in a 64-voxel bake - two thirds of the way up its own box. | |
| 86 | """ | |
| 87 | lo, hi = self._occupancy(PLANAR, refine_passes=2) | |
| 88 | offset = self._offset(lo, hi) | |
| 89 | self.assertLess(offset, SIZE * 0.10, | |
| 90 | f"the plane sits {offset:.1f} voxels off centre; the box is " | |
| 91 | "still fitted to the probe's rounding rather than the fractal") | |
| 92 | ||
| 93 | def test_refinement_is_what_centres_it(self): | |
| 94 | """ | |
| 95 | Guards the guard: if the unrefined fit were already centred, the test above | |
| 96 | would pass without the fix doing anything. | |
| 97 | """ | |
| 98 | before = self._offset(*self._occupancy(PLANAR, refine_passes=0)) | |
| 99 | 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") | |
| 103 | ||
| 104 | def test_a_solid_fractal_is_left_alone(self): | |
| 105 | """ | |
| 106 | Refinement must not disturb a fractal that already fills its box - no axis | |
| 107 | collapses, so nothing should trigger. | |
| 108 | """ | |
| 109 | before = self._occupancy(SOLID, refine_passes=0) | |
| 110 | after = self._occupancy(SOLID, refine_passes=2) | |
| 111 | self.assertEqual(before, after, | |
| 112 | "refinement altered a fully 3D bake, which it should skip") | |
| 113 | ||
| 114 | ||
| 115 | if __name__ == "__main__": | |
| 116 | unittest.main(argv=sys.argv[:1], exit=False, verbosity=2) |