"""
A fractal whose depth STRADDLES a surface must composite smoothly, not as noise.
This reproduces, headlessly, the artefact visible in the viewport when a cube sits
inside a nebula: a hard rectangle of white salt-and-pepper where the cube is.
The cause is not opacity and no blend mode fixes it. `depth_img` holds the
NEAREST sample's depth, which is a random variable — a min over stochastic
samples. Where the fractal's depth distribution straddles the surface, the
LESS_EQUAL test 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.
The honest answer for a pixel where the nebula surrounds a cube is not yes/no but
"this fraction of the emission along the ray is in front". One depth and one
colour per pixel cannot express that; several depth slices can.
So the test drives the real tonemap against a framebuffer whose depth attachment
it owns, with a depth image full of NOISE straddling that surface, and measures
the spatial roughness of the result. Dithering is high-frequency by construction,
so neighbour-to-neighbour variance separates it from a smooth partial blend
without needing to know what the right brightness is.
STATUS: the artefact test is an EXPECTED FAILURE. It reproduces a defect that is
not yet fixed, and is committed in that state deliberately — the reproduction is
worth having pinned, and marking it expected keeps the suite honest rather than
either hiding the defect or leaving a red test nobody trusts. Measured on the
current code: roughness 0.4995 straddling against 0.0000 unoccluded.
THE FIX, when it is built: accumulate into several depth SLICES rather than one
buffer, then blit them back-to-front with each slice writing its own
gl_FragDepth. The hardware depth test then rejects whole slices per pixel, so
occlusion becomes N-step instead of binary and the dithering disappears. It needs
no access to Blender's depth texture, which Python cannot obtain as a GPU texture
(see issue #7) — only N x the accumulator and N blits. It is a coordinated change
across accumulate_hit, the drain, maxpass, the tonemap and the still-render path
that shares the kernel, which is why it is not in this commit.
blender -b --factory-startup -noaudio --python blender/test_depth_slices.py
"""
import os
import struct
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
SIZE = 64
SURFACE_DEPTH = 0.5 # the cube's face, in window space
SPREAD = 0.12 # the fractal's depth spread around it
# GPUTexture(data=...) accepts FLOAT buffers only, so the R32UI depth map is
# filled on the GPU — and bit-casting in the shader keeps this using the SAME
# encoding the kernel uses rather than inventing a second one here. A cheap hash
# gives per-pixel noise without a random buffer to upload.
_NOISE_FILL = """
void main()
{
ivec2 c = ivec2(gl_GlobalInvocationID.xy);
if (c.x >= size.x || c.y >= size.y) return;
uint h = uint(c.x) * 73856093u ^ uint(c.y) * 19349663u ^ uint(seed) * 83492791u;
h ^= h >> 13; h *= 1274126177u; h ^= h >> 16;
float u = float(h) / 4294967295.0;
float d = clamp(centre + (u - 0.5) * 2.0 * spread, 0.0, 1.0);
imageStore(dst, c, uvec4(floatBitsToUint(d), 0u, 0u, 0u));
}
"""
def _noise_depth(size, centre, spread, seed=0):
"""
A depth image straddling `centre` — what a min-over-samples buffer really
looks like where a nebula surrounds a surface.
Stored as floatBitsToUint of window-space z, the kernel's encoding.
"""
import gpu
tex = gpu.types.GPUTexture((size, size), format='R32UI')
info = gpu.types.GPUShaderCreateInfo()
info.local_group_size(8, 8)
info.image(0, 'R32UI', 'UINT_2D', 'dst', qualifiers={'NO_RESTRICT', 'WRITE'})
info.push_constant('IVEC2', 'size')
info.push_constant('FLOAT', 'centre')
info.push_constant('FLOAT', 'spread')
info.push_constant('INT', 'seed')
info.compute_source(_NOISE_FILL)
shader = gpu.shader.create_from_info(info)
shader.bind()
shader.image('dst', tex)
shader.uniform_int('size', (size, size))
shader.uniform_float('centre', centre)
shader.uniform_float('spread', spread)
shader.uniform_int('seed', (seed,))
gpu.compute.dispatch(shader, (size + 7) // 8, (size + 7) // 8, 1)
return tex
_RANGE_FILL = """
void main()
{
if (gl_GlobalInvocationID.x > 1u) return;
/* Texel 0 holds the MINIMUM COMPLEMENTED, matching the production reduction:
GPUTexture.clear writes one value to every texel, so a min and a max cannot
both be seeded from a single clear, and storing ~min lets one clear to zero
serve both. Writing it uncomplemented here made the reader recover a NaN,
the range test `dmax > dmin` failed, and the whole thing silently fell back
to single-band behaviour — which the sweep caught as "only 2 distinct
levels". */
uint bits = floatBitsToUint((gl_GlobalInvocationID.x == 0u) ? lo : hi);
if (gl_GlobalInvocationID.x == 0u) bits = ~bits;
imageStore(dst, ivec2(int(gl_GlobalInvocationID.x), 0),
uvec4(bits, 0u, 0u, 0u));
}
"""
def _range_texture(lo, hi):
"""The 2-texel min/max buffer the tonemap bands against, filled on the GPU."""
import gpu
tex = gpu.types.GPUTexture((2, 1), format='R32UI')
info = gpu.types.GPUShaderCreateInfo()
info.local_group_size(2, 1)
info.image(0, 'R32UI', 'UINT_2D', 'dst', qualifiers={'NO_RESTRICT', 'WRITE'})
info.push_constant('FLOAT', 'lo')
info.push_constant('FLOAT', 'hi')
info.compute_source(_RANGE_FILL)
shader = gpu.shader.create_from_info(info)
shader.bind()
shader.image('dst', tex)
shader.uniform_float('lo', lo)
shader.uniform_float('hi', hi)
gpu.compute.dispatch(shader, 1, 1, 1)
return tex
def _roughness(image):
"""
Mean absolute difference between horizontal neighbours, normalised.
Salt-and-pepper is high-frequency by construction, so this separates it from a
smooth gradient without assuming what the correct brightness is.
"""
import numpy as np
lum = image[:, :, 0]
if float(lum.max()) <= 0.0:
return 0.0
diff = np.abs(np.diff(lum, axis=1))
return float(diff.mean() / lum.max())
class DepthStraddle(unittest.TestCase):
@classmethod
def setUpClass(cls):
try:
import gpu
import numpy # noqa: F401
except ImportError as ex:
raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")
import ifs_viewport # noqa: F401 — ours, unguarded
gpu.init()
def _composite(self, depth_tex):
"""Runs the real tonemap over a uniform accumulator against a scene surface."""
import gpu
import numpy as np
import ifs_viewport
from gpu_extras.batch import batch_for_shader
# A uniform, fully-lit accumulator: any structure in the OUTPUT therefore
# comes from compositing, not from the fractal.
accum = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F')
accum.clear(format='FLOAT', value=(1.0, 1.0, 1.0, 1.0))
maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI')
maxbuf.clear(format='UINT', value=(0, 0, 0, 0))
maxpass = ifs_viewport.build_maxpass()
maxpass.bind()
maxpass.image('accum_img', accum)
maxpass.image('max_img', maxbuf)
maxpass.uniform_int('out_size', (SIZE, SIZE))
gpu.compute.dispatch(maxpass, (SIZE + 7) // 8, (SIZE + 7) // 8, 1)
colour = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F')
depth_slot = gpu.types.GPUTexture((SIZE, SIZE), format='DEPTH_COMPONENT32F')
fb = gpu.types.GPUFrameBuffer(depth_slot=depth_slot, color_slots=(colour,))
tonemap = ifs_viewport.build_tonemap()
batch = batch_for_shader(tonemap, 'TRI_FAN',
{"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))})
with fb.bind():
# The scene surface: a flat wall at SURFACE_DEPTH across the frame.
fb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=SURFACE_DEPTH)
tonemap.bind()
tonemap.uniform_sampler('accum_tex', accum)
tonemap.uniform_sampler('max_tex', maxbuf)
tonemap.uniform_sampler('depth_tex', depth_tex)
tonemap.uniform_sampler('range_tex', depth_tex)
tonemap.uniform_int('out_size', (SIZE, SIZE))
tonemap.uniform_float('brightness', 8.0)
tonemap.uniform_int('shading_mode', (0,))
tonemap.uniform_float('opacity_gain', 4.0)
tonemap.uniform_float('relief', 12.0)
gpu.state.blend_set('ADDITIVE_PREMULT')
gpu.state.depth_mask_set(False)
gpu.state.depth_test_set('LESS_EQUAL')
batch.draw(tonemap)
gpu.state.depth_test_set('NONE')
gpu.state.blend_set('NONE')
raw = fb.read_color(0, 0, SIZE, SIZE, 3, 0, 'FLOAT').to_list()
return np.array(raw, dtype=np.float32).reshape(SIZE, SIZE, 3)
# ------------------------------------------------------------- the controls
def test_wholly_in_front_draws_smoothly(self):
"""A fractal entirely nearer than the surface must be smooth — this is the
case that already works, and it calibrates what 'smooth' measures."""
image = self._composite(_noise_depth(SIZE, 0.2, 0.02))
rough = _roughness(image)
print(f"[slices] wholly in front: roughness {rough:.4f}")
self.assertLess(rough, 0.05, 'even an unoccluded fractal is speckled')
def test_wholly_behind_is_hidden(self):
"""And entirely behind must vanish, or 'smooth' is satisfied by drawing
nothing at all."""
import numpy as np
image = self._composite(_noise_depth(SIZE, 0.9, 0.02))
self.assertLess(float(image.max()), 0.02,
'a fractal behind the surface still drew')
# ------------------------------------------------------------- the artefact
@unittest.expectedFailure # KNOWN: one depth per pixel cannot express this
def test_straddling_the_surface_does_not_dither(self):
"""
THE test. With one depth per pixel this is salt-and-pepper: the min-depth
buffer is noise, so LESS_EQUAL flips per pixel and the full brightness
lands wherever it passes.
Compared against the wholly-in-front control rather than an absolute
threshold, because what counts as rough depends on the tonemap.
"""
smooth = _roughness(self._composite(_noise_depth(SIZE, 0.2, 0.02)))
straddle = _roughness(self._composite(
_noise_depth(SIZE, SURFACE_DEPTH, SPREAD)))
print(f"[slices] straddling: roughness {straddle:.4f} "
f"against {smooth:.4f} unoccluded ({straddle / max(smooth, 1e-9):.1f}x)")
self.assertLess(
straddle, max(smooth * 3.0, 0.05),
f'a fractal straddling the surface composited at {straddle:.4f} '
f'roughness against {smooth:.4f} unoccluded — it is dithering, which '
'is the white speckle seen where a cube sits inside the nebula')
class SlicedOcclusion(unittest.TestCase):
"""
With depth BANDS, occlusion is graded rather than binary.
The single-buffer path above cannot do better than yes/no per pixel, and its
yes/no comes from a min over stochastic samples — hence the speckle. Bands
replace that: the accumulator is N screens tall, a sample lands in the band its
own depth falls into, and each band is blitted with its OWN gl_FragDepth. The
hardware then discards whole bands per pixel.
So the claim is not "smoother". It is that the drawn energy TRACKS the depth of
the occluding surface, which a binary test cannot do at all. That is what these
measure, by sweeping the surface through the bands and checking the result is
monotonic and proportional.
Note gl_FragDepth is `(band + 1) / bands` — the band's FAR edge — so a surface
at 0.5 with 8 bands keeps bands 0..3 and discards 4..7.
"""
BANDS = 8
@classmethod
def setUpClass(cls):
try:
import gpu
import numpy # noqa: F401
except ImportError as ex:
raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")
import ifs_viewport # noqa: F401
gpu.init()
def _composite_bands(self, surface_depth, bands=None):
"""Fills every band equally, blits them all, returns the drawn image."""
import gpu
import numpy as np
import ifs_viewport
from gpu_extras.batch import batch_for_shader
bands = bands or self.BANDS
# A TALL accumulator: `bands` screens stacked, each uniformly lit. Equal
# energy per band means the drawn total is a direct count of survivors.
accum = gpu.types.GPUTexture((SIZE, SIZE * bands), format='RGBA32F')
accum.clear(format='FLOAT', value=(1.0, 1.0, 1.0, 1.0))
maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI')
maxbuf.clear(format='UINT', value=(0, 0, 0, 0))
maxpass = ifs_viewport.build_maxpass()
maxpass.bind()
maxpass.image('accum_img', accum)
maxpass.image('max_img', maxbuf)
maxpass.uniform_int('out_size', (SIZE, SIZE * bands))
gpu.compute.dispatch(maxpass, (SIZE + 7) // 8, (SIZE * bands + 7) // 8, 1)
far = gpu.types.GPUTexture((SIZE, SIZE), format='R32UI')
far.clear(format='UINT', value=(0x3F800000, 0, 0, 0))
# A REAL range: bands map onto the full [0,1] here so the sweep below has
# known answers. Binding an empty range instead would silently exercise the
# single-band fallback and the test would prove nothing about bands.
rng = _range_texture(0.0, 1.0)
colour = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F')
depth_slot = gpu.types.GPUTexture((SIZE, SIZE), format='DEPTH_COMPONENT32F')
fb = gpu.types.GPUFrameBuffer(depth_slot=depth_slot, color_slots=(colour,))
tonemap = ifs_viewport.build_tonemap()
batch = batch_for_shader(tonemap, 'TRI_FAN',
{"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))})
with fb.bind():
fb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=surface_depth)
tonemap.bind()
tonemap.uniform_sampler('accum_tex', accum)
tonemap.uniform_sampler('max_tex', maxbuf)
tonemap.uniform_sampler('depth_tex', far)
tonemap.uniform_sampler('range_tex', rng)
tonemap.uniform_int('out_size', (SIZE, SIZE))
tonemap.uniform_float('brightness', 8.0)
tonemap.uniform_int('shading_mode', (0,))
tonemap.uniform_float('opacity_gain', 4.0)
tonemap.uniform_float('relief', 12.0)
tonemap.uniform_int('depth_slices', (bands,))
gpu.state.blend_set('ADDITIVE_PREMULT')
gpu.state.depth_mask_set(False)
gpu.state.depth_test_set('LESS_EQUAL')
for band in reversed(range(bands)):
tonemap.uniform_int('slice', (band,))
batch.draw(tonemap)
gpu.state.depth_test_set('NONE')
gpu.state.blend_set('NONE')
raw = fb.read_color(0, 0, SIZE, SIZE, 3, 0, 'FLOAT').to_list()
return np.array(raw, dtype=np.float32).reshape(SIZE, SIZE, 3)
def test_occlusion_is_graded_not_binary(self):
"""
THE claim. Sweep the surface through the bands; the drawn energy must rise
monotonically and land in between at the midpoint — a binary test can only
ever produce two values.
"""
import numpy as np
levels = {}
for surface in (0.0, 0.25, 0.5, 0.75, 1.0):
levels[surface] = float(self._composite_bands(surface).mean())
print("[slices] drawn energy by surface depth: " +
" ".join(f"{d}->{v:.4f}" for d, v in levels.items()))
ordered = [levels[d] for d in (0.0, 0.25, 0.5, 0.75, 1.0)]
for a, b in zip(ordered, ordered[1:]):
self.assertLessEqual(a, b + 1e-6,
f"energy is not monotonic in surface depth: {ordered}")
self.assertGreater(ordered[-1], ordered[0] + 1e-4,
'a surface at the far plane drew no more than one at the '
'near plane — nothing is being graded')
distinct = len({round(v, 4) for v in ordered})
self.assertGreater(distinct, 2,
f'only {distinct} distinct levels across the sweep — that '
'is a binary test, not graded occlusion')
def test_a_surface_at_the_far_plane_hides_nothing(self):
"""Control: everything must draw, or 'graded' is satisfied by drawing less
and less of nothing."""
image = self._composite_bands(1.0)
self.assertGreater(float(image.max()), 0.05, 'nothing drew at all')
def test_a_surface_at_the_near_plane_hides_everything(self):
"""The other control."""
image = self._composite_bands(0.0)
self.assertLess(float(image.max()), 0.02,
'a surface at the near plane failed to hide the fractal')
if __name__ == "__main__":
result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result
sys.exit(0 if result.wasSuccessful() else 1)