OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / b32d728

Bake the fractal to an OpenVDB volume, and calm the viewport under mouselook
## Volume "is there a way to make it real data in the blender scene so it can be rendered like any other data" - yes. Bake Volume writes a .vdb and imports it, after which Cycles lights it, occludes it, motion-blurs it and composites it like any other volume. The live viewport stays a screen-space overlay; this is the other thing. The kernel gets a second variant: the projection block is replaced wholesale by a voxel write, NOT bypassed. project() can `continue` on out-of-frame, so leaving it in would silently drop every voxel outside the current view and bake a view-dependent volume. Four unknowns were checked before writing any of it, and all four held: UINT_3D images with imageAtomicAdd compile, 3D R32UI textures allocate, Blender ships `openvdb` in its Python (renamed from pyopenvdb) with FloatGrid and write, and volume_import exists. No external dependency. **Bounds are the hard part** and the first attempt was quietly wrong. A chaos game is unbounded, so the bake needs a box; I probed a fixed +/-8 and it reported x = +/-10 for a gasket that had simply run off the edge - fitting to a box the fractal overflows returns the box, not the fractal. The probe now starts at +/-4 and DOUBLES while more than 1% of occupied voxels touch its boundary, and reports `clipped` if it gives up. Fitting uses a percentile rather than the extremes, because a handful of samples fly arbitrarily far and fitting to those spends the whole grid on empty space - so a bake does clip its faintest tails, which is why explicit bounds remain available. Density is normalised to a 0..1 peak: absolute counts scale with how long the bake ran, and a volume whose density depends on render time is unusable. Colour is accumulated density-weighted, so it is divided back out into a second Vec3S grid. Verified end to end through the operator: 96^3 bake, 4910 occupied voxels, imported as a Volume object carrying both `density` and `color` grids. ## Mouselook "the mouselook is what is throwing off the renderer in fly mode" - the right diagnosis. Any camera change discards the accumulation, and rotation changes it on EVERY frame where translation often pauses, so one dispatch has to cover the whole image and the result is sparse noise. The internal resolution now drops to a quarter of the region while moving and returns to a half once the view has been still for three frames. Same sample budget over a quarter of the pixels, so motion stays readable. A resize already invalidates, so the two paths no longer invalidate twice in the same frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b32d7285c477cd61293d91d1f1fbd1e62065803b
by plankatron <fleeky@gmail.com> · 2026-08-16T23:37:48.000Z

Changed files (5) +397 -8

