OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 651ae1c

Depth bands: partial occlusion out of a binary hardware test
The white speckle where a cube sits inside the nebula was never an opacity problem. `depth_img` held the NEAREST sample's depth — a min over stochastic samples, so a random variable per pixel — and LESS_EQUAL flipped per pixel wherever the fractal's depth straddled the surface, landing the FULL accumulated brightness wherever it passed. Measured at 0.4995 neighbour roughness against 0.0000 unoccluded: essentially maximal dithering. The real problem is that one depth and one colour per pixel cannot answer the only question that matters there. Not "is the fractal in front?" but "how much of the emission along this ray is in front?". So the accumulator is now N screens tall, a sample lands in the band its own depth falls into, and each band is blitted with its OWN gl_FragDepth. The hardware then discards whole bands per pixel. Occlusion becomes N-step, and the dithering has no source left because gl_FragDepth no longer comes from the noisy depth texture. Measured, sweeping an occluding surface through the bands with 8 of them: surface depth 0.0 0.25 0.5 0.75 1.0 drawn energy 0.000 1.909 3.817 5.726 7.634 Exactly linear — 0, 2, 4, 6, 8 bands surviving. A binary test can produce two values; this produces as many as there are bands. It needs NO access to Blender's depth texture, which Python cannot obtain as a GPU texture (issue #7) — only the hardware test it already uses. Kept 2D rather than going to a 3D texture: bands are extra ROWS, so every image binding type is unchanged and the drain and auto-exposure needed only a taller out_size. Exposure is computed across ALL bands deliberately — per-band normalisation would stop them adding up. Default 4, not 8, and exposed as `depth_bands`. The cost is linear and not small: at 1920x1080 the two buffers together are 66 MB at 1 band, 265 MB at 4, 531 MB at 8. Four steps is already a large improvement on binary; eight is a preference. The single-buffer artefact test stays as an EXPECTED FAILURE. It characterises the path that still exists at depth_bands=1 and documents why bands are there — it is not made obsolete by the fix, it is the motivation for it. Verified a real Session allocates banded buffers (accum 64x256 at 4 bands, 128x384 after a resize to 128x96). 11 GPU suites and 8 plain-Python suites pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
651ae1c7380212eb53ebb669022f38b829ebcd65
by plankatron <fleeky@gmail.com> · 2026-08-18T19:16:08.000Z

Changed files (5) +213 -19

