OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge commits / 4a472f8

Per-object suppression for modifier-dependent geometry updates
Blanket frame suppression blocked ALL depsgraph updates after applying Godot transforms, including legitimate geometry updates from modifier dependencies (e.g. boolean operand moves → target mesh recalculates). Now suppression is per-object: only echoes for the specific objects Godot sent are blocked. Modifier-dependent objects (not in the suppressed set) get their geometry updates captured and flushed back to Godot. - ChangeDetector: _suppressed_objects dict replaces _skip_frames counter - SceneImporter.poll_updates() returns set of updated object names - Timer passes updated names to suppress() instead of blanket suppress Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
4a472f8dcaae44799a5efe7c3f64bb6e1cc4b64e
by plankatron <fleeky@gmail.com> · 2026-03-03T17:29:52.000Z

Changed files (4) +48 -25

blender_addon/__init__.py +7 -4
……@@ -84,15 +84,18 @@ def _poll_timer_impl(generation: int):
8484 _importer.full_import()
8585 if _detector is not None:
8686 _detector.set_id_map(_importer._id_to_name)
87 _detector.suppress(5)
87 # Suppress echoes for all imported objects
88 _detector.suppress(set(_importer._id_to_name.values()), 5)
8889 got_incoming = True
8990 print(f"BlenderBridge: Synced scene from Godot")
9091 elif text in ("updated", "transform_update"):
9192 # Incremental update from Godot (region 0)
9293 if _importer is not None:
93 count = _importer.poll_updates()
94 if count > 0 and _detector is not None:
95 _detector.suppress(5)
94 updated_names = _importer.poll_updates()
95 if updated_names and _detector is not None:
96 # Only suppress echoes for the objects Godot just sent.
97 # Other objects (e.g. modifier dependents) still update.
98 _detector.suppress(updated_names, 5)
9699 got_incoming = True
97100 elif text.startswith("texture_registered:"):
98101 # Godot registered a texture: texture_registered:name,id
blender_addon/change_detector.py +28 -15
……@@ -79,9 +79,11 @@ class ChangeDetector:
7979 self._pending: dict[str, int] = {}
8080 # Cache of depsgraph for mesh extraction during flush
8181 self._last_depsgraph = None
82 # Feedback loop prevention: skip N depsgraph callbacks after applying
83 # incoming transforms from Godot (poll_updates triggers depsgraph_update_post)
84 self._skip_frames = 0
82 # Feedback loop prevention: per-object suppression after applying
83 # incoming Godot transforms. Only suppresses echoes for the specific
84 # objects that Godot sent, allowing modifier-dependent updates through.
85 # Dict: object_name → remaining ticks
86 self._suppressed_objects: dict[str, int] = {}
8587
8688 def register(self):
8789 """Register the depsgraph handler."""
……@@ -100,18 +102,28 @@ class ChangeDetector:
100102 pass
101103 self._registered = False
102104
103 def suppress(self, frames: int = 5):
104 """Suppress depsgraph change detection for N timer ticks.
105 def suppress(self, names: set[str], frames: int = 5):
106 """Suppress depsgraph echoes for specific objects.
105107
106 Called after applying incoming Godot transforms to prevent the
107 depsgraph echo from being flushed back as a spurious update.
108 Only the named objects are suppressed. Other objects (e.g. those
109 with modifiers that depend on the moved object) still get their
110 updates captured and flushed to Godot.
108111 """
109 self._skip_frames = max(self._skip_frames, frames)
112 for name in names:
113 self._suppressed_objects[name] = max(
114 self._suppressed_objects.get(name, 0), frames
115 )
110116
111117 def unsuppress(self):
112 """Clear suppression (called each timer tick to decrement)."""
113 if self._skip_frames > 0:
114 self._skip_frames -= 1
118 """Decrement suppression counters (called each timer tick)."""
119 expired = []
120 for name, ticks in self._suppressed_objects.items():
121 if ticks <= 1:
122 expired.append(name)
123 else:
124 self._suppressed_objects[name] = ticks - 1
125 for name in expired:
126 del self._suppressed_objects[name]
115127
116128 def set_id_map(self, id_map: dict[int, str]):
117129 """Set the object ID mapping from the scene importer (reversed)."""
……@@ -136,16 +148,17 @@ class ChangeDetector:
136148 """Called after every depsgraph evaluation. Accumulates changes."""
137149 self._last_depsgraph = depsgraph
138150
139 # Skip depsgraph changes during suppression (feedback loop prevention)
140 if self._skip_frames > 0:
141 return
142
143151 for update in depsgraph.updates:
144152 obj = update.id
145153 if not isinstance(obj, bpy.types.Object):
146154 continue
147155
148156 name = obj.name
157
158 # Skip echoes for objects that Godot just told us about
159 if name in self._suppressed_objects:
160 continue
161
149162 existing = self._pending.get(name, -1)
150163
151164 if update.is_updated_geometry:
blender_addon/scene_import.py +13 -6
……@@ -92,23 +92,30 @@ class SceneImporter:
9292 if stale_ids:
9393 print(f"BlenderBridge: Removed {len(stale_ids)} stale object(s)")
9494
95 def poll_updates(self) -> int:
96 """Check for incremental updates from region 0. Returns number of objects updated."""
95 def poll_updates(self) -> set[str]:
96 """Check for incremental updates from region 0.
97
98 Returns set of Blender object names that were updated (for
99 per-object suppression of depsgraph echoes).
100 """
97101 header = self._shm.read_header(region=0)
98102
99103 # No direction check needed — region 0 is always Godot→Blender
100104 if header["sequence"] <= self._last_sequence:
101 return 0
105 return set()
102106
103107 self._last_sequence = header["sequence"]
104 updated = 0
108 updated_names: set[str] = set()
105109
106110 for i in range(header["object_count"]):
107111 entry = self._shm.read_object_entry(i, region=0)
112 obj_id = entry["object_id"]
113 name = self._id_to_name.get(obj_id)
114 if name:
115 updated_names.add(name)
108116 self._apply_update(entry)
109 updated += 1
110117
111 return updated
118 return updated_names
112119
113120 # ── Name prefix helpers ──────────────────────────────────────────────
114121
blender_bridge_addon.zip +0 -0
(no content changes — binary or rename)