OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / bc8ff0d

Realtime controls: panel edits drive the live viewport
Iterator and transform parameters are now editable while the viewport renders. Change a weight, a colour, a Sphere radius or an Affine translate and the fractal re-forms under the cursor. The design point that decides whether this feels instant or sluggish is WHAT each edit invalidates: - **brightness is tonemap-only.** It changes how the accumulator is displayed, not the attractor, so the samples stay valid and the image does NOT restart. Dragging it is free. - **opacity / colour / transform params** change the attractor, so accumulated samples no longer belong to it and are discarded. - **base weight** additionally re-normalises every xaos row, so the alias texture is rebuilt too - not just the iterator block. Getting that wrong in either direction is what makes a live renderer feel bad: restart on brightness and every drag re-renders; skip the restart on a parameter and you accumulate two different fractals on top of each other. Property callbacks only set a flag. A slider drag fires them on every pixel of mouse travel, so the repack happens once per redraw in the draw handler rather than several UBO rebuilds per mouse-move. Transform parameters are drawn under their own names, and only the ones the plugin declares - Sphere shows Radius and Fill, Affine shows Translate/Scale/ Rotate as vec3 rows. Fixed-size vectors (8 real, 3 vec3) keep this a plain PropertyGroup; those bounds come from the shipped library, where the widest are SSA at 7 reals and Affine at 3 vec3s. Verified headlessly: names and values populate from the document, edits with no live session do not raise, opacity flags params while base weight also flags weights, brightness syncs without flagging either, the repack produces new UBOs, and both a real param (Sphere Radius) and a vec3 param (Affine Translate) reach the document. The feel itself needs a real viewport and is yours to judge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bc8ff0d5114e94fd683b6c8ad49cce718b27bbc4
by plankatron <fleeky@gmail.com> · 2026-08-16T22:57:49.000Z

Changed files (2) +157 -15

