| … | … | @@ -0,0 +1,293 @@ |
| 1 | """ |
| 2 | Live progressive rendering into the 3D viewport. |
| 3 | |
| 4 | A draw handler dispatches a batch of chaos-game iterations per redraw, drains into |
| 5 | a float accumulator, tonemaps on the GPU and blits the result over the viewport. |
| 6 | Nothing is read back to the CPU in the steady state — the per-frame readback that |
| 7 | a Python tonemap would need is what would make this a slideshow. |
| 8 | |
| 9 | Two things make it feel right rather than merely work: |
| 10 | |
| 11 | * **The kernel follows the VIEWPORT camera**, not the document's. Orbiting rebuilds |
| 12 | the view-projection from `region_3d`, so you navigate the fractal the way you |
| 13 | navigate a scene. |
| 14 | * **Camera movement invalidates the histogram.** Accumulated samples belong to the |
| 15 | old viewpoint, so they are discarded and accumulation restarts — the engine does |
| 16 | the same thing through `reset_points_state`. While you move you get a noisy |
| 17 | preview; when you stop it resolves. That is inherent to progressive accumulation, |
| 18 | not a defect. |
| 19 | |
| 20 | The auto-exposure max is computed on the GPU with `imageAtomicMax` into a 1x1 |
| 21 | image, so even the peak density never crosses the bus. |
| 22 | """ |
| 23 | |
| 24 | from __future__ import annotations |
| 25 | |
| 26 | import math |
| 27 | |
| 28 | import bpy |
| 29 | import gpu |
| 30 | from gpu_extras.batch import batch_for_shader |
| 31 | from mathutils import Vector |
| 32 | |
| 33 | import ifs_document |
| 34 | import ifs_host |
| 35 | import ifs_render |
| 36 | |
| 37 | # One live session at a time. A second would fight the first for the histogram. |
| 38 | _session = None |
| 39 | |
| 40 | |
| 41 | _MAXPASS = """ |
| 42 | void main() |
| 43 | { |
| 44 | ivec2 c = ivec2(gl_GlobalInvocationID.xy); |
| 45 | if (c.x >= out_size.x || c.y >= out_size.y) return; |
| 46 | float d = imageLoad(accum_img, c).w; |
| 47 | imageAtomicMax(max_img, ivec2(0, 0), uint(d)); |
| 48 | } |
| 49 | """ |
| 50 | |
| 51 | _TONEMAP_VERT = """ |
| 52 | void main() |
| 53 | { |
| 54 | uv = pos * 0.5 + 0.5; |
| 55 | gl_Position = vec4(pos, 0.0, 1.0); |
| 56 | } |
| 57 | """ |
| 58 | |
| 59 | # The engine's curve: log density, colour averaged by density (RendererGL's |
| 60 | # tonemap). Kept in one place so the still and live paths cannot drift. |
| 61 | _TONEMAP_FRAG = """ |
| 62 | void main() |
| 63 | { |
| 64 | ivec2 c = ivec2(uv * vec2(out_size)); |
| 65 | vec4 h = texelFetch(accum_tex, c, 0); |
| 66 | float peak = max(float(texelFetch(max_tex, ivec2(0, 0), 0).x), 1.0); |
| 67 | float d = max(h.w, 1e-12); |
| 68 | float s = log(1.0 + brightness * h.w / peak) / log(10.0) / d; |
| 69 | fragColor = vec4(clamp(h.rgb * s, 0.0, 1.0), 1.0); |
| 70 | } |
| 71 | """ |
| 72 | |
| 73 | |
| 74 | class Session: |
| 75 | """Persistent GPU state for one live viewport.""" |
| 76 | |
| 77 | def __init__(self, document_path, iters_per_frame=400, hist_scale=1024.0): |
| 78 | self.iters_per_frame = iters_per_frame |
| 79 | self.hist_scale = hist_scale |
| 80 | self.handle = None |
| 81 | self.width = 0 |
| 82 | self.height = 0 |
| 83 | self.last_view = None |
| 84 | self.dispatch_count = 0 |
| 85 | self.frames = 0 |
| 86 | |
| 87 | self.kernel, self.drain, self.library = ifs_render._build_shaders( |
| 88 | 1, 1, hist_scale) |
| 89 | self.maxpass = self._build_maxpass() |
| 90 | self.tonemap = self._build_tonemap() |
| 91 | self.batch = batch_for_shader( |
| 92 | self.tonemap, 'TRI_FAN', |
| 93 | {"pos": ((-1, -1), (1, -1), (1, 1), (-1, 1))}) |
| 94 | |
| 95 | self.doc = ifs_document.load(document_path, self.library) |
| 96 | self.brightness = self.doc.brightness * 8.0 |
| 97 | self._build_buffers() |
| 98 | self._resize(64, 64) |
| 99 | |
| 100 | # ------------------------------------------------------------------ shaders |
| 101 | |
| 102 | def _build_maxpass(self): |
| 103 | info = gpu.types.GPUShaderCreateInfo() |
| 104 | info.local_group_size(8, 8) |
| 105 | rw = {'NO_RESTRICT', 'READ', 'WRITE'} |
| 106 | info.image(0, 'RGBA32F', 'FLOAT_2D', 'accum_img', qualifiers={'NO_RESTRICT', 'READ'}) |
| 107 | info.image(1, 'R32UI', 'UINT_2D', 'max_img', qualifiers=rw) |
| 108 | info.push_constant('IVEC2', 'out_size') |
| 109 | info.compute_source(_MAXPASS) |
| 110 | return gpu.shader.create_from_info(info) |
| 111 | |
| 112 | def _build_tonemap(self): |
| 113 | vert = gpu.types.GPUStageInterfaceInfo("ifs_tonemap") |
| 114 | vert.smooth('VEC2', "uv") |
| 115 | info = gpu.types.GPUShaderCreateInfo() |
| 116 | info.vertex_in(0, 'VEC2', "pos") |
| 117 | info.vertex_out(vert) |
| 118 | info.fragment_out(0, 'VEC4', "fragColor") |
| 119 | info.sampler(0, 'FLOAT_2D', "accum_tex") |
| 120 | info.sampler(1, 'UINT_2D', "max_tex") |
| 121 | info.push_constant('IVEC2', "out_size") |
| 122 | info.push_constant('FLOAT', "brightness") |
| 123 | info.vertex_source(_TONEMAP_VERT) |
| 124 | info.fragment_source(_TONEMAP_FRAG) |
| 125 | return gpu.shader.create_from_info(info) |
| 126 | |
| 127 | # ------------------------------------------------------------------ buffers |
| 128 | |
| 129 | def _build_buffers(self): |
| 130 | doc = self.doc |
| 131 | self.ubos = { |
| 132 | 'iterators_buf': gpu.types.GPUUniformBuf( |
| 133 | ifs_host.pack_iterators(doc.iterators)), |
| 134 | 'palette_buf': gpu.types.GPUUniformBuf( |
| 135 | ifs_host.pack_palette(doc.palette(doc.settings.gradient_resolution))), |
| 136 | 'real_params_buf': gpu.types.GPUUniformBuf( |
| 137 | ifs_host.pack_real_params(ifs_host.flatten_real_params(doc.iterators))), |
| 138 | 'vec3_params_buf': gpu.types.GPUUniformBuf( |
| 139 | ifs_host.pack_vec3_params(ifs_host.flatten_vec3_params(doc.iterators))), |
| 140 | } |
| 141 | alias = ifs_host.build_alias_tables(doc.iterators) |
| 142 | n = len(doc.iterators) |
| 143 | self.alias_tex = gpu.types.GPUTexture( |
| 144 | (n, n), format='RGBA32F', |
| 145 | data=gpu.types.Buffer('FLOAT', len(alias), alias)) |
| 146 | |
| 147 | def _resize(self, width, height): |
| 148 | if (width, height) == (self.width, self.height): |
| 149 | return |
| 150 | self.width, self.height = width, height |
| 151 | self.histogram = gpu.types.GPUTexture((width * 4, height), format='R32UI') |
| 152 | self.accum = gpu.types.GPUTexture((width, height), format='RGBA32F') |
| 153 | self.maxbuf = gpu.types.GPUTexture((1, 1), format='R32UI') |
| 154 | rows = (ifs_render.INVOCATIONS + ifs_render.STATE_W - 1) // ifs_render.STATE_W |
| 155 | self.state = gpu.types.GPUTexture( |
| 156 | (ifs_render.STATE_W * 2, rows), format='RGBA32F') |
| 157 | self.invalidate() |
| 158 | |
| 159 | def invalidate(self): |
| 160 | """Discards accumulated samples. Called when the viewpoint changes.""" |
| 161 | self.histogram.clear(format='UINT', value=(0, 0, 0, 0)) |
| 162 | self.accum.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0)) |
| 163 | self.state.clear(format='FLOAT', value=(0.0, 0.0, 0.0, 0.0)) |
| 164 | self.dispatch_count = 0 |
| 165 | |
| 166 | # -------------------------------------------------------------------- camera |
| 167 | |
| 168 | def _view_projection(self, region_3d, lens): |
| 169 | """ |
| 170 | The engine's view-projection, from the viewport's current camera. |
| 171 | |
| 172 | Blender's own `perspective_matrix` cannot be used directly: the kernel |
| 173 | discards `w >= 0`, which is the opposite of Blender's convention. Building |
| 174 | it from eye/forward/up through the engine's own construction keeps the sign |
| 175 | right instead of fighting it. |
| 176 | """ |
| 177 | world = region_3d.view_matrix.inverted() |
| 178 | eye = world.translation |
| 179 | forward = -Vector(world.col[2][:3]) # Blender cameras look down -Z |
| 180 | up = Vector(world.col[1][:3]) |
| 181 | fov = math.degrees(2.0 * math.atan(18.0 / max(lens, 1.0))) |
| 182 | return ifs_render.view_projection( |
| 183 | (eye.x, eye.y, eye.z), (forward.x, forward.y, forward.z), |
| 184 | (up.x, up.y, up.z), fov_degrees=fov) |
| 185 | |
| 186 | # ---------------------------------------------------------------------- draw |
| 187 | |
| 188 | def draw(self, context): |
| 189 | region = context.region |
| 190 | self._resize(max(region.width // 2, 16), max(region.height // 2, 16)) |
| 191 | |
| 192 | space = context.space_data |
| 193 | view_proj = self._view_projection(space.region_3d, space.lens) |
| 194 | if view_proj != self.last_view: |
| 195 | self.last_view = view_proj |
| 196 | self.invalidate() |
| 197 | |
| 198 | camera = self.doc.camera |
| 199 | camera.view_proj = view_proj |
| 200 | world = space.region_3d.view_matrix.inverted() |
| 201 | camera.position = (*world.translation, 1.0) |
| 202 | settings_ubo = gpu.types.GPUUniformBuf( |
| 203 | ifs_host.pack_settings(camera, self.doc.settings)) |
| 204 | |
| 205 | frame = gpu.types.GPUUniformBuf(ifs_host.pack_frame( |
| 206 | seed=0x9E3779B9, width=self.width, height=self.height, |
| 207 | dispatch_cnt=self.dispatch_count, |
| 208 | reset_points_state=1 if self.dispatch_count == 0 else 0, |
| 209 | invocation_iters=self.iters_per_frame)) |
| 210 | |
| 211 | self.kernel.bind() |
| 212 | self.kernel.image('histogram_img', self.histogram) |
| 213 | self.kernel.image('state_img', self.state) |
| 214 | self.kernel.uniform_sampler('alias_tables_tex', self.alias_tex) |
| 215 | self.kernel.uniform_block('frame', frame) |
| 216 | self.kernel.uniform_block('settings', settings_ubo) |
| 217 | for name, ubo in self.ubos.items(): |
| 218 | self.kernel.uniform_block(name, ubo) |
| 219 | gpu.compute.dispatch(self.kernel, ifs_render.WORKGROUPS, 1, 1) |
| 220 | self.dispatch_count += 1 |
| 221 | |
| 222 | self.drain.bind() |
| 223 | self.drain.image('histogram_img', self.histogram) |
| 224 | self.drain.image('accum_img', self.accum) |
| 225 | self.drain.uniform_int('out_size', (self.width, self.height)) |
| 226 | gpu.compute.dispatch(self.drain, (self.width + 7) // 8, |
| 227 | (self.height + 7) // 8, 1) |
| 228 | |
| 229 | # Auto-exposure without a readback: the peak lives in a 1x1 uint image. |
| 230 | self.maxbuf.clear(format='UINT', value=(0, 0, 0, 0)) |
| 231 | self.maxpass.bind() |
| 232 | self.maxpass.image('accum_img', self.accum) |
| 233 | self.maxpass.image('max_img', self.maxbuf) |
| 234 | self.maxpass.uniform_int('out_size', (self.width, self.height)) |
| 235 | gpu.compute.dispatch(self.maxpass, (self.width + 7) // 8, |
| 236 | (self.height + 7) // 8, 1) |
| 237 | |
| 238 | self.tonemap.bind() |
| 239 | self.tonemap.uniform_sampler('accum_tex', self.accum) |
| 240 | self.tonemap.uniform_sampler('max_tex', self.maxbuf) |
| 241 | self.tonemap.uniform_int('out_size', (self.width, self.height)) |
| 242 | self.tonemap.uniform_float('brightness', self.brightness) |
| 243 | self.batch.draw(self.tonemap) |
| 244 | |
| 245 | self.frames += 1 |
| 246 | # Keep animating: without this the viewport only refines when nudged. |
| 247 | for area in context.screen.areas: |
| 248 | if area.type == 'VIEW_3D': |
| 249 | area.tag_redraw() |
| 250 | |
| 251 | @property |
| 252 | def iterations(self): |
| 253 | return self.dispatch_count * self.iters_per_frame * ifs_render.INVOCATIONS |
| 254 | |
| 255 | |
| 256 | def start(document_path, iters_per_frame=400): |
| 257 | global _session |
| 258 | stop() |
| 259 | session = Session(document_path, iters_per_frame) |
| 260 | session.handle = bpy.types.SpaceView3D.draw_handler_add( |
| 261 | _draw_callback, (session,), 'WINDOW', 'POST_PIXEL') |
| 262 | _session = session |
| 263 | return session |
| 264 | |
| 265 | |
| 266 | def _draw_callback(session): |
| 267 | try: |
| 268 | session.draw(bpy.context) |
| 269 | except Exception: |
| 270 | # A raising draw handler is re-invoked every redraw and floods the console, |
| 271 | # so it removes itself and reports once. |
| 272 | import traceback |
| 273 | traceback.print_exc() |
| 274 | stop() |
| 275 | |
| 276 | |
| 277 | def stop(): |
| 278 | global _session |
| 279 | if _session is not None and _session.handle is not None: |
| 280 | bpy.types.SpaceView3D.draw_handler_remove(_session.handle, 'WINDOW') |
| 281 | _session.handle = None |
| 282 | _session = None |
| 283 | |
| 284 | |
| 285 | def is_running(): |
| 286 | return _session is not None |
| 287 | |
| 288 | |
| 289 | def status(): |
| 290 | if _session is None: |
| 291 | return "stopped" |
| 292 | return (f"{_session.frames} frames, {_session.iterations / 1e9:.2f}G iterations, " |
| 293 | f"{_session.width}x{_session.height}") |