| … | … | @@ -50,6 +50,7 @@ import ifs_codegen |
| 50 | 50 | import ifs_document |
| 51 | 51 | import ifs_render |
| 52 | 52 | import ifs_viewport |
| 53 | import ifs_volume | |
| 53 | 54 | |
| 54 | 55 | def _assets_root() -> str: |
| 55 | 56 | """ |
| … | … | @@ -177,6 +178,13 @@ class IFSSceneSettings(PropertyGroup): |
| 177 | 178 | name="Iterations/frame", default=400, min=10, max=20000, |
| 178 | 179 | description="Work per viewport redraw. Higher refines faster and drops the " |
| 179 | 180 | "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") | |
| 180 | 188 | iterators: CollectionProperty(type=IFSIteratorItem) |
| 181 | 189 | active_iterator: IntProperty(default=0) |
| 182 | 190 | status: StringProperty(default="") |
| … | … | @@ -306,6 +314,43 @@ def _redraw(context): |
| 306 | 314 | area.tag_redraw() |
| 307 | 315 | |
| 308 | 316 | |
| 317 | class 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 | ||
| 309 | 354 | class IFS_OT_render(Operator): |
| 310 | 355 | """Render the loaded fractal and show it in the Image Editor""" |
| 311 | 356 | |
| … | … | @@ -413,6 +458,13 @@ class IFS_PT_main(Panel): |
| 413 | 458 | if running: |
| 414 | 459 | box.label(text=ifs_viewport.status(), icon='INFO') |
| 415 | 460 | |
| 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 | ||
| 416 | 468 | |
| 417 | 469 | class IFS_PT_iterators(Panel): |
| 418 | 470 | bl_label = "Iterators" |
| … | … | @@ -470,6 +522,7 @@ _CLASSES = ( |
| 470 | 522 | IFS_OT_load_template, |
| 471 | 523 | IFS_OT_render, |
| 472 | 524 | IFS_OT_viewport, |
| 525 | IFS_OT_bake_volume, | |
| 473 | 526 | IFS_UL_iterators, |
| 474 | 527 | IFS_PT_main, |
| 475 | 528 | IFS_PT_iterators, |
| … | … | @@ -39,6 +39,7 @@ RUNTIME_FILES = ( |
| 39 | 39 | "ifs_document.py", |
| 40 | 40 | "ifs_render.py", |
| 41 | 41 | "ifs_viewport.py", |
| 42 | "ifs_volume.py", | |
| 42 | 43 | ) |
| 43 | 44 | |
| 44 | 45 |
| … | … | @@ -253,6 +253,58 @@ def extract_ubos(source: str) -> tuple[str, str, str]: |
| 253 | 253 | return out, "\n\n".join(typedefs), "\n".join(defines) |
| 254 | 254 | |
| 255 | 255 | |
| 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. | |
| 279 | void 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 | ||
| 294 | VOLUME_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 | ||
| 256 | 308 | @dataclass |
| 257 | 309 | class RewriteReport: |
| 258 | 310 | """What the rewrite did, so a silent no-op is impossible to miss.""" |
| … | … | @@ -266,7 +318,8 @@ class RewriteReport: |
| 266 | 318 | |
| 267 | 319 | |
| 268 | 320 | def 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]: | |
| 270 | 323 | """ |
| 271 | 324 | Rewrites an engine kernel onto the Blender resource model. |
| 272 | 325 | |
| … | … | @@ -300,7 +353,13 @@ def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE, |
| 300 | 353 | out = sub(_LOOSE_UNIFORM, "", "drop loose uniforms (moved to frame_ubo)", out, expected=6) |
| 301 | 354 | |
| 302 | 355 | # 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) | |
| 304 | 363 | out = sub(re.compile(r"\bp\s*=\s*state\[gid\]\s*;"), "p = load_state(gid);", |
| 305 | 364 | "rewrite state load", out) |
| 306 | 365 | out = sub(re.compile(r"\bstate\[gid\]\s*=\s*p\s*;"), "store_state(gid, p);", |
| … | … | @@ -284,9 +284,10 @@ class Session: |
| 284 | 284 | (n, n), format='RGBA32F', |
| 285 | 285 | data=gpu.types.Buffer('FLOAT', len(alias), alias)) |
| 286 | 286 | |
| 287 | def _resize(self, width, height): | |
| 287 | def _resize(self, width, height) -> bool: | |
| 288 | """Returns True if the buffers were rebuilt (which already invalidated).""" | |
| 288 | 289 | if (width, height) == (self.width, self.height): |
| 289 | return | |
| 290 | return False | |
| 290 | 291 | self.width, self.height = width, height |
| 291 | 292 | self.histogram = gpu.types.GPUTexture((width * 4, height), format='R32UI') |
| 292 | 293 | self.accum = gpu.types.GPUTexture((width, height), format='RGBA32F') |
| … | … | @@ -295,6 +296,7 @@ class Session: |
| 295 | 296 | self.state = gpu.types.GPUTexture( |
| 296 | 297 | (ifs_render.STATE_W * 2, rows), format='RGBA32F') |
| 297 | 298 | self.invalidate() |
| 299 | return True | |
| 298 | 300 | |
| 299 | 301 | def invalidate(self): |
| 300 | 302 | """Discards accumulated samples. Called when the viewpoint changes.""" |
| … | … | @@ -352,20 +354,38 @@ class Session: |
| 352 | 354 | |
| 353 | 355 | # ---------------------------------------------------------------------- draw |
| 354 | 356 | |
| 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 | ||
| 355 | 367 | def draw(self, context): |
| 356 | 368 | settings = getattr(context.scene, "ifs", None) |
| 357 | 369 | if settings is not None and settings.iterators: |
| 358 | 370 | self.sync_from_scene(settings) |
| 359 | 371 | self._rebuild_dirty() |
| 360 | 372 | |
| 361 | region = context.region | |
| 362 | self._resize(max(region.width // 2, 16), max(region.height // 2, 16)) | |
| 363 | ||
| 364 | 373 | space = context.space_data |
| 365 | 374 | 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: | |
| 367 | 385 | self.last_view = view_proj |
| 368 | 386 | self.invalidate() |
| 387 | elif moved: | |
| 388 | self.last_view = view_proj | |
| 369 | 389 | |
| 370 | 390 | camera = self.doc.camera |
| 371 | 391 | camera.view_proj = view_proj |
| … | … | @@ -0,0 +1,256 @@ |
| 1 | """ | |
| 2 | Bakes a fractal into an OpenVDB volume, so Cycles renders it like any other data. | |
| 3 | ||
| 4 | The live viewport is a screen-space overlay: no depth, no lighting, invisible to | |
| 5 | F12. This is the other thing entirely — the chaos game accumulates into a 3D voxel | |
| 6 | grid instead of a 2D histogram, the grid is written as `.vdb`, and Blender imports | |
| 7 | it as a Volume object. From there it lights, occludes, motion-blurs and composites | |
| 8 | like any other volume. | |
| 9 | ||
| 10 | Blender ships `openvdb` in its Python (renamed from `pyopenvdb`), so no external | |
| 11 | dependency is needed. | |
| 12 | ||
| 13 | ## Bounds | |
| 14 | ||
| 15 | A chaos game is unbounded — a few samples fly arbitrarily far — so a bake needs a | |
| 16 | box, and a box that is too big wastes resolution on empty space while one too | |
| 17 | small clips the fractal. `auto_bounds` runs a cheap coarse pass first, finds the | |
| 18 | voxels that actually got hit, and fits the real bake to that with a margin. It is | |
| 19 | a percentile rather than the extremes, because the extremes are exactly the | |
| 20 | handful of outliers that would blow the box back up. | |
| 21 | ||
| 22 | ## Memory | |
| 23 | ||
| 24 | The grid is 4N x N x N uint32 — four contiguous channels per voxel, mirroring the | |
| 25 | 2D histogram's interleaving for the same coalescing reason. At N=256 that is | |
| 26 | 268 MB on the GPU; N=512 is 2.1 GB and will not fit alongside anything else on a | |
| 27 | consumer card. 256 is the practical default. | |
| 28 | """ | |
| 29 | ||
| 30 | from __future__ import annotations | |
| 31 | ||
| 32 | import os | |
| 33 | ||
| 34 | import gpu | |
| 35 | import numpy as np | |
| 36 | ||
| 37 | import ifs_codegen | |
| 38 | import ifs_document | |
| 39 | import ifs_host | |
| 40 | import ifs_layout | |
| 41 | import ifs_render | |
| 42 | ||
| 43 | HIST_SCALE = ifs_layout.DEFAULT_HIST_SCALE | |
| 44 | ||
| 45 | ||
| 46 | ||
| 47 | def 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 | ||
| 82 | def _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 | ||
| 97 | def 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 | ||
| 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): | |
| 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 | ||
| 182 | def 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 | ||
| 226 | def 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 | } |