OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge commits / b83ae1b

Fix mesh data sync and object name transmission from Blender
Two bugs fixed: 1. Mesh data overwrite race: depsgraph fires rapidly, overwriting UPDATE_FULL (with mesh) with UPDATE_TRANSFORM (without mesh) before Godot reads. Now accumulates changes in memory during depsgraph handler and flushes atomically on timer tick. Blender sends "updated" UDS notification; Godot imports on next frame. 2. Object names: names are now embedded in the data blob as a length-prefixed string before the transform. Godot reads actual Blender names instead of generating "blender_obj_N". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
b83ae1b8e4fad3c850f6490aaa29eaaaa752d4c2
by plankatron <fleeky@gmail.com> · 2026-03-03T13:42:52.000Z

Changed files (4) +135 -55

blender_addon/__init__.py +6 -0
……@@ -78,6 +78,12 @@ def _poll_timer():
7878 _baker.register_texture(parts[0], int(parts[1]))
7979 print(f"BlenderBridge: Registered texture '{parts[0]}' id={parts[1]}")
8080
81 # Flush accumulated Blender changes to SHM and notify Godot
82 if _detector is not None:
83 flushed = _detector.flush()
84 if flushed > 0 and _conn is not None:
85 _conn.send_message(b"updated")
86
8187 # Flush any pending tile_ready messages from the baker
8288 if _baker is not None:
8389 for tile_msg in _baker.get_pending_messages():
blender_addon/change_detector.py +88 -49
……@@ -1,7 +1,8 @@
11"""Detect Blender scene changes and export to shared memory.
22
33Uses depsgraph_update_post handler to detect transform and geometry changes,
4then writes them to SHM region 1 (Blender→Godot).
4accumulates them in memory, and flushes to SHM region 1 (Blender->Godot)
5on timer tick to prevent rapid depsgraph fires from overwriting data.
56"""
67
78import struct
……@@ -57,16 +58,27 @@ def _blender_light_sub_type(obj) -> int:
5758
5859
5960class ChangeDetector:
60 """Detects Blender scene changes and exports to SHM region 1."""
61 """Detects Blender scene changes and exports to SHM region 1.
62
63 Changes are accumulated in memory during depsgraph_update_post callbacks,
64 then flushed to SHM atomically when flush() is called from the timer.
65 This prevents rapid depsgraph fires from overwriting UPDATE_FULL data
66 with UPDATE_TRANSFORM before Godot reads it.
67 """
6168
6269 def __init__(self, shm: ShmRegion, max_objects: int = 256):
6370 self._shm = shm
6471 self._max_objects = max_objects
6572 self._sequence = 0
66 # Map Blender object name → object_id
73 # Map Blender object name -> object_id
6774 self._name_to_id: dict[str, int] = {}
6875 self._next_id = 1
6976 self._registered = False
77 # Pending changes accumulated between flushes.
78 # Key: object name, Value: (update_type, depsgraph ref for mesh extraction)
79 self._pending: dict[str, int] = {}
80 # Cache of depsgraph for mesh extraction during flush
81 self._last_depsgraph = None
7082
7183 def register(self):
7284 """Register the depsgraph handler."""
……@@ -98,76 +110,103 @@ class ChangeDetector:
98110 self._next_id += 1
99111 return self._name_to_id[name]
100112
101 # ── Depsgraph handler ────────────────────────────────────────────────
113 # -- Depsgraph handler (accumulate only, no SHM writes) ----------------
102114
103115 def _on_depsgraph_update(self, scene, depsgraph):
104 """Called after every depsgraph evaluation."""
105 dirty = []
116 """Called after every depsgraph evaluation. Accumulates changes."""
117 self._last_depsgraph = depsgraph
106118
107119 for update in depsgraph.updates:
108120 obj = update.id
109121 if not isinstance(obj, bpy.types.Object):
110122 continue
111123
112 if update.is_updated_transform:
113 dirty.append(("transform", obj))
124 name = obj.name
125 existing = self._pending.get(name, -1)
126
114127 if update.is_updated_geometry:
115 dirty.append(("geometry", obj))
128 # Geometry change: upgrade to at least VERTEX, or FULL if new
129 if existing == -1:
130 if name not in self._name_to_id:
131 self._pending[name] = UPDATE_FULL
132 else:
133 self._pending[name] = UPDATE_VERTEX
134 elif existing == UPDATE_TRANSFORM:
135 self._pending[name] = UPDATE_FULL
136 # else: already VERTEX or FULL, keep it
116137
117 if dirty:
118 self._export_changes(dirty, depsgraph)
138 if update.is_updated_transform:
139 if existing == -1:
140 if name not in self._name_to_id:
141 self._pending[name] = UPDATE_FULL
142 else:
143 self._pending[name] = UPDATE_TRANSFORM
144 # else: don't downgrade from VERTEX/FULL to TRANSFORM
119145
120 # ── Export changes to SHM ────────────────────────────────────────────
146 # -- Flush accumulated changes to SHM ----------------------------------
121147
122 def _export_changes(self, dirty: list[tuple[str, object]], depsgraph=None):
123 """Write changed objects to shared memory region 1."""
124 if not self._shm.valid:
125 return
148 def flush(self) -> int:
149 """Write all pending changes to SHM region 1 atomically.
150
151 Returns the number of objects written. Call this from the timer,
152 not from the depsgraph handler.
153 """
154 if not self._pending or not self._shm.valid:
155 return 0
156
157 pending = self._pending
158 self._pending = {}
126159
127160 self._sequence += 1
128161 # Region base for absolute mmap writes
129162 region1_base = self._shm.region_base(1)
130 # Blob offset is region-relative (matches C++ convention)
131 # C++ stores data_offset relative to region base, so we do the same.
132163 from .shm_protocol import HEADER_SIZE, ENTRY_SIZE
133164 blob_rel_start = HEADER_SIZE + self._max_objects * ENTRY_SIZE
134 blob_rel_offset = blob_rel_start # region-relative offset for ObjectEntry.data_offset
135 blob_abs_offset = region1_base + blob_rel_start # absolute offset for mmap writes
165 blob_rel_offset = blob_rel_start
166 blob_abs_offset = region1_base + blob_rel_start
136167
137 # Deduplicate: if both transform and geometry changed, use FULL
138 obj_updates: dict[str, tuple[int, object]] = {}
139 for change_type, obj in dirty:
140 name = obj.name
141 if name in obj_updates:
142 # Merge: if already has one type, upgrade to FULL
143 existing_type, _ = obj_updates[name]
144 if existing_type != UPDATE_FULL:
145 obj_updates[name] = (UPDATE_FULL, obj)
146 else:
147 if change_type == "transform":
148 obj_updates[name] = (UPDATE_TRANSFORM, obj)
149 elif change_type == "geometry":
150 obj_updates[name] = (UPDATE_VERTEX, obj)
151 else:
152 obj_updates[name] = (UPDATE_FULL, obj)
168 depsgraph = self._last_depsgraph
153169
154170 entry_index = 0
155 written_updates: dict[str, int] = {} # name → actual update_type written
156 for name, (update_type, obj) in obj_updates.items():
171 written_updates: dict[str, int] = {}
172
173 for name, update_type in pending.items():
157174 if entry_index >= self._max_objects:
158175 break
159176
177 # Look up the Blender object
178 obj = bpy.data.objects.get(name)
179 if obj is None:
180 continue
181
160182 is_new = name not in self._name_to_id
161183 obj_id = self.get_or_assign_id(name)
162184
163 # New objects need full data (geometry) so Godot can create proper meshes
185 # New mesh objects always get FULL
164186 if is_new and obj.type == "MESH":
165187 update_type = UPDATE_FULL
188
166189 obj_type = _blender_type_to_obj_type(obj)
167190 sub_type = _blender_light_sub_type(obj)
168 data_rel_start = blob_rel_offset # region-relative for ObjectEntry
169
170 # Write transform (always) — use absolute offset for mmap write
191 data_rel_start = blob_rel_offset
192
193 # Write object name (length-prefixed, padded to 4-byte alignment)
194 name_bytes = name.encode("utf-8")[:255]
195 name_len = len(name_bytes)
196 self._shm.write_blob(blob_abs_offset, struct.pack("<B", name_len))
197 blob_abs_offset += 1
198 blob_rel_offset += 1
199 self._shm.write_blob(blob_abs_offset, name_bytes)
200 blob_abs_offset += name_len
201 blob_rel_offset += name_len
202 # Pad to 4-byte alignment
203 pad = (4 - ((1 + name_len) % 4)) % 4
204 if pad > 0:
205 self._shm.write_blob(blob_abs_offset, b"\x00" * pad)
206 blob_abs_offset += pad
207 blob_rel_offset += pad
208
209 # Write transform (always)
171210 mat = np.array(obj.matrix_world, dtype=np.float32).flatten()
172211 self._shm.write_blob_array(blob_abs_offset, mat)
173212 blob_abs_offset += TRANSFORM_SIZE
……@@ -175,7 +214,6 @@ class ChangeDetector:
175214
176215 # Write vertex data for geometry updates
177216 if update_type in (UPDATE_VERTEX, UPDATE_FULL) and obj.type == "MESH":
178 # Use evaluated mesh for safe access (works in edit mode too)
179217 if depsgraph is not None:
180218 eval_obj = obj.evaluated_get(depsgraph)
181219 mesh = eval_obj.to_mesh()
……@@ -193,7 +231,7 @@ class ChangeDetector:
193231 blob_abs_offset += 4
194232 blob_rel_offset += 4
195233
196 # Bulk-read positions using foreach_get (C-level, fast)
234 # Bulk-read positions
197235 coords = np.empty(vert_count * 3, dtype=np.float32)
198236 mesh.vertices.foreach_get("co", coords)
199237 written = self._shm.write_blob_array(blob_abs_offset, coords)
……@@ -210,7 +248,7 @@ class ChangeDetector:
210248 blob_abs_offset += written
211249 blob_rel_offset += written
212250
213 # Triangle indices from loop_triangles
251 # Triangle indices
214252 mesh.calc_loop_triangles()
215253 tri_count = len(mesh.loop_triangles)
216254 idx_count = tri_count * 3
……@@ -228,13 +266,12 @@ class ChangeDetector:
228266 blob_abs_offset += len(raw)
229267 blob_rel_offset += len(raw)
230268
231 # Clean up evaluated mesh
232269 if eval_obj is not None:
233270 eval_obj.to_mesh_clear()
234271
235272 data_rel_size = blob_rel_offset - data_rel_start
236273
237 # Write object entry to region 1 (data_offset is region-relative)
274 # Write object entry to region 1
238275 self._shm.write_object_entry(
239276 index=entry_index,
240277 object_id=obj_id,
……@@ -253,7 +290,7 @@ class ChangeDetector:
253290 if entry_index > 0:
254291 self._shm.write_header(
255292 region=1,
256 direction=1, # blender→godot (kept for compat)
293 direction=1,
257294 sequence=self._sequence,
258295 object_count=entry_index,
259296 )
……@@ -261,4 +298,6 @@ class ChangeDetector:
261298 "{}(id={} t={})".format(n, self._name_to_id.get(n, "?"), ut)
262299 for n, ut in written_updates.items()
263300 )
264 print(f"BlenderBridge: Sent {entry_index} change(s) seq={self._sequence} [{details}]")
301 print(f"BlenderBridge: Flushed {entry_index} change(s) seq={self._sequence} [{details}]")
302
303 return entry_index
src/blender_bridge.cpp +40 -6
……@@ -496,6 +496,22 @@ void BlenderBridge::import_changes(Node *root) {
496496 for (uint32_t i = 0; i < hdr.object_count; i++) {
497497 ObjectEntry entry = read_object_entry(_read_base, i);
498498
499 // Read name from blob: [uint8 name_len] [name_bytes] [padding to 4-byte align]
500 uint32_t cursor = entry.data_offset;
501 String blender_name;
502 if (entry.data_size > 0) {
503 uint8_t name_len = base[cursor];
504 cursor += 1;
505 if (name_len > 0 && cursor + name_len <= entry.data_offset + entry.data_size) {
506 blender_name = String::utf8(reinterpret_cast<const char *>(base + cursor), name_len);
507 cursor += name_len;
508 }
509 // Skip padding to 4-byte alignment
510 uint32_t total = 1 + name_len;
511 uint32_t pad = (4 - (total % 4)) % 4;
512 cursor += pad;
513 }
514
499515 // Find matching tracked object
500516 Node3D *target = nullptr;
501517 for (const auto &tracked : _tracked_objects) {
……@@ -509,6 +525,7 @@ void BlenderBridge::import_changes(Node *root) {
509525 if (!target) {
510526 // Unknown object — Blender created it. Create a new node in Godot.
511527 UtilityFunctions::print("BlenderBridge: NEW obj id=", (int)entry.object_id,
528 " name=", blender_name,
512529 " type=", (int)entry.object_type, " update=", (int)entry.update_type,
513530 " size=", (int)entry.data_size);
514531 if (entry.update_type == UPDATE_DELETE)
……@@ -539,7 +556,10 @@ void BlenderBridge::import_changes(Node *root) {
539556 new_node = memnew(Node3D);
540557 }
541558
542 String name = String("blender_obj_") + String::num_int64(entry.object_id);
559 // Use actual Blender name, fallback to "blender_obj_N"
560 String name = blender_name.is_empty()
561 ? String("blender_obj_") + String::num_int64(entry.object_id)
562 : blender_name;
543563 new_node->set_name(name);
544564 export_root->add_child(new_node);
545565 new_node->set_owner(export_root);
……@@ -558,10 +578,13 @@ void BlenderBridge::import_changes(Node *root) {
558578 target = new_node;
559579 }
560580
581 // cursor now points past the name to the transform data
582 uint32_t transform_offset = cursor;
583
561584 if (entry.update_type == UPDATE_TRANSFORM || entry.update_type == UPDATE_FULL) {
562 // Read transform from blob
585 // Read transform from blob (after name prefix)
563586 float xform_data[16];
564 memcpy(xform_data, base + entry.data_offset, TRANSFORM_SIZE);
587 memcpy(xform_data, base + transform_offset, TRANSFORM_SIZE);
565588
566589 // Reverse of _export_transform: M^-1 * B' * M
567590 // where B' is the Blender-space 4x4 from SHM
……@@ -611,7 +634,7 @@ void BlenderBridge::import_changes(Node *root) {
611634 if (entry.update_type == UPDATE_VERTEX || entry.update_type == UPDATE_FULL) {
612635 MeshInstance3D *mesh_inst = Object::cast_to<MeshInstance3D>(target);
613636 if (mesh_inst && entry.data_size > TRANSFORM_SIZE + 4) {
614 uint32_t cursor = entry.data_offset + TRANSFORM_SIZE;
637 uint32_t cursor = transform_offset + TRANSFORM_SIZE;
615638 uint32_t vert_count;
616639 memcpy(&vert_count, base + cursor, 4);
617640 cursor += 4;
……@@ -757,12 +780,17 @@ void BlenderBridge::_process(double delta) {
757780 _handle_uds_message(msg);
758781 }
759782
760 // Auto-sync: check for incoming changes and push outgoing changes
761 if (_auto_sync && _shm.valid()) {
783 // Import Blender changes when notified via UDS
784 if (_import_pending && _shm.valid()) {
785 _import_pending = false;
762786 Node *root = get_tree() ? get_tree()->get_root() : nullptr;
763787 if (root) {
764788 import_changes(root);
765789 }
790 }
791
792 // Auto-sync: push outgoing Godot changes
793 if (_auto_sync && _shm.valid()) {
766794 _detect_and_push_changes();
767795 }
768796}
……@@ -780,6 +808,12 @@ void BlenderBridge::_handle_uds_message(const UdsMessage &msg) {
780808
781809 std::string text(reinterpret_cast<const char *>(msg.data.data()), msg.size);
782810
811 if (text == "updated") {
812 // Blender flushed changes to region 1 — import on next frame
813 _import_pending = true;
814 return;
815 }
816
783817 if (text.substr(0, 5) == "hello") {
784818 const char *ack = "connected";
785819 _uds.send_to(msg.client_id, ack, (uint32_t)strlen(ack));
src/blender_bridge.h +1 -0
……@@ -79,6 +79,7 @@ private:
7979 int _max_objects = 256;
8080 bool _auto_sync = false;
8181 bool _server_running = false;
82 bool _import_pending = false; // Set by UDS "updated" from Blender
8283 NodePath _virtual_texture_path;
8384
8485 // ── UDS message handling ────────────────────────────────────────────