blender/__init__.py +9 -0
……@@ -215,6 +215,14 @@ class IFSSceneSettings(PropertyGroup):
215215 description="Emission strength of the point material. Unlike the volume's "
216216 "exposure this needs no derivation - a point is a surface the "
217217 "renderer sees directly, not a density integrated along a ray")
218 depth_bands: IntProperty(
219 name="Depth bands", default=4, min=1, max=16,
220 description="How finely the overlay is split by depth for compositing with "
221 "scene objects. 1 is a single buffer and occludes all-or-nothing "
222 "per pixel, which speckles where geometry sits inside the "
223 "fractal. Costs VRAM linearly: at 1920x1080 roughly 66 MB per "
224 "band",
225 update=_touch('params'))
218226 hdri_width: IntProperty(
219227 name="Width", default=2048, min=256, max=8192,
220228 description="Equirectangular width. Height is half of it — u spans 360 "
……@@ -727,6 +735,7 @@ class IFS_PT_main(Panel):
727735 if settings.shading_mode == 'METAL':
728736 box.prop(settings, "relief")
729737 box.prop(settings, "iters_per_frame")
738 box.prop(settings, "depth_bands")
730739 running = ifs_viewport.is_running()
731740 box.operator("ifs.toggle_viewport",
732741 text="Stop" if running else "Start",
blender/ifs_layout.py +26 -6
……@@ -138,6 +138,7 @@ _DEPTH_HELPERS = """
138138// [0,1] is monotonic, so imageAtomicMin over floatBitsToUint is an EXACT minimum -
139139// no fixed-point scale and no quantisation, unlike the histogram.
140140uint g_depth = 0x3F800000u;
141float g_depth_f = 1.0; // the same value, kept as a float for slice selection
141142
142143// Blender's own view-projection, NOT the engine's. `view_proj_mat` is mirrored in x
143144// and carries the FOV quirk, and its NDC z is not what Blender's depth buffer holds.
……@@ -157,12 +158,31 @@ void set_depth(vec3 world)
157158
158159_ACCUMULATE_DEPTH = """void accumulate_hit(ivec2 proj, vec4 color)
159160{
160\tivec2 base = ivec2(proj.x * 4, proj.y);
161\timageAtomicAdd(histogram_img, base + ivec2(0, 0), uint(max(color.r, 0.0) * HIST_SCALE));
162\timageAtomicAdd(histogram_img, base + ivec2(1, 0), uint(max(color.g, 0.0) * HIST_SCALE));
163\timageAtomicAdd(histogram_img, base + ivec2(2, 0), uint(max(color.b, 0.0) * HIST_SCALE));
164\timageAtomicAdd(histogram_img, base + ivec2(3, 0), uint(max(color.w, 0.0) * HIST_SCALE));
165\timageAtomicMin(depth_img, proj, g_depth);
161 // SLICED BY DEPTH. The histogram is `depth_slices` screens tall and a sample
162 // lands in the band its own depth falls into.
163 //
164 // One depth and one colour per pixel cannot answer the only question that
165 // matters where a cube sits inside a nebula: not "is the fractal in front?" but
166 // "how much of the emission along this ray is in front?". With a single buffer
167 // the nearest-sample depth is a MIN over stochastic samples — a random variable —
168 // so LESS_EQUAL flips per pixel and the full brightness lands wherever it passes.
169 // Measured as 0.4995 neighbour-to-neighbour roughness against 0.0000 unoccluded:
170 // essentially maximal salt-and-pepper, and exactly the white speckle seen in the
171 // viewport.
172 //
173 // Slices turn that into an N-step answer. Each band is blitted separately with
174 // its own gl_FragDepth, so the hardware rejects whole bands per pixel and the
175 // dithering has nothing to come from. No access to Blender's depth texture is
176 // needed — which is just as well, because Python cannot obtain one (issue #7).
177 int slice = clamp(int(g_depth_f * float(depth_slices)), 0, depth_slices - 1);
178 ivec2 base = ivec2(proj.x * 4, proj.y + slice * height);
179 imageAtomicAdd(histogram_img, base + ivec2(0, 0), uint(max(color.r, 0.0) * HIST_SCALE));
180 imageAtomicAdd(histogram_img, base + ivec2(1, 0), uint(max(color.g, 0.0) * HIST_SCALE));
181 imageAtomicAdd(histogram_img, base + ivec2(2, 0), uint(max(color.b, 0.0) * HIST_SCALE));
182 imageAtomicAdd(histogram_img, base + ivec2(3, 0), uint(max(color.w, 0.0) * HIST_SCALE));
183 // Still written: the nearest depth is what a single-slice consumer wants, and
184 // the still render has no slices.
185 imageAtomicMin(depth_img, proj, g_depth);
166186}"""
167187
168188# Set once per sample, at the top of the plot block, so BOTH accumulate_hit arms
blender/ifs_render.py +1 -0
……@@ -232,6 +232,7 @@ def _build_shaders(width, height, hist_scale, depth=False, equirect=False):
232232 # limit (see the add-on's known gaps). Fine on OpenGL; this moves into a UBO
233233 # with the rest when Vulkan or Metal is attempted.
234234 info.push_constant('MAT4', 'blender_view_proj')
235 info.push_constant('INT', 'depth_slices')
235236 if equirect:
236237 info.push_constant('VEC3', 'equirect_centre')
237238 info.sampler(0, 'FLOAT_2D', 'alias_tables_tex')
blender/ifs_viewport.py +55 -13
……@@ -67,9 +67,17 @@ _TONEMAP_FRAG = """
6767#define MODE_FLAT 1
6868#define MODE_METAL 2
6969
70// The accumulator is `depth_slices` screens tall; this maps a screen pixel into
71// the band being drawn. Every sampler read goes through it, so the neighbourhood
72// reads that drive relief and the density filter stay inside the same band.
73ivec2 sliced(ivec2 c)
74{
75 return ivec2(clamp(c, ivec2(0), out_size - 1)) + ivec2(0, slice * out_size.y);
76}
77
7078float density_at(ivec2 c)
7179{
72 return texelFetch(accum_tex, clamp(c, ivec2(0), out_size - 1), 0).w;
80 return texelFetch(accum_tex, sliced(c), 0).w;
7381}
7482
7583float luminance_at(ivec2 c, float peak)
……@@ -90,9 +98,15 @@ void main()
9098 // so it neither occludes nor is occluded. Note this texture is at the accumulator's
9199 // resolution, which is a half or quarter of the screen, so the occlusion boundary
92100 // is coarser than the geometry that casts it.
93 gl_FragDepth = uintBitsToFloat(texelFetch(depth_tex, c, 0).x);
94
95 vec4 h = texelFetch(accum_tex, c, 0);
101 // With slices, the band's own FAR edge is the depth: the hardware then rejects
102 // whole bands per pixel and occlusion is N-step instead of the binary test that
103 // dithered. With one slice this falls back to the nearest-sample depth, which is
104 // what the still path and any single-band consumer want.
105 gl_FragDepth = depth_slices > 1
106 ? float(slice + 1) / float(depth_slices)
107 : uintBitsToFloat(texelFetch(depth_tex, c, 0).x);
108
109 vec4 h = texelFetch(accum_tex, sliced(c), 0);
96110 float peak = max(float(texelFetch(max_tex, ivec2(0, 0), 0).x), 1.0);
97111 float d = max(h.w, 1e-12);
98112 float lum = log(1.0 + brightness * h.w / peak) / log(10.0);
……@@ -173,6 +187,8 @@ def build_tonemap():
173187 info.push_constant('INT', "shading_mode")
174188 info.push_constant('FLOAT', "opacity_gain")
175189 info.push_constant('FLOAT', "relief")
190 info.push_constant('INT', "slice")
191 info.push_constant('INT', "depth_slices")
176192 # Declaring this is what makes writing gl_FragDepth legal; without it the write
177193 # is silently dropped by the shader wrapper.
178194 info.depth_write('ANY')
……@@ -187,6 +203,15 @@ class Session:
187203 def __init__(self, document_path, iters_per_frame=400, hist_scale=1024.0):
188204 self.iters_per_frame = iters_per_frame
189205 self.hist_scale = hist_scale
206 # Depth bands. The accumulator is this many screens tall and each band is
207 # blitted with its own gl_FragDepth, which is what turns a binary occlusion
208 # test into an N-step one. 1 restores the old single-buffer behaviour.
209 #
210 # 4 by default because the cost is linear in bands and it is not small:
211 # at 1920x1080 the histogram and accumulator together are 66 MB at 1 band,
212 # 265 MB at 4 and 531 MB at 8. Four steps is already a large improvement on
213 # binary; eight is a preference, not a necessity.
214 self.depth_slices = 4
190215 self.handle = None
191216 self.width = 0
192217 self.height = 0
……@@ -297,6 +322,12 @@ class Session:
297322 document first, or this silently replaces a document's 14.55 with the
298323 property default of 8 on the first redraw and the fractal renders dim.
299324 """
325 bands = int(getattr(settings, 'depth_bands', self.depth_slices))
326 if bands != self.depth_slices:
327 # Changing the band count reshapes both buffers, so force a rebuild the
328 # same way a resize does rather than leaving stale geometry behind.
329 self.depth_slices = bands
330 self.width = self.height = 0
300331 self.brightness = settings.brightness
301332 self.shading_mode = self.SHADING_MODES.get(settings.shading_mode, 0)
302333 self.opacity_gain = settings.opacity_gain
……@@ -385,8 +416,10 @@ class Session:
385416 if (width, height) == (self.width, self.height):
386417 return False
387418 self.width, self.height = width, height
388 self.histogram = gpu.types.GPUTexture((width * 4, height), format='R32UI')
389 self.accum = gpu.types.GPUTexture((width, height), format='RGBA32F')
419 bands = max(1, self.depth_slices)
420 self.histogram = gpu.types.GPUTexture((width * 4, height * bands),
421 format='R32UI')
422 self.accum = gpu.types.GPUTexture((width, height * bands), format='RGBA32F')
390423 self.maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI')
391424 # One depth per pixel, not four - it is a minimum, not an accumulation, so it
392425 # needs no interleaving and no drain.
……@@ -537,6 +570,7 @@ class Session:
537570 self.kernel.uniform_float(
538571 'blender_view_proj',
539572 space.region_3d.window_matrix @ space.region_3d.view_matrix)
573 self.kernel.uniform_int('depth_slices', (self.depth_slices,))
540574 self.kernel.uniform_sampler('alias_tables_tex', self.alias_tex)
541575 self.kernel.uniform_block('frame', frame)
542576 self.kernel.uniform_block('settings', settings_ubo)
……@@ -549,9 +583,11 @@ class Session:
549583 self.drain.bind()
550584 self.drain.image('histogram_img', self.histogram)
551585 self.drain.image('accum_img', self.accum)
552 self.drain.uniform_int('out_size', (self.width, self.height))
553 gpu.compute.dispatch(self.drain, (self.width + 7) // 8,
554 (self.height + 7) // 8, 1)
586 # Drain and auto-expose across ALL bands: the exposure has to be common or
587 # each band would be normalised to its own peak and they would not add up.
588 tall = self.height * max(1, self.depth_slices)
589 self.drain.uniform_int('out_size', (self.width, tall))
590 gpu.compute.dispatch(self.drain, (self.width + 7) // 8, (tall + 7) // 8, 1)
555591
556592 self._tonemap_blit()
557593
……@@ -577,9 +613,9 @@ class Session:
577613 self.maxpass.bind()
578614 self.maxpass.image('accum_img', self.accum)
579615 self.maxpass.image('max_img', self.maxbuf)
580 self.maxpass.uniform_int('out_size', (self.width, self.height))
581 gpu.compute.dispatch(self.maxpass, (self.width + 7) // 8,
582 (self.height + 7) // 8, 1)
616 tall = self.height * max(1, self.depth_slices)
617 self.maxpass.uniform_int('out_size', (self.width, tall))
618 gpu.compute.dispatch(self.maxpass, (self.width + 7) // 8, (tall + 7) // 8, 1)
583619
584620 self.tonemap.bind()
585621 self.tonemap.uniform_sampler('accum_tex', self.accum)
……@@ -599,7 +635,13 @@ class Session:
599635 # still shows.
600636 gpu.state.depth_mask_set(False)
601637 gpu.state.depth_test_set('LESS_EQUAL')
602 self.batch.draw(self.tonemap)
638 # Back to front. Each band carries its own depth, so the hardware discards
639 # the ones a surface is in front of and keeps the rest — partial occlusion
640 # out of a binary test, with no access to Blender's depth texture.
641 self.tonemap.uniform_int('depth_slices', (self.depth_slices,))
642 for band in reversed(range(max(1, self.depth_slices))):
643 self.tonemap.uniform_int('slice', (band,))
644 self.batch.draw(self.tonemap)
603645 gpu.state.depth_test_set('NONE')
604646 gpu.state.blend_set('NONE') # leave the viewport's state as we found it
605647
blender/test_depth_slices.py +122 -0
……@@ -213,6 +213,128 @@ class DepthStraddle(unittest.TestCase):
213213 'is the white speckle seen where a cube sits inside the nebula')
214214
215215
216class SlicedOcclusion(unittest.TestCase):
217 """
218 With depth BANDS, occlusion is graded rather than binary.
219
220 The single-buffer path above cannot do better than yes/no per pixel, and its
221 yes/no comes from a min over stochastic samples — hence the speckle. Bands
222 replace that: the accumulator is N screens tall, a sample lands in the band its
223 own depth falls into, and each band is blitted with its OWN gl_FragDepth. The
224 hardware then discards whole bands per pixel.
225
226 So the claim is not "smoother". It is that the drawn energy TRACKS the depth of
227 the occluding surface, which a binary test cannot do at all. That is what these
228 measure, by sweeping the surface through the bands and checking the result is
229 monotonic and proportional.
230
231 Note gl_FragDepth is `(band + 1) / bands` — the band's FAR edge — so a surface
232 at 0.5 with 8 bands keeps bands 0..3 and discards 4..7.
233 """
234
235 BANDS = 8
236
237 @classmethod
238 def setUpClass(cls):
239 try:
240 import gpu
241 import numpy # noqa: F401
242 except ImportError as ex:
243 raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")
244 import ifs_viewport # noqa: F401
245 gpu.init()
246
247 def _composite_bands(self, surface_depth, bands=None):
248 """Fills every band equally, blits them all, returns the drawn image."""
249 import gpu
250 import numpy as np
251 import ifs_viewport
252 from gpu_extras.batch import batch_for_shader
253
254 bands = bands or self.BANDS
255 # A TALL accumulator: `bands` screens stacked, each uniformly lit. Equal
256 # energy per band means the drawn total is a direct count of survivors.
257 accum = gpu.types.GPUTexture((SIZE, SIZE * bands), format='RGBA32F')
258 accum.clear(format='FLOAT', value=(1.0, 1.0, 1.0, 1.0))
259 maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI')
260 maxbuf.clear(format='UINT', value=(0, 0, 0, 0))
261 maxpass = ifs_viewport.build_maxpass()
262 maxpass.bind()
263 maxpass.image('accum_img', accum)
264 maxpass.image('max_img', maxbuf)
265 maxpass.uniform_int('out_size', (SIZE, SIZE * bands))
266 gpu.compute.dispatch(maxpass, (SIZE + 7) // 8, (SIZE * bands + 7) // 8, 1)
267
268 far = gpu.types.GPUTexture((SIZE, SIZE), format='R32UI')
269 far.clear(format='UINT', value=(0x3F800000, 0, 0, 0))
270
271 colour = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F')
272 depth_slot = gpu.types.GPUTexture((SIZE, SIZE), format='DEPTH_COMPONENT32F')
273 fb = gpu.types.GPUFrameBuffer(depth_slot=depth_slot, color_slots=(colour,))
274 tonemap = ifs_viewport.build_tonemap()
275 batch = batch_for_shader(tonemap, 'TRI_FAN',
276 {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))})
277 with fb.bind():
278 fb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=surface_depth)
279 tonemap.bind()
280 tonemap.uniform_sampler('accum_tex', accum)
281 tonemap.uniform_sampler('max_tex', maxbuf)
282 tonemap.uniform_sampler('depth_tex', far)
283 tonemap.uniform_int('out_size', (SIZE, SIZE))
284 tonemap.uniform_float('brightness', 8.0)
285 tonemap.uniform_int('shading_mode', (0,))
286 tonemap.uniform_float('opacity_gain', 4.0)
287 tonemap.uniform_float('relief', 12.0)
288 tonemap.uniform_int('depth_slices', (bands,))
289 gpu.state.blend_set('ADDITIVE_PREMULT')
290 gpu.state.depth_mask_set(False)
291 gpu.state.depth_test_set('LESS_EQUAL')
292 for band in reversed(range(bands)):
293 tonemap.uniform_int('slice', (band,))
294 batch.draw(tonemap)
295 gpu.state.depth_test_set('NONE')
296 gpu.state.blend_set('NONE')
297 raw = fb.read_color(0, 0, SIZE, SIZE, 3, 0, 'FLOAT').to_list()
298 return np.array(raw, dtype=np.float32).reshape(SIZE, SIZE, 3)
299
300 def test_occlusion_is_graded_not_binary(self):
301 """
302 THE claim. Sweep the surface through the bands; the drawn energy must rise
303 monotonically and land in between at the midpoint — a binary test can only
304 ever produce two values.
305 """
306 import numpy as np
307 levels = {}
308 for surface in (0.0, 0.25, 0.5, 0.75, 1.0):
309 levels[surface] = float(self._composite_bands(surface).mean())
310 print("[slices] drawn energy by surface depth: " +
311 " ".join(f"{d}->{v:.4f}" for d, v in levels.items()))
312
313 ordered = [levels[d] for d in (0.0, 0.25, 0.5, 0.75, 1.0)]
314 for a, b in zip(ordered, ordered[1:]):
315 self.assertLessEqual(a, b + 1e-6,
316 f"energy is not monotonic in surface depth: {ordered}")
317 self.assertGreater(ordered[-1], ordered[0] + 1e-4,
318 'a surface at the far plane drew no more than one at the '
319 'near plane — nothing is being graded')
320 distinct = len({round(v, 4) for v in ordered})
321 self.assertGreater(distinct, 2,
322 f'only {distinct} distinct levels across the sweep — that '
323 'is a binary test, not graded occlusion')
324
325 def test_a_surface_at_the_far_plane_hides_nothing(self):
326 """Control: everything must draw, or 'graded' is satisfied by drawing less
327 and less of nothing."""
328 image = self._composite_bands(1.0)
329 self.assertGreater(float(image.max()), 0.05, 'nothing drew at all')
330
331 def test_a_surface_at_the_near_plane_hides_everything(self):
332 """The other control."""
333 image = self._composite_bands(0.0)
334 self.assertLess(float(image.max()), 0.02,
335 'a surface at the near plane failed to hide the fractal')
336
337
216338if __name__ == "__main__":
217339 result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result
218340 sys.exit(0 if result.wasSuccessful() else 1)