blender/__init__.py +53 -0
……@@ -50,6 +50,7 @@ import ifs_codegen
5050import ifs_document
5151import ifs_render
5252import ifs_viewport
53import ifs_volume
5354
5455def _assets_root() -> str:
5556 """
……@@ -177,6 +178,13 @@ class IFSSceneSettings(PropertyGroup):
177178 name="Iterations/frame", default=400, min=10, max=20000,
178179 description="Work per viewport redraw. Higher refines faster and drops the "
179180 "framerate; the engine's own default is 500")
181 volume_size: IntProperty(
182 name="Voxels", default=192, min=32, max=512,
183 description="Grid is 4N x N x N uint32 on the GPU: 192 is ~113MB, "
184 "256 is ~268MB, 512 is ~2.1GB and will not fit")
185 volume_dispatches: IntProperty(name="Dispatches", default=200, min=1, max=100000)
186 volume_path: StringProperty(
187 name="VDB", subtype='FILE_PATH', default="//fractal.vdb")
180188 iterators: CollectionProperty(type=IFSIteratorItem)
181189 active_iterator: IntProperty(default=0)
182190 status: StringProperty(default="")
……@@ -306,6 +314,43 @@ def _redraw(context):
306314 area.tag_redraw()
307315
308316
317class IFS_OT_bake_volume(Operator):
318 """Bake the fractal to an OpenVDB volume and import it into the scene"""
319
320 bl_idname = "ifs.bake_volume"
321 bl_label = "Bake Volume"
322
323 def execute(self, context):
324 settings = context.scene.ifs
325 path = bpy.path.abspath(settings.document)
326 if not path or not os.path.isfile(path):
327 self.report({'ERROR'}, "Pick an .ifsjson file first")
328 return {'CANCELLED'}
329
330 out = bpy.path.abspath(settings.volume_path)
331 if not out.endswith(".vdb"):
332 out += ".vdb"
333 os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
334
335 try:
336 written, stats = ifs_volume.bake(
337 path, out, size=settings.volume_size,
338 dispatches=settings.volume_dispatches)
339 except Exception as ex:
340 traceback.print_exc()
341 self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
342 return {'CANCELLED'}
343
344 bpy.ops.object.volume_import(filepath=written)
345 occupied = stats["occupied_voxels"]
346 settings.status = (f"Baked {occupied} voxels to {os.path.basename(written)}")
347 if stats.get("clipped"):
348 self.report({'WARNING'},
349 "The fractal overflowed the probe box; the bake is clipped")
350 self.report({'INFO'}, settings.status)
351 return {'FINISHED'}
352
353
309354class IFS_OT_render(Operator):
310355 """Render the loaded fractal and show it in the Image Editor"""
311356
……@@ -413,6 +458,13 @@ class IFS_PT_main(Panel):
413458 if running:
414459 box.label(text=ifs_viewport.status(), icon='INFO')
415460
461 box = layout.box()
462 box.label(text="Volume (renders in Cycles)")
463 box.prop(settings, "volume_size")
464 box.prop(settings, "volume_dispatches")
465 box.prop(settings, "volume_path")
466 box.operator("ifs.bake_volume", icon='OUTLINER_OB_VOLUME')
467
416468
417469class IFS_PT_iterators(Panel):
418470 bl_label = "Iterators"
……@@ -470,6 +522,7 @@ _CLASSES = (
470522 IFS_OT_load_template,
471523 IFS_OT_render,
472524 IFS_OT_viewport,
525 IFS_OT_bake_volume,
473526 IFS_UL_iterators,
474527 IFS_PT_main,
475528 IFS_PT_iterators,
blender/build_addon.py +1 -0
……@@ -39,6 +39,7 @@ RUNTIME_FILES = (
3939 "ifs_document.py",
4040 "ifs_render.py",
4141 "ifs_viewport.py",
42 "ifs_volume.py",
4243)
4344
4445
blender/ifs_layout.py +61 -2
……@@ -253,6 +253,58 @@ def extract_ubos(source: str) -> tuple[str, str, str]:
253253 return out, "\n\n".join(typedefs), "\n".join(defines)
254254
255255
256# ------------------------------------------------------------------- volume mode
257#
258# Instead of projecting to a 2D histogram, accumulate into a 3D voxel grid. That
259# turns the chaos game's output into a density field, which is what OpenVDB stores
260# and what Cycles renders natively - so the fractal becomes real scene data rather
261# than an overlay.
262#
263# The projection block is replaced wholesale rather than bypassed: project() can
264# `continue` on out-of-frame, which would silently drop every voxel outside the
265# current view and bake a view-dependent volume.
266
267_PLOT_BLOCK = re.compile(
268 r"[ \t]*float defocus;.*?accumulate_hit\(proj, color\);\s*\}", re.S)
269
270_PLOT_VOLUME = """ vec4 color = vec4(getPaletteColor(p.color_index), selected_iterator.opacity);
271 if (color.w == 0.0)
272 continue;
273 color.xyz *= color.w;
274 accumulate_voxel(p.pos.xyz, color);"""
275
276_VOXEL_HELPER = """
277// Four contiguous texels per voxel, mirroring the 2D histogram's layout so the
278// same coalescing argument applies: one cache line per voxel.
279void accumulate_voxel(vec3 pos, vec4 color)
280{
281 vec3 span = max(volume_max - volume_min, vec3(1e-9));
282 vec3 t = (pos - volume_min) / span;
283 if (any(lessThan(t, vec3(0.0))) || any(greaterThanEqual(t, vec3(1.0))))
284 return; // outside the bake bounds; the chaos game is unbounded
285 ivec3 v = ivec3(t * vec3(volume_size));
286 ivec3 base = ivec3(v.x * 4, v.y, v.z);
287 imageAtomicAdd(volume_img, base + ivec3(0, 0, 0), uint(max(color.r, 0.0) * HIST_SCALE));
288 imageAtomicAdd(volume_img, base + ivec3(1, 0, 0), uint(max(color.g, 0.0) * HIST_SCALE));
289 imageAtomicAdd(volume_img, base + ivec3(2, 0, 0), uint(max(color.b, 0.0) * HIST_SCALE));
290 imageAtomicAdd(volume_img, base + ivec3(3, 0, 0), uint(max(color.w, 0.0) * HIST_SCALE));
291}
292"""
293
294VOLUME_LAYOUT: tuple[Resource, ...] = (
295 Resource("image", 0, "volume_img", "UINT_3D",
296 "R32UI, 4N x N x N, fixed point. imageAtomicAdd target."),
297 Resource("image", 1, "state_img", "FLOAT_2D", "as the 2D path"),
298 Resource("sampler", 0, "alias_tables_tex", "FLOAT_2D", ""),
299 Resource("uniform_buf", 0, "frame_ubo", "FrameParams", "", instance="frame"),
300 Resource("uniform_buf", 1, "settings_ubo", "SettingsParams", "", instance="settings"),
301 Resource("uniform_buf", 2, "iterators_ubo", "IteratorsParams", "", instance="iterators_buf"),
302 Resource("uniform_buf", 3, "palette_ubo", "PaletteParams", "", instance="palette_buf"),
303 Resource("uniform_buf", 4, "real_params_ubo", "RealParams", "", instance="real_params_buf"),
304 Resource("uniform_buf", 5, "vec3_params_ubo", "Vec3Params", "", instance="vec3_params_buf"),
305)
306
307
256308@dataclass
257309class RewriteReport:
258310 """What the rewrite did, so a silent no-op is impossible to miss."""
……@@ -266,7 +318,8 @@ class RewriteReport:
266318
267319
268320def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE,
269 state_width: int = 256) -> tuple[str, str, RewriteReport]:
321 state_width: int = 256,
322 volume: bool = False) -> tuple[str, str, RewriteReport]:
270323 """
271324 Rewrites an engine kernel onto the Blender resource model.
272325
……@@ -300,7 +353,13 @@ def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE,
300353 out = sub(_LOOSE_UNIFORM, "", "drop loose uniforms (moved to frame_ubo)", out, expected=6)
301354
302355 # Accesses.
303 out = sub(_ACCUMULATE, lambda _m: _ACCUMULATE_NEW, "rewrite accumulate_hit", out)
356 if volume:
357 out = sub(_PLOT_BLOCK, lambda _m: _PLOT_VOLUME,
358 "replace the projection block with a voxel write", out)
359 out = sub(_ACCUMULATE, lambda _m: _VOXEL_HELPER,
360 "swap accumulate_hit for accumulate_voxel", out)
361 else:
362 out = sub(_ACCUMULATE, lambda _m: _ACCUMULATE_NEW, "rewrite accumulate_hit", out)
304363 out = sub(re.compile(r"\bp\s*=\s*state\[gid\]\s*;"), "p = load_state(gid);",
305364 "rewrite state load", out)
306365 out = sub(re.compile(r"\bstate\[gid\]\s*=\s*p\s*;"), "store_state(gid, p);",
blender/ifs_viewport.py +26 -6
……@@ -284,9 +284,10 @@ class Session:
284284 (n, n), format='RGBA32F',
285285 data=gpu.types.Buffer('FLOAT', len(alias), alias))
286286
287 def _resize(self, width, height):
287 def _resize(self, width, height) -> bool:
288 """Returns True if the buffers were rebuilt (which already invalidated)."""
288289 if (width, height) == (self.width, self.height):
289 return
290 return False
290291 self.width, self.height = width, height
291292 self.histogram = gpu.types.GPUTexture((width * 4, height), format='R32UI')
292293 self.accum = gpu.types.GPUTexture((width, height), format='RGBA32F')
……@@ -295,6 +296,7 @@ class Session:
295296 self.state = gpu.types.GPUTexture(
296297 (ifs_render.STATE_W * 2, rows), format='RGBA32F')
297298 self.invalidate()
299 return True
298300
299301 def invalidate(self):
300302 """Discards accumulated samples. Called when the viewpoint changes."""
……@@ -352,20 +354,38 @@ class Session:
352354
353355 # ---------------------------------------------------------------------- draw
354356
357 # While the camera moves, every frame throws its samples away, so one dispatch
358 # has to cover the whole image and the result is sparse noise. Rotation shows
359 # this worst - mouselook changes the view every single frame, where translation
360 # often pauses. Rendering at a coarser internal resolution during motion puts
361 # the same budget over a quarter of the pixels, so it stays readable, and full
362 # resolution returns as soon as the view settles.
363 MOTION_DIVISOR = 4
364 STILL_DIVISOR = 2
365 STILL_FRAMES = 3
366
355367 def draw(self, context):
356368 settings = getattr(context.scene, "ifs", None)
357369 if settings is not None and settings.iterators:
358370 self.sync_from_scene(settings)
359371 self._rebuild_dirty()
360372
361 region = context.region
362 self._resize(max(region.width // 2, 16), max(region.height // 2, 16))
363
364373 space = context.space_data
365374 view_proj = self._view_projection(space.region_3d, space.lens)
366 if self._view_moved(view_proj):
375 moved = self._view_moved(view_proj)
376 self._still_frames = 0 if moved else getattr(self, "_still_frames", 0) + 1
377
378 divisor = (self.MOTION_DIVISOR if self._still_frames < self.STILL_FRAMES
379 else self.STILL_DIVISOR)
380 region = context.region
381 resized = self._resize(max(region.width // divisor, 16),
382 max(region.height // divisor, 16))
383
384 if moved and not resized:
367385 self.last_view = view_proj
368386 self.invalidate()
387 elif moved:
388 self.last_view = view_proj
369389
370390 camera = self.doc.camera
371391 camera.view_proj = view_proj
blender/ifs_volume.py +256 -0
……@@ -0,0 +1,256 @@
1"""
2Bakes a fractal into an OpenVDB volume, so Cycles renders it like any other data.
3
4The live viewport is a screen-space overlay: no depth, no lighting, invisible to
5F12. This is the other thing entirely — the chaos game accumulates into a 3D voxel
6grid instead of a 2D histogram, the grid is written as `.vdb`, and Blender imports
7it as a Volume object. From there it lights, occludes, motion-blurs and composites
8like any other volume.
9
10Blender ships `openvdb` in its Python (renamed from `pyopenvdb`), so no external
11dependency is needed.
12
13## Bounds
14
15A chaos game is unbounded — a few samples fly arbitrarily far — so a bake needs a
16box, and a box that is too big wastes resolution on empty space while one too
17small clips the fractal. `auto_bounds` runs a cheap coarse pass first, finds the
18voxels that actually got hit, and fits the real bake to that with a margin. It is
19a percentile rather than the extremes, because the extremes are exactly the
20handful of outliers that would blow the box back up.
21
22## Memory
23
24The grid is 4N x N x N uint32 — four contiguous channels per voxel, mirroring the
252D histogram's interleaving for the same coalescing reason. At N=256 that is
26268 MB on the GPU; N=512 is 2.1 GB and will not fit alongside anything else on a
27consumer card. 256 is the practical default.
28"""
29
30from __future__ import annotations
31
32import os
33
34import gpu
35import numpy as np
36
37import ifs_codegen
38import ifs_document
39import ifs_host
40import ifs_layout
41import ifs_render
42
43HIST_SCALE = ifs_layout.DEFAULT_HIST_SCALE
44
45
46
47def build_shader(hist_scale: float = HIST_SCALE):
48 """Compiles the volume variant of the kernel."""
49 library = ifs_codegen.load_library(
50 os.path.join(ifs_render.REPO, "Transforms"),
51 os.path.join(ifs_render.REPO, "WpfDisplay", "Library", "Includes"))
52 template_path = os.path.join(
53 ifs_render.REPO, "IFSEngine", "Rendering", "Shaders", "ifs_kernel.comp.shader")
54 with open(template_path, "r", encoding="utf-8-sig") as handle:
55 kernel = ifs_codegen.generate_kernel(handle.read(), library)
56 kernel, _ = ifs_codegen.apply_portability_fixes(kernel)
57 body, typedefs, report = ifs_layout.rewrite(
58 kernel, hist_scale, ifs_render.STATE_W, volume=True)
59 if not report.ok:
60 raise RuntimeError("volume rewrite incomplete: " + "; ".join(report.missing))
61
62 info = gpu.types.GPUShaderCreateInfo()
63 info.typedef_source(typedefs)
64 info.local_group_size(ifs_render.LOCAL_SIZE)
65 rw = {'NO_RESTRICT', 'READ', 'WRITE'}
66 info.image(0, 'R32UI', 'UINT_3D', 'volume_img', qualifiers=rw)
67 info.image(1, 'RGBA32F', 'FLOAT_2D', 'state_img', qualifiers=rw)
68 info.sampler(0, 'FLOAT_2D', 'alias_tables_tex')
69 info.push_constant('VEC3', 'volume_min')
70 info.push_constant('VEC3', 'volume_max')
71 info.push_constant('INT', 'volume_size')
72 info.uniform_buf(0, 'FrameParams', 'frame')
73 info.uniform_buf(1, 'SettingsParams', 'settings')
74 info.uniform_buf(2, 'IteratorsParams', 'iterators_buf')
75 info.uniform_buf(3, 'PaletteParams', 'palette_buf')
76 info.uniform_buf(4, 'RealParams', 'real_params_buf')
77 info.uniform_buf(5, 'Vec3Params', 'vec3_params_buf')
78 info.compute_source(body)
79 return gpu.shader.create_from_info(info), library
80
81
82def _document_buffers(doc):
83 return {
84 'settings': gpu.types.GPUUniformBuf(
85 ifs_host.pack_settings(doc.camera, doc.settings)),
86 'iterators_buf': gpu.types.GPUUniformBuf(
87 ifs_host.pack_iterators(doc.iterators)),
88 'palette_buf': gpu.types.GPUUniformBuf(
89 ifs_host.pack_palette(doc.palette(doc.settings.gradient_resolution))),
90 'real_params_buf': gpu.types.GPUUniformBuf(
91 ifs_host.pack_real_params(ifs_host.flatten_real_params(doc.iterators))),
92 'vec3_params_buf': gpu.types.GPUUniformBuf(
93 ifs_host.pack_vec3_params(ifs_host.flatten_vec3_params(doc.iterators))),
94 }
95
96
97def accumulate(shader, doc, ubos, alias_tex, size, bounds, dispatches,
98 invocation_iters=500):
99 """Runs the chaos game into a fresh 4N x N x N grid and reads it back."""
100 lo, hi = bounds
101 volume = gpu.types.GPUTexture((size * 4, size, size), format='R32UI')
102 volume.clear(format='UINT', value=(0, 0, 0, 0))
103 rows = (ifs_render.INVOCATIONS + ifs_render.STATE_W - 1) // ifs_render.STATE_W
104 state = gpu.types.GPUTexture((ifs_render.STATE_W * 2, rows), format='RGBA32F')
105 state.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0))
106
107 for index in range(dispatches):
108 frame = gpu.types.GPUUniformBuf(ifs_host.pack_frame(
109 seed=0x9E3779B9, width=size, height=size, dispatch_cnt=index,
110 reset_points_state=1 if index == 0 else 0,
111 invocation_iters=invocation_iters))
112 shader.bind()
113 shader.image('volume_img', volume)
114 shader.image('state_img', state)
115 shader.uniform_sampler('alias_tables_tex', alias_tex)
116 shader.uniform_float('volume_min', lo)
117 shader.uniform_float('volume_max', hi)
118 shader.uniform_int('volume_size', (size,))
119 shader.uniform_block('frame', frame)
120 for name, ubo in ubos.items():
121 shader.uniform_block(name, ubo)
122 gpu.compute.dispatch(shader, ifs_render.WORKGROUPS, 1, 1)
123
124 raw = np.array(volume.read(), dtype=np.uint32)
125 # (z, y, 4x) as read back; split the interleaved channels out.
126 grid = raw.reshape(size, size, size, 4).astype(np.float32) / HIST_SCALE
127 return grid
128
129
130def 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):
132 """
133 Fits the bake box to where the fractal actually is.
134
135 Two things this has to get right, and the first is easy to miss:
136
137 * **The probe box must not clip.** Fitting inside a box the fractal overflows
138 silently returns the box, not the fractal - the first version probed a fixed
139 +/-8 and reported x = +/-10 for a gasket that simply ran off the edge. The
140 probe now starts small and DOUBLES while a meaningful share of occupied
141 voxels still touch its boundary.
142 * **Fit to a percentile, not the extremes.** A chaos game throws a handful of
143 samples arbitrarily far; fitting to those spends the whole grid on empty
144 space. The outliers are excluded on purpose, so a bake does clip its faintest
145 tails - that is the trade, and it is why `bounds` can be given explicitly.
146 """
147 extent = start_extent
148 while True:
149 box = ((-extent, -extent, -extent), (extent, extent, extent))
150 coarse = accumulate(shader, doc, ubos, alias_tex, probe_size, box, dispatches)
151 density = coarse[:, :, :, 3]
152 hit = np.argwhere(density > 0)
153 if hit.size == 0:
154 if extent >= max_extent:
155 raise RuntimeError(
156 f"nothing found within +/-{extent:g}; the document may render "
157 "nothing, or need explicit bounds")
158 extent *= 2.0
159 continue
160
161 edge = ((hit == 0) | (hit == probe_size - 1)).any(axis=1).mean()
162 if edge <= 0.01 or extent >= max_extent:
163 break
164 extent *= 2.0
165
166 clipped = edge > 0.01
167 zyx_lo = np.percentile(hit, 100 - percentile, axis=0)
168 zyx_hi = np.percentile(hit, percentile, axis=0)
169 lo_n = np.array([zyx_lo[2], zyx_lo[1], zyx_lo[0]]) / probe_size
170 hi_n = np.array([zyx_hi[2], zyx_hi[1], zyx_hi[0]]) / probe_size
171
172 lo_w = -extent + lo_n * 2.0 * extent
173 hi_w = -extent + hi_n * 2.0 * extent
174 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)})
180
181
182def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"):
183 """
184 Writes density and colour grids to a `.vdb`.
185
186 Density is normalised to a 0..1 peak: absolute sample counts depend on how long
187 the bake ran, and a volume whose density scales with render time is unusable.
188 """
189 import openvdb
190
191 size = grid.shape[0]
192 lo, hi = bounds
193 voxel = (hi[0] - lo[0]) / size
194
195 density = grid[:, :, :, 3]
196 peak = float(density.max())
197 if peak <= 0:
198 raise RuntimeError("nothing was accumulated - the bounds may exclude the fractal")
199 density = np.ascontiguousarray(density / peak, dtype=np.float32)
200
201 grids = []
202 dgrid = openvdb.FloatGrid()
203 dgrid.copyFromArray(density)
204 dgrid.name = name
205 dgrid.transform = openvdb.createLinearTransform(voxelSize=voxel)
206 grids.append(dgrid)
207
208 # Colour is accumulated weighted by density, so divide it back out.
209 safe = np.maximum(grid[:, :, :, 3:4], 1e-9)
210 colour = np.ascontiguousarray(
211 np.clip(grid[:, :, :, :3] / safe, 0.0, 1.0).astype(np.float32))
212 try:
213 cgrid = openvdb.Vec3SGrid()
214 cgrid.copyFromArray(colour)
215 cgrid.name = "color"
216 cgrid.transform = openvdb.createLinearTransform(voxelSize=voxel)
217 grids.append(cgrid)
218 except Exception:
219 # A density-only volume is still useful; do not lose the bake over colour.
220 pass
221
222 openvdb.write(path, grids=grids)
223 return path, peak
224
225
226def bake(document_path, out_path, size=256, dispatches=200, bounds=None,
227 invocation_iters=500):
228 """Full bake: compile, fit bounds, accumulate, write. Returns (path, stats)."""
229 gpu.init()
230 shader, library = build_shader()
231 doc = ifs_document.load(document_path, library)
232 ubos = _document_buffers(doc)
233
234 alias = ifs_host.build_alias_tables(doc.iterators)
235 n = len(doc.iterators)
236 alias_tex = gpu.types.GPUTexture(
237 (n, n), format='RGBA32F',
238 data=gpu.types.Buffer('FLOAT', len(alias), alias))
239
240 fit = {}
241 if bounds is None:
242 lo, hi, fit = auto_bounds(shader, doc, ubos, alias_tex)
243 bounds = (lo, hi)
244
245 grid = accumulate(shader, doc, ubos, alias_tex, size, bounds, dispatches,
246 invocation_iters)
247 path, peak = write_vdb(grid, bounds, out_path)
248 occupied = int((grid[:, :, :, 3] > 0).sum())
249 return path, {
250 **fit,
251 "bounds": bounds,
252 "size": size,
253 "occupied_voxels": occupied,
254 "total_voxels": size ** 3,
255 "peak_density": peak,
256 }