OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 657c2d6

The baked volume rendered as a featureless glowing ball
Principled Volume's Emission Strength does NOT scale with density. The imported volume had no material at all, and every material I reached for set Strength to a constant - so every voxel inside the volume emitted equally and the fractal, which lives entirely in the density grid, was discarded. A uniformly glowing blob is the exact expected output of that. The tell was in the numbers before it was in an image: three renders at material density 20, 1.0 and 0.2, across two different grids, returned byte-identical statistics (lit=33636, mean=0.277). Nothing that responds to nothing was ever being shaded. Driving Emission Strength from the density grid moves the same scene to lit=7996, mean=0.021, and the render becomes a nebula with a bright core, wisps and scattered points instead of a ball. Imports now get a material: density -> Emission Strength through a scale node, colour -> Emission Color, scattering density 0 because a fractal flame is emission rather than smoke. The scale is exposed (default 2000, tuned on nebula_sample) since it depends on the document's density distribution. The imported object is found by type rather than bpy.context.object, which came back None at larger file sizes. GAMMA IS BACK TO 1.0. I added it earlier believing the crushed density distribution was the cause - the measurement was real (median 2e-5 of peak) but it was not the problem, and lifting the faint bulk toward uniform moves AWAY from the sparse, mostly-black look the still render has. The parameter stays, with the comment rewritten to say what it does not fix, so nobody reaches for it against a blob again. Node graphs bind lazily. bind_tree ran ONCE, in start(), against whatever trees existed then - so building the graph after starting the viewport, which is the obvious order because the panel is right there, left tree_name None forever. The depsgraph handler could not rescue it either: its first line returned when tree_name was None. resolve_tree() now runs in draw() and at the top of that handler, and the bake calls apply_pending_graph() first so an edit cannot be one redraw stale. Verified through the add-on's own _assign_volume_material rather than a bespoke setup: emission strength linked via ShaderNodeMath, colour linked, scatter density 0, both grids used, and the render reproduces lit=14752 mean=0.0559 - the same figures as the hand-built material that produced the good image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
657c2d69a76297cf30d5737cfa86b3749efadb59
by plankatron <fleeky@gmail.com> · 2026-08-17T21:48:26.000Z

Changed files (3) +121 -8

blender/__init__.py +59 -1
……@@ -184,6 +184,13 @@ class IFSSceneSettings(PropertyGroup):
184184 description="Grid is 4N x N x N uint32 on the GPU: 192 is ~113MB, "
185185 "256 is ~268MB, 512 is ~2.1GB and will not fit")
186186 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)
187194 volume_path: StringProperty(
188195 name="VDB", subtype='FILE_PATH', default="//fractal.vdb")
189196 iterators: CollectionProperty(type=IFSIteratorItem)
……@@ -315,6 +322,48 @@ def _redraw(context):
315322 area.tag_redraw()
316323
317324
325def _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
318367class IFS_OT_bake_volume(Operator):
319368 """Bake the fractal to an OpenVDB volume and import it into the scene"""
320369
……@@ -329,7 +378,12 @@ class IFS_OT_bake_volume(Operator):
329378 # live session's document, and baking from the file threw all of that away - so
330379 # a bake could only ever reproduce the .ifsjson, and every edit made in Blender
331380 # 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
333387 if live is None and (not path or not os.path.isfile(path)):
334388 self.report({'ERROR'},
335389 "Start the live viewport, or pick an .ifsjson file first")
……@@ -355,6 +409,10 @@ class IFS_OT_bake_volume(Operator):
355409 return {'CANCELLED'}
356410
357411 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)
358416 occupied = stats["occupied_voxels"]
359417 # Says WHICH document was baked: the difference between the live edits and the
360418 # file on disk is exactly what used to be invisible.
blender/ifs_viewport.py +36 -1
……@@ -232,6 +232,35 @@ class Session:
232232 self.tree_name = name
233233 self._dirty_graph = name is not None
234234
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
235264 def mark_graph_dirty(self):
236265 self._dirty_graph = True
237266
……@@ -438,6 +467,7 @@ class Session:
438467 settings = getattr(context.scene, "ifs", None)
439468 if settings is not None and settings.iterators:
440469 self.sync_from_scene(settings)
470 self.resolve_tree()
441471 self._apply_graph()
442472 self._rebuild_dirty()
443473
……@@ -576,7 +606,12 @@ def stop():
576606@bpy.app.handlers.persistent
577607def _on_depsgraph_update(scene, depsgraph):
578608 """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:
580615 return
581616 for update in depsgraph.updates:
582617 if getattr(update.id, "name", None) == _session.tree_name:
blender/ifs_volume.py +26 -6
……@@ -234,7 +234,8 @@ def _fit(hit, box, probe_size, percentile, margin):
234234 return lo_w - span * margin, hi_w + span * margin
235235
236236
237def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"):
237def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density",
238 gamma: float = 1.0):
238239 """
239240 Writes density and colour grids to a `.vdb`.
240241
……@@ -275,12 +276,31 @@ def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"):
275276 # notices; it shows only as a bake whose orientation disagrees with the still
276277 # render. `auto_bounds` above already does this reorder (its `zyx_*` locals), so
277278 # 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.
278295 density = grid[:, :, :, 3]
279296 peak = float(density.max())
280297 if peak <= 0:
281298 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)
282302 density = np.ascontiguousarray(
283 (density / peak).transpose(2, 1, 0), dtype=np.float32)
303 normalised.transpose(2, 1, 0), dtype=np.float32)
284304
285305 grids = []
286306 dgrid = openvdb.FloatGrid()
……@@ -312,7 +332,7 @@ def write_vdb(grid: np.ndarray, bounds, path: str, name: str = "density"):
312332
313333
314334def 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):
316336 """
317337 Bakes a document that is ALREADY loaded, which is what makes the bake follow what
318338 you are looking at.
……@@ -345,7 +365,7 @@ def bake_document(doc, out_path, size=256, dispatches=200, bounds=None,
345365
346366 grid = accumulate(shader, doc, ubos, alias_tex, size, bounds, dispatches,
347367 invocation_iters)
348 path, peak = write_vdb(grid, bounds, out_path)
368 path, peak = write_vdb(grid, bounds, out_path, gamma=gamma)
349369 occupied = int((grid[:, :, :, 3] > 0).sum())
350370 return path, {
351371 **fit,
……@@ -358,7 +378,7 @@ def bake_document(doc, out_path, size=256, dispatches=200, bounds=None,
358378
359379
360380def bake(document_path, out_path, size=256, dispatches=200, bounds=None,
361 invocation_iters=500):
381 invocation_iters=500, gamma=1.0):
362382 """
363383 Loads a document from disk and bakes it. Returns (path, stats).
364384
……@@ -370,4 +390,4 @@ def bake(document_path, out_path, size=256, dispatches=200, bounds=None,
370390 shader, library = build_shader()
371391 doc = ifs_document.load(document_path, library)
372392 return bake_document(doc, out_path, size, dispatches, bounds,
373 invocation_iters, shader=shader)
393 invocation_iters, shader=shader, gamma=gamma)