| … | … | @@ -11,6 +11,19 @@ Usage: |
| 11 | 11 | |
| 12 | 12 | import bpy |
| 13 | 13 | |
| 14 | ADDON_VERSION = "0.3.0" | |
| 15 | ||
| 16 | # Support hot-reload (F3 > Reload Scripts) | |
| 17 | if "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 | ||
| 26 | from . import change_detector, connection, scene_import, shm_protocol, tile_baker | |
| 14 | 27 | from .change_detector import ChangeDetector |
| 15 | 28 | from .connection import BridgeConnection |
| 16 | 29 | from .scene_import import SceneImporter |
| … | … | @@ -27,7 +40,7 @@ _baker: TileBaker | None = None |
| 27 | 40 | _timer_running = False |
| 28 | 41 | |
| 29 | 42 | SHM_NAME = "/blender_bridge" |
| 30 | SHM_SIZE = 16 * 1024 * 1024 # 16 MB | |
| 43 | SHM_SIZE = 24 * 1024 * 1024 # 24 MB (8 MB region 0 + 8 MB region 1 + 8 MB tile ring) | |
| 31 | 44 | MAX_OBJECTS = 256 |
| 32 | 45 | SOCKET_PATH = "/tmp/blender_bridge.sock" |
| 33 | 46 | POLL_INTERVAL = 0.05 # 50ms = 20 Hz polling |
| … | … | @@ -48,28 +61,16 @@ def _poll_timer(): |
| 48 | 61 | for msg in messages: |
| 49 | 62 | text = msg.decode("utf-8", errors="replace") |
| 50 | 63 | if text == "scene_exported": |
| 51 | # Full scene import from Godot — suppress change detector | |
| 64 | # Full scene sync from Godot | |
| 52 | 65 | 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() | |
| 60 | 66 | _importer.full_import() |
| 61 | 67 | if _detector is not None: |
| 62 | 68 | _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) | |
| 67 | 72 | if _importer is not None: |
| 68 | if _detector is not None: | |
| 69 | _detector.suppress() | |
| 70 | 73 | count = _importer.poll_updates() |
| 71 | if _detector is not None: | |
| 72 | _detector.unsuppress() | |
| 73 | 74 | elif text.startswith("texture_registered:"): |
| 74 | 75 | # Godot registered a texture: texture_registered:name,id |
| 75 | 76 | parts = text.split(":", 1)[1].split(",") |
| … | … | @@ -85,9 +86,98 @@ def _poll_timer(): |
| 85 | 86 | return POLL_INTERVAL # Continue polling |
| 86 | 87 | |
| 87 | 88 | |
| 89 | # ── Addon Preferences ─────────────────────────────────────────────────────── | |
| 90 | ||
| 91 | ||
| 92 | class 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 | ||
| 88 | 108 | # ── Operators ──────────────────────────────────────────────────────────────── |
| 89 | 109 | |
| 90 | 110 | |
| 111 | class 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 | ||
| 91 | 181 | class BRIDGE_OT_connect(bpy.types.Operator): |
| 92 | 182 | """Connect to Godot BlenderBridge server""" |
| 93 | 183 | |
| … | … | @@ -116,9 +206,12 @@ class BRIDGE_OT_connect(bpy.types.Operator): |
| 116 | 206 | _detector = ChangeDetector(_shm, MAX_OBJECTS) |
| 117 | 207 | _detector.register() |
| 118 | 208 | |
| 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 | |
| 120 | 213 | _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) | |
| 122 | 215 | |
| 123 | 216 | # Start polling timer |
| 124 | 217 | if not _timer_running: |
| … | … | @@ -160,55 +253,28 @@ class BRIDGE_OT_disconnect(bpy.types.Operator): |
| 160 | 253 | |
| 161 | 254 | |
| 162 | 255 | class BRIDGE_OT_import_scene(bpy.types.Operator): |
| 163 | """Manually import the current Godot scene""" | |
| 256 | """Sync Blender scene to match Godot scene""" | |
| 164 | 257 | |
| 165 | 258 | bl_idname = "bridge.import_scene" |
| 166 | bl_label = "Import Scene" | |
| 259 | bl_label = "Sync Scene" | |
| 167 | 260 | |
| 168 | 261 | def execute(self, context): |
| 169 | 262 | if _importer is None: |
| 170 | 263 | self.report({"ERROR"}, "Not connected") |
| 171 | 264 | return {"CANCELLED"} |
| 172 | 265 | |
| 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 | ||
| 181 | 266 | _importer.full_import() |
| 182 | 267 | if _detector is not None: |
| 183 | 268 | _detector.set_id_map(_importer._id_to_name) |
| 184 | _detector.unsuppress() | |
| 185 | 269 | |
| 186 | header = _shm.read_header() | |
| 270 | header = _shm.read_header(region=0) | |
| 187 | 271 | self.report( |
| 188 | 272 | {"INFO"}, |
| 189 | f"Imported {header['object_count']} objects from Godot", | |
| 273 | f"Synced {header['object_count']} objects from Godot", | |
| 190 | 274 | ) |
| 191 | 275 | return {"FINISHED"} |
| 192 | 276 | |
| 193 | 277 | |
| 194 | def _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 | ||
| 212 | 278 | |
| 213 | 279 | # ── UI Panel ───────────────────────────────────────────────────────────────── |
| 214 | 280 | |
| … | … | @@ -232,23 +298,28 @@ class BRIDGE_PT_panel(bpy.types.Panel): |
| 232 | 298 | else: |
| 233 | 299 | layout.operator("bridge.disconnect", icon="UNLINKED") |
| 234 | 300 | 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") | |
| 237 | 302 | |
| 238 | 303 | # Status info |
| 239 | 304 | box = layout.box() |
| 240 | 305 | box.label(text="Status: Connected", icon="CHECKMARK") |
| 241 | 306 | 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}") | |
| 247 | 316 | |
| 248 | 317 | |
| 249 | 318 | # ── Registration ───────────────────────────────────────────────────────────── |
| 250 | 319 | |
| 251 | 320 | _classes = ( |
| 321 | BridgeAddonPreferences, | |
| 322 | BRIDGE_OT_update_addon, | |
| 252 | 323 | BRIDGE_OT_connect, |
| 253 | 324 | BRIDGE_OT_disconnect, |
| 254 | 325 | BRIDGE_OT_import_scene, |
| … | … | @@ -259,11 +330,6 @@ _classes = ( |
| 259 | 330 | def register(): |
| 260 | 331 | for cls in _classes: |
| 261 | 332 | 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 | ) | |
| 267 | 333 | |
| 268 | 334 | |
| 269 | 335 | def unregister(): |
| … | … | @@ -283,8 +349,6 @@ def unregister(): |
| 283 | 349 | _shm = None |
| 284 | 350 | _timer_running = False |
| 285 | 351 | |
| 286 | del bpy.types.Scene.bridge_clear_on_import | |
| 287 | ||
| 288 | 352 | for cls in reversed(_classes): |
| 289 | 353 | bpy.utils.unregister_class(cls) |
| 290 | 354 |
| … | … | @@ -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.1.0" | |
| 5 | version = "0.3.0" | |
| 6 | 6 | blender_version_min = "4.2.0" |
| 7 | 7 | type = "add-on" |
| 8 | 8 | maintainer = "blender-bridge <noreply@localhost>" |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | """Detect Blender scene changes and export to shared memory. |
| 2 | 2 | |
| 3 | 3 | Uses depsgraph_update_post handler to detect transform and geometry changes, |
| 4 | then writes them to SHM with direction=1 (blender→godot). | |
| 4 | then writes them to SHM region 1 (Blender→Godot). | |
| 5 | 5 | """ |
| 6 | 6 | |
| 7 | 7 | import struct |
| … | … | @@ -9,6 +9,9 @@ import struct |
| 9 | 9 | import numpy as np |
| 10 | 10 | |
| 11 | 11 | from .shm_protocol import ( |
| 12 | LIGHT_POINT, | |
| 13 | LIGHT_SPOT, | |
| 14 | LIGHT_SUN, | |
| 12 | 15 | OBJ_CAMERA, |
| 13 | 16 | OBJ_EMPTY, |
| 14 | 17 | OBJ_LIGHT, |
| … | … | @@ -41,8 +44,20 @@ def _blender_type_to_obj_type(obj) -> int: |
| 41 | 44 | return OBJ_EMPTY |
| 42 | 45 | |
| 43 | 46 | |
| 47 | def _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 | ||
| 44 | 59 | class ChangeDetector: |
| 45 | """Detects Blender scene changes and exports to SHM.""" | |
| 60 | """Detects Blender scene changes and exports to SHM region 1.""" | |
| 46 | 61 | |
| 47 | 62 | def __init__(self, shm: ShmRegion, max_objects: int = 256): |
| 48 | 63 | self._shm = shm |
| … | … | @@ -52,10 +67,6 @@ class ChangeDetector: |
| 52 | 67 | self._name_to_id: dict[str, int] = {} |
| 53 | 68 | self._next_id = 1 |
| 54 | 69 | 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 | |
| 59 | 70 | |
| 60 | 71 | def register(self): |
| 61 | 72 | """Register the depsgraph handler.""" |
| … | … | @@ -89,34 +100,8 @@ class ChangeDetector: |
| 89 | 100 | |
| 90 | 101 | # ── Depsgraph handler ──────────────────────────────────────────────── |
| 91 | 102 | |
| 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 | ||
| 106 | 103 | def _on_depsgraph_update(self, scene, depsgraph): |
| 107 | 104 | """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 | ||
| 120 | 105 | dirty = [] |
| 121 | 106 | |
| 122 | 107 | for update in depsgraph.updates: |
| … | … | @@ -130,18 +115,24 @@ class ChangeDetector: |
| 130 | 115 | dirty.append(("geometry", obj)) |
| 131 | 116 | |
| 132 | 117 | if dirty: |
| 133 | self._export_changes(dirty) | |
| 118 | self._export_changes(dirty, depsgraph) | |
| 134 | 119 | |
| 135 | 120 | # ── Export changes to SHM ──────────────────────────────────────────── |
| 136 | 121 | |
| 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.""" | |
| 139 | 124 | if not self._shm.valid: |
| 140 | 125 | return |
| 141 | 126 | |
| 142 | 127 | 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 | |
| 145 | 136 | |
| 146 | 137 | # Deduplicate: if both transform and geometry changed, use FULL |
| 147 | 138 | obj_updates: dict[str, tuple[int, object]] = {} |
| … | … | @@ -161,77 +152,113 @@ class ChangeDetector: |
| 161 | 152 | obj_updates[name] = (UPDATE_FULL, obj) |
| 162 | 153 | |
| 163 | 154 | entry_index = 0 |
| 155 | written_updates: dict[str, int] = {} # name → actual update_type written | |
| 164 | 156 | for name, (update_type, obj) in obj_updates.items(): |
| 165 | 157 | if entry_index >= self._max_objects: |
| 166 | 158 | break |
| 167 | 159 | |
| 160 | is_new = name not in self._name_to_id | |
| 168 | 161 | 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 | |
| 169 | 166 | 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 | |
| 171 | 169 | |
| 172 | # Write transform (always) | |
| 170 | # Write transform (always) — use absolute offset for mmap write | |
| 173 | 171 | 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 | |
| 176 | 175 | |
| 177 | 176 | # Write vertex data for geometry updates |
| 178 | 177 | 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) | |
| 220 | 238 | self._shm.write_object_entry( |
| 221 | 239 | index=entry_index, |
| 222 | 240 | object_id=obj_id, |
| 223 | 241 | object_type=obj_type, |
| 224 | 242 | 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, | |
| 227 | 245 | sequence=self._sequence, |
| 246 | sub_type=sub_type, | |
| 247 | region=1, | |
| 228 | 248 | ) |
| 249 | written_updates[name] = update_type | |
| 229 | 250 | entry_index += 1 |
| 230 | 251 | |
| 231 | # Update header | |
| 252 | # Update header in region 1 | |
| 232 | 253 | if entry_index > 0: |
| 233 | 254 | self._shm.write_header( |
| 234 | direction=1, # blender→godot | |
| 255 | region=1, | |
| 256 | direction=1, # blender→godot (kept for compat) | |
| 235 | 257 | sequence=self._sequence, |
| 236 | 258 | object_count=entry_index, |
| 237 | 259 | ) |
| 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}]") |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | """Import Godot scene from shared memory into Blender. |
| 2 | 2 | |
| 3 | Reads the SHM region written by BlenderBridge::export_scene() and | |
| 4 | reconstructs objects in the current Blender scene. | |
| 3 | Reads SHM region 0 (Godot→Blender) written by BlenderBridge::export_scene() | |
| 4 | and reconstructs objects in the current Blender scene. | |
| 5 | 5 | |
| 6 | 6 | Coordinate conversion (already applied by C++ side): |
| 7 | 7 | Godot Y-up (x,y,z) → Blender Z-up (x,-z,y) — positions are pre-converted. |
| … | … | @@ -13,6 +13,9 @@ import struct |
| 13 | 13 | import numpy as np |
| 14 | 14 | |
| 15 | 15 | from .shm_protocol import ( |
| 16 | LIGHT_POINT, | |
| 17 | LIGHT_SPOT, | |
| 18 | LIGHT_SUN, | |
| 16 | 19 | OBJ_CAMERA, |
| 17 | 20 | OBJ_EMPTY, |
| 18 | 21 | OBJ_LIGHT, |
| … | … | @@ -35,7 +38,7 @@ except ImportError: |
| 35 | 38 | |
| 36 | 39 | |
| 37 | 40 | class SceneImporter: |
| 38 | """Imports scene data from SHM into Blender.""" | |
| 41 | """Imports scene data from SHM region 0 (Godot→Blender) into Blender.""" | |
| 39 | 42 | |
| 40 | 43 | def __init__(self, shm: ShmRegion, max_objects: int = 256): |
| 41 | 44 | self._shm = shm |
| … | … | @@ -45,24 +48,51 @@ class SceneImporter: |
| 45 | 48 | self._last_sequence = 0 |
| 46 | 49 | |
| 47 | 50 | 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) | |
| 50 | 57 | |
| 51 | 58 | if header["magic"] != b"BGOD": |
| 52 | 59 | raise ValueError(f"Bad magic: {header['magic']}") |
| 53 | 60 | |
| 54 | 61 | self._last_sequence = header["sequence"] |
| 55 | 62 | |
| 63 | # Track which object IDs appear in this import | |
| 64 | imported_ids = set() | |
| 65 | ||
| 56 | 66 | 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"]) | |
| 58 | 69 | self._import_object(entry) |
| 59 | 70 | |
| 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 | ||
| 60 | 91 | 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) | |
| 63 | 94 | |
| 64 | if header["direction"] != 0: | |
| 65 | return 0 | |
| 95 | # No direction check needed — region 0 is always Godot→Blender | |
| 66 | 96 | if header["sequence"] <= self._last_sequence: |
| 67 | 97 | return 0 |
| 68 | 98 | |
| … | … | @@ -70,7 +100,7 @@ class SceneImporter: |
| 70 | 100 | updated = 0 |
| 71 | 101 | |
| 72 | 102 | 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) | |
| 74 | 104 | self._apply_update(entry) |
| 75 | 105 | updated += 1 |
| 76 | 106 | |
| … | … | @@ -82,6 +112,23 @@ class SceneImporter: |
| 82 | 112 | obj_id = entry["object_id"] |
| 83 | 113 | obj_type = entry["object_type"] |
| 84 | 114 | |
| 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 | |
| 85 | 132 | if obj_type == OBJ_MESH: |
| 86 | 133 | obj = self._import_mesh(entry) |
| 87 | 134 | elif obj_type == OBJ_CAMERA: |
| … | … | @@ -168,9 +215,16 @@ class SceneImporter: |
| 168 | 215 | return obj |
| 169 | 216 | |
| 170 | 217 | 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" | |
| 172 | 226 | light = bpy.data.lights.new( |
| 173 | f"bridge_light_{entry['object_id']}", "POINT" | |
| 227 | f"bridge_light_{entry['object_id']}", bl_type | |
| 174 | 228 | ) |
| 175 | 229 | obj = bpy.data.objects.new(light.name, light) |
| 176 | 230 | bpy.context.collection.objects.link(obj) |
| … | … | @@ -30,12 +30,13 @@ assert struct.calcsize(HEADER_FMT) == HEADER_SIZE |
| 30 | 30 | # uint32_t object_id I |
| 31 | 31 | # uint8_t object_type B |
| 32 | 32 | # 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 | |
| 34 | 35 | # uint32_t data_offset I |
| 35 | 36 | # uint32_t data_size I |
| 36 | 37 | # uint32_t sequence I |
| 37 | 38 | # uint8_t _reserved[12] 12s |
| 38 | ENTRY_FMT = "<IBB2sIII12s" | |
| 39 | ENTRY_FMT = "<IBBBBIII12s" | |
| 39 | 40 | ENTRY_SIZE = 32 |
| 40 | 41 | assert struct.calcsize(ENTRY_FMT) == ENTRY_SIZE |
| 41 | 42 | |
| … | … | @@ -51,18 +52,36 @@ UPDATE_MATERIAL = 2 |
| 51 | 52 | UPDATE_FULL = 3 |
| 52 | 53 | UPDATE_DELETE = 4 |
| 53 | 54 | |
| 55 | # ── Light subtypes ────────────────────────────────────────────────────────── | |
| 56 | LIGHT_POINT = 0 | |
| 57 | LIGHT_SUN = 1 # DirectionalLight3D ↔ Blender "SUN" | |
| 58 | LIGHT_SPOT = 2 | |
| 59 | ||
| 54 | 60 | # ── Transform size ─────────────────────────────────────────────────────────── |
| 55 | 61 | TRANSFORM_SIZE = 64 # 4×4 float32 matrix |
| 56 | 62 | |
| 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 | |
| 68 | REGION_SIZE = 8 * 1024 * 1024 # 8 MB per region | |
| 69 | ||
| 57 | 70 | |
| 58 | 71 | class 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 | """ | |
| 60 | 78 | |
| 61 | 79 | def __init__(self): |
| 62 | 80 | self._mm = None |
| 63 | 81 | self._fd = -1 |
| 64 | 82 | self._name = None |
| 65 | 83 | self._size = 0 |
| 84 | self._region_size = REGION_SIZE # 8 MB per region (fixed) | |
| 66 | 85 | self._owner = False |
| 67 | 86 | |
| 68 | 87 | # ── Lifecycle ──────────────────────────────────────────────────────── |
| … | … | @@ -122,10 +141,17 @@ class ShmRegion: |
| 122 | 141 | def size(self) -> int: |
| 123 | 142 | return self._size |
| 124 | 143 | |
| 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 | ||
| 125 | 150 | # ── Header read/write ──────────────────────────────────────────────── |
| 126 | 151 | |
| 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] | |
| 129 | 155 | magic, ver, direction, _pad, seq, obj_count, _pad2, frame, _res = ( |
| 130 | 156 | struct.unpack(HEADER_FMT, data) |
| 131 | 157 | ) |
| … | … | @@ -140,6 +166,7 @@ class ShmRegion: |
| 140 | 166 | |
| 141 | 167 | def write_header( |
| 142 | 168 | self, |
| 169 | region: int = 0, | |
| 143 | 170 | magic: bytes = b"BGOD", |
| 144 | 171 | version: int = 1, |
| 145 | 172 | direction: int = 0, |
| … | … | @@ -147,6 +174,7 @@ class ShmRegion: |
| 147 | 174 | object_count: int = 0, |
| 148 | 175 | frame_number: int = 0, |
| 149 | 176 | ): |
| 177 | base = self.region_base(region) | |
| 150 | 178 | data = struct.pack( |
| 151 | 179 | HEADER_FMT, |
| 152 | 180 | magic, |
| … | … | @@ -159,20 +187,22 @@ class ShmRegion: |
| 159 | 187 | frame_number, |
| 160 | 188 | b"\x00" * 32, # _reserved |
| 161 | 189 | ) |
| 162 | self._mm[0:HEADER_SIZE] = data | |
| 190 | self._mm[base : base + HEADER_SIZE] = data | |
| 163 | 191 | |
| 164 | 192 | # ── Object entry read/write ────────────────────────────────────────── |
| 165 | 193 | |
| 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 | |
| 168 | 197 | 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 = ( | |
| 170 | 199 | struct.unpack(ENTRY_FMT, data) |
| 171 | 200 | ) |
| 172 | 201 | return { |
| 173 | 202 | "object_id": obj_id, |
| 174 | 203 | "object_type": obj_type, |
| 175 | 204 | "update_type": upd_type, |
| 205 | "sub_type": sub_type, | |
| 176 | 206 | "data_offset": data_off, |
| 177 | 207 | "data_size": data_sz, |
| 178 | 208 | "sequence": seq, |
| … | … | @@ -187,14 +217,18 @@ class ShmRegion: |
| 187 | 217 | data_offset: int, |
| 188 | 218 | data_size: int, |
| 189 | 219 | sequence: int, |
| 220 | sub_type: int = 0, | |
| 221 | region: int = 0, | |
| 190 | 222 | ): |
| 191 | offset = HEADER_SIZE + index * ENTRY_SIZE | |
| 223 | base = self.region_base(region) | |
| 224 | offset = base + HEADER_SIZE + index * ENTRY_SIZE | |
| 192 | 225 | data = struct.pack( |
| 193 | 226 | ENTRY_FMT, |
| 194 | 227 | object_id, |
| 195 | 228 | object_type, |
| 196 | 229 | update_type, |
| 197 | b"\x00\x00", # _pad | |
| 230 | sub_type, | |
| 231 | 0, # _pad | |
| 198 | 232 | data_offset, |
| 199 | 233 | data_size, |
| 200 | 234 | sequence, |
| … | … | @@ -215,14 +249,16 @@ class ShmRegion: |
| 215 | 249 | self._mm[offset : offset + len(data)] = data |
| 216 | 250 | |
| 217 | 251 | 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() | |
| 220 | 254 | self._mm[offset : offset + len(raw)] = raw |
| 221 | 255 | return len(raw) |
| 222 | 256 | |
| 223 | 257 | # ── Layout helpers ─────────────────────────────────────────────────── |
| 224 | 258 | |
| 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 |
| … | … | @@ -1,12 +1,16 @@ |
| 1 | 1 | #include "blender_bridge.h" |
| 2 | 2 | #include "virtual_texture.h" |
| 3 | 3 | |
| 4 | #include <algorithm> | |
| 4 | 5 | #include <cstdio> |
| 5 | 6 | #include <string> |
| 6 | 7 | |
| 7 | 8 | #include <godot_cpp/classes/camera3d.hpp> |
| 9 | #include <godot_cpp/classes/directional_light3d.hpp> | |
| 8 | 10 | #include <godot_cpp/classes/light3d.hpp> |
| 11 | #include <godot_cpp/classes/omni_light3d.hpp> | |
| 9 | 12 | #include <godot_cpp/classes/scene_tree.hpp> |
| 13 | #include <godot_cpp/classes/spot_light3d.hpp> | |
| 10 | 14 | #include <godot_cpp/classes/window.hpp> |
| 11 | 15 | #include <godot_cpp/variant/utility_functions.hpp> |
| 12 | 16 | |
| … | … | @@ -119,18 +123,24 @@ Error BlenderBridge::start_server(const String &socket_path) { |
| 119 | 123 | return ERR_CANT_CREATE; |
| 120 | 124 | } |
| 121 | 125 | |
| 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 | |
| 123 | 132 | BridgeHeader hdr = {}; |
| 124 | 133 | hdr.magic[0] = 'B'; |
| 125 | 134 | hdr.magic[1] = 'G'; |
| 126 | 135 | hdr.magic[2] = 'O'; |
| 127 | 136 | hdr.magic[3] = 'D'; |
| 128 | 137 | hdr.version = 1; |
| 129 | hdr.direction = 0; // godot→blender (bootstrap direction) | |
| 138 | hdr.direction = 0; | |
| 130 | 139 | hdr.sequence = 0; |
| 131 | 140 | hdr.object_count = 0; |
| 132 | 141 | hdr.frame_number = 0; |
| 133 | write_header(_shm.ptr(), hdr); | |
| 142 | write_header(_write_base, hdr); | |
| 143 | write_header(_read_base, hdr); | |
| 134 | 144 | |
| 135 | 145 | // Start UDS server |
| 136 | 146 | CharString sock_cs = socket_path.utf8(); |
| … | … | @@ -144,9 +154,10 @@ Error BlenderBridge::start_server(const String &socket_path) { |
| 144 | 154 | _export_sequence = 0; |
| 145 | 155 | _last_import_sequence = 0; |
| 146 | 156 | _tracked_objects.clear(); |
| 157 | _known_instance_ids.clear(); | |
| 147 | 158 | _next_object_id = 1; |
| 148 | 159 | |
| 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, ")"); | |
| 150 | 161 | return OK; |
| 151 | 162 | } |
| 152 | 163 | |
| … | … | @@ -158,6 +169,10 @@ void BlenderBridge::stop_server() { |
| 158 | 169 | _shm.destroy(); |
| 159 | 170 | _server_running = false; |
| 160 | 171 | _tracked_objects.clear(); |
| 172 | _known_instance_ids.clear(); | |
| 173 | _write_base = nullptr; | |
| 174 | _read_base = nullptr; | |
| 175 | _region_size = 0; | |
| 161 | 176 | |
| 162 | 177 | UtilityFunctions::print("BlenderBridge: Server stopped"); |
| 163 | 178 | } |
| … | … | @@ -211,21 +226,22 @@ void BlenderBridge::_export_transform(const Transform3D &xform, float *out) { |
| 211 | 226 | // |
| 212 | 227 | // With coordinate conversion M applied: M * col, M * origin |
| 213 | 228 | |
| 229 | // M * B * M^-1 (similarity transform, NOT transposed) | |
| 214 | 230 | // Row 0 (Blender X) |
| 215 | 231 | 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]; | |
| 218 | 234 | out[3] = o.x; |
| 219 | 235 | |
| 220 | 236 | // Row 1 (Blender Y = -Godot Z) |
| 221 | out[4] = -b[0][2]; | |
| 237 | out[4] = -b[2][0]; | |
| 222 | 238 | out[5] = b[2][2]; |
| 223 | out[6] = -b[1][2]; | |
| 239 | out[6] = -b[2][1]; | |
| 224 | 240 | out[7] = -o.z; |
| 225 | 241 | |
| 226 | 242 | // 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]; | |
| 229 | 245 | out[10] = b[1][1]; |
| 230 | 246 | out[11] = o.y; |
| 231 | 247 | |
| … | … | @@ -241,24 +257,50 @@ uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob |
| 241 | 257 | entry.object_id = _next_object_id++; |
| 242 | 258 | entry.update_type = UPDATE_FULL; |
| 243 | 259 | entry.data_offset = blob_offset; |
| 260 | entry.sub_type = 0; | |
| 244 | 261 | |
| 245 | // Determine type | |
| 262 | // Determine type and subtype | |
| 246 | 263 | MeshInstance3D *mesh_inst = Object::cast_to<MeshInstance3D>(node); |
| 247 | 264 | Camera3D *camera = Object::cast_to<Camera3D>(node); |
| 248 | 265 | Light3D *light = Object::cast_to<Light3D>(node); |
| 266 | bool needs_look_compensation = false; | |
| 249 | 267 | |
| 250 | if (mesh_inst) | |
| 268 | if (mesh_inst) { | |
| 251 | 269 | entry.object_type = OBJ_MESH; |
| 252 | else if (camera) | |
| 270 | } else if (camera) { | |
| 253 | 271 | entry.object_type = OBJ_CAMERA; |
| 254 | else if (light) | |
| 272 | needs_look_compensation = true; | |
| 273 | } else if (light) { | |
| 255 | 274 | 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 { | |
| 257 | 286 | entry.object_type = OBJ_EMPTY; |
| 287 | } | |
| 258 | 288 | |
| 259 | 289 | // Write transform (always present, first 64 bytes of blob) |
| 260 | 290 | float xform_data[16]; |
| 261 | 291 | _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 | ||
| 262 | 304 | memcpy(blob_base + blob_offset, xform_data, TRANSFORM_SIZE); |
| 263 | 305 | blob_offset += TRANSFORM_SIZE; |
| 264 | 306 | |
| … | … | @@ -326,7 +368,7 @@ uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob |
| 326 | 368 | |
| 327 | 369 | entry.data_size = blob_offset - entry.data_offset; |
| 328 | 370 | entry.sequence = _export_sequence; |
| 329 | write_object_entry(_shm.ptr(), index, entry); | |
| 371 | write_object_entry(_write_base, index, entry); | |
| 330 | 372 | |
| 331 | 373 | // Track the object with initial transform hash |
| 332 | 374 | TrackedObject tracked; |
| … | … | @@ -335,6 +377,7 @@ uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob |
| 335 | 377 | tracked.name = node->get_name(); |
| 336 | 378 | tracked.type = (ObjectType)entry.object_type; |
| 337 | 379 | tracked.transform_hash = _hash_transform(node->get_global_transform()); |
| 380 | tracked.from_blender = false; | |
| 338 | 381 | _tracked_objects.push_back(tracked); |
| 339 | 382 | |
| 340 | 383 | return entry.object_id; |
| … | … | @@ -354,31 +397,45 @@ Error BlenderBridge::export_scene(Node *root) { |
| 354 | 397 | return ERR_OUT_OF_MEMORY; |
| 355 | 398 | } |
| 356 | 399 | |
| 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()); | |
| 359 | 405 | _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 | } | |
| 360 | 412 | _export_sequence++; |
| 413 | _export_root_id = root->get_instance_id(); | |
| 414 | _last_export_node_count = (int)nodes.size(); | |
| 361 | 415 | |
| 362 | // Blob data starts after header + object table | |
| 416 | // Blob data starts after header + object table — within region 0 | |
| 363 | 417 | uint32_t blob_start = (uint32_t)data_blob_offset(_max_objects); |
| 364 | 418 | 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); | |
| 366 | 420 | |
| 367 | 421 | // Export each node |
| 368 | 422 | for (size_t i = 0; i < nodes.size(); i++) { |
| 369 | 423 | _export_node(nodes[i], (uint32_t)i, base, blob_offset); |
| 370 | 424 | } |
| 371 | 425 | |
| 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) | |
| 375 | 435 | hdr.sequence = _export_sequence; |
| 376 | 436 | hdr.object_count = (uint32_t)nodes.size(); |
| 377 | 437 | 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); | |
| 382 | 439 | |
| 383 | 440 | // Signal Blender via UDS |
| 384 | 441 | if (_uds.client_count() > 0) { |
| … | … | @@ -398,7 +455,7 @@ Dictionary BlenderBridge::get_export_info() const { |
| 398 | 455 | return info; |
| 399 | 456 | } |
| 400 | 457 | |
| 401 | BridgeHeader hdr = read_header(_shm.ptr()); | |
| 458 | BridgeHeader hdr = read_header(_write_base); | |
| 402 | 459 | info["object_count"] = (int)hdr.object_count; |
| 403 | 460 | info["sequence"] = (int)hdr.sequence; |
| 404 | 461 | info["direction"] = (int)hdr.direction; |
| … | … | @@ -419,30 +476,25 @@ Dictionary BlenderBridge::get_export_info() const { |
| 419 | 476 | // ── Scene import (shared memory → Godot) ────────────────────────────────────── |
| 420 | 477 | |
| 421 | 478 | void 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) | |
| 428 | 480 | return; |
| 429 | 481 | |
| 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); | |
| 435 | 484 | |
| 436 | 485 | // Only process if new data |
| 437 | 486 | if (hdr.sequence <= _last_import_sequence) |
| 438 | 487 | return; |
| 439 | 488 | |
| 440 | 489 | _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); | |
| 442 | 491 | int imported = 0; |
| 443 | 492 | |
| 493 | UtilityFunctions::print("BlenderBridge: import_changes seq=", (int)hdr.sequence, | |
| 494 | " count=", (int)hdr.object_count, " tracked=", (int)_tracked_objects.size()); | |
| 495 | ||
| 444 | 496 | 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); | |
| 446 | 498 | |
| 447 | 499 | // Find matching tracked object |
| 448 | 500 | Node3D *target = nullptr; |
| … | … | @@ -454,41 +506,106 @@ void BlenderBridge::import_changes(Node *root) { |
| 454 | 506 | } |
| 455 | 507 | } |
| 456 | 508 | |
| 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 | } | |
| 459 | 560 | |
| 460 | 561 | if (entry.update_type == UPDATE_TRANSFORM || entry.update_type == UPDATE_FULL) { |
| 461 | 562 | // Read transform from blob |
| 462 | 563 | float xform_data[16]; |
| 463 | 564 | memcpy(xform_data, base + entry.data_offset, TRANSFORM_SIZE); |
| 464 | 565 | |
| 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 | |
| 467 | 568 | Transform3D xform; |
| 468 | 569 | Basis &b = xform.basis; |
| 469 | 570 | Vector3 &o = xform.origin; |
| 470 | 571 | |
| 471 | // Export: out[0]=b00, out[1]=-b20, out[2]=b10 | |
| 472 | 572 | 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]; | |
| 475 | 575 | |
| 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]; | |
| 483 | 577 | 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]; | |
| 485 | 583 | |
| 486 | // Export: out[3]=o.x, out[7]=-o.z, out[11]=o.y | |
| 487 | 584 | o.x = xform_data[3]; |
| 488 | 585 | o.y = xform_data[11]; |
| 489 | 586 | o.z = -xform_data[7]; |
| 490 | 587 | |
| 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 | ||
| 491 | 600 | 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 | } | |
| 492 | 609 | } |
| 493 | 610 | |
| 494 | 611 | if (entry.update_type == UPDATE_VERTEX || entry.update_type == UPDATE_FULL) { |
| … | … | @@ -545,29 +662,75 @@ void BlenderBridge::import_changes(Node *root) { |
| 545 | 662 | memcpy(indices.ptrw(), base + cursor, idx_count * 4); |
| 546 | 663 | } |
| 547 | 664 | |
| 548 | // Rebuild mesh surface — convert to ArrayMesh if needed | |
| 549 | 665 | Ref<ArrayMesh> arr_mesh = Object::cast_to<ArrayMesh>(mesh_inst->get_mesh().ptr()); |
| 550 | 666 | if (!arr_mesh.is_valid()) { |
| 551 | 667 | arr_mesh.instantiate(); |
| 552 | 668 | mesh_inst->set_mesh(arr_mesh); |
| 553 | 669 | } |
| 554 | 670 | |
| 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 | } | |
| 563 | 681 | } |
| 564 | 682 | |
| 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 | } | |
| 567 | 722 | } |
| 568 | 723 | } |
| 569 | 724 | |
| 570 | 725 | 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--; | |
| 571 | 734 | target->queue_free(); |
| 572 | 735 | } |
| 573 | 736 | |
| … | … | @@ -594,11 +757,6 @@ void BlenderBridge::_process(double delta) { |
| 594 | 757 | _handle_uds_message(msg); |
| 595 | 758 | } |
| 596 | 759 | |
| 597 | // Tick down import cooldown | |
| 598 | if (_import_cooldown > 0.0) { | |
| 599 | _import_cooldown -= delta; | |
| 600 | } | |
| 601 | ||
| 602 | 760 | // Auto-sync: check for incoming changes and push outgoing changes |
| 603 | 761 | if (_auto_sync && _shm.valid()) { |
| 604 | 762 | Node *root = get_tree() ? get_tree()->get_root() : nullptr; |
| … | … | @@ -695,16 +853,180 @@ uint64_t BlenderBridge::_hash_transform(const Transform3D &xform) { |
| 695 | 853 | } |
| 696 | 854 | |
| 697 | 855 | void 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) | |
| 699 | 862 | return; |
| 700 | 863 | |
| 701 | uint8_t *base = static_cast<uint8_t *>(_shm.ptr()); | |
| 864 | uint8_t *base = static_cast<uint8_t *>(_write_base); | |
| 702 | 865 | uint32_t blob_start = (uint32_t)data_blob_offset(_max_objects); |
| 703 | 866 | uint32_t blob_offset = blob_start; |
| 704 | 867 | uint32_t changed_count = 0; |
| 705 | 868 | |
| 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) { | |
| 708 | 1030 | Object *obj = ObjectDB::get_instance(tracked.godot_instance_id); |
| 709 | 1031 | Node3D *node = Object::cast_to<Node3D>(obj); |
| 710 | 1032 | if (!node) |
| … | … | @@ -714,7 +1036,7 @@ void BlenderBridge::_detect_and_push_changes() { |
| 714 | 1036 | if (new_hash == tracked.transform_hash) |
| 715 | 1037 | continue; |
| 716 | 1038 | |
| 717 | // Transform changed — write delta to shm | |
| 1039 | // Transform changed — write delta to region 0 | |
| 718 | 1040 | tracked.transform_hash = new_hash; |
| 719 | 1041 | |
| 720 | 1042 | ObjectEntry entry = {}; |
| … | … | @@ -726,30 +1048,43 @@ void BlenderBridge::_detect_and_push_changes() { |
| 726 | 1048 | |
| 727 | 1049 | float xform_data[16]; |
| 728 | 1050 | _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 | ||
| 729 | 1063 | memcpy(base + blob_offset, xform_data, TRANSFORM_SIZE); |
| 730 | 1064 | blob_offset += TRANSFORM_SIZE; |
| 731 | 1065 | entry.data_size = TRANSFORM_SIZE; |
| 732 | 1066 | |
| 733 | write_object_entry(_shm.ptr(), (uint32_t)changed_count, entry); | |
| 1067 | write_object_entry(_write_base, changed_count, entry); | |
| 734 | 1068 | changed_count++; |
| 735 | 1069 | } |
| 736 | 1070 | |
| 1071 | // Update _known_instance_ids | |
| 1072 | _known_instance_ids = current_ids; | |
| 1073 | _last_export_node_count = (int)current_nodes.size(); | |
| 1074 | ||
| 737 | 1075 | if (changed_count > 0) { |
| 738 | 1076 | _export_sequence++; |
| 739 | 1077 | |
| 740 | BridgeHeader hdr = read_header(_shm.ptr()); | |
| 741 | hdr.direction = 0; // godot→blender | |
| 1078 | BridgeHeader hdr = read_header(_write_base); | |
| 1079 | hdr.direction = 0; | |
| 742 | 1080 | hdr.sequence = _export_sequence; |
| 743 | 1081 | hdr.object_count = changed_count; |
| 744 | 1082 | 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); | |
| 749 | 1084 | |
| 750 | 1085 | // Signal Blender |
| 751 | 1086 | if (_uds.client_count() > 0) { |
| 752 | const char *msg = "transform_update"; | |
| 1087 | const char *msg = "updated"; | |
| 753 | 1088 | _uds.broadcast(msg, (uint32_t)strlen(msg)); |
| 754 | 1089 | } |
| 755 | 1090 | } |
| … | … | @@ -13,6 +13,8 @@ |
| 13 | 13 | #include <godot_cpp/variant/transform3d.hpp> |
| 14 | 14 | #include <godot_cpp/variant/typed_array.hpp> |
| 15 | 15 | |
| 16 | #include <set> | |
| 17 | ||
| 16 | 18 | #include "shm_protocol.h" |
| 17 | 19 | #include "uds_server.h" |
| 18 | 20 | |
| … | … | @@ -73,7 +75,7 @@ private: |
| 73 | 75 | ShmRegion _shm; |
| 74 | 76 | UdsServer _uds; |
| 75 | 77 | 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) | |
| 77 | 79 | int _max_objects = 256; |
| 78 | 80 | bool _auto_sync = false; |
| 79 | 81 | bool _server_running = false; |
| … | … | @@ -82,15 +84,17 @@ private: |
| 82 | 84 | // ── UDS message handling ──────────────────────────────────────────── |
| 83 | 85 | void _handle_uds_message(const UdsMessage &msg); |
| 84 | 86 | |
| 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 | ||
| 85 | 94 | // ── Sequence tracking ─────────────────────────────────────────────── |
| 86 | 95 | uint64_t _export_sequence = 0; |
| 87 | 96 | uint64_t _last_import_sequence = 0; |
| 88 | 97 | |
| 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 | ||
| 94 | 98 | // ── Object ID mapping ─────────────────────────────────────────────── |
| 95 | 99 | struct TrackedObject { |
| 96 | 100 | uint32_t object_id; |
| … | … | @@ -99,10 +103,17 @@ private: |
| 99 | 103 | ObjectType type; |
| 100 | 104 | // Change detection: cached transform hash |
| 101 | 105 | uint64_t transform_hash = 0; |
| 106 | // Origin tracking: true if created by Blender import | |
| 107 | bool from_blender = false; | |
| 102 | 108 | }; |
| 103 | 109 | std::vector<TrackedObject> _tracked_objects; |
| 104 | 110 | uint32_t _next_object_id = 1; |
| 105 | 111 | |
| 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 | ||
| 106 | 117 | // ── Export helpers ─────────────────────────────────────────────────── |
| 107 | 118 | void _collect_exportable_nodes(Node *node, std::vector<Node3D *> &out); |
| 108 | 119 | uint32_t _export_node(Node3D *node, uint32_t index, uint8_t *blob_base, uint32_t &blob_offset); |
| … | … | @@ -31,6 +31,13 @@ enum ObjectType : uint8_t { |
| 31 | 31 | OBJ_EMPTY = 3, |
| 32 | 32 | }; |
| 33 | 33 | |
| 34 | // ── Light subtypes ───────────────────────────────────────────────────────── | |
| 35 | enum LightSubType : uint8_t { | |
| 36 | LIGHT_POINT = 0, | |
| 37 | LIGHT_SUN = 1, // DirectionalLight3D ↔ Blender "SUN" | |
| 38 | LIGHT_SPOT = 2, | |
| 39 | }; | |
| 40 | ||
| 34 | 41 | // ── Update types ─────────────────────────────────────────────────────────── |
| 35 | 42 | enum UpdateType : uint8_t { |
| 36 | 43 | UPDATE_TRANSFORM = 0, |
| … | … | @@ -45,7 +52,8 @@ struct ObjectEntry { |
| 45 | 52 | uint32_t object_id; |
| 46 | 53 | uint8_t object_type; |
| 47 | 54 | uint8_t update_type; |
| 48 | uint8_t _pad[2]; | |
| 55 | uint8_t sub_type; // light subtype (LightSubType) or 0 | |
| 56 | uint8_t _pad; | |
| 49 | 57 | uint32_t data_offset; |
| 50 | 58 | uint32_t data_size; |
| 51 | 59 | uint32_t sequence; |
| … | … | @@ -85,6 +93,19 @@ inline size_t data_blob_offset(uint32_t max_objects) { |
| 85 | 93 | return OBJECT_TABLE_OFFSET + max_objects * sizeof(ObjectEntry); |
| 86 | 94 | } |
| 87 | 95 | |
| 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 | ||
| 103 | constexpr size_t REGION_SIZE = 8 * 1024 * 1024; // 8 MB per region | |
| 104 | ||
| 105 | inline void *region_base(void *shm, int region) { | |
| 106 | return static_cast<uint8_t *>(shm) + region * REGION_SIZE; | |
| 107 | } | |
| 108 | ||
| 88 | 109 | // ── POSIX shared memory wrapper ──────────────────────────────────────────── |
| 89 | 110 | class ShmRegion { |
| 90 | 111 | public: |