OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / d1c92e6

Exposure is derived from the bake instead of a constant I tuned once
The emission scale was hardcoded to 2000, tuned against a single nebula bake. It cannot be a constant: measured across three shipped templates the required scale spans 157x - nebula 60, the planar gasket 3, plastic 471 - because peak-normalised density depends entirely on how long-tailed that particular fractal's histogram is. A number that suits one document blows another to white, which is what a real bake did. The statistic is what the volume integrator actually accumulates: density summed along a ray. Sum the columns, average over the ones containing anything, and scale so a typical ray integrates to ~1. Averaged over ALL THREE axes. The first version summed one fixed axis, which for a planar fractal is the thin direction - it reported the shortest possible path and derived an exposure far too dark. The camera can look from anywhere, so the metric has to be view-independent. That flaw was caught by the gasket rendering nothing, not by reasoning. The Emission setting becomes a MULTIPLIER on the derived value (default 1.0) rather than an absolute, so it adapts per document and stays tunable. VERIFIED for nebula_sample: derived scale 60, well exposed, 0.5% of lit pixels blown, and it reads as a nebula with a core and wisps rather than the glowing ball a constant produced. NOT VERIFIED for the planar gasket. Edge-on it rendered 111 lit pixels and face-on zero, which is contradictory and I did not isolate why - it may be that the bake is nearly empty at size 96 (6016 occupied voxels) or that my camera was wrong. This commit is a strict improvement over a constant, but the planar case is open and probably belongs with the same "planar fractals are a special case" thread that auto_bounds already needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d1c92e6497e3192c8014b25683bd1777898a8f75
by plankatron <fleeky@gmail.com> · 2026-08-17T23:22:18.000Z

Changed files (2) +36 -7

blender/__init__.py +9 -7
……@@ -185,12 +185,12 @@ class IFSSceneSettings(PropertyGroup):
185185 "256 is ~268MB, 512 is ~2.1GB and will not fit")
186186 volume_dispatches: IntProperty(name="Dispatches", default=200, min=1, max=100000)
187187 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)
188 name="Emission x",
189 description=("Multiplies the exposure derived from the bake. The scale itself is "
190 "computed from the fractal's own density along a ray, because the "
191 "figure differs by ~200x between documents - a fixed number blows one "
192 "to white and leaves another invisible. 1.0 is the derived exposure"),
193 default=1.0, min=0.0, soft_max=100.0)
194194 volume_path: StringProperty(
195195 name="VDB", subtype='FILE_PATH', default="//fractal.vdb")
196196 iterators: CollectionProperty(type=IFSIteratorItem)
……@@ -412,7 +412,9 @@ class IFS_OT_bake_volume(Operator):
412412 # context.object came back None at larger file sizes, so find it by type.
413413 imported = [o for o in bpy.context.scene.objects if o.type == 'VOLUME']
414414 if imported:
415 _assign_volume_material(imported[-1], settings.volume_emission)
415 _assign_volume_material(
416 imported[-1],
417 stats.get("emission_scale", 1.0) * settings.volume_emission)
416418 occupied = stats["occupied_voxels"]
417419 # Says WHICH document was baked: the difference between the live edits and the
418420 # file on disk is exactly what used to be invisible.
blender/ifs_volume.py +27 -0
……@@ -367,6 +367,31 @@ def bake_document(doc, out_path, size=256, dispatches=200, bounds=None,
367367 invocation_iters)
368368 path, peak = write_vdb(grid, bounds, out_path, gamma=gamma)
369369 occupied = int((grid[:, :, :, 3] > 0).sum())
370
371 # How bright to drive emission, DERIVED rather than guessed. A fixed number cannot
372 # work: measured across two shipped templates the required scale differs by ~200x
373 # (nebula wants ~1100, the gasket ~5), because peak-normalised density depends
374 # entirely on how long-tailed that particular fractal's histogram is. A constant
375 # tuned on one document blows the other to white, which is exactly what happened.
376 #
377 # The statistic is what the renderer actually integrates: density summed ALONG A
378 # RAY. Sum each column, average over the columns that contain anything, and scale
379 # so a typical ray through the fractal integrates to ~1. Not a heuristic - it is
380 # the quantity the volume integrator accumulates.
381 # Averaged over ALL THREE axes, not one. Summing a single axis measures the path
382 # a ray takes along that axis only, and for a PLANAR fractal - the gasket is two
383 # probe voxels thick - the fixed axis is the thin direction, so the statistic
384 # reports the shortest possible path and derives an exposure far too dark. The
385 # camera can look from anywhere, so the metric has to be view-independent.
386 normalised = grid[:, :, :, 3] / peak if peak > 0 else grid[:, :, :, 3]
387 per_axis = []
388 for axis in range(3):
389 columns = normalised.sum(axis=axis)
390 lit_columns = columns[columns > 0]
391 if lit_columns.size:
392 per_axis.append(float(lit_columns.mean()))
393 mean_column = float(np.mean(per_axis)) if per_axis else 0.0
394 emission_scale = (1.0 / mean_column) if mean_column > 0 else 1.0
370395 return path, {
371396 **fit,
372397 "bounds": bounds,
……@@ -374,6 +399,8 @@ def bake_document(doc, out_path, size=256, dispatches=200, bounds=None,
374399 "occupied_voxels": occupied,
375400 "total_voxels": size ** 3,
376401 "peak_density": peak,
402 "emission_scale": emission_scale,
403 "mean_column_density": mean_column,
377404 }
378405
379406