OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / test_depth_composite.py

227 lines · python ref:
"""
Deterministic proof that the overlay COMPOSITES against scene depth correctly.

`test_depth.py` proves the kernel writes depth. That is only half the claim: a
buffer full of the FAR value also produces a picture where geometry appears to
occlude the fractal, because 1.0 fails LESS_EQUAL against every real surface and
passes against the cleared background. The two are indistinguishable on screen, so
"I looked at a screenshot" cannot settle it — and neither can a live-viewport probe,
where accumulation drift and the fractal's own extent confound every measurement.

So this drives the real `build_tonemap()` shader against a framebuffer whose depth
attachment we clear ourselves, with a depth texture we fill ourselves:

    scene depth        := 0.6 everywhere
    fractal depth      := 0.3 on the left half   (in FRONT  -> must draw)
                          0.9 on the right half  (BEHIND    -> must be hidden)

Both halves carry identical colour and density, so any difference between them is
the depth test and nothing else. The control pass repeats it with the test disabled
and requires BOTH halves to draw — without that, a shader that drew nothing at all
would satisfy the "hidden" half and look like a pass.

Needs a GPU. Run inside Blender:
    blender -b --factory-startup -noaudio --python blender/test_depth_composite.py
"""

import os
import struct
import sys
import unittest

# `blender --python` does not put the script's own directory on the path.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

SIZE = 64
SCENE_DEPTH = 0.6
NEAR = 0.3      # in front of the scene surface
FAR = 0.9       # behind it


def _bits(value: float) -> int:
    return struct.unpack("<I", struct.pack("<f", value))[0]


# GPUTexture(data=...) accepts FLOAT buffers only, so the uint depth map is filled on
# the GPU instead. Passing the two depths as floats and bit-casting in the shader also
# keeps this using the SAME encoding the kernel uses, rather than a second one here.
_FILL = """
void main()
{
  ivec2 c = ivec2(gl_GlobalInvocationID.xy);
  if (c.x >= size.x || c.y >= size.y) return;
  float d = (c.x < size.x / 2) ? near_depth : far_depth;
  imageStore(dst, c, uvec4(floatBitsToUint(d), 0u, 0u, 0u));
}
"""


