OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge commits / 3b678c4

Fix feedback loop, face normals, ID sync, and reconnection (v0.6.0)
- Add two-layer feedback loop prevention: Blender suppresses depsgraph for 5 ticks after applying Godot transforms, Godot skips change detection for 10 frames after importing Blender changes - Fix face normal inversion in flat-shading vertex duplication (swap cross product operands for correct outward normals) - Sync ChangeDetector ID map to SceneImporter after flush so Godot can send transforms back for Blender-created objects - Fix reconnection: clean up stale state, use timer generation counter to prevent duplicate timers on rapid disconnect+reconnect Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3b678c4a7891ced71f4b576df33e2a58c13ca3f4
by plankatron <fleeky@gmail.com> · 2026-03-03T16:46:23.000Z

Changed files (6) +84 -13

blender_addon/__init__.py +51 -8
……@@ -11,7 +11,7 @@ Usage:
1111
1212import bpy
1313
14ADDON_VERSION = "0.5.0"
14ADDON_VERSION = "0.6.0"
1515
1616# Support hot-reload (F3 > Reload Scripts)
1717if "shm_protocol" in locals():
……@@ -38,6 +38,7 @@ _importer: SceneImporter | None = None
3838_detector: ChangeDetector | None = None
3939_baker: TileBaker | None = None
4040_timer_running = False
41_timer_generation = 0 # Incremented on each connect to invalidate stale timers
4142
4243SHM_NAME = "/blender_bridge"
4344SHM_SIZE = 24 * 1024 * 1024 # 24 MB (8 MB region 0 + 8 MB region 1 + 8 MB tile ring)
……@@ -49,14 +50,31 @@ POLL_INTERVAL = 0.05 # 50ms = 20 Hz polling
4950# ── Timer callback ───────────────────────────────────────────────────────────
5051
5152
52def _poll_timer():
53def _make_poll_timer(generation: int):
54 """Create a poll timer closure bound to a specific generation."""
55 def _poll_timer():
56 return _poll_timer_impl(generation)
57 return _poll_timer
58
59
60def _poll_timer_impl(generation: int):
5361 """Called by Blender's timer to poll for updates from Godot."""
54 global _conn, _importer, _baker
62 global _conn, _importer, _baker, _timer_running
63
64 # Stop if this timer belongs to a stale generation (disconnect+reconnect happened)
65 if generation != _timer_generation:
66 return None
5567
5668 if _conn is None or not _conn.connected:
69 _timer_running = False
5770 return None # Stop timer
5871
72 # Tick suppression counter (feedback loop prevention)
73 if _detector is not None:
74 _detector.unsuppress()
75
5976 # Poll UDS messages
77 got_incoming = False
6078 messages = _conn.poll_messages()
6179 for msg in messages:
6280 text = msg.decode("utf-8", errors="replace")
……@@ -66,11 +84,16 @@ def _poll_timer():
6684 _importer.full_import()
6785 if _detector is not None:
6886 _detector.set_id_map(_importer._id_to_name)
87 _detector.suppress(5)
88 got_incoming = True
6989 print(f"BlenderBridge: Synced scene from Godot")
7090 elif text in ("updated", "transform_update"):
7191 # Incremental update from Godot (region 0)
7292 if _importer is not None:
7393 count = _importer.poll_updates()
94 if count > 0 and _detector is not None:
95 _detector.suppress(5)
96 got_incoming = True
7497 elif text.startswith("texture_registered:"):
7598 # Godot registered a texture: texture_registered:name,id
7699 parts = text.split(":", 1)[1].split(",")
……@@ -83,6 +106,10 @@ def _poll_timer():
83106 flushed = _detector.flush()
84107 if flushed > 0 and _conn is not None:
85108 _conn.send_message(b"updated")
109 # Sync detector's ID map to importer so Godot transforms for
110 # Blender-created objects can be applied on incoming updates
111 if _importer is not None:
112 _importer._id_to_name.update(_detector.get_id_map())
86113
87114 # Flush any pending tile_ready messages from the baker
88115 if _baker is not None:
……@@ -191,7 +218,20 @@ class BRIDGE_OT_connect(bpy.types.Operator):
191218 bl_label = "Connect to Godot"
192219
193220 def execute(self, context):
194 global _shm, _conn, _importer, _detector, _baker, _timer_running
221 global _shm, _conn, _importer, _detector, _baker, _timer_running, _timer_generation
222
223 # Clean up any stale state from previous connection
224 if _detector is not None:
225 _detector.unregister()
226 _detector = None
227 _baker = None
228 _importer = None
229 if _conn is not None:
230 _conn.close()
231 _conn = None
232 if _shm is not None:
233 _shm.close()
234 _shm = None
195235
196236 # Open shared memory
197237 _shm = ShmRegion()
……@@ -219,10 +259,13 @@ class BRIDGE_OT_connect(bpy.types.Operator):
219259 _baker = TileBaker(_shm, tile_size=128, max_mips=8)
220260 _baker.init_ring_buffer(tile_ring_offset, tile_ring_size)
221261
222 # Start polling timer
223 if not _timer_running:
224 bpy.app.timers.register(_poll_timer, first_interval=POLL_INTERVAL)
225 _timer_running = True
262 # Start polling timer with new generation (old timers auto-stop
263 # when they detect their generation is stale)
264 _timer_generation += 1
265 bpy.app.timers.register(
266 _make_poll_timer(_timer_generation), first_interval=POLL_INTERVAL
267 )
268 _timer_running = True
226269
227270 self.report({"INFO"}, "Connected to Godot BlenderBridge")
228271 return {"FINISHED"}
blender_addon/blender_manifest.toml +1 -1
……@@ -2,7 +2,7 @@ schema_version = "1.0.0"
22id = "blender_godot_bridge"
33name = "Blender-Godot Bridge"
44tagline = "Real-time bidirectional scene sync with Godot"
5version = "0.5.0"
5version = "0.6.0"
66blender_version_min = "4.2.0"
77type = "add-on"
88maintainer = "blender-bridge <noreply@localhost>"
blender_addon/change_detector.py +24 -0
……@@ -79,6 +79,9 @@ 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
8285
8386 def register(self):
8487 """Register the depsgraph handler."""
……@@ -97,6 +100,19 @@ class ChangeDetector:
97100 pass
98101 self._registered = False
99102
103 def suppress(self, frames: int = 5):
104 """Suppress depsgraph change detection for N timer ticks.
105
106 Called after applying incoming Godot transforms to prevent the
107 depsgraph echo from being flushed back as a spurious update.
108 """
109 self._skip_frames = max(self._skip_frames, frames)
110
111 def unsuppress(self):
112 """Clear suppression (called each timer tick to decrement)."""
113 if self._skip_frames > 0:
114 self._skip_frames -= 1
115
100116 def set_id_map(self, id_map: dict[int, str]):
101117 """Set the object ID mapping from the scene importer (reversed)."""
102118 self._name_to_id = {name: obj_id for obj_id, name in id_map.items()}
……@@ -110,12 +126,20 @@ class ChangeDetector:
110126 self._next_id += 1
111127 return self._name_to_id[name]
112128
129 def get_id_map(self) -> dict[int, str]:
130 """Return the current ID map as {object_id: name} for syncing with SceneImporter."""
131 return {obj_id: name for name, obj_id in self._name_to_id.items()}
132
113133 # -- Depsgraph handler (accumulate only, no SHM writes) ----------------
114134
115135 def _on_depsgraph_update(self, scene, depsgraph):
116136 """Called after every depsgraph evaluation. Accumulates changes."""
117137 self._last_depsgraph = depsgraph
118138
139 # Skip depsgraph changes during suppression (feedback loop prevention)
140 if self._skip_frames > 0:
141 return
142
119143 for update in depsgraph.updates:
120144 obj = update.id
121145 if not isinstance(obj, bpy.types.Object):
blender_bridge_addon.zip +0 -0
(no content changes — binary or rename)
src/blender_bridge.cpp +7 -4
……@@ -161,7 +161,7 @@ Error BlenderBridge::start_server(const String &socket_path) {
161161 _known_instance_ids.clear();
162162 _next_object_id = 1;
163163
164 UtilityFunctions::print("BlenderBridge v0.5.0: Server started (shm=", _shm_name, ", sock=", socket_path, ", regions=2x", (int)_region_size, ")");
164 UtilityFunctions::print("BlenderBridge v0.6.0: Server started (shm=", _shm_name, ", sock=", socket_path, ", regions=2x", (int)_region_size, ")");
165165 return OK;
166166}
167167
……@@ -803,7 +803,7 @@ void BlenderBridge::import_changes(Node *root) {
803803 Vector3 p0 = orig_pos[i0];
804804 Vector3 p1 = orig_pos[i1];
805805 Vector3 p2 = orig_pos[i2];
806 Vector3 face_n = (p1 - p0).cross(p2 - p0).normalized();
806 Vector3 face_n = (p2 - p0).cross(p1 - p0).normalized();
807807
808808 int32_t base_idx = new_positions.size();
809809 new_positions.push_back(p0);
……@@ -897,11 +897,14 @@ void BlenderBridge::_process(double delta) {
897897 Node *root = get_tree() ? get_tree()->get_root() : nullptr;
898898 if (root) {
899899 import_changes(root);
900 _import_cooldown = 10; // Skip change detection for 10 frames after import
900901 }
901902 }
902903
903 // Auto-sync: push outgoing Godot changes
904 if (_auto_sync && _shm.valid()) {
904 // Auto-sync: push outgoing Godot changes (skip during import cooldown)
905 if (_import_cooldown > 0) {
906 _import_cooldown--;
907 } else if (_auto_sync && _shm.valid()) {
905908 _detect_and_push_changes();
906909 }
907910}
src/blender_bridge.h +1 -0
……@@ -80,6 +80,7 @@ private:
8080 bool _auto_sync = false;
8181 bool _server_running = false;
8282 bool _import_pending = false; // Set by UDS "updated" from Blender
83 int _import_cooldown = 0; // Frames to skip _detect_and_push_changes after import
8384 NodePath _virtual_texture_path;
8485
8586 // ── UDS message handling ────────────────────────────────────────────