OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 32a8853

Pin the cube-in-nebula speckle with a headless reproduction
Reported from the viewport as a hard rectangle of white salt-and-pepper where a cube sits inside the nebula, and asked about as an opacity problem. It is not, and no blend mode fixes it. `depth_img` holds the NEAREST sample's depth — a min over stochastic samples, so a random variable per pixel. Where the fractal's depth distribution straddles a surface, LESS_EQUAL passes on roughly half the pixels and fails on the rest, and where it passes the FULL accumulated brightness lands on the surface. Hence speckle, and hence white. Measured, driving the real tonemap against a framebuffer whose depth attachment the test owns: wholly in front roughness 0.0000 (smooth, and drawn) wholly behind max < 0.02 (hidden) STRADDLING roughness 0.4995 (neighbours differ by half the peak) 0.4995 is essentially maximal dithering. Both controls are needed: without "wholly behind" a shader that drew nothing would satisfy "smooth". Committed as an EXPECTED FAILURE. The defect is real and unfixed, and marking it expected keeps the suite honest — neither hiding it nor leaving a red test that erodes trust in the others. The fix is depth SLICES: accumulate into several depth layers and blit them back-to-front, each writing its own gl_FragDepth, so the hardware rejects whole slices per pixel and occlusion becomes N-step instead of binary. It needs no access to Blender's depth texture — which Python cannot get as a GPU texture, the blocker recorded in issue #7 — only N x the accumulator and N blits. Not in this commit because it is a coordinated change across accumulate_hit, the drain, maxpass, the tonemap AND the still-render path that shares the kernel. The reproduction is worth landing first: it is the gate that will tell us the slices worked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
32a8853712144562ee554bbaef6b6463875aad1f
by plankatron <fleeky@gmail.com> · 2026-08-18T18:46:26.000Z

Changed files (1) +218 -0