def _make_depth_texture(size, near, far):
    """An R32UI map: `near` on the left half, `far` on the right."""
    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', 'near_depth')
    info.push_constant('FLOAT', 'far_depth')
    info.compute_source(_FILL)
    shader = gpu.shader.create_from_info(info)
    shader.bind()
    shader.image('dst', tex)
    shader.uniform_int('size', (size, size))
    shader.uniform_float('near_depth', near)
    shader.uniform_float('far_depth', far)
    gpu.compute.dispatch(shader, (size + 7) // 8, (size + 7) // 8, 1)
    return tex


def _make_peak():
    import gpu
    tex = gpu.types.GPUTexture((1, 1), format='R32UI')
    tex.clear(format='UINT', value=(1, 0, 0, 0))
    return tex


class DepthComposite(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        try:
            import gpu  # noqa: F401
        except ImportError:
            raise unittest.SkipTest("needs Blender's gpu module")
        import gpu
        gpu.init()

    def _render(self, depth_test: bool):
        import gpu
        from gpu_extras.batch import batch_for_shader
        import ifs_viewport

        colour = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F')
        depth_attach = gpu.types.GPUTexture((SIZE, SIZE), format='DEPTH_COMPONENT32F')
        fb = gpu.types.GPUFrameBuffer(depth_slot=depth_attach, color_slots=(colour,))

        # Uniform density and colour across the whole image: the ONLY thing that
        # differs left to right is the depth.
        accum_data = []
        for _y in range(SIZE):
            for _x in range(SIZE):
                accum_data.extend((1.0, 1.0, 1.0, 1.0))   # rgb + density
        accum = gpu.types.GPUTexture(
            (SIZE, SIZE), format='RGBA32F',
            data=gpu.types.Buffer('FLOAT', len(accum_data), accum_data))

        peak = _make_peak()
        depth_tex = _make_depth_texture(SIZE, NEAR, FAR)

        shader = ifs_viewport.build_tonemap()
        batch = batch_for_shader(
            shader, 'TRI_FAN', {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))})

        with fb.bind():
            fb.clear(color=(0.0, 0.0, 0.0, 1.0), depth=SCENE_DEPTH)
            shader.bind()
            shader.uniform_sampler('accum_tex', accum)
            shader.uniform_sampler('max_tex', peak)
            shader.uniform_sampler('depth_tex', depth_tex)
            # Single-band: an empty range makes the tonemap fall back to the
            # per-pixel depth, which is exactly what this file tests.
            shader.uniform_sampler('range_tex', depth_tex)
            shader.uniform_int('out_size', (SIZE, SIZE))
            shader.uniform_float('brightness', 40.0)
            shader.uniform_int('shading_mode', (0,))     # EMISSION: opaque write
            shader.uniform_float('opacity_gain', 1.0)
            shader.uniform_float('relief', 1.0)
            gpu.state.blend_set('NONE')
            gpu.state.depth_mask_set(False)
            gpu.state.depth_test_set('LESS_EQUAL' if depth_test else 'NONE')
            batch.draw(shader)
            gpu.state.depth_test_set('NONE')
            rows = fb.read_color(0, 0, SIZE, SIZE, 3, 0, 'FLOAT').to_list()

        def mean(x0, x1):
            total = count = 0.0
            for row in rows:
                for px in row[x0:x1]:
                    total += sum(px[:3]) / 3.0
                    count += 1
            return total / count

        return mean(0, SIZE // 2), mean(SIZE // 2, SIZE)

    def test_control_without_depth_test_both_halves_draw(self):
        """Without this, a shader that drew NOTHING would pass the occlusion test."""
        left, right = self._render(depth_test=False)
        self.assertGreater(left, 0.01, "left half did not draw at all")
        self.assertGreater(right, 0.01, "right half did not draw at all")
        self.assertAlmostEqual(left, right, places=4,
                               msg="halves differ with the depth test OFF, so the "
                                   "two regions are not otherwise identical")

    def test_in_front_draws_and_behind_is_hidden(self):
        left, right = self._render(depth_test=True)
        self.assertGreater(left, 0.01,
                           "fractal IN FRONT of the surface was wrongly hidden — "
                           "this is the failure an all-far depth buffer produces, "
                           "and the one a screenshot cannot distinguish")
        self.assertLess(right, 1e-6,
                        "fractal BEHIND the surface was wrongly drawn")

    def test_depth_buffer_is_not_written(self):
        """
        The overlay must TEST depth without WRITING it: writing would let a nebula's
        depth occlude gizmos and grid drawn after us, and leave the buffer holding a
        value meaningless to everything downstream.
        """
        import gpu
        from gpu_extras.batch import batch_for_shader
        import ifs_viewport

        colour = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F')
        depth_attach = gpu.types.GPUTexture((SIZE, SIZE), format='DEPTH_COMPONENT32F')
        fb = gpu.types.GPUFrameBuffer(depth_slot=depth_attach, color_slots=(colour,))

        accum_data = [1.0] * (SIZE * SIZE * 4)
        accum = gpu.types.GPUTexture(
            (SIZE, SIZE), format='RGBA32F',
            data=gpu.types.Buffer('FLOAT', len(accum_data), accum_data))
        peak = _make_peak()
        depth_tex = _make_depth_texture(SIZE, NEAR, NEAR)   # all in front: all draws

        shader = ifs_viewport.build_tonemap()
        batch = batch_for_shader(
            shader, 'TRI_FAN', {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))})

        with fb.bind():
            fb.clear(color=(0.0, 0.0, 0.0, 1.0), depth=SCENE_DEPTH)
            shader.bind()
            shader.uniform_sampler('accum_tex', accum)
            shader.uniform_sampler('max_tex', peak)
            shader.uniform_sampler('depth_tex', depth_tex)
            # Single-band: an empty range makes the tonemap fall back to the
            # per-pixel depth, which is exactly what this file tests.
            shader.uniform_sampler('range_tex', depth_tex)
            shader.uniform_int('out_size', (SIZE, SIZE))
            shader.uniform_float('brightness', 40.0)
            shader.uniform_int('shading_mode', (0,))
            shader.uniform_float('opacity_gain', 1.0)
            shader.uniform_float('relief', 1.0)
            gpu.state.blend_set('NONE')
            gpu.state.depth_mask_set(False)
            gpu.state.depth_test_set('LESS_EQUAL')
            batch.draw(shader)
            gpu.state.depth_test_set('NONE')
            after = fb.read_depth(0, 0, SIZE, SIZE).to_list()

        flat = [v for row in after for v in (row if isinstance(row, (list, tuple)) else [row])]
        worst = max(abs(v - SCENE_DEPTH) for v in flat)
        self.assertLess(worst, 1e-6,
                        "the overlay wrote to the depth buffer; it must only test")


if __name__ == "__main__":
    argv = [a for a in sys.argv if not a.endswith(".blend")]
    unittest.main(argv=argv[:1], exit=False, verbosity=2)