| … | … | @@ -50,6 +50,7 @@ import ifs_codegen |
| 50 | 50 | import ifs_document |
| 51 | 51 | import ifs_points |
| 52 | 52 | import ifs_render |
| 53 | import ifs_splat | |
| 53 | 54 | import ifs_viewport |
| 54 | 55 | import ifs_volume |
| 55 | 56 | import ifs_nodes |
| … | … | @@ -209,6 +210,13 @@ class IFSSceneSettings(PropertyGroup): |
| 209 | 210 | "nearest-neighbour spacing, so 1.0 makes neighbouring points " |
| 210 | 211 | "just touch. How big a point should look also depends on how " |
| 211 | 212 | "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')) | |
| 212 | 220 | points_emission: FloatProperty( |
| 213 | 221 | name="Emission", default=1.0, min=0.0, soft_max=100.0, |
| 214 | 222 | description="Emission strength of the point material. Unlike the volume's " |
| … | … | @@ -643,7 +651,13 @@ class IFS_PT_main(Panel): |
| 643 | 651 | box.prop(settings, "opacity_gain") |
| 644 | 652 | if settings.shading_mode == 'METAL': |
| 645 | 653 | 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") | |
| 647 | 661 | running = ifs_viewport.is_running() |
| 648 | 662 | box.operator("ifs.toggle_viewport", |
| 649 | 663 | text="Stop" if running else "Start", |
| … | … | @@ -41,6 +41,7 @@ RUNTIME_FILES = ( |
| 41 | 41 | "ifs_viewport.py", |
| 42 | 42 | "ifs_volume.py", |
| 43 | 43 | "ifs_points.py", |
| 44 | "ifs_splat.py", | |
| 44 | 45 | "ifs_nodes.py", |
| 45 | 46 | ) |
| 46 | 47 |
| … | … | @@ -0,0 +1,213 @@ |
| 1 | """ | |
| 2 | Draws the emitted points as gaussian splats. | |
| 3 | ||
| 4 | The viewport's normal path accumulates samples into a 2D histogram and tonemaps | |
| 5 | it. This is the other way of showing the same points: give each one a camera- | |
| 6 | facing quad and a gaussian falloff, and add them together. It renders the | |
| 7 | PointCloud 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 | ||
| 11 | A published gaussian-splat renderer spends most of its code on two things we do | |
| 12 | not 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 | ||
| 29 | The cost of skipping the sort is that splats do not occlude EACH OTHER. For | |
| 30 | emission that is correct rather than an approximation. It does mean this pass | |
| 31 | suits the EMISSION shading mode; FLAT and METAL are alpha modes and would need | |
| 32 | the sort back before they could use it. | |
| 33 | ||
| 34 | ## Getting the splats to the shader | |
| 35 | ||
| 36 | Blender's GPU module has no SSBOs, so the per-splat data rides in two RGBA32F | |
| 37 | textures indexed by `texelFetch` — the same substitute `ifs_points` uses for the | |
| 38 | points buffer, and the same one the WebGL reference uses. The vertex buffer holds | |
| 39 | only a quad corner and a splat index; four vertices per splat, six indices. | |
| 40 | ||
| 41 | Position texture: `xyz` = centre, `w` = radius (sigma). | |
| 42 | Colour texture: `rgb` = colour, `w` = opacity. | |
| 43 | ||
| 44 | That is exactly the layout `ifs_points.emit` already returns, so no repacking. | |
| 45 | """ | |
| 46 | ||
| 47 | from __future__ import annotations | |
| 48 | ||
| 49 | import gpu | |
| 50 | import numpy as np | |
| 51 | from 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. | |
| 55 | SPLAT_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. | |
| 60 | CUTOFF_SIGMA = 2.0 | |
| 61 | ||
| 62 | ||
| 63 | VERTEX_SHADER = """ | |
| 64 | void 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 | ||
| 83 | FRAGMENT_SHADER = """ | |
| 84 | void 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 | ||
| 98 | def 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 | ||
| 123 | class 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 | ||
| 134 | def _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 | ||
| 146 | def 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 | ||
| 191 | def 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') |
| … | … | @@ -212,6 +212,19 @@ class Session: |
| 212 | 212 | self.opacity_gain = 4.0 |
| 213 | 213 | self.relief = 12.0 |
| 214 | 214 | |
| 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 | ||
| 215 | 228 | # What needs rebuilding before the next dispatch. Property callbacks fire |
| 216 | 229 | # continuously while a slider is dragged, so they only set flags here and |
| 217 | 230 | # the work happens once per redraw. |
| … | … | @@ -280,11 +293,29 @@ class Session: |
| 280 | 293 | if kind == 'weights': |
| 281 | 294 | self._dirty_weights = True |
| 282 | 295 | self._dirty_params = True |
| 296 | self.drop_splats() | |
| 283 | 297 | elif kind == 'params': |
| 284 | 298 | self._dirty_params = True |
| 299 | self.drop_splats() | |
| 300 | elif kind == 'splats': | |
| 301 | self.drop_splats() | |
| 285 | 302 | elif kind != 'tonemap': |
| 286 | 303 | raise ValueError(f"unknown dirty kind {kind!r}") |
| 287 | 304 | |
| 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 | ||
| 288 | 319 | SHADING_MODES = {'EMISSION': 0, 'FLAT': 1, 'METAL': 2} |
| 289 | 320 | |
| 290 | 321 | def sync_from_scene(self, settings): |
| … | … | @@ -300,6 +331,17 @@ class Session: |
| 300 | 331 | self.opacity_gain = settings.opacity_gain |
| 301 | 332 | self.relief = settings.relief |
| 302 | 333 | |
| 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 | ||
| 303 | 345 | # When a node graph is bound it OWNS the iterators. Copying the panel's |
| 304 | 346 | # values on every frame would revert each graph edit one frame after it |
| 305 | 347 | # landed, which looks exactly like the graph doing nothing at all. The |
| … | … | @@ -493,6 +535,16 @@ class Session: |
| 493 | 535 | self._rebuild_dirty() |
| 494 | 536 | |
| 495 | 537 | 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 | ||
| 496 | 548 | view_proj = self._view_projection(space.region_3d, space.lens) |
| 497 | 549 | moved = self._view_moved(view_proj) |
| 498 | 550 | self._still_frames = 0 if moved else getattr(self, "_still_frames", 0) + 1 |
| … | … | @@ -585,6 +637,56 @@ class Session: |
| 585 | 637 | if area.type == 'VIEW_3D': |
| 586 | 638 | area.tag_redraw() |
| 587 | 639 | |
| 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 | ||
| 588 | 690 | @property |
| 589 | 691 | def iterations(self): |
| 590 | 692 | return self.since_reset * self.iters_per_frame * ifs_render.INVOCATIONS |
| … | … | @@ -0,0 +1,226 @@ |
| 1 | """ | |
| 2 | The splat pass draws GAUSSIANS, additively, and nothing else. | |
| 3 | ||
| 4 | `ifs_splat` renders the emitted points as camera-facing gaussian quads instead of | |
| 5 | plotting them into a histogram. Every claim below is one a screenshot cannot | |
| 6 | settle, so each is measured off a framebuffer this test owns. | |
| 7 | ||
| 8 | The 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 | ||
| 24 | The gaussian profile assertion is the load-bearing one. It is checked at several | |
| 25 | radii against the closed form, so a shader that produced *some* falloff — linear, | |
| 26 | or a smoothstep — fails rather than passing on "it fades out". | |
| 27 | ||
| 28 | Needs a GPU. Run inside Blender: | |
| 29 | blender -b --factory-startup -noaudio --python blender/test_splat_shading.py | |
| 30 | """ | |
| 31 | ||
| 32 | import math | |
| 33 | import os | |
| 34 | import sys | |
| 35 | import unittest | |
| 36 | ||
| 37 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| 38 | ||
| 39 | SIZE = 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. | |
| 44 | CAM_Z = 4.0 | |
| 45 | FOV_SCALE = 1.0 # projection built by hand below | |
| 46 | ||
| 47 | ||
| 48 | def _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 | ||
| 64 | def _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 | ||
| 86 | class 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 | ||
| 224 | if __name__ == "__main__": | |
| 225 | result = unittest.main(argv=sys.argv[:1], exit=False, verbosity=2).result | |
| 226 | sys.exit(0 if result.wasSuccessful() else 1) |