OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 1069812

Scene geometry occludes the live overlay, and a test that can tell
The viewport overlay was a screen-space blit with no depth: it drew over everything. It now hands the hardware a per-pixel depth and is occluded by scene geometry. The kernel accumulates the NEAREST contributing sample's depth into an R32UI image via imageAtomicMin, and the tonemap writes it to gl_FragDepth. The bit pattern of a float in [0,1] is monotonic, so the atomic min over floatBitsToUint is exact - no fixed-point scale, unlike the histogram. Depth comes from BLENDER's view-projection, passed in as a MAT4, not the engine's: view_proj_mat is mirrored in x and its z does not correspond to what Blender's depth buffer holds. The overlay tests depth without writing it, so a nebula's depth cannot occlude gizmos drawn after it. Depth is written inside accumulate_hit, not at the call sites, because there are two of them - the direct plot and the density-estimation filter, which splats into NEIGHBOURING pixels. Writing at the call site leaves every filtered pixel at the far plane, so blurred regions silently refuse to be occluded. A flame is an accumulation along the whole ray and has no single depth. Nearest sample is the honest choice: exact when the fractal is wholly in front of or behind an object, erring toward showing it when they interpenetrate. The depth map is at the accumulator's half/quarter resolution, so occlusion edges are coarser than the geometry casting them. The still and volume paths are untouched - rewrite(depth=True) is opt-in, since a declared-but-unused image still costs one of Blender's eight slots, and depth is rejected outright in volume mode, which never projects to a pixel. test_depth_composite.py exists because the obvious verifications cannot tell success from failure. A depth buffer that was never written holds the far value everywhere, which fails LESS_EQUAL against every surface and passes against the 1.0 background - a picture identical to correct occlusion. Live-viewport probes are confounded by accumulation drift and by the fractal's own extent past the occluder; several such attempts gave contradictory answers. So the real tonemap shader is driven against a framebuffer whose depth attachment the test owns: scene depth 0.6, fractal at 0.3 on the left (draws) and 0.9 on the right (hidden), with a control pass that requires both halves to draw when the test is off. Mutating the fixture to the all-far case turns it red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1069812f844c8aad34b06d78529df1371da1b2f2
by plankatron <fleeky@gmail.com> · 2026-08-17T17:24:54.000Z

Changed files (5) +535 -36

