| … | … | @@ -11,7 +11,7 @@ Usage: |
| 11 | 11 | |
| 12 | 12 | import bpy |
| 13 | 13 | |
| 14 | ADDON_VERSION = "0.5.0" | |
| 14 | ADDON_VERSION = "0.6.0" | |
| 15 | 15 | |
| 16 | 16 | # Support hot-reload (F3 > Reload Scripts) |
| 17 | 17 | if "shm_protocol" in locals(): |
| … | … | @@ -38,6 +38,7 @@ _importer: SceneImporter | None = None |
| 38 | 38 | _detector: ChangeDetector | None = None |
| 39 | 39 | _baker: TileBaker | None = None |
| 40 | 40 | _timer_running = False |
| 41 | _timer_generation = 0 # Incremented on each connect to invalidate stale timers | |
| 41 | 42 | |
| 42 | 43 | SHM_NAME = "/blender_bridge" |
| 43 | 44 | SHM_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 |
| 49 | 50 | # ── Timer callback ─────────────────────────────────────────────────────────── |
| 50 | 51 | |
| 51 | 52 | |
| 52 | def _poll_timer(): | |
| 53 | def _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 | ||
| 60 | def _poll_timer_impl(generation: int): | |
| 53 | 61 | """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 | |
| 55 | 67 | |
| 56 | 68 | if _conn is None or not _conn.connected: |
| 69 | _timer_running = False | |
| 57 | 70 | return None # Stop timer |
| 58 | 71 | |
| 72 | # Tick suppression counter (feedback loop prevention) | |
| 73 | if _detector is not None: | |
| 74 | _detector.unsuppress() | |
| 75 | ||
| 59 | 76 | # Poll UDS messages |
| 77 | got_incoming = False | |
| 60 | 78 | messages = _conn.poll_messages() |
| 61 | 79 | for msg in messages: |
| 62 | 80 | text = msg.decode("utf-8", errors="replace") |
| … | … | @@ -66,11 +84,16 @@ def _poll_timer(): |
| 66 | 84 | _importer.full_import() |
| 67 | 85 | if _detector is not None: |
| 68 | 86 | _detector.set_id_map(_importer._id_to_name) |
| 87 | _detector.suppress(5) | |
| 88 | got_incoming = True | |
| 69 | 89 | print(f"BlenderBridge: Synced scene from Godot") |
| 70 | 90 | elif text in ("updated", "transform_update"): |
| 71 | 91 | # Incremental update from Godot (region 0) |
| 72 | 92 | if _importer is not None: |
| 73 | 93 | count = _importer.poll_updates() |
| 94 | if count > 0 and _detector is not None: | |
| 95 | _detector.suppress(5) | |
| 96 | got_incoming = True | |
| 74 | 97 | elif text.startswith("texture_registered:"): |
| 75 | 98 | # Godot registered a texture: texture_registered:name,id |
| 76 | 99 | parts = text.split(":", 1)[1].split(",") |
| … | … | @@ -83,6 +106,10 @@ def _poll_timer(): |
| 83 | 106 | flushed = _detector.flush() |
| 84 | 107 | if flushed > 0 and _conn is not None: |
| 85 | 108 | _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()) | |
| 86 | 113 | |
| 87 | 114 | # Flush any pending tile_ready messages from the baker |
| 88 | 115 | if _baker is not None: |
| … | … | @@ -191,7 +218,20 @@ class BRIDGE_OT_connect(bpy.types.Operator): |
| 191 | 218 | bl_label = "Connect to Godot" |
| 192 | 219 | |
| 193 | 220 | 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 | |
| 195 | 235 | |
| 196 | 236 | # Open shared memory |
| 197 | 237 | _shm = ShmRegion() |
| … | … | @@ -219,10 +259,13 @@ class BRIDGE_OT_connect(bpy.types.Operator): |
| 219 | 259 | _baker = TileBaker(_shm, tile_size=128, max_mips=8) |
| 220 | 260 | _baker.init_ring_buffer(tile_ring_offset, tile_ring_size) |
| 221 | 261 | |
| 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 | |
| 226 | 269 | |
| 227 | 270 | self.report({"INFO"}, "Connected to Godot BlenderBridge") |
| 228 | 271 | return {"FINISHED"} |
| … | … | @@ -2,7 +2,7 @@ schema_version = "1.0.0" |
| 2 | 2 | id = "blender_godot_bridge" |
| 3 | 3 | name = "Blender-Godot Bridge" |
| 4 | 4 | tagline = "Real-time bidirectional scene sync with Godot" |
| 5 | version = "0.5.0" | |
| 5 | version = "0.6.0" | |
| 6 | 6 | blender_version_min = "4.2.0" |
| 7 | 7 | type = "add-on" |
| 8 | 8 | maintainer = "blender-bridge <noreply@localhost>" |
| … | … | @@ -79,6 +79,9 @@ class ChangeDetector: |
| 79 | 79 | self._pending: dict[str, int] = {} |
| 80 | 80 | # Cache of depsgraph for mesh extraction during flush |
| 81 | 81 | 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 | 85 | |
| 83 | 86 | def register(self): |
| 84 | 87 | """Register the depsgraph handler.""" |
| … | … | @@ -97,6 +100,19 @@ class ChangeDetector: |
| 97 | 100 | pass |
| 98 | 101 | self._registered = False |
| 99 | 102 | |
| 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 | ||
| 100 | 116 | def set_id_map(self, id_map: dict[int, str]): |
| 101 | 117 | """Set the object ID mapping from the scene importer (reversed).""" |
| 102 | 118 | self._name_to_id = {name: obj_id for obj_id, name in id_map.items()} |
| … | … | @@ -110,12 +126,20 @@ class ChangeDetector: |
| 110 | 126 | self._next_id += 1 |
| 111 | 127 | return self._name_to_id[name] |
| 112 | 128 | |
| 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 | ||
| 113 | 133 | # -- Depsgraph handler (accumulate only, no SHM writes) ---------------- |
| 114 | 134 | |
| 115 | 135 | def _on_depsgraph_update(self, scene, depsgraph): |
| 116 | 136 | """Called after every depsgraph evaluation. Accumulates changes.""" |
| 117 | 137 | self._last_depsgraph = depsgraph |
| 118 | 138 | |
| 139 | # Skip depsgraph changes during suppression (feedback loop prevention) | |
| 140 | if self._skip_frames > 0: | |
| 141 | return | |
| 142 | ||
| 119 | 143 | for update in depsgraph.updates: |
| 120 | 144 | obj = update.id |
| 121 | 145 | if not isinstance(obj, bpy.types.Object): |
| … | … | @@ -161,7 +161,7 @@ Error BlenderBridge::start_server(const String &socket_path) { |
| 161 | 161 | _known_instance_ids.clear(); |
| 162 | 162 | _next_object_id = 1; |
| 163 | 163 | |
| 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, ")"); | |
| 165 | 165 | return OK; |
| 166 | 166 | } |
| 167 | 167 | |
| … | … | @@ -803,7 +803,7 @@ void BlenderBridge::import_changes(Node *root) { |
| 803 | 803 | Vector3 p0 = orig_pos[i0]; |
| 804 | 804 | Vector3 p1 = orig_pos[i1]; |
| 805 | 805 | 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(); | |
| 807 | 807 | |
| 808 | 808 | int32_t base_idx = new_positions.size(); |
| 809 | 809 | new_positions.push_back(p0); |
| … | … | @@ -897,11 +897,14 @@ void BlenderBridge::_process(double delta) { |
| 897 | 897 | Node *root = get_tree() ? get_tree()->get_root() : nullptr; |
| 898 | 898 | if (root) { |
| 899 | 899 | import_changes(root); |
| 900 | _import_cooldown = 10; // Skip change detection for 10 frames after import | |
| 900 | 901 | } |
| 901 | 902 | } |
| 902 | 903 | |
| 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()) { | |
| 905 | 908 | _detect_and_push_changes(); |
| 906 | 909 | } |
| 907 | 910 | } |
| … | … | @@ -80,6 +80,7 @@ private: |
| 80 | 80 | bool _auto_sync = false; |
| 81 | 81 | bool _server_running = false; |
| 82 | 82 | bool _import_pending = false; // Set by UDS "updated" from Blender |
| 83 | int _import_cooldown = 0; // Frames to skip _detect_and_push_changes after import | |
| 83 | 84 | NodePath _virtual_texture_path; |
| 84 | 85 | |
| 85 | 86 | // ── UDS message handling ──────────────────────────────────────────── |