blender/test_depth_slices.py +218 -0
……@@ -0,0 +1,218 @@
1"""
2A fractal whose depth STRADDLES a surface must composite smoothly, not as noise.
3
4This reproduces, headlessly, the artefact visible in the viewport when a cube sits
5inside a nebula: a hard rectangle of white salt-and-pepper where the cube is.
6
7The cause is not opacity and no blend mode fixes it. `depth_img` holds the
8NEAREST sample's depth, which is a random variable — a min over stochastic
9samples. Where the fractal's depth distribution straddles the surface, the
10LESS_EQUAL test passes on roughly half the pixels and fails on the rest, and where
11it passes the FULL accumulated brightness lands on the surface. Hence speckle,
12and hence white.
13
14The honest answer for a pixel where the nebula surrounds a cube is not yes/no but
15"this fraction of the emission along the ray is in front". One depth and one
16colour per pixel cannot express that; several depth slices can.
17
18So the test drives the real tonemap against a framebuffer whose depth attachment
19it owns, with a depth image full of NOISE straddling that surface, and measures
20the spatial roughness of the result. Dithering is high-frequency by construction,
21so neighbour-to-neighbour variance separates it from a smooth partial blend
22without needing to know what the right brightness is.
23
24STATUS: the artefact test is an EXPECTED FAILURE. It reproduces a defect that is
25not yet fixed, and is committed in that state deliberately — the reproduction is
26worth having pinned, and marking it expected keeps the suite honest rather than
27either hiding the defect or leaving a red test nobody trusts. Measured on the
28current code: roughness 0.4995 straddling against 0.0000 unoccluded.
29
30THE FIX, when it is built: accumulate into several depth SLICES rather than one
31buffer, then blit them back-to-front with each slice writing its own
32gl_FragDepth. The hardware depth test then rejects whole slices per pixel, so
33occlusion becomes N-step instead of binary and the dithering disappears. It needs
34no access to Blender's depth texture, which Python cannot obtain as a GPU texture
35(see issue #7) — only N x the accumulator and N blits. It is a coordinated change
36across accumulate_hit, the drain, maxpass, the tonemap and the still-render path
37that shares the kernel, which is why it is not in this commit.
38
39 blender -b --factory-startup -noaudio --python blender/test_depth_slices.py
40"""
41
42import os
43import struct
44import sys
45import unittest
46
47sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
48
49SIZE = 64
50SURFACE_DEPTH = 0.5 # the cube's face, in window space
51SPREAD = 0.12 # the fractal's depth spread around it
52
53
54# GPUTexture(data=...) accepts FLOAT buffers only, so the R32UI depth map is
55# filled on the GPU — and bit-casting in the shader keeps this using the SAME
56# encoding the kernel uses rather than inventing a second one here. A cheap hash
57# gives per-pixel noise without a random buffer to upload.
58_NOISE_FILL = """
59void main()
60{
61 ivec2 c = ivec2(gl_GlobalInvocationID.xy);
62 if (c.x >= size.x || c.y >= size.y) return;
63 uint h = uint(c.x) * 73856093u ^ uint(c.y) * 19349663u ^ uint(seed) * 83492791u;
64 h ^= h >> 13; h *= 1274126177u; h ^= h >> 16;
65 float u = float(h) / 4294967295.0;
66 float d = clamp(centre + (u - 0.5) * 2.0 * spread, 0.0, 1.0);
67 imageStore(dst, c, uvec4(floatBitsToUint(d), 0u, 0u, 0u));
68}
69"""
70
71
72def _noise_depth(size, centre, spread, seed=0):
73 """
74 A depth image straddling `centre` — what a min-over-samples buffer really
75 looks like where a nebula surrounds a surface.
76
77 Stored as floatBitsToUint of window-space z, the kernel's encoding.
78 """
79 import gpu
80 tex = gpu.types.GPUTexture((size, size), format='R32UI')
81 info = gpu.types.GPUShaderCreateInfo()
82 info.local_group_size(8, 8)
83 info.image(0, 'R32UI', 'UINT_2D', 'dst', qualifiers={'NO_RESTRICT', 'WRITE'})
84 info.push_constant('IVEC2', 'size')
85 info.push_constant('FLOAT', 'centre')
86 info.push_constant('FLOAT', 'spread')
87 info.push_constant('INT', 'seed')
88 info.compute_source(_NOISE_FILL)
89 shader = gpu.shader.create_from_info(info)
90 shader.bind()
91 shader.image('dst', tex)
92 shader.uniform_int('size', (size, size))
93 shader.uniform_float('centre', centre)
94 shader.uniform_float('spread', spread)
95 shader.uniform_int('seed', (seed,))
96 gpu.compute.dispatch(shader, (size + 7) // 8, (size + 7) // 8, 1)
97 return tex
98
99
100def _roughness(image):
101 """
102 Mean absolute difference between horizontal neighbours, normalised.
103
104 Salt-and-pepper is high-frequency by construction, so this separates it from a
105 smooth gradient without assuming what the correct brightness is.
106 """
107 import numpy as np
108 lum = image[:, :, 0]
109 if float(lum.max()) <= 0.0:
110 return 0.0
111 diff = np.abs(np.diff(lum, axis=1))
112 return float(diff.mean() / lum.max())
113
114
115class DepthStraddle(unittest.TestCase):
116 @classmethod
117 def setUpClass(cls):
118 try:
119 import gpu
120 import numpy # noqa: F401
121 except ImportError as ex:
122 raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")
123 import ifs_viewport # noqa: F401 — ours, unguarded
124 gpu.init()
125
126 def _composite(self, depth_tex):
127 """Runs the real tonemap over a uniform accumulator against a scene surface."""
128 import gpu
129 import numpy as np
130 import ifs_viewport
131 from gpu_extras.batch import batch_for_shader
132
133 # A uniform, fully-lit accumulator: any structure in the OUTPUT therefore
134 # comes from compositing, not from the fractal.
135 accum = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F')
136 accum.clear(format='FLOAT', value=(1.0, 1.0, 1.0, 1.0))
137 maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI')
138 maxbuf.clear(format='UINT', value=(0, 0, 0, 0))
139 maxpass = ifs_viewport.build_maxpass()
140 maxpass.bind()
141 maxpass.image('accum_img', accum)
142 maxpass.image('max_img', maxbuf)
143 maxpass.uniform_int('out_size', (SIZE, SIZE))
144 gpu.compute.dispatch(maxpass, (SIZE + 7) // 8, (SIZE + 7) // 8, 1)
145
146 colour = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F')
147 depth_slot = gpu.types.GPUTexture((SIZE, SIZE), format='DEPTH_COMPONENT32F')
148 fb = gpu.types.GPUFrameBuffer(depth_slot=depth_slot, color_slots=(colour,))
149
150 tonemap = ifs_viewport.build_tonemap()
151 batch = batch_for_shader(tonemap, 'TRI_FAN',
152 {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))})
153 with fb.bind():
154 # The scene surface: a flat wall at SURFACE_DEPTH across the frame.
155 fb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=SURFACE_DEPTH)
156 tonemap.bind()
157 tonemap.uniform_sampler('accum_tex', accum)
158 tonemap.uniform_sampler('max_tex', maxbuf)
159 tonemap.uniform_sampler('depth_tex', depth_tex)
160 tonemap.uniform_int('out_size', (SIZE, SIZE))
161 tonemap.uniform_float('brightness', 8.0)
162 tonemap.uniform_int('shading_mode', (0,))
163 tonemap.uniform_float('opacity_gain', 4.0)
164 tonemap.uniform_float('relief', 12.0)
165 gpu.state.blend_set('ADDITIVE_PREMULT')
166 gpu.state.depth_mask_set(False)
167 gpu.state.depth_test_set('LESS_EQUAL')
168 batch.draw(tonemap)
169 gpu.state.depth_test_set('NONE')
170 gpu.state.blend_set('NONE')
171 raw = fb.read_color(0, 0, SIZE, SIZE, 3, 0, 'FLOAT').to_list()
172 return np.array(raw, dtype=np.float32).reshape(SIZE, SIZE, 3)
173
174 # ------------------------------------------------------------- the controls
175
176 def test_wholly_in_front_draws_smoothly(self):
177 """A fractal entirely nearer than the surface must be smooth — this is the
178 case that already works, and it calibrates what 'smooth' measures."""
179 image = self._composite(_noise_depth(SIZE, 0.2, 0.02))
180 rough = _roughness(image)
181 print(f"[slices] wholly in front: roughness {rough:.4f}")
182 self.assertLess(rough, 0.05, 'even an unoccluded fractal is speckled')
183
184 def test_wholly_behind_is_hidden(self):
185 """And entirely behind must vanish, or 'smooth' is satisfied by drawing
186 nothing at all."""
187 import numpy as np
188 image = self._composite(_noise_depth(SIZE, 0.9, 0.02))
189 self.assertLess(float(image.max()), 0.02,
190 'a fractal behind the surface still drew')
191
192 # ------------------------------------------------------------- the artefact
193
194 @unittest.expectedFailure # KNOWN: one depth per pixel cannot express this
195 def test_straddling_the_surface_does_not_dither(self):
196 """
197 THE test. With one depth per pixel this is salt-and-pepper: the min-depth
198 buffer is noise, so LESS_EQUAL flips per pixel and the full brightness
199 lands wherever it passes.
200
201 Compared against the wholly-in-front control rather than an absolute
202 threshold, because what counts as rough depends on the tonemap.
203 """
204 smooth = _roughness(self._composite(_noise_depth(SIZE, 0.2, 0.02)))
205 straddle = _roughness(self._composite(
206 _noise_depth(SIZE, SURFACE_DEPTH, SPREAD)))
207 print(f"[slices] straddling: roughness {straddle:.4f} "
208 f"against {smooth:.4f} unoccluded ({straddle / max(smooth, 1e-9):.1f}x)")
209 self.assertLess(
210 straddle, max(smooth * 3.0, 0.05),
211 f'a fractal straddling the surface composited at {straddle:.4f} '
212 f'roughness against {smooth:.4f} unoccluded — it is dithering, which '
213 'is the white speckle seen where a cube sits inside the nebula')
214
215
216if __name__ == "__main__":
217 result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result
218 sys.exit(0 if result.wasSuccessful() else 1)