| … | … | @@ -184,6 +184,13 @@ class IFSSceneSettings(PropertyGroup): |
| 184 | 184 | description="Grid is 4N x N x N uint32 on the GPU: 192 is ~113MB, " |
| 185 | 185 | "256 is ~268MB, 512 is ~2.1GB and will not fit") |
| 186 | 186 | volume_dispatches: IntProperty(name="Dispatches", default=200, min=1, max=100000) |
| 187 | volume_emission: FloatProperty( | |
| 188 | name="Emission", | |
| 189 | description=("Scales the density grid into emission. A flame is emissive, and " | |
| 190 | "Principled Volume's Emission Strength does NOT scale with density, " | |
| 191 | "so the grid has to drive it explicitly or every voxel emits equally " | |
| 192 | "and the fractal renders as a featureless blob"), | |
| 193 | default=2000.0, min=0.0, soft_max=100000.0) | |
| 187 | 194 | volume_path: StringProperty( |
| 188 | 195 | name="VDB", subtype='FILE_PATH', default="//fractal.vdb") |
| 189 | 196 | iterators: CollectionProperty(type=IFSIteratorItem) |
| … | … | @@ -315,6 +322,48 @@ def _redraw(context): |
| 315 | 322 | area.tag_redraw() |
| 316 | 323 | |
| 317 | 324 | |
| 325 | def _assign_volume_material(obj, emission_scale): | |
| 326 | """ | |
| 327 | Gives the imported volume a material driven by the baked grids. | |
| 328 | ||
| 329 | Without this the object has NO material at all, and a volume with no material is | |
| 330 | very nearly invisible - measured, Cycles renders it at background brightness. | |
| 331 | ||
| 332 | The load-bearing part is that DENSITY drives Emission Strength. Principled Volume's | |
| 333 | emission does not scale with density, so a constant Strength makes every voxel emit | |
| 334 | equally and discards the entire fractal, which lives in the density grid. That is | |
| 335 | what produced a uniformly glowing ball; three renders across a 100x density change | |
| 336 | came back byte-identical because nothing was responding to anything. | |
| 337 | ||
| 338 | Scattering density is 0: a fractal flame is emission, not smoke. The colour grid | |
| 339 | tints the emission, which is what carries the palette. | |
| 340 | """ | |
| 341 | material = bpy.data.materials.new("IFS Volume") | |
| 342 | material.use_nodes = True | |
| 343 | tree = material.node_tree | |
| 344 | tree.nodes.clear() | |
| 345 | ||
| 346 | output = tree.nodes.new('ShaderNodeOutputMaterial') | |
| 347 | volume = tree.nodes.new('ShaderNodeVolumePrincipled') | |
| 348 | volume.inputs['Density'].default_value = 0.0 | |
| 349 | ||
| 350 | colour = tree.nodes.new('ShaderNodeAttribute') | |
| 351 | colour.attribute_name = "color" | |
| 352 | tree.links.new(colour.outputs['Color'], volume.inputs['Emission Color']) | |
| 353 | ||
| 354 | density = tree.nodes.new('ShaderNodeAttribute') | |
| 355 | density.attribute_name = "density" | |
| 356 | scale = tree.nodes.new('ShaderNodeMath') | |
| 357 | scale.operation = 'MULTIPLY' | |
| 358 | scale.inputs[1].default_value = emission_scale | |
| 359 | tree.links.new(density.outputs['Fac'], scale.inputs[0]) | |
| 360 | tree.links.new(scale.outputs[0], volume.inputs['Emission Strength']) | |
| 361 | ||
| 362 | tree.links.new(volume.outputs[0], output.inputs['Volume']) | |
| 363 | obj.data.materials.append(material) | |
| 364 | return material | |
| 365 | ||
| 366 | ||
| 318 | 367 | class IFS_OT_bake_volume(Operator): |
| 319 | 368 | """Bake the fractal to an OpenVDB volume and import it into the scene""" |
| 320 | 369 | |
| … | … | @@ -329,7 +378,12 @@ class IFS_OT_bake_volume(Operator): |
| 329 | 378 | # live session's document, and baking from the file threw all of that away - so |
| 330 | 379 | # a bake could only ever reproduce the .ifsjson, and every edit made in Blender |
| 331 | 380 | # was silently ignored. |
| 332 | live = ifs_viewport.session().doc if ifs_viewport.is_running() else None | |
| 381 | live = None | |
| 382 | if ifs_viewport.is_running(): | |
| 383 | # Flush any pending graph edit before reading the document, or the bake | |
| 384 | # gets the state from before the last node change. | |
| 385 | ifs_viewport.session().apply_pending_graph() | |
| 386 | live = ifs_viewport.session().doc | |
| 333 | 387 | if live is None and (not path or not os.path.isfile(path)): |
| 334 | 388 | self.report({'ERROR'}, |
| 335 | 389 | "Start the live viewport, or pick an .ifsjson file first") |
| … | … | @@ -355,6 +409,10 @@ class IFS_OT_bake_volume(Operator): |
| 355 | 409 | return {'CANCELLED'} |
| 356 | 410 | |
| 357 | 411 | bpy.ops.object.volume_import(filepath=written) |
| 412 | # context.object came back None at larger file sizes, so find it by type. | |
| 413 | imported = [o for o in bpy.context.scene.objects if o.type == 'VOLUME'] | |
| 414 | if imported: | |
| 415 | _assign_volume_material(imported[-1], settings.volume_emission) | |
| 358 | 416 | occupied = stats["occupied_voxels"] |
| 359 | 417 | # Says WHICH document was baked: the difference between the live edits and the |
| 360 | 418 | # file on disk is exactly what used to be invisible. |
| … | … | @@ -232,6 +232,35 @@ class Session: |
| 232 | 232 | self.tree_name = name |
| 233 | 233 | self._dirty_graph = name is not None |
| 234 | 234 | |
| 235 | def resolve_tree(self): | |
| 236 | """ | |
| 237 | Binds an IFS tree if none is bound yet. | |
| 238 | ||
| 239 | The binding used to happen ONCE, in `start`, against whatever trees already | |
| 240 | existed. Build the graph after starting the viewport - the obvious order, since | |
| 241 | the panel is right there - and `tree_name` stayed None forever, so the graph | |
| 242 | drove nothing and the depsgraph handler could not rescue it either: its first | |
| 243 | line returns when `tree_name is None`. Resolving lazily means the graph is | |
| 244 | picked up whenever it appears. | |
| 245 | """ | |
| 246 | if self.tree_name is not None: | |
| 247 | return | |
| 248 | for tree in bpy.data.node_groups: | |
| 249 | if tree.bl_idname == 'IFSNodeTree': | |
| 250 | self.bind_tree(tree.name) | |
| 251 | return | |
| 252 | ||
| 253 | def apply_pending_graph(self): | |
| 254 | """ | |
| 255 | Flushes a pending graph edit into the document. | |
| 256 | ||
| 257 | `_apply_graph` otherwise runs only inside `draw`, so a bake triggered before the | |
| 258 | next redraw would read a document one edit stale - the graph would look bound | |
| 259 | and still not reach the volume. | |
| 260 | """ | |
| 261 | self.resolve_tree() | |
| 262 | self._apply_graph() | |
| 263 | ||
| 235 | 264 | def mark_graph_dirty(self): |
| 236 | 265 | self._dirty_graph = True |
| 237 | 266 | |
| … | … | @@ -438,6 +467,7 @@ class Session: |
| 438 | 467 | settings = getattr(context.scene, "ifs", None) |
| 439 | 468 | if settings is not None and settings.iterators: |
| 440 | 469 | self.sync_from_scene(settings) |
| 470 | self.resolve_tree() | |
| 441 | 471 | self._apply_graph() |
| 442 | 472 | self._rebuild_dirty() |
| 443 | 473 | |
| … | … | @@ -576,7 +606,12 @@ def stop(): |
| 576 | 606 | @bpy.app.handlers.persistent |
| 577 | 607 | def _on_depsgraph_update(scene, depsgraph): |
| 578 | 608 | """Flags the session when its bound node tree is edited.""" |
| 579 | if _session is None or _session.tree_name is None: | |
| 609 | if _session is None: | |
| 610 | return | |
| 611 | # Resolve FIRST: a tree created after the session started is exactly the case the | |
| 612 | # old early-return made permanently invisible. | |
| 613 | _session.resolve_tree() | |
| 614 | if _session.tree_name is None: | |
| 580 | 615 | return |
| 581 | 616 | for update in depsgraph.updates: |
| 582 | 617 | if getattr(update.id, "name", None) == _session.tree_name: |
| … | … | @@ -234,7 +234,8 @@ def _fit(hit, box, probe_size, percentile, margin): |
| 234 | 234 | return lo_w - span * margin, hi_w + span * margin |
| 235 | 235 | |
| 236 | 236 | |
| 237 | def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"): | |
| 237 | def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density", | |
| 238 | gamma: float = 1.0): | |
| 238 | 239 | """ |
| 239 | 240 | Writes density and colour grids to a `.vdb`. |
| 240 | 241 | |
| … | … | @@ -275,12 +276,31 @@ def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"): |
| 275 | 276 | # notices; it shows only as a bake whose orientation disagrees with the still |
| 276 | 277 | # render. `auto_bounds` above already does this reorder (its `zyx_*` locals), so |
| 277 | 278 | # before this the same file computed correct bounds around transposed data. |
| 279 | # GAMMA, off by default. A chaos game's density histogram is | |
| 280 | # violently long-tailed: measured on nebula_sample the median occupied voxel sits at | |
| 281 | # 2e-5 of the peak and the 90th percentile at 3e-4, so dividing by the max alone | |
| 282 | # crushes the entire fractal to zero and leaves a couple of voxels at 1.0. There is | |
| 283 | # gamma=4 lifts that median to 0.063 and the 90th to 0.136. It is OFF by default | |
| 284 | # because it does not fix what it looks like it should: the featureless blob was | |
| 285 | # Principled Volume's Emission Strength not scaling with density, so every voxel | |
| 286 | # emitted equally regardless of the histogram. Lifting the faint bulk toward uniform | |
| 287 | # actually moves AWAY from the sparse, mostly-black look the still render has. Kept | |
| 288 | # as a knob for deliberately hazier bakes, not as a fix. (A log curve is no use here | |
| 289 | # either: log(1+x) ~ x for small x, so it compresses the top rather than lifting the | |
| 290 | # bottom.) | |
| 291 | # | |
| 292 | # Applied to density ONLY, and only after colour has been divided by the ORIGINAL | |
| 293 | # density below - colour is accumulated weighted by density, so dividing it by a | |
| 294 | # gamma-lifted density collapses it to black. That mistake renders an empty volume. | |
| 278 | 295 | density = grid[:, :, :, 3] |
| 279 | 296 | peak = float(density.max()) |
| 280 | 297 | if peak <= 0: |
| 281 | 298 | raise RuntimeError("nothing was accumulated - the bounds may exclude the fractal") |
| 299 | normalised = density / peak | |
| 300 | if gamma and gamma != 1.0: | |
| 301 | normalised = np.power(normalised, 1.0 / gamma) | |
| 282 | 302 | density = np.ascontiguousarray( |
| 283 | (density / peak).transpose(2, 1, 0), dtype=np.float32) | |
| 303 | normalised.transpose(2, 1, 0), dtype=np.float32) | |
| 284 | 304 | |
| 285 | 305 | grids = [] |
| 286 | 306 | dgrid = openvdb.FloatGrid() |
| … | … | @@ -312,7 +332,7 @@ def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"): |
| 312 | 332 | |
| 313 | 333 | |
| 314 | 334 | def bake_document(doc, out_path, size=256, dispatches=200, bounds=None, |
| 315 | invocation_iters=500, shader=None): | |
| 335 | invocation_iters=500, shader=None, gamma=1.0): | |
| 316 | 336 | """ |
| 317 | 337 | Bakes a document that is ALREADY loaded, which is what makes the bake follow what |
| 318 | 338 | you are looking at. |
| … | … | @@ -345,7 +365,7 @@ def bake_document(doc, out_path, size=256, dispatches=200, bounds=None, |
| 345 | 365 | |
| 346 | 366 | grid = accumulate(shader, doc, ubos, alias_tex, size, bounds, dispatches, |
| 347 | 367 | invocation_iters) |
| 348 | path, peak = write_vdb(grid, bounds, out_path) | |
| 368 | path, peak = write_vdb(grid, bounds, out_path, gamma=gamma) | |
| 349 | 369 | occupied = int((grid[:, :, :, 3] > 0).sum()) |
| 350 | 370 | return path, { |
| 351 | 371 | **fit, |
| … | … | @@ -358,7 +378,7 @@ def bake_document(doc, out_path, size=256, dispatches=200, bounds=None, |
| 358 | 378 | |
| 359 | 379 | |
| 360 | 380 | def bake(document_path, out_path, size=256, dispatches=200, bounds=None, |
| 361 | invocation_iters=500): | |
| 381 | invocation_iters=500, gamma=1.0): | |
| 362 | 382 | """ |
| 363 | 383 | Loads a document from disk and bakes it. Returns (path, stats). |
| 364 | 384 | |
| … | … | @@ -370,4 +390,4 @@ def bake(document_path, out_path, size=256, dispatches=200, bounds=None, |
| 370 | 390 | shader, library = build_shader() |
| 371 | 391 | doc = ifs_document.load(document_path, library) |
| 372 | 392 | return bake_document(doc, out_path, size, dispatches, bounds, |
| 373 | invocation_iters, shader=shader) | |
| 393 | invocation_iters, shader=shader, gamma=gamma) |