OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 3a6f08f

Splat shading in the viewport: gaussians, added, no sort
An alternative to the histogram path rather than a stage of it. The histogram accumulates samples into a 2D image and tonemaps it; this emits the points once and draws each as a camera-facing gaussian quad, added together. The reason it is ~200 lines and not a subsystem is that our case deletes both hard parts of a general 3DGS renderer (see NOTES-splat-rendering.md): * ISOTROPIC splats delete the covariance machinery. A trained splat is an oriented ellipsoid, so a renderer packs a 3D covariance, builds the Jacobian per splat, computes cov2d = T' Vrk T and eigen-decomposes it. Ours are spheres — to_pointcloud writes the identity quaternion because the chaos game gives positions, not local anisotropy — so the projection of a sphere is a circle and all of it collapses to offsetting the quad corner in CAMERA space by the radius. Perspective then shrinks distant splats for free. * EMISSION is order-independent, so there is NO depth sort. Sorting exists for alpha compositing; a flame adds and addition commutes. That removes the single most expensive component of every splat renderer. Recorded honestly: without a sort, splats do not occlude EACH OTHER. That is correct for emission, not an approximation — but it is why this pass suits the EMISSION mode, and FLAT/METAL would need the sort back. Per-splat data rides in two RGBA32F textures indexed by texelFetch, because Blender's GPU module has no SSBOs — the same substitute ifs_points already uses, and the same one the WebGL reference uses. The layout is exactly what ifs_points.emit returns, so nothing is repacked. Wiring: a `Gaussian splats` toggle on the Live Viewport panel. The cloud is emitted lazily on the first splat frame and dropped by any `params`/`weights` edit, because unlike the histogram — which merely restarts accumulating — a stale cloud is a fixed set of samples from the OLD attractor and would sit there showing the previous fractal while the panel said otherwise. `tonemap` edits do not drop it: brightness does not move a sample. 7 tests against a framebuffer the test owns. The gaussian-profile assertion is checked against exp(-d^2) in closed form at 0.5/1.0/1.5 sigma, so a linear ramp or a smoothstep fails rather than passing on "it fades out" — and it was proven to discriminate by MUTATION: deleting the exp() from the shader failed exactly that one test and nothing else. Also asserted: the 2-sigma radial cut (without it splats are soft boxes), additive stacking (two coincident splats give 2x, so the no-sort argument holds), per-point opacity, and perspective shrink. One test bug fixed on the way: setUpClass guarded the ifs_splat import too, so a missing module of OURS printed "OK (skipped=1)". Only the environment is skippable now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3a6f08feb2f662bc7019bc0464c5a01323d59612
by plankatron <fleeky@gmail.com> · 2026-08-18T14:37:37.000Z

Changed files (5) +557 -1

