| … | … | @@ -215,6 +215,14 @@ class IFSSceneSettings(PropertyGroup): |
| 215 | 215 | description="Emission strength of the point material. Unlike the volume's " |
| 216 | 216 | "exposure this needs no derivation - a point is a surface the " |
| 217 | 217 | "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')) | |
| 218 | 226 | hdri_width: IntProperty( |
| 219 | 227 | name="Width", default=2048, min=256, max=8192, |
| 220 | 228 | description="Equirectangular width. Height is half of it — u spans 360 " |
| … | … | @@ -727,6 +735,7 @@ class IFS_PT_main(Panel): |
| 727 | 735 | if settings.shading_mode == 'METAL': |
| 728 | 736 | box.prop(settings, "relief") |
| 729 | 737 | box.prop(settings, "iters_per_frame") |
| 738 | box.prop(settings, "depth_bands") | |
| 730 | 739 | running = ifs_viewport.is_running() |
| 731 | 740 | box.operator("ifs.toggle_viewport", |
| 732 | 741 | text="Stop" if running else "Start", |
| … | … | @@ -138,6 +138,7 @@ _DEPTH_HELPERS = """ |
| 138 | 138 | // [0,1] is monotonic, so imageAtomicMin over floatBitsToUint is an EXACT minimum - |
| 139 | 139 | // no fixed-point scale and no quantisation, unlike the histogram. |
| 140 | 140 | uint g_depth = 0x3F800000u; |
| 141 | float g_depth_f = 1.0; // the same value, kept as a float for slice selection | |
| 141 | 142 | |
| 142 | 143 | // Blender's own view-projection, NOT the engine's. `view_proj_mat` is mirrored in x |
| 143 | 144 | // 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) |
| 157 | 158 | |
| 158 | 159 | _ACCUMULATE_DEPTH = """void accumulate_hit(ivec2 proj, vec4 color) |
| 159 | 160 | { |
| 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); | |
| 166 | 186 | }""" |
| 167 | 187 | |
| 168 | 188 | # Set once per sample, at the top of the plot block, so BOTH accumulate_hit arms |
| … | … | @@ -232,6 +232,7 @@ def _build_shaders(width, height, hist_scale, depth=False, equirect=False): |
| 232 | 232 | # limit (see the add-on's known gaps). Fine on OpenGL; this moves into a UBO |
| 233 | 233 | # with the rest when Vulkan or Metal is attempted. |
| 234 | 234 | info.push_constant('MAT4', 'blender_view_proj') |
| 235 | info.push_constant('INT', 'depth_slices') | |
| 235 | 236 | if equirect: |
| 236 | 237 | info.push_constant('VEC3', 'equirect_centre') |
| 237 | 238 | info.sampler(0, 'FLOAT_2D', 'alias_tables_tex') |
| … | … | @@ -67,9 +67,17 @@ _TONEMAP_FRAG = """ |
| 67 | 67 | #define MODE_FLAT 1 |
| 68 | 68 | #define MODE_METAL 2 |
| 69 | 69 | |
| 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. | |
| 73 | ivec2 sliced(ivec2 c) | |
| 74 | { | |
| 75 | return ivec2(clamp(c, ivec2(0), out_size - 1)) + ivec2(0, slice * out_size.y); | |
| 76 | } | |
| 77 | ||
| 70 | 78 | float density_at(ivec2 c) |
| 71 | 79 | { |
| 72 | return texelFetch(accum_tex, clamp(c, ivec2(0), out_size - 1), 0).w; | |
| 80 | return texelFetch(accum_tex, sliced(c), 0).w; | |
| 73 | 81 | } |
| 74 | 82 | |
| 75 | 83 | float luminance_at(ivec2 c, float peak) |
| … | … | @@ -90,9 +98,15 @@ void main() |
| 90 | 98 | // so it neither occludes nor is occluded. Note this texture is at the accumulator's |
| 91 | 99 | // resolution, which is a half or quarter of the screen, so the occlusion boundary |
| 92 | 100 | // 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); | |
| 96 | 110 | float peak = max(float(texelFetch(max_tex, ivec2(0, 0), 0).x), 1.0); |
| 97 | 111 | float d = max(h.w, 1e-12); |
| 98 | 112 | float lum = log(1.0 + brightness * h.w / peak) / log(10.0); |
| … | … | @@ -173,6 +187,8 @@ def build_tonemap(): |
| 173 | 187 | info.push_constant('INT', "shading_mode") |
| 174 | 188 | info.push_constant('FLOAT', "opacity_gain") |
| 175 | 189 | info.push_constant('FLOAT', "relief") |
| 190 | info.push_constant('INT', "slice") | |
| 191 | info.push_constant('INT', "depth_slices") | |
| 176 | 192 | # Declaring this is what makes writing gl_FragDepth legal; without it the write |
| 177 | 193 | # is silently dropped by the shader wrapper. |
| 178 | 194 | info.depth_write('ANY') |
| … | … | @@ -187,6 +203,15 @@ class Session: |
| 187 | 203 | def __init__(self, document_path, iters_per_frame=400, hist_scale=1024.0): |
| 188 | 204 | self.iters_per_frame = iters_per_frame |
| 189 | 205 | 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 | |
| 190 | 215 | self.handle = None |
| 191 | 216 | self.width = 0 |
| 192 | 217 | self.height = 0 |
| … | … | @@ -297,6 +322,12 @@ class Session: |
| 297 | 322 | document first, or this silently replaces a document's 14.55 with the |
| 298 | 323 | property default of 8 on the first redraw and the fractal renders dim. |
| 299 | 324 | """ |
| 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 | |
| 300 | 331 | self.brightness = settings.brightness |
| 301 | 332 | self.shading_mode = self.SHADING_MODES.get(settings.shading_mode, 0) |
| 302 | 333 | self.opacity_gain = settings.opacity_gain |
| … | … | @@ -385,8 +416,10 @@ class Session: |
| 385 | 416 | if (width, height) == (self.width, self.height): |
| 386 | 417 | return False |
| 387 | 418 | 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') | |
| 390 | 423 | self.maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI') |
| 391 | 424 | # One depth per pixel, not four - it is a minimum, not an accumulation, so it |
| 392 | 425 | # needs no interleaving and no drain. |
| … | … | @@ -537,6 +570,7 @@ class Session: |
| 537 | 570 | self.kernel.uniform_float( |
| 538 | 571 | 'blender_view_proj', |
| 539 | 572 | space.region_3d.window_matrix @ space.region_3d.view_matrix) |
| 573 | self.kernel.uniform_int('depth_slices', (self.depth_slices,)) | |
| 540 | 574 | self.kernel.uniform_sampler('alias_tables_tex', self.alias_tex) |
| 541 | 575 | self.kernel.uniform_block('frame', frame) |
| 542 | 576 | self.kernel.uniform_block('settings', settings_ubo) |
| … | … | @@ -549,9 +583,11 @@ class Session: |
| 549 | 583 | self.drain.bind() |
| 550 | 584 | self.drain.image('histogram_img', self.histogram) |
| 551 | 585 | 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) | |
| 555 | 591 | |
| 556 | 592 | self._tonemap_blit() |
| 557 | 593 | |
| … | … | @@ -577,9 +613,9 @@ class Session: |
| 577 | 613 | self.maxpass.bind() |
| 578 | 614 | self.maxpass.image('accum_img', self.accum) |
| 579 | 615 | 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) | |
| 583 | 619 | |
| 584 | 620 | self.tonemap.bind() |
| 585 | 621 | self.tonemap.uniform_sampler('accum_tex', self.accum) |
| … | … | @@ -599,7 +635,13 @@ class Session: |
| 599 | 635 | # still shows. |
| 600 | 636 | gpu.state.depth_mask_set(False) |
| 601 | 637 | 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) | |
| 603 | 645 | gpu.state.depth_test_set('NONE') |
| 604 | 646 | gpu.state.blend_set('NONE') # leave the viewport's state as we found it |
| 605 | 647 |
| … | … | @@ -213,6 +213,128 @@ class DepthStraddle(unittest.TestCase): |
| 213 | 213 | 'is the white speckle seen where a cube sits inside the nebula') |
| 214 | 214 | |
| 215 | 215 | |
| 216 | class 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 | ||
| 216 | 338 | if __name__ == "__main__": |
| 217 | 339 | result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result |
| 218 | 340 | sys.exit(0 if result.wasSuccessful() else 1) |