| … | … | @@ -10,6 +10,7 @@ Usage: |
| 10 | 10 | """ |
| 11 | 11 | |
| 12 | 12 | import bpy |
| 13 | import numpy as np | |
| 13 | 14 | |
| 14 | 15 | def _read_manifest_version() -> str: |
| 15 | 16 | """Read version from blender_manifest.toml (single source of truth).""" |
| … | … | @@ -160,6 +161,37 @@ def _on_shm_resized(new_size: int, new_region_size: int): |
| 160 | 161 | print(f"BlenderBridge: Tile ring moved to offset {tile_ring_offset}") |
| 161 | 162 | |
| 162 | 163 | |
| 164 | def _send_image_file(img_name: str, tex_id: int): | |
| 165 | """Save Blender image as raw RGBA8 file and notify Godot of the path.""" | |
| 166 | global _conn | |
| 167 | ||
| 168 | if _conn is None or bpy is None: | |
| 169 | return | |
| 170 | ||
| 171 | image = bpy.data.images.get(img_name) | |
| 172 | if image is None: | |
| 173 | return | |
| 174 | ||
| 175 | w, h = image.size[0], image.size[1] | |
| 176 | if w == 0 or h == 0: | |
| 177 | return | |
| 178 | ||
| 179 | # Extract pixels as raw RGBA8 (Blender stores bottom-to-top, Godot expects top-to-bottom) | |
| 180 | pixels = np.empty(w * h * 4, dtype=np.float32) | |
| 181 | image.pixels.foreach_get(pixels) | |
| 182 | arr = pixels.reshape(h, w, 4) | |
| 183 | arr = np.flipud(arr) # flip Y: Blender bottom-up → Godot top-down | |
| 184 | uint8 = (np.clip(arr, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8) | |
| 185 | ||
| 186 | path = f"/tmp/bridge_tex_{tex_id}.rgba" | |
| 187 | with open(path, "wb") as f: | |
| 188 | f.write(uint8.tobytes()) | |
| 189 | ||
| 190 | msg = f"image_file:{tex_id},{w},{h},{path}" | |
| 191 | _conn.send_message(msg.encode()) | |
| 192 | print(f"BlenderBridge: Sent image '{img_name}' ({w}x{h}) as {path}") | |
| 193 | ||
| 194 | ||
| 163 | 195 | def _poll_timer_impl(generation: int): |
| 164 | 196 | """Called by Blender's timer to poll for updates from Godot.""" |
| 165 | 197 | global _conn, _shm, _importer, _detector, _baker, _timer_running |
| … | … | @@ -229,9 +261,8 @@ def _poll_timer_impl(generation: int): |
| 229 | 261 | tex_id = int(parts[1]) |
| 230 | 262 | _baker.register_texture(img_name, tex_id) |
| 231 | 263 | print(f"BlenderBridge: Registered texture '{img_name}' id={tex_id}") |
| 232 | # Bake immediately so tiles flow on this tick | |
| 233 | _baker.bake_full_image(img_name) | |
| 234 | print(f"BlenderBridge: Baked texture '{img_name}'") | |
| 264 | # Send full image file to Godot (replaces VT tile pipeline) | |
| 265 | _send_image_file(img_name, tex_id) | |
| 235 | 266 | |
| 236 | 267 | # Flush accumulated Blender changes to SHM and notify Godot |
| 237 | 268 | if _detector is not None: |
| … | … | @@ -243,13 +274,15 @@ def _poll_timer_impl(generation: int): |
| 243 | 274 | if _importer is not None: |
| 244 | 275 | _importer._id_to_name.update(_detector.get_id_map()) |
| 245 | 276 | |
| 246 | # Re-bake any images that were edited (texture paint, image editor) | |
| 277 | # Re-send any images that were edited (texture paint, image editor) | |
| 247 | 278 | if _detector is not None and _baker is not None: |
| 248 | 279 | # Keep detector's tracked images in sync with baker's registrations |
| 249 | 280 | _detector.set_tracked_images(set(_baker._texture_ids.keys())) |
| 250 | 281 | dirty_images = _detector.drain_dirty_images() |
| 251 | 282 | for img_name in dirty_images: |
| 252 | _baker.bake_full_image(img_name) | |
| 283 | tex_id = _baker._texture_ids.get(img_name) | |
| 284 | if tex_id is not None: | |
| 285 | _send_image_file(img_name, tex_id) | |
| 253 | 286 | |
| 254 | 287 | # Poll non-blocking procedural baker for completed bakes |
| 255 | 288 | if _proc_baker is not None and _conn is not None and _baker is not None: |
| … | … | @@ -261,7 +294,8 @@ def _poll_timer_impl(generation: int): |
| 261 | 294 | msg = f"request_texture:{img_name},{img_w},{img_h}" |
| 262 | 295 | _conn.send_message(msg.encode()) |
| 263 | 296 | else: |
| 264 | _baker.bake_full_image(img_name) | |
| 297 | tex_id = _baker._texture_ids[img_name] | |
| 298 | _send_image_file(img_name, tex_id) | |
| 265 | 299 | |
| 266 | 300 | # Re-bake procedural textures if shading changed (parameter edits) |
| 267 | 301 | if _detector is not None and _detector.had_shading_update(): |
| … | … | @@ -330,6 +364,10 @@ def _attempt_reconnect(): |
| 330 | 364 | _baker = TileBaker(_shm, tile_size=128, max_mips=8) |
| 331 | 365 | _baker.init_ring_buffer(tile_ring_offset, tile_ring_size) |
| 332 | 366 | |
| 367 | # Share texture ID map with change_detector | |
| 368 | if _detector is not None and _baker is not None: | |
| 369 | _detector._texture_ids = _baker._texture_ids | |
| 370 | ||
| 333 | 371 | _conn.clear_reconnect() |
| 334 | 372 | print("BlenderBridge: Reconnected successfully") |
| 335 | 373 | return POLL_INTERVAL # Resume normal polling |
| … | … | @@ -480,6 +518,9 @@ class BRIDGE_OT_connect(bpy.types.Operator): |
| 480 | 518 | _baker = TileBaker(_shm, tile_size=128, max_mips=8) |
| 481 | 519 | _baker.init_ring_buffer(tile_ring_offset, tile_ring_size) |
| 482 | 520 | |
| 521 | # Share texture ID map so change_detector can write tex_id in material blob | |
| 522 | _detector._texture_ids = _baker._texture_ids | |
| 523 | ||
| 483 | 524 | # Set up non-blocking procedural baker |
| 484 | 525 | _proc_baker = ProceduralBaker() |
| 485 | 526 |
| … | … | @@ -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.17.1" | |
| 5 | version = "0.18.0" | |
| 6 | 6 | blender_version_min = "4.2.0" |
| 7 | 7 | type = "add-on" |
| 8 | 8 | maintainer = "blender-bridge <noreply@localhost>" |
| … | … | @@ -104,6 +104,8 @@ class ChangeDetector: |
| 104 | 104 | self._had_shading_update = False |
| 105 | 105 | # Callback for notifying Godot of SHM resize: fn(new_size, new_region_size) |
| 106 | 106 | self._resize_callback = None |
| 107 | # Texture ID lookup: image_name → int tex_id (shared ref from TileBaker) | |
| 108 | self._texture_ids: dict[str, int] = {} | |
| 107 | 109 | |
| 108 | 110 | def register(self): |
| 109 | 111 | """Register the depsgraph handler.""" |
| … | … | @@ -727,6 +729,18 @@ class ChangeDetector: |
| 727 | 729 | blob_abs_offset += written |
| 728 | 730 | blob_rel_offset += written |
| 729 | 731 | |
| 732 | # Write texture_id: look up image texture on Base Color input | |
| 733 | tex_id = 0 | |
| 734 | base_color_input = bsdf.inputs.get("Base Color") | |
| 735 | if base_color_input and base_color_input.is_linked: | |
| 736 | for link in base_color_input.links: | |
| 737 | if link.from_node.type == "TEX_IMAGE" and link.from_node.image: | |
| 738 | tex_id = self._texture_ids.get(link.from_node.image.name, 0) | |
| 739 | break | |
| 740 | self._shm.write_blob(blob_abs_offset, struct.pack("<I", tex_id)) | |
| 741 | blob_abs_offset += 4 | |
| 742 | blob_rel_offset += 4 | |
| 743 | ||
| 730 | 744 | if not has_mat: |
| 731 | 745 | self._shm.write_blob( |
| 732 | 746 | blob_abs_offset, struct.pack("<I", 0) |
| … | … | @@ -292,16 +292,17 @@ class SceneImporter: |
| 292 | 292 | uv_layer.data.foreach_set("uv", uv_flat.tolist()) |
| 293 | 293 | mesh.update() |
| 294 | 294 | |
| 295 | # Read and apply material data | |
| 295 | # Read and apply material data (44 mat + 4 tex_id = 48 bytes) | |
| 296 | 296 | if cursor + 4 <= data_end: |
| 297 | 297 | has_material = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0] |
| 298 | 298 | cursor += 4 |
| 299 | if has_material and cursor + 44 <= data_end: | |
| 299 | if has_material and cursor + 48 <= data_end: | |
| 300 | 300 | mat_data = np.frombuffer( |
| 301 | 301 | bytes(self._shm._mm[cursor:cursor + 44]), |
| 302 | 302 | dtype=np.float32, |
| 303 | 303 | ) |
| 304 | 304 | cursor += 44 |
| 305 | cursor += 4 # skip texture_id (used by Godot import_changes only) | |
| 305 | 306 | self._apply_material_to_mesh(mesh, mat_data) |
| 306 | 307 | |
| 307 | 308 | obj = bpy.data.objects.new(mesh.name, mesh) |
| … | … | @@ -507,14 +508,15 @@ class SceneImporter: |
| 507 | 508 | uv_layer.data.foreach_set("uv", uv_flat.tolist()) |
| 508 | 509 | mesh.update() |
| 509 | 510 | |
| 510 | # Read and apply material data | |
| 511 | # Read and apply material data (44 mat + 4 tex_id = 48 bytes) | |
| 511 | 512 | if cursor + 4 <= data_end: |
| 512 | 513 | has_material = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0] |
| 513 | 514 | cursor += 4 |
| 514 | if has_material and cursor + 44 <= data_end: | |
| 515 | if has_material and cursor + 48 <= data_end: | |
| 515 | 516 | mat_data = np.frombuffer( |
| 516 | 517 | bytes(self._shm._mm[cursor:cursor + 44]), |
| 517 | 518 | dtype=np.float32, |
| 518 | 519 | ) |
| 519 | 520 | cursor += 44 |
| 521 | cursor += 4 # skip texture_id (used by Godot import_changes only) | |
| 520 | 522 | self._apply_material_to_mesh(mesh, mat_data) |
| … | … | @@ -264,7 +264,7 @@ Error BlenderBridge::start_server(const String &socket_path) { |
| 264 | 264 | _known_instance_ids.clear(); |
| 265 | 265 | _next_object_id = 1; |
| 266 | 266 | |
| 267 | UtilityFunctions::print("BlenderBridge v0.17.1: Server started (shm=", _shm_name, ", sock=", socket_path, ", regions=2x", (int)_region_size, ")"); | |
| 267 | UtilityFunctions::print("BlenderBridge v0.18.0: Server started (shm=", _shm_name, ", sock=", socket_path, ", regions=2x", (int)_region_size, ")"); | |
| 268 | 268 | return OK; |
| 269 | 269 | } |
| 270 | 270 | |
| … | … | @@ -570,6 +570,9 @@ uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob |
| 570 | 570 | mat_data[10] = std_mat->get_emission_energy_multiplier(); |
| 571 | 571 | memcpy(blob_base + blob_offset, mat_data, 44); |
| 572 | 572 | blob_offset += 44; |
| 573 | uint32_t tex_id = 0; // Godot-side meshes don't carry bridge texture IDs | |
| 574 | memcpy(blob_base + blob_offset, &tex_id, 4); | |
| 575 | blob_offset += 4; | |
| 573 | 576 | } else { |
| 574 | 577 | uint32_t has_material = 0; |
| 575 | 578 | memcpy(blob_base + blob_offset, &has_material, 4); |
| … | … | @@ -1075,16 +1078,19 @@ void BlenderBridge::import_changes(Node *root) { |
| 1075 | 1078 | arr_mesh->clear_surfaces(); |
| 1076 | 1079 | arr_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays); |
| 1077 | 1080 | |
| 1078 | // Read material data (after UV data) | |
| 1081 | // Read material data (after UV data): 44 bytes PBR + 4 bytes tex_id | |
| 1079 | 1082 | uint32_t has_material = 0; |
| 1080 | 1083 | if (cursor + 4 <= data_end) { |
| 1081 | 1084 | memcpy(&has_material, base + cursor, 4); |
| 1082 | 1085 | cursor += 4; |
| 1083 | 1086 | } |
| 1084 | if (has_material && cursor + 44 <= data_end) { | |
| 1087 | if (has_material && cursor + 48 <= data_end) { | |
| 1085 | 1088 | float mat_data[11]; |
| 1086 | 1089 | memcpy(mat_data, base + cursor, 44); |
| 1087 | 1090 | cursor += 44; |
| 1091 | uint32_t tex_id = 0; | |
| 1092 | memcpy(&tex_id, base + cursor, 4); | |
| 1093 | cursor += 4; | |
| 1088 | 1094 | |
| 1089 | 1095 | Color albedo(mat_data[0], mat_data[1], mat_data[2], mat_data[3]); |
| 1090 | 1096 | float roughness_val = mat_data[4]; |
| … | … | @@ -1102,6 +1108,12 @@ void BlenderBridge::import_changes(Node *root) { |
| 1102 | 1108 | std_mat->set_emission(emission); |
| 1103 | 1109 | std_mat->set_emission_energy_multiplier(mat_data[10]); |
| 1104 | 1110 | } |
| 1111 | ||
| 1112 | // Apply image texture if available | |
| 1113 | if (tex_id > 0 && _image_textures.has(tex_id)) { | |
| 1114 | std_mat->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, _image_textures[tex_id]); | |
| 1115 | } | |
| 1116 | ||
| 1105 | 1117 | mesh_inst->set_surface_override_material(0, std_mat); |
| 1106 | 1118 | } |
| 1107 | 1119 | |
| … | … | @@ -1313,6 +1325,36 @@ void BlenderBridge::_handle_uds_message(const UdsMessage &msg) { |
| 1313 | 1325 | } |
| 1314 | 1326 | return; |
| 1315 | 1327 | } |
| 1328 | ||
| 1329 | if (text.substr(0, 11) == "image_file:") { | |
| 1330 | // Parse: image_file:tex_id,w,h,filepath | |
| 1331 | // Blender saved a full image as raw RGBA8 and sent the path | |
| 1332 | size_t c1 = text.find(',', 11); | |
| 1333 | size_t c2 = (c1 != std::string::npos) ? text.find(',', c1 + 1) : std::string::npos; | |
| 1334 | size_t c3 = (c2 != std::string::npos) ? text.find(',', c2 + 1) : std::string::npos; | |
| 1335 | if (c3 != std::string::npos) { | |
| 1336 | int tex_id = std::atoi(text.substr(11, c1 - 11).c_str()); | |
| 1337 | int w = std::atoi(text.substr(c1 + 1, c2 - c1 - 1).c_str()); | |
| 1338 | int h = std::atoi(text.substr(c2 + 1, c3 - c2 - 1).c_str()); | |
| 1339 | std::string filepath = text.substr(c3 + 1); | |
| 1340 | ||
| 1341 | // Read raw RGBA8 file | |
| 1342 | String gd_path = String::utf8(filepath.c_str(), (int)filepath.size()); | |
| 1343 | Ref<FileAccess> f = FileAccess::open(gd_path, FileAccess::READ); | |
| 1344 | if (f.is_valid()) { | |
| 1345 | int64_t expected = (int64_t)w * h * 4; | |
| 1346 | PackedByteArray data = f->get_buffer(expected); | |
| 1347 | if (data.size() == expected) { | |
| 1348 | Ref<Image> img = Image::create_from_data(w, h, false, Image::FORMAT_RGBA8, data); | |
| 1349 | Ref<ImageTexture> tex = ImageTexture::create_from_image(img); | |
| 1350 | _image_textures[tex_id] = tex; | |
| 1351 | UtilityFunctions::print("BlenderBridge: Loaded image texture id=", tex_id, | |
| 1352 | " (", w, "x", h, ") from ", gd_path); | |
| 1353 | } | |
| 1354 | } | |
| 1355 | } | |
| 1356 | return; | |
| 1357 | } | |
| 1316 | 1358 | } |
| 1317 | 1359 | |
| 1318 | 1360 | // ── Change detection ────────────────────────────────────────────────────────── |
| … | … | @@ -1533,7 +1575,7 @@ void BlenderBridge::_detect_and_push_changes() { |
| 1533 | 1575 | } |
| 1534 | 1576 | |
| 1535 | 1577 | |
| 1536 | // Write material data (only for meshes) | |
| 1578 | // Write material data (only for meshes): 44 mat + 4 tex_id | |
| 1537 | 1579 | if (entry.object_type == OBJ_MESH && mesh_inst) { |
| 1538 | 1580 | Ref<Material> mat_ref = mesh_inst->get_surface_override_material(0); |
| 1539 | 1581 | if (!mat_ref.is_valid() && mesh_inst->get_mesh().is_valid() && mesh_inst->get_mesh()->get_surface_count() > 0) { |
| … | … | @@ -1554,6 +1596,9 @@ void BlenderBridge::_detect_and_push_changes() { |
| 1554 | 1596 | mat_data[10] = std_mat->get_emission_energy_multiplier(); |
| 1555 | 1597 | memcpy(base + blob_offset, mat_data, 44); |
| 1556 | 1598 | blob_offset += 44; |
| 1599 | uint32_t tex_id = 0; | |
| 1600 | memcpy(base + blob_offset, &tex_id, 4); | |
| 1601 | blob_offset += 4; | |
| 1557 | 1602 | } else { |
| 1558 | 1603 | uint32_t has_material = 0; |
| 1559 | 1604 | memcpy(base + blob_offset, &has_material, 4); |
| … | … | @@ -5,6 +5,9 @@ |
| 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/file_access.hpp> | |
| 9 | #include <godot_cpp/classes/image.hpp> | |
| 10 | #include <godot_cpp/classes/image_texture.hpp> | |
| 8 | 11 | #include <godot_cpp/classes/standard_material3d.hpp> |
| 9 | 12 | #include <godot_cpp/core/class_db.hpp> |
| 10 | 13 | #include <godot_cpp/variant/dictionary.hpp> |
| … | … | @@ -88,6 +91,9 @@ private: |
| 88 | 91 | int _import_cooldown = 0; // Frames to skip _detect_and_push_changes after import |
| 89 | 92 | NodePath _virtual_texture_path; |
| 90 | 93 | |
| 94 | // ── Image texture cache (whole-image transfer from Blender) ────────── | |
| 95 | HashMap<int, Ref<ImageTexture>> _image_textures; | |
| 96 | ||
| 91 | 97 | // ── UDS message handling ──────────────────────────────────────────── |
| 92 | 98 | void _handle_uds_message(const UdsMessage &msg); |
| 93 | 99 |