blender/__init__.py +15 -1
……@@ -50,6 +50,7 @@ import ifs_codegen
5050import ifs_document
5151import ifs_points
5252import ifs_render
53import ifs_splat
5354import ifs_viewport
5455import ifs_volume
5556import ifs_nodes
……@@ -209,6 +210,13 @@ class IFSSceneSettings(PropertyGroup):
209210 "nearest-neighbour spacing, so 1.0 makes neighbouring points "
210211 "just touch. How big a point should look also depends on how "
211212 "close the camera is, which is why this exists")
213 viewport_splats: BoolProperty(
214 name="Gaussian splats",
215 description="Draw the live viewport as gaussian splats instead of the "
216 "accumulating histogram. Emits the points once, then renders "
217 "them as camera-facing gaussians, added together. No depth "
218 "sort is needed because emission commutes",
219 default=False, update=_touch('splats'))
212220 points_emission: FloatProperty(
213221 name="Emission", default=1.0, min=0.0, soft_max=100.0,
214222 description="Emission strength of the point material. Unlike the volume's "
……@@ -643,7 +651,13 @@ class IFS_PT_main(Panel):
643651 box.prop(settings, "opacity_gain")
644652 if settings.shading_mode == 'METAL':
645653 box.prop(settings, "relief")
646 box.prop(settings, "iters_per_frame")
654 box.prop(settings, "viewport_splats")
655 if settings.viewport_splats:
656 sub = box.column(align=True)
657 sub.prop(settings, "points_capacity")
658 sub.prop(settings, "points_radius")
659 else:
660 box.prop(settings, "iters_per_frame")
647661 running = ifs_viewport.is_running()
648662 box.operator("ifs.toggle_viewport",
649663 text="Stop" if running else "Start",
blender/build_addon.py +1 -0
……@@ -41,6 +41,7 @@ RUNTIME_FILES = (
4141 "ifs_viewport.py",
4242 "ifs_volume.py",
4343 "ifs_points.py",
44 "ifs_splat.py",
4445 "ifs_nodes.py",
4546)
4647
blender/ifs_splat.py +213 -0
……@@ -0,0 +1,213 @@
1"""
2Draws the emitted points as gaussian splats.
3
4The viewport's normal path accumulates samples into a 2D histogram and tonemaps
5it. This is the other way of showing the same points: give each one a camera-
6facing quad and a gaussian falloff, and add them together. It renders the
7PointCloud we already emit, at float precision, with no grid to be finer than.
8
9## Why this is so much smaller than a general 3DGS renderer
10
11A published gaussian-splat renderer spends most of its code on two things we do
12not need. See `NOTES-splat-rendering.md` for the reading this came from.
13
14* **The covariance projection.** A trained splat is an oriented, anisotropic
15 ellipsoid, so the renderer packs a 3D covariance, builds the Jacobian of the
16 perspective projection per splat, computes `cov2d = T' Vrk T`, eigen-decomposes
17 the 2x2 result and emits a quad along its major/minor axes. Ours are
18 ISOTROPIC — `to_pointcloud` writes the identity quaternion because the chaos
19 game gives sample positions, not local anisotropy — so `cov3d = r^2 I`, the
20 projection of a sphere is a circle, and all of that collapses to offsetting the
21 quad corner in CAMERA space by the radius. Perspective then shrinks distant
22 splats for free.
23* **The depth sort.** Splat renderers sort back-to-front every frame because
24 alpha compositing is order-dependent; that sort is the expensive and fiddly
25 part. A fractal flame is EMISSION, contributions add, and addition commutes —
26 so there is nothing to sort. This is the same reason the viewport's tonemap
27 already blends `ADDITIVE_PREMULT`.
28
29The cost of skipping the sort is that splats do not occlude EACH OTHER. For
30emission that is correct rather than an approximation. It does mean this pass
31suits the EMISSION shading mode; FLAT and METAL are alpha modes and would need
32the sort back before they could use it.
33
34## Getting the splats to the shader
35
36Blender's GPU module has no SSBOs, so the per-splat data rides in two RGBA32F
37textures indexed by `texelFetch` — the same substitute `ifs_points` uses for the
38points buffer, and the same one the WebGL reference uses. The vertex buffer holds
39only a quad corner and a splat index; four vertices per splat, six indices.
40
41Position texture: `xyz` = centre, `w` = radius (sigma).
42Colour texture: `rgb` = colour, `w` = opacity.
43
44That is exactly the layout `ifs_points.emit` already returns, so no repacking.
45"""
46
47from __future__ import annotations
48
49import gpu
50import numpy as np
51from gpu_extras.batch import batch_for_shader
52
53# One texel per splat, same reasoning as ifs_points.POINTS_WIDTH: comfortably
54# inside every GL_MAX_TEXTURE_SIZE worth worrying about.
55SPLAT_WIDTH = 4096
56
57# The quad spans +/-2 sigma; the fragment shader discards beyond that, so the
58# corners are cut and a splat is round rather than square. exp(-4) = 0.018, i.e.
59# under 2% of peak, which is where the falloff stops being worth a fragment.
60CUTOFF_SIGMA = 2.0
61
62
63VERTEX_SHADER = """
64void main()
65{
66 ivec2 texel = ivec2(splat_index % splat_width, splat_index / splat_width);
67 vec4 centre = texelFetch(splat_pos, texel, 0);
68 vec4 tint = texelFetch(splat_col, texel, 0);
69
70 /* Offset in CAMERA space, not clip space. A sphere projects to a circle, so
71 an isotropic splat needs no covariance maths at all — and doing it here
72 rather than in NDC means perspective shrinks distant splats for free. */
73 vec4 cam = view * vec4(centre.xyz, 1.0);
74 cam.xy += corner * centre.w;
75
76 vColor = tint.rgb;
77 vAlpha = tint.w;
78 vOffset = corner;
79 gl_Position = projection * cam;
80}
81"""
82
83FRAGMENT_SHADER = """
84void main()
85{
86 /* vOffset is in units of sigma, so this is -(d/sigma)^2 directly. */
87 float a = -dot(vOffset, vOffset);
88 if (a < -CUTOFF_SQ) discard; /* round, not square */
89
90 float weight = exp(a) * vAlpha;
91 /* Premultiplied: the blend mode is ADDITIVE_PREMULT, matching the viewport's
92 emission path. Colour is NOT divided by anything — a flame is light. */
93 fragColor = vec4(vColor * weight, weight);
94}
95"""
96
97
98def build_shader():
99 """Compiles the splat pass."""
100 info = gpu.types.GPUShaderCreateInfo()
101 interface = gpu.types.GPUStageInterfaceInfo("ifs_splat_iface")
102 interface.smooth('VEC3', 'vColor')
103 interface.smooth('FLOAT', 'vAlpha')
104 interface.smooth('VEC2', 'vOffset')
105
106 info.vertex_in(0, 'VEC2', 'corner')
107 info.vertex_in(1, 'INT', 'splat_index')
108 info.vertex_out(interface)
109 info.fragment_out(0, 'VEC4', 'fragColor')
110
111 info.sampler(0, 'FLOAT_2D', 'splat_pos')
112 info.sampler(1, 'FLOAT_2D', 'splat_col')
113 info.push_constant('MAT4', 'view')
114 info.push_constant('MAT4', 'projection')
115 info.push_constant('INT', 'splat_width')
116
117 info.define('CUTOFF_SQ', str(CUTOFF_SIGMA * CUTOFF_SIGMA))
118 info.vertex_source(VERTEX_SHADER)
119 info.fragment_source(FRAGMENT_SHADER)
120 return gpu.shader.create_from_info(info)
121
122
123class SplatCloud:
124 """The GPU-side state for one cloud: two data textures and a quad batch."""
125
126 def __init__(self, batch, positions_tex, colors_tex, width, count):
127 self.batch = batch
128 self.positions_tex = positions_tex
129 self.colors_tex = colors_tex
130 self.width = width
131 self.count = count
132
133
134def _to_texture(rows, width):
135 """Packs an (N, 4) float array into an RGBA32F texture of `width` texels."""
136 count = len(rows)
137 height = max(1, (count + width - 1) // width)
138 padded = np.zeros((height * width, 4), dtype=np.float32)
139 padded[:count] = rows
140 flat = np.ascontiguousarray(padded).ravel()
141 return gpu.types.GPUTexture(
142 (width, height), format='RGBA32F',
143 data=gpu.types.Buffer('FLOAT', len(flat), flat))
144
145
146def upload(positions, colors, radius, width=SPLAT_WIDTH):
147 """
148 Builds a `SplatCloud` from the arrays `ifs_points.emit_document` returns.
149
150 `radius` is the sigma of every splat — one value, because the cloud is
151 isotropic and `estimate_spacing` derives a single spacing for it. Per-point
152 opacity rides in `colors[:, 3]`, which is the w the kernel emits and which
153 `to_pointcloud` now also writes as the `opacity` attribute.
154 """
155 positions = np.asarray(positions, dtype=np.float32)
156 colors = np.asarray(colors, dtype=np.float32)
157 count = len(positions)
158 if count == 0:
159 raise ValueError('cannot build a splat cloud from zero points')
160
161 pos_rows = np.zeros((count, 4), dtype=np.float32)
162 pos_rows[:, :3] = positions[:, :3]
163 pos_rows[:, 3] = radius
164
165 col_rows = np.ones((count, 4), dtype=np.float32)
166 col_rows[:, :colors.shape[1]] = colors[:, :4]
167 if colors.shape[1] < 4:
168 col_rows[:, 3] = 1.0
169
170 # Four corners per splat, spanning +/-CUTOFF_SIGMA, and two triangles.
171 quad = np.array([(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)],
172 dtype=np.float32) * CUTOFF_SIGMA
173 corners = np.tile(quad, (count, 1))
174 indices = np.repeat(np.arange(count, dtype=np.int32), 4)
175 tris = (np.array([0, 1, 2, 0, 2, 3], dtype=np.int32)[None, :]
176 + (np.arange(count, dtype=np.int32) * 4)[:, None]).reshape(-1, 3)
177
178 shader_format = gpu.types.GPUVertFormat()
179 shader_format.attr_add(id="corner", comp_type='F32', len=2, fetch_mode='FLOAT')
180 shader_format.attr_add(id="splat_index", comp_type='I32', len=1, fetch_mode='INT')
181 vbo = gpu.types.GPUVertBuf(shader_format, len=count * 4)
182 vbo.attr_fill(id="corner", data=corners.tolist())
183 vbo.attr_fill(id="splat_index", data=indices.tolist())
184 ibo = gpu.types.GPUIndexBuf(type='TRIS', seq=tris.tolist())
185 batch = gpu.types.GPUBatch(type='TRIS', buf=vbo, elem=ibo)
186
187 return SplatCloud(batch, _to_texture(pos_rows, width), _to_texture(col_rows, width),
188 width, count)
189
190
191def draw(cloud, shader, view_matrix, projection_matrix, additive=True,
192 depth_test=True):
193 """
194 Draws the cloud with the viewport's compositing rules.
195
196 Depth is TESTED but not WRITTEN, exactly as the tonemap pass does: writing
197 would let the fractal occlude gizmos and grid drawn after us, and would leave
198 the buffer holding a nebula's depth, which is meaningless downstream.
199 """
200 shader.bind()
201 shader.uniform_sampler('splat_pos', cloud.positions_tex)
202 shader.uniform_sampler('splat_col', cloud.colors_tex)
203 shader.uniform_int('splat_width', (cloud.width,))
204 shader.uniform_float('view', view_matrix)
205 shader.uniform_float('projection', projection_matrix)
206
207 gpu.state.blend_set('ADDITIVE_PREMULT' if additive else 'ALPHA')
208 gpu.state.depth_mask_set(False)
209 if depth_test:
210 gpu.state.depth_test_set('LESS_EQUAL')
211 cloud.batch.draw(shader)
212 gpu.state.depth_test_set('NONE')
213 gpu.state.blend_set('NONE')
blender/ifs_viewport.py +102 -0
……@@ -212,6 +212,19 @@ class Session:
212212 self.opacity_gain = 4.0
213213 self.relief = 12.0
214214
215 # Gaussian-splat preview. An ALTERNATIVE to the histogram path rather than
216 # a stage of it: the histogram accumulates progressively into a 2D image,
217 # splats draw the emitted points as geometry. Nothing here is built until
218 # the mode is switched on, because emitting the points costs a full GPU
219 # pass and most sessions never ask for it.
220 self.splats_enabled = False
221 self.splat_shader = None # the draw shader (ifs_splat)
222 self.splat_emit_shader = None # the points kernel, cached across re-emits
223 self.splat_cloud = None
224 self.splat_stats = None
225 self.splat_capacity = 1_000_000
226 self.splat_radius_scale = 1.0
227
215228 # What needs rebuilding before the next dispatch. Property callbacks fire
216229 # continuously while a slider is dragged, so they only set flags here and
217230 # the work happens once per redraw.
……@@ -280,11 +293,29 @@ class Session:
280293 if kind == 'weights':
281294 self._dirty_weights = True
282295 self._dirty_params = True
296 self.drop_splats()
283297 elif kind == 'params':
284298 self._dirty_params = True
299 self.drop_splats()
300 elif kind == 'splats':
301 self.drop_splats()
285302 elif kind != 'tonemap':
286303 raise ValueError(f"unknown dirty kind {kind!r}")
287304
305 def drop_splats(self):
306 """
307 Discards the splat cloud so the next draw re-emits it.
308
309 `params` and `weights` change the ATTRACTOR, and unlike the histogram — which
310 merely restarts accumulating — a splat cloud is a fixed set of samples from
311 the old fractal. Leaving it up would show the previous document indefinitely
312 while the panel says otherwise, which is the "succeeds silently into a void"
313 failure this add-on keeps producing. `tonemap` deliberately does NOT drop it:
314 brightness does not move a sample.
315 """
316 self.splat_cloud = None
317 self.splat_stats = None
318
288319 SHADING_MODES = {'EMISSION': 0, 'FLAT': 1, 'METAL': 2}
289320
290321 def sync_from_scene(self, settings):
……@@ -300,6 +331,17 @@ class Session:
300331 self.opacity_gain = settings.opacity_gain
301332 self.relief = settings.relief
302333
334 # Splat settings. Capacity and radius are read every frame so the panel
335 # sliders reach the preview; a change to CAPACITY needs a re-emit (it is
336 # how many samples we keep), a change to RADIUS does not (it only scales
337 # the quads), so only the former drops the cloud.
338 self.splats_enabled = bool(getattr(settings, 'viewport_splats', False))
339 capacity = int(getattr(settings, 'points_capacity', self.splat_capacity))
340 if capacity != self.splat_capacity:
341 self.splat_capacity = capacity
342 self.drop_splats()
343 self.splat_radius_scale = float(getattr(settings, 'points_radius', 1.0))
344
303345 # When a node graph is bound it OWNS the iterators. Copying the panel's
304346 # values on every frame would revert each graph edit one frame after it
305347 # landed, which looks exactly like the graph doing nothing at all. The
……@@ -493,6 +535,16 @@ class Session:
493535 self._rebuild_dirty()
494536
495537 space = context.space_data
538
539 # The splat path REPLACES the histogram rather than adding to it: the two
540 # draw the same fractal by different means, and running both would double
541 # the emission and cost a compute dispatch per frame for an image nobody
542 # sees. Everything above this line still runs — the panel, the node graph
543 # and the dirty flags all have to reach the document either way.
544 if self.splats_enabled:
545 self._draw_splats(space)
546 return
547
496548 view_proj = self._view_projection(space.region_3d, space.lens)
497549 moved = self._view_moved(view_proj)
498550 self._still_frames = 0 if moved else getattr(self, "_still_frames", 0) + 1
……@@ -585,6 +637,56 @@ class Session:
585637 if area.type == 'VIEW_3D':
586638 area.tag_redraw()
587639
640 def _build_splats(self):
641 """
642 Emits the points once and uploads them as a splat cloud.
643
644 This is a full GPU pass and it BLOCKS the redraw it runs in, so it happens
645 only when the cloud is missing — on the first splat frame, and after an
646 edit that changed the attractor. The points kernel is cached across
647 re-emits because compiling it is most of the cost.
648 """
649 import ifs_points
650 import ifs_splat
651
652 if self.splat_shader is None:
653 self.splat_shader = ifs_splat.build_shader()
654 if self.splat_emit_shader is None:
655 self.splat_emit_shader, _lib = ifs_points.build_shader()
656
657 positions, colors, stats = ifs_points.emit_document(
658 self.doc, capacity=self.splat_capacity, shader=self.splat_emit_shader)
659 self.splat_cloud = ifs_splat.upload(
660 positions, colors, stats['radius'] * self.splat_radius_scale)
661 self.splat_stats = stats
662
663 def _draw_splats(self, space):
664 """
665 The splat pass: emitted points as camera-facing gaussians, added together.
666
667 Blender's own view and window matrices are used whole, for the same reason
668 the histogram path hands the kernel `blender_view_proj` rather than the
669 engine's construction — the engine's matrix is mirrored in x and its z does
670 not correspond to what the depth buffer holds.
671 """
672 import ifs_splat
673
674 if self.splat_cloud is None:
675 try:
676 self._build_splats()
677 except Exception:
678 # A raising draw handler removes itself and the viewport goes dark
679 # with nothing said. Report once and fall back rather than that.
680 import traceback
681 traceback.print_exc()
682 self.splats_enabled = False
683 return
684
685 ifs_splat.draw(self.splat_cloud, self.splat_shader,
686 space.region_3d.view_matrix,
687 space.region_3d.window_matrix)
688 self.frames += 1
689
588690 @property
589691 def iterations(self):
590692 return self.since_reset * self.iters_per_frame * ifs_render.INVOCATIONS
blender/test_splat_shading.py +226 -0
……@@ -0,0 +1,226 @@
1"""
2The splat pass draws GAUSSIANS, additively, and nothing else.
3
4`ifs_splat` renders the emitted points as camera-facing gaussian quads instead of
5plotting them into a histogram. Every claim below is one a screenshot cannot
6settle, so each is measured off a framebuffer this test owns.
7
8The failure modes worth catching, all of which still produce "a picture of dots":
9
10* **A hard disc instead of a gaussian.** Drawing the quad without the exp()
11 falloff gives flat-topped discs. At a glance, on a fractal made of millions of
12 sub-pixel points, that looks almost identical — and it is the entire difference
13 between a splat and the point rendering we already have. Asserted against the
14 analytic profile exp(-d^2), not merely "the centre is brighter".
15* **No 2-sigma cut.** Without the `discard` the quad's square corners show as
16 soft boxes, which reads as blockiness rather than as a bug.
17* **Alpha instead of additive.** The whole reason we need no depth sort is that
18 emission commutes. If the blend is alpha, two overlapping splats do NOT sum,
19 order starts to matter, and the unsorted cloud is silently wrong. Asserted by
20 stacking two identical splats and requiring twice the energy.
21* **Opacity ignored.** `to_pointcloud` carries per-point opacity now; a shader
22 that drops it renders a uniformly bright cloud that looks fine in isolation.
23
24The gaussian profile assertion is the load-bearing one. It is checked at several
25radii against the closed form, so a shader that produced *some* falloff — linear,
26or a smoothstep — fails rather than passing on "it fades out".
27
28Needs a GPU. Run inside Blender:
29 blender -b --factory-startup -noaudio --python blender/test_splat_shading.py
30"""
31
32import math
33import os
34import sys
35import unittest
36
37sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
38
39SIZE = 128
40
41# An orthographic-ish setup: the camera looks down -Z from z=+4 at the origin, so
42# a splat at the origin lands dead centre and world units map predictably to the
43# frame. Chosen so the maths in the assertions is arithmetic, not trigonometry.
44CAM_Z = 4.0
45FOV_SCALE = 1.0 # projection built by hand below
46
47
48def _matrices():
49 """A view and a perspective projection, built explicitly so the test does not
50 depend on Blender's region_3d or on a scene camera."""
51 from mathutils import Matrix
52 view = Matrix.Translation((0.0, 0.0, -CAM_Z))
53 near, far = 0.1, 100.0
54 f = 1.0 / math.tan(math.radians(45.0) / 2.0)
55 proj = Matrix((
56 (f, 0.0, 0.0, 0.0),
57 (0.0, f, 0.0, 0.0),
58 (0.0, 0.0, (far + near) / (near - far), (2 * far * near) / (near - far)),
59 (0.0, 0.0, -1.0, 0.0),
60 ))
61 return view, proj
62
63
64def _render(positions, colors, radius, size=SIZE, additive=True):
65 """Draws the splats into an offscreen buffer and returns an (h, w, 3) array."""
66 import gpu
67 import numpy as np
68 import ifs_splat
69
70 shader = ifs_splat.build_shader()
71 cloud = ifs_splat.upload(np.asarray(positions, dtype=np.float32),
72 np.asarray(colors, dtype=np.float32),
73 radius)
74 view, proj = _matrices()
75
76 colour = gpu.types.GPUTexture((size, size), format='RGBA32F')
77 depth = gpu.types.GPUTexture((size, size), format='DEPTH_COMPONENT32F')
78 fb = gpu.types.GPUFrameBuffer(depth_slot=depth, color_slots=(colour,))
79 with fb.bind():
80 fb.clear(color=(0.0, 0.0, 0.0, 0.0), depth=1.0)
81 ifs_splat.draw(cloud, shader, view, proj, additive=additive)
82 raw = fb.read_color(0, 0, size, size, 3, 0, 'FLOAT').to_list()
83 return np.array(raw, dtype=np.float32).reshape(size, size, 3)
84
85
86class SplatShading(unittest.TestCase):
87 @classmethod
88 def setUpClass(cls):
89 # Skip only for the ENVIRONMENT. A missing `ifs_splat` is OUR code being
90 # absent or broken, and swallowing that into a skip prints "OK (skipped=1)"
91 # — the same silent pass that has cost this project a bug per session.
92 try:
93 import gpu
94 import numpy # noqa: F401
95 except ImportError as ex:
96 raise unittest.SkipTest(f"needs Blender's gpu module ({ex})")
97 import ifs_splat # noqa: F401 — deliberately NOT guarded
98 gpu.init()
99
100 # ------------------------------------------------------------ it draws
101
102 def test_a_single_splat_draws_something(self):
103 """Baseline. Every assertion below is meaningless against a blank frame."""
104 import numpy as np
105 img = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], radius=0.5)
106 self.assertGreater(float(img.max()), 0.05,
107 'nothing was drawn — the splat pass produced an empty frame')
108
109 def test_the_profile_is_a_gaussian_not_a_disc(self):
110 """
111 THE assertion. A quad without the exp() falloff is a flat disc, and on a
112 cloud of millions of sub-pixel points that is nearly indistinguishable by
113 eye from a real splat — while being exactly the thing we are adding.
114
115 Sampled along a radius and compared to exp(-(d/sigma)^2) in closed form, so
116 a linear ramp or a smoothstep fails instead of passing on "it fades".
117 """
118 import numpy as np
119 radius = 0.6
120 img = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], radius=radius)
121 lum = img[:, :, 0]
122 centre = SIZE // 2
123 peak = float(lum[centre, centre])
124 self.assertGreater(peak, 0.05, 'no peak to measure')
125
126 # Pixels-per-sigma, measured off the frame rather than derived: find where
127 # the profile falls to exp(-1) and call that 1 sigma.
128 row = lum[centre, centre:]
129 target = peak * math.exp(-1.0)
130 sigma_px = None
131 for i in range(1, len(row)):
132 if row[i] <= target:
133 sigma_px = i - 1 + (row[i - 1] - target) / max(row[i - 1] - row[i], 1e-9)
134 break
135 self.assertIsNotNone(sigma_px, 'the profile never falls to 1/e — it is not a gaussian')
136
137 for frac in (0.5, 1.0, 1.5):
138 px = int(round(sigma_px * frac))
139 if centre + px >= SIZE:
140 continue
141 got = float(lum[centre, centre + px]) / peak
142 want = math.exp(-(frac ** 2))
143 self.assertAlmostEqual(
144 want, got, delta=0.12,
145 msg=f'at {frac} sigma the profile is {got:.3f}, a gaussian is {want:.3f} '
146 '— this is not exp(-d^2)')
147
148 def test_it_is_cut_at_two_sigma(self):
149 """Without the discard the quad's square corners show as soft boxes."""
150 import numpy as np
151 img = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], radius=0.5)
152 lum = img[:, :, 0]
153 centre = SIZE // 2
154 peak = float(lum[centre, centre])
155 # The extreme corner of the splat's own quad is at sqrt(2)*2 sigma, well
156 # beyond the cut, so it must be black even though the quad covers it.
157 lit = lum > peak * math.exp(-4.0) * 0.5
158 ys, xs = np.nonzero(lit)
159 if len(xs):
160 r = np.sqrt((xs - centre) ** 2 + (ys - centre) ** 2)
161 # The lit region must be round, not square: the furthest lit pixel is
162 # no more than ~10% beyond the furthest lit pixel on the axes.
163 on_axis = float(r[(ys == centre)].max()) if (ys == centre).any() else 0.0
164 self.assertLess(float(r.max()), on_axis * 1.25 + 2.0,
165 'the lit region reaches into the quad corners — '
166 'there is no radial cut, so splats render as boxes')
167
168 # ------------------------------------------------------------- additive
169
170 def test_two_coincident_splats_sum(self):
171 """
172 Additive blending is WHY we need no depth sort. Under alpha blending two
173 identical splats would not sum, order would start to matter, and the
174 unsorted cloud would be quietly wrong.
175 """
176 import numpy as np
177 one = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 0.4]], radius=0.5)
178 two = _render([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]],
179 [[1.0, 1.0, 1.0, 0.4], [1.0, 1.0, 1.0, 0.4]], radius=0.5)
180 a, b = float(one.max()), float(two.max())
181 self.assertAlmostEqual(2.0, b / a, delta=0.15,
182 msg=f'two splats gave {b / a:.2f}x one, not 2x — '
183 'the blend is not additive')
184
185 def test_opacity_scales_the_splat(self):
186 """`to_pointcloud` carries per-point opacity; a shader that drops it renders
187 a uniformly bright cloud that looks perfectly fine on its own."""
188 import numpy as np
189 dim = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 0.25]], radius=0.5)
190 bright = _render([[0.0, 0.0, 0.0]], [[1.0, 1.0, 1.0, 1.0]], radius=0.5)
191 ratio = float(bright.max()) / max(float(dim.max()), 1e-9)
192 self.assertAlmostEqual(4.0, ratio, delta=0.5,
193 msg=f'opacity 1.0 vs 0.25 gave {ratio:.2f}x, not 4x')
194
195 def test_colour_is_carried_per_splat(self):
196 import numpy as np
197 img = _render([[0.0, 0.0, 0.0]], [[1.0, 0.0, 0.0, 1.0]], radius=0.5)
198 centre = SIZE // 2
199 r, g, b = (float(v) for v in img[centre, centre])
200 self.assertGreater(r, 0.05, 'red channel is empty')
201 self.assertLess(max(g, b), r * 0.05, 'a pure red splat leaked into green/blue')
202
203 # ----------------------------------------------------------- perspective
204
205 def test_a_more_distant_splat_is_smaller(self):
206 """The quad is offset in CAMERA space, so perspective does the shrinking for
207 free. If it were offset in clip space every splat would be the same size on
208 screen regardless of depth."""
209 import numpy as np
210
211 def lit_pixels(z):
212 img = _render([[0.0, 0.0, z]], [[1.0, 1.0, 1.0, 1.0]], radius=0.4)
213 return int((img[:, :, 0] > float(img.max()) * 0.1).sum())
214
215 near = lit_pixels(0.0)
216 far = lit_pixels(-3.0)
217 self.assertGreater(near, 0, 'the near splat did not draw')
218 self.assertGreater(far, 0, 'the far splat did not draw')
219 self.assertLess(far, near * 0.8,
220 f'the distant splat covered {far} px against {near} near — '
221 'it is not shrinking with depth')
222
223
224if __name__ == "__main__":
225 result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result
226 sys.exit(0 if result.wasSuccessful() else 1)