OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 905b54f

Render live in the 3D viewport
"why is it not in the live viewport?" - because I had only built a batch renderer with a UI on it. This is the missing half. A draw handler dispatches a batch of chaos-game iterations per redraw, drains into a float accumulator, tonemaps on the GPU and blits over the viewport. Nothing crosses the bus in the steady state, which is the whole point: a Python tonemap would need a per-frame readback and turn this into a slideshow. Auto-exposure finds the peak density with imageAtomicMax into a 1x1 uint image rather than reading the accumulator back. Two things make it navigable rather than merely animated: - **The kernel follows the VIEWPORT camera.** Orbiting rebuilds the view-projection from region_3d, so you fly the fractal like a scene. Blender's own perspective_matrix cannot be used directly - the kernel discards `w >= 0`, the opposite of Blender's convention - so it is rebuilt from eye/forward/up through the engine's own construction rather than fighting the sign. - **Moving invalidates the histogram.** Accumulated samples belong to the old viewpoint. You get a noisy preview while moving and it resolves when you stop, which is what the engine does through reset_points_state and is inherent to progressive accumulation. Two failure modes handled because they are nasty in a draw handler: a raising handler is re-invoked every redraw and floods the console, so it removes itself and reports once; and a handler that outlives the add-on crashes on the next redraw, so unregister() stops it. Four shaders now - kernel, drain, max-pass, tonemap - all verified to compile in Blender 5.2 by building a Session headlessly. The interactive behaviour itself cannot be checked without a real viewport, so that part is unverified by me. 67 tests unchanged; this adds no CPU-side logic to test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
905b54fb39324c36b8babd90ee6657389d56972c
by plankatron <fleeky@gmail.com> · 2026-08-16T22:51:02.000Z

Changed files (3) +349 -0

blender/__init__.py +55 -0
……@@ -48,6 +48,7 @@ if _HERE not in sys.path:
4848import ifs_codegen
4949import ifs_document
5050import ifs_render
51import ifs_viewport
5152
5253def _assets_root() -> str:
5354 """
……@@ -112,6 +113,10 @@ class IFSSceneSettings(PropertyGroup):
112113 use_document_resolution: BoolProperty(
113114 name="Use document resolution", default=False,
114115 description="Render at the size stored in the document instead of the above")
116 iters_per_frame: IntProperty(
117 name="Iterations/frame", default=400, min=10, max=20000,
118 description="Work per viewport redraw. Higher refines faster and drops the "
119 "framerate; the engine's own default is 500")
115120 iterators: CollectionProperty(type=IFSIteratorItem)
116121 active_iterator: IntProperty(default=0)
117122 status: StringProperty(default="")
……@@ -189,6 +194,42 @@ class IFS_OT_load_template(Operator):
189194 return {'RUNNING_MODAL'}
190195
191196
197class IFS_OT_viewport(Operator):
198 """Start or stop live progressive rendering in the 3D viewport"""
199
200 bl_idname = "ifs.toggle_viewport"
201 bl_label = "Live Viewport"
202
203 def execute(self, context):
204 settings = context.scene.ifs
205 if ifs_viewport.is_running():
206 ifs_viewport.stop()
207 settings.status = "Live viewport stopped"
208 _redraw(context)
209 return {'FINISHED'}
210
211 path = bpy.path.abspath(settings.document)
212 if not path or not os.path.isfile(path):
213 self.report({'ERROR'}, "Pick an .ifsjson file first")
214 return {'CANCELLED'}
215 try:
216 ifs_viewport.start(path, iters_per_frame=settings.iters_per_frame)
217 except Exception as ex:
218 traceback.print_exc()
219 self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
220 return {'CANCELLED'}
221
222 settings.status = "Live viewport running - orbit to look around"
223 _redraw(context)
224 return {'FINISHED'}
225
226
227def _redraw(context):
228 for area in context.screen.areas:
229 if area.type == 'VIEW_3D':
230 area.tag_redraw()
231
232
192233class IFS_OT_render(Operator):
193234 """Render the loaded fractal and show it in the Image Editor"""
194235
……@@ -280,6 +321,17 @@ class IFS_PT_main(Panel):
280321
281322 layout.operator("ifs.render", icon='RENDER_STILL')
282323
324 box = layout.box()
325 box.label(text="Live Viewport")
326 box.prop(settings, "iters_per_frame")
327 running = ifs_viewport.is_running()
328 box.operator("ifs.toggle_viewport",
329 text="Stop" if running else "Start",
330 icon='PAUSE' if running else 'PLAY',
331 depress=running)
332 if running:
333 box.label(text=ifs_viewport.status(), icon='INFO')
334
283335
284336class IFS_PT_iterators(Panel):
285337 bl_label = "Iterators"
……@@ -316,6 +368,7 @@ _CLASSES = (
316368 IFS_OT_load,
317369 IFS_OT_load_template,
318370 IFS_OT_render,
371 IFS_OT_viewport,
319372 IFS_UL_iterators,
320373 IFS_PT_main,
321374 IFS_PT_iterators,
……@@ -329,6 +382,8 @@ def register():
329382
330383
331384def unregister():
385 # A live draw handler outlives the add-on and crashes on the next redraw.
386 ifs_viewport.stop()
332387 del bpy.types.Scene.ifs
333388 for cls in reversed(_CLASSES):
334389 bpy.utils.unregister_class(cls)
blender/build_addon.py +1 -0
……@@ -38,6 +38,7 @@ RUNTIME_FILES = (
3838 "ifs_host.py",
3939 "ifs_document.py",
4040 "ifs_render.py",
41 "ifs_viewport.py",
4142)
4243
4344
blender/ifs_viewport.py +293 -0
……@@ -0,0 +1,293 @@
1"""
2Live progressive rendering into the 3D viewport.
3
4A draw handler dispatches a batch of chaos-game iterations per redraw, drains into
5a float accumulator, tonemaps on the GPU and blits the result over the viewport.
6Nothing is read back to the CPU in the steady state — the per-frame readback that
7a Python tonemap would need is what would make this a slideshow.
8
9Two 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
20The auto-exposure max is computed on the GPU with `imageAtomicMax` into a 1x1
21image, so even the peak density never crosses the bus.
22"""
23
24from __future__ import annotations
25
26import math
27
28import bpy
29import gpu
30from gpu_extras.batch import batch_for_shader
31from mathutils import Vector
32
33import ifs_document
34import ifs_host
35import 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 = """
42void 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 = """
52void 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 = """
62void 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
74class 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
256def 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
266def _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
277def 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
285def is_running():
286 return _session is not None
287
288
289def 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}")