OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / fe4df61

Splats accumulate into the tonemap instead of drawing beads
The splat pass drew emissive gaussians straight to the viewport, and that was wrong in a way no setting could fix. A fractal flame's appearance is a CURVE APPLIED TO ACCUMULATED DENSITY — brightness at a pixel comes from how many samples landed there, shaped by Brightness/Gamma/GammaThreshold/Vibrancy. Raw per-sample emission is not the model, so it rendered as discrete fuzzy beads, and Brightness, the shading modes and opacity gain did NOTHING because the shader that reads them never ran. Reported from the actual viewport; the panel had been lying about what it controlled. Now the pass fills the SAME float `accum` buffer the histogram path fills, and both call one `_tonemap_blit`. Splats become another way of ACCUMULATING density rather than a second renderer. Every existing control works unchanged, and it is the same code rather than two copies that agree today. Occlusion comes back with it: the fragment shader writes `depth_img` via imageAtomicMin in the kernel's exact encoding — floatBitsToUint of window-space z, monotonic over [0,1] so the atomic min is exact. We draw with Blender's own window matrix, so gl_FragCoord.z IS that value; no near/far re-derivation. Every surviving fragment writes it, for the reason the density-estimation filter had to: a fragment left at the far value silently refuses to be occluded. Fragment-stage image writes were verified supported before relying on them. Two new tests pin the complaint itself, differentially, because "it produced an image" would pass against a pass that still ignored every control: brightness 2 -> 0.04350 mean, 32 -> 0.19735 the tonemap is in the path 1 splat 0.6985 -> 8 coincident 1.0000 density accumulates Also lands `ifs_points.local_radius`: a per-point radius from grid-binned local density. Measured, a single global radius cannot work — within one document the nearest-neighbour distance spans 16-18x (light_rods 0.90 -> 14.19, nebula 0.0175 -> 0.322), so any one value leaves sparse regions as dots while over-covering dense ones. Not yet wired into the emit; the tonemap was the larger error and fixing scale first would only have produced better-shaped dots. 10 tests here; points/attributes/depth-composite/viewport suites unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fe4df614f09cddb6f819c0ef75a2f9beb8cc8918
by plankatron <fleeky@gmail.com> · 2026-08-18T15:14:15.000Z

Changed files (4) +265 -32

