OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 3644cb3

Every baked volume was transposed, displaced and squashed
Three geometry bugs in write_vdb, each wrong in a way that still renders a plausible fractal, which is why none of them surfaced. TRANSPOSED. GPUTexture.read() hands back a 3D texture as (z, y, x*4), so the accumulator is indexed [z][y][x] - while openvdb's copyFromArray reads array index (i, j, k) as world (x, y, z). The array went straight through, so x and z were swapped in every bake. auto_bounds in the same file ALREADY did this reorder (its zyx_* locals), so the file computed correct bounds around transposed data. DISPLACED. The grid transform carried a voxel size and nothing else, and copyFromArray writes from the index origin, so the volume sat at world (0 .. size*voxel) rather than at the bounds it had just been fitted to. SQUASHED. The voxel size came from the x extent alone, but auto_bounds fits each axis independently, so non-cubic bounds are the normal case rather than an edge case. y and z were scaled by whatever the aspect happened to be. Both conventions were measured rather than reasoned about: a marker written to voxel (0,0,2) came back at reshaped index [2,0,0], array[3,0,0]=1 produced grid(3,0,0)=1, and the row-vector layout of the transform matrix was checked against indexToWorld. The fix is two transposes and a full scale+translate matrix built per grid. test_volume_axes.py pins all three. Its fixture is deliberately awkward - a marker at three distinct indices, and bounds that are both non-cubic and off-origin - because cubic bounds at the origin hide every one of these. Before the fix it failed with "the lit voxel is not at (x, y, z) = (1, 3, 5)", "voxel maps to 0.25, expected -0.75" and "voxel size 0.25, expected 0.5". It skips outside Blender rather than erroring: ifs_volume imports gpu, so a module-level import would turn one skip into a collection error for the whole headless suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3644cb32d2d962d5d71ab59484723c128a894b54
by plankatron <fleeky@gmail.com> · 2026-08-17T19:05:07.000Z

Changed files (2) +197 -5

