OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / b8571a2

Drop the viewport splat toggle — the histogram wins by 655x
Measured: the histogram plots 6,553,600 samples per frame and accumulates without bound (655 million after 100 frames); the splat path emitted a fixed 1,000,000 and redrew them every frame. The splat viewport therefore showed ~0.15% of the samples the histogram held after two seconds, which is why it read as fuzzy dots at any radius, and why calibrating the radius up ~8x (≈64x covered area) bought a performance regression and no structure. A preview competing with one that is better informed by 655x and cheaper per frame is not worth keeping, so the toggle, the session state, the lazy emit and the draw branch are gone. ifs_splat is KEPT and still tested (10/10). It is the foundation for the F12 composite and for writing authored splats out — the wrong renderer for a preview, the right one for getting the fractal somewhere Blender's own pipeline cannot. Two findings from the exercise survive in NOTES-splat-rendering.md: isotropic + emissive deletes most of a 3DGS renderer (no covariance projection, no depth sort), and the tonemap cannot live in a material because auto-exposure needs the whole-image maximum. Panel is simpler for it: Live Viewport is back to one shading control and iterations, and points_capacity/points_radius have a single unambiguous home. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b8571a220f329991b6aaf2c995ac17983ed15e8e
by plankatron <fleeky@gmail.com> · 2026-08-18T15:37:37.000Z

Changed files (2) +1 -150

