| … | … | @@ -0,0 +1,221 @@ |
| 1 | # Design: a unified nodal interface, and native world shading | |
| 2 | ||
| 3 | Written 2026-08-18, before any of it is built. Two questions that turned out to be | |
| 4 | related: how the add-on should be *driven*, and whether the fractal can be a world | |
| 5 | shader rather than an image. | |
| 6 | ||
| 7 | --- | |
| 8 | ||
| 9 | ## Part 1 — the nodal interface | |
| 10 | ||
| 11 | ### Why the panel stopped fitting | |
| 12 | ||
| 13 | The add-on now has **four output paths** — live viewport, volume bake, point | |
| 14 | cloud, environment map — and a fifth (still render) that predates them. Each | |
| 15 | arrived as an operator plus a handful of properties appended to one flat panel. | |
| 16 | The panel is a list; the thing it describes is a graph. | |
| 17 | ||
| 18 | The evidence that this is a real problem, not an aesthetic one, is that the same | |
| 19 | bugs 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 | ||
| 35 | A 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 | |
| 41 | panel and a `Build Node Graph` operator. **It owns the fractal DEFINITION only** — | |
| 42 | iterators and xaos. Outputs are not represented. | |
| 43 | ||
| 44 | Four things learned building it, all of which constrain this design: | |
| 45 | ||
| 46 | 1. **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. | |
| 51 | 2. **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. | |
| 56 | 3. **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. | |
| 59 | 4. **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 | |
| 66 | should not survive alongside it. Two interfaces over the same state is the | |
| 67 | duplication problem the UI audit already found, scaled up. | |
| 68 | ||
| 69 | Three 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 | ||
| 84 | Making `Accumulate` a real node is the load-bearing part. It is where every output | |
| 85 | path already converges — the histogram, the auto-exposure and the tonemap are | |
| 86 | literally shared code — and giving it a name fixes the *Brightness under Output* | |
| 87 | mislabel by construction rather than by moving a slider. | |
| 88 | ||
| 89 | The "which document" bug disappears the same way: there is one `Document` node, | |
| 90 | everything downstream reads what it produces, and there is no second source to | |
| 91 | diverge from. | |
| 92 | ||
| 93 | ### Preview | |
| 94 | ||
| 95 | In 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 | |
| 97 | material output. One Output node is active and its result is what you see. | |
| 98 | ||
| 99 | That 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 | |
| 101 | them and it is the bake. Same node, same code path, no separate preview | |
| 102 | implementation to drift. | |
| 103 | ||
| 104 | It also gives the **progress and cancel** the accessibility audit flagged as the | |
| 105 | worst defect, because a graph naturally evaluates incrementally: an output that | |
| 106 | refines has somewhere to report from, where a modal operator behind a button does | |
| 107 | not. | |
| 108 | ||
| 109 | ### The honest limitation | |
| 110 | ||
| 111 | **This is a presentation, not an evaluated graph.** Because a free-floating tree is | |
| 112 | never evaluated by the depsgraph (constraint 1), we do the evaluation ourselves, | |
| 113 | triggered 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 | ||
| 119 | It is a graph-shaped *editor* over a struct we walk ourselves. That is fine — it | |
| 120 | buys the structure and the vocabulary — but claiming more would be inventing | |
| 121 | behaviour we cannot implement, and the add-on has been bitten before by controls | |
| 122 | that succeed into a void. | |
| 123 | ||
| 124 | ### Migration | |
| 125 | ||
| 126 | 1. `Accumulate` and `Output` nodes, with the panel still authoritative. | |
| 127 | 2. 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. | |
| 129 | 3. Active-output preview replaces the per-output operators. | |
| 130 | 4. Retire the panel's per-output boxes. | |
| 131 | ||
| 132 | Each step leaves the add-on working, and step 2 is the one that kills the | |
| 133 | duplication class outright. | |
| 134 | ||
| 135 | --- | |
| 136 | ||
| 137 | ## Part 2 — can the fractal be a world shader? | |
| 138 | ||
| 139 | The short answer is **no for the general case, yes for two useful special cases**, | |
| 140 | and the distinction is worth being precise about because it decides what is worth | |
| 141 | building. | |
| 142 | ||
| 143 | ### The structural obstacle | |
| 144 | ||
| 145 | A world shader is asked *"what colour is direction **d**?"* and must answer | |
| 146 | pointwise, in closed form. A fractal flame is a **density field built by | |
| 147 | accumulation** — millions of samples binned into a histogram. There is no function | |
| 148 | of **d** to evaluate. This is the same fact that has shaped everything else in the | |
| 149 | project: it is why the renderer is a histogram, why splats needed to accumulate, | |
| 150 | and why the volume bake is the only in-scene representation that approaches the | |
| 151 | native look. | |
| 152 | ||
| 153 | Blender's world *does* have a full node tree, and Cycles supports OSL script nodes, | |
| 154 | so custom shading code is available. It does not help. The obstacle is the | |
| 155 | representation, not the language. | |
| 156 | ||
| 157 | ### (a) Inverse iteration — real, for a subset | |
| 158 | ||
| 159 | For an IFS whose maps are **invertible**, membership in the attractor can be tested | |
| 160 | per-point by iterating the INVERSE maps and checking whether the point escapes. | |
| 161 | This is genuinely shader-native and is how the well-known Kleinian and Apollonian | |
| 162 | gasket shaders work: infinite resolution, no texture, animatable by animating the | |
| 163 | transform parameters. | |
| 164 | ||
| 165 | Two 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 | ||
| 175 | Verdict: a real and interesting experiment for affine-only documents, and honestly | |
| 176 | labelled a different aesthetic rather than a translation of our output. | |
| 177 | ||
| 178 | ### (b) Spherical harmonics — the one I would actually build | |
| 179 | ||
| 180 | Project the baked equirectangular map onto spherical harmonics. Evaluating SH from | |
| 181 | a direction is a handful of dot products, so it is shader-native and cheap; | |
| 182 | coefficients interpolate, so it is trivially animatable. | |
| 183 | ||
| 184 | SH is low-frequency: degree 2 is nine coefficients and a nebula's filaments are | |
| 185 | simply gone. That sounds fatal until you notice it is only fatal for one of the two | |
| 186 | jobs 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 | ||
| 193 | Irradiance from an environment is inherently smooth — that is why game engines | |
| 194 | store it as SH. So the hybrid is: **image on the camera ray, SH for the lighting | |
| 195 | contribution.** Both are already derivable from one bake, the SH half is cheap to | |
| 196 | evaluate and cheap to animate, and neither is an approximation of the other's job. | |
| 197 | ||
| 198 | This is the piece with real value: it makes the sky's *lighting* animatable at | |
| 199 | shader rates without re-baking, while the visible sky updates at whatever rate a | |
| 200 | re-bake affords. | |
| 201 | ||
| 202 | ### (c) Fitting procedural noise — available today, and not ours | |
| 203 | ||
| 204 | Blender's Noise and Voronoi nodes can be composed into something nebula-like: | |
| 205 | fully native, infinitely resolvable, animatable. But it is *a* nebula, not *this* | |
| 206 | one — there is no fitting procedure that would make it reproduce a given | |
| 207 | `.ifsjson`. | |
| 208 | ||
| 209 | Where this genuinely belongs is **compositing**, which needs no new work: our baked | |
| 210 | environment mixed in the World node tree with procedural stars. That plays to what | |
| 211 | those nodes are good at rather than asking them to be a fractal. | |
| 212 | ||
| 213 | ### What this means for animation | |
| 214 | ||
| 215 | The practical answer for an animated sky is the one already built: **re-bake per | |
| 216 | frame**. It is 0.6 s at 1024×512, the seed is a pure function of the frame so a | |
| 217 | re-render is byte-identical, and the panel properties are already keyframeable. | |
| 218 | ||
| 219 | SH (b) is the addition worth making, because it decouples the *lighting* from the | |
| 220 | bake rate entirely — the scene can be lit by a smoothly animating sky evaluated in | |
| 221 | the shader, while the visible sky refreshes as often as a bake allows. |
| … | … | @@ -207,6 +207,7 @@ def _tonemap(accum, width, height, brightness): |
| 207 | 207 | tonemap.uniform_sampler('accum_tex', accum) |
| 208 | 208 | tonemap.uniform_sampler('max_tex', maxbuf) |
| 209 | 209 | tonemap.uniform_sampler('depth_tex', far) |
| 210 | tonemap.uniform_sampler('range_tex', far) # 2 texels unused at 1 band | |
| 210 | 211 | tonemap.uniform_int('out_size', (width, height)) |
| 211 | 212 | tonemap.uniform_float('brightness', brightness) |
| 212 | 213 | tonemap.uniform_int('shading_mode', (0,)) # EMISSION: a sky is light |
| … | … | @@ -174,7 +174,26 @@ _ACCUMULATE_DEPTH = """void accumulate_hit(ivec2 proj, vec4 color) |
| 174 | 174 | // its own gl_FragDepth, so the hardware rejects whole bands per pixel and the |
| 175 | 175 | // dithering has nothing to come from. No access to Blender's depth texture is |
| 176 | 176 | // 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); | |
| 178 | 197 | ivec2 base = ivec2(proj.x * 4, proj.y + slice * height); |
| 179 | 198 | imageAtomicAdd(histogram_img, base + ivec2(0, 0), uint(max(color.r, 0.0) * HIST_SCALE)); |
| 180 | 199 | 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) |
| 191 | 210 | _DEFOCUS_DECL = re.compile(r"(^[ \t]*float defocus;)", re.M) |
| 192 | 211 | |
| 193 | 212 | DEPTH_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."), | |
| 194 | 216 | Resource("image", 2, "depth_img", "UINT_2D", |
| 195 | 217 | "R32UI, W x H. Nearest-sample window depth as float bits; " |
| 196 | 218 | "imageAtomicMin target. Cleared to DEPTH_CLEAR."), |
| … | … | @@ -233,6 +233,8 @@ def _build_shaders(width, height, hist_scale, depth=False, equirect=False): |
| 233 | 233 | # with the rest when Vulkan or Metal is attempted. |
| 234 | 234 | info.push_constant('MAT4', 'blender_view_proj') |
| 235 | 235 | info.push_constant('INT', 'depth_slices') |
| 236 | info.image(3, 'R32UI', 'UINT_2D', 'depth_range_img', | |
| 237 | qualifiers={'NO_RESTRICT', 'READ'}) | |
| 236 | 238 | if equirect: |
| 237 | 239 | info.push_constant('VEC3', 'equirect_centre') |
| 238 | 240 | info.sampler(0, 'FLOAT_2D', 'alias_tables_tex') |
| … | … | @@ -98,12 +98,17 @@ void main() |
| 98 | 98 | // so it neither occludes nor is occluded. Note this texture is at the accumulator's |
| 99 | 99 | // resolution, which is a half or quarter of the screen, so the occlusion boundary |
| 100 | 100 | // 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) | |
| 107 | 112 | : uintBitsToFloat(texelFetch(depth_tex, c, 0).x); |
| 108 | 113 | |
| 109 | 114 | vec4 h = texelFetch(accum_tex, sliced(c), 0); |
| … | … | @@ -161,6 +166,47 @@ void main() |
| 161 | 166 | # Compiling these is the only place the gl_FragDepth write and the MAT4 push constant |
| 162 | 167 | # are actually checked by a driver. |
| 163 | 168 | |
| 169 | _DEPTH_RANGE = """ | |
| 170 | void 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 | ||
| 192 | def 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 | ||
| 164 | 210 | def build_maxpass(): |
| 165 | 211 | info = gpu.types.GPUShaderCreateInfo() |
| 166 | 212 | info.local_group_size(8, 8) |
| … | … | @@ -182,6 +228,7 @@ def build_tonemap(): |
| 182 | 228 | info.sampler(0, 'FLOAT_2D', "accum_tex") |
| 183 | 229 | info.sampler(1, 'UINT_2D', "max_tex") |
| 184 | 230 | info.sampler(2, 'UINT_2D', "depth_tex") |
| 231 | info.sampler(3, 'UINT_2D', "range_tex") | |
| 185 | 232 | info.push_constant('IVEC2', "out_size") |
| 186 | 233 | info.push_constant('FLOAT', "brightness") |
| 187 | 234 | info.push_constant('INT', "shading_mode") |
| … | … | @@ -226,6 +273,7 @@ class Session: |
| 226 | 273 | self.kernel, self.drain, self.library = ifs_render._build_shaders( |
| 227 | 274 | 1, 1, hist_scale, depth=True) |
| 228 | 275 | self.maxpass = build_maxpass() |
| 276 | self.depth_range = build_depth_range() | |
| 229 | 277 | self.tonemap = build_tonemap() |
| 230 | 278 | self.batch = batch_for_shader( |
| 231 | 279 | self.tonemap, 'TRI_FAN', |
| … | … | @@ -424,6 +472,8 @@ class Session: |
| 424 | 472 | # One depth per pixel, not four - it is a minimum, not an accumulation, so it |
| 425 | 473 | # needs no interleaving and no drain. |
| 426 | 474 | 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') | |
| 427 | 477 | rows = (ifs_render.INVOCATIONS + ifs_render.STATE_W - 1) // ifs_render.STATE_W |
| 428 | 478 | self.state = gpu.types.GPUTexture( |
| 429 | 479 | (ifs_render.STATE_W * 2, rows), format='RGBA32F') |
| … | … | @@ -559,8 +609,21 @@ class Session: |
| 559 | 609 | reset_points_state=1 if self.since_reset == 0 else 0, |
| 560 | 610 | invocation_iters=self.iters_per_frame)) |
| 561 | 611 | |
| 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 | ||
| 562 | 624 | self.kernel.bind() |
| 563 | 625 | self.kernel.image('histogram_img', self.histogram) |
| 626 | self.kernel.image('depth_range_img', self.rangebuf) | |
| 564 | 627 | self.kernel.image('state_img', self.state) |
| 565 | 628 | self.kernel.image('depth_img', self.depthbuf) |
| 566 | 629 | # Blender's OWN view-projection, taken whole rather than rebuilt: the kernel's |
| … | … | @@ -621,6 +684,7 @@ class Session: |
| 621 | 684 | self.tonemap.uniform_sampler('accum_tex', self.accum) |
| 622 | 685 | self.tonemap.uniform_sampler('max_tex', self.maxbuf) |
| 623 | 686 | self.tonemap.uniform_sampler('depth_tex', self.depthbuf) |
| 687 | self.tonemap.uniform_sampler('range_tex', self.rangebuf) | |
| 624 | 688 | self.tonemap.uniform_int('out_size', (self.width, self.height)) |
| 625 | 689 | self.tonemap.uniform_float('brightness', self.brightness) |
| 626 | 690 | self.tonemap.uniform_int('shading_mode', (self.shading_mode,)) |
| … | … | @@ -131,11 +131,22 @@ class DepthRewrite(unittest.TestCase): |
| 131 | 131 | self.assertNotIn("set_depth", self.plain) |
| 132 | 132 | self.assertNotIn("blender_view_proj", self.plain) |
| 133 | 133 | |
| 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) | |
| 139 | 150 | |
| 140 | 151 | def test_volume_and_depth_are_rejected_together(self): |
| 141 | 152 | with self.assertRaises(ValueError): |
| … | … | @@ -126,6 +126,9 @@ class DepthComposite(unittest.TestCase): |
| 126 | 126 | shader.uniform_sampler('accum_tex', accum) |
| 127 | 127 | shader.uniform_sampler('max_tex', peak) |
| 128 | 128 | 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) | |
| 129 | 132 | shader.uniform_int('out_size', (SIZE, SIZE)) |
| 130 | 133 | shader.uniform_float('brightness', 40.0) |
| 131 | 134 | shader.uniform_int('shading_mode', (0,)) # EMISSION: opaque write |
| … | … | @@ -197,6 +200,9 @@ class DepthComposite(unittest.TestCase): |
| 197 | 200 | shader.uniform_sampler('accum_tex', accum) |
| 198 | 201 | shader.uniform_sampler('max_tex', peak) |
| 199 | 202 | 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) | |
| 200 | 206 | shader.uniform_int('out_size', (SIZE, SIZE)) |
| 201 | 207 | shader.uniform_float('brightness', 40.0) |
| 202 | 208 | shader.uniform_int('shading_mode', (0,)) |
| … | … | @@ -97,6 +97,44 @@ def _noise_depth(size, centre, spread, seed=0): |
| 97 | 97 | return tex |
| 98 | 98 | |
| 99 | 99 | |
| 100 | _RANGE_FILL = """ | |
| 101 | void 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 | ||
| 119 | def _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 | ||
| 100 | 138 | def _roughness(image): |
| 101 | 139 | """ |
| 102 | 140 | Mean absolute difference between horizontal neighbours, normalised. |
| … | … | @@ -157,6 +195,7 @@ class DepthStraddle(unittest.TestCase): |
| 157 | 195 | tonemap.uniform_sampler('accum_tex', accum) |
| 158 | 196 | tonemap.uniform_sampler('max_tex', maxbuf) |
| 159 | 197 | tonemap.uniform_sampler('depth_tex', depth_tex) |
| 198 | tonemap.uniform_sampler('range_tex', depth_tex) | |
| 160 | 199 | tonemap.uniform_int('out_size', (SIZE, SIZE)) |
| 161 | 200 | tonemap.uniform_float('brightness', 8.0) |
| 162 | 201 | tonemap.uniform_int('shading_mode', (0,)) |
| … | … | @@ -268,6 +307,11 @@ class SlicedOcclusion(unittest.TestCase): |
| 268 | 307 | far = gpu.types.GPUTexture((SIZE, SIZE), format='R32UI') |
| 269 | 308 | far.clear(format='UINT', value=(0x3F800000, 0, 0, 0)) |
| 270 | 309 | |
| 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 | ||
| 271 | 315 | colour = gpu.types.GPUTexture((SIZE, SIZE), format='RGBA32F') |
| 272 | 316 | depth_slot = gpu.types.GPUTexture((SIZE, SIZE), format='DEPTH_COMPONENT32F') |
| 273 | 317 | fb = gpu.types.GPUFrameBuffer(depth_slot=depth_slot, color_slots=(colour,)) |
| … | … | @@ -280,6 +324,7 @@ class SlicedOcclusion(unittest.TestCase): |
| 280 | 324 | tonemap.uniform_sampler('accum_tex', accum) |
| 281 | 325 | tonemap.uniform_sampler('max_tex', maxbuf) |
| 282 | 326 | tonemap.uniform_sampler('depth_tex', far) |
| 327 | tonemap.uniform_sampler('range_tex', rng) | |
| 283 | 328 | tonemap.uniform_int('out_size', (SIZE, SIZE)) |
| 284 | 329 | tonemap.uniform_float('brightness', 8.0) |
| 285 | 330 | tonemap.uniform_int('shading_mode', (0,)) |
| … | … | @@ -294,6 +294,7 @@ class SplatThroughTonemap(unittest.TestCase): |
| 294 | 294 | tonemap.uniform_sampler('accum_tex', accum) |
| 295 | 295 | tonemap.uniform_sampler('max_tex', maxbuf) |
| 296 | 296 | tonemap.uniform_sampler('depth_tex', depth_img) |
| 297 | tonemap.uniform_sampler('range_tex', depth_img) | |
| 297 | 298 | tonemap.uniform_int('out_size', (size, size)) |
| 298 | 299 | tonemap.uniform_float('brightness', brightness) |
| 299 | 300 | tonemap.uniform_int('shading_mode', (0,)) |