blender/ifs_volume.py +38 -5
……@@ -190,30 +190,63 @@ def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"):
190190
191191 size = grid.shape[0]
192192 lo, hi = bounds
193 voxel = (hi[0] - lo[0]) / size
194193
194 # Per-axis scale AND a translation to `lo`. Both matter and both were missing:
195 #
196 # * `auto_bounds` fits each axis independently (per-axis percentile + margin), so
197 # non-cubic bounds are the NORMAL case. One voxel size taken from the x extent
198 # squashed y and z by whatever the aspect happened to be.
199 # * A transform carrying only a voxel size leaves the grid at world
200 # (0 .. size*voxel), because copyFromArray writes from index origin. The bake
201 # landed nowhere near the bounds it was fitted to.
202 #
203 # openvdb uses the ROW-vector convention, so translation goes in the last ROW -
204 # verified against indexToWorld rather than assumed.
205 def _transform():
206 """A fresh Transform per grid; sharing one between grids is asking for it."""
207 sx, sy, sz = ((hi[a] - lo[a]) / size for a in range(3))
208 return openvdb.createLinearTransform(matrix=[
209 [sx, 0.0, 0.0, 0.0],
210 [0.0, sy, 0.0, 0.0],
211 [0.0, 0.0, sz, 0.0],
212 [lo[0], lo[1], lo[2], 1.0],
213 ])
214
215 # TRANSPOSE, and it is load-bearing. Two conventions meet here and disagree:
216 # `GPUTexture.read()` hands back a 3D texture as (z, y, x*4), so `grid` is
217 # indexed [z][y][x][channel] - while openvdb's `copyFromArray` reads array index
218 # (i, j, k) as world (x, y, z). Passing the array straight through swaps x and z,
219 # and a transposed fractal is still a plausible fractal, so nothing downstream
220 # notices; it shows only as a bake whose orientation disagrees with the still
221 # render. `auto_bounds` above already does this reorder (its `zyx_*` locals), so
222 # before this the same file computed correct bounds around transposed data.
195223 density = grid[:, :, :, 3]
196224 peak = float(density.max())
197225 if peak <= 0:
198226 raise RuntimeError("nothing was accumulated - the bounds may exclude the fractal")
199 density = np.ascontiguousarray(density / peak, dtype=np.float32)
227 density = np.ascontiguousarray(
228 (density / peak).transpose(2, 1, 0), dtype=np.float32)
200229
201230 grids = []
202231 dgrid = openvdb.FloatGrid()
203232 dgrid.copyFromArray(density)
204233 dgrid.name = name
205 dgrid.transform = openvdb.createLinearTransform(voxelSize=voxel)
234 dgrid.transform = _transform()
206235 grids.append(dgrid)
207236
208237 # Colour is accumulated weighted by density, so divide it back out.
209238 safe = np.maximum(grid[:, :, :, 3:4], 1e-9)
239 # Same transpose as density - the spatial axes only, leaving the component axis
240 # last. A colour grid transposed differently from density would tint the wrong
241 # parts of the fractal, which is harder to spot than a whole-volume rotation.
210242 colour = np.ascontiguousarray(
211 np.clip(grid[:, :, :, :3] / safe, 0.0, 1.0).astype(np.float32))
243 np.clip(grid[:, :, :, :3] / safe, 0.0, 1.0)
244 .transpose(2, 1, 0, 3).astype(np.float32))
212245 try:
213246 cgrid = openvdb.Vec3SGrid()
214247 cgrid.copyFromArray(colour)
215248 cgrid.name = "color"
216 cgrid.transform = openvdb.createLinearTransform(voxelSize=voxel)
249 cgrid.transform = _transform()
217250 grids.append(cgrid)
218251 except Exception:
219252 # A density-only volume is still useful; do not lose the bake over colour.
blender/test_volume_axes.py +159 -0
……@@ -0,0 +1,159 @@
1"""
2Proves the baked VDB is not transposed.
3
4Two conventions meet in `write_vdb`, and they disagree:
5
6* `GPUTexture.read()` returns a 3D texture as `(z, y, x*4)`, so after the reshape
7 the array is indexed `[z][y][x][channel]` — which is what the comment in
8 `accumulate` says, and what `auto_bounds` assumes (it names its locals `zyx_*`
9 and explicitly reorders to xyz).
10* `openvdb`'s `copyFromArray` reads array index `(i, j, k)` as world `(x, y, z)`.
11
12Handing a `[z][y][x]` array straight to `copyFromArray` therefore swaps x and z.
13Nothing catches it downstream because a transposed fractal is still a plausible
14fractal — it only shows as a bake whose orientation disagrees with the still
15render, which is exactly the kind of silent wrongness this project keeps finding.
16
17Both conventions were measured, not reasoned about: a marker written to voxel
18(0,0,2) came back at reshaped index [2,0,0], and `array[3,0,0]=1` produced
19`grid(3,0,0)=1`.
20
21Needs openvdb, so it runs inside Blender:
22 blender -b --factory-startup -noaudio --python blender/test_volume_axes.py
23"""
24
25import os
26import sys
27import tempfile
28import unittest
29
30sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31
32# NOT imported at module scope: `ifs_volume` pulls in `gpu`, which only exists
33# inside Blender, and a module-level import turns "skip outside Blender" into a
34# collection ERROR for the whole headless suite.
35
36S = 8
37# Deliberately asymmetric: if any two axes were swapped, at least one index moves.
38MARK_Z, MARK_Y, MARK_X = 5, 3, 1
39BOUNDS = ((-1.0, -1.0, -1.0), (1.0, 1.0, 1.0))
40# Deliberately non-cubic AND off-origin: catches both a missing translation and a
41# single shared voxel size. Extents 2, 4, 6 -> voxel sizes 0.25, 0.5, 0.75 at S=8.
42NONCUBIC = ((-1.0, -2.0, -3.0), (1.0, 2.0, 3.0))
43
44
45def _grid_with_marker():
46 """A (z, y, x, 4) accumulator with one lit voxel, as `accumulate` returns."""
47 import numpy as np
48 grid = np.zeros((S, S, S, 4), dtype=np.float32)
49 grid[MARK_Z, MARK_Y, MARK_X, 3] = 1.0 # density
50 grid[MARK_Z, MARK_Y, MARK_X, :3] = (1.0, 0.0, 0.0) # colour, pre-divided by density
51 return grid
52
53
54class BakeAxes(unittest.TestCase):
55 @classmethod
56 def setUpClass(cls):
57 try:
58 import openvdb # noqa: F401
59 import numpy # noqa: F401
60 import ifs_volume # noqa: F401
61 except ImportError as ex:
62 raise unittest.SkipTest(f"needs Blender's openvdb/gpu modules ({ex})")
63
64 def _write_and_read(self):
65 import openvdb
66 import ifs_volume
67 tmp = tempfile.mkdtemp()
68 path = os.path.join(tmp, "axes.vdb")
69 ifs_volume.write_vdb(_grid_with_marker(), BOUNDS, path)
70 grids = openvdb.readAllGridMetadata(path)
71 names = [g.name for g in grids]
72 out = {}
73 for name in names:
74 out[name] = openvdb.read(path, name)
75 return out
76
77 def test_density_voxel_lands_at_the_world_position_it_was_written_to(self):
78 grids = self._write_and_read()
79 self.assertIn("density", grids)
80 acc = grids["density"].getConstAccessor()
81
82 correct = acc.getValue((MARK_X, MARK_Y, MARK_Z))
83 swapped = acc.getValue((MARK_Z, MARK_Y, MARK_X))
84
85 self.assertGreater(
86 correct, 0.5,
87 "the lit voxel is not at (x, y, z) = (%d, %d, %d); the array is indexed "
88 "[z][y][x] and copyFromArray reads (i, j, k) as (x, y, z), so it needs "
89 "transposing before the write" % (MARK_X, MARK_Y, MARK_Z))
90 self.assertLess(
91 swapped, 0.5,
92 "the lit voxel is at the x/z-SWAPPED position — the bake is transposed")
93
94 def test_colour_grid_uses_the_same_convention_as_density(self):
95 """A colour grid transposed differently from density would tint the wrong
96 parts of the fractal, which is even harder to spot than a global rotation."""
97 grids = self._write_and_read()
98 if "color" not in grids:
99 self.skipTest("colour grid not written (openvdb Vec3SGrid unavailable)")
100 acc = grids["color"].getConstAccessor()
101 value = acc.getValue((MARK_X, MARK_Y, MARK_Z))
102 self.assertGreater(value[0], 0.5,
103 "colour grid disagrees with the density grid's axes")
104
105 def test_the_marker_is_asymmetric_enough_to_detect_a_swap(self):
106 """Guards the guard: equal indices would make a transpose invisible."""
107 self.assertEqual(3, len({MARK_X, MARK_Y, MARK_Z}))
108
109 # ------------------------------------------------------- placement and scale
110
111 def _write_noncubic(self):
112 """`auto_bounds` fits each axis independently, so non-cubic bounds are the
113 normal case, not an edge case."""
114 import openvdb
115 import ifs_volume
116 tmp = tempfile.mkdtemp()
117 path = os.path.join(tmp, "placed.vdb")
118 ifs_volume.write_vdb(_grid_with_marker(), NONCUBIC, path)
119 return openvdb.read(path, "density")
120
121 def test_voxel_lands_at_its_world_position_not_at_the_origin(self):
122 """
123 The grid must sit inside the bounds `auto_bounds` chose. A transform carrying
124 only a voxel size leaves the volume at world (0 .. size*voxel) instead — the
125 fractal renders in the wrong place, still looking like a fractal.
126 """
127 grid = self._write_noncubic()
128 lo, hi = NONCUBIC
129 expected = tuple(lo[a] + (MARK_X, MARK_Y, MARK_Z)[a] * (hi[a] - lo[a]) / S
130 for a in range(3))
131 actual = grid.transform.indexToWorld((MARK_X, MARK_Y, MARK_Z))
132 for axis, (e, a) in enumerate(zip(expected, actual)):
133 self.assertAlmostEqual(
134 e, a, places=5,
135 msg=f"axis {axis}: voxel maps to {a}, expected {e} — the grid "
136 "transform is missing the translation to the bake bounds")
137
138 def test_each_axis_gets_its_own_voxel_size(self):
139 """
140 A single voxel size taken from the x extent squashes y and z whenever the
141 bounds are not cubic, which distorts the fractal's proportions.
142 """
143 grid = self._write_noncubic()
144 lo, hi = NONCUBIC
145 origin = grid.transform.indexToWorld((0, 0, 0))
146 for axis in range(3):
147 step = [0, 0, 0]
148 step[axis] = 1
149 one = grid.transform.indexToWorld(tuple(step))
150 measured = one[axis] - origin[axis]
151 expected = (hi[axis] - lo[axis]) / S
152 self.assertAlmostEqual(
153 expected, measured, places=5,
154 msg=f"axis {axis}: voxel size {measured}, expected {expected} — "
155 "a single scale taken from the x extent distorts the other axes")
156
157
158if __name__ == "__main__":
159 unittest.main(argv=sys.argv[:1], exit=False, verbosity=2)