OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge commits / d310c33

Phase 2: UV transfer, materials, jitter reduction, auto-reconnect, geometry nodes (v0.12.0)
- Bidirectional UV coordinate sync (per-loop-corner, winding-aware) - Basic material transfer: StandardMaterial3D <-> Principled BSDF (albedo, roughness, metallic, emission color/strength) - Transform jitter reduction via FNV-1a hash cache - Geometry nodes support: NODES modifier detection + geometry hash cache to prevent flush storms from unchanged evaluated meshes - Auto-reconnection on unexpected disconnect (2s retry interval) - Connection refactor: intentional vs unexpected disconnect tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
d310c33216d4736d2ad6959f429262c4d7fa033c
by plankatron <fleeky@gmail.com> · 2026-03-03T19:04:49.000Z

Changed files (8) +506 -12

blender_addon/__init__.py +54 -3
……@@ -11,7 +11,7 @@ Usage:
1111
1212import bpy
1313
14ADDON_VERSION = "0.8.0"
14ADDON_VERSION = "0.12.0"
1515
1616# Support hot-reload (F3 > Reload Scripts)
1717if "shm_protocol" in locals():
……@@ -59,16 +59,25 @@ def _make_poll_timer(generation: int):
5959
6060def _poll_timer_impl(generation: int):
6161 """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
6363
6464 # Stop if this timer belongs to a stale generation (disconnect+reconnect happened)
6565 if generation != _timer_generation:
6666 return None
6767
68 if _conn is None or not _conn.connected:
68 if _conn is None:
6969 _timer_running = False
7070 return None # Stop timer
7171
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
7281 # Tick suppression counter (feedback loop prevention)
7382 if _detector is not None:
7483 _detector.unsuppress()
……@@ -76,6 +85,12 @@ def _poll_timer_impl(generation: int):
7685 # Poll UDS messages
7786 got_incoming = False
7887 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
7994 for msg in messages:
8095 text = msg.decode("utf-8", errors="replace")
8196 if text == "scene_exported":
……@@ -122,6 +137,42 @@ def _poll_timer_impl(generation: int):
122137 return POLL_INTERVAL # Continue polling
123138
124139
140def _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
125176# ── Addon Preferences ───────────────────────────────────────────────────────
126177
127178
blender_addon/blender_manifest.toml +1 -1
……@@ -2,7 +2,7 @@ schema_version = "1.0.0"
22id = "blender_godot_bridge"
33name = "Blender-Godot Bridge"
44tagline = "Real-time bidirectional scene sync with Godot"
5version = "0.8.0"
5version = "0.12.0"
66blender_version_min = "4.2.0"
77type = "add-on"
88maintainer = "blender-bridge <noreply@localhost>"
blender_addon/change_detector.py +143 -2
……@@ -84,6 +84,11 @@ class ChangeDetector:
8484 # objects that Godot sent, allowing modifier-dependent updates through.
8585 # Dict: object_name → remaining ticks
8686 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] = {}
8792
8893 def register(self):
8994 """Register the depsgraph handler."""
……@@ -142,6 +147,18 @@ class ChangeDetector:
142147 """Return the current ID map as {object_id: name} for syncing with SceneImporter."""
143148 return {obj_id: name for name, obj_id in self._name_to_id.items()}
144149
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
145162 # -- Depsgraph handler (accumulate only, no SHM writes) ----------------
146163
147164 def _on_depsgraph_update(self, scene, depsgraph):
……@@ -161,7 +178,17 @@ class ChangeDetector:
161178
162179 existing = self._pending.get(name, -1)
163180
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:
165192 # Geometry change: upgrade to at least VERTEX, or FULL if new
166193 if existing == -1:
167194 if name not in self._name_to_id:
……@@ -173,6 +200,12 @@ class ChangeDetector:
173200 # else: already VERTEX or FULL, keep it
174201
175202 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
176209 if existing == -1:
177210 if name not in self._name_to_id:
178211 self._pending[name] = UPDATE_FULL
……@@ -243,6 +276,8 @@ class ChangeDetector:
243276 written_updates[name] = UPDATE_DELETE
244277 entry_index += 1
245278 del self._name_to_id[name]
279 self._transform_hashes.pop(name, None)
280 self._geometry_hashes.pop(name, None)
246281 continue
247282
248283 # Look up the Blender object
……@@ -277,11 +312,12 @@ class ChangeDetector:
277312 blob_abs_offset += pad
278313 blob_rel_offset += pad
279314
280 # Write transform (always)
315 # Write transform (always) and update hash cache
281316 mat = np.array(obj.matrix_world, dtype=np.float32).flatten()
282317 self._shm.write_blob_array(blob_abs_offset, mat)
283318 blob_abs_offset += TRANSFORM_SIZE
284319 blob_rel_offset += TRANSFORM_SIZE
320 self._transform_hashes[name] = self._hash_transform(obj.matrix_world)
285321
286322 # Write vertex data for geometry updates
287323 if update_type in (UPDATE_VERTEX, UPDATE_FULL) and obj.type == "MESH":
……@@ -292,6 +328,32 @@ class ChangeDetector:
292328 mesh = obj.data
293329 eval_obj = None
294330
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
295357 if mesh is not None:
296358 mesh.calc_loop_triangles()
297359 tri_count = len(mesh.loop_triangles)
……@@ -369,9 +431,88 @@ class ChangeDetector:
369431 blob_abs_offset += 4
370432 blob_rel_offset += 4
371433
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
372465 if eval_obj is not None:
373466 eval_obj.to_mesh_clear()
374467
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
375516 data_rel_size = blob_rel_offset - data_rel_start
376517
377518 # Write object entry to region 1
blender_addon/connection.py +28 -5
……@@ -15,6 +15,7 @@ class BridgeConnection:
1515 self._sock = None
1616 self._path = socket_path
1717 self._recv_buf = bytearray()
18 self._disconnected_unexpectedly = False
1819
1920 # ── Lifecycle ────────────────────────────────────────────────────────
2021
……@@ -24,6 +25,7 @@ class BridgeConnection:
2425 self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
2526 self._sock.connect(self._path)
2627 self._sock.setblocking(False)
28 self._disconnected_unexpectedly = False
2729 # Send handshake
2830 self.send_message(b"hello")
2931 return True
……@@ -32,7 +34,7 @@ class BridgeConnection:
3234 return False
3335
3436 def close(self):
35 """Close the connection."""
37 """Intentional close — no auto-reconnect."""
3638 if self._sock is not None:
3739 try:
3840 self._sock.close()
……@@ -40,11 +42,32 @@ class BridgeConnection:
4042 pass
4143 self._sock = None
4244 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
4357
4458 @property
4559 def connected(self) -> bool:
4660 return self._sock is not None
4761
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
4871 # ── Send ─────────────────────────────────────────────────────────────
4972
5073 def send_message(self, data: bytes):
……@@ -54,7 +77,7 @@ class BridgeConnection:
5477 try:
5578 self._sock.sendall(struct.pack("<I", len(data)) + data)
5679 except OSError:
57 self.close()
80 self._close_unexpected()
5881
5982 # ── Receive (non-blocking) ───────────────────────────────────────────
6083
……@@ -69,13 +92,13 @@ class BridgeConnection:
6992 chunk = self._sock.recv(4096)
7093 if not chunk:
7194 # Server closed
72 self.close()
95 self._close_unexpected()
7396 return []
7497 self._recv_buf.extend(chunk)
7598 except BlockingIOError:
7699 pass # No more data available
77100 except OSError:
78 self.close()
101 self._close_unexpected()
79102 return []
80103
81104 # Parse complete messages
……@@ -83,7 +106,7 @@ class BridgeConnection:
83106 while len(self._recv_buf) >= 4:
84107 msg_len = struct.unpack_from("<I", self._recv_buf, 0)[0]
85108 if msg_len > 16 * 1024 * 1024: # 16 MB sanity limit
86 self.close()
109 self._close_unexpected()
87110 return messages
88111 if len(self._recv_buf) < 4 + msg_len:
89112 break # Incomplete
blender_addon/scene_import.py +100 -0
……@@ -255,6 +255,23 @@ class SceneImporter:
255255 bytes(self._shm._mm[cursor:cursor + smooth_count]),
256256 dtype=np.uint8,
257257 )
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()
258275
259276 mesh = bpy.data.meshes.new(obj_name)
260277 mesh.from_pydata(verts, [], faces)
……@@ -265,10 +282,54 @@ class SceneImporter:
265282 mesh.polygons.foreach_set("use_smooth", smooth_flags.tolist())
266283 mesh.update()
267284
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
268307 obj = bpy.data.objects.new(mesh.name, mesh)
269308 bpy.context.collection.objects.link(obj)
270309 return obj
271310
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
272333 def _import_camera(self, entry: dict, godot_name: str = ""):
273334 """Create a Blender camera."""
274335 obj_name = godot_name or f"bridge_camera_{entry['object_id']}"
……@@ -394,6 +455,23 @@ class SceneImporter:
394455 bytes(self._shm._mm[cursor:cursor + smooth_count]),
395456 dtype=np.uint8,
396457 )
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()
397475
398476 mesh = obj.data
399477 if len(mesh.vertices) == vert_count:
……@@ -418,3 +496,25 @@ class SceneImporter:
418496 if smooth_flags is not None and len(smooth_flags) == len(mesh.polygons):
419497 mesh.polygons.foreach_set("use_smooth", smooth_flags.tolist())
420498 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)
blender_bridge_addon.zip +0 -0
(no content changes — binary or rename)
src/blender_bridge.cpp +178 -1
……@@ -23,6 +23,8 @@ using namespace godot;
2323// [uint32 has_normals] [float32×3 × vert_count normals]
2424// [uint32 idx_count] [int32 × idx_count indices]
2525// [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]
2628
2729static constexpr uint32_t TRANSFORM_SIZE = 64; // 4×4 float matrix
2830
……@@ -161,7 +163,7 @@ Error BlenderBridge::start_server(const String &socket_path) {
161163 _known_instance_ids.clear();
162164 _next_object_id = 1;
163165
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, ")");
165167 return OK;
166168}
167169
……@@ -423,9 +425,61 @@ uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob
423425 }
424426 }
425427 }
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 }
426448 }
427449 }
428450
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
429483 entry.data_size = blob_offset - entry.data_offset;
430484 entry.sequence = _export_sequence;
431485 write_object_entry(_write_base, index, entry);
……@@ -801,6 +855,37 @@ void BlenderBridge::import_changes(Node *root) {
801855 cursor += smooth_count;
802856 }
803857
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
804889 // Handle flat shading: duplicate vertices with face normals
805890 if (!smooth_flags.empty() && normals.size() > 0 && indices.size() > 0) {
806891 uint32_t tri_count = indices.size() / 3;
……@@ -812,8 +897,10 @@ void BlenderBridge::import_changes(Node *root) {
812897 if (has_flat) {
813898 PackedVector3Array new_positions = positions;
814899 PackedVector3Array new_normals = normals;
900 PackedVector2Array new_uvs = uvs;
815901 PackedInt32Array new_indices;
816902 const Vector3 *orig_pos = positions.ptr();
903 const Vector2 *orig_uv = uvs.size() > 0 ? uvs.ptr() : nullptr;
817904
818905 for (uint32_t t = 0; t < tri_count; t++) {
819906 int32_t i0 = indices[t * 3];
……@@ -835,6 +922,12 @@ void BlenderBridge::import_changes(Node *root) {
835922 new_normals.push_back(face_n);
836923 new_normals.push_back(face_n);
837924
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
838931 new_indices.push_back(base_idx);
839932 new_indices.push_back(base_idx + 1);
840933 new_indices.push_back(base_idx + 2);
……@@ -848,6 +941,7 @@ void BlenderBridge::import_changes(Node *root) {
848941
849942 positions = new_positions;
850943 normals = new_normals;
944 uvs = new_uvs;
851945 indices = new_indices;
852946 }
853947 }
……@@ -871,9 +965,39 @@ void BlenderBridge::import_changes(Node *root) {
871965 if (indices.size() > 0) {
872966 arrays[Mesh::ARRAY_INDEX] = indices;
873967 }
968 if (uvs.size() > 0) {
969 arrays[Mesh::ARRAY_TEX_UV] = uvs;
970 }
874971
875972 arr_mesh->clear_surfaces();
876973 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
8771001 }
8781002 }
8791003 }
……@@ -1188,10 +1312,63 @@ void BlenderBridge::_detect_and_push_changes() {
11881312 blob_offset += tri_count;
11891313 }
11901314 }
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 }
11911335 }
11921336 }
11931337 }
11941338
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
11951372 entry.data_size = blob_offset - entry.data_offset;
11961373 entry.sequence = (uint32_t)(_export_sequence + 1);
11971374 write_object_entry(_write_base, changed_count, entry);
src/blender_bridge.h +2 -0
……@@ -5,9 +5,11 @@
55#include <godot_cpp/classes/mesh_instance3d.hpp>
66#include <godot_cpp/classes/node.hpp>
77#include <godot_cpp/classes/node3d.hpp>
8#include <godot_cpp/classes/standard_material3d.hpp>
89#include <godot_cpp/core/class_db.hpp>
910#include <godot_cpp/variant/dictionary.hpp>
1011#include <godot_cpp/variant/packed_float32_array.hpp>
12#include <godot_cpp/variant/packed_vector2_array.hpp>
1113#include <godot_cpp/variant/packed_vector3_array.hpp>
1214#include <godot_cpp/variant/string.hpp>
1315#include <godot_cpp/variant/transform3d.hpp>