blender/__init__.py +1 -21
……@@ -50,7 +50,6 @@ import ifs_codegen
5050import ifs_document
5151import ifs_points
5252import ifs_render
53import ifs_splat
5453import ifs_viewport
5554import ifs_volume
5655import ifs_nodes
……@@ -210,13 +209,6 @@ class IFSSceneSettings(PropertyGroup):
210209 "nearest-neighbour spacing, so 1.0 makes neighbouring points "
211210 "just touch. How big a point should look also depends on how "
212211 "close the camera is, which is why this exists")
213 viewport_splats: BoolProperty(
214 name="Gaussian splats",
215 description="Draw the live viewport as gaussian splats instead of the "
216 "accumulating histogram. Emits the points once, then renders "
217 "them as camera-facing gaussians, added together. No depth "
218 "sort is needed because emission commutes",
219 default=False, update=_touch('splats'))
220212 points_emission: FloatProperty(
221213 name="Emission", default=1.0, min=0.0, soft_max=100.0,
222214 description="Emission strength of the point material. Unlike the volume's "
……@@ -651,17 +643,7 @@ class IFS_PT_main(Panel):
651643 box.prop(settings, "opacity_gain")
652644 if settings.shading_mode == 'METAL':
653645 box.prop(settings, "relief")
654 box.prop(settings, "viewport_splats")
655 # `points_capacity` and `points_radius` are ONE setting shared with the
656 # Point Cloud section below. Drawing them in both places meant changing
657 # either silently changed the other, with nothing on screen saying so.
658 # Named here, owned there.
659 sub = box.column(align=True)
660 sub.enabled = not settings.viewport_splats
661 sub.prop(settings, "iters_per_frame")
662 if settings.viewport_splats:
663 box.label(text="Uses Points / Radius from Point Cloud below",
664 icon='INFO')
646 box.prop(settings, "iters_per_frame")
665647 running = ifs_viewport.is_running()
666648 box.operator("ifs.toggle_viewport",
667649 text="Stop" if running else "Start",
……@@ -679,8 +661,6 @@ class IFS_PT_main(Panel):
679661
680662 box = layout.box()
681663 box.label(text="Point Cloud (Cycles and EEVEE)")
682 if settings.viewport_splats:
683 box.label(text="Also drives the splat viewport", icon='INFO')
684664 box.prop(settings, "points_capacity")
685665 box.prop(settings, "points_radius")
686666 box.prop(settings, "points_emission")
blender/ifs_viewport.py +0 -129
……@@ -212,18 +212,6 @@ class Session:
212212 self.opacity_gain = 4.0
213213 self.relief = 12.0
214214
215 # Gaussian-splat preview. An ALTERNATIVE to the histogram path rather than
216 # a stage of it: the histogram accumulates progressively into a 2D image,
217 # splats draw the emitted points as geometry. Nothing here is built until
218 # the mode is switched on, because emitting the points costs a full GPU
219 # pass and most sessions never ask for it.
220 self.splats_enabled = False
221 self.splat_shader = None # the draw shader (ifs_splat)
222 self.splat_emit_shader = None # the points kernel, cached across re-emits
223 self.splat_cloud = None
224 self.splat_stats = None
225 self.splat_capacity = 1_000_000
226 self.splat_radius_scale = 1.0
227215
228216 # What needs rebuilding before the next dispatch. Property callbacks fire
229217 # continuously while a slider is dragged, so they only set flags here and
……@@ -293,28 +281,11 @@ class Session:
293281 if kind == 'weights':
294282 self._dirty_weights = True
295283 self._dirty_params = True
296 self.drop_splats()
297284 elif kind == 'params':
298285 self._dirty_params = True
299 self.drop_splats()
300 elif kind == 'splats':
301 self.drop_splats()
302286 elif kind != 'tonemap':
303287 raise ValueError(f"unknown dirty kind {kind!r}")
304288
305 def drop_splats(self):
306 """
307 Discards the splat cloud so the next draw re-emits it.
308
309 `params` and `weights` change the ATTRACTOR, and unlike the histogram — which
310 merely restarts accumulating — a splat cloud is a fixed set of samples from
311 the old fractal. Leaving it up would show the previous document indefinitely
312 while the panel says otherwise, which is the "succeeds silently into a void"
313 failure this add-on keeps producing. `tonemap` deliberately does NOT drop it:
314 brightness does not move a sample.
315 """
316 self.splat_cloud = None
317 self.splat_stats = None
318289
319290 SHADING_MODES = {'EMISSION': 0, 'FLAT': 1, 'METAL': 2}
320291
……@@ -331,16 +302,6 @@ class Session:
331302 self.opacity_gain = settings.opacity_gain
332303 self.relief = settings.relief
333304
334 # Splat settings. Capacity and radius are read every frame so the panel
335 # sliders reach the preview; a change to CAPACITY needs a re-emit (it is
336 # how many samples we keep), a change to RADIUS does not (it only scales
337 # the quads), so only the former drops the cloud.
338 self.splats_enabled = bool(getattr(settings, 'viewport_splats', False))
339 capacity = int(getattr(settings, 'points_capacity', self.splat_capacity))
340 if capacity != self.splat_capacity:
341 self.splat_capacity = capacity
342 self.drop_splats()
343 self.splat_radius_scale = float(getattr(settings, 'points_radius', 1.0))
344305
345306 # When a node graph is bound it OWNS the iterators. Copying the panel's
346307 # values on every frame would revert each graph edit one frame after it
……@@ -536,14 +497,6 @@ class Session:
536497
537498 space = context.space_data
538499
539 # The splat path REPLACES the histogram rather than adding to it: the two
540 # draw the same fractal by different means, and running both would double
541 # the emission and cost a compute dispatch per frame for an image nobody
542 # sees. Everything above this line still runs — the panel, the node graph
543 # and the dirty flags all have to reach the document either way.
544 if self.splats_enabled:
545 self._draw_splats(context, space)
546 return
547500
548501 view_proj = self._view_projection(space.region_3d, space.lens)
549502 moved = self._view_moved(view_proj)
……@@ -650,88 +603,6 @@ class Session:
650603 gpu.state.depth_test_set('NONE')
651604 gpu.state.blend_set('NONE') # leave the viewport's state as we found it
652605
653 def _build_splats(self):
654 """
655 Emits the points once and uploads them as a splat cloud.
656
657 This is a full GPU pass and it BLOCKS the redraw it runs in, so it happens
658 only when the cloud is missing — on the first splat frame, and after an
659 edit that changed the attractor. The points kernel is cached across
660 re-emits because compiling it is most of the cost.
661 """
662 import ifs_points
663 import ifs_splat
664
665 if self.splat_shader is None:
666 self.splat_shader = ifs_splat.build_shader()
667 if self.splat_emit_shader is None:
668 self.splat_emit_shader, _lib = ifs_points.build_shader()
669
670 positions, colors, stats = ifs_points.emit_document(
671 self.doc, capacity=self.splat_capacity, shader=self.splat_emit_shader)
672
673 # A radius PER POINT. The global estimate cannot serve a fractal: measured
674 # on the shipped templates the nearest-neighbour distance spans 16-18x
675 # within one document, so one value leaves the filaments as separate beads
676 # while bloating the halo. `dimension` is reused from the emit rather than
677 # re-estimated — it is the same cloud.
678 radius = ifs_points.local_radius(positions, dimension=stats.get('dimension'))
679 self.splat_cloud = ifs_splat.upload(
680 positions, colors, radius * self.splat_radius_scale)
681 self.splat_stats = stats
682
683 def _draw_splats(self, context, space):
684 """
685 The splat pass: emitted points accumulated as gaussians, then TONEMAPPED.
686
687 The important word is tonemapped. An earlier version drew the gaussians
688 straight to the viewport and it was wrong in a way no setting could fix —
689 a flame's appearance comes from a curve applied to ACCUMULATED density,
690 not from what one sample looks like, so raw emissive quads render as fuzzy
691 beads and Brightness / shading mode / opacity gain do nothing at all
692 because the shader that reads them never ran.
693
694 So this fills the SAME `accum` buffer the histogram path fills and then
695 calls the same `_tonemap_blit`. Splats become a different way of
696 accumulating density rather than a different renderer, every existing
697 control keeps working, and occlusion comes back because the fragment
698 shader writes `depth_img` in the kernel's encoding.
699 """
700 import ifs_splat
701
702 region = context.region
703 # Full resolution: the progressive divisor exists to keep an ACCUMULATING
704 # render interactive, and a splat pass redraws the whole cloud each frame
705 # regardless.
706 self._resize(max(region.width, 16), max(region.height, 16))
707
708 if self.splat_cloud is None:
709 try:
710 self._build_splats()
711 except Exception:
712 # A raising draw handler removes itself and the viewport goes dark
713 # with nothing said. Report once and fall back rather than that.
714 import traceback
715 traceback.print_exc()
716 self.splats_enabled = False
717 return
718
719 # Both buffers are rebuilt from scratch every frame. Unlike the histogram
720 # there is nothing to accumulate ACROSS frames — the cloud is a fixed set
721 # of samples — so stale contents would just double-expose.
722 self.accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0))
723 self.depthbuf.clear(format='UINT', value=(ifs_layout.DEPTH_CLEAR, 0, 0, 0))
724
725 framebuffer = gpu.types.GPUFrameBuffer(color_slots=(self.accum,))
726 with framebuffer.bind():
727 ifs_splat.draw(self.splat_cloud, self.splat_shader,
728 space.region_3d.view_matrix,
729 space.region_3d.window_matrix,
730 self.depthbuf)
731
732 self._tonemap_blit()
733 self.frames += 1
734
735606 @property
736607 def iterations(self):
737608 return self.since_reset * self.iters_per_frame * ifs_render.INVOCATIONS