blender/ifs_points.py +53 -0
……@@ -294,6 +294,59 @@ def estimate_spacing(points, sample=2048, seed=0):
294294 return max(spacing, 1e-9), dimension
295295
296296
297def local_radius(points, dimension=None, cells=64, k=0.5, clamp=(0.02, 50.0)):
298 """
299 A radius PER POINT, from the local sample density.
300
301 A single global radius cannot work on a fractal, and this is measurable rather
302 than aesthetic: within one document the nearest-neighbour distance varies by
303 16-18x between the p10 and p90 point (light_rods 0.90 -> 14.19,
304 nebula 0.0175 -> 0.322). Any single value therefore leaves the sparse regions
305 as separate dots while over-covering the dense ones — which is exactly what a
306 flame looks like when it renders as fuzzy beads instead of filaments.
307
308 Exact nearest-neighbour distances are O(n^2) and out of the question at a
309 million points, so density is read off a uniform grid: bin the points, and
310 inside a cell of side `s` holding `c` samples of an attractor with dimension
311 `D`, the typical spacing is `s / c**(1/D)`. That is the same reasoning
312 `estimate_spacing` uses globally, applied locally and for free — the whole
313 thing is a bincount.
314
315 The grid is fitted to the MASS, not the extent. light_rods spans 3236 units
316 while its 99.5% body spans ~1167 and its dense sheet far less; gridding the
317 full extent would put the entire fractal in one cell and measure nothing.
318 """
319 points = np.asarray(points, dtype=np.float32)
320 count = len(points)
321 if count == 0:
322 return np.zeros(0, dtype=np.float32)
323
324 if dimension is None:
325 _spacing, dimension = estimate_spacing(points)
326 dimension = float(min(3.0, max(1.0, dimension)))
327
328 # Percentile bounds, so a handful of far-flung samples cannot define the grid.
329 lo = np.percentile(points, 0.5, axis=0)
330 hi = np.percentile(points, 99.5, axis=0)
331 span = np.maximum(hi - lo, 1e-9)
332 cell = span / float(cells)
333
334 index = np.floor((points - lo) / cell).astype(np.int64)
335 np.clip(index, 0, cells - 1, out=index)
336 flat = (index[:, 0] * cells + index[:, 1]) * cells + index[:, 2]
337 occupancy = np.bincount(flat, minlength=cells ** 3)
338
339 # Cell side as a single length: the geometric mean, so a flat fractal whose
340 # cells are slabs is not judged by its thinnest axis.
341 side = float(np.exp(np.log(cell).mean()))
342 per_point = occupancy[flat].astype(np.float32)
343 spacing = side / np.maximum(per_point, 1.0) ** (1.0 / dimension)
344
345 radius = (k * spacing).astype(np.float32)
346 typical = float(np.median(radius)) if count else 1.0
347 return np.clip(radius, typical * clamp[0], typical * clamp[1]).astype(np.float32)
348
349
297350def emit_document(doc, capacity=1_000_000, max_dispatches=400,
298351 invocation_iters=None, shader=None):
299352 """
blender/ifs_splat.py +32 -11
……@@ -87,9 +87,21 @@ void main()
8787 float a = -dot(vOffset, vOffset);
8888 if (a < -CUTOFF_SQ) discard; /* round, not square */
8989
90 /* Nearest-splat depth, in exactly the encoding the kernel uses so the shared
91 tonemap can read this buffer without knowing which pass filled it:
92 floatBitsToUint of window-space z, which is monotonic over [0,1] so an
93 atomic MIN is an exact minimum. We draw with Blender's own window matrix,
94 so gl_FragCoord.z IS that value — no re-derivation, no near/far guesswork.
95 Written for every surviving fragment rather than at the centre, for the
96 reason the density-estimation filter had to: a fragment left at the far
97 value silently refuses to be occluded. */
98 imageAtomicMin(depth_img, ivec2(gl_FragCoord.xy), floatBitsToUint(gl_FragCoord.z));
99
90100 float weight = exp(a) * vAlpha;
91 /* Premultiplied: the blend mode is ADDITIVE_PREMULT, matching the viewport's
92 emission path. Colour is NOT divided by anything — a flame is light. */
101 /* Premultiplied and ADDITIVE: this lands in the float accumulation buffer that
102 the histogram path also fills, so the shared maxpass + tonemap turn it into
103 the image. Values above 1 are wanted, not clamped — overlapping splats are
104 how a flame gets its core, and the tonemap is what compresses that back. */
93105 fragColor = vec4(vColor * weight, weight);
94106}
95107"""
……@@ -110,6 +122,8 @@ def build_shader():
110122
111123 info.sampler(0, 'FLOAT_2D', 'splat_pos')
112124 info.sampler(1, 'FLOAT_2D', 'splat_col')
125 info.image(0, 'R32UI', 'UINT_2D', 'depth_img',
126 qualifiers={'NO_RESTRICT', 'READ', 'WRITE'})
113127 info.push_constant('MAT4', 'view')
114128 info.push_constant('MAT4', 'projection')
115129 info.push_constant('INT', 'splat_width')
……@@ -192,26 +206,33 @@ def upload(positions, colors, radius, width=SPLAT_WIDTH):
192206 width, count)
193207
194208
195def draw(cloud, shader, view_matrix, projection_matrix, additive=True,
196 depth_test=True):
209def draw(cloud, shader, view_matrix, projection_matrix, depth_img,
210 additive=True):
197211 """
198 Draws the cloud with the viewport's compositing rules.
212 Accumulates the cloud into whatever framebuffer is currently bound.
213
214 This does NOT draw to the screen. It is a density pass: the caller binds a
215 float colour attachment (the viewport's `accum`), and the tonemap turns the
216 result into an image afterwards. That indirection is the whole point — the
217 fractal's look comes from a curve applied to ACCUMULATED density, not from
218 what one sample looks like, so drawing splats straight to the viewport
219 produced fuzzy beads that no panel control could fix.
199220
200 Depth is TESTED but not WRITTEN, exactly as the tonemap pass does: writing
201 would let the fractal occlude gizmos and grid drawn after us, and would leave
202 the buffer holding a nebula's depth, which is meaningless downstream.
221 `depth_img` is the R32UI nearest-depth buffer the tonemap reads, written here
222 by the fragment shader in the kernel's own encoding.
203223 """
204224 shader.bind()
205225 shader.uniform_sampler('splat_pos', cloud.positions_tex)
206226 shader.uniform_sampler('splat_col', cloud.colors_tex)
227 shader.image('depth_img', depth_img)
207228 shader.uniform_int('splat_width', (cloud.width,))
208229 shader.uniform_float('view', view_matrix)
209230 shader.uniform_float('projection', projection_matrix)
210231
232 # No depth TEST here: this pass has no scene depth to test against, and
233 # occlusion happens later when the tonemap blit emits gl_FragDepth from
234 # depth_img and is tested against the real viewport buffer.
211235 gpu.state.blend_set('ADDITIVE_PREMULT' if additive else 'ALPHA')
212236 gpu.state.depth_mask_set(False)
213 if depth_test:
214 gpu.state.depth_test_set('LESS_EQUAL')
215237 cloud.batch.draw(shader)
216 gpu.state.depth_test_set('NONE')
217238 gpu.state.blend_set('NONE')
blender/ifs_viewport.py +55 -17
……@@ -542,7 +542,7 @@ class Session:
542542 # sees. Everything above this line still runs — the panel, the node graph
543543 # and the dirty flags all have to reach the document either way.
544544 if self.splats_enabled:
545 self._draw_splats(space)
545 self._draw_splats(context, space)
546546 return
547547
548548 view_proj = self._view_projection(space.region_3d, space.lens)
……@@ -600,6 +600,25 @@ class Session:
600600 gpu.compute.dispatch(self.drain, (self.width + 7) // 8,
601601 (self.height + 7) // 8, 1)
602602
603 self._tonemap_blit()
604
605 self.frames += 1
606 # Keep animating: without this the viewport only refines when nudged.
607 for area in context.screen.areas:
608 if area.type == 'VIEW_3D':
609 area.tag_redraw()
610
611 def _tonemap_blit(self):
612 """
613 Auto-expose the accumulator and blit it to the viewport.
614
615 Shared by BOTH paths, deliberately. The histogram fills `accum` by
616 draining a uint histogram; the splat pass fills it by additive rasterising
617 gaussians. Everything after that — the peak scan, the tonemap curve
618 (Brightness, the shading modes, opacity gain, relief) and the depth
619 composite — is identical, and identical because it is the same code rather
620 than because two copies agree today.
621 """
603622 # Auto-exposure without a readback: the peak lives in a 1x1 uint image.
604623 self.maxbuf.clear(format='UINT', value=(0, 0, 0, 0))
605624 self.maxpass.bind()
……@@ -631,12 +650,6 @@ class Session:
631650 gpu.state.depth_test_set('NONE')
632651 gpu.state.blend_set('NONE') # leave the viewport's state as we found it
633652
634 self.frames += 1
635 # Keep animating: without this the viewport only refines when nudged.
636 for area in context.screen.areas:
637 if area.type == 'VIEW_3D':
638 area.tag_redraw()
639
640653 def _build_splats(self):
641654 """
642655 Emits the points once and uploads them as a splat cloud.
……@@ -660,17 +673,31 @@ class Session:
660673 positions, colors, stats['radius'] * self.splat_radius_scale)
661674 self.splat_stats = stats
662675
663 def _draw_splats(self, space):
676 def _draw_splats(self, context, space):
664677 """
665 The splat pass: emitted points as camera-facing gaussians, added together.
666
667 Blender's own view and window matrices are used whole, for the same reason
668 the histogram path hands the kernel `blender_view_proj` rather than the
669 engine's construction — the engine's matrix is mirrored in x and its z does
670 not correspond to what the depth buffer holds.
678 The splat pass: emitted points accumulated as gaussians, then TONEMAPPED.
679
680 The important word is tonemapped. An earlier version drew the gaussians
681 straight to the viewport and it was wrong in a way no setting could fix —
682 a flame's appearance comes from a curve applied to ACCUMULATED density,
683 not from what one sample looks like, so raw emissive quads render as fuzzy
684 beads and Brightness / shading mode / opacity gain do nothing at all
685 because the shader that reads them never ran.
686
687 So this fills the SAME `accum` buffer the histogram path fills and then
688 calls the same `_tonemap_blit`. Splats become a different way of
689 accumulating density rather than a different renderer, every existing
690 control keeps working, and occlusion comes back because the fragment
691 shader writes `depth_img` in the kernel's encoding.
671692 """
672693 import ifs_splat
673694
695 region = context.region
696 # Full resolution: the progressive divisor exists to keep an ACCUMULATING
697 # render interactive, and a splat pass redraws the whole cloud each frame
698 # regardless.
699 self._resize(max(region.width, 16), max(region.height, 16))
700
674701 if self.splat_cloud is None:
675702 try:
676703 self._build_splats()
……@@ -682,9 +709,20 @@ class Session:
682709 self.splats_enabled = False
683710 return
684711
685 ifs_splat.draw(self.splat_cloud, self.splat_shader,
686 space.region_3d.view_matrix,
687 space.region_3d.window_matrix)
712 # Both buffers are rebuilt from scratch every frame. Unlike the histogram
713 # there is nothing to accumulate ACROSS frames — the cloud is a fixed set
714 # of samples — so stale contents would just double-expose.
715 self.accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0))
716 self.depthbuf.clear(format='UINT', value=(ifs_layout.DEPTH_CLEAR, 0, 0, 0))
717
718 framebuffer = gpu.types.GPUFrameBuffer(color_slots=(self.accum,))
719 with framebuffer.bind():
720 ifs_splat.draw(self.splat_cloud, self.splat_shader,
721 space.region_3d.view_matrix,
722 space.region_3d.window_matrix,
723 self.depthbuf)
724
725 self._tonemap_blit()
688726 self.frames += 1
689727
690728 @property
blender/test_splat_shading.py +125 -4
……@@ -74,11 +74,14 @@ def _render(positions, colors, radius, size=SIZE, additive=True):
7474 view, proj = _matrices()
7575
7676 colour = gpu.types.GPUTexture((size, size), format='RGBA32F')
77 depth = gpu.types.GPUTexture((size, size), format='DEPTH_COMPONENT32F')
78 fb = gpu.types.GPUFrameBuffer(depth_slot=depth, color_slots=(colour,))
77 # The R32UI nearest-depth buffer the pass writes, in the kernel's encoding.
78 # Cleared to DEPTH_CLEAR (1.0f) exactly as the viewport does.
79 depth_img = gpu.types.GPUTexture((size, size), format='R32UI')
80 depth_img.clear(format='UINT', value=(0x3F800000, 0, 0, 0))
81 fb = gpu.types.GPUFrameBuffer(color_slots=(colour,))
7982 with fb.bind():
80 fb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=1.0)
81 ifs_splat.draw(cloud, shader, view, proj, additive=additive)
83 fb.clear(color=(0.0, 0.0, 0.0, 0.0))
84 ifs_splat.draw(cloud, shader, view, proj, depth_img, additive=additive)
8285 raw = fb.read_color(0, 0, size, size, 3, 0, 'FLOAT').to_list()
8386 return np.array(raw, dtype=np.float32).reshape(size, size, 3)
8487
……@@ -221,6 +224,124 @@ class SplatShading(unittest.TestCase):
221224 'it is not shrinking with depth')
222225
223226
227class SplatThroughTonemap(unittest.TestCase):
228 """
229 The splat pass feeds the SAME accumulate -> tonemap back half as the histogram.
230
231 This is the fix for a real complaint: with splats drawn straight to the
232 viewport, Brightness, the shading modes and opacity gain did nothing at all,
233 because the shader that reads them never ran. The fractal's look is a curve
234 applied to ACCUMULATED density — per-sample emission is not the model — so
235 raw quads rendered as fuzzy beads no setting could correct.
236
237 Asserted differentially, because "it produced an image" would pass against a
238 pass that still ignored every control.
239 """
240
241 @classmethod
242 def setUpClass(cls):
243 try:
244 import gpu
245 import numpy # noqa: F401
246 except ImportError as ex:
247 raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")
248 import ifs_splat # noqa: F401 — ours, deliberately unguarded
249 import ifs_viewport # noqa: F401
250 gpu.init()
251
252 def _accumulate_and_tonemap(self, positions, colors, radius, brightness,
253 size=SIZE):
254 import gpu
255 import numpy as np
256 import ifs_splat
257 import ifs_viewport
258
259 accum = gpu.types.GPUTexture((size, size), format='RGBA32F')
260 accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0))
261 depth_img = gpu.types.GPUTexture((size, size), format='R32UI')
262 depth_img.clear(format='UINT', value=(0x3F800000, 0, 0, 0))
263
264 shader = ifs_splat.build_shader()
265 cloud = ifs_splat.upload(np.asarray(positions, dtype=np.float32),
266 np.asarray(colors, dtype=np.float32), radius)
267 view, proj = _matrices()
268
269 fb = gpu.types.GPUFrameBuffer(color_slots=(accum,))
270 with fb.bind():
271 fb.clear(color=(0.0, 0.0, 0.0, 0.0))
272 ifs_splat.draw(cloud, shader, view, proj, depth_img)
273
274 # The viewport's own auto-exposure and tonemap, unmodified.
275 maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI')
276 maxbuf.clear(format='UINT', value=(0, 0, 0, 0))
277 maxpass = ifs_viewport.build_maxpass()
278 maxpass.bind()
279 maxpass.image('accum_img', accum)
280 maxpass.image('max_img', maxbuf)
281 maxpass.uniform_int('out_size', (size, size))
282 gpu.compute.dispatch(maxpass, (size + 7) // 8, (size + 7) // 8, 1)
283
284 tonemap = ifs_viewport.build_tonemap()
285 from gpu_extras.batch import batch_for_shader
286 batch = batch_for_shader(tonemap, 'TRI_FAN',
287 {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))})
288 out = gpu.types.GPUTexture((size, size), format='RGBA32F')
289 out_depth = gpu.types.GPUTexture((size, size), format='DEPTH_COMPONENT32F')
290 ofb = gpu.types.GPUFrameBuffer(depth_slot=out_depth, color_slots=(out,))
291 with ofb.bind():
292 ofb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=1.0)
293 tonemap.bind()
294 tonemap.uniform_sampler('accum_tex', accum)
295 tonemap.uniform_sampler('max_tex', maxbuf)
296 tonemap.uniform_sampler('depth_tex', depth_img)
297 tonemap.uniform_int('out_size', (size, size))
298 tonemap.uniform_float('brightness', brightness)
299 tonemap.uniform_int('shading_mode', (0,))
300 tonemap.uniform_float('opacity_gain', 4.0)
301 tonemap.uniform_float('relief', 12.0)
302 gpu.state.blend_set('ADDITIVE_PREMULT')
303 batch.draw(tonemap)
304 gpu.state.blend_set('NONE')
305 raw = ofb.read_color(0, 0, size, size, 3, 0, 'FLOAT').to_list()
306 return np.array(raw, dtype=np.float32).reshape(size, size, 3)
307
308 def test_the_tonemapped_result_is_not_empty(self):
309 """Baseline — the two passes are actually connected."""
310 img = self._accumulate_and_tonemap(
311 [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], 0.5, brightness=8.0)
312 self.assertGreater(float(img.max()), 0.01,
313 'splats accumulated but the tonemap produced nothing')
314
315 def test_brightness_reaches_the_image(self):
316 """
317 The complaint, made into a test. Before the rewire this control was inert
318 while splats were on, because the tonemap shader never ran.
319 """
320 dim = self._accumulate_and_tonemap(
321 [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], 0.5, brightness=2.0)
322 bright = self._accumulate_and_tonemap(
323 [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], 0.5, brightness=32.0)
324 print(f"[tonemap] brightness 2 -> {dim.mean():.5f}, 32 -> {bright.mean():.5f}")
325 self.assertGreater(float(bright.mean()), float(dim.mean()) * 1.05,
326 'Brightness does not change the splat image — the '
327 'tonemap is not in the path')
328
329 def test_overlapping_splats_accumulate_density(self):
330 """
331 Density, not per-sample emission, is what the curve is applied to. Stacked
332 splats must read brighter after the tonemap, or we are still drawing
333 independent beads.
334 """
335 one = self._accumulate_and_tonemap(
336 [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 0.5]], 0.5, brightness=8.0)
337 many = self._accumulate_and_tonemap(
338 [[0.0, 0.0, 0.0]] * 8, [[1.0, 1.0, 1.0, 0.5]] * 8, 0.5, brightness=8.0)
339 print(f"[tonemap] 1 splat {one.max():.4f} -> 8 splats {many.max():.4f}")
340 self.assertGreater(float(many.max()), float(one.max()) * 1.05,
341 'eight coincident splats are no brighter than one — '
342 'density is not accumulating')
343
344
224345if __name__ == "__main__":
225346 result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result
226347 sys.exit(0 if result.wasSuccessful() else 1)