OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / test_splat_shading.py

349 lines · python ref:
"""
The splat pass draws GAUSSIANS, additively, and nothing else.

`ifs_splat` renders the emitted points as camera-facing gaussian quads instead of
plotting them into a histogram. Every claim below is one a screenshot cannot
settle, so each is measured off a framebuffer this test owns.

The failure modes worth catching, all of which still produce "a picture of dots":

* **A hard disc instead of a gaussian.** Drawing the quad without the exp()
  falloff gives flat-topped discs. At a glance, on a fractal made of millions of
  sub-pixel points, that looks almost identical — and it is the entire difference
  between a splat and the point rendering we already have. Asserted against the
  analytic profile exp(-d^2), not merely "the centre is brighter".
* **No 2-sigma cut.** Without the `discard` the quad's square corners show as
  soft boxes, which reads as blockiness rather than as a bug.
* **Alpha instead of additive.** The whole reason we need no depth sort is that
  emission commutes. If the blend is alpha, two overlapping splats do NOT sum,
  order starts to matter, and the unsorted cloud is silently wrong. Asserted by
  stacking two identical splats and requiring twice the energy.
* **Opacity ignored.** `to_pointcloud` carries per-point opacity now; a shader
  that drops it renders a uniformly bright cloud that looks fine in isolation.

The gaussian profile assertion is the load-bearing one. It is checked at several
radii against the closed form, so a shader that produced *some* falloff — linear,
or a smoothstep — fails rather than passing on "it fades out".

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

import math
import os
import sys
import unittest

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

SIZE = 128

# An orthographic-ish setup: the camera looks down -Z from z=+4 at the origin, so
# a splat at the origin lands dead centre and world units map predictably to the
# frame. Chosen so the maths in the assertions is arithmetic, not trigonometry.
CAM_Z = 4.0
FOV_SCALE = 1.0     # projection built by hand below


def _matrices():
    """A view and a perspective projection, built explicitly so the test does not
    depend on Blender's region_3d or on a scene camera."""
    from mathutils import Matrix
    view = Matrix.Translation((0.0, 0.0, -CAM_Z))
    near, far = 0.1, 100.0
    f = 1.0 / math.tan(math.radians(45.0) / 2.0)
    proj = Matrix((
        (f, 0.0, 0.0, 0.0),
        (0.0, f, 0.0, 0.0),
        (0.0, 0.0, (far + near) / (near - far), (2 * far * near) / (near - far)),
        (0.0, 0.0, -1.0, 0.0),
    ))
    return view, proj


def _render(positions, colors, radius, size=SIZE, additive=True):
    """Draws the splats into an offscreen buffer and returns an (h, w, 3) array."""
    import gpu
    import numpy as np
    import ifs_splat

    shader = ifs_splat.build_shader()
    cloud = ifs_splat.upload(np.asarray(positions, dtype=np.float32),
                             np.asarray(colors, dtype=np.float32),
                             radius)
    view, proj = _matrices()

    colour = gpu.types.GPUTexture((size, size), format='RGBA32F')
    # The R32UI nearest-depth buffer the pass writes, in the kernel's encoding.
    # Cleared to DEPTH_CLEAR (1.0f) exactly as the viewport does.
    depth_img = gpu.types.GPUTexture((size, size), format='R32UI')
    depth_img.clear(format='UINT', value=(0x3F800000, 0, 0, 0))
    fb = gpu.types.GPUFrameBuffer(color_slots=(colour,))
    with fb.bind():
        fb.clear(color=(0.0, 0.0, 0.0, 0.0))
        ifs_splat.draw(cloud, shader, view, proj, depth_img, additive=additive)
        raw = fb.read_color(0, 0, size, size, 3, 0, 'FLOAT').to_list()
    return np.array(raw, dtype=np.float32).reshape(size, size, 3)


class SplatShading(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        # Skip only for the ENVIRONMENT. A missing `ifs_splat` is OUR code being
        # absent or broken, and swallowing that into a skip prints "OK (skipped=1)"
        # — the same silent pass that has cost this project a bug per session.
        try:
            import gpu
            import numpy   # noqa: F401
        except ImportError as ex:
            raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")
        import ifs_splat  # noqa: F401  — deliberately NOT guarded
        gpu.init()

    # ------------------------------------------------------------ it draws

    def test_a_single_splat_draws_something(self):
        """Baseline. Every assertion below is meaningless against a blank frame."""
        import numpy as np
        img = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], radius=0.5)
        self.assertGreater(float(img.max()), 0.05,
                           'nothing was drawn — the splat pass produced an empty frame')

    def test_the_profile_is_a_gaussian_not_a_disc(self):
        """
        THE assertion. A quad without the exp() falloff is a flat disc, and on a
        cloud of millions of sub-pixel points that is nearly indistinguishable by
        eye from a real splat — while being exactly the thing we are adding.

        Sampled along a radius and compared to exp(-(d/sigma)^2) in closed form, so
        a linear ramp or a smoothstep fails instead of passing on "it fades".
        """
        import numpy as np
        radius = 0.6
        img = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], radius=radius)
        lum = img[:, :, 0]
        centre = SIZE // 2
        peak = float(lum[centre, centre])
        self.assertGreater(peak, 0.05, 'no peak to measure')

        # Pixels-per-sigma, measured off the frame rather than derived: find where
        # the profile falls to exp(-1) and call that 1 sigma.
        row = lum[centre, centre:]
        target = peak * math.exp(-1.0)
        sigma_px = None
        for i in range(1, len(row)):
            if row[i] <= target:
                sigma_px = i - 1 + (row[i - 1] - target) / max(row[i - 1] - row[i], 1e-9)
                break
        self.assertIsNotNone(sigma_px, 'the profile never falls to 1/e — it is not a gaussian')

        for frac in (0.5, 1.0, 1.5):
            px = int(round(sigma_px * frac))
            if centre + px >= SIZE:
                continue
            got = float(lum[centre, centre + px]) / peak
            want = math.exp(-(frac ** 2))
            self.assertAlmostEqual(
                want, got, delta=0.12,
                msg=f'at {frac} sigma the profile is {got:.3f}, a gaussian is {want:.3f} '
                    '— this is not exp(-d^2)')

    def test_it_is_cut_at_two_sigma(self):
        """Without the discard the quad's square corners show as soft boxes."""
        import numpy as np
        img = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], radius=0.5)
        lum = img[:, :, 0]
        centre = SIZE // 2
        peak = float(lum[centre, centre])
        # The extreme corner of the splat's own quad is at sqrt(2)*2 sigma, well
        # beyond the cut, so it must be black even though the quad covers it.
        lit = lum > peak * math.exp(-4.0) * 0.5
        ys, xs = np.nonzero(lit)
        if len(xs):
            r = np.sqrt((xs - centre) ** 2 + (ys - centre) ** 2)
            # The lit region must be round, not square: the furthest lit pixel is
            # no more than ~10% beyond the furthest lit pixel on the axes.
            on_axis = float(r[(ys == centre)].max()) if (ys == centre).any() else 0.0
            self.assertLess(float(r.max()), on_axis * 1.25 + 2.0,
                            'the lit region reaches into the quad corners — '
                            'there is no radial cut, so splats render as boxes')

    # ------------------------------------------------------------- additive

    def test_two_coincident_splats_sum(self):
        """
        Additive blending is WHY we need no depth sort. Under alpha blending two
        identical splats would not sum, order would start to matter, and the
        unsorted cloud would be quietly wrong.
        """
        import numpy as np
        one = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 0.4]], radius=0.5)
        two = _render([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]],
                      [[1.0, 1.0, 1.0, 0.4], [1.0, 1.0, 1.0, 0.4]], radius=0.5)
        a, b = float(one.max()), float(two.max())
        self.assertAlmostEqual(2.0, b / a, delta=0.15,
                               msg=f'two splats gave {b / a:.2f}x one, not 2x — '
                                   'the blend is not additive')

    def test_opacity_scales_the_splat(self):
        """`to_pointcloud` carries per-point opacity; a shader that drops it renders
        a uniformly bright cloud that looks perfectly fine on its own."""
        import numpy as np
        dim = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 0.25]], radius=0.5)
        bright = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], radius=0.5)
        ratio = float(bright.max()) / max(float(dim.max()), 1e-9)
        self.assertAlmostEqual(4.0, ratio, delta=0.5,
                               msg=f'opacity 1.0 vs 0.25 gave {ratio:.2f}x, not 4x')

    def test_colour_is_carried_per_splat(self):
        import numpy as np
        img = _render([[0.0, 0.0, 0.0]], [[1.0, 0.0, 0.0, 1.0]], radius=0.5)
        centre = SIZE // 2
        r, g, b = (float(v) for v in img[centre, centre])
        self.assertGreater(r, 0.05, 'red channel is empty')
        self.assertLess(max(g, b), r * 0.05, 'a pure red splat leaked into green/blue')

    # ----------------------------------------------------------- perspective

    def test_a_more_distant_splat_is_smaller(self):
        """The quad is offset in CAMERA space, so perspective does the shrinking for
        free. If it were offset in clip space every splat would be the same size on
        screen regardless of depth."""
        import numpy as np

        def lit_pixels(z):
            img = _render([[0.0, 0.0, z]], [[1.0, 1.0, 1.0, 1.0]], radius=0.4)
            return int((img[:, :, 0] > float(img.max()) * 0.1).sum())

        near = lit_pixels(0.0)
        far = lit_pixels(-3.0)
        self.assertGreater(near, 0, 'the near splat did not draw')
        self.assertGreater(far, 0, 'the far splat did not draw')
        self.assertLess(far, near * 0.8,
                        f'the distant splat covered {far} px against {near} near — '
                        'it is not shrinking with depth')


