| … | … | @@ -11,7 +11,7 @@ Usage: |
| 11 | 11 | |
| 12 | 12 | import bpy |
| 13 | 13 | |
| 14 | ADDON_VERSION = "0.8.0" | |
| 14 | ADDON_VERSION = "0.12.0" | |
| 15 | 15 | |
| 16 | 16 | # Support hot-reload (F3 > Reload Scripts) |
| 17 | 17 | if "shm_protocol" in locals(): |
| … | … | @@ -59,16 +59,25 @@ def _make_poll_timer(generation: int): |
| 59 | 59 | |
| 60 | 60 | def _poll_timer_impl(generation: int): |
| 61 | 61 | """Called by Blender's timer to poll for updates from Godot.""" |
| 62 | global _conn, _importer, _baker, _timer_running | |
| 62 | global _conn, _shm, _importer, _detector, _baker, _timer_running | |
| 63 | 63 | |
| 64 | 64 | # Stop if this timer belongs to a stale generation (disconnect+reconnect happened) |
| 65 | 65 | if generation != _timer_generation: |
| 66 | 66 | return None |
| 67 | 67 | |
| 68 | if _conn is None or not _conn.connected: | |
| 68 | if _conn is None: | |
| 69 | 69 | _timer_running = False |
| 70 | 70 | return None # Stop timer |
| 71 | 71 | |
| 72 | # Auto-reconnect on unexpected disconnect | |
| 73 | if not _conn.connected and _conn.should_reconnect: | |
| 74 | return _attempt_reconnect() | |
| 75 | ||
| 76 | # Manual disconnect — stop timer | |
| 77 | if not _conn.connected: | |
| 78 | _timer_running = False | |
| 79 | return None | |
| 80 | ||
| 72 | 81 | # Tick suppression counter (feedback loop prevention) |
| 73 | 82 | if _detector is not None: |
| 74 | 83 | _detector.unsuppress() |
| … | … | @@ -76,6 +85,12 @@ def _poll_timer_impl(generation: int): |
| 76 | 85 | # Poll UDS messages |
| 77 | 86 | got_incoming = False |
| 78 | 87 | messages = _conn.poll_messages() |
| 88 | ||
| 89 | # Check if polling caused an unexpected disconnect | |
| 90 | if not _conn.connected and _conn.should_reconnect: | |
| 91 | print("BlenderBridge: Connection lost, will attempt reconnect...") | |
| 92 | return 2.0 # Retry in 2 seconds | |
| 93 | ||
| 79 | 94 | for msg in messages: |
| 80 | 95 | text = msg.decode("utf-8", errors="replace") |
| 81 | 96 | if text == "scene_exported": |
| … | … | @@ -122,6 +137,42 @@ def _poll_timer_impl(generation: int): |
| 122 | 137 | return POLL_INTERVAL # Continue polling |
| 123 | 138 | |
| 124 | 139 | |
| 140 | def _attempt_reconnect(): | |
| 141 | """Try to reconnect after unexpected disconnect. Returns timer interval.""" | |
| 142 | global _shm, _conn, _importer, _detector, _baker | |
| 143 | ||
| 144 | # Re-open SHM (Godot may have restarted with fresh SHM) | |
| 145 | if _shm is not None: | |
| 146 | _shm.close() | |
| 147 | _shm = ShmRegion() | |
| 148 | if not _shm.open(SHM_NAME, SHM_SIZE): | |
| 149 | return 2.0 # Retry in 2 seconds | |
| 150 | ||
| 151 | # Reconnect UDS | |
| 152 | if not _conn.connect(): | |
| 153 | _shm.close() | |
| 154 | _shm = None | |
| 155 | return 2.0 # Retry in 2 seconds | |
| 156 | ||
| 157 | # Re-initialize importer and detector | |
| 158 | _importer = SceneImporter(_shm, MAX_OBJECTS) | |
| 159 | if _detector is not None: | |
| 160 | _detector.unregister() | |
| 161 | _detector = ChangeDetector(_shm, MAX_OBJECTS) | |
| 162 | _detector.register() | |
| 163 | ||
| 164 | # Re-initialize tile baker | |
| 165 | from .shm_protocol import REGION_SIZE | |
| 166 | tile_ring_offset = REGION_SIZE * 2 | |
| 167 | tile_ring_size = SHM_SIZE - tile_ring_offset | |
| 168 | _baker = TileBaker(_shm, tile_size=128, max_mips=8) | |
| 169 | _baker.init_ring_buffer(tile_ring_offset, tile_ring_size) | |
| 170 | ||
| 171 | _conn.clear_reconnect() | |
| 172 | print("BlenderBridge: Reconnected successfully") | |
| 173 | return POLL_INTERVAL # Resume normal polling | |
| 174 | ||
| 175 | ||
| 125 | 176 | # ── Addon Preferences ─────────────────────────────────────────────────────── |
| 126 | 177 | |
| 127 | 178 |
| … | … | @@ -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.8.0" | |
| 5 | version = "0.12.0" | |
| 6 | 6 | blender_version_min = "4.2.0" |
| 7 | 7 | type = "add-on" |
| 8 | 8 | maintainer = "blender-bridge <noreply@localhost>" |
| … | … | @@ -84,6 +84,11 @@ class ChangeDetector: |
| 84 | 84 | # objects that Godot sent, allowing modifier-dependent updates through. |
| 85 | 85 | # Dict: object_name → remaining ticks |
| 86 | 86 | self._suppressed_objects: dict[str, int] = {} |
| 87 | # Transform hash cache for jitter reduction | |
| 88 | self._transform_hashes: dict[str, int] = {} | |
| 89 | # Geometry hash cache: prevents re-flushing unchanged geometry | |
| 90 | # (geometry nodes fire is_updated_geometry every frame even when output is identical) | |
| 91 | self._geometry_hashes: dict[str, int] = {} | |
| 87 | 92 | |
| 88 | 93 | def register(self): |
| 89 | 94 | """Register the depsgraph handler.""" |
| … | … | @@ -142,6 +147,18 @@ class ChangeDetector: |
| 142 | 147 | """Return the current ID map as {object_id: name} for syncing with SceneImporter.""" |
| 143 | 148 | return {obj_id: name for name, obj_id in self._name_to_id.items()} |
| 144 | 149 | |
| 150 | @staticmethod | |
| 151 | def _hash_transform(mat) -> int: | |
| 152 | """FNV-1a hash of 4×4 matrix floats for change detection.""" | |
| 153 | h = 14695981039346656037 | |
| 154 | for row in mat: | |
| 155 | for val in row: | |
| 156 | bits = struct.pack("<f", val) | |
| 157 | for b in bits: | |
| 158 | h ^= b | |
| 159 | h = (h * 1099511628211) & 0xFFFFFFFFFFFFFFFF | |
| 160 | return h | |
| 161 | ||
| 145 | 162 | # -- Depsgraph handler (accumulate only, no SHM writes) ---------------- |
| 146 | 163 | |
| 147 | 164 | def _on_depsgraph_update(self, scene, depsgraph): |
| … | … | @@ -161,7 +178,17 @@ class ChangeDetector: |
| 161 | 178 | |
| 162 | 179 | existing = self._pending.get(name, -1) |
| 163 | 180 | |
| 164 | if update.is_updated_geometry: | |
| 181 | # Detect geometry changes: explicit flag OR geometry nodes modifier | |
| 182 | # with any update (geometry nodes often fire is_updated_transform | |
| 183 | # instead of is_updated_geometry when inputs change) | |
| 184 | is_geo_change = update.is_updated_geometry | |
| 185 | if not is_geo_change and update.is_updated_transform: | |
| 186 | if obj.type == "MESH" and any( | |
| 187 | m.type == "NODES" for m in obj.modifiers | |
| 188 | ): | |
| 189 | is_geo_change = True | |
| 190 | ||
| 191 | if is_geo_change: | |
| 165 | 192 | # Geometry change: upgrade to at least VERTEX, or FULL if new |
| 166 | 193 | if existing == -1: |
| 167 | 194 | if name not in self._name_to_id: |
| … | … | @@ -173,6 +200,12 @@ class ChangeDetector: |
| 173 | 200 | # else: already VERTEX or FULL, keep it |
| 174 | 201 | |
| 175 | 202 | if update.is_updated_transform: |
| 203 | # Hash-based jitter reduction: skip if transform unchanged | |
| 204 | new_hash = self._hash_transform(obj.matrix_world) | |
| 205 | if new_hash == self._transform_hashes.get(name, -1): | |
| 206 | continue | |
| 207 | self._transform_hashes[name] = new_hash | |
| 208 | ||
| 176 | 209 | if existing == -1: |
| 177 | 210 | if name not in self._name_to_id: |
| 178 | 211 | self._pending[name] = UPDATE_FULL |
| … | … | @@ -243,6 +276,8 @@ class ChangeDetector: |
| 243 | 276 | written_updates[name] = UPDATE_DELETE |
| 244 | 277 | entry_index += 1 |
| 245 | 278 | del self._name_to_id[name] |
| 279 | self._transform_hashes.pop(name, None) | |
| 280 | self._geometry_hashes.pop(name, None) | |
| 246 | 281 | continue |
| 247 | 282 | |
| 248 | 283 | # Look up the Blender object |
| … | … | @@ -277,11 +312,12 @@ class ChangeDetector: |
| 277 | 312 | blob_abs_offset += pad |
| 278 | 313 | blob_rel_offset += pad |
| 279 | 314 | |
| 280 | # Write transform (always) | |
| 315 | # Write transform (always) and update hash cache | |
| 281 | 316 | mat = np.array(obj.matrix_world, dtype=np.float32).flatten() |
| 282 | 317 | self._shm.write_blob_array(blob_abs_offset, mat) |
| 283 | 318 | blob_abs_offset += TRANSFORM_SIZE |
| 284 | 319 | blob_rel_offset += TRANSFORM_SIZE |
| 320 | self._transform_hashes[name] = self._hash_transform(obj.matrix_world) | |
| 285 | 321 | |
| 286 | 322 | # Write vertex data for geometry updates |
| 287 | 323 | if update_type in (UPDATE_VERTEX, UPDATE_FULL) and obj.type == "MESH": |
| … | … | @@ -292,6 +328,32 @@ class ChangeDetector: |
| 292 | 328 | mesh = obj.data |
| 293 | 329 | eval_obj = None |
| 294 | 330 | |
| 331 | if mesh is not None: | |
| 332 | # Geometry hash check: skip re-export if evaluated mesh | |
| 333 | # is identical (geometry nodes fire is_updated_geometry | |
| 334 | # every frame even when output doesn't change) | |
| 335 | all_verts_hash = np.empty( | |
| 336 | len(mesh.vertices) * 3, dtype=np.float32 | |
| 337 | ) | |
| 338 | mesh.vertices.foreach_get("co", all_verts_hash) | |
| 339 | geo_hash = hash(all_verts_hash.tobytes()) | |
| 340 | if geo_hash == self._geometry_hashes.get(name): | |
| 341 | # Mesh unchanged — downgrade to transform-only or skip | |
| 342 | if eval_obj is not None: | |
| 343 | eval_obj.to_mesh_clear() | |
| 344 | if update_type == UPDATE_VERTEX: | |
| 345 | # Only geometry was flagged but unchanged — skip entirely | |
| 346 | blob_abs_offset = region1_base + data_rel_start | |
| 347 | blob_rel_offset = data_rel_start | |
| 348 | continue | |
| 349 | # UPDATE_FULL: still need to write (new object), but skip geometry | |
| 350 | update_type = UPDATE_TRANSFORM | |
| 351 | if eval_obj is not None: | |
| 352 | eval_obj = None | |
| 353 | mesh = None | |
| 354 | else: | |
| 355 | self._geometry_hashes[name] = geo_hash | |
| 356 | ||
| 295 | 357 | if mesh is not None: |
| 296 | 358 | mesh.calc_loop_triangles() |
| 297 | 359 | tri_count = len(mesh.loop_triangles) |
| … | … | @@ -369,9 +431,88 @@ class ChangeDetector: |
| 369 | 431 | blob_abs_offset += 4 |
| 370 | 432 | blob_rel_offset += 4 |
| 371 | 433 | |
| 434 | # Write UV coordinates (float32×2 per vertex) | |
| 435 | uv_layer = mesh.uv_layers.active | |
| 436 | if uv_layer is not None: | |
| 437 | all_uvs = np.empty( | |
| 438 | len(mesh.loops) * 2, dtype=np.float32 | |
| 439 | ) | |
| 440 | uv_layer.data.foreach_get("uv", all_uvs) | |
| 441 | all_uvs = all_uvs.reshape(-1, 2) | |
| 442 | ||
| 443 | # Index by tri_loop_idx for per-corner data | |
| 444 | out_uvs = all_uvs[tri_loop_idx].flatten() | |
| 445 | ||
| 446 | self._shm.write_blob( | |
| 447 | blob_abs_offset, | |
| 448 | struct.pack("<I", out_vert_count), | |
| 449 | ) | |
| 450 | blob_abs_offset += 4 | |
| 451 | blob_rel_offset += 4 | |
| 452 | written = self._shm.write_blob_array( | |
| 453 | blob_abs_offset, out_uvs | |
| 454 | ) | |
| 455 | blob_abs_offset += written | |
| 456 | blob_rel_offset += written | |
| 457 | else: | |
| 458 | # No UVs — write zero count | |
| 459 | self._shm.write_blob( | |
| 460 | blob_abs_offset, struct.pack("<I", 0) | |
| 461 | ) | |
| 462 | blob_abs_offset += 4 | |
| 463 | blob_rel_offset += 4 | |
| 464 | ||
| 372 | 465 | if eval_obj is not None: |
| 373 | 466 | eval_obj.to_mesh_clear() |
| 374 | 467 | |
| 468 | # Write material data (for meshes, after vertex/UV data) | |
| 469 | if obj.type == "MESH" and obj.active_material is not None: | |
| 470 | mat = obj.active_material | |
| 471 | has_mat = 0 | |
| 472 | if mat.use_nodes and mat.node_tree: | |
| 473 | bsdf = None | |
| 474 | for node in mat.node_tree.nodes: | |
| 475 | if node.type == "BSDF_PRINCIPLED": | |
| 476 | bsdf = node | |
| 477 | break | |
| 478 | if bsdf is not None: | |
| 479 | has_mat = 1 | |
| 480 | self._shm.write_blob( | |
| 481 | blob_abs_offset, struct.pack("<I", 1) | |
| 482 | ) | |
| 483 | blob_abs_offset += 4 | |
| 484 | blob_rel_offset += 4 | |
| 485 | ||
| 486 | base_color = bsdf.inputs["Base Color"].default_value | |
| 487 | roughness = bsdf.inputs["Roughness"].default_value | |
| 488 | metallic = bsdf.inputs["Metallic"].default_value | |
| 489 | emission_color = bsdf.inputs["Emission Color"].default_value | |
| 490 | emission_strength = bsdf.inputs["Emission Strength"].default_value | |
| 491 | ||
| 492 | mat_data = np.array([ | |
| 493 | base_color[0], base_color[1], base_color[2], base_color[3], | |
| 494 | roughness, | |
| 495 | metallic, | |
| 496 | emission_color[0], emission_color[1], emission_color[2], emission_color[3], | |
| 497 | emission_strength, | |
| 498 | ], dtype=np.float32) | |
| 499 | written = self._shm.write_blob_array(blob_abs_offset, mat_data) | |
| 500 | blob_abs_offset += written | |
| 501 | blob_rel_offset += written | |
| 502 | if not has_mat: | |
| 503 | self._shm.write_blob( | |
| 504 | blob_abs_offset, struct.pack("<I", 0) | |
| 505 | ) | |
| 506 | blob_abs_offset += 4 | |
| 507 | blob_rel_offset += 4 | |
| 508 | elif obj.type == "MESH": | |
| 509 | # No material assigned | |
| 510 | self._shm.write_blob( | |
| 511 | blob_abs_offset, struct.pack("<I", 0) | |
| 512 | ) | |
| 513 | blob_abs_offset += 4 | |
| 514 | blob_rel_offset += 4 | |
| 515 | ||
| 375 | 516 | data_rel_size = blob_rel_offset - data_rel_start |
| 376 | 517 | |
| 377 | 518 | # Write object entry to region 1 |
| … | … | @@ -15,6 +15,7 @@ class BridgeConnection: |
| 15 | 15 | self._sock = None |
| 16 | 16 | self._path = socket_path |
| 17 | 17 | self._recv_buf = bytearray() |
| 18 | self._disconnected_unexpectedly = False | |
| 18 | 19 | |
| 19 | 20 | # ── Lifecycle ──────────────────────────────────────────────────────── |
| 20 | 21 | |
| … | … | @@ -24,6 +25,7 @@ class BridgeConnection: |
| 24 | 25 | self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) |
| 25 | 26 | self._sock.connect(self._path) |
| 26 | 27 | self._sock.setblocking(False) |
| 28 | self._disconnected_unexpectedly = False | |
| 27 | 29 | # Send handshake |
| 28 | 30 | self.send_message(b"hello") |
| 29 | 31 | return True |
| … | … | @@ -32,7 +34,7 @@ class BridgeConnection: |
| 32 | 34 | return False |
| 33 | 35 | |
| 34 | 36 | def close(self): |
| 35 | """Close the connection.""" | |
| 37 | """Intentional close — no auto-reconnect.""" | |
| 36 | 38 | if self._sock is not None: |
| 37 | 39 | try: |
| 38 | 40 | self._sock.close() |
| … | … | @@ -40,11 +42,32 @@ class BridgeConnection: |
| 40 | 42 | pass |
| 41 | 43 | self._sock = None |
| 42 | 44 | self._recv_buf.clear() |
| 45 | self._disconnected_unexpectedly = False | |
| 46 | ||
| 47 | def _close_unexpected(self): | |
| 48 | """Unexpected disconnect (EOF/error) — triggers auto-reconnect.""" | |
| 49 | if self._sock is not None: | |
| 50 | try: | |
| 51 | self._sock.close() | |
| 52 | except OSError: | |
| 53 | pass | |
| 54 | self._sock = None | |
| 55 | self._recv_buf.clear() | |
| 56 | self._disconnected_unexpectedly = True | |
| 43 | 57 | |
| 44 | 58 | @property |
| 45 | 59 | def connected(self) -> bool: |
| 46 | 60 | return self._sock is not None |
| 47 | 61 | |
| 62 | @property | |
| 63 | def should_reconnect(self) -> bool: | |
| 64 | """True if disconnected unexpectedly and should attempt reconnect.""" | |
| 65 | return self._disconnected_unexpectedly and self._sock is None | |
| 66 | ||
| 67 | def clear_reconnect(self): | |
| 68 | """Clear the reconnect flag (e.g. after successful reconnect).""" | |
| 69 | self._disconnected_unexpectedly = False | |
| 70 | ||
| 48 | 71 | # ── Send ───────────────────────────────────────────────────────────── |
| 49 | 72 | |
| 50 | 73 | def send_message(self, data: bytes): |
| … | … | @@ -54,7 +77,7 @@ class BridgeConnection: |
| 54 | 77 | try: |
| 55 | 78 | self._sock.sendall(struct.pack("<I", len(data)) + data) |
| 56 | 79 | except OSError: |
| 57 | self.close() | |
| 80 | self._close_unexpected() | |
| 58 | 81 | |
| 59 | 82 | # ── Receive (non-blocking) ─────────────────────────────────────────── |
| 60 | 83 | |
| … | … | @@ -69,13 +92,13 @@ class BridgeConnection: |
| 69 | 92 | chunk = self._sock.recv(4096) |
| 70 | 93 | if not chunk: |
| 71 | 94 | # Server closed |
| 72 | self.close() | |
| 95 | self._close_unexpected() | |
| 73 | 96 | return [] |
| 74 | 97 | self._recv_buf.extend(chunk) |
| 75 | 98 | except BlockingIOError: |
| 76 | 99 | pass # No more data available |
| 77 | 100 | except OSError: |
| 78 | self.close() | |
| 101 | self._close_unexpected() | |
| 79 | 102 | return [] |
| 80 | 103 | |
| 81 | 104 | # Parse complete messages |
| … | … | @@ -83,7 +106,7 @@ class BridgeConnection: |
| 83 | 106 | while len(self._recv_buf) >= 4: |
| 84 | 107 | msg_len = struct.unpack_from("<I", self._recv_buf, 0)[0] |
| 85 | 108 | if msg_len > 16 * 1024 * 1024: # 16 MB sanity limit |
| 86 | self.close() | |
| 109 | self._close_unexpected() | |
| 87 | 110 | return messages |
| 88 | 111 | if len(self._recv_buf) < 4 + msg_len: |
| 89 | 112 | break # Incomplete |
| … | … | @@ -255,6 +255,23 @@ class SceneImporter: |
| 255 | 255 | bytes(self._shm._mm[cursor:cursor + smooth_count]), |
| 256 | 256 | dtype=np.uint8, |
| 257 | 257 | ) |
| 258 | cursor += smooth_count | |
| 259 | ||
| 260 | # Read UV coordinates (after smooth flags) | |
| 261 | uv_data = None | |
| 262 | if cursor + 4 <= data_end: | |
| 263 | uv_count = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0] | |
| 264 | cursor += 4 | |
| 265 | if uv_count > 0 and cursor + uv_count * 8 <= data_end: | |
| 266 | uv_data = np.frombuffer( | |
| 267 | bytes(self._shm._mm[cursor:cursor + uv_count * 8]), | |
| 268 | dtype=np.float32, | |
| 269 | ).reshape(-1, 2).copy() | |
| 270 | cursor += uv_count * 8 | |
| 271 | ||
| 272 | # Reverse UV winding to match reversed face winding (swap indices 1↔2 per triangle) | |
| 273 | for ti in range(0, len(uv_data) - 2, 3): | |
| 274 | uv_data[ti + 1], uv_data[ti + 2] = uv_data[ti + 2].copy(), uv_data[ti + 1].copy() | |
| 258 | 275 | |
| 259 | 276 | mesh = bpy.data.meshes.new(obj_name) |
| 260 | 277 | mesh.from_pydata(verts, [], faces) |
| … | … | @@ -265,10 +282,54 @@ class SceneImporter: |
| 265 | 282 | mesh.polygons.foreach_set("use_smooth", smooth_flags.tolist()) |
| 266 | 283 | mesh.update() |
| 267 | 284 | |
| 285 | # Apply UVs | |
| 286 | if uv_data is not None and len(mesh.loops) > 0: | |
| 287 | uv_layer = mesh.uv_layers.new() | |
| 288 | # UVs are per-loop in Blender; our data is per-loop-triangle-corner | |
| 289 | # which maps directly to mesh loops for triangle-only meshes | |
| 290 | if len(uv_data) == len(mesh.loops): | |
| 291 | uv_flat = uv_data.flatten() | |
| 292 | uv_layer.data.foreach_set("uv", uv_flat.tolist()) | |
| 293 | mesh.update() | |
| 294 | ||
| 295 | # Read and apply material data | |
| 296 | if cursor + 4 <= data_end: | |
| 297 | has_material = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0] | |
| 298 | cursor += 4 | |
| 299 | if has_material and cursor + 44 <= data_end: | |
| 300 | mat_data = np.frombuffer( | |
| 301 | bytes(self._shm._mm[cursor:cursor + 44]), | |
| 302 | dtype=np.float32, | |
| 303 | ) | |
| 304 | cursor += 44 | |
| 305 | self._apply_material_to_mesh(mesh, mat_data) | |
| 306 | ||
| 268 | 307 | obj = bpy.data.objects.new(mesh.name, mesh) |
| 269 | 308 | bpy.context.collection.objects.link(obj) |
| 270 | 309 | return obj |
| 271 | 310 | |
| 311 | def _apply_material_to_mesh(self, mesh, mat_data): | |
| 312 | """Create a Principled BSDF material from material data array.""" | |
| 313 | mat = bpy.data.materials.new(name=mesh.name) | |
| 314 | mat.use_nodes = True | |
| 315 | bsdf = mat.node_tree.nodes.get("Principled BSDF") | |
| 316 | if bsdf is not None: | |
| 317 | bsdf.inputs["Base Color"].default_value = ( | |
| 318 | float(mat_data[0]), float(mat_data[1]), | |
| 319 | float(mat_data[2]), float(mat_data[3]), | |
| 320 | ) | |
| 321 | bsdf.inputs["Roughness"].default_value = float(mat_data[4]) | |
| 322 | bsdf.inputs["Metallic"].default_value = float(mat_data[5]) | |
| 323 | bsdf.inputs["Emission Color"].default_value = ( | |
| 324 | float(mat_data[6]), float(mat_data[7]), | |
| 325 | float(mat_data[8]), float(mat_data[9]), | |
| 326 | ) | |
| 327 | bsdf.inputs["Emission Strength"].default_value = float(mat_data[10]) | |
| 328 | if len(mesh.materials) == 0: | |
| 329 | mesh.materials.append(mat) | |
| 330 | else: | |
| 331 | mesh.materials[0] = mat | |
| 332 | ||
| 272 | 333 | def _import_camera(self, entry: dict, godot_name: str = ""): |
| 273 | 334 | """Create a Blender camera.""" |
| 274 | 335 | obj_name = godot_name or f"bridge_camera_{entry['object_id']}" |
| … | … | @@ -394,6 +455,23 @@ class SceneImporter: |
| 394 | 455 | bytes(self._shm._mm[cursor:cursor + smooth_count]), |
| 395 | 456 | dtype=np.uint8, |
| 396 | 457 | ) |
| 458 | cursor += smooth_count | |
| 459 | ||
| 460 | # Read UV coordinates (after smooth flags) | |
| 461 | uv_data = None | |
| 462 | if cursor + 4 <= data_end: | |
| 463 | uv_count = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0] | |
| 464 | cursor += 4 | |
| 465 | if uv_count > 0 and cursor + uv_count * 8 <= data_end: | |
| 466 | uv_data = np.frombuffer( | |
| 467 | bytes(self._shm._mm[cursor:cursor + uv_count * 8]), | |
| 468 | dtype=np.float32, | |
| 469 | ).reshape(-1, 2).copy() | |
| 470 | cursor += uv_count * 8 | |
| 471 | ||
| 472 | # Reverse UV winding to match reversed face winding | |
| 473 | for ti in range(0, len(uv_data) - 2, 3): | |
| 474 | uv_data[ti + 1], uv_data[ti + 2] = uv_data[ti + 2].copy(), uv_data[ti + 1].copy() | |
| 397 | 475 | |
| 398 | 476 | mesh = obj.data |
| 399 | 477 | if len(mesh.vertices) == vert_count: |
| … | … | @@ -418,3 +496,25 @@ class SceneImporter: |
| 418 | 496 | if smooth_flags is not None and len(smooth_flags) == len(mesh.polygons): |
| 419 | 497 | mesh.polygons.foreach_set("use_smooth", smooth_flags.tolist()) |
| 420 | 498 | mesh.update() |
| 499 | ||
| 500 | # Apply UVs | |
| 501 | if uv_data is not None and len(mesh.loops) > 0: | |
| 502 | if not mesh.uv_layers: | |
| 503 | mesh.uv_layers.new() | |
| 504 | uv_layer = mesh.uv_layers.active | |
| 505 | if len(uv_data) == len(mesh.loops): | |
| 506 | uv_flat = uv_data.flatten() | |
| 507 | uv_layer.data.foreach_set("uv", uv_flat.tolist()) | |
| 508 | mesh.update() | |
| 509 | ||
| 510 | # Read and apply material data | |
| 511 | if cursor + 4 <= data_end: | |
| 512 | has_material = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0] | |
| 513 | cursor += 4 | |
| 514 | if has_material and cursor + 44 <= data_end: | |
| 515 | mat_data = np.frombuffer( | |
| 516 | bytes(self._shm._mm[cursor:cursor + 44]), | |
| 517 | dtype=np.float32, | |
| 518 | ) | |
| 519 | cursor += 44 | |
| 520 | self._apply_material_to_mesh(mesh, mat_data) |
| … | … | @@ -23,6 +23,8 @@ using namespace godot; |
| 23 | 23 | // [uint32 has_normals] [float32×3 × vert_count normals] |
| 24 | 24 | // [uint32 idx_count] [int32 × idx_count indices] |
| 25 | 25 | // [uint32 smooth_count] [uint8 × smooth_count flags] (0=flat, 1=smooth) |
| 26 | // [uint32 uv_count] [float32×2 × uv_count uvs] | |
| 27 | // [uint32 has_material] [44 bytes material data if has_material] | |
| 26 | 28 | |
| 27 | 29 | static constexpr uint32_t TRANSFORM_SIZE = 64; // 4×4 float matrix |
| 28 | 30 | |
| … | … | @@ -161,7 +163,7 @@ Error BlenderBridge::start_server(const String &socket_path) { |
| 161 | 163 | _known_instance_ids.clear(); |
| 162 | 164 | _next_object_id = 1; |
| 163 | 165 | |
| 164 | UtilityFunctions::print("BlenderBridge v0.8.0: Server started (shm=", _shm_name, ", sock=", socket_path, ", regions=2x", (int)_region_size, ")"); | |
| 166 | UtilityFunctions::print("BlenderBridge v0.12.0: Server started (shm=", _shm_name, ", sock=", socket_path, ", regions=2x", (int)_region_size, ")"); | |
| 165 | 167 | return OK; |
| 166 | 168 | } |
| 167 | 169 | |
| … | … | @@ -423,9 +425,61 @@ uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob |
| 423 | 425 | } |
| 424 | 426 | } |
| 425 | 427 | } |
| 428 | ||
| 429 | // Write UV coordinates (float32×2 per vertex) | |
| 430 | bool has_uvs = arrays.size() > Mesh::ARRAY_TEX_UV && arrays[Mesh::ARRAY_TEX_UV].get_type() != Variant::NIL; | |
| 431 | uint32_t uv_count = 0; | |
| 432 | if (has_uvs) { | |
| 433 | PackedVector2Array uvs = arrays[Mesh::ARRAY_TEX_UV]; | |
| 434 | uv_count = uvs.size(); | |
| 435 | } | |
| 436 | memcpy(blob_base + blob_offset, &uv_count, 4); | |
| 437 | blob_offset += 4; | |
| 438 | ||
| 439 | if (uv_count > 0) { | |
| 440 | PackedVector2Array uvs = arrays[Mesh::ARRAY_TEX_UV]; | |
| 441 | const Vector2 *uv_r = uvs.ptr(); | |
| 442 | for (uint32_t v = 0; v < uv_count; v++) { | |
| 443 | float uv[2] = { uv_r[v].x, uv_r[v].y }; | |
| 444 | memcpy(blob_base + blob_offset, uv, 8); | |
| 445 | blob_offset += 8; | |
| 446 | } | |
| 447 | } | |
| 426 | 448 | } |
| 427 | 449 | } |
| 428 | 450 | |
| 451 | // Write material data (only for meshes) | |
| 452 | if (entry.object_type == OBJ_MESH && mesh_inst) { | |
| 453 | Ref<Material> mat_ref = mesh_inst->get_surface_override_material(0); | |
| 454 | if (!mat_ref.is_valid() && mesh_inst->get_mesh().is_valid() && mesh_inst->get_mesh()->get_surface_count() > 0) { | |
| 455 | mat_ref = mesh_inst->get_mesh()->surface_get_material(0); | |
| 456 | } | |
| 457 | Ref<StandardMaterial3D> std_mat = Object::cast_to<StandardMaterial3D>(mat_ref.ptr()); | |
| 458 | if (std_mat.is_valid()) { | |
| 459 | uint32_t has_material = 1; | |
| 460 | memcpy(blob_base + blob_offset, &has_material, 4); | |
| 461 | blob_offset += 4; | |
| 462 | Color albedo = std_mat->get_albedo(); | |
| 463 | float mat_data[11]; | |
| 464 | mat_data[0] = albedo.r; mat_data[1] = albedo.g; mat_data[2] = albedo.b; mat_data[3] = albedo.a; | |
| 465 | mat_data[4] = std_mat->get_roughness(); | |
| 466 | mat_data[5] = std_mat->get_metallic(); | |
| 467 | Color emission = std_mat->get_emission(); | |
| 468 | mat_data[6] = emission.r; mat_data[7] = emission.g; mat_data[8] = emission.b; mat_data[9] = emission.a; | |
| 469 | mat_data[10] = std_mat->get_emission_energy_multiplier(); | |
| 470 | memcpy(blob_base + blob_offset, mat_data, 44); | |
| 471 | blob_offset += 44; | |
| 472 | } else { | |
| 473 | uint32_t has_material = 0; | |
| 474 | memcpy(blob_base + blob_offset, &has_material, 4); | |
| 475 | blob_offset += 4; | |
| 476 | } | |
| 477 | } else if (entry.object_type == OBJ_MESH) { | |
| 478 | uint32_t has_material = 0; | |
| 479 | memcpy(blob_base + blob_offset, &has_material, 4); | |
| 480 | blob_offset += 4; | |
| 481 | } | |
| 482 | ||
| 429 | 483 | entry.data_size = blob_offset - entry.data_offset; |
| 430 | 484 | entry.sequence = _export_sequence; |
| 431 | 485 | write_object_entry(_write_base, index, entry); |
| … | … | @@ -801,6 +855,37 @@ void BlenderBridge::import_changes(Node *root) { |
| 801 | 855 | cursor += smooth_count; |
| 802 | 856 | } |
| 803 | 857 | |
| 858 | ||
| 859 | // Read UV coordinates (after smooth flags) | |
| 860 | PackedVector2Array uvs; | |
| 861 | uint32_t uv_count = 0; | |
| 862 | if (cursor + 4 <= data_end) { | |
| 863 | memcpy(&uv_count, base + cursor, 4); | |
| 864 | cursor += 4; | |
| 865 | } | |
| 866 | ||
| 867 | if (uv_count > 0 && cursor + uv_count * 8 <= data_end) { | |
| 868 | uvs.resize(uv_count); | |
| 869 | Vector2 *uv_w = uvs.ptrw(); | |
| 870 | for (uint32_t v = 0; v < uv_count; v++) { | |
| 871 | float uv[2]; | |
| 872 | memcpy(uv, base + cursor, 8); | |
| 873 | cursor += 8; | |
| 874 | uv_w[v].x = uv[0]; | |
| 875 | uv_w[v].y = uv[1]; | |
| 876 | } | |
| 877 | ||
| 878 | // Reverse UV winding for per-corner data (same as positions/normals) | |
| 879 | if (idx_count == 0 && uv_count >= 3) { | |
| 880 | Vector2 *uvw = uvs.ptrw(); | |
| 881 | for (uint32_t t = 0; t + 2 < uv_count; t += 3) { | |
| 882 | Vector2 tmp = uvw[t + 1]; | |
| 883 | uvw[t + 1] = uvw[t + 2]; | |
| 884 | uvw[t + 2] = tmp; | |
| 885 | } | |
| 886 | } | |
| 887 | } | |
| 888 | ||
| 804 | 889 | // Handle flat shading: duplicate vertices with face normals |
| 805 | 890 | if (!smooth_flags.empty() && normals.size() > 0 && indices.size() > 0) { |
| 806 | 891 | uint32_t tri_count = indices.size() / 3; |
| … | … | @@ -812,8 +897,10 @@ void BlenderBridge::import_changes(Node *root) { |
| 812 | 897 | if (has_flat) { |
| 813 | 898 | PackedVector3Array new_positions = positions; |
| 814 | 899 | PackedVector3Array new_normals = normals; |
| 900 | PackedVector2Array new_uvs = uvs; | |
| 815 | 901 | PackedInt32Array new_indices; |
| 816 | 902 | const Vector3 *orig_pos = positions.ptr(); |
| 903 | const Vector2 *orig_uv = uvs.size() > 0 ? uvs.ptr() : nullptr; | |
| 817 | 904 | |
| 818 | 905 | for (uint32_t t = 0; t < tri_count; t++) { |
| 819 | 906 | int32_t i0 = indices[t * 3]; |
| … | … | @@ -835,6 +922,12 @@ void BlenderBridge::import_changes(Node *root) { |
| 835 | 922 | new_normals.push_back(face_n); |
| 836 | 923 | new_normals.push_back(face_n); |
| 837 | 924 | |
| 925 | if (orig_uv) { | |
| 926 | new_uvs.push_back(orig_uv[i0]); | |
| 927 | new_uvs.push_back(orig_uv[i1]); | |
| 928 | new_uvs.push_back(orig_uv[i2]); | |
| 929 | } | |
| 930 | ||
| 838 | 931 | new_indices.push_back(base_idx); |
| 839 | 932 | new_indices.push_back(base_idx + 1); |
| 840 | 933 | new_indices.push_back(base_idx + 2); |
| … | … | @@ -848,6 +941,7 @@ void BlenderBridge::import_changes(Node *root) { |
| 848 | 941 | |
| 849 | 942 | positions = new_positions; |
| 850 | 943 | normals = new_normals; |
| 944 | uvs = new_uvs; | |
| 851 | 945 | indices = new_indices; |
| 852 | 946 | } |
| 853 | 947 | } |
| … | … | @@ -871,9 +965,39 @@ void BlenderBridge::import_changes(Node *root) { |
| 871 | 965 | if (indices.size() > 0) { |
| 872 | 966 | arrays[Mesh::ARRAY_INDEX] = indices; |
| 873 | 967 | } |
| 968 | if (uvs.size() > 0) { | |
| 969 | arrays[Mesh::ARRAY_TEX_UV] = uvs; | |
| 970 | } | |
| 874 | 971 | |
| 875 | 972 | arr_mesh->clear_surfaces(); |
| 876 | 973 | arr_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays); |
| 974 | ||
| 975 | // Read material data (after UV data) | |
| 976 | uint32_t has_material = 0; | |
| 977 | if (cursor + 4 <= data_end) { | |
| 978 | memcpy(&has_material, base + cursor, 4); | |
| 979 | cursor += 4; | |
| 980 | } | |
| 981 | ||
| 982 | if (has_material && cursor + 44 <= data_end) { | |
| 983 | float mat_data[11]; | |
| 984 | memcpy(mat_data, base + cursor, 44); | |
| 985 | cursor += 44; | |
| 986 | ||
| 987 | Ref<StandardMaterial3D> std_mat; | |
| 988 | std_mat.instantiate(); | |
| 989 | std_mat->set_albedo(Color(mat_data[0], mat_data[1], mat_data[2], mat_data[3])); | |
| 990 | std_mat->set_roughness(mat_data[4]); | |
| 991 | std_mat->set_metallic(mat_data[5]); | |
| 992 | Color emission(mat_data[6], mat_data[7], mat_data[8], mat_data[9]); | |
| 993 | if (emission.r > 0.0f || emission.g > 0.0f || emission.b > 0.0f) { | |
| 994 | std_mat->set_feature(BaseMaterial3D::FEATURE_EMISSION, true); | |
| 995 | std_mat->set_emission(emission); | |
| 996 | std_mat->set_emission_energy_multiplier(mat_data[10]); | |
| 997 | } | |
| 998 | mesh_inst->set_surface_override_material(0, std_mat); | |
| 999 | } | |
| 1000 | ||
| 877 | 1001 | } |
| 878 | 1002 | } |
| 879 | 1003 | } |
| … | … | @@ -1188,10 +1312,63 @@ void BlenderBridge::_detect_and_push_changes() { |
| 1188 | 1312 | blob_offset += tri_count; |
| 1189 | 1313 | } |
| 1190 | 1314 | } |
| 1315 | ||
| 1316 | // Write UV coordinates (float32×2 per vertex) | |
| 1317 | bool has_uvs = arrays.size() > Mesh::ARRAY_TEX_UV && arrays[Mesh::ARRAY_TEX_UV].get_type() != Variant::NIL; | |
| 1318 | uint32_t uv_count = 0; | |
| 1319 | if (has_uvs) { | |
| 1320 | PackedVector2Array uvs = arrays[Mesh::ARRAY_TEX_UV]; | |
| 1321 | uv_count = uvs.size(); | |
| 1322 | } | |
| 1323 | memcpy(base + blob_offset, &uv_count, 4); | |
| 1324 | blob_offset += 4; | |
| 1325 | ||
| 1326 | if (uv_count > 0) { | |
| 1327 | PackedVector2Array uvs = arrays[Mesh::ARRAY_TEX_UV]; | |
| 1328 | const Vector2 *uv_r = uvs.ptr(); | |
| 1329 | for (uint32_t v = 0; v < uv_count; v++) { | |
| 1330 | float uv[2] = { uv_r[v].x, uv_r[v].y }; | |
| 1331 | memcpy(base + blob_offset, uv, 8); | |
| 1332 | blob_offset += 8; | |
| 1333 | } | |
| 1334 | } | |
| 1191 | 1335 | } |
| 1192 | 1336 | } |
| 1193 | 1337 | } |
| 1194 | 1338 | |
| 1339 | ||
| 1340 | // Write material data (only for meshes) | |
| 1341 | if (entry.object_type == OBJ_MESH && mesh_inst) { | |
| 1342 | Ref<Material> mat_ref = mesh_inst->get_surface_override_material(0); | |
| 1343 | if (!mat_ref.is_valid() && mesh_inst->get_mesh().is_valid() && mesh_inst->get_mesh()->get_surface_count() > 0) { | |
| 1344 | mat_ref = mesh_inst->get_mesh()->surface_get_material(0); | |
| 1345 | } | |
| 1346 | Ref<StandardMaterial3D> std_mat = Object::cast_to<StandardMaterial3D>(mat_ref.ptr()); | |
| 1347 | if (std_mat.is_valid()) { | |
| 1348 | uint32_t has_material = 1; | |
| 1349 | memcpy(base + blob_offset, &has_material, 4); | |
| 1350 | blob_offset += 4; | |
| 1351 | Color albedo = std_mat->get_albedo(); | |
| 1352 | float mat_data[11]; | |
| 1353 | mat_data[0] = albedo.r; mat_data[1] = albedo.g; mat_data[2] = albedo.b; mat_data[3] = albedo.a; | |
| 1354 | mat_data[4] = std_mat->get_roughness(); | |
| 1355 | mat_data[5] = std_mat->get_metallic(); | |
| 1356 | Color emission = std_mat->get_emission(); | |
| 1357 | mat_data[6] = emission.r; mat_data[7] = emission.g; mat_data[8] = emission.b; mat_data[9] = emission.a; | |
| 1358 | mat_data[10] = std_mat->get_emission_energy_multiplier(); | |
| 1359 | memcpy(base + blob_offset, mat_data, 44); | |
| 1360 | blob_offset += 44; | |
| 1361 | } else { | |
| 1362 | uint32_t has_material = 0; | |
| 1363 | memcpy(base + blob_offset, &has_material, 4); | |
| 1364 | blob_offset += 4; | |
| 1365 | } | |
| 1366 | } else if (entry.object_type == OBJ_MESH) { | |
| 1367 | uint32_t has_material = 0; | |
| 1368 | memcpy(base + blob_offset, &has_material, 4); | |
| 1369 | blob_offset += 4; | |
| 1370 | } | |
| 1371 | ||
| 1195 | 1372 | entry.data_size = blob_offset - entry.data_offset; |
| 1196 | 1373 | entry.sequence = (uint32_t)(_export_sequence + 1); |
| 1197 | 1374 | write_object_entry(_write_base, changed_count, entry); |
| … | … | @@ -5,9 +5,11 @@ |
| 5 | 5 | #include <godot_cpp/classes/mesh_instance3d.hpp> |
| 6 | 6 | #include <godot_cpp/classes/node.hpp> |
| 7 | 7 | #include <godot_cpp/classes/node3d.hpp> |
| 8 | #include <godot_cpp/classes/standard_material3d.hpp> | |
| 8 | 9 | #include <godot_cpp/core/class_db.hpp> |
| 9 | 10 | #include <godot_cpp/variant/dictionary.hpp> |
| 10 | 11 | #include <godot_cpp/variant/packed_float32_array.hpp> |
| 12 | #include <godot_cpp/variant/packed_vector2_array.hpp> | |
| 11 | 13 | #include <godot_cpp/variant/packed_vector3_array.hpp> |
| 12 | 14 | #include <godot_cpp/variant/string.hpp> |
| 13 | 15 | #include <godot_cpp/variant/transform3d.hpp> |