OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge commits / f43ed3a

Split SHM into dual regions to eliminate feedback loop
The bridge had a catastrophic feedback loop where both sides wrote to the same SHM region using a direction byte as a mutex. Now each side owns its own write region (Region 0: Godot→Blender, Region 1: Blender→Godot) with an 8MB tile ring buffer after both regions (24MB total). Key changes: - Split 16MB SHM into 3 zones: Region 0 (8MB) + Region 1 (8MB) + Tile ring (8MB) - Remove _import_cooldown, suppress/unsuppress, direction checks - Incremental change detection instead of full scene re-export on node add/remove - from_blender flag on TrackedObject prevents echo-back of imported objects - Live geometry editing via evaluated mesh (safe in edit mode) - Fast path: surface_update_vertex_region() for same-topology vertex updates - Fix write_blob_array corrupting int32 indices by casting to float32 - Fix diagnostic print showing t=0 for all objects Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
f43ed3a1cb5767d3f41f860ad9ef5d13cc052e70
by plankatron <fleeky@gmail.com> · 2026-03-03T13:22:30.000Z

Changed files (8) +822 -274

blender_addon/__init__.py +128 -64
……@@ -11,6 +11,19 @@ Usage:
1111
1212import bpy
1313
14ADDON_VERSION = "0.3.0"
15
16# Support hot-reload (F3 > Reload Scripts)
17if "shm_protocol" in locals():
18 import importlib
19
20 importlib.reload(shm_protocol) # noqa: F821
21 importlib.reload(connection) # noqa: F821
22 importlib.reload(scene_import) # noqa: F821
23 importlib.reload(change_detector) # noqa: F821
24 importlib.reload(tile_baker) # noqa: F821
25
26from . import change_detector, connection, scene_import, shm_protocol, tile_baker
1427from .change_detector import ChangeDetector
1528from .connection import BridgeConnection
1629from .scene_import import SceneImporter
……@@ -27,7 +40,7 @@ _baker: TileBaker | None = None
2740_timer_running = False
2841
2942SHM_NAME = "/blender_bridge"
30SHM_SIZE = 16 * 1024 * 1024 # 16 MB
43SHM_SIZE = 24 * 1024 * 1024 # 24 MB (8 MB region 0 + 8 MB region 1 + 8 MB tile ring)
3144MAX_OBJECTS = 256
3245SOCKET_PATH = "/tmp/blender_bridge.sock"
3346POLL_INTERVAL = 0.05 # 50ms = 20 Hz polling
……@@ -48,28 +61,16 @@ def _poll_timer():
4861 for msg in messages:
4962 text = msg.decode("utf-8", errors="replace")
5063 if text == "scene_exported":
51 # Full scene import from Godot — suppress change detector
64 # Full scene sync from Godot
5265 if _importer is not None:
53 if _detector is not None:
54 _detector.suppress()
55 scene = bpy.context.scene
56 if hasattr(scene, "bridge_clear_on_import") and scene.bridge_clear_on_import:
57 _clear_bridge_objects()
58 # Reset importer state so IDs don't conflict
59 _importer._id_to_name.clear()
6066 _importer.full_import()
6167 if _detector is not None:
6268 _detector.set_id_map(_importer._id_to_name)
63 _detector.unsuppress()
64 print(f"BlenderBridge: Imported scene from Godot")
65 elif text == "transform_update":
66 # Incremental transform update — suppress change detector
69 print(f"BlenderBridge: Synced scene from Godot")
70 elif text in ("updated", "transform_update"):
71 # Incremental update from Godot (region 0)
6772 if _importer is not None:
68 if _detector is not None:
69 _detector.suppress()
7073 count = _importer.poll_updates()
71 if _detector is not None:
72 _detector.unsuppress()
7374 elif text.startswith("texture_registered:"):
7475 # Godot registered a texture: texture_registered:name,id
7576 parts = text.split(":", 1)[1].split(",")
……@@ -85,9 +86,98 @@ def _poll_timer():
8586 return POLL_INTERVAL # Continue polling
8687
8788
89# ── Addon Preferences ───────────────────────────────────────────────────────
90
91
92class BridgeAddonPreferences(bpy.types.AddonPreferences):
93 bl_idname = __package__
94
95 source_path: bpy.props.StringProperty(
96 name="Source Path",
97 subtype="DIR_PATH",
98 default="/storage/projects/code/godot/blender-bridge/blender_addon",
99 description="Local path to blender_bridge addon source directory",
100 )
101
102 def draw(self, context):
103 layout = self.layout
104 layout.prop(self, "source_path")
105 layout.operator("bridge.update_addon", icon="FILE_REFRESH")
106
107
88108# ── Operators ────────────────────────────────────────────────────────────────
89109
90110
111class BRIDGE_OT_update_addon(bpy.types.Operator):
112 """Update addon files from local source and hot-reload"""
113
114 bl_idname = "bridge.update_addon"
115 bl_label = "Update Addon"
116
117 def execute(self, context):
118 import os
119 import shutil
120
121 prefs = context.preferences.addons[__package__].preferences
122 source = bpy.path.abspath(prefs.source_path)
123
124 if not os.path.isdir(source):
125 self.report({"ERROR"}, f"Source directory not found: {source}")
126 return {"CANCELLED"}
127
128 addon_dir = os.path.dirname(os.path.realpath(__file__))
129 is_symlink = os.path.islink(os.path.dirname(__file__))
130
131 copied = 0
132 if not is_symlink:
133 for fname in os.listdir(source):
134 if fname.endswith((".py", ".toml")):
135 src = os.path.join(source, fname)
136 dst = os.path.join(addon_dir, fname)
137 shutil.copy2(src, dst)
138 copied += 1
139
140 # Hot-reload: unregister, purge our modules, reimport, register
141 pkg = __package__
142
143 def _deferred_reload():
144 import importlib
145 import sys
146
147 # Disconnect bridge if active
148 try:
149 unregister()
150 except Exception as e:
151 print(f"BlenderBridge: unregister warning: {e}")
152
153 # Purge all our modules from sys.modules so Python reimports fresh
154 to_remove = [
155 name
156 for name in sys.modules
157 if name == pkg or name.startswith(pkg + ".")
158 ]
159 for name in to_remove:
160 del sys.modules[name]
161
162 # Reimport and re-register
163 try:
164 mod = importlib.import_module(pkg)
165 mod.register()
166 print("BlenderBridge: Addon reloaded successfully")
167 except Exception as e:
168 print(f"BlenderBridge: Reload failed: {e}")
169
170 return None
171
172 bpy.app.timers.register(_deferred_reload, first_interval=0.1)
173
174 if is_symlink:
175 self.report({"INFO"}, "Reloading addon...")
176 else:
177 self.report({"INFO"}, f"Copied {copied} files, reloading...")
178 return {"FINISHED"}
179
180
91181class BRIDGE_OT_connect(bpy.types.Operator):
92182 """Connect to Godot BlenderBridge server"""
93183
……@@ -116,9 +206,12 @@ class BRIDGE_OT_connect(bpy.types.Operator):
116206 _detector = ChangeDetector(_shm, MAX_OBJECTS)
117207 _detector.register()
118208
119 # Set up tile baker (ring buffer in top half of SHM)
209 # Set up tile baker (ring buffer after both regions)
210 from .shm_protocol import REGION_SIZE
211 tile_ring_offset = REGION_SIZE * 2 # 16 MB offset
212 tile_ring_size = SHM_SIZE - tile_ring_offset # remaining 8 MB
120213 _baker = TileBaker(_shm, tile_size=128, max_mips=8)
121 _baker.init_ring_buffer(SHM_SIZE // 2, SHM_SIZE // 2)
214 _baker.init_ring_buffer(tile_ring_offset, tile_ring_size)
122215
123216 # Start polling timer
124217 if not _timer_running:
……@@ -160,55 +253,28 @@ class BRIDGE_OT_disconnect(bpy.types.Operator):
160253
161254
162255class BRIDGE_OT_import_scene(bpy.types.Operator):
163 """Manually import the current Godot scene"""
256 """Sync Blender scene to match Godot scene"""
164257
165258 bl_idname = "bridge.import_scene"
166 bl_label = "Import Scene"
259 bl_label = "Sync Scene"
167260
168261 def execute(self, context):
169262 if _importer is None:
170263 self.report({"ERROR"}, "Not connected")
171264 return {"CANCELLED"}
172265
173 # Suppress change detector during import
174 if _detector is not None:
175 _detector.suppress()
176
177 # Clear existing bridge objects if enabled
178 if context.scene.bridge_clear_on_import:
179 _clear_bridge_objects()
180
181266 _importer.full_import()
182267 if _detector is not None:
183268 _detector.set_id_map(_importer._id_to_name)
184 _detector.unsuppress()
185269
186 header = _shm.read_header()
270 header = _shm.read_header(region=0)
187271 self.report(
188272 {"INFO"},
189 f"Imported {header['object_count']} objects from Godot",
273 f"Synced {header['object_count']} objects from Godot",
190274 )
191275 return {"FINISHED"}
192276
193277
194def _clear_bridge_objects():
195 """Remove all objects previously imported by the bridge."""
196 to_remove = [obj for obj in bpy.data.objects if obj.name.startswith("bridge_")]
197 if to_remove:
198 print(f"BlenderBridge: Clearing {len(to_remove)} bridge objects")
199 for obj in to_remove:
200 # Store data reference before removing object
201 data = obj.data
202 bpy.data.objects.remove(obj, do_unlink=True)
203 # Clean up orphaned data blocks (meshes, cameras, lights)
204 if data is not None and data.users == 0:
205 if isinstance(data, bpy.types.Mesh):
206 bpy.data.meshes.remove(data)
207 elif isinstance(data, bpy.types.Camera):
208 bpy.data.cameras.remove(data)
209 elif isinstance(data, bpy.types.Light):
210 bpy.data.lights.remove(data)
211
212278
213279# ── UI Panel ─────────────────────────────────────────────────────────────────
214280
……@@ -232,23 +298,28 @@ class BRIDGE_PT_panel(bpy.types.Panel):
232298 else:
233299 layout.operator("bridge.disconnect", icon="UNLINKED")
234300 layout.separator()
235 layout.prop(context.scene, "bridge_clear_on_import")
236 layout.operator("bridge.import_scene", icon="IMPORT")
301 layout.operator("bridge.import_scene", icon="FILE_REFRESH")
237302
238303 # Status info
239304 box = layout.box()
240305 box.label(text="Status: Connected", icon="CHECKMARK")
241306 if _shm is not None and _shm.valid:
242 header = _shm.read_header()
243 box.label(text=f"Objects: {header['object_count']}")
244 box.label(text=f"Sequence: {header['sequence']}")
245 direction = "Godot→Blender" if header["direction"] == 0 else "Blender→Godot"
246 box.label(text=f"Direction: {direction}")
307 hdr0 = _shm.read_header(region=0)
308 hdr1 = _shm.read_header(region=1)
309 box.label(text=f"Godot→Blender: {hdr0['object_count']} obj, seq={hdr0['sequence']}")
310 box.label(text=f"Blender→Godot: {hdr1['object_count']} obj, seq={hdr1['sequence']}")
311
312 # Update button (always visible)
313 layout.separator()
314 layout.operator("bridge.update_addon", icon="IMPORT")
315 layout.label(text=f"v{ADDON_VERSION}")
247316
248317
249318# ── Registration ─────────────────────────────────────────────────────────────
250319
251320_classes = (
321 BridgeAddonPreferences,
322 BRIDGE_OT_update_addon,
252323 BRIDGE_OT_connect,
253324 BRIDGE_OT_disconnect,
254325 BRIDGE_OT_import_scene,
……@@ -259,11 +330,6 @@ _classes = (
259330def register():
260331 for cls in _classes:
261332 bpy.utils.register_class(cls)
262 bpy.types.Scene.bridge_clear_on_import = bpy.props.BoolProperty(
263 name="Clear Scene on Import",
264 description="Remove existing bridge objects before importing from Godot",
265 default=True,
266 )
267333
268334
269335def unregister():
……@@ -283,8 +349,6 @@ def unregister():
283349 _shm = None
284350 _timer_running = False
285351
286 del bpy.types.Scene.bridge_clear_on_import
287
288352 for cls in reversed(_classes):
289353 bpy.utils.unregister_class(cls)
290354
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.1.0"
5version = "0.3.0"
66blender_version_min = "4.2.0"
77type = "add-on"
88maintainer = "blender-bridge <noreply@localhost>"
blender_addon/change_detector.py +113 -86
……@@ -1,7 +1,7 @@
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 with direction=1 (blender→godot).
4then writes them to SHM region 1 (Blender→Godot).
55"""
66
77import struct
……@@ -9,6 +9,9 @@ import struct
99import numpy as np
1010
1111from .shm_protocol import (
12 LIGHT_POINT,
13 LIGHT_SPOT,
14 LIGHT_SUN,
1215 OBJ_CAMERA,
1316 OBJ_EMPTY,
1417 OBJ_LIGHT,
……@@ -41,8 +44,20 @@ def _blender_type_to_obj_type(obj) -> int:
4144 return OBJ_EMPTY
4245
4346
47def _blender_light_sub_type(obj) -> int:
48 """Map Blender light type to protocol LightSubType."""
49 if obj.type not in ("LIGHT", "LAMP"):
50 return 0
51 light_type = obj.data.type
52 if light_type == "SUN":
53 return LIGHT_SUN
54 elif light_type == "SPOT":
55 return LIGHT_SPOT
56 return LIGHT_POINT
57
58
4459class ChangeDetector:
45 """Detects Blender scene changes and exports to SHM."""
60 """Detects Blender scene changes and exports to SHM region 1."""
4661
4762 def __init__(self, shm: ShmRegion, max_objects: int = 256):
4863 self._shm = shm
……@@ -52,10 +67,6 @@ class ChangeDetector:
5267 self._name_to_id: dict[str, int] = {}
5368 self._next_id = 1
5469 self._registered = False
55 # Suppress change detection during imports (prevent feedback loop)
56 self._suppressed = False
57 # Skip N depsgraph callbacks after unsuppress to absorb echo
58 self._skip_frames = 0
5970
6071 def register(self):
6172 """Register the depsgraph handler."""
……@@ -89,34 +100,8 @@ class ChangeDetector:
89100
90101 # ── Depsgraph handler ────────────────────────────────────────────────
91102
92 def suppress(self):
93 """Temporarily suppress change detection (call during imports)."""
94 self._suppressed = True
95
96 def unsuppress(self):
97 """Re-enable change detection after import.
98
99 Skips the next 5 depsgraph callbacks to absorb the echo from
100 the import — Blender's depsgraph fires asynchronously after
101 objects are modified, and those updates would loop back.
102 """
103 self._suppressed = False
104 self._skip_frames = 5
105
106103 def _on_depsgraph_update(self, scene, depsgraph):
107104 """Called after every depsgraph evaluation."""
108 if self._suppressed:
109 return
110
111 # Skip N callbacks after unsuppress to absorb depsgraph echo
112 if self._skip_frames > 0:
113 self._skip_frames -= 1
114 return
115
116 # Skip while any object is in edit mode — accessing mesh data crashes
117 if bpy.context.mode != "OBJECT":
118 return
119
120105 dirty = []
121106
122107 for update in depsgraph.updates:
……@@ -130,18 +115,24 @@ class ChangeDetector:
130115 dirty.append(("geometry", obj))
131116
132117 if dirty:
133 self._export_changes(dirty)
118 self._export_changes(dirty, depsgraph)
134119
135120 # ── Export changes to SHM ────────────────────────────────────────────
136121
137 def _export_changes(self, dirty: list[tuple[str, object]]):
138 """Write changed objects to shared memory."""
122 def _export_changes(self, dirty: list[tuple[str, object]], depsgraph=None):
123 """Write changed objects to shared memory region 1."""
139124 if not self._shm.valid:
140125 return
141126
142127 self._sequence += 1
143 blob_start = ShmRegion.data_blob_offset(self._max_objects)
144 blob_offset = blob_start
128 # Region base for absolute mmap writes
129 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.
132 from .shm_protocol import HEADER_SIZE, ENTRY_SIZE
133 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
145136
146137 # Deduplicate: if both transform and geometry changed, use FULL
147138 obj_updates: dict[str, tuple[int, object]] = {}
……@@ -161,77 +152,113 @@ class ChangeDetector:
161152 obj_updates[name] = (UPDATE_FULL, obj)
162153
163154 entry_index = 0
155 written_updates: dict[str, int] = {} # name → actual update_type written
164156 for name, (update_type, obj) in obj_updates.items():
165157 if entry_index >= self._max_objects:
166158 break
167159
160 is_new = name not in self._name_to_id
168161 obj_id = self.get_or_assign_id(name)
162
163 # New objects need full data (geometry) so Godot can create proper meshes
164 if is_new and obj.type == "MESH":
165 update_type = UPDATE_FULL
169166 obj_type = _blender_type_to_obj_type(obj)
170 data_start = blob_offset
167 sub_type = _blender_light_sub_type(obj)
168 data_rel_start = blob_rel_offset # region-relative for ObjectEntry
171169
172 # Write transform (always)
170 # Write transform (always) — use absolute offset for mmap write
173171 mat = np.array(obj.matrix_world, dtype=np.float32).flatten()
174 self._shm.write_blob_array(blob_offset, mat)
175 blob_offset += TRANSFORM_SIZE
172 self._shm.write_blob_array(blob_abs_offset, mat)
173 blob_abs_offset += TRANSFORM_SIZE
174 blob_rel_offset += TRANSFORM_SIZE
176175
177176 # Write vertex data for geometry updates
178177 if update_type in (UPDATE_VERTEX, UPDATE_FULL) and obj.type == "MESH":
179 mesh = obj.data
180 vert_count = len(mesh.vertices)
181 if vert_count > 0:
182 # Write vertex count
183 self._shm.write_blob(
184 blob_offset, struct.pack("<I", vert_count)
185 )
186 blob_offset += 4
187
188 # Bulk-read positions using foreach_get (C-level, fast)
189 coords = np.empty(vert_count * 3, dtype=np.float32)
190 mesh.vertices.foreach_get("co", coords)
191 written = self._shm.write_blob_array(blob_offset, coords)
192 blob_offset += written
193
194 # has_normals flag + normals
195 normals = np.empty(vert_count * 3, dtype=np.float32)
196 mesh.vertices.foreach_get("normal", normals)
197 self._shm.write_blob(blob_offset, struct.pack("<I", 1))
198 blob_offset += 4
199 written = self._shm.write_blob_array(blob_offset, normals)
200 blob_offset += written
201
202 # Triangle indices from loop_triangles
203 mesh.calc_loop_triangles()
204 tri_count = len(mesh.loop_triangles)
205 idx_count = tri_count * 3
206 self._shm.write_blob(
207 blob_offset, struct.pack("<I", idx_count)
208 )
209 blob_offset += 4
210
211 if idx_count > 0:
212 indices = np.empty(idx_count, dtype=np.int32)
213 mesh.loop_triangles.foreach_get("vertices", indices)
214 written = self._shm.write_blob_array(blob_offset, indices)
215 blob_offset += written
216
217 data_size = blob_offset - data_start
218
219 # Write object entry
178 # Use evaluated mesh for safe access (works in edit mode too)
179 if depsgraph is not None:
180 eval_obj = obj.evaluated_get(depsgraph)
181 mesh = eval_obj.to_mesh()
182 else:
183 mesh = obj.data
184 eval_obj = None
185
186 if mesh is not None:
187 vert_count = len(mesh.vertices)
188 if vert_count > 0:
189 # Write vertex count
190 self._shm.write_blob(
191 blob_abs_offset, struct.pack("<I", vert_count)
192 )
193 blob_abs_offset += 4
194 blob_rel_offset += 4
195
196 # Bulk-read positions using foreach_get (C-level, fast)
197 coords = np.empty(vert_count * 3, dtype=np.float32)
198 mesh.vertices.foreach_get("co", coords)
199 written = self._shm.write_blob_array(blob_abs_offset, coords)
200 blob_abs_offset += written
201 blob_rel_offset += written
202
203 # has_normals flag + normals
204 normals = np.empty(vert_count * 3, dtype=np.float32)
205 mesh.vertices.foreach_get("normal", normals)
206 self._shm.write_blob(blob_abs_offset, struct.pack("<I", 1))
207 blob_abs_offset += 4
208 blob_rel_offset += 4
209 written = self._shm.write_blob_array(blob_abs_offset, normals)
210 blob_abs_offset += written
211 blob_rel_offset += written
212
213 # Triangle indices from loop_triangles
214 mesh.calc_loop_triangles()
215 tri_count = len(mesh.loop_triangles)
216 idx_count = tri_count * 3
217 self._shm.write_blob(
218 blob_abs_offset, struct.pack("<I", idx_count)
219 )
220 blob_abs_offset += 4
221 blob_rel_offset += 4
222
223 if idx_count > 0:
224 indices = np.empty(idx_count, dtype=np.int32)
225 mesh.loop_triangles.foreach_get("vertices", indices)
226 raw = indices.tobytes()
227 self._shm.write_blob(blob_abs_offset, raw)
228 blob_abs_offset += len(raw)
229 blob_rel_offset += len(raw)
230
231 # Clean up evaluated mesh
232 if eval_obj is not None:
233 eval_obj.to_mesh_clear()
234
235 data_rel_size = blob_rel_offset - data_rel_start
236
237 # Write object entry to region 1 (data_offset is region-relative)
220238 self._shm.write_object_entry(
221239 index=entry_index,
222240 object_id=obj_id,
223241 object_type=obj_type,
224242 update_type=update_type,
225 data_offset=data_start,
226 data_size=data_size,
243 data_offset=data_rel_start,
244 data_size=data_rel_size,
227245 sequence=self._sequence,
246 sub_type=sub_type,
247 region=1,
228248 )
249 written_updates[name] = update_type
229250 entry_index += 1
230251
231 # Update header
252 # Update header in region 1
232253 if entry_index > 0:
233254 self._shm.write_header(
234 direction=1, # blender→godot
255 region=1,
256 direction=1, # blender→godot (kept for compat)
235257 sequence=self._sequence,
236258 object_count=entry_index,
237259 )
260 details = ", ".join(
261 "{}(id={} t={})".format(n, self._name_to_id.get(n, "?"), ut)
262 for n, ut in written_updates.items()
263 )
264 print(f"BlenderBridge: Sent {entry_index} change(s) seq={self._sequence} [{details}]")
blender_addon/scene_import.py +67 -13
……@@ -1,7 +1,7 @@
11"""Import Godot scene from shared memory into Blender.
22
3Reads the SHM region written by BlenderBridge::export_scene() and
4reconstructs objects in the current Blender scene.
3Reads SHM region 0 (Godot→Blender) written by BlenderBridge::export_scene()
4and reconstructs objects in the current Blender scene.
55
66Coordinate conversion (already applied by C++ side):
77 Godot Y-up (x,y,z) → Blender Z-up (x,-z,y) — positions are pre-converted.
……@@ -13,6 +13,9 @@ import struct
1313import numpy as np
1414
1515from .shm_protocol import (
16 LIGHT_POINT,
17 LIGHT_SPOT,
18 LIGHT_SUN,
1619 OBJ_CAMERA,
1720 OBJ_EMPTY,
1821 OBJ_LIGHT,
……@@ -35,7 +38,7 @@ except ImportError:
3538
3639
3740class SceneImporter:
38 """Imports scene data from SHM into Blender."""
41 """Imports scene data from SHM region 0 (Godot→Blender) into Blender."""
3942
4043 def __init__(self, shm: ShmRegion, max_objects: int = 256):
4144 self._shm = shm
……@@ -45,24 +48,51 @@ class SceneImporter:
4548 self._last_sequence = 0
4649
4750 def full_import(self):
48 """Import all objects from the initial scene export (bootstrap)."""
49 header = self._shm.read_header()
51 """Import all objects from Godot, syncing the Blender scene.
52
53 Creates/updates objects from the SHM data and removes any stale
54 bridge objects that are no longer in the Godot scene.
55 """
56 header = self._shm.read_header(region=0)
5057
5158 if header["magic"] != b"BGOD":
5259 raise ValueError(f"Bad magic: {header['magic']}")
5360
5461 self._last_sequence = header["sequence"]
5562
63 # Track which object IDs appear in this import
64 imported_ids = set()
65
5666 for i in range(header["object_count"]):
57 entry = self._shm.read_object_entry(i)
67 entry = self._shm.read_object_entry(i, region=0)
68 imported_ids.add(entry["object_id"])
5869 self._import_object(entry)
5970
71 # Scene sync: remove bridge objects not in current Godot scene
72 stale_ids = set(self._id_to_name.keys()) - imported_ids
73 for obj_id in stale_ids:
74 name = self._id_to_name.pop(obj_id, None)
75 if name is None:
76 continue
77 obj = bpy.data.objects.get(name)
78 if obj is not None:
79 data = obj.data
80 bpy.data.objects.remove(obj, do_unlink=True)
81 if data is not None and data.users == 0:
82 if isinstance(data, bpy.types.Mesh):
83 bpy.data.meshes.remove(data)
84 elif isinstance(data, bpy.types.Camera):
85 bpy.data.cameras.remove(data)
86 elif isinstance(data, bpy.types.Light):
87 bpy.data.lights.remove(data)
88 if stale_ids:
89 print(f"BlenderBridge: Removed {len(stale_ids)} stale object(s)")
90
6091 def poll_updates(self) -> int:
61 """Check for incremental updates. Returns number of objects updated."""
62 header = self._shm.read_header()
92 """Check for incremental updates from region 0. Returns number of objects updated."""
93 header = self._shm.read_header(region=0)
6394
64 if header["direction"] != 0:
65 return 0
95 # No direction check needed — region 0 is always Godot→Blender
6696 if header["sequence"] <= self._last_sequence:
6797 return 0
6898
……@@ -70,7 +100,7 @@ class SceneImporter:
70100 updated = 0
71101
72102 for i in range(header["object_count"]):
73 entry = self._shm.read_object_entry(i)
103 entry = self._shm.read_object_entry(i, region=0)
74104 self._apply_update(entry)
75105 updated += 1
76106
……@@ -82,6 +112,23 @@ class SceneImporter:
82112 obj_id = entry["object_id"]
83113 obj_type = entry["object_type"]
84114
115 # Check if we already have an object for this ID — reuse it
116 existing_name = self._id_to_name.get(obj_id)
117 if existing_name:
118 existing_obj = bpy.data.objects.get(existing_name)
119 if existing_obj is not None:
120 # Update transform in place
121 self._apply_transform(existing_obj, entry["data_offset"])
122 # Update geometry if mesh with vertex data
123 if (
124 obj_type == OBJ_MESH
125 and existing_obj.type == "MESH"
126 and entry["data_size"] > TRANSFORM_SIZE + 4
127 ):
128 self._update_mesh_geometry(existing_obj, entry)
129 return
130
131 # No existing object — create a new one
85132 if obj_type == OBJ_MESH:
86133 obj = self._import_mesh(entry)
87134 elif obj_type == OBJ_CAMERA:
……@@ -168,9 +215,16 @@ class SceneImporter:
168215 return obj
169216
170217 def _import_light(self, entry: dict):
171 """Create a Blender light."""
218 """Create a Blender light with correct type from sub_type field."""
219 sub = entry.get("sub_type", LIGHT_POINT)
220 if sub == LIGHT_SUN:
221 bl_type = "SUN"
222 elif sub == LIGHT_SPOT:
223 bl_type = "SPOT"
224 else:
225 bl_type = "POINT"
172226 light = bpy.data.lights.new(
173 f"bridge_light_{entry['object_id']}", "POINT"
227 f"bridge_light_{entry['object_id']}", bl_type
174228 )
175229 obj = bpy.data.objects.new(light.name, light)
176230 bpy.context.collection.objects.link(obj)
blender_addon/shm_protocol.py +53 -17
……@@ -30,12 +30,13 @@ assert struct.calcsize(HEADER_FMT) == HEADER_SIZE
3030# uint32_t object_id I
3131# uint8_t object_type B
3232# uint8_t update_type B
33# uint8_t _pad[2] 2s
33# uint8_t sub_type B (light subtype: 0=POINT, 1=SUN, 2=SPOT)
34# uint8_t _pad B
3435# uint32_t data_offset I
3536# uint32_t data_size I
3637# uint32_t sequence I
3738# uint8_t _reserved[12] 12s
38ENTRY_FMT = "<IBB2sIII12s"
39ENTRY_FMT = "<IBBBBIII12s"
3940ENTRY_SIZE = 32
4041assert struct.calcsize(ENTRY_FMT) == ENTRY_SIZE
4142
……@@ -51,18 +52,36 @@ UPDATE_MATERIAL = 2
5152UPDATE_FULL = 3
5253UPDATE_DELETE = 4
5354
55# ── Light subtypes ──────────────────────────────────────────────────────────
56LIGHT_POINT = 0
57LIGHT_SUN = 1 # DirectionalLight3D ↔ Blender "SUN"
58LIGHT_SPOT = 2
59
5460# ── Transform size ───────────────────────────────────────────────────────────
5561TRANSFORM_SIZE = 64 # 4×4 float32 matrix
5662
63# ── Region layout ────────────────────────────────────────────────────────────
64# SHM is divided into 3 zones:
65# Region 0 [0, REGION_SIZE): Godot→Blender
66# Region 1 [REGION_SIZE, 2*REGION_SIZE): Blender→Godot
67# Tile ring [2*REGION_SIZE, SHM_SIZE): Texture streaming
68REGION_SIZE = 8 * 1024 * 1024 # 8 MB per region
69
5770
5871class ShmRegion:
59 """POSIX shared memory wrapper matching the C++ ShmRegion class."""
72 """POSIX shared memory wrapper matching the C++ ShmRegion class.
73
74 Supports split-region mode: the SHM is divided into two equal halves.
75 Region 0 (offset 0): Godot writes, Blender reads
76 Region 1 (offset size/2): Blender writes, Godot reads
77 """
6078
6179 def __init__(self):
6280 self._mm = None
6381 self._fd = -1
6482 self._name = None
6583 self._size = 0
84 self._region_size = REGION_SIZE # 8 MB per region (fixed)
6685 self._owner = False
6786
6887 # ── Lifecycle ────────────────────────────────────────────────────────
……@@ -122,10 +141,17 @@ class ShmRegion:
122141 def size(self) -> int:
123142 return self._size
124143
144 # ── Region helpers ──────────────────────────────────────────────────
145
146 def region_base(self, region: int) -> int:
147 """Return the absolute byte offset for the start of a region."""
148 return region * self._region_size
149
125150 # ── Header read/write ────────────────────────────────────────────────
126151
127 def read_header(self) -> dict:
128 data = self._mm[0:HEADER_SIZE]
152 def read_header(self, region: int = 0) -> dict:
153 base = self.region_base(region)
154 data = self._mm[base : base + HEADER_SIZE]
129155 magic, ver, direction, _pad, seq, obj_count, _pad2, frame, _res = (
130156 struct.unpack(HEADER_FMT, data)
131157 )
……@@ -140,6 +166,7 @@ class ShmRegion:
140166
141167 def write_header(
142168 self,
169 region: int = 0,
143170 magic: bytes = b"BGOD",
144171 version: int = 1,
145172 direction: int = 0,
……@@ -147,6 +174,7 @@ class ShmRegion:
147174 object_count: int = 0,
148175 frame_number: int = 0,
149176 ):
177 base = self.region_base(region)
150178 data = struct.pack(
151179 HEADER_FMT,
152180 magic,
……@@ -159,20 +187,22 @@ class ShmRegion:
159187 frame_number,
160188 b"\x00" * 32, # _reserved
161189 )
162 self._mm[0:HEADER_SIZE] = data
190 self._mm[base : base + HEADER_SIZE] = data
163191
164192 # ── Object entry read/write ──────────────────────────────────────────
165193
166 def read_object_entry(self, index: int) -> dict:
167 offset = HEADER_SIZE + index * ENTRY_SIZE
194 def read_object_entry(self, index: int, region: int = 0) -> dict:
195 base = self.region_base(region)
196 offset = base + HEADER_SIZE + index * ENTRY_SIZE
168197 data = self._mm[offset : offset + ENTRY_SIZE]
169 obj_id, obj_type, upd_type, _pad, data_off, data_sz, seq, _res = (
198 obj_id, obj_type, upd_type, sub_type, _pad, data_off, data_sz, seq, _res = (
170199 struct.unpack(ENTRY_FMT, data)
171200 )
172201 return {
173202 "object_id": obj_id,
174203 "object_type": obj_type,
175204 "update_type": upd_type,
205 "sub_type": sub_type,
176206 "data_offset": data_off,
177207 "data_size": data_sz,
178208 "sequence": seq,
……@@ -187,14 +217,18 @@ class ShmRegion:
187217 data_offset: int,
188218 data_size: int,
189219 sequence: int,
220 sub_type: int = 0,
221 region: int = 0,
190222 ):
191 offset = HEADER_SIZE + index * ENTRY_SIZE
223 base = self.region_base(region)
224 offset = base + HEADER_SIZE + index * ENTRY_SIZE
192225 data = struct.pack(
193226 ENTRY_FMT,
194227 object_id,
195228 object_type,
196229 update_type,
197 b"\x00\x00", # _pad
230 sub_type,
231 0, # _pad
198232 data_offset,
199233 data_size,
200234 sequence,
……@@ -215,14 +249,16 @@ class ShmRegion:
215249 self._mm[offset : offset + len(data)] = data
216250
217251 def write_blob_array(self, offset: int, arr: np.ndarray):
218 """Write a numpy array to the blob region."""
219 raw = arr.astype(np.float32).tobytes()
252 """Write a numpy array to the blob region (preserves dtype)."""
253 raw = arr.tobytes()
220254 self._mm[offset : offset + len(raw)] = raw
221255 return len(raw)
222256
223257 # ── Layout helpers ───────────────────────────────────────────────────
224258
225 @staticmethod
226 def data_blob_offset(max_objects: int) -> int:
227 """Offset where data blobs start (past header + object table)."""
228 return HEADER_SIZE + max_objects * ENTRY_SIZE
259 def data_blob_offset(self, max_objects: int, region: int = 0) -> int:
260 """Offset where data blobs start (past header + object table).
261
262 Returns an absolute offset into the SHM (region base + header + table).
263 """
264 return self.region_base(region) + HEADER_SIZE + max_objects * ENTRY_SIZE
src/blender_bridge.cpp +421 -86
……@@ -1,12 +1,16 @@
11#include "blender_bridge.h"
22#include "virtual_texture.h"
33
4#include <algorithm>
45#include <cstdio>
56#include <string>
67
78#include <godot_cpp/classes/camera3d.hpp>
9#include <godot_cpp/classes/directional_light3d.hpp>
810#include <godot_cpp/classes/light3d.hpp>
11#include <godot_cpp/classes/omni_light3d.hpp>
912#include <godot_cpp/classes/scene_tree.hpp>
13#include <godot_cpp/classes/spot_light3d.hpp>
1014#include <godot_cpp/classes/window.hpp>
1115#include <godot_cpp/variant/utility_functions.hpp>
1216
……@@ -119,18 +123,24 @@ Error BlenderBridge::start_server(const String &socket_path) {
119123 return ERR_CANT_CREATE;
120124 }
121125
122 // Initialize header
126 // Set up split-region pointers
127 _region_size = REGION_SIZE;
128 _write_base = _shm.ptr(); // Region 0: Godot writes
129 _read_base = region_base(_shm.ptr(), 1); // Region 1: Blender writes
130
131 // Initialize BOTH region headers
123132 BridgeHeader hdr = {};
124133 hdr.magic[0] = 'B';
125134 hdr.magic[1] = 'G';
126135 hdr.magic[2] = 'O';
127136 hdr.magic[3] = 'D';
128137 hdr.version = 1;
129 hdr.direction = 0; // godot→blender (bootstrap direction)
138 hdr.direction = 0;
130139 hdr.sequence = 0;
131140 hdr.object_count = 0;
132141 hdr.frame_number = 0;
133 write_header(_shm.ptr(), hdr);
142 write_header(_write_base, hdr);
143 write_header(_read_base, hdr);
134144
135145 // Start UDS server
136146 CharString sock_cs = socket_path.utf8();
……@@ -144,9 +154,10 @@ Error BlenderBridge::start_server(const String &socket_path) {
144154 _export_sequence = 0;
145155 _last_import_sequence = 0;
146156 _tracked_objects.clear();
157 _known_instance_ids.clear();
147158 _next_object_id = 1;
148159
149 UtilityFunctions::print("BlenderBridge: Server started (shm=", _shm_name, ", sock=", socket_path, ")");
160 UtilityFunctions::print("BlenderBridge v0.3.0: Server started (shm=", _shm_name, ", sock=", socket_path, ", regions=2x", (int)_region_size, ")");
150161 return OK;
151162}
152163
……@@ -158,6 +169,10 @@ void BlenderBridge::stop_server() {
158169 _shm.destroy();
159170 _server_running = false;
160171 _tracked_objects.clear();
172 _known_instance_ids.clear();
173 _write_base = nullptr;
174 _read_base = nullptr;
175 _region_size = 0;
161176
162177 UtilityFunctions::print("BlenderBridge: Server stopped");
163178}
……@@ -211,21 +226,22 @@ void BlenderBridge::_export_transform(const Transform3D &xform, float *out) {
211226 //
212227 // With coordinate conversion M applied: M * col, M * origin
213228
229 // M * B * M^-1 (similarity transform, NOT transposed)
214230 // Row 0 (Blender X)
215231 out[0] = b[0][0];
216 out[1] = -b[2][0];
217 out[2] = b[1][0];
232 out[1] = -b[0][2];
233 out[2] = b[0][1];
218234 out[3] = o.x;
219235
220236 // Row 1 (Blender Y = -Godot Z)
221 out[4] = -b[0][2];
237 out[4] = -b[2][0];
222238 out[5] = b[2][2];
223 out[6] = -b[1][2];
239 out[6] = -b[2][1];
224240 out[7] = -o.z;
225241
226242 // Row 2 (Blender Z = Godot Y)
227 out[8] = b[0][1];
228 out[9] = -b[2][1];
243 out[8] = b[1][0];
244 out[9] = -b[1][2];
229245 out[10] = b[1][1];
230246 out[11] = o.y;
231247
……@@ -241,24 +257,50 @@ uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob
241257 entry.object_id = _next_object_id++;
242258 entry.update_type = UPDATE_FULL;
243259 entry.data_offset = blob_offset;
260 entry.sub_type = 0;
244261
245 // Determine type
262 // Determine type and subtype
246263 MeshInstance3D *mesh_inst = Object::cast_to<MeshInstance3D>(node);
247264 Camera3D *camera = Object::cast_to<Camera3D>(node);
248265 Light3D *light = Object::cast_to<Light3D>(node);
266 bool needs_look_compensation = false;
249267
250 if (mesh_inst)
268 if (mesh_inst) {
251269 entry.object_type = OBJ_MESH;
252 else if (camera)
270 } else if (camera) {
253271 entry.object_type = OBJ_CAMERA;
254 else if (light)
272 needs_look_compensation = true;
273 } else if (light) {
255274 entry.object_type = OBJ_LIGHT;
256 else
275 // Detect specific light type
276 if (Object::cast_to<DirectionalLight3D>(node)) {
277 entry.sub_type = LIGHT_SUN;
278 needs_look_compensation = true;
279 } else if (Object::cast_to<SpotLight3D>(node)) {
280 entry.sub_type = LIGHT_SPOT;
281 needs_look_compensation = true;
282 } else {
283 entry.sub_type = LIGHT_POINT;
284 }
285 } else {
257286 entry.object_type = OBJ_EMPTY;
287 }
258288
259289 // Write transform (always present, first 64 bytes of blob)
260290 float xform_data[16];
261291 _export_transform(node->get_global_transform(), xform_data);
292
293 // Camera/directional/spot lights: post-multiply by R_x(90°) to compensate
294 // for the -Z_local look direction convention across coordinate systems.
295 // Without this, cameras/lights point in the wrong direction in Blender.
296 if (needs_look_compensation) {
297 // R_x(90°): new col1 = old col2, new col2 = -old col1
298 float old1[3] = {xform_data[1], xform_data[5], xform_data[9]};
299 float old2[3] = {xform_data[2], xform_data[6], xform_data[10]};
300 xform_data[1] = old2[0]; xform_data[5] = old2[1]; xform_data[9] = old2[2];
301 xform_data[2] = -old1[0]; xform_data[6] = -old1[1]; xform_data[10] = -old1[2];
302 }
303
262304 memcpy(blob_base + blob_offset, xform_data, TRANSFORM_SIZE);
263305 blob_offset += TRANSFORM_SIZE;
264306
……@@ -326,7 +368,7 @@ uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob
326368
327369 entry.data_size = blob_offset - entry.data_offset;
328370 entry.sequence = _export_sequence;
329 write_object_entry(_shm.ptr(), index, entry);
371 write_object_entry(_write_base, index, entry);
330372
331373 // Track the object with initial transform hash
332374 TrackedObject tracked;
……@@ -335,6 +377,7 @@ uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob
335377 tracked.name = node->get_name();
336378 tracked.type = (ObjectType)entry.object_type;
337379 tracked.transform_hash = _hash_transform(node->get_global_transform());
380 tracked.from_blender = false;
338381 _tracked_objects.push_back(tracked);
339382
340383 return entry.object_id;
……@@ -354,31 +397,45 @@ Error BlenderBridge::export_scene(Node *root) {
354397 return ERR_OUT_OF_MEMORY;
355398 }
356399
357 // Reset tracking
358 _tracked_objects.clear();
400 // Clear only Godot-origin tracked objects (preserve Blender-origin ones)
401 _tracked_objects.erase(
402 std::remove_if(_tracked_objects.begin(), _tracked_objects.end(),
403 [](const TrackedObject &t) { return !t.from_blender; }),
404 _tracked_objects.end());
359405 _next_object_id = 1;
406 // Ensure IDs don't collide with Blender-origin objects
407 for (const auto &t : _tracked_objects) {
408 if (t.object_id >= _next_object_id) {
409 _next_object_id = t.object_id + 1;
410 }
411 }
360412 _export_sequence++;
413 _export_root_id = root->get_instance_id();
414 _last_export_node_count = (int)nodes.size();
361415
362 // Blob data starts after header + object table
416 // Blob data starts after header + object table — within region 0
363417 uint32_t blob_start = (uint32_t)data_blob_offset(_max_objects);
364418 uint32_t blob_offset = blob_start;
365 uint8_t *base = static_cast<uint8_t *>(_shm.ptr());
419 uint8_t *base = static_cast<uint8_t *>(_write_base);
366420
367421 // Export each node
368422 for (size_t i = 0; i < nodes.size(); i++) {
369423 _export_node(nodes[i], (uint32_t)i, base, blob_offset);
370424 }
371425
372 // Update header
373 BridgeHeader hdr = read_header(_shm.ptr());
374 hdr.direction = 0; // godot→blender
426 // Populate _known_instance_ids for incremental change detection
427 _known_instance_ids.clear();
428 for (const auto &t : _tracked_objects) {
429 _known_instance_ids.insert(t.godot_instance_id);
430 }
431
432 // Update header in region 0 (Godot→Blender)
433 BridgeHeader hdr = read_header(_write_base);
434 hdr.direction = 0; // godot→blender (unused in split-region, kept for compat)
375435 hdr.sequence = _export_sequence;
376436 hdr.object_count = (uint32_t)nodes.size();
377437 hdr.frame_number++;
378 write_header(_shm.ptr(), hdr);
379
380 // Cooldown: ignore Blender's depsgraph echo for 500ms
381 _import_cooldown = 0.5;
438 write_header(_write_base, hdr);
382439
383440 // Signal Blender via UDS
384441 if (_uds.client_count() > 0) {
……@@ -398,7 +455,7 @@ Dictionary BlenderBridge::get_export_info() const {
398455 return info;
399456 }
400457
401 BridgeHeader hdr = read_header(_shm.ptr());
458 BridgeHeader hdr = read_header(_write_base);
402459 info["object_count"] = (int)hdr.object_count;
403460 info["sequence"] = (int)hdr.sequence;
404461 info["direction"] = (int)hdr.direction;
……@@ -419,30 +476,25 @@ Dictionary BlenderBridge::get_export_info() const {
419476// ── Scene import (shared memory → Godot) ──────────────────────────────────────
420477
421478void BlenderBridge::import_changes(Node *root) {
422 if (!_shm.valid())
423 return;
424
425 // Skip during cooldown — prevents feedback loop from Blender's
426 // depsgraph echo after we just exported
427 if (_import_cooldown > 0.0)
479 if (!_shm.valid() || !_read_base)
428480 return;
429481
430 BridgeHeader hdr = read_header(_shm.ptr());
431
432 // Only process blender→godot updates
433 if (hdr.direction != 1)
434 return;
482 // Read from region 1 (Blender→Godot) — no direction check needed
483 BridgeHeader hdr = read_header(_read_base);
435484
436485 // Only process if new data
437486 if (hdr.sequence <= _last_import_sequence)
438487 return;
439488
440489 _last_import_sequence = hdr.sequence;
441 uint8_t *base = static_cast<uint8_t *>(_shm.ptr());
490 uint8_t *base = static_cast<uint8_t *>(_read_base);
442491 int imported = 0;
443492
493 UtilityFunctions::print("BlenderBridge: import_changes seq=", (int)hdr.sequence,
494 " count=", (int)hdr.object_count, " tracked=", (int)_tracked_objects.size());
495
444496 for (uint32_t i = 0; i < hdr.object_count; i++) {
445 ObjectEntry entry = read_object_entry(_shm.ptr(), i);
497 ObjectEntry entry = read_object_entry(_read_base, i);
446498
447499 // Find matching tracked object
448500 Node3D *target = nullptr;
……@@ -454,41 +506,106 @@ void BlenderBridge::import_changes(Node *root) {
454506 }
455507 }
456508
457 if (!target)
458 continue;
509 if (!target) {
510 // Unknown object — Blender created it. Create a new node in Godot.
511 UtilityFunctions::print("BlenderBridge: NEW obj id=", (int)entry.object_id,
512 " type=", (int)entry.object_type, " update=", (int)entry.update_type,
513 " size=", (int)entry.data_size);
514 if (entry.update_type == UPDATE_DELETE)
515 continue;
516
517 Node *export_root = Object::cast_to<Node>(ObjectDB::get_instance(_export_root_id));
518 if (!export_root)
519 export_root = root;
520
521 Node3D *new_node = nullptr;
522 if (entry.object_type == OBJ_MESH) {
523 MeshInstance3D *mi = memnew(MeshInstance3D);
524 Ref<ArrayMesh> mesh;
525 mesh.instantiate();
526 mi->set_mesh(mesh);
527 new_node = mi;
528 } else if (entry.object_type == OBJ_CAMERA) {
529 new_node = memnew(Camera3D);
530 } else if (entry.object_type == OBJ_LIGHT) {
531 if (entry.sub_type == LIGHT_SUN) {
532 new_node = memnew(DirectionalLight3D);
533 } else if (entry.sub_type == LIGHT_SPOT) {
534 new_node = memnew(SpotLight3D);
535 } else {
536 new_node = memnew(OmniLight3D);
537 }
538 } else {
539 new_node = memnew(Node3D);
540 }
541
542 String name = String("blender_obj_") + String::num_int64(entry.object_id);
543 new_node->set_name(name);
544 export_root->add_child(new_node);
545 new_node->set_owner(export_root);
546
547 TrackedObject tracked;
548 tracked.object_id = entry.object_id;
549 tracked.godot_instance_id = new_node->get_instance_id();
550 tracked.name = name;
551 tracked.type = (ObjectType)entry.object_type;
552 tracked.transform_hash = 0;
553 tracked.from_blender = true;
554 _tracked_objects.push_back(tracked);
555 _known_instance_ids.insert(new_node->get_instance_id());
556 _last_export_node_count++;
557
558 target = new_node;
559 }
459560
460561 if (entry.update_type == UPDATE_TRANSFORM || entry.update_type == UPDATE_FULL) {
461562 // Read transform from blob
462563 float xform_data[16];
463564 memcpy(xform_data, base + entry.data_offset, TRANSFORM_SIZE);
464565
465 // Convert Blender Z-up back to Godot Y-up
466 // Reverse of _export_transform (translation in column 3: positions 3,7,11)
566 // Reverse of _export_transform: M^-1 * B' * M
567 // where B' is the Blender-space 4x4 from SHM
467568 Transform3D xform;
468569 Basis &b = xform.basis;
469570 Vector3 &o = xform.origin;
470571
471 // Export: out[0]=b00, out[1]=-b20, out[2]=b10
472572 b[0][0] = xform_data[0];
473 b[1][0] = xform_data[2];
474 b[2][0] = -xform_data[1];
573 b[0][1] = xform_data[2];
574 b[0][2] = -xform_data[1];
475575
476 // Export: out[4]=-b02, out[5]=b22, out[6]=-b12
477 b[0][2] = -xform_data[4];
478 b[1][2] = -xform_data[6];
479 b[2][2] = xform_data[5];
480
481 // Export: out[8]=b01, out[9]=-b21, out[10]=b11
482 b[0][1] = xform_data[8];
576 b[1][0] = xform_data[8];
483577 b[1][1] = xform_data[10];
484 b[2][1] = -xform_data[9];
578 b[1][2] = -xform_data[9];
579
580 b[2][0] = -xform_data[4];
581 b[2][1] = -xform_data[6];
582 b[2][2] = xform_data[5];
485583
486 // Export: out[3]=o.x, out[7]=-o.z, out[11]=o.y
487584 o.x = xform_data[3];
488585 o.y = xform_data[11];
489586 o.z = -xform_data[7];
490587
588 // Camera/directional/spot lights: post-multiply by R_x(-90°)
589 // to undo the look-direction compensation applied during export.
590 bool needs_look_compensation = (entry.object_type == OBJ_CAMERA) ||
591 (entry.object_type == OBJ_LIGHT && entry.sub_type != LIGHT_POINT);
592 if (needs_look_compensation) {
593 // R_x(-90°): new col1 = -old col2, new col2 = old col1
594 real_t old_col1[3] = {b[0][1], b[1][1], b[2][1]};
595 real_t old_col2[3] = {b[0][2], b[1][2], b[2][2]};
596 b[0][1] = -old_col2[0]; b[1][1] = -old_col2[1]; b[2][1] = -old_col2[2];
597 b[0][2] = old_col1[0]; b[1][2] = old_col1[1]; b[2][2] = old_col1[2];
598 }
599
491600 target->set_global_transform(xform);
601
602 // Update tracked hash so _detect_and_push_changes won't echo this back
603 for (auto &tracked : _tracked_objects) {
604 if (tracked.object_id == entry.object_id) {
605 tracked.transform_hash = _hash_transform(xform);
606 break;
607 }
608 }
492609 }
493610
494611 if (entry.update_type == UPDATE_VERTEX || entry.update_type == UPDATE_FULL) {
……@@ -545,29 +662,75 @@ void BlenderBridge::import_changes(Node *root) {
545662 memcpy(indices.ptrw(), base + cursor, idx_count * 4);
546663 }
547664
548 // Rebuild mesh surface — convert to ArrayMesh if needed
549665 Ref<ArrayMesh> arr_mesh = Object::cast_to<ArrayMesh>(mesh_inst->get_mesh().ptr());
550666 if (!arr_mesh.is_valid()) {
551667 arr_mesh.instantiate();
552668 mesh_inst->set_mesh(arr_mesh);
553669 }
554670
555 Array arrays;
556 arrays.resize(Mesh::ARRAY_MAX);
557 arrays[Mesh::ARRAY_VERTEX] = positions;
558 if (normals.size() > 0) {
559 arrays[Mesh::ARRAY_NORMAL] = normals;
560 }
561 if (indices.size() > 0) {
562 arrays[Mesh::ARRAY_INDEX] = indices;
671 // Fast path: same-topology vertex update via surface_update_vertex_region
672 bool same_topology = false;
673 if (entry.update_type == UPDATE_VERTEX && arr_mesh->get_surface_count() > 0) {
674 Array existing = arr_mesh->surface_get_arrays(0);
675 if (existing.size() > Mesh::ARRAY_VERTEX) {
676 PackedVector3Array existing_pos = existing[Mesh::ARRAY_VERTEX];
677 if ((uint32_t)existing_pos.size() == vert_count) {
678 same_topology = true;
679 }
680 }
563681 }
564682
565 arr_mesh->clear_surfaces();
566 arr_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
683 if (same_topology) {
684 // Build vertex buffer in the format expected by surface_update_vertex_region.
685 // For PRIMITIVE_TRIANGLES with ARRAY_VERTEX + ARRAY_NORMAL:
686 // interleaved: [pos.x, pos.y, pos.z, norm.x, norm.y, norm.z] per vertex
687 PackedByteArray vertex_data;
688 if (has_normals && normals.size() == (int)vert_count) {
689 vertex_data.resize(vert_count * 24); // 12 bytes pos + 12 bytes normal
690 uint8_t *vd = vertex_data.ptrw();
691 const Vector3 *p = positions.ptr();
692 const Vector3 *n = normals.ptr();
693 for (uint32_t v = 0; v < vert_count; v++) {
694 float vals[6] = {p[v].x, p[v].y, p[v].z, n[v].x, n[v].y, n[v].z};
695 memcpy(vd + v * 24, vals, 24);
696 }
697 } else {
698 vertex_data.resize(vert_count * 12);
699 uint8_t *vd = vertex_data.ptrw();
700 const Vector3 *p = positions.ptr();
701 for (uint32_t v = 0; v < vert_count; v++) {
702 float vals[3] = {p[v].x, p[v].y, p[v].z};
703 memcpy(vd + v * 12, vals, 12);
704 }
705 }
706 arr_mesh->surface_update_vertex_region(0, 0, vertex_data);
707 } else {
708 // Full rebuild: clear and re-add surface
709 Array arrays;
710 arrays.resize(Mesh::ARRAY_MAX);
711 arrays[Mesh::ARRAY_VERTEX] = positions;
712 if (normals.size() > 0) {
713 arrays[Mesh::ARRAY_NORMAL] = normals;
714 }
715 if (indices.size() > 0) {
716 arrays[Mesh::ARRAY_INDEX] = indices;
717 }
718
719 arr_mesh->clear_surfaces();
720 arr_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
721 }
567722 }
568723 }
569724
570725 if (entry.update_type == UPDATE_DELETE) {
726 // Remove from tracking before freeing
727 uint64_t del_id = target->get_instance_id();
728 _known_instance_ids.erase(del_id);
729 _tracked_objects.erase(
730 std::remove_if(_tracked_objects.begin(), _tracked_objects.end(),
731 [&](const TrackedObject &t) { return t.object_id == entry.object_id; }),
732 _tracked_objects.end());
733 _last_export_node_count--;
571734 target->queue_free();
572735 }
573736
……@@ -594,11 +757,6 @@ void BlenderBridge::_process(double delta) {
594757 _handle_uds_message(msg);
595758 }
596759
597 // Tick down import cooldown
598 if (_import_cooldown > 0.0) {
599 _import_cooldown -= delta;
600 }
601
602760 // Auto-sync: check for incoming changes and push outgoing changes
603761 if (_auto_sync && _shm.valid()) {
604762 Node *root = get_tree() ? get_tree()->get_root() : nullptr;
……@@ -695,16 +853,180 @@ uint64_t BlenderBridge::_hash_transform(const Transform3D &xform) {
695853}
696854
697855void BlenderBridge::_detect_and_push_changes() {
698 if (!_shm.valid() || _tracked_objects.empty())
856 if (!_shm.valid() || !_write_base)
857 return;
858
859 Object *root_obj = ObjectDB::get_instance(_export_root_id);
860 Node *export_root = Object::cast_to<Node>(root_obj);
861 if (!export_root)
699862 return;
700863
701 uint8_t *base = static_cast<uint8_t *>(_shm.ptr());
864 uint8_t *base = static_cast<uint8_t *>(_write_base);
702865 uint32_t blob_start = (uint32_t)data_blob_offset(_max_objects);
703866 uint32_t blob_offset = blob_start;
704867 uint32_t changed_count = 0;
705868
706 for (size_t i = 0; i < _tracked_objects.size(); i++) {
707 auto &tracked = _tracked_objects[i];
869 // ── Detect new/removed Godot nodes (incremental, no full re-export) ──
870
871 std::vector<Node3D *> current_nodes;
872 _collect_exportable_nodes(export_root, current_nodes);
873 std::set<uint64_t> current_ids;
874 for (auto *n : current_nodes) {
875 current_ids.insert(n->get_instance_id());
876 }
877
878 // New nodes: in scene but not tracked
879 for (auto *node : current_nodes) {
880 uint64_t nid = node->get_instance_id();
881 if (_known_instance_ids.count(nid) > 0)
882 continue;
883
884 // New Godot node — export as UPDATE_FULL
885 uint32_t obj_id = _next_object_id++;
886
887 ObjectEntry entry = {};
888 entry.object_id = obj_id;
889 entry.update_type = UPDATE_FULL;
890 entry.data_offset = blob_offset;
891 entry.sub_type = 0;
892
893 MeshInstance3D *mesh_inst = Object::cast_to<MeshInstance3D>(node);
894 Camera3D *camera = Object::cast_to<Camera3D>(node);
895 Light3D *light = Object::cast_to<Light3D>(node);
896 bool needs_look_compensation = false;
897
898 if (mesh_inst) {
899 entry.object_type = OBJ_MESH;
900 } else if (camera) {
901 entry.object_type = OBJ_CAMERA;
902 needs_look_compensation = true;
903 } else if (light) {
904 entry.object_type = OBJ_LIGHT;
905 if (Object::cast_to<DirectionalLight3D>(node)) {
906 entry.sub_type = LIGHT_SUN;
907 needs_look_compensation = true;
908 } else if (Object::cast_to<SpotLight3D>(node)) {
909 entry.sub_type = LIGHT_SPOT;
910 needs_look_compensation = true;
911 } else {
912 entry.sub_type = LIGHT_POINT;
913 }
914 } else {
915 entry.object_type = OBJ_EMPTY;
916 }
917
918 // Write transform
919 float xform_data[16];
920 _export_transform(node->get_global_transform(), xform_data);
921 if (needs_look_compensation) {
922 float old1[3] = {xform_data[1], xform_data[5], xform_data[9]};
923 float old2[3] = {xform_data[2], xform_data[6], xform_data[10]};
924 xform_data[1] = old2[0]; xform_data[5] = old2[1]; xform_data[9] = old2[2];
925 xform_data[2] = -old1[0]; xform_data[6] = -old1[1]; xform_data[10] = -old1[2];
926 }
927 memcpy(base + blob_offset, xform_data, TRANSFORM_SIZE);
928 blob_offset += TRANSFORM_SIZE;
929
930 // Write vertex data for meshes
931 if (mesh_inst && mesh_inst->get_mesh().is_valid()) {
932 Ref<Mesh> mesh = mesh_inst->get_mesh();
933 if (mesh->get_surface_count() > 0) {
934 Array arrays = mesh->surface_get_arrays(0);
935 if (arrays.size() > Mesh::ARRAY_VERTEX) {
936 PackedVector3Array positions = arrays[Mesh::ARRAY_VERTEX];
937 uint32_t vert_count = positions.size();
938 memcpy(base + blob_offset, &vert_count, 4);
939 blob_offset += 4;
940
941 const Vector3 *pos_r = positions.ptr();
942 for (uint32_t v = 0; v < vert_count; v++) {
943 float in[3] = { pos_r[v].x, pos_r[v].y, pos_r[v].z };
944 float out[3];
945 _godot_to_blender_vec3(in, out);
946 memcpy(base + blob_offset, out, 12);
947 blob_offset += 12;
948 }
949
950 bool has_normals = arrays.size() > Mesh::ARRAY_NORMAL && arrays[Mesh::ARRAY_NORMAL].get_type() != Variant::NIL;
951 uint32_t normals_flag = has_normals ? 1 : 0;
952 memcpy(base + blob_offset, &normals_flag, 4);
953 blob_offset += 4;
954
955 if (has_normals) {
956 PackedVector3Array normals = arrays[Mesh::ARRAY_NORMAL];
957 const Vector3 *norm_r = normals.ptr();
958 for (uint32_t v = 0; v < vert_count; v++) {
959 float in[3] = { norm_r[v].x, norm_r[v].y, norm_r[v].z };
960 float out[3];
961 _godot_to_blender_vec3(in, out);
962 memcpy(base + blob_offset, out, 12);
963 blob_offset += 12;
964 }
965 }
966
967 bool has_indices = arrays.size() > Mesh::ARRAY_INDEX && arrays[Mesh::ARRAY_INDEX].get_type() != Variant::NIL;
968 uint32_t idx_count = 0;
969 if (has_indices) {
970 PackedInt32Array indices = arrays[Mesh::ARRAY_INDEX];
971 idx_count = indices.size();
972 }
973 memcpy(base + blob_offset, &idx_count, 4);
974 blob_offset += 4;
975
976 if (idx_count > 0) {
977 PackedInt32Array indices = arrays[Mesh::ARRAY_INDEX];
978 const int32_t *idx_r = indices.ptr();
979 memcpy(base + blob_offset, idx_r, idx_count * 4);
980 blob_offset += idx_count * 4;
981 }
982 }
983 }
984 }
985
986 entry.data_size = blob_offset - entry.data_offset;
987 entry.sequence = (uint32_t)(_export_sequence + 1);
988 write_object_entry(_write_base, changed_count, entry);
989 changed_count++;
990
991 // Track the new node
992 TrackedObject tracked;
993 tracked.object_id = obj_id;
994 tracked.godot_instance_id = nid;
995 tracked.name = node->get_name();
996 tracked.type = (ObjectType)entry.object_type;
997 tracked.transform_hash = _hash_transform(node->get_global_transform());
998 tracked.from_blender = false;
999 _tracked_objects.push_back(tracked);
1000 }
1001
1002 // Removed nodes: tracked but not in scene
1003 for (auto it = _tracked_objects.begin(); it != _tracked_objects.end(); ) {
1004 if (it->from_blender) {
1005 ++it;
1006 continue; // Don't delete Blender-origin objects from Godot side
1007 }
1008 if (current_ids.count(it->godot_instance_id) == 0) {
1009 // Node was removed from Godot — send DELETE
1010 ObjectEntry entry = {};
1011 entry.object_id = it->object_id;
1012 entry.object_type = it->type;
1013 entry.update_type = UPDATE_DELETE;
1014 entry.data_offset = 0;
1015 entry.data_size = 0;
1016 entry.sequence = (uint32_t)(_export_sequence + 1);
1017 write_object_entry(_write_base, changed_count, entry);
1018 changed_count++;
1019
1020 _known_instance_ids.erase(it->godot_instance_id);
1021 it = _tracked_objects.erase(it);
1022 } else {
1023 ++it;
1024 }
1025 }
1026
1027 // ── Detect transform changes on existing tracked objects ──
1028
1029 for (auto &tracked : _tracked_objects) {
7081030 Object *obj = ObjectDB::get_instance(tracked.godot_instance_id);
7091031 Node3D *node = Object::cast_to<Node3D>(obj);
7101032 if (!node)
……@@ -714,7 +1036,7 @@ void BlenderBridge::_detect_and_push_changes() {
7141036 if (new_hash == tracked.transform_hash)
7151037 continue;
7161038
717 // Transform changed — write delta to shm
1039 // Transform changed — write delta to region 0
7181040 tracked.transform_hash = new_hash;
7191041
7201042 ObjectEntry entry = {};
……@@ -726,30 +1048,43 @@ void BlenderBridge::_detect_and_push_changes() {
7261048
7271049 float xform_data[16];
7281050 _export_transform(node->get_global_transform(), xform_data);
1051
1052 // Camera/directional/spot lights: apply look-direction compensation
1053 bool needs_look_compensation = (tracked.type == OBJ_CAMERA) ||
1054 (tracked.type == OBJ_LIGHT && Object::cast_to<DirectionalLight3D>(node)) ||
1055 (tracked.type == OBJ_LIGHT && Object::cast_to<SpotLight3D>(node));
1056 if (needs_look_compensation) {
1057 float old1[3] = {xform_data[1], xform_data[5], xform_data[9]};
1058 float old2[3] = {xform_data[2], xform_data[6], xform_data[10]};
1059 xform_data[1] = old2[0]; xform_data[5] = old2[1]; xform_data[9] = old2[2];
1060 xform_data[2] = -old1[0]; xform_data[6] = -old1[1]; xform_data[10] = -old1[2];
1061 }
1062
7291063 memcpy(base + blob_offset, xform_data, TRANSFORM_SIZE);
7301064 blob_offset += TRANSFORM_SIZE;
7311065 entry.data_size = TRANSFORM_SIZE;
7321066
733 write_object_entry(_shm.ptr(), (uint32_t)changed_count, entry);
1067 write_object_entry(_write_base, changed_count, entry);
7341068 changed_count++;
7351069 }
7361070
1071 // Update _known_instance_ids
1072 _known_instance_ids = current_ids;
1073 _last_export_node_count = (int)current_nodes.size();
1074
7371075 if (changed_count > 0) {
7381076 _export_sequence++;
7391077
740 BridgeHeader hdr = read_header(_shm.ptr());
741 hdr.direction = 0; // godot→blender
1078 BridgeHeader hdr = read_header(_write_base);
1079 hdr.direction = 0;
7421080 hdr.sequence = _export_sequence;
7431081 hdr.object_count = changed_count;
7441082 hdr.frame_number++;
745 write_header(_shm.ptr(), hdr);
746
747 // Cooldown: ignore Blender's depsgraph echo for 200ms
748 _import_cooldown = 0.2;
1083 write_header(_write_base, hdr);
7491084
7501085 // Signal Blender
7511086 if (_uds.client_count() > 0) {
752 const char *msg = "transform_update";
1087 const char *msg = "updated";
7531088 _uds.broadcast(msg, (uint32_t)strlen(msg));
7541089 }
7551090 }
src/blender_bridge.h +17 -6
……@@ -13,6 +13,8 @@
1313#include <godot_cpp/variant/transform3d.hpp>
1414#include <godot_cpp/variant/typed_array.hpp>
1515
16#include <set>
17
1618#include "shm_protocol.h"
1719#include "uds_server.h"
1820
……@@ -73,7 +75,7 @@ private:
7375 ShmRegion _shm;
7476 UdsServer _uds;
7577 String _shm_name = "/blender_bridge";
76 int _shm_size = 16 * 1024 * 1024; // 16 MB default
78 int _shm_size = 24 * 1024 * 1024; // 24 MB (8 MB region 0 + 8 MB region 1 + 8 MB tile ring)
7779 int _max_objects = 256;
7880 bool _auto_sync = false;
7981 bool _server_running = false;
……@@ -82,15 +84,17 @@ private:
8284 // ── UDS message handling ────────────────────────────────────────────
8385 void _handle_uds_message(const UdsMessage &msg);
8486
87 // ── Split-region SHM pointers ───────────────────────────────────────
88 // Region 0: Godot→Blender (Godot writes, Blender reads)
89 // Region 1: Blender→Godot (Blender writes, Godot reads)
90 void *_write_base = nullptr; // Region 0 base
91 void *_read_base = nullptr; // Region 1 base
92 size_t _region_size = 0;
93
8594 // ── Sequence tracking ───────────────────────────────────────────────
8695 uint64_t _export_sequence = 0;
8796 uint64_t _last_import_sequence = 0;
8897
89 // ── Feedback loop prevention ────────────────────────────────────────
90 // After export, ignore incoming changes for a brief period to prevent
91 // Blender's depsgraph echo from corrupting the Godot scene.
92 double _import_cooldown = 0.0;
93
9498 // ── Object ID mapping ───────────────────────────────────────────────
9599 struct TrackedObject {
96100 uint32_t object_id;
……@@ -99,10 +103,17 @@ private:
99103 ObjectType type;
100104 // Change detection: cached transform hash
101105 uint64_t transform_hash = 0;
106 // Origin tracking: true if created by Blender import
107 bool from_blender = false;
102108 };
103109 std::vector<TrackedObject> _tracked_objects;
104110 uint32_t _next_object_id = 1;
105111
112 // ── Scene structure tracking ────────────────────────────────────────
113 ObjectID _export_root_id;
114 int _last_export_node_count = 0;
115 std::set<uint64_t> _known_instance_ids;
116
106117 // ── Export helpers ───────────────────────────────────────────────────
107118 void _collect_exportable_nodes(Node *node, std::vector<Node3D *> &out);
108119 uint32_t _export_node(Node3D *node, uint32_t index, uint8_t *blob_base, uint32_t &blob_offset);
src/shm_protocol.h +22 -1
……@@ -31,6 +31,13 @@ enum ObjectType : uint8_t {
3131 OBJ_EMPTY = 3,
3232};
3333
34// ── Light subtypes ─────────────────────────────────────────────────────────
35enum LightSubType : uint8_t {
36 LIGHT_POINT = 0,
37 LIGHT_SUN = 1, // DirectionalLight3D ↔ Blender "SUN"
38 LIGHT_SPOT = 2,
39};
40
3441// ── Update types ───────────────────────────────────────────────────────────
3542enum UpdateType : uint8_t {
3643 UPDATE_TRANSFORM = 0,
……@@ -45,7 +52,8 @@ struct ObjectEntry {
4552 uint32_t object_id;
4653 uint8_t object_type;
4754 uint8_t update_type;
48 uint8_t _pad[2];
55 uint8_t sub_type; // light subtype (LightSubType) or 0
56 uint8_t _pad;
4957 uint32_t data_offset;
5058 uint32_t data_size;
5159 uint32_t sequence;
……@@ -85,6 +93,19 @@ inline size_t data_blob_offset(uint32_t max_objects) {
8593 return OBJECT_TABLE_OFFSET + max_objects * sizeof(ObjectEntry);
8694}
8795
96// ── Split-region helpers ──────────────────────────────────────────────────
97// SHM layout (3 zones):
98// Region 0 [0, REGION_SIZE): Godot→Blender
99// Region 1 [REGION_SIZE, 2*REGION_SIZE): Blender→Godot
100// Tile ring [2*REGION_SIZE, SHM_SIZE): Texture streaming
101// Each region has its own header, object table, and data blob area.
102
103constexpr size_t REGION_SIZE = 8 * 1024 * 1024; // 8 MB per region
104
105inline void *region_base(void *shm, int region) {
106 return static_cast<uint8_t *>(shm) + region * REGION_SIZE;
107}
108
88109// ── POSIX shared memory wrapper ────────────────────────────────────────────
89110class ShmRegion {
90111public: