"""
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)