blender/__init__.py +86 -15
……@@ -33,6 +33,7 @@ import traceback
3333import bpy
3434from bpy.props import (
3535 BoolProperty,
36 FloatVectorProperty,
3637 CollectionProperty,
3738 EnumProperty,
3839 FloatProperty,
……@@ -88,16 +89,50 @@ def library():
8889
8990# ------------------------------------------------------------------- properties
9091
92def _touch(kind):
93 """
94 Property update callback: flags the live session and asks for a redraw.
95
96 Edits only set a flag; the repack happens once in the draw handler. A slider
97 drag fires this on every pixel, so doing the work here would rebuild several
98 UBOs per mouse-move.
99 """
100 def update(self, context):
101 if ifs_viewport.is_running():
102 ifs_viewport.session().mark_dirty(kind)
103 _redraw(context)
104 return update
105
106
107# Sized from the shipped library: the widest plugin declares 7 real params (SSA)
108# and 3 vec3 params (Affine, Mobius). Fixed-size vectors keep this a plain
109# PropertyGroup; only the count each plugin declares is drawn.
110MAX_REAL_UI = 8
111MAX_VEC3_UI = 3
112
113
91114class IFSIteratorItem(PropertyGroup):
92 """One iterator, mirrored from the loaded document for display and tweaking."""
115 """One iterator, mirrored from the loaded document and editable live."""
93116
94117 # A `name` property is required or every row in the UIList shows the same
95118 # label — getname_anim_fcurve falls back to the type name without it.
96119 name: StringProperty(name="Name", default="Iterator")
97120 transform: StringProperty(name="Transform", default="")
98 opacity: FloatProperty(name="Opacity", default=1.0, min=0.0, max=1.0)
99 color_index: FloatProperty(name="Color", default=0.0, min=0.0, max=1.0)
100 base_weight: FloatProperty(name="Weight", default=1.0, min=0.0)
121 opacity: FloatProperty(name="Opacity", default=1.0, min=0.0, max=1.0,
122 update=_touch('params'))
123 color_index: FloatProperty(name="Color", default=0.0, min=0.0, max=1.0,
124 update=_touch('params'))
125 color_speed: FloatProperty(name="Color speed", default=0.5, min=0.0, max=1.0,
126 update=_touch('params'))
127 # Changing a base weight re-normalises every xaos row, so the alias texture
128 # has to be rebuilt, not just the iterator block.
129 base_weight: FloatProperty(name="Weight", default=1.0, min=0.0, soft_max=10.0,
130 update=_touch('weights'))
131
132 real_values: FloatVectorProperty(size=MAX_REAL_UI, update=_touch('params'))
133 vec3_values: FloatVectorProperty(size=MAX_VEC3_UI * 3, update=_touch('params'))
134 real_names: StringProperty(default="") # comma-joined, for labelling
135 vec3_names: StringProperty(default="")
101136
102137
103138class IFSSceneSettings(PropertyGroup):
……@@ -109,7 +144,10 @@ class IFSSceneSettings(PropertyGroup):
109144 dispatches: IntProperty(
110145 name="Dispatches", default=132, min=1, max=100000,
111146 description="Batches of 16384 x 500 iterations. More is cleaner and slower")
112 brightness: FloatProperty(name="Brightness", default=8.0, min=0.01, max=1000.0)
147 # Tonemap only - the accumulated samples stay valid, so dragging this does not
148 # restart the render.
149 brightness: FloatProperty(name="Brightness", default=8.0, min=0.01,
150 soft_max=64.0, max=1000.0, update=_touch('tonemap'))
113151 use_document_resolution: BoolProperty(
114152 name="Use document resolution", default=False,
115153 description="Render at the size stored in the document instead of the above")
……@@ -126,14 +164,27 @@ class IFSSceneSettings(PropertyGroup):
126164
127165def _sync_iterators(settings, doc):
128166 settings.iterators.clear()
167 plugins = {p.name: p for p in library().plugins}
129168 for index, it in enumerate(doc.iterators):
169 name = _transform_name(it)
130170 item = settings.iterators.add()
131 item.name = f"{index}: {_transform_name(it)}"
132 item.transform = _transform_name(it)
171 item.name = f"{index}: {name}"
172 item.transform = name
133173 item.opacity = it.opacity
134174 item.color_index = it.color_index
175 item.color_speed = it.color_speed
135176 item.base_weight = it.base_weight
136177
178 plugin = plugins.get(name)
179 if plugin is not None:
180 item.real_names = ",".join(plugin.real_params)
181 item.vec3_names = ",".join(plugin.vec3_params)
182 for i, value in enumerate(it.real_params[:MAX_REAL_UI]):
183 item.real_values[i] = value
184 for i, vec in enumerate(it.vec3_params[:MAX_VEC3_UI]):
185 for c in range(3):
186 item.vec3_values[i * 3 + c] = vec[c]
187
137188
138189def _transform_name(iterator):
139190 for name, tf_id in library().tf_ids.items():
……@@ -350,14 +401,34 @@ class IFS_PT_iterators(Panel):
350401 "IFS_UL_iterators", "", settings, "iterators",
351402 settings, "active_iterator", rows=4)
352403
353 if 0 <= settings.active_iterator < len(settings.iterators):
354 item = settings.iterators[settings.active_iterator]
355 column = self.layout.column(align=True)
356 column.label(text=f"Transform: {item.transform}")
357 column.prop(item, "color_index")
358 column.prop(item, "base_weight")
359 column.label(text="Edits are not fed back to the render yet",
360 icon='ERROR')
404 if not (0 <= settings.active_iterator < len(settings.iterators)):
405 return
406 item = settings.iterators[settings.active_iterator]
407
408 column = self.layout.column(align=True)
409 column.label(text=f"Transform: {item.transform}")
410 column.prop(item, "opacity", slider=True)
411 column.prop(item, "color_index", slider=True)
412 column.prop(item, "color_speed", slider=True)
413 column.prop(item, "base_weight")
414
415 # Only the parameters this plugin actually declares, under their own names.
416 real_names = [n for n in item.real_names.split(",") if n]
417 vec3_names = [n for n in item.vec3_names.split(",") if n]
418 if real_names or vec3_names:
419 box = self.layout.box()
420 box.label(text=f"{item.transform} parameters")
421 for i, pname in enumerate(real_names[:MAX_REAL_UI]):
422 box.prop(item, "real_values", index=i, text=pname)
423 for i, pname in enumerate(vec3_names[:MAX_VEC3_UI]):
424 row = box.row(align=True)
425 row.label(text=pname)
426 for c in range(3):
427 row.prop(item, "vec3_values", index=i * 3 + c, text="")
428
429 if not ifs_viewport.is_running():
430 self.layout.label(text="Start the live viewport to see edits",
431 icon='INFO')
361432
362433
363434# --------------------------------------------------------------------- register
blender/ifs_viewport.py +71 -0
……@@ -94,9 +94,70 @@ class Session:
9494
9595 self.doc = ifs_document.load(document_path, self.library)
9696 self.brightness = self.doc.brightness * 8.0
97
98 # What needs rebuilding before the next dispatch. Property callbacks fire
99 # continuously while a slider is dragged, so they only set flags here and
100 # the work happens once per redraw.
101 self._dirty_params = False
102 self._dirty_weights = False
103
97104 self._build_buffers()
98105 self._resize(64, 64)
99106
107 def mark_dirty(self, kind: str):
108 """
109 Flags what a UI edit invalidated.
110
111 `tonemap` costs nothing - brightness only changes how the accumulator is
112 displayed, so the samples stay valid and the image does NOT restart. That
113 distinction is the difference between a brightness slider that feels
114 instant and one that re-renders the fractal on every pixel of drag.
115
116 `params` and `weights` change the attractor itself, so accumulated samples
117 no longer belong to it and must be discarded. `weights` additionally
118 rebuilds the xaos alias texture.
119 """
120 if kind == 'weights':
121 self._dirty_weights = True
122 self._dirty_params = True
123 elif kind == 'params':
124 self._dirty_params = True
125 elif kind != 'tonemap':
126 raise ValueError(f"unknown dirty kind {kind!r}")
127
128 def sync_from_scene(self, settings):
129 """Copies the panel's values onto the document, flagging what changed."""
130 self.brightness = settings.brightness
131 for item, iterator in zip(settings.iterators, self.doc.iterators):
132 iterator.opacity = item.opacity
133 iterator.color_index = item.color_index
134 iterator.color_speed = item.color_speed
135 iterator.base_weight = item.base_weight
136 count = len(iterator.real_params)
137 iterator.real_params = list(item.real_values[:count])
138 iterator.vec3_params = [tuple(item.vec3_values[i * 3:i * 3 + 3])
139 for i in range(len(iterator.vec3_params))]
140
141 def _rebuild_dirty(self):
142 if not self._dirty_params:
143 return
144 doc = self.doc
145 self.ubos['iterators_buf'] = gpu.types.GPUUniformBuf(
146 ifs_host.pack_iterators(doc.iterators))
147 self.ubos['real_params_buf'] = gpu.types.GPUUniformBuf(
148 ifs_host.pack_real_params(ifs_host.flatten_real_params(doc.iterators)))
149 self.ubos['vec3_params_buf'] = gpu.types.GPUUniformBuf(
150 ifs_host.pack_vec3_params(ifs_host.flatten_vec3_params(doc.iterators)))
151 if self._dirty_weights:
152 alias = ifs_host.build_alias_tables(doc.iterators)
153 n = len(doc.iterators)
154 self.alias_tex = gpu.types.GPUTexture(
155 (n, n), format='RGBA32F',
156 data=gpu.types.Buffer('FLOAT', len(alias), alias))
157 self._dirty_weights = False
158 self._dirty_params = False
159 self.invalidate()
160
100161 # ------------------------------------------------------------------ shaders
101162
102163 def _build_maxpass(self):
……@@ -186,6 +247,11 @@ class Session:
186247 # ---------------------------------------------------------------------- draw
187248
188249 def draw(self, context):
250 settings = getattr(context.scene, "ifs", None)
251 if settings is not None and settings.iterators:
252 self.sync_from_scene(settings)
253 self._rebuild_dirty()
254
189255 region = context.region
190256 self._resize(max(region.width // 2, 16), max(region.height // 2, 16))
191257
……@@ -286,6 +352,11 @@ def is_running():
286352 return _session is not None
287353
288354
355def session():
356 """The live session. Callers must check `is_running()` first."""
357 return _session
358
359
289360def status():
290361 if _session is None:
291362 return "stopped"