OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / c2bf776

Band over the depth range the fractal occupies, not over [0,1]
Reported: with bands on, a cube fully occluded the fractal rather than partially, and beyond two bands it went black. The bands worked; the mapping from band to depth did not. `gl_FragDepth = (band+1)/bands` assumed the fractal spans window depth [0,1]. It does not. Window depth is violently nonlinear — with Blender's default clipping: distance 2 -> 0.95010 distance 20 -> 0.99510 distance 5 -> 0.98010 distance 50 -> 0.99810 distance 10 -> 0.99010 So a scene at distance 5..20 occupies 0.980..0.995, every sample lands in the LAST band, and that band blits at 1.0 — behind everything. Arithmetic first, then the screenshot agreed. Bands now span the range the fractal actually occupies, measured on the GPU by reducing the previous frame's depth buffer to a min and a max. No readback: that would stall the pipeline the auto-exposure deliberately avoids stalling. It is self-correcting, because the viewport is progressive and the camera moves far less between frames than the fractal's own depth extent. Verified against a synthetic buffer: recovers 0.980..0.995 and correctly excludes untouched far pixels, which would otherwise peg the maximum at the far plane for any frame with empty sky. ONE TRAP, found by probing rather than by reasoning: GPUTexture.clear writes the SAME value to every texel, so a min and a max cannot both be seeded from one clear — seeded to 1.0 the maximum can never fall below the far plane, seeded to 0 the minimum is stuck there. Both reductions are therefore MAX, with the minimum stored COMPLEMENTED: ~x is monotonically decreasing, so max(~d) == ~min(d) and a single clear to zero seeds both. Readers complement texel 0 back. That trap then bit the test too: its range fixture wrote an uncomplemented minimum, the reader recovered a NaN, `dmax > dmin` failed and the whole thing fell back to single-band behaviour — caught as "only 2 distinct levels across the sweep", which is exactly what that assertion exists to catch. test_depth's image-budget assertion updated from one slot to two and made to name them, because it guards Blender's eight-image limit and the count genuinely changed: histogram 0, state 1, depth 2, range 3. Graded occlusion unchanged: 0.000 / 1.909 / 3.817 / 5.726 / 7.634 across the sweep. 11 GPU suites and 3 plain-Python suites pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c2bf7760aafcebc19f56993a3ac5b05695d052d4
by plankatron <fleeky@gmail.com> · 2026-08-18T19:49:46.000Z

Changed files (9) +385 -12