blender/ifs_layout.py +77 -1
……@@ -115,6 +115,67 @@ _ACCUMULATE_NEW = """void accumulate_hit(ivec2 proj, vec4 color)
115115\timageAtomicAdd(histogram_img, base + ivec2(3, 0), uint(max(color.w, 0.0) * HIST_SCALE));
116116}"""
117117
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.
134DEPTH_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.
140uint 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.
146void 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
173DEPTH_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
118179# p_state is 8 floats; the two ints ride through as bit patterns so nothing is lost
119180# to float rounding on a large iterator index or depth.
120181_STATE_HELPERS = """
……@@ -319,14 +380,22 @@ class RewriteReport:
319380
320381def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE,
321382 state_width: int = 256,
322 volume: bool = False) -> tuple[str, str, RewriteReport]:
383 volume: bool = False,
384 depth: bool = False) -> tuple[str, str, RewriteReport]:
323385 """
324386 Rewrites an engine kernel onto the Blender resource model.
325387
326388 Every substitution is checked: if the kernel changes upstream such that a
327389 pattern no longer matches, that lands in `report.missing` rather than silently
328390 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.
329395 """
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")
330399 applied: list[str] = []
331400 missing: list[str] = []
332401
……@@ -358,6 +427,11 @@ def rewrite(source: str, hist_scale: float = DEFAULT_HIST_SCALE,
358427 "replace the projection block with a voxel write", out)
359428 out = sub(_ACCUMULATE, lambda _m: _VOXEL_HELPER,
360429 "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)
361435 else:
362436 out = sub(_ACCUMULATE, lambda _m: _ACCUMULATE_NEW, "rewrite accumulate_hit", out)
363437 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,
372446 # The helpers reference p_state, so they must follow its declaration.
373447 anchor = "//Shader Storage Buffer Objects"
374448 helpers = _STATE_HELPERS.format(hist_scale=hist_scale, state_w=state_width)
449 if depth:
450 helpers += _DEPTH_HELPERS
375451 if anchor in out:
376452 out = out.replace(anchor, helpers + "\n" + anchor, 1)
377453 applied.append("insert state helpers")
blender/ifs_render.py +14 -2
……@@ -195,7 +195,13 @@ void main()
195195"""
196196
197197
198def _build_shaders(width, height, hist_scale):
198def _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 """
199205 library = load_library(os.path.join(REPO, "Transforms"),
200206 os.path.join(REPO, "WpfDisplay", "Library", "Includes"))
201207 template_path = os.path.join(REPO, "IFSEngine", "Rendering", "Shaders",
……@@ -203,7 +209,7 @@ def _build_shaders(width, height, hist_scale):
203209 with open(template_path, "r", encoding="utf-8-sig") as handle:
204210 kernel = generate_kernel(handle.read(), library)
205211 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)
207213 if not report.ok:
208214 raise RuntimeError("kernel rewrite incomplete: " + "; ".join(report.missing))
209215
……@@ -213,6 +219,12 @@ def _build_shaders(width, height, hist_scale):
213219 rw = {'NO_RESTRICT', 'READ', 'WRITE'}
214220 info.image(0, 'R32UI', 'UINT_2D', 'histogram_img', qualifiers=rw)
215221 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')
216228 info.sampler(0, 'FLOAT_2D', 'alias_tables_tex')
217229 info.uniform_buf(0, 'FrameParams', 'frame')
218230 info.uniform_buf(1, 'SettingsParams', 'settings')
blender/ifs_viewport.py +78 -33
……@@ -32,6 +32,7 @@ from mathutils import Vector
3232
3333import ifs_document
3434import ifs_host
35import ifs_layout
3536import ifs_render
3637
3738# 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)
7980void main()
8081{
8182 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
8295 vec4 h = texelFetch(accum_tex, c, 0);
8396 float peak = max(float(texelFetch(max_tex, ivec2(0, 0), 0).x), 1.0);
8497 float d = max(h.w, 1e-12);
……@@ -129,6 +142,45 @@ void main()
129142"""
130143
131144
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
150def 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
161def 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
132184class Session:
133185 """Persistent GPU state for one live viewport."""
134186
……@@ -147,9 +199,9 @@ class Session:
147199 self.frames = 0
148200
149201 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()
153205 self.batch = batch_for_shader(
154206 self.tonemap, 'TRI_FAN',
155207 {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))})
……@@ -276,36 +328,6 @@ class Session:
276328 self._dirty_params = False
277329 self.invalidate()
278330
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
309331 # ------------------------------------------------------------------ buffers
310332
311333 def _build_buffers(self):
……@@ -334,6 +356,9 @@ class Session:
334356 self.histogram = gpu.types.GPUTexture((width * 4, height), format='R32UI')
335357 self.accum = gpu.types.GPUTexture((width, height), format='RGBA32F')
336358 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')
337362 rows = (ifs_render.INVOCATIONS + ifs_render.STATE_W - 1) // ifs_render.STATE_W
338363 self.state = gpu.types.GPUTexture(
339364 (ifs_render.STATE_W * 2, rows), format='RGBA32F')
……@@ -345,6 +370,9 @@ class Session:
345370 self.histogram.clear(format='UINT', value=(0, 0, 0, 0))
346371 self.accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0))
347372 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))
348376 # dispatch_count is NOT reset: it is hash input, and rewinding it makes
349377 # every restart draw the same points. Under continuous camera motion that
350378 # is a fixed constellation sliding across the screen rather than the
……@@ -446,6 +474,14 @@ class Session:
446474 self.kernel.bind()
447475 self.kernel.image('histogram_img', self.histogram)
448476 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)
449485 self.kernel.uniform_sampler('alias_tables_tex', self.alias_tex)
450486 self.kernel.uniform_block('frame', frame)
451487 self.kernel.uniform_block('settings', settings_ubo)
……@@ -474,6 +510,7 @@ class Session:
474510 self.tonemap.bind()
475511 self.tonemap.uniform_sampler('accum_tex', self.accum)
476512 self.tonemap.uniform_sampler('max_tex', self.maxbuf)
513 self.tonemap.uniform_sampler('depth_tex', self.depthbuf)
477514 self.tonemap.uniform_int('out_size', (self.width, self.height))
478515 self.tonemap.uniform_float('brightness', self.brightness)
479516 self.tonemap.uniform_int('shading_mode', (self.shading_mode,))
……@@ -481,7 +518,15 @@ class Session:
481518 self.tonemap.uniform_float('relief', self.relief)
482519 # Emission ADDS light; the shaded modes are surfaces and composite over.
483520 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')
484528 self.batch.draw(self.tonemap)
529 gpu.state.depth_test_set('NONE')
485530 gpu.state.blend_set('NONE') # leave the viewport's state as we found it
486531
487532 self.frames += 1
blender/test_depth.py +146 -0
……@@ -0,0 +1,146 @@
1"""
2Tests for the nearest-sample depth channel that lets scene geometry occlude the
3live overlay.
4
5The 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
7two places — the direct plot and the density-estimation filter, which splats into
8NEIGHBOURING pixels — and an implementation that writes depth at the call site
9instead of inside the function leaves every filtered pixel at the far plane. That
10renders correctly in every still where filtering is off, and un-occludes exactly the
11blurred regions when it is on. So the two-call-site property is asserted directly.
12
13Run: python3 -m unittest discover -s blender
14"""
15
16import os
17import re
18import unittest
19
20from ifs_codegen import apply_portability_fixes, generate_kernel, load_library
21from ifs_layout import DEPTH_CLEAR, DEPTH_EXTRA_LAYOUT, rewrite
22
23REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
24TRANSFORMS = os.path.join(REPO, "Transforms")
25INCLUDES = os.path.join(REPO, "WpfDisplay", "Library", "Includes")
26TEMPLATE = os.path.join(REPO, "IFSEngine", "Rendering", "Shaders", "ifs_kernel.comp.shader")
27
28
29def 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
37class 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
145if __name__ == "__main__":
146 unittest.main()
blender/test_depth_composite.py +220 -0
……@@ -0,0 +1,220 @@
1"""
2Deterministic 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
5buffer full of the FAR value also produces a picture where geometry appears to
6occlude the fractal, because 1.0 fails LESS_EQUAL against every real surface and
7passes 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,
9where accumulation drift and the fractal's own extent confound every measurement.
10
11So this drives the real `build_tonemap()` shader against a framebuffer whose depth
12attachment 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
18Both halves carry identical colour and density, so any difference between them is
19the depth test and nothing else. The control pass repeats it with the test disabled
20and requires BOTH halves to draw — without that, a shader that drew nothing at all
21would satisfy the "hidden" half and look like a pass.
22
23Needs a GPU. Run inside Blender:
24 blender -b --factory-startup -noaudio --python blender/test_depth_composite.py
25"""
26
27import os
28import struct
29import sys
30import unittest
31
32# `blender --python` does not put the script's own directory on the path.
33sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
34
35SIZE = 64
36SCENE_DEPTH = 0.6
37NEAR = 0.3 # in front of the scene surface
38FAR = 0.9 # behind it
39
40
41def _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 = """
49void 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
59def _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
80def _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
87class 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
218if __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)