| … | … | @@ -115,6 +115,67 @@ _ACCUMULATE_NEW = """void accumulate_hit(ivec2 proj, vec4 color) |
| 115 | 115 | \timageAtomicAdd(histogram_img, base + ivec2(3, 0), uint(max(color.w, 0.0) * HIST_SCALE)); |
| 116 | 116 | }""" |
| 117 | 117 | |
| 118 | # ---------------------------------------------------------------------- depth mode | |
| 119 | # | |
| 120 | # The live overlay draws a fullscreen quad, so to let scene geometry occlude it the | |
| 121 | # quad must hand the hardware ONE depth per pixel. A flame has no such thing: it is | |
| 122 | # an accumulation along the whole ray. The NEAREST contributing sample is the honest | |
| 123 | # single value - it errs toward showing the fractal, and it is exactly right whenever | |
| 124 | # the fractal is wholly in front of or wholly behind an object. Only interpenetration | |
| 125 | # is approximated, and then per pixel. | |
| 126 | # | |
| 127 | # Depth is written INSIDE accumulate_hit rather than at the call sites because there | |
| 128 | # are two of them: the direct plot, and the density-estimation filter that splats | |
| 129 | # into NEIGHBOURING pixels. Writing at the call site would leave every filtered pixel | |
| 130 | # at the far plane, so blurred regions would silently refuse to be occluded. | |
| 131 | ||
| 132 | # 1.0 - the far plane. A pixel nothing landed on contributes nothing, and comparing | |
| 133 | # LESS_EQUAL against a cleared depth buffer of 1.0 leaves it a no-op either way. | |
| 134 | DEPTH_CLEAR = 0x3F800000 | |
| 135 | ||
| 136 | _DEPTH_HELPERS = """ | |
| 137 | // Nearest-sample depth, in Blender's window space. The bit pattern of a float in | |
| 138 | // [0,1] is monotonic, so imageAtomicMin over floatBitsToUint is an EXACT minimum - | |
| 139 | // no fixed-point scale and no quantisation, unlike the histogram. | |
| 140 | uint g_depth = 0x3F800000u; | |
| 141 | ||
| 142 | // Blender's own view-projection, NOT the engine's. `view_proj_mat` is mirrored in x | |
| 143 | // and carries the FOV quirk, and its NDC z is not what Blender's depth buffer holds. | |
| 144 | // Taking the matrix from region_3d means our z matches the buffer BY CONSTRUCTION | |
| 145 | // rather than by re-deriving the near/far mapping here and hoping it agrees. | |
| 146 | void set_depth(vec3 world) | |
| 147 | { | |
| 148 | \tvec4 h = blender_view_proj * vec4(world, 1.0); | |
| 149 | \tif (h.w <= 0.0) | |
| 150 | \t{ | |
| 151 | \t\tg_depth = 0x3F800000u; // behind the camera: never occludes anything | |
| 152 | \t\treturn; | |
| 153 | \t} | |
| 154 | \tg_depth = floatBitsToUint(clamp((h.z / h.w) * 0.5 + 0.5, 0.0, 1.0)); | |
| 155 | } | |
| 156 | """ | |
| 157 | ||
| 158 | _ACCUMULATE_DEPTH = """void accumulate_hit(ivec2 proj, vec4 color) | |
| 159 | { | |
| 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); | |
| 166 | }""" | |
| 167 | ||
| 168 | # Set once per sample, at the top of the plot block, so BOTH accumulate_hit arms | |
| 169 | # carry the same depth. `p.pos.xyz` is the sample's world position - the same value | |
| 170 | # the volume path voxelises. | |
| 171 | _DEFOCUS_DECL = re.compile(r"(^[ \t]*float defocus;)", re.M) | |
| 172 | ||
| 173 | DEPTH_EXTRA_LAYOUT: tuple[Resource, ...] = ( | |
| 174 | Resource("image", 2, "depth_img", "UINT_2D", | |
| 175 | "R32UI, W x H. Nearest-sample window depth as float bits; " | |
| 176 | "imageAtomicMin target. Cleared to DEPTH_CLEAR."), | |
| 177 | ) | |
| 178 | ||
| 118 | 179 | # p_state is 8 floats; the two ints ride through as bit patterns so nothing is lost |
| 119 | 180 | # to float rounding on a large iterator index or depth. |
| 120 | 181 | _STATE_HELPERS = """ |
| … | … | @@ -319,14 +380,22 @@ class RewriteReport: |
| 319 | 380 | |
| 320 | 381 | def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE, |
| 321 | 382 | state_width: int = 256, |
| 322 | volume: bool = False) -> tuple[str, str, RewriteReport]: | |
| 383 | volume: bool = False, | |
| 384 | depth: bool = False) -> tuple[str, str, RewriteReport]: | |
| 323 | 385 | """ |
| 324 | 386 | Rewrites an engine kernel onto the Blender resource model. |
| 325 | 387 | |
| 326 | 388 | Every substitution is checked: if the kernel changes upstream such that a |
| 327 | 389 | pattern no longer matches, that lands in `report.missing` rather than silently |
| 328 | 390 | producing a kernel that compiles and behaves differently. |
| 391 | ||
| 392 | `depth` adds the nearest-sample depth channel the viewport overlay needs to be | |
| 393 | occluded by scene geometry. It is meaningless with `volume`, which replaces the | |
| 394 | 2D projection wholesale and so has no screen pixel to be in front of. | |
| 329 | 395 | """ |
| 396 | if volume and depth: | |
| 397 | raise ValueError("depth is a 2D-projection concept; it has no meaning in " | |
| 398 | "volume mode, which never projects to a screen pixel") | |
| 330 | 399 | applied: list[str] = [] |
| 331 | 400 | missing: list[str] = [] |
| 332 | 401 | |
| … | … | @@ -358,6 +427,11 @@ def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE, |
| 358 | 427 | "replace the projection block with a voxel write", out) |
| 359 | 428 | out = sub(_ACCUMULATE, lambda _m: _VOXEL_HELPER, |
| 360 | 429 | "swap accumulate_hit for accumulate_voxel", out) |
| 430 | elif depth: | |
| 431 | out = sub(_ACCUMULATE, lambda _m: _ACCUMULATE_DEPTH, | |
| 432 | "rewrite accumulate_hit with depth", out) | |
| 433 | out = sub(_DEFOCUS_DECL, lambda m: m.group(1) + "\n\t\tset_depth(p.pos.xyz);", | |
| 434 | "inject set_depth at the top of the plot block", out) | |
| 361 | 435 | else: |
| 362 | 436 | out = sub(_ACCUMULATE, lambda _m: _ACCUMULATE_NEW, "rewrite accumulate_hit", out) |
| 363 | 437 | out = sub(re.compile(r"\bp\s*=\s*state\[gid\]\s*;"), "p = load_state(gid);", |
| … | … | @@ -372,6 +446,8 @@ def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE, |
| 372 | 446 | # The helpers reference p_state, so they must follow its declaration. |
| 373 | 447 | anchor = "//Shader Storage Buffer Objects" |
| 374 | 448 | helpers = _STATE_HELPERS.format(hist_scale=hist_scale, state_w=state_width) |
| 449 | if depth: | |
| 450 | helpers += _DEPTH_HELPERS | |
| 375 | 451 | if anchor in out: |
| 376 | 452 | out = out.replace(anchor, helpers + "\n" + anchor, 1) |
| 377 | 453 | applied.append("insert state helpers") |
| … | … | @@ -195,7 +195,13 @@ void main() |
| 195 | 195 | """ |
| 196 | 196 | |
| 197 | 197 | |
| 198 | def _build_shaders(width, height, hist_scale): | |
| 198 | def _build_shaders(width, height, hist_scale, depth=False): | |
| 199 | """ | |
| 200 | `depth` adds the nearest-sample depth image the live overlay needs to be occluded | |
| 201 | by scene geometry. It is off for the still path, which has no scene to be behind | |
| 202 | — and a declared-but-unused image would still consume one of Blender's eight | |
| 203 | slots, so this cannot be left permanently on. | |
| 204 | """ | |
| 199 | 205 | library = load_library(os.path.join(REPO, "Transforms"), |
| 200 | 206 | os.path.join(REPO, "WpfDisplay", "Library", "Includes")) |
| 201 | 207 | template_path = os.path.join(REPO, "IFSEngine", "Rendering", "Shaders", |
| … | … | @@ -203,7 +209,7 @@ def _build_shaders(width, height, hist_scale): |
| 203 | 209 | with open(template_path, "r", encoding="utf-8-sig") as handle: |
| 204 | 210 | kernel = generate_kernel(handle.read(), library) |
| 205 | 211 | kernel, _ = apply_portability_fixes(kernel) |
| 206 | body, typedefs, report = rewrite(kernel, hist_scale, STATE_W) | |
| 212 | body, typedefs, report = rewrite(kernel, hist_scale, STATE_W, depth=depth) | |
| 207 | 213 | if not report.ok: |
| 208 | 214 | raise RuntimeError("kernel rewrite incomplete: " + "; ".join(report.missing)) |
| 209 | 215 | |
| … | … | @@ -213,6 +219,12 @@ def _build_shaders(width, height, hist_scale): |
| 213 | 219 | rw = {'NO_RESTRICT', 'READ', 'WRITE'} |
| 214 | 220 | info.image(0, 'R32UI', 'UINT_2D', 'histogram_img', qualifiers=rw) |
| 215 | 221 | info.image(1, 'RGBA32F', 'FLOAT_2D', 'state_img', qualifiers=rw) |
| 222 | if depth: | |
| 223 | info.image(2, 'R32UI', 'UINT_2D', 'depth_img', qualifiers=rw) | |
| 224 | # One more push constant on a budget already over the 128-byte portable | |
| 225 | # limit (see the add-on's known gaps). Fine on OpenGL; this moves into a UBO | |
| 226 | # with the rest when Vulkan or Metal is attempted. | |
| 227 | info.push_constant('MAT4', 'blender_view_proj') | |
| 216 | 228 | info.sampler(0, 'FLOAT_2D', 'alias_tables_tex') |
| 217 | 229 | info.uniform_buf(0, 'FrameParams', 'frame') |
| 218 | 230 | info.uniform_buf(1, 'SettingsParams', 'settings') |
| … | … | @@ -32,6 +32,7 @@ from mathutils import Vector |
| 32 | 32 | |
| 33 | 33 | import ifs_document |
| 34 | 34 | import ifs_host |
| 35 | import ifs_layout | |
| 35 | 36 | import ifs_render |
| 36 | 37 | |
| 37 | 38 | # One live session at a time. A second would fight the first for the histogram. |
| … | … | @@ -79,6 +80,18 @@ float luminance_at(ivec2 c, float peak) |
| 79 | 80 | void main() |
| 80 | 81 | { |
| 81 | 82 | ivec2 c = ivec2(uv * vec2(out_size)); |
| 83 | ||
| 84 | // Hand the hardware depth test the nearest sample that landed on this pixel, so | |
| 85 | // scene geometry occludes the fractal. Written on EVERY path, before any early | |
| 86 | // return - gl_FragDepth is undefined if a branch skips it, and an undefined depth | |
| 87 | // against LESS_EQUAL flickers rather than fails cleanly. | |
| 88 | // | |
| 89 | // A pixel nothing landed on holds 1.0 (the far plane) and contributes no colour, | |
| 90 | // so it neither occludes nor is occluded. Note this texture is at the accumulator's | |
| 91 | // resolution, which is a half or quarter of the screen, so the occlusion boundary | |
| 92 | // is coarser than the geometry that casts it. | |
| 93 | gl_FragDepth = uintBitsToFloat(texelFetch(depth_tex, c, 0).x); | |
| 94 | ||
| 82 | 95 | vec4 h = texelFetch(accum_tex, c, 0); |
| 83 | 96 | float peak = max(float(texelFetch(max_tex, ivec2(0, 0), 0).x), 1.0); |
| 84 | 97 | float d = max(h.w, 1e-12); |
| … | … | @@ -129,6 +142,45 @@ void main() |
| 129 | 142 | """ |
| 130 | 143 | |
| 131 | 144 | |
| 145 | # Both builders are pure, and live at module level rather than on Session so the | |
| 146 | # compile gate can build them without a document, a GPU-backed Session or a viewport. | |
| 147 | # Compiling these is the only place the gl_FragDepth write and the MAT4 push constant | |
| 148 | # are actually checked by a driver. | |
| 149 | ||
| 150 | def build_maxpass(): | |
| 151 | info = gpu.types.GPUShaderCreateInfo() | |
| 152 | info.local_group_size(8, 8) | |
| 153 | rw = {'NO_RESTRICT', 'READ', 'WRITE'} | |
| 154 | info.image(0, 'RGBA32F', 'FLOAT_2D', 'accum_img', qualifiers={'NO_RESTRICT', 'READ'}) | |
| 155 | info.image(1, 'R32UI', 'UINT_2D', 'max_img', qualifiers=rw) | |
| 156 | info.push_constant('IVEC2', 'out_size') | |
| 157 | info.compute_source(_MAXPASS) | |
| 158 | return gpu.shader.create_from_info(info) | |
| 159 | ||
| 160 | ||
| 161 | def build_tonemap(): | |
| 162 | vert = gpu.types.GPUStageInterfaceInfo("ifs_tonemap") | |
| 163 | vert.smooth('VEC2', "uv") | |
| 164 | info = gpu.types.GPUShaderCreateInfo() | |
| 165 | info.vertex_in(0, 'VEC2', "pos") | |
| 166 | info.vertex_out(vert) | |
| 167 | info.fragment_out(0, 'VEC4', "fragColor") | |
| 168 | info.sampler(0, 'FLOAT_2D', "accum_tex") | |
| 169 | info.sampler(1, 'UINT_2D', "max_tex") | |
| 170 | info.sampler(2, 'UINT_2D', "depth_tex") | |
| 171 | info.push_constant('IVEC2', "out_size") | |
| 172 | info.push_constant('FLOAT', "brightness") | |
| 173 | info.push_constant('INT', "shading_mode") | |
| 174 | info.push_constant('FLOAT', "opacity_gain") | |
| 175 | info.push_constant('FLOAT', "relief") | |
| 176 | # Declaring this is what makes writing gl_FragDepth legal; without it the write | |
| 177 | # is silently dropped by the shader wrapper. | |
| 178 | info.depth_write('ANY') | |
| 179 | info.vertex_source(_TONEMAP_VERT) | |
| 180 | info.fragment_source(_TONEMAP_FRAG) | |
| 181 | return gpu.shader.create_from_info(info) | |
| 182 | ||
| 183 | ||
| 132 | 184 | class Session: |
| 133 | 185 | """Persistent GPU state for one live viewport.""" |
| 134 | 186 | |
| … | … | @@ -147,9 +199,9 @@ class Session: |
| 147 | 199 | self.frames = 0 |
| 148 | 200 | |
| 149 | 201 | self.kernel, self.drain, self.library = ifs_render._build_shaders( |
| 150 | 1, 1, hist_scale) | |
| 151 | self.maxpass = self._build_maxpass() | |
| 152 | self.tonemap = self._build_tonemap() | |
| 202 | 1, 1, hist_scale, depth=True) | |
| 203 | self.maxpass = build_maxpass() | |
| 204 | self.tonemap = build_tonemap() | |
| 153 | 205 | self.batch = batch_for_shader( |
| 154 | 206 | self.tonemap, 'TRI_FAN', |
| 155 | 207 | {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))}) |
| … | … | @@ -276,36 +328,6 @@ class Session: |
| 276 | 328 | self._dirty_params = False |
| 277 | 329 | self.invalidate() |
| 278 | 330 | |
| 279 | # ------------------------------------------------------------------ shaders | |
| 280 | ||
| 281 | def _build_maxpass(self): | |
| 282 | info = gpu.types.GPUShaderCreateInfo() | |
| 283 | info.local_group_size(8, 8) | |
| 284 | rw = {'NO_RESTRICT', 'READ', 'WRITE'} | |
| 285 | info.image(0, 'RGBA32F', 'FLOAT_2D', 'accum_img', qualifiers={'NO_RESTRICT', 'READ'}) | |
| 286 | info.image(1, 'R32UI', 'UINT_2D', 'max_img', qualifiers=rw) | |
| 287 | info.push_constant('IVEC2', 'out_size') | |
| 288 | info.compute_source(_MAXPASS) | |
| 289 | return gpu.shader.create_from_info(info) | |
| 290 | ||
| 291 | def _build_tonemap(self): | |
| 292 | vert = gpu.types.GPUStageInterfaceInfo("ifs_tonemap") | |
| 293 | vert.smooth('VEC2', "uv") | |
| 294 | info = gpu.types.GPUShaderCreateInfo() | |
| 295 | info.vertex_in(0, 'VEC2', "pos") | |
| 296 | info.vertex_out(vert) | |
| 297 | info.fragment_out(0, 'VEC4', "fragColor") | |
| 298 | info.sampler(0, 'FLOAT_2D', "accum_tex") | |
| 299 | info.sampler(1, 'UINT_2D', "max_tex") | |
| 300 | info.push_constant('IVEC2', "out_size") | |
| 301 | info.push_constant('FLOAT', "brightness") | |
| 302 | info.push_constant('INT', "shading_mode") | |
| 303 | info.push_constant('FLOAT', "opacity_gain") | |
| 304 | info.push_constant('FLOAT', "relief") | |
| 305 | info.vertex_source(_TONEMAP_VERT) | |
| 306 | info.fragment_source(_TONEMAP_FRAG) | |
| 307 | return gpu.shader.create_from_info(info) | |
| 308 | ||
| 309 | 331 | # ------------------------------------------------------------------ buffers |
| 310 | 332 | |
| 311 | 333 | def _build_buffers(self): |
| … | … | @@ -334,6 +356,9 @@ class Session: |
| 334 | 356 | self.histogram = gpu.types.GPUTexture((width * 4, height), format='R32UI') |
| 335 | 357 | self.accum = gpu.types.GPUTexture((width, height), format='RGBA32F') |
| 336 | 358 | self.maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI') |
| 359 | # One depth per pixel, not four - it is a minimum, not an accumulation, so it | |
| 360 | # needs no interleaving and no drain. | |
| 361 | self.depthbuf = gpu.types.GPUTexture((width, height), format='R32UI') | |
| 337 | 362 | rows = (ifs_render.INVOCATIONS + ifs_render.STATE_W - 1) // ifs_render.STATE_W |
| 338 | 363 | self.state = gpu.types.GPUTexture( |
| 339 | 364 | (ifs_render.STATE_W * 2, rows), format='RGBA32F') |
| … | … | @@ -345,6 +370,9 @@ class Session: |
| 345 | 370 | self.histogram.clear(format='UINT', value=(0, 0, 0, 0)) |
| 346 | 371 | self.accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0)) |
| 347 | 372 | self.state.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0)) |
| 373 | # Cleared to the FAR plane, not to zero: zero is the near plane, and a pixel | |
| 374 | # nothing has landed on yet would occlude the entire scene behind it. | |
| 375 | self.depthbuf.clear(format='UINT', value=(ifs_layout.DEPTH_CLEAR, 0, 0, 0)) | |
| 348 | 376 | # dispatch_count is NOT reset: it is hash input, and rewinding it makes |
| 349 | 377 | # every restart draw the same points. Under continuous camera motion that |
| 350 | 378 | # is a fixed constellation sliding across the screen rather than the |
| … | … | @@ -446,6 +474,14 @@ class Session: |
| 446 | 474 | self.kernel.bind() |
| 447 | 475 | self.kernel.image('histogram_img', self.histogram) |
| 448 | 476 | self.kernel.image('state_img', self.state) |
| 477 | self.kernel.image('depth_img', self.depthbuf) | |
| 478 | # Blender's OWN view-projection, taken whole rather than rebuilt: the kernel's | |
| 479 | # `view_proj_mat` is the engine's construction (mirrored in x, FOV quirk) and | |
| 480 | # its z does not correspond to what Blender's depth buffer holds. Using the | |
| 481 | # viewport's matrix means our depth matches the buffer by construction. | |
| 482 | self.kernel.uniform_float( | |
| 483 | 'blender_view_proj', | |
| 484 | space.region_3d.window_matrix @ space.region_3d.view_matrix) | |
| 449 | 485 | self.kernel.uniform_sampler('alias_tables_tex', self.alias_tex) |
| 450 | 486 | self.kernel.uniform_block('frame', frame) |
| 451 | 487 | self.kernel.uniform_block('settings', settings_ubo) |
| … | … | @@ -474,6 +510,7 @@ class Session: |
| 474 | 510 | self.tonemap.bind() |
| 475 | 511 | self.tonemap.uniform_sampler('accum_tex', self.accum) |
| 476 | 512 | self.tonemap.uniform_sampler('max_tex', self.maxbuf) |
| 513 | self.tonemap.uniform_sampler('depth_tex', self.depthbuf) | |
| 477 | 514 | self.tonemap.uniform_int('out_size', (self.width, self.height)) |
| 478 | 515 | self.tonemap.uniform_float('brightness', self.brightness) |
| 479 | 516 | self.tonemap.uniform_int('shading_mode', (self.shading_mode,)) |
| … | … | @@ -481,7 +518,15 @@ class Session: |
| 481 | 518 | self.tonemap.uniform_float('relief', self.relief) |
| 482 | 519 | # Emission ADDS light; the shaded modes are surfaces and composite over. |
| 483 | 520 | gpu.state.blend_set('ADDITIVE_PREMULT' if self.shading_mode == 0 else 'ALPHA') |
| 521 | # TEST against the scene's depth, but do NOT write to it. Writing would let | |
| 522 | # the fractal occlude overlays drawn after us (gizmos, grid, text) and would | |
| 523 | # leave the buffer holding a nebula's depth, which is meaningless to anything | |
| 524 | # downstream. LESS_EQUAL rather than LESS so a sample coplanar with a surface | |
| 525 | # still shows. | |
| 526 | gpu.state.depth_mask_set(False) | |
| 527 | gpu.state.depth_test_set('LESS_EQUAL') | |
| 484 | 528 | self.batch.draw(self.tonemap) |
| 529 | gpu.state.depth_test_set('NONE') | |
| 485 | 530 | gpu.state.blend_set('NONE') # leave the viewport's state as we found it |
| 486 | 531 | |
| 487 | 532 | self.frames += 1 |
| … | … | @@ -0,0 +1,146 @@ |
| 1 | """ | |
| 2 | Tests for the nearest-sample depth channel that lets scene geometry occlude the | |
| 3 | live overlay. | |
| 4 | ||
| 5 | The failure this suite exists to catch is not "depth is wrong by a bit", it is | |
| 6 | "depth is silently absent for SOME pixels". The kernel calls `accumulate_hit` from | |
| 7 | two places — the direct plot and the density-estimation filter, which splats into | |
| 8 | NEIGHBOURING pixels — and an implementation that writes depth at the call site | |
| 9 | instead of inside the function leaves every filtered pixel at the far plane. That | |
| 10 | renders correctly in every still where filtering is off, and un-occludes exactly the | |
| 11 | blurred regions when it is on. So the two-call-site property is asserted directly. | |
| 12 | ||
| 13 | Run: python3 -m unittest discover -s blender | |
| 14 | """ | |
| 15 | ||
| 16 | import os | |
| 17 | import re | |
| 18 | import unittest | |
| 19 | ||
| 20 | from ifs_codegen import apply_portability_fixes, generate_kernel, load_library | |
| 21 | from ifs_layout import DEPTH_CLEAR, DEPTH_EXTRA_LAYOUT, rewrite | |
| 22 | ||
| 23 | REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| 24 | TRANSFORMS = os.path.join(REPO, "Transforms") | |
| 25 | INCLUDES = os.path.join(REPO, "WpfDisplay", "Library", "Includes") | |
| 26 | TEMPLATE = os.path.join(REPO, "IFSEngine", "Rendering", "Shaders", "ifs_kernel.comp.shader") | |
| 27 | ||
| 28 | ||
| 29 | def kernel_source() -> str: | |
| 30 | library = load_library(TRANSFORMS, INCLUDES) | |
| 31 | with open(TEMPLATE, "r", encoding="utf-8-sig") as handle: | |
| 32 | kernel = generate_kernel(handle.read(), library) | |
| 33 | kernel, _ = apply_portability_fixes(kernel) | |
| 34 | return kernel | |
| 35 | ||
| 36 | ||
| 37 | class DepthRewrite(unittest.TestCase): | |
| 38 | @classmethod | |
| 39 | def setUpClass(cls): | |
| 40 | if not os.path.isfile(TEMPLATE): | |
| 41 | raise unittest.SkipTest("kernel template not found") | |
| 42 | cls.raw = kernel_source() | |
| 43 | cls.body, _typedefs, cls.report = rewrite(cls.raw, depth=True) | |
| 44 | cls.plain, _t, _r = rewrite(cls.raw) | |
| 45 | ||
| 46 | def test_rewrite_is_complete(self): | |
| 47 | self.assertEqual([], self.report.missing) | |
| 48 | ||
| 49 | def test_depth_substitutions_applied(self): | |
| 50 | for label in ("rewrite accumulate_hit with depth", | |
| 51 | "inject set_depth at the top of the plot block"): | |
| 52 | self.assertIn(label, self.report.applied) | |
| 53 | ||
| 54 | # ---------------------------------------------------------------- the real risk | |
| 55 | ||
| 56 | def test_the_kernel_really_has_two_accumulate_hit_call_sites(self): | |
| 57 | """ | |
| 58 | Guards the PREMISE of the design. If upstream collapses these to one, or | |
| 59 | adds a third, the reasoning below needs revisiting rather than silently | |
| 60 | still passing. | |
| 61 | """ | |
| 62 | calls = re.findall(r"\baccumulate_hit\s*\(", self.raw) | |
| 63 | # One declaration + two call sites. | |
| 64 | self.assertEqual(3, len(calls), "kernel's accumulate_hit call shape changed") | |
| 65 | ||
| 66 | def test_depth_is_written_inside_accumulate_hit_not_at_call_sites(self): | |
| 67 | body = self.body | |
| 68 | match = re.search(r"void accumulate_hit\(ivec2 proj, vec4 color\)\s*\{.*?\n\}", | |
| 69 | body, re.S) | |
| 70 | self.assertIsNotNone(match, "accumulate_hit not found after rewrite") | |
| 71 | self.assertIn("imageAtomicMin(depth_img, proj, g_depth)", match.group(0), | |
| 72 | "depth must be written INSIDE accumulate_hit, so that the " | |
| 73 | "density-estimation filter's neighbour splats carry it too") | |
| 74 | # ...and nowhere else, which is what would reintroduce the per-call-site bug. | |
| 75 | self.assertEqual(1, body.count("imageAtomicMin(depth_img")) | |
| 76 | ||
| 77 | def test_set_depth_precedes_both_arms(self): | |
| 78 | """`set_depth` must run once per sample, before the branch that chooses | |
| 79 | between the filtered and direct plot — not inside either arm.""" | |
| 80 | body = self.body | |
| 81 | set_at = body.index("set_depth(p.pos.xyz)") | |
| 82 | for call in re.finditer(r"accumulate_hit\((?:nb|proj),", body): | |
| 83 | self.assertLess(set_at, call.start(), | |
| 84 | "set_depth must precede every accumulate_hit call") | |
| 85 | ||
| 86 | # ------------------------------------------------------------------- soundness | |
| 87 | ||
| 88 | def test_depth_uses_blenders_matrix_not_the_engines(self): | |
| 89 | """ | |
| 90 | The engine's `view_proj_mat` is mirrored in x and its NDC z is not what | |
| 91 | Blender's depth buffer holds. Comparing against the buffer with the engine's | |
| 92 | z would be wrong in a way that still renders a plausible picture. | |
| 93 | """ | |
| 94 | helper = re.search(r"void set_depth\(vec3 world\)\s*\{.*?\n\}", self.body, re.S) | |
| 95 | self.assertIsNotNone(helper) | |
| 96 | self.assertIn("blender_view_proj", helper.group(0)) | |
| 97 | self.assertNotIn("view_proj_mat", helper.group(0)) | |
| 98 | ||
| 99 | def test_behind_camera_never_occludes(self): | |
| 100 | helper = re.search(r"void set_depth\(vec3 world\)\s*\{.*?\n\}", self.body, re.S) | |
| 101 | self.assertIn("h.w <= 0.0", helper.group(0), | |
| 102 | "points behind the camera must fall back to the far plane") | |
| 103 | ||
| 104 | def test_depth_clear_is_the_far_plane(self): | |
| 105 | """The clear value is a float bit pattern, not a fixed-point count. If it | |
| 106 | stops meaning 1.0, empty pixels start occluding scene geometry.""" | |
| 107 | import struct | |
| 108 | as_float = struct.unpack("<f", struct.pack("<I", DEPTH_CLEAR))[0] | |
| 109 | self.assertEqual(1.0, as_float) | |
| 110 | ||
| 111 | def test_atomic_min_over_float_bits_is_monotonic(self): | |
| 112 | """ | |
| 113 | imageAtomicMin on floatBitsToUint is only an exact minimum because the IEEE | |
| 114 | bit pattern of a NON-NEGATIVE float is monotonic. set_depth clamps to [0,1], | |
| 115 | so this holds — but assert the property rather than trust the comment. | |
| 116 | """ | |
| 117 | import struct | |
| 118 | ||
| 119 | def bits(f): | |
| 120 | return struct.unpack("<I", struct.pack("<f", f))[0] | |
| 121 | ||
| 122 | samples = [i / 64.0 for i in range(65)] | |
| 123 | self.assertEqual(sorted(samples, key=bits), samples) | |
| 124 | ||
| 125 | # ------------------------------------------------------------------- isolation | |
| 126 | ||
| 127 | def test_plain_rewrite_is_untouched(self): | |
| 128 | """The still and volume paths must not pay for a viewport-only feature — | |
| 129 | a declared-but-unused image costs one of Blender's eight slots.""" | |
| 130 | self.assertNotIn("depth_img", self.plain) | |
| 131 | self.assertNotIn("set_depth", self.plain) | |
| 132 | self.assertNotIn("blender_view_proj", self.plain) | |
| 133 | ||
| 134 | def test_depth_adds_exactly_one_image_slot(self): | |
| 135 | self.assertEqual(1, len(DEPTH_EXTRA_LAYOUT)) | |
| 136 | self.assertEqual("depth_img", DEPTH_EXTRA_LAYOUT[0].name) | |
| 137 | # Slots 0 and 1 are the histogram and the point state. | |
| 138 | self.assertEqual(2, DEPTH_EXTRA_LAYOUT[0].slot) | |
| 139 | ||
| 140 | def test_volume_and_depth_are_rejected_together(self): | |
| 141 | with self.assertRaises(ValueError): | |
| 142 | rewrite(self.raw, volume=True, depth=True) | |
| 143 | ||
| 144 | ||
| 145 | if __name__ == "__main__": | |
| 146 | unittest.main() |
| … | … | @@ -0,0 +1,220 @@ |
| 1 | """ | |
| 2 | Deterministic proof that the overlay COMPOSITES against scene depth correctly. | |
| 3 | ||
| 4 | `test_depth.py` proves the kernel writes depth. That is only half the claim: a | |
| 5 | buffer full of the FAR value also produces a picture where geometry appears to | |
| 6 | occlude the fractal, because 1.0 fails LESS_EQUAL against every real surface and | |
| 7 | passes against the cleared background. The two are indistinguishable on screen, so | |
| 8 | "I looked at a screenshot" cannot settle it — and neither can a live-viewport probe, | |
| 9 | where accumulation drift and the fractal's own extent confound every measurement. | |
| 10 | ||
| 11 | So this drives the real `build_tonemap()` shader against a framebuffer whose depth | |
| 12 | attachment we clear ourselves, with a depth texture we fill ourselves: | |
| 13 | ||
| 14 | scene depth := 0.6 everywhere | |
| 15 | fractal depth := 0.3 on the left half (in FRONT -> must draw) | |
| 16 | 0.9 on the right half (BEHIND -> must be hidden) | |
| 17 | ||
| 18 | Both halves carry identical colour and density, so any difference between them is | |
| 19 | the depth test and nothing else. The control pass repeats it with the test disabled | |
| 20 | and requires BOTH halves to draw — without that, a shader that drew nothing at all | |
| 21 | would satisfy the "hidden" half and look like a pass. | |
| 22 | ||
| 23 | Needs a GPU. Run inside Blender: | |
| 24 | blender -b --factory-startup -noaudio --python blender/test_depth_composite.py | |
| 25 | """ | |
| 26 | ||
| 27 | import os | |
| 28 | import struct | |
| 29 | import sys | |
| 30 | import unittest | |
| 31 | ||
| 32 | # `blender --python` does not put the script's own directory on the path. | |
| 33 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| 34 | ||
| 35 | SIZE = 64 | |
| 36 | SCENE_DEPTH = 0.6 | |
| 37 | NEAR = 0.3 # in front of the scene surface | |
| 38 | FAR = 0.9 # behind it | |
| 39 | ||
| 40 | ||
| 41 | def _bits(value: float) -> int: | |
| 42 | return struct.unpack("<I", struct.pack("<f", value))[0] | |
| 43 | ||
| 44 | ||
| 45 | # GPUTexture(data=...) accepts FLOAT buffers only, so the uint depth map is filled on | |
| 46 | # the GPU instead. Passing the two depths as floats and bit-casting in the shader also | |
| 47 | # keeps this using the SAME encoding the kernel uses, rather than a second one here. | |
| 48 | _FILL = """ | |
| 49 | void main() | |
| 50 | { | |
| 51 | ivec2 c = ivec2(gl_GlobalInvocationID.xy); | |
| 52 | if (c.x >= size.x || c.y >= size.y) return; | |
| 53 | float d = (c.x < size.x / 2) ? near_depth : far_depth; | |
| 54 | imageStore(dst, c, uvec4(floatBitsToUint(d), 0u, 0u, 0u)); | |
| 55 | } | |
| 56 | """ | |
| 57 | ||
| 58 | ||
| 59 | def _make_depth_texture(size, near, far): | |
| 60 | """An R32UI map: `near` on the left half, `far` on the right.""" | |
| 61 | import gpu | |
| 62 | tex = gpu.types.GPUTexture((size, size), format='R32UI') | |
| 63 | info = gpu.types.GPUShaderCreateInfo() | |
| 64 | info.local_group_size(8, 8) | |
| 65 | info.image(0, 'R32UI', 'UINT_2D', 'dst', qualifiers={'NO_RESTRICT', 'WRITE'}) | |
| 66 | info.push_constant('IVEC2', 'size') | |
| 67 | info.push_constant('FLOAT', 'near_depth') | |
| 68 | info.push_constant('FLOAT', 'far_depth') | |
| 69 | info.compute_source(_FILL) | |
| 70 | shader = gpu.shader.create_from_info(info) | |
| 71 | shader.bind() | |
| 72 | shader.image('dst', tex) | |
| 73 | shader.uniform_int('size', (size, size)) | |
| 74 | shader.uniform_float('near_depth', near) | |
| 75 | shader.uniform_float('far_depth', far) | |
| 76 | gpu.compute.dispatch(shader, (size + 7) // 8, (size + 7) // 8, 1) | |
| 77 | return tex | |
| 78 | ||
| 79 | ||
| 80 | def _make_peak(): | |
| 81 | import gpu | |
| 82 | tex = gpu.types.GPUTexture((1, 1), format='R32UI') | |
| 83 | tex.clear(format='UINT', value=(1, 0, 0, 0)) | |
| 84 | return tex | |
| 85 | ||
| 86 | ||
| 87 | class DepthComposite(unittest.TestCase): | |
| 88 | @classmethod | |
| 89 | def setUpClass(cls): | |
| 90 | try: | |
| 91 | import gpu # noqa: F401 | |
| 92 | except ImportError: | |
| 93 | raise unittest.SkipTest("needs Blender's gpu module") | |
| 94 | import gpu | |
| 95 | gpu.init() | |
| 96 | ||
| 97 | def _render(self, depth_test: bool): | |
| 98 | import gpu | |
| 99 | from gpu_extras.batch import batch_for_shader | |
| 100 | import ifs_viewport | |
| 101 | ||
| 102 | colour = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F') | |
| 103 | depth_attach = gpu.types.GPUTexture((SIZE, SIZE), format='DEPTH_COMPONENT32F') | |
| 104 | fb = gpu.types.GPUFrameBuffer(depth_slot=depth_attach, color_slots=(colour,)) | |
| 105 | ||
| 106 | # Uniform density and colour across the whole image: the ONLY thing that | |
| 107 | # differs left to right is the depth. | |
| 108 | accum_data = [] | |
| 109 | for _y in range(SIZE): | |
| 110 | for _x in range(SIZE): | |
| 111 | accum_data.extend((1.0, 1.0, 1.0, 1.0)) # rgb + density | |
| 112 | accum = gpu.types.GPUTexture( | |
| 113 | (SIZE, SIZE), format='RGBA32F', | |
| 114 | data=gpu.types.Buffer('FLOAT', len(accum_data), accum_data)) | |
| 115 | ||
| 116 | peak = _make_peak() | |
| 117 | depth_tex = _make_depth_texture(SIZE, NEAR, FAR) | |
| 118 | ||
| 119 | shader = ifs_viewport.build_tonemap() | |
| 120 | batch = batch_for_shader( | |
| 121 | shader, 'TRI_FAN', {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))}) | |
| 122 | ||
| 123 | with fb.bind(): | |
| 124 | fb.clear(color=(0.0, 0.0, 0.0, 1.0), depth=SCENE_DEPTH) | |
| 125 | shader.bind() | |
| 126 | shader.uniform_sampler('accum_tex', accum) | |
| 127 | shader.uniform_sampler('max_tex', peak) | |
| 128 | shader.uniform_sampler('depth_tex', depth_tex) | |
| 129 | shader.uniform_int('out_size', (SIZE, SIZE)) | |
| 130 | shader.uniform_float('brightness', 40.0) | |
| 131 | shader.uniform_int('shading_mode', (0,)) # EMISSION: opaque write | |
| 132 | shader.uniform_float('opacity_gain', 1.0) | |
| 133 | shader.uniform_float('relief', 1.0) | |
| 134 | gpu.state.blend_set('NONE') | |
| 135 | gpu.state.depth_mask_set(False) | |
| 136 | gpu.state.depth_test_set('LESS_EQUAL' if depth_test else 'NONE') | |
| 137 | batch.draw(shader) | |
| 138 | gpu.state.depth_test_set('NONE') | |
| 139 | rows = fb.read_color(0, 0, SIZE, SIZE, 3, 0, 'FLOAT').to_list() | |
| 140 | ||
| 141 | def mean(x0, x1): | |
| 142 | total = count = 0.0 | |
| 143 | for row in rows: | |
| 144 | for px in row[x0:x1]: | |
| 145 | total += sum(px[:3]) / 3.0 | |
| 146 | count += 1 | |
| 147 | return total / count | |
| 148 | ||
| 149 | return mean(0, SIZE // 2), mean(SIZE // 2, SIZE) | |
| 150 | ||
| 151 | def test_control_without_depth_test_both_halves_draw(self): | |
| 152 | """Without this, a shader that drew NOTHING would pass the occlusion test.""" | |
| 153 | left, right = self._render(depth_test=False) | |
| 154 | self.assertGreater(left, 0.01, "left half did not draw at all") | |
| 155 | self.assertGreater(right, 0.01, "right half did not draw at all") | |
| 156 | self.assertAlmostEqual(left, right, places=4, | |
| 157 | msg="halves differ with the depth test OFF, so the " | |
| 158 | "two regions are not otherwise identical") | |
| 159 | ||
| 160 | def test_in_front_draws_and_behind_is_hidden(self): | |
| 161 | left, right = self._render(depth_test=True) | |
| 162 | self.assertGreater(left, 0.01, | |
| 163 | "fractal IN FRONT of the surface was wrongly hidden — " | |
| 164 | "this is the failure an all-far depth buffer produces, " | |
| 165 | "and the one a screenshot cannot distinguish") | |
| 166 | self.assertLess(right, 1e-6, | |
| 167 | "fractal BEHIND the surface was wrongly drawn") | |
| 168 | ||
| 169 | def test_depth_buffer_is_not_written(self): | |
| 170 | """ | |
| 171 | The overlay must TEST depth without WRITING it: writing would let a nebula's | |
| 172 | depth occlude gizmos and grid drawn after us, and leave the buffer holding a | |
| 173 | value meaningless to everything downstream. | |
| 174 | """ | |
| 175 | import gpu | |
| 176 | from gpu_extras.batch import batch_for_shader | |
| 177 | import ifs_viewport | |
| 178 | ||
| 179 | colour = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F') | |
| 180 | depth_attach = gpu.types.GPUTexture((SIZE, SIZE), format='DEPTH_COMPONENT32F') | |
| 181 | fb = gpu.types.GPUFrameBuffer(depth_slot=depth_attach, color_slots=(colour,)) | |
| 182 | ||
| 183 | accum_data = [1.0] * (SIZE * SIZE * 4) | |
| 184 | accum = gpu.types.GPUTexture( | |
| 185 | (SIZE, SIZE), format='RGBA32F', | |
| 186 | data=gpu.types.Buffer('FLOAT', len(accum_data), accum_data)) | |
| 187 | peak = _make_peak() | |
| 188 | depth_tex = _make_depth_texture(SIZE, NEAR, NEAR) # all in front: all draws | |
| 189 | ||
| 190 | shader = ifs_viewport.build_tonemap() | |
| 191 | batch = batch_for_shader( | |
| 192 | shader, 'TRI_FAN', {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))}) | |
| 193 | ||
| 194 | with fb.bind(): | |
| 195 | fb.clear(color=(0.0, 0.0, 0.0, 1.0), depth=SCENE_DEPTH) | |
| 196 | shader.bind() | |
| 197 | shader.uniform_sampler('accum_tex', accum) | |
| 198 | shader.uniform_sampler('max_tex', peak) | |
| 199 | shader.uniform_sampler('depth_tex', depth_tex) | |
| 200 | shader.uniform_int('out_size', (SIZE, SIZE)) | |
| 201 | shader.uniform_float('brightness', 40.0) | |
| 202 | shader.uniform_int('shading_mode', (0,)) | |
| 203 | shader.uniform_float('opacity_gain', 1.0) | |
| 204 | shader.uniform_float('relief', 1.0) | |
| 205 | gpu.state.blend_set('NONE') | |
| 206 | gpu.state.depth_mask_set(False) | |
| 207 | gpu.state.depth_test_set('LESS_EQUAL') | |
| 208 | batch.draw(shader) | |
| 209 | gpu.state.depth_test_set('NONE') | |
| 210 | after = fb.read_depth(0, 0, SIZE, SIZE).to_list() | |
| 211 | ||
| 212 | flat = [v for row in after for v in (row if isinstance(row, (list, tuple)) else [row])] | |
| 213 | worst = max(abs(v - SCENE_DEPTH) for v in flat) | |
| 214 | self.assertLess(worst, 1e-6, | |
| 215 | "the overlay wrote to the depth buffer; it must only test") | |
| 216 | ||
| 217 | ||
| 218 | if __name__ == "__main__": | |
| 219 | argv = [a for a in sys.argv if not a.endswith(".blend")] | |
| 220 | unittest.main(argv=argv[:1], exit=False, verbosity=2) |