blender/DESIGN-nodal-and-world-shading.md +221 -0
……@@ -0,0 +1,221 @@
1# Design: a unified nodal interface, and native world shading
2
3Written 2026-08-18, before any of it is built. Two questions that turned out to be
4related: how the add-on should be *driven*, and whether the fractal can be a world
5shader rather than an image.
6
7---
8
9## Part 1 — the nodal interface
10
11### Why the panel stopped fitting
12
13The add-on now has **four output paths** — live viewport, volume bake, point
14cloud, environment map — and a fifth (still render) that predates them. Each
15arrived as an operator plus a handful of properties appended to one flat panel.
16The panel is a list; the thing it describes is a graph.
17
18The evidence that this is a real problem, not an aesthetic one, is that the same
19bugs keep recurring:
20
21- **"Which document am I reading?"** The volume bake reloaded the `.ifsjson` from
22 disk while the panel and node graph wrote into the live session's document, so
23 every edit made inside Blender was silently discarded. Each new output has since
24 had to re-implement the same `if ifs_viewport.is_running(): use the session doc`
25 guard. Four copies of one decision.
26- **Duplicated properties.** `points_capacity` and `points_radius` were drawn in
27 two sections with nothing indicating they were one setting; changing either
28 silently changed the other. Introduced while adding splats, found in the UI
29 audit.
30- **Grouping that misleads.** *Brightness* sits under **Output** but drives the
31 live viewport's tonemap. A control's location implies its scope.
32- **No shared vocabulary for the shared thing.** Every output consumes the same
33 accumulated histogram through the same tonemap, and nothing in the UI says so.
34
35A graph makes all four structural rather than remembered.
36
37### What already exists
38
39`ifs_nodes.py` (416 lines) defines `IFSNodeTree`, `IFSIteratorNode`,
40`IFSFlowSocket`, `build_tree`/`apply_tree` to sync with a document, an xaos matrix
41panel and a `Build Node Graph` operator. **It owns the fractal DEFINITION only** —
42iterators and xaos. Outputs are not represented.
43
44Four things learned building it, all of which constrain this design:
45
461. **A free-floating node tree is never evaluated by the depsgraph.** Measured:
47 editing a node property with a session running produced *zero* depsgraph
48 updates, because an `IFSNodeTree` has no scene user. Every property needs an
49 explicit `update=` callback. The panel had this right from the start; the nodes
50 did not, and the graph appeared dead for it.
512. **Xaos is cyclic and Blender node trees assume a DAG.** Blender marks one link
52 per cycle invalid — a 3-node ring keeps all three links with exactly one
53 invalid — so the editor shows red warning triangles. Links are the wrong
54 primitive for xaos; an editable matrix owns the weights and the links are
55 decorative.
563. **RNA identity is not object identity.** `link.from_node is node` is False even
57 for the same node, because Blender rebuilds wrappers on access. That once
58 produced an empty xaos, i.e. a fractal with no transitions.
594. **Of Blender's eleven stock workspaces only three have a Node Editor**, and
60 **Layout** — where the IFS panel lives — is not one of them. `Build Node Graph`
61 looked like it did nothing for that reason alone.
62
63### The design decision
64
65**The tree should own the whole pipeline, not just the fractal**, and the panel
66should not survive alongside it. Two interfaces over the same state is the
67duplication problem the UI audit already found, scaled up.
68
69Three node categories:
70
71**Source** — what the fractal *is*
72- `Document` — loads an `.ifsjson`; the ONE place a file is read
73- `Iterator` — exists today
74- `Palette`
75- `Xaos` — the weight matrix, as a matrix (see constraint 2)
76
77**Accumulate** — the shared middle, currently invisible
78- `Accumulate` — dispatches, iterations, brightness, shading mode, opacity gain,
79 relief, depth bands
80
81**Output** — what comes out
82- `Viewport`, `Still`, `Volume`, `Point Cloud`, `Environment`
83
84Making `Accumulate` a real node is the load-bearing part. It is where every output
85path already converges — the histogram, the auto-exposure and the tonemap are
86literally shared code — and giving it a name fixes the *Brightness under Output*
87mislabel by construction rather than by moving a slider.
88
89The "which document" bug disappears the same way: there is one `Document` node,
90everything downstream reads what it produces, and there is no second source to
91diverge from.
92
93### Preview
94
95In a nodal interface, preview is **not a button**. Blender's own convention is an
96*active output* — the compositor's Viewer node, the shader editor's active
97material output. One Output node is active and its result is what you see.
98
99That gives preview-before-bake for free: point the active output at an
100`Environment` node with a low `Width` and few dispatches and it is a preview; raise
101them and it is the bake. Same node, same code path, no separate preview
102implementation to drift.
103
104It also gives the **progress and cancel** the accessibility audit flagged as the
105worst defect, because a graph naturally evaluates incrementally: an output that
106refines has somewhere to report from, where a modal operator behind a button does
107not.
108
109### The honest limitation
110
111**This is a presentation, not an evaluated graph.** Because a free-floating tree is
112never evaluated by the depsgraph (constraint 1), we do the evaluation ourselves,
113triggered by explicit `update=` callbacks. So the design must not promise:
114
115- lazy evaluation or dependency-ordered re-execution,
116- caching semantics ("only the changed branch re-runs"),
117- anything that reads as "Blender is evaluating this".
118
119It is a graph-shaped *editor* over a struct we walk ourselves. That is fine — it
120buys the structure and the vocabulary — but claiming more would be inventing
121behaviour we cannot implement, and the add-on has been bitten before by controls
122that succeed into a void.
123
124### Migration
125
1261. `Accumulate` and `Output` nodes, with the panel still authoritative.
1272. Move each output's properties onto its node; the panel draws the ACTIVE output's
128 properties, so there is exactly one home for each value.
1293. Active-output preview replaces the per-output operators.
1304. Retire the panel's per-output boxes.
131
132Each step leaves the add-on working, and step 2 is the one that kills the
133duplication class outright.
134
135---
136
137## Part 2 — can the fractal be a world shader?
138
139The short answer is **no for the general case, yes for two useful special cases**,
140and the distinction is worth being precise about because it decides what is worth
141building.
142
143### The structural obstacle
144
145A world shader is asked *"what colour is direction **d**?"* and must answer
146pointwise, in closed form. A fractal flame is a **density field built by
147accumulation** — millions of samples binned into a histogram. There is no function
148of **d** to evaluate. This is the same fact that has shaped everything else in the
149project: it is why the renderer is a histogram, why splats needed to accumulate,
150and why the volume bake is the only in-scene representation that approaches the
151native look.
152
153Blender's world *does* have a full node tree, and Cycles supports OSL script nodes,
154so custom shading code is available. It does not help. The obstacle is the
155representation, not the language.
156
157### (a) Inverse iteration — real, for a subset
158
159For an IFS whose maps are **invertible**, membership in the attractor can be tested
160per-point by iterating the INVERSE maps and checking whether the point escapes.
161This is genuinely shader-native and is how the well-known Kleinian and Apollonian
162gasket shaders work: infinite resolution, no texture, animatable by animating the
163transform parameters.
164
165Two limits, both material:
166
167- Our library has **46 transform plugins** and many are not invertible — spherical,
168 sinusoidal and friends. The technique applies only to documents built from
169 invertible (broadly affine) transforms.
170- It yields a **membership or distance field, not a density**. A flame's character
171 comes from how densely the chaos game visits each region; an escape test gives
172 you a sharp geometric object instead. It would look like a *different* fractal,
173 not like ours.
174
175Verdict: a real and interesting experiment for affine-only documents, and honestly
176labelled a different aesthetic rather than a translation of our output.
177
178### (b) Spherical harmonics — the one I would actually build
179
180Project the baked equirectangular map onto spherical harmonics. Evaluating SH from
181a direction is a handful of dot products, so it is shader-native and cheap;
182coefficients interpolate, so it is trivially animatable.
183
184SH is low-frequency: degree 2 is nine coefficients and a nebula's filaments are
185simply gone. That sounds fatal until you notice it is only fatal for one of the two
186jobs the sky does:
187
188| job | needs | representation |
189|---|---|---|
190| what the camera SEES | detail, filaments | the image we already bake |
191| how the sky LIGHTS the scene | low frequencies only | **SH** |
192
193Irradiance from an environment is inherently smooth — that is why game engines
194store it as SH. So the hybrid is: **image on the camera ray, SH for the lighting
195contribution.** Both are already derivable from one bake, the SH half is cheap to
196evaluate and cheap to animate, and neither is an approximation of the other's job.
197
198This is the piece with real value: it makes the sky's *lighting* animatable at
199shader rates without re-baking, while the visible sky updates at whatever rate a
200re-bake affords.
201
202### (c) Fitting procedural noise — available today, and not ours
203
204Blender's Noise and Voronoi nodes can be composed into something nebula-like:
205fully native, infinitely resolvable, animatable. But it is *a* nebula, not *this*
206one — there is no fitting procedure that would make it reproduce a given
207`.ifsjson`.
208
209Where this genuinely belongs is **compositing**, which needs no new work: our baked
210environment mixed in the World node tree with procedural stars. That plays to what
211those nodes are good at rather than asking them to be a fractal.
212
213### What this means for animation
214
215The practical answer for an animated sky is the one already built: **re-bake per
216frame**. It is 0.6 s at 1024×512, the seed is a pure function of the frame so a
217re-render is byte-identical, and the panel properties are already keyframeable.
218
219SH (b) is the addition worth making, because it decouples the *lighting* from the
220bake rate entirely — the scene can be lit by a smoothly animating sky evaluated in
221the shader, while the visible sky refreshes as often as a bake allows.
blender/ifs_hdri.py +1 -0
……@@ -207,6 +207,7 @@ def _tonemap(accum, width, height, brightness):
207207 tonemap.uniform_sampler('accum_tex', accum)
208208 tonemap.uniform_sampler('max_tex', maxbuf)
209209 tonemap.uniform_sampler('depth_tex', far)
210 tonemap.uniform_sampler('range_tex', far) # 2 texels unused at 1 band
210211 tonemap.uniform_int('out_size', (width, height))
211212 tonemap.uniform_float('brightness', brightness)
212213 tonemap.uniform_int('shading_mode', (0,)) # EMISSION: a sky is light
blender/ifs_layout.py +23 -1
……@@ -174,7 +174,26 @@ _ACCUMULATE_DEPTH = """void accumulate_hit(ivec2 proj, vec4 color)
174174 // its own gl_FragDepth, so the hardware rejects whole bands per pixel and the
175175 // dithering has nothing to come from. No access to Blender's depth texture is
176176 // needed — which is just as well, because Python cannot obtain one (issue #7).
177 int slice = clamp(int(g_depth_f * float(depth_slices)), 0, depth_slices - 1);
177 // Banded over the range the fractal ACTUALLY occupies, not over [0,1].
178 //
179 // Window depth is violently nonlinear: with Blender's default clipping a scene
180 // spanning distance 5..20 spans window depth 0.980..0.995. Uniform bands over
181 // [0,1] therefore put EVERY sample in the last band, whose far edge is 1.0 —
182 // behind everything — so the fractal was fully occluded by any object rather
183 // than partially. Measured as arithmetic before it was measured on screen.
184 //
185 // The range comes from the PREVIOUS frame's depth buffer, reduced on the GPU
186 // (no readback, which would stall the pipeline the auto-exposure deliberately
187 // avoids stalling). It is self-correcting: the viewport is progressive and the
188 // camera moves between frames far less than the fractal's own depth extent.
189 // Texel 0 holds the minimum COMPLEMENTED so one clear can seed both reductions;
190 // see the note in ifs_viewport's _DEPTH_RANGE.
191 float dmin = uintBitsToFloat(~imageLoad(depth_range_img, ivec2(0, 0)).x);
192 float dmax = uintBitsToFloat(imageLoad(depth_range_img, ivec2(1, 0)).x);
193 float band_t = (dmax > dmin)
194 ? clamp((g_depth_f - dmin) / (dmax - dmin), 0.0, 1.0)
195 : 0.0; // first frame, or a fractal with no depth extent: one band
196 int slice = clamp(int(band_t * float(depth_slices)), 0, depth_slices - 1);
178197 ivec2 base = ivec2(proj.x * 4, proj.y + slice * height);
179198 imageAtomicAdd(histogram_img, base + ivec2(0, 0), uint(max(color.r, 0.0) * HIST_SCALE));
180199 imageAtomicAdd(histogram_img, base + ivec2(1, 0), uint(max(color.g, 0.0) * HIST_SCALE));
……@@ -191,6 +210,9 @@ _ACCUMULATE_DEPTH = """void accumulate_hit(ivec2 proj, vec4 color)
191210_DEFOCUS_DECL = re.compile(r"(^[ \t]*float defocus;)", re.M)
192211
193212DEPTH_EXTRA_LAYOUT: tuple[Resource, ...] = (
213 Resource("image", 3, "depth_range_img", "UINT_2D",
214 "R32UI, 2x1. Texel 0 = min, texel 1 = max depth of the PREVIOUS "
215 "frame, used to band over the range the fractal really occupies."),
194216 Resource("image", 2, "depth_img", "UINT_2D",
195217 "R32UI, W x H. Nearest-sample window depth as float bits; "
196218 "imageAtomicMin target. Cleared to DEPTH_CLEAR."),
blender/ifs_render.py +2 -0
……@@ -233,6 +233,8 @@ def _build_shaders(width, height, hist_scale, depth=False, equirect=False):
233233 # with the rest when Vulkan or Metal is attempted.
234234 info.push_constant('MAT4', 'blender_view_proj')
235235 info.push_constant('INT', 'depth_slices')
236 info.image(3, 'R32UI', 'UINT_2D', 'depth_range_img',
237 qualifiers={'NO_RESTRICT', 'READ'})
236238 if equirect:
237239 info.push_constant('VEC3', 'equirect_centre')
238240 info.sampler(0, 'FLOAT_2D', 'alias_tables_tex')
blender/ifs_viewport.py +70 -6
……@@ -98,12 +98,17 @@ void main()
9898 // so it neither occludes nor is occluded. Note this texture is at the accumulator's
9999 // resolution, which is a half or quarter of the screen, so the occlusion boundary
100100 // is coarser than the geometry that casts it.
101 // With slices, the band's own FAR edge is the depth: the hardware then rejects
102 // whole bands per pixel and occlusion is N-step instead of the binary test that
103 // dithered. With one slice this falls back to the nearest-sample depth, which is
104 // what the still path and any single-band consumer want.
105 gl_FragDepth = depth_slices > 1
106 ? float(slice + 1) / float(depth_slices)
101 // The band's FAR edge, expressed INSIDE the range the fractal occupies. Mapping
102 // bands onto [0,1] instead put every band beyond any real geometry — window
103 // depth is nonlinear enough that a scene at distance 5..20 lives in 0.980..0.995
104 // — so the last band sat at 1.0 and a cube occluded the fractal entirely.
105 //
106 // With one band, or before a range has been measured, this falls back to the
107 // nearest-sample depth, which is what the still path wants.
108 float dmin = uintBitsToFloat(~texelFetch(range_tex, ivec2(0, 0), 0).x);
109 float dmax = uintBitsToFloat(texelFetch(range_tex, ivec2(1, 0), 0).x);
110 gl_FragDepth = (depth_slices > 1 && dmax > dmin)
111 ? dmin + (float(slice + 1) / float(depth_slices)) * (dmax - dmin)
107112 : uintBitsToFloat(texelFetch(depth_tex, c, 0).x);
108113
109114 vec4 h = texelFetch(accum_tex, sliced(c), 0);
……@@ -161,6 +166,47 @@ void main()
161166# Compiling these is the only place the gl_FragDepth write and the MAT4 push constant
162167# are actually checked by a driver.
163168
169_DEPTH_RANGE = """
170void main()
171{
172 ivec2 c = ivec2(gl_GlobalInvocationID.xy);
173 if (c.x >= out_size.x || c.y >= out_size.y) return;
174 uint d = imageLoad(depth_img, c).x;
175 /* Untouched pixels hold the far plane. Including them would peg the maximum at
176 1.0 for any frame with empty sky, which is most of them, and the range would
177 be meaningless. */
178 if (d >= 0x3F800000u) return;
179
180 /* Both texels reduced with MAX, and the minimum stored COMPLEMENTED.
181 GPUTexture.clear sets every texel to the same value, so a min and a max
182 cannot both be seeded correctly from one clear — seeded to 1.0 the maximum
183 can never fall below the far plane, and seeded to 0 the minimum is stuck
184 there. Since ~x is monotonically decreasing in x, max(~d) == ~min(d), so one
185 clear to zero seeds both and the reader complements texel 0 back. */
186 imageAtomicMax(range_img, ivec2(0, 0), ~d);
187 imageAtomicMax(range_img, ivec2(1, 0), d);
188}
189"""
190
191
192def build_depth_range():
193 """
194 Reduces the depth buffer to the fractal's own min and max depth.
195
196 Two texels, on the GPU, with no readback — the same discipline the
197 auto-exposure follows, and for the same reason: a readback here would stall
198 the pipeline every frame.
199 """
200 info = gpu.types.GPUShaderCreateInfo()
201 info.local_group_size(8, 8)
202 rw = {'NO_RESTRICT', 'READ', 'WRITE'}
203 info.image(0, 'R32UI', 'UINT_2D', 'depth_img', qualifiers={'NO_RESTRICT', 'READ'})
204 info.image(1, 'R32UI', 'UINT_2D', 'range_img', qualifiers=rw)
205 info.push_constant('IVEC2', 'out_size')
206 info.compute_source(_DEPTH_RANGE)
207 return gpu.shader.create_from_info(info)
208
209
164210def build_maxpass():
165211 info = gpu.types.GPUShaderCreateInfo()
166212 info.local_group_size(8, 8)
……@@ -182,6 +228,7 @@ def build_tonemap():
182228 info.sampler(0, 'FLOAT_2D', "accum_tex")
183229 info.sampler(1, 'UINT_2D', "max_tex")
184230 info.sampler(2, 'UINT_2D', "depth_tex")
231 info.sampler(3, 'UINT_2D', "range_tex")
185232 info.push_constant('IVEC2', "out_size")
186233 info.push_constant('FLOAT', "brightness")
187234 info.push_constant('INT', "shading_mode")
……@@ -226,6 +273,7 @@ class Session:
226273 self.kernel, self.drain, self.library = ifs_render._build_shaders(
227274 1, 1, hist_scale, depth=True)
228275 self.maxpass = build_maxpass()
276 self.depth_range = build_depth_range()
229277 self.tonemap = build_tonemap()
230278 self.batch = batch_for_shader(
231279 self.tonemap, 'TRI_FAN',
……@@ -424,6 +472,8 @@ class Session:
424472 # One depth per pixel, not four - it is a minimum, not an accumulation, so it
425473 # needs no interleaving and no drain.
426474 self.depthbuf = gpu.types.GPUTexture((width, height), format='R32UI')
475 # Texel 0 = min, texel 1 = max depth of whatever the fractal has touched.
476 self.rangebuf = gpu.types.GPUTexture((2, 1), format='R32UI')
427477 rows = (ifs_render.INVOCATIONS + ifs_render.STATE_W - 1) // ifs_render.STATE_W
428478 self.state = gpu.types.GPUTexture(
429479 (ifs_render.STATE_W * 2, rows), format='RGBA32F')
……@@ -559,8 +609,21 @@ class Session:
559609 reset_points_state=1 if self.since_reset == 0 else 0,
560610 invocation_iters=self.iters_per_frame))
561611
612 # Measure the fractal's depth extent from the previous frame, then band
613 # over THAT. Cleared to an empty range (min=+far, max=0) so a frame with
614 # nothing in it reports no range and the kernel falls back to one band.
615 # Zero seeds BOTH reductions; see the complement note in _DEPTH_RANGE.
616 self.rangebuf.clear(format='UINT', value=(0, 0, 0, 0))
617 self.depth_range.bind()
618 self.depth_range.image('depth_img', self.depthbuf)
619 self.depth_range.image('range_img', self.rangebuf)
620 self.depth_range.uniform_int('out_size', (self.width, self.height))
621 gpu.compute.dispatch(self.depth_range, (self.width + 7) // 8,
622 (self.height + 7) // 8, 1)
623
562624 self.kernel.bind()
563625 self.kernel.image('histogram_img', self.histogram)
626 self.kernel.image('depth_range_img', self.rangebuf)
564627 self.kernel.image('state_img', self.state)
565628 self.kernel.image('depth_img', self.depthbuf)
566629 # Blender's OWN view-projection, taken whole rather than rebuilt: the kernel's
……@@ -621,6 +684,7 @@ class Session:
621684 self.tonemap.uniform_sampler('accum_tex', self.accum)
622685 self.tonemap.uniform_sampler('max_tex', self.maxbuf)
623686 self.tonemap.uniform_sampler('depth_tex', self.depthbuf)
687 self.tonemap.uniform_sampler('range_tex', self.rangebuf)
624688 self.tonemap.uniform_int('out_size', (self.width, self.height))
625689 self.tonemap.uniform_float('brightness', self.brightness)
626690 self.tonemap.uniform_int('shading_mode', (self.shading_mode,))
blender/test_depth.py +16 -5
……@@ -131,11 +131,22 @@ class DepthRewrite(unittest.TestCase):
131131 self.assertNotIn("set_depth", self.plain)
132132 self.assertNotIn("blender_view_proj", self.plain)
133133
134 def test_depth_adds_exactly_one_image_slot(self):
135 self.assertEqual(1, len(DEPTH_EXTRA_LAYOUT))
136 self.assertEqual("depth_img", DEPTH_EXTRA_LAYOUT[0].name)
137 # Slots 0 and 1 are the histogram and the point state.
138 self.assertEqual(2, DEPTH_EXTRA_LAYOUT[0].slot)
134 def test_depth_adds_exactly_two_image_slots(self):
135 """
136 Depth mode costs TWO slots, and the count is asserted because Blender only
137 has eight and a declared-but-unused image consumes one anyway.
138
139 `depth_img` is the per-pixel nearest depth. `depth_range_img` is the two
140 texels holding the fractal's own min and max depth, which is what lets the
141 bands span the range the fractal actually occupies — window depth is
142 nonlinear enough that a scene at distance 5..20 lives in 0.980..0.995, so
143 bands spread over [0,1] put every one of them behind real geometry.
144
145 Running total with depth on: histogram 0, state 1, depth 2, range 3.
146 """
147 names = {r.name: r.slot for r in DEPTH_EXTRA_LAYOUT}
148 self.assertEqual(2, len(DEPTH_EXTRA_LAYOUT), f"slots changed: {names}")
149 self.assertEqual({"depth_img": 2, "depth_range_img": 3}, names)
139150
140151 def test_volume_and_depth_are_rejected_together(self):
141152 with self.assertRaises(ValueError):
blender/test_depth_composite.py +6 -0
……@@ -126,6 +126,9 @@ class DepthComposite(unittest.TestCase):
126126 shader.uniform_sampler('accum_tex', accum)
127127 shader.uniform_sampler('max_tex', peak)
128128 shader.uniform_sampler('depth_tex', depth_tex)
129 # Single-band: an empty range makes the tonemap fall back to the
130 # per-pixel depth, which is exactly what this file tests.
131 shader.uniform_sampler('range_tex', depth_tex)
129132 shader.uniform_int('out_size', (SIZE, SIZE))
130133 shader.uniform_float('brightness', 40.0)
131134 shader.uniform_int('shading_mode', (0,)) # EMISSION: opaque write
……@@ -197,6 +200,9 @@ class DepthComposite(unittest.TestCase):
197200 shader.uniform_sampler('accum_tex', accum)
198201 shader.uniform_sampler('max_tex', peak)
199202 shader.uniform_sampler('depth_tex', depth_tex)
203 # Single-band: an empty range makes the tonemap fall back to the
204 # per-pixel depth, which is exactly what this file tests.
205 shader.uniform_sampler('range_tex', depth_tex)
200206 shader.uniform_int('out_size', (SIZE, SIZE))
201207 shader.uniform_float('brightness', 40.0)
202208 shader.uniform_int('shading_mode', (0,))
blender/test_depth_slices.py +45 -0
……@@ -97,6 +97,44 @@ def _noise_depth(size, centre, spread, seed=0):
9797 return tex
9898
9999
100_RANGE_FILL = """
101void main()
102{
103 if (gl_GlobalInvocationID.x > 1u) return;
104 /* Texel 0 holds the MINIMUM COMPLEMENTED, matching the production reduction:
105 GPUTexture.clear writes one value to every texel, so a min and a max cannot
106 both be seeded from a single clear, and storing ~min lets one clear to zero
107 serve both. Writing it uncomplemented here made the reader recover a NaN,
108 the range test `dmax > dmin` failed, and the whole thing silently fell back
109 to single-band behaviour — which the sweep caught as "only 2 distinct
110 levels". */
111 uint bits = floatBitsToUint((gl_GlobalInvocationID.x == 0u) ? lo : hi);
112 if (gl_GlobalInvocationID.x == 0u) bits = ~bits;
113 imageStore(dst, ivec2(int(gl_GlobalInvocationID.x), 0),
114 uvec4(bits, 0u, 0u, 0u));
115}
116"""
117
118
119def _range_texture(lo, hi):
120 """The 2-texel min/max buffer the tonemap bands against, filled on the GPU."""
121 import gpu
122 tex = gpu.types.GPUTexture((2, 1), format='R32UI')
123 info = gpu.types.GPUShaderCreateInfo()
124 info.local_group_size(2, 1)
125 info.image(0, 'R32UI', 'UINT_2D', 'dst', qualifiers={'NO_RESTRICT', 'WRITE'})
126 info.push_constant('FLOAT', 'lo')
127 info.push_constant('FLOAT', 'hi')
128 info.compute_source(_RANGE_FILL)
129 shader = gpu.shader.create_from_info(info)
130 shader.bind()
131 shader.image('dst', tex)
132 shader.uniform_float('lo', lo)
133 shader.uniform_float('hi', hi)
134 gpu.compute.dispatch(shader, 1, 1, 1)
135 return tex
136
137
100138def _roughness(image):
101139 """
102140 Mean absolute difference between horizontal neighbours, normalised.
……@@ -157,6 +195,7 @@ class DepthStraddle(unittest.TestCase):
157195 tonemap.uniform_sampler('accum_tex', accum)
158196 tonemap.uniform_sampler('max_tex', maxbuf)
159197 tonemap.uniform_sampler('depth_tex', depth_tex)
198 tonemap.uniform_sampler('range_tex', depth_tex)
160199 tonemap.uniform_int('out_size', (SIZE, SIZE))
161200 tonemap.uniform_float('brightness', 8.0)
162201 tonemap.uniform_int('shading_mode', (0,))
……@@ -268,6 +307,11 @@ class SlicedOcclusion(unittest.TestCase):
268307 far = gpu.types.GPUTexture((SIZE, SIZE), format='R32UI')
269308 far.clear(format='UINT', value=(0x3F800000, 0, 0, 0))
270309
310 # A REAL range: bands map onto the full [0,1] here so the sweep below has
311 # known answers. Binding an empty range instead would silently exercise the
312 # single-band fallback and the test would prove nothing about bands.
313 rng = _range_texture(0.0, 1.0)
314
271315 colour = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F')
272316 depth_slot = gpu.types.GPUTexture((SIZE, SIZE), format='DEPTH_COMPONENT32F')
273317 fb = gpu.types.GPUFrameBuffer(depth_slot=depth_slot, color_slots=(colour,))
……@@ -280,6 +324,7 @@ class SlicedOcclusion(unittest.TestCase):
280324 tonemap.uniform_sampler('accum_tex', accum)
281325 tonemap.uniform_sampler('max_tex', maxbuf)
282326 tonemap.uniform_sampler('depth_tex', far)
327 tonemap.uniform_sampler('range_tex', rng)
283328 tonemap.uniform_int('out_size', (SIZE, SIZE))
284329 tonemap.uniform_float('brightness', 8.0)
285330 tonemap.uniform_int('shading_mode', (0,))
blender/test_splat_shading.py +1 -0
……@@ -294,6 +294,7 @@ class SplatThroughTonemap(unittest.TestCase):
294294 tonemap.uniform_sampler('accum_tex', accum)
295295 tonemap.uniform_sampler('max_tex', maxbuf)
296296 tonemap.uniform_sampler('depth_tex', depth_img)
297 tonemap.uniform_sampler('range_tex', depth_img)
297298 tonemap.uniform_int('out_size', (size, size))
298299 tonemap.uniform_float('brightness', brightness)
299300 tonemap.uniform_int('shading_mode', (0,))