OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge commits / 3073ccd

Add whole-image texture transfer (replaces broken VT tile pipeline)
Textures now flow as raw RGBA8 files instead of VT tiles: - Blender saves full image to /tmp/bridge_tex_<id>.rgba (numpy, Y-flipped) - Sends image_file:<id>,<w>,<h>,<path> via UDS - Godot loads file, creates ImageTexture, caches by tex_id - Material blob extended to 48 bytes (44 PBR + 4 tex_id) - change_detector writes tex_id from baker's texture registry - import_changes applies cached ImageTexture to StandardMaterial3D albedo - Dirty image re-send on texture paint (replaces tile re-bake) v0.18.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3073ccdab9c4d20888bf6ac2c6ad6da7062432b7
by plankatron <fleeky@gmail.com> · 2026-03-04T20:04:31.000Z

Changed files (6) +123 -15

blender_addon/__init__.py +47 -6
……@@ -10,6 +10,7 @@ Usage:
1010"""
1111
1212import bpy
13import numpy as np
1314
1415def _read_manifest_version() -> str:
1516 """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):
160161 print(f"BlenderBridge: Tile ring moved to offset {tile_ring_offset}")
161162
162163
164def _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
163195def _poll_timer_impl(generation: int):
164196 """Called by Blender's timer to poll for updates from Godot."""
165197 global _conn, _shm, _importer, _detector, _baker, _timer_running
……@@ -229,9 +261,8 @@ def _poll_timer_impl(generation: int):
229261 tex_id = int(parts[1])
230262 _baker.register_texture(img_name, tex_id)
231263 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)
235266
236267 # Flush accumulated Blender changes to SHM and notify Godot
237268 if _detector is not None:
……@@ -243,13 +274,15 @@ def _poll_timer_impl(generation: int):
243274 if _importer is not None:
244275 _importer._id_to_name.update(_detector.get_id_map())
245276
246 # Re-bake any images that were edited (texture paint, image editor)
277 # Re-send any images that were edited (texture paint, image editor)
247278 if _detector is not None and _baker is not None:
248279 # Keep detector's tracked images in sync with baker's registrations
249280 _detector.set_tracked_images(set(_baker._texture_ids.keys()))
250281 dirty_images = _detector.drain_dirty_images()
251282 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)
253286
254287 # Poll non-blocking procedural baker for completed bakes
255288 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):
261294 msg = f"request_texture:{img_name},{img_w},{img_h}"
262295 _conn.send_message(msg.encode())
263296 else:
264 _baker.bake_full_image(img_name)
297 tex_id = _baker._texture_ids[img_name]
298 _send_image_file(img_name, tex_id)
265299
266300 # Re-bake procedural textures if shading changed (parameter edits)
267301 if _detector is not None and _detector.had_shading_update():
……@@ -330,6 +364,10 @@ def _attempt_reconnect():
330364 _baker = TileBaker(_shm, tile_size=128, max_mips=8)
331365 _baker.init_ring_buffer(tile_ring_offset, tile_ring_size)
332366
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
333371 _conn.clear_reconnect()
334372 print("BlenderBridge: Reconnected successfully")
335373 return POLL_INTERVAL # Resume normal polling
……@@ -480,6 +518,9 @@ class BRIDGE_OT_connect(bpy.types.Operator):
480518 _baker = TileBaker(_shm, tile_size=128, max_mips=8)
481519 _baker.init_ring_buffer(tile_ring_offset, tile_ring_size)
482520
521 # Share texture ID map so change_detector can write tex_id in material blob
522 _detector._texture_ids = _baker._texture_ids
523
483524 # Set up non-blocking procedural baker
484525 _proc_baker = ProceduralBaker()
485526
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.17.1"
5version = "0.18.0"
66blender_version_min = "4.2.0"
77type = "add-on"
88maintainer = "blender-bridge <noreply@localhost>"
blender_addon/change_detector.py +14 -0
……@@ -104,6 +104,8 @@ class ChangeDetector:
104104 self._had_shading_update = False
105105 # Callback for notifying Godot of SHM resize: fn(new_size, new_region_size)
106106 self._resize_callback = None
107 # Texture ID lookup: image_name → int tex_id (shared ref from TileBaker)
108 self._texture_ids: dict[str, int] = {}
107109
108110 def register(self):
109111 """Register the depsgraph handler."""
……@@ -727,6 +729,18 @@ class ChangeDetector:
727729 blob_abs_offset += written
728730 blob_rel_offset += written
729731
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
730744 if not has_mat:
731745 self._shm.write_blob(
732746 blob_abs_offset, struct.pack("<I", 0)
blender_addon/scene_import.py +6 -4
……@@ -292,16 +292,17 @@ class SceneImporter:
292292 uv_layer.data.foreach_set("uv", uv_flat.tolist())
293293 mesh.update()
294294
295 # Read and apply material data
295 # Read and apply material data (44 mat + 4 tex_id = 48 bytes)
296296 if cursor + 4 <= data_end:
297297 has_material = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0]
298298 cursor += 4
299 if has_material and cursor + 44 <= data_end:
299 if has_material and cursor + 48 <= data_end:
300300 mat_data = np.frombuffer(
301301 bytes(self._shm._mm[cursor:cursor + 44]),
302302 dtype=np.float32,
303303 )
304304 cursor += 44
305 cursor += 4 # skip texture_id (used by Godot import_changes only)
305306 self._apply_material_to_mesh(mesh, mat_data)
306307
307308 obj = bpy.data.objects.new(mesh.name, mesh)
……@@ -507,14 +508,15 @@ class SceneImporter:
507508 uv_layer.data.foreach_set("uv", uv_flat.tolist())
508509 mesh.update()
509510
510 # Read and apply material data
511 # Read and apply material data (44 mat + 4 tex_id = 48 bytes)
511512 if cursor + 4 <= data_end:
512513 has_material = struct.unpack("<I", bytes(self._shm._mm[cursor:cursor + 4]))[0]
513514 cursor += 4
514 if has_material and cursor + 44 <= data_end:
515 if has_material and cursor + 48 <= data_end:
515516 mat_data = np.frombuffer(
516517 bytes(self._shm._mm[cursor:cursor + 44]),
517518 dtype=np.float32,
518519 )
519520 cursor += 44
521 cursor += 4 # skip texture_id (used by Godot import_changes only)
520522 self._apply_material_to_mesh(mesh, mat_data)
src/blender_bridge.cpp +49 -4
……@@ -264,7 +264,7 @@ Error BlenderBridge::start_server(const String &socket_path) {
264264 _known_instance_ids.clear();
265265 _next_object_id = 1;
266266
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, ")");
268268 return OK;
269269}
270270
……@@ -570,6 +570,9 @@ uint32_t BlenderBridge::_export_node(Node3D *node, uint32_t index, uint8_t *blob
570570 mat_data[10] = std_mat->get_emission_energy_multiplier();
571571 memcpy(blob_base + blob_offset, mat_data, 44);
572572 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;
573576 } else {
574577 uint32_t has_material = 0;
575578 memcpy(blob_base + blob_offset, &has_material, 4);
……@@ -1075,16 +1078,19 @@ void BlenderBridge::import_changes(Node *root) {
10751078 arr_mesh->clear_surfaces();
10761079 arr_mesh->add_surface_from_arrays(Mesh::PRIMITIVE_TRIANGLES, arrays);
10771080
1078 // Read material data (after UV data)
1081 // Read material data (after UV data): 44 bytes PBR + 4 bytes tex_id
10791082 uint32_t has_material = 0;
10801083 if (cursor + 4 <= data_end) {
10811084 memcpy(&has_material, base + cursor, 4);
10821085 cursor += 4;
10831086 }
1084 if (has_material && cursor + 44 <= data_end) {
1087 if (has_material && cursor + 48 <= data_end) {
10851088 float mat_data[11];
10861089 memcpy(mat_data, base + cursor, 44);
10871090 cursor += 44;
1091 uint32_t tex_id = 0;
1092 memcpy(&tex_id, base + cursor, 4);
1093 cursor += 4;
10881094
10891095 Color albedo(mat_data[0], mat_data[1], mat_data[2], mat_data[3]);
10901096 float roughness_val = mat_data[4];
……@@ -1102,6 +1108,12 @@ void BlenderBridge::import_changes(Node *root) {
11021108 std_mat->set_emission(emission);
11031109 std_mat->set_emission_energy_multiplier(mat_data[10]);
11041110 }
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
11051117 mesh_inst->set_surface_override_material(0, std_mat);
11061118 }
11071119
……@@ -1313,6 +1325,36 @@ void BlenderBridge::_handle_uds_message(const UdsMessage &msg) {
13131325 }
13141326 return;
13151327 }
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 }
13161358}
13171359
13181360// ── Change detection ──────────────────────────────────────────────────────────
……@@ -1533,7 +1575,7 @@ void BlenderBridge::_detect_and_push_changes() {
15331575 }
15341576
15351577
1536 // Write material data (only for meshes)
1578 // Write material data (only for meshes): 44 mat + 4 tex_id
15371579 if (entry.object_type == OBJ_MESH && mesh_inst) {
15381580 Ref<Material> mat_ref = mesh_inst->get_surface_override_material(0);
15391581 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() {
15541596 mat_data[10] = std_mat->get_emission_energy_multiplier();
15551597 memcpy(base + blob_offset, mat_data, 44);
15561598 blob_offset += 44;
1599 uint32_t tex_id = 0;
1600 memcpy(base + blob_offset, &tex_id, 4);
1601 blob_offset += 4;
15571602 } else {
15581603 uint32_t has_material = 0;
15591604 memcpy(base + blob_offset, &has_material, 4);
src/blender_bridge.h +6 -0
……@@ -5,6 +5,9 @@
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/file_access.hpp>
9#include <godot_cpp/classes/image.hpp>
10#include <godot_cpp/classes/image_texture.hpp>
811#include <godot_cpp/classes/standard_material3d.hpp>
912#include <godot_cpp/core/class_db.hpp>
1013#include <godot_cpp/variant/dictionary.hpp>
……@@ -88,6 +91,9 @@ private:
8891 int _import_cooldown = 0; // Frames to skip _detect_and_push_changes after import
8992 NodePath _virtual_texture_path;
9093
94 // ── Image texture cache (whole-image transfer from Blender) ──────────
95 HashMap<int, Ref<ImageTexture>> _image_textures;
96
9197 // ── UDS message handling ────────────────────────────────────────────
9298 void _handle_uds_message(const UdsMessage &msg);
9399