| … | … | @@ -1,7 +1,8 @@ |
| 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 region 1 (Blender→Godot). |
| 4 | accumulates them in memory, and flushes to SHM region 1 (Blender->Godot) |
| 5 | on timer tick to prevent rapid depsgraph fires from overwriting data. |
| 5 | 6 | """ |
| 6 | 7 | |
| 7 | 8 | import struct |
| … | … | @@ -57,16 +58,27 @@ def _blender_light_sub_type(obj) -> int: |
| 57 | 58 | |
| 58 | 59 | |
| 59 | 60 | class ChangeDetector: |
| 60 | | """Detects Blender scene changes and exports to SHM region 1.""" |
| 61 | """Detects Blender scene changes and exports to SHM region 1. |
| 62 | |
| 63 | Changes are accumulated in memory during depsgraph_update_post callbacks, |
| 64 | then flushed to SHM atomically when flush() is called from the timer. |
| 65 | This prevents rapid depsgraph fires from overwriting UPDATE_FULL data |
| 66 | with UPDATE_TRANSFORM before Godot reads it. |
| 67 | """ |
| 61 | 68 | |
| 62 | 69 | def __init__(self, shm: ShmRegion, max_objects: int = 256): |
| 63 | 70 | self._shm = shm |
| 64 | 71 | self._max_objects = max_objects |
| 65 | 72 | self._sequence = 0 |
| 66 | | # Map Blender object name → object_id |
| 73 | # Map Blender object name -> object_id |
| 67 | 74 | self._name_to_id: dict[str, int] = {} |
| 68 | 75 | self._next_id = 1 |
| 69 | 76 | self._registered = False |
| 77 | # Pending changes accumulated between flushes. |
| 78 | # Key: object name, Value: (update_type, depsgraph ref for mesh extraction) |
| 79 | self._pending: dict[str, int] = {} |
| 80 | # Cache of depsgraph for mesh extraction during flush |
| 81 | self._last_depsgraph = None |
| 70 | 82 | |
| 71 | 83 | def register(self): |
| 72 | 84 | """Register the depsgraph handler.""" |
| … | … | @@ -98,76 +110,103 @@ class ChangeDetector: |
| 98 | 110 | self._next_id += 1 |
| 99 | 111 | return self._name_to_id[name] |
| 100 | 112 | |
| 101 | | # ── Depsgraph handler ──────────────────────────────────────────────── |
| 113 | # -- Depsgraph handler (accumulate only, no SHM writes) ---------------- |
| 102 | 114 | |
| 103 | 115 | def _on_depsgraph_update(self, scene, depsgraph): |
| 104 | | """Called after every depsgraph evaluation.""" |
| 105 | | dirty = [] |
| 116 | """Called after every depsgraph evaluation. Accumulates changes.""" |
| 117 | self._last_depsgraph = depsgraph |
| 106 | 118 | |
| 107 | 119 | for update in depsgraph.updates: |
| 108 | 120 | obj = update.id |
| 109 | 121 | if not isinstance(obj, bpy.types.Object): |
| 110 | 122 | continue |
| 111 | 123 | |
| 112 | | if update.is_updated_transform: |
| 113 | | dirty.append(("transform", obj)) |
| 124 | name = obj.name |
| 125 | existing = self._pending.get(name, -1) |
| 126 | |
| 114 | 127 | if update.is_updated_geometry: |
| 115 | | dirty.append(("geometry", obj)) |
| 128 | # Geometry change: upgrade to at least VERTEX, or FULL if new |
| 129 | if existing == -1: |
| 130 | if name not in self._name_to_id: |
| 131 | self._pending[name] = UPDATE_FULL |
| 132 | else: |
| 133 | self._pending[name] = UPDATE_VERTEX |
| 134 | elif existing == UPDATE_TRANSFORM: |
| 135 | self._pending[name] = UPDATE_FULL |
| 136 | # else: already VERTEX or FULL, keep it |
| 116 | 137 | |
| 117 | | if dirty: |
| 118 | | self._export_changes(dirty, depsgraph) |
| 138 | if update.is_updated_transform: |
| 139 | if existing == -1: |
| 140 | if name not in self._name_to_id: |
| 141 | self._pending[name] = UPDATE_FULL |
| 142 | else: |
| 143 | self._pending[name] = UPDATE_TRANSFORM |
| 144 | # else: don't downgrade from VERTEX/FULL to TRANSFORM |
| 119 | 145 | |
| 120 | | # ── Export changes to SHM ──────────────────────────────────────────── |
| 146 | # -- Flush accumulated changes to SHM ---------------------------------- |
| 121 | 147 | |
| 122 | | def _export_changes(self, dirty: list[tuple[str, object]], depsgraph=None): |
| 123 | | """Write changed objects to shared memory region 1.""" |
| 124 | | if not self._shm.valid: |
| 125 | | return |
| 148 | def flush(self) -> int: |
| 149 | """Write all pending changes to SHM region 1 atomically. |
| 150 | |
| 151 | Returns the number of objects written. Call this from the timer, |
| 152 | not from the depsgraph handler. |
| 153 | """ |
| 154 | if not self._pending or not self._shm.valid: |
| 155 | return 0 |
| 156 | |
| 157 | pending = self._pending |
| 158 | self._pending = {} |
| 126 | 159 | |
| 127 | 160 | self._sequence += 1 |
| 128 | 161 | # Region base for absolute mmap writes |
| 129 | 162 | 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 | 163 | from .shm_protocol import HEADER_SIZE, ENTRY_SIZE |
| 133 | 164 | blob_rel_start = HEADER_SIZE + self._max_objects * ENTRY_SIZE |
| 134 | | blob_rel_offset = blob_rel_start # region-relative offset for ObjectEntry.data_offset |
| 135 | | blob_abs_offset = region1_base + blob_rel_start # absolute offset for mmap writes |
| 165 | blob_rel_offset = blob_rel_start |
| 166 | blob_abs_offset = region1_base + blob_rel_start |
| 136 | 167 | |
| 137 | | # Deduplicate: if both transform and geometry changed, use FULL |
| 138 | | obj_updates: dict[str, tuple[int, object]] = {} |
| 139 | | for change_type, obj in dirty: |
| 140 | | name = obj.name |
| 141 | | if name in obj_updates: |
| 142 | | # Merge: if already has one type, upgrade to FULL |
| 143 | | existing_type, _ = obj_updates[name] |
| 144 | | if existing_type != UPDATE_FULL: |
| 145 | | obj_updates[name] = (UPDATE_FULL, obj) |
| 146 | | else: |
| 147 | | if change_type == "transform": |
| 148 | | obj_updates[name] = (UPDATE_TRANSFORM, obj) |
| 149 | | elif change_type == "geometry": |
| 150 | | obj_updates[name] = (UPDATE_VERTEX, obj) |
| 151 | | else: |
| 152 | | obj_updates[name] = (UPDATE_FULL, obj) |
| 168 | depsgraph = self._last_depsgraph |
| 153 | 169 | |
| 154 | 170 | entry_index = 0 |
| 155 | | written_updates: dict[str, int] = {} # name → actual update_type written |
| 156 | | for name, (update_type, obj) in obj_updates.items(): |
| 171 | written_updates: dict[str, int] = {} |
| 172 | |
| 173 | for name, update_type in pending.items(): |
| 157 | 174 | if entry_index >= self._max_objects: |
| 158 | 175 | break |
| 159 | 176 | |
| 177 | # Look up the Blender object |
| 178 | obj = bpy.data.objects.get(name) |
| 179 | if obj is None: |
| 180 | continue |
| 181 | |
| 160 | 182 | is_new = name not in self._name_to_id |
| 161 | 183 | obj_id = self.get_or_assign_id(name) |
| 162 | 184 | |
| 163 | | # New objects need full data (geometry) so Godot can create proper meshes |
| 185 | # New mesh objects always get FULL |
| 164 | 186 | if is_new and obj.type == "MESH": |
| 165 | 187 | update_type = UPDATE_FULL |
| 188 | |
| 166 | 189 | obj_type = _blender_type_to_obj_type(obj) |
| 167 | 190 | sub_type = _blender_light_sub_type(obj) |
| 168 | | data_rel_start = blob_rel_offset # region-relative for ObjectEntry |
| 169 | | |
| 170 | | # Write transform (always) — use absolute offset for mmap write |
| 191 | data_rel_start = blob_rel_offset |
| 192 | |
| 193 | # Write object name (length-prefixed, padded to 4-byte alignment) |
| 194 | name_bytes = name.encode("utf-8")[:255] |
| 195 | name_len = len(name_bytes) |
| 196 | self._shm.write_blob(blob_abs_offset, struct.pack("<B", name_len)) |
| 197 | blob_abs_offset += 1 |
| 198 | blob_rel_offset += 1 |
| 199 | self._shm.write_blob(blob_abs_offset, name_bytes) |
| 200 | blob_abs_offset += name_len |
| 201 | blob_rel_offset += name_len |
| 202 | # Pad to 4-byte alignment |
| 203 | pad = (4 - ((1 + name_len) % 4)) % 4 |
| 204 | if pad > 0: |
| 205 | self._shm.write_blob(blob_abs_offset, b"\x00" * pad) |
| 206 | blob_abs_offset += pad |
| 207 | blob_rel_offset += pad |
| 208 | |
| 209 | # Write transform (always) |
| 171 | 210 | mat = np.array(obj.matrix_world, dtype=np.float32).flatten() |
| 172 | 211 | self._shm.write_blob_array(blob_abs_offset, mat) |
| 173 | 212 | blob_abs_offset += TRANSFORM_SIZE |
| … | … | @@ -175,7 +214,6 @@ class ChangeDetector: |
| 175 | 214 | |
| 176 | 215 | # Write vertex data for geometry updates |
| 177 | 216 | if update_type in (UPDATE_VERTEX, UPDATE_FULL) and obj.type == "MESH": |
| 178 | | # Use evaluated mesh for safe access (works in edit mode too) |
| 179 | 217 | if depsgraph is not None: |
| 180 | 218 | eval_obj = obj.evaluated_get(depsgraph) |
| 181 | 219 | mesh = eval_obj.to_mesh() |
| … | … | @@ -193,7 +231,7 @@ class ChangeDetector: |
| 193 | 231 | blob_abs_offset += 4 |
| 194 | 232 | blob_rel_offset += 4 |
| 195 | 233 | |
| 196 | | # Bulk-read positions using foreach_get (C-level, fast) |
| 234 | # Bulk-read positions |
| 197 | 235 | coords = np.empty(vert_count * 3, dtype=np.float32) |
| 198 | 236 | mesh.vertices.foreach_get("co", coords) |
| 199 | 237 | written = self._shm.write_blob_array(blob_abs_offset, coords) |
| … | … | @@ -210,7 +248,7 @@ class ChangeDetector: |
| 210 | 248 | blob_abs_offset += written |
| 211 | 249 | blob_rel_offset += written |
| 212 | 250 | |
| 213 | | # Triangle indices from loop_triangles |
| 251 | # Triangle indices |
| 214 | 252 | mesh.calc_loop_triangles() |
| 215 | 253 | tri_count = len(mesh.loop_triangles) |
| 216 | 254 | idx_count = tri_count * 3 |
| … | … | @@ -228,13 +266,12 @@ class ChangeDetector: |
| 228 | 266 | blob_abs_offset += len(raw) |
| 229 | 267 | blob_rel_offset += len(raw) |
| 230 | 268 | |
| 231 | | # Clean up evaluated mesh |
| 232 | 269 | if eval_obj is not None: |
| 233 | 270 | eval_obj.to_mesh_clear() |
| 234 | 271 | |
| 235 | 272 | data_rel_size = blob_rel_offset - data_rel_start |
| 236 | 273 | |
| 237 | | # Write object entry to region 1 (data_offset is region-relative) |
| 274 | # Write object entry to region 1 |
| 238 | 275 | self._shm.write_object_entry( |
| 239 | 276 | index=entry_index, |
| 240 | 277 | object_id=obj_id, |
| … | … | @@ -253,7 +290,7 @@ class ChangeDetector: |
| 253 | 290 | if entry_index > 0: |
| 254 | 291 | self._shm.write_header( |
| 255 | 292 | region=1, |
| 256 | | direction=1, # blender→godot (kept for compat) |
| 293 | direction=1, |
| 257 | 294 | sequence=self._sequence, |
| 258 | 295 | object_count=entry_index, |
| 259 | 296 | ) |
| … | … | @@ -261,4 +298,6 @@ class ChangeDetector: |
| 261 | 298 | "{}(id={} t={})".format(n, self._name_to_id.get(n, "?"), ut) |
| 262 | 299 | for n, ut in written_updates.items() |
| 263 | 300 | ) |
| 264 | | print(f"BlenderBridge: Sent {entry_index} change(s) seq={self._sequence} [{details}]") |
| 301 | print(f"BlenderBridge: Flushed {entry_index} change(s) seq={self._sequence} [{details}]") |
| 302 | |
| 303 | return entry_index |