class SplatThroughTonemap(unittest.TestCase):
    """
    The splat pass feeds the SAME accumulate -> tonemap back half as the histogram.

    This is the fix for a real complaint: with splats drawn straight to the
    viewport, Brightness, the shading modes and opacity gain did nothing at all,
    because the shader that reads them never ran. The fractal's look is a curve
    applied to ACCUMULATED density — per-sample emission is not the model — so
    raw quads rendered as fuzzy beads no setting could correct.

    Asserted differentially, because "it produced an image" would pass against a
    pass that still ignored every control.
    """

    @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_splat    # noqa: F401  — ours, deliberately unguarded
        import ifs_viewport  # noqa: F401
        gpu.init()

    def _accumulate_and_tonemap(self, positions, colors, radius, brightness,
                                size=SIZE):
        import gpu
        import numpy as np
        import ifs_splat
        import ifs_viewport

        accum = gpu.types.GPUTexture((size, size), format='RGBA32F')
        accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0))
        depth_img = gpu.types.GPUTexture((size, size), format='R32UI')
        depth_img.clear(format='UINT', value=(0x3F800000, 0, 0, 0))

        shader = ifs_splat.build_shader()
        cloud = ifs_splat.upload(np.asarray(positions, dtype=np.float32),
                                 np.asarray(colors, dtype=np.float32), radius)
        view, proj = _matrices()

        fb = gpu.types.GPUFrameBuffer(color_slots=(accum,))
        with fb.bind():
            fb.clear(color=(0.0, 0.0, 0.0, 0.0))
            ifs_splat.draw(cloud, shader, view, proj, depth_img)

        # The viewport's own auto-exposure and tonemap, unmodified.
        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)

        tonemap = ifs_viewport.build_tonemap()
        from gpu_extras.batch import batch_for_shader
        batch = batch_for_shader(tonemap, 'TRI_FAN',
                                 {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))})
        out = gpu.types.GPUTexture((size, size), format='RGBA32F')
        out_depth = gpu.types.GPUTexture((size, size), format='DEPTH_COMPONENT32F')
        ofb = gpu.types.GPUFrameBuffer(depth_slot=out_depth, color_slots=(out,))
        with ofb.bind():
            ofb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=1.0)
            tonemap.bind()
            tonemap.uniform_sampler('accum_tex', accum)
            tonemap.uniform_sampler('max_tex', maxbuf)
            tonemap.uniform_sampler('depth_tex', depth_img)
            tonemap.uniform_sampler('range_tex', depth_img)
            tonemap.uniform_int('out_size', (size, size))
            tonemap.uniform_float('brightness', brightness)
            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')
            batch.draw(tonemap)
            gpu.state.blend_set('NONE')
            raw = ofb.read_color(0, 0, size, size, 3, 0, 'FLOAT').to_list()
        return np.array(raw, dtype=np.float32).reshape(size, size, 3)

    def test_the_tonemapped_result_is_not_empty(self):
        """Baseline — the two passes are actually connected."""
        img = self._accumulate_and_tonemap(
            [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], 0.5, brightness=8.0)
        self.assertGreater(float(img.max()), 0.01,
                           'splats accumulated but the tonemap produced nothing')

    def test_brightness_reaches_the_image(self):
        """
        The complaint, made into a test. Before the rewire this control was inert
        while splats were on, because the tonemap shader never ran.
        """
        dim = self._accumulate_and_tonemap(
            [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], 0.5, brightness=2.0)
        bright = self._accumulate_and_tonemap(
            [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], 0.5, brightness=32.0)
        print(f"[tonemap] brightness 2 -> {dim.mean():.5f}, 32 -> {bright.mean():.5f}")
        self.assertGreater(float(bright.mean()), float(dim.mean()) * 1.05,
                           'Brightness does not change the splat image — the '
                           'tonemap is not in the path')

    def test_overlapping_splats_accumulate_density(self):
        """
        Density, not per-sample emission, is what the curve is applied to. Stacked
        splats must read brighter after the tonemap, or we are still drawing
        independent beads.
        """
        one = self._accumulate_and_tonemap(
            [[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 0.5]], 0.5, brightness=8.0)
        many = self._accumulate_and_tonemap(
            [[0.0, 0.0, 0.0]] * 8, [[1.0, 1.0, 1.0, 0.5]] * 8, 0.5, brightness=8.0)
        print(f"[tonemap] 1 splat {one.max():.4f} -> 8 splats {many.max():.4f}")
        self.assertGreater(float(many.max()), float(one.max()) * 1.05,
                           'eight coincident splats are no brighter than one — '
                           'density is not accumulating')


if __name__ == "__main__":
    result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result
    sys.exit(0 if result.wasSuccessful() else 1)