OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge commits / 52a353e

Non-blocking procedural baking and bug fixes (v0.16.0)
Replace blocking inline bake with subprocess-based baking triggered by UI button. Fix vert_count==0 rejection for empty meshes and reconnect texture spam caused by lost _texture_ids. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
52a353e6509b3eac4e8ae210a10497a058e11a07
by plankatron <fleeky@gmail.com> · 2026-03-04T03:30:25.000Z

Changed files (6) +309 -126

blender_addon/__init__.py +80 -122
……@@ -11,7 +11,7 @@ Usage:
1111
1212import bpy
1313
14ADDON_VERSION = "0.15.0"
14ADDON_VERSION = "0.16.0"
1515
1616# Support hot-reload (F3 > Reload Scripts)
1717if "shm_protocol" in locals():
……@@ -22,10 +22,12 @@ if "shm_protocol" in locals():
2222 importlib.reload(scene_import) # noqa: F821
2323 importlib.reload(change_detector) # noqa: F821
2424 importlib.reload(tile_baker) # noqa: F821
25 importlib.reload(procedural_baker) # noqa: F821
2526
26from . import change_detector, connection, scene_import, shm_protocol, tile_baker
27from . import change_detector, connection, procedural_baker, scene_import, shm_protocol, tile_baker
2728from .change_detector import ChangeDetector
2829from .connection import BridgeConnection
30from .procedural_baker import ProceduralBaker
2931from .scene_import import SceneImporter
3032from .shm_protocol import ShmRegion
3133from .tile_baker import TileBaker
……@@ -37,10 +39,9 @@ _conn: BridgeConnection | None = None
3739_importer: SceneImporter | None = None
3840_detector: ChangeDetector | None = None
3941_baker: TileBaker | None = None
42_proc_baker: ProceduralBaker | None = None
4043_timer_running = False
4144_timer_generation = 0 # Incremented on each connect to invalidate stale timers
42_sent_procedurals: set = set() # "obj_name:mat_name" already baked+registered
43_baked_images: dict = {} # "obj_name:mat_name" → baked image name
4445
4546SHM_NAME = "/blender_bridge"
4647SHM_SIZE = 24 * 1024 * 1024 # 24 MB (8 MB region 0 + 8 MB region 1 + 8 MB tile ring)
……@@ -65,7 +66,7 @@ def _scan_material_textures():
6566 Handles image textures (existing pipeline), procedural textures (bake first),
6667 and ColorRamp chains (trace upstream recursively).
6768 """
68 global _conn, _baker, _sent_procedurals, _baked_images
69 global _conn, _baker, _proc_baker
6970
7071 if _conn is None or _baker is None:
7172 return
……@@ -101,7 +102,7 @@ _PROCEDURAL_TYPES = {
101102
102103def _handle_texture_node(obj, mat, from_node, image_requested):
103104 """Route a texture node to the appropriate handler."""
104 global _conn, _baker, _sent_procedurals, _baked_images
105 global _conn, _baker, _proc_baker
105106
106107 # Image textures → existing VT pipeline (no bake)
107108 if from_node.type == "TEX_IMAGE" and from_node.image is not None:
……@@ -123,20 +124,10 @@ def _handle_texture_node(obj, mat, from_node, image_requested):
123124 _handle_texture_node(obj, mat, inner_link.from_node, image_requested)
124125 return
125126
126 # Procedural textures → bake to image, then VT pipeline
127 # Procedural textures → queue for non-blocking background bake
127128 if from_node.type in _PROCEDURAL_TYPES:
128 mat_key = f"{obj.name}:{mat.name}"
129 if mat_key in _sent_procedurals:
130 return
131 bake_img = _bake_material_to_image(obj, mat)
132 if bake_img is not None:
133 _baked_images[mat_key] = bake_img.name
134 _sent_procedurals.add(mat_key)
135 img_w, img_h = bake_img.size[0], bake_img.size[1]
136 if bake_img.name not in _baker._texture_ids:
137 msg = f"request_texture:{bake_img.name},{img_w},{img_h}"
138 _conn.send_message(msg.encode())
139 print(f"BlenderBridge: Baked procedural '{mat.name}' → requesting texture")
129 if _proc_baker is not None:
130 _proc_baker.queue(obj.name, mat.name)
140131
141132
142133TILE_RING_SIZE = 8 * 1024 * 1024 # 8 MB tile ring (fixed, does not grow)
……@@ -156,92 +147,6 @@ def _on_shm_resized(new_size: int, new_region_size: int):
156147 print(f"BlenderBridge: Tile ring moved to offset {tile_ring_offset}")
157148
158149
159BAKE_SIZE = 1024
160
161
162def _bake_material_to_image(obj, mat, size=BAKE_SIZE):
163 """Bake material's Base Color to an image using Cycles."""
164 import bpy
165
166 # Object must have UVs for baking
167 if not obj.data.uv_layers:
168 print(f"BlenderBridge: Skipping bake for '{obj.name}' — no UVs")
169 return None
170
171 scene = bpy.context.scene
172 old_engine = scene.render.engine
173
174 # Create or reuse bake target image
175 bake_name = f"_bb_bake_{mat.name}"
176 if bake_name in bpy.data.images:
177 bake_img = bpy.data.images[bake_name]
178 if bake_img.size[0] != size or bake_img.size[1] != size:
179 bpy.data.images.remove(bake_img)
180 bake_img = bpy.data.images.new(bake_name, size, size, alpha=True)
181 else:
182 bake_img = bpy.data.images.new(bake_name, size, size, alpha=True)
183
184 # Add temp Image Texture node as bake target (must be active node)
185 tree = mat.node_tree
186 bake_node = tree.nodes.new("ShaderNodeTexImage")
187 bake_node.image = bake_img
188 bake_node.name = "_bb_bake_target"
189 tree.nodes.active = bake_node
190
191 # Save selection state
192 old_active = bpy.context.view_layer.objects.active
193 old_selected = [o for o in bpy.context.selected_objects]
194
195 try:
196 # Use Cycles for baking
197 scene.render.engine = "CYCLES"
198 scene.cycles.samples = 4
199 scene.cycles.device = "CPU"
200
201 bpy.ops.object.select_all(action="DESELECT")
202 obj.select_set(True)
203 bpy.context.view_layer.objects.active = obj
204
205 bpy.ops.object.bake(type="DIFFUSE", pass_filter={"COLOR"}, margin=2)
206 print(f"BlenderBridge: Baked '{mat.name}' on '{obj.name}' ({size}x{size})")
207 return bake_img
208
209 except Exception as e:
210 print(f"BlenderBridge: Bake failed for '{mat.name}': {e}")
211 return None
212
213 finally:
214 # Clean up
215 tree.nodes.remove(bake_node)
216 scene.render.engine = old_engine
217 bpy.ops.object.select_all(action="DESELECT")
218 for o in old_selected:
219 if o and o.name in bpy.data.objects:
220 o.select_set(True)
221 if old_active and old_active.name in bpy.data.objects:
222 bpy.context.view_layer.objects.active = old_active
223
224
225def _rebake_procedurals():
226 """Re-bake all procedural textures when material parameters change."""
227 global _sent_procedurals, _baked_images, _baker
228 if _baker is None:
229 return
230
231 import bpy
232
233 for mat_key, img_name in _baked_images.items():
234 obj_name, mat_name = mat_key.split(":", 1)
235 obj = bpy.data.objects.get(obj_name)
236 mat = bpy.data.materials.get(mat_name)
237 if obj is None or mat is None:
238 continue
239 bake_img = _bake_material_to_image(obj, mat)
240 if bake_img is not None and bake_img.name in _baker._texture_ids:
241 _baker.bake_full_image(bake_img.name)
242 print(f"BlenderBridge: Re-baked procedural '{mat_name}'")
243
244
245150def _poll_timer_impl(generation: int):
246151 """Called by Blender's timer to poll for updates from Godot."""
247152 global _conn, _shm, _importer, _detector, _baker, _timer_running
……@@ -333,9 +238,28 @@ def _poll_timer_impl(generation: int):
333238 for img_name in dirty_images:
334239 _baker.bake_full_image(img_name)
335240
241 # Poll non-blocking procedural baker for completed bakes
242 if _proc_baker is not None and _conn is not None and _baker is not None:
243 for mat_key, img_name in _proc_baker.poll():
244 img = bpy.data.images.get(img_name)
245 if img is not None:
246 img_w, img_h = img.size[0], img.size[1]
247 if img_name not in _baker._texture_ids:
248 msg = f"request_texture:{img_name},{img_w},{img_h}"
249 _conn.send_message(msg.encode())
250 else:
251 _baker.bake_full_image(img_name)
252
336253 # Re-bake procedural textures if shading changed (parameter edits)
337254 if _detector is not None and _detector.had_shading_update():
338 _rebake_procedurals()
255 if _proc_baker is not None:
256 for mat_key in list(_proc_baker._results.keys()):
257 _proc_baker.invalidate(mat_key)
258 obj_name, mat_name = mat_key.split(":", 1)
259 _proc_baker.queue(obj_name, mat_name)
260 if _proc_baker.pending_count > 0:
261 _proc_baker._save_blend()
262 _proc_baker.start()
339263
340264 # Flush any pending tile_ready messages from the baker
341265 if _baker is not None:
……@@ -370,11 +294,16 @@ def _attempt_reconnect():
370294 _detector.register()
371295 _detector.set_resize_callback(_on_shm_resized)
372296
373 # Re-initialize tile baker (use actual region size, not default)
297 # Reuse existing baker to preserve _texture_ids (prevents re-requesting
298 # all textures on reconnect). Only update SHM reference and ring buffer.
374299 tile_ring_offset = _shm._region_size * 2
375300 tile_ring_size = _shm.size - tile_ring_offset
376 _baker = TileBaker(_shm, tile_size=128, max_mips=8)
377 _baker.init_ring_buffer(tile_ring_offset, tile_ring_size)
301 if _baker is not None:
302 _baker._shm = _shm
303 _baker.init_ring_buffer(tile_ring_offset, tile_ring_size)
304 else:
305 _baker = TileBaker(_shm, tile_size=128, max_mips=8)
306 _baker.init_ring_buffer(tile_ring_offset, tile_ring_size)
378307
379308 _conn.clear_reconnect()
380309 print("BlenderBridge: Reconnected successfully")
……@@ -480,13 +409,13 @@ class BRIDGE_OT_connect(bpy.types.Operator):
480409 bl_label = "Connect to Godot"
481410
482411 def execute(self, context):
483 global _shm, _conn, _importer, _detector, _baker, _timer_running, _timer_generation
484 global _sent_procedurals, _baked_images
485
486 _sent_procedurals = set()
487 _baked_images = {}
412 global _shm, _conn, _importer, _detector, _baker, _proc_baker
413 global _timer_running, _timer_generation
488414
489415 # Clean up any stale state from previous connection
416 if _proc_baker is not None:
417 _proc_baker.reset()
418 _proc_baker = None
490419 if _detector is not None:
491420 _detector.unregister()
492421 _detector = None
……@@ -526,6 +455,9 @@ class BRIDGE_OT_connect(bpy.types.Operator):
526455 _baker = TileBaker(_shm, tile_size=128, max_mips=8)
527456 _baker.init_ring_buffer(tile_ring_offset, tile_ring_size)
528457
458 # Set up non-blocking procedural baker
459 _proc_baker = ProceduralBaker()
460
529461 # Start polling timer with new generation (old timers auto-stop
530462 # when they detect their generation is stale)
531463 _timer_generation += 1
……@@ -545,17 +477,17 @@ class BRIDGE_OT_disconnect(bpy.types.Operator):
545477 bl_label = "Disconnect"
546478
547479 def execute(self, context):
548 global _shm, _conn, _importer, _detector, _baker, _timer_running
549 global _sent_procedurals, _baked_images
480 global _shm, _conn, _importer, _detector, _baker, _proc_baker, _timer_running
550481
551482 if _detector is not None:
552483 _detector.unregister()
553484 _detector = None
554485
555486 _baker = None
487 if _proc_baker is not None:
488 _proc_baker.reset()
489 _proc_baker = None
556490 _importer = None
557 _sent_procedurals = set()
558 _baked_images = {}
559491
560492 if _conn is not None:
561493 _conn.close()
……@@ -614,6 +546,21 @@ class BRIDGE_OT_finalize(bpy.types.Operator):
614546 return {"FINISHED"}
615547
616548
549class BRIDGE_OT_bake_textures(bpy.types.Operator):
550 """Bake procedural textures in background (non-blocking)"""
551
552 bl_idname = "bridge.bake_textures"
553 bl_label = "Bake Textures"
554
555 def execute(self, context):
556 if _proc_baker is None or _proc_baker.pending_count == 0:
557 self.report({"INFO"}, "No procedural textures to bake")
558 return {"CANCELLED"}
559 _proc_baker.start()
560 self.report({"INFO"}, f"Baking {_proc_baker.pending_count} texture(s) in background...")
561 return {"FINISHED"}
562
563
617564# ── UI Panel ─────────────────────────────────────────────────────────────────
618565
619566
……@@ -639,6 +586,16 @@ class BRIDGE_PT_panel(bpy.types.Panel):
639586 layout.operator("bridge.import_scene", icon="FILE_REFRESH")
640587 layout.operator("bridge.finalize", icon="EXPORT")
641588
589 # Procedural bake button
590 if _proc_baker is not None:
591 pending = _proc_baker.pending_count
592 if pending > 0:
593 layout.operator("bridge.bake_textures",
594 text=f"Bake Textures ({pending})",
595 icon="RENDER_STILL")
596 elif _proc_baker.is_baking:
597 layout.label(text="Baking...", icon="TIME")
598
642599 # Status info
643600 box = layout.box()
644601 box.label(text="Status: Connected", icon="CHECKMARK")
……@@ -663,6 +620,7 @@ _classes = (
663620 BRIDGE_OT_disconnect,
664621 BRIDGE_OT_import_scene,
665622 BRIDGE_OT_finalize,
623 BRIDGE_OT_bake_textures,
666624 BRIDGE_PT_panel,
667625)
668626
……@@ -673,17 +631,17 @@ def register():
673631
674632
675633def unregister():
676 global _shm, _conn, _importer, _detector, _baker, _timer_running
677 global _sent_procedurals, _baked_images
634 global _shm, _conn, _importer, _detector, _baker, _proc_baker, _timer_running
678635
679636 # Clean up connection
680637 if _detector is not None:
681638 _detector.unregister()
682639 _detector = None
683640 _baker = None
641 if _proc_baker is not None:
642 _proc_baker.reset()
643 _proc_baker = None
684644 _importer = None
685 _sent_procedurals = set()
686 _baked_images = {}
687645 if _conn is not None:
688646 _conn.close()
689647 _conn = None
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.15.0"
5version = "0.16.0"
66blender_version_min = "4.2.0"
77type = "add-on"
88maintainer = "blender-bridge <noreply@localhost>"
blender_addon/change_detector.py +8 -0
……@@ -658,6 +658,14 @@ class ChangeDetector:
658658 )
659659 blob_abs_offset += 4
660660 blob_rel_offset += 4
661 else:
662 # Empty mesh — write vert_count=0 so C++ side
663 # knows to skip geometry (protocol consistency)
664 self._shm.write_blob(
665 blob_abs_offset, struct.pack("<I", 0)
666 )
667 blob_abs_offset += 4
668 blob_rel_offset += 4
661669
662670 if eval_obj is not None:
663671 eval_obj.to_mesh_clear()
blender_addon/procedural_baker.py +214 -0
……@@ -0,0 +1,214 @@
1"""Non-blocking procedural texture baker using background Blender subprocess.
2
3Bakes procedural materials (Noise, Voronoi, etc.) to images by spawning
4`blender --background --python bake_script.py`, keeping the main UI responsive.
5"""
6
7import os
8import shutil
9import subprocess
10import tempfile
11
12import bpy
13
14
15class ProceduralBaker:
16 """Non-blocking procedural texture baker using background subprocess."""
17
18 def __init__(self):
19 self._queue = [] # list of {obj_name, mat_name, mat_key, size}
20 self._process = None # Active subprocess.Popen
21 self._current = None # Currently baking item dict
22 self._results = {} # mat_key -> baked image name
23 self._blend_path = None # Path to cached .blend save
24 self._temp_dir = None # Temp directory for bake files
25 self._started = False # Whether baking has been triggered
26
27 @property
28 def pending_count(self):
29 """Number of materials waiting to be baked."""
30 return len(self._queue)
31
32 @property
33 def is_baking(self):
34 """Whether a bake subprocess is currently running."""
35 return self._process is not None
36
37 def queue(self, obj_name, mat_name, size=1024):
38 """Add a material to the bake queue. Non-blocking."""
39 mat_key = f"{obj_name}:{mat_name}"
40 if mat_key in self._results:
41 return # Already baked
42 for item in self._queue:
43 if item["mat_key"] == mat_key:
44 return # Already queued
45 if self._current and self._current["mat_key"] == mat_key:
46 return # Currently baking
47 self._queue.append({
48 "obj_name": obj_name,
49 "mat_name": mat_name,
50 "mat_key": mat_key,
51 "size": size,
52 })
53
54 def invalidate(self, mat_key=None):
55 """Mark material(s) for re-baking (parameter changed)."""
56 if mat_key:
57 self._results.pop(mat_key, None)
58 else:
59 self._results.clear()
60
61 def start(self):
62 """Begin processing the bake queue. Saves .blend then starts first subprocess."""
63 if not self._queue:
64 return
65 self._started = True
66 self._save_blend()
67 self._start_next()
68
69 def poll(self):
70 """Check for completed bakes. Returns list of (mat_key, image_name)."""
71 completed = []
72 if self._process is not None:
73 ret = self._process.poll()
74 if ret is not None:
75 if ret == 0 and self._current:
76 img_name = self._load_result(self._current)
77 if img_name:
78 completed.append((self._current["mat_key"], img_name))
79 elif self._current:
80 print(f"BlenderBridge: Bake failed for '{self._current['mat_name']}' (exit {ret})")
81 if self._process.stderr:
82 err = self._process.stderr.read()
83 if err:
84 print(f" stderr: {err.decode('utf-8', errors='replace')[:500]}")
85 self._process = None
86 self._current = None
87
88 # Start next if idle and started
89 if self._process is None and self._queue and self._started:
90 self._start_next()
91
92 return completed
93
94 def cleanup(self):
95 """Kill subprocess and clean temp files."""
96 if self._process:
97 self._process.kill()
98 self._process = None
99 if self._temp_dir and os.path.exists(self._temp_dir):
100 shutil.rmtree(self._temp_dir, ignore_errors=True)
101 self._temp_dir = None
102 self._blend_path = None
103
104 def reset(self):
105 """Full reset (disconnect)."""
106 self.cleanup()
107 self._queue.clear()
108 self._results.clear()
109 self._current = None
110 self._started = False
111
112 # -- Internal --
113
114 def _save_blend(self):
115 """Save current .blend to temp for subprocess use."""
116 if self._temp_dir is None:
117 self._temp_dir = tempfile.mkdtemp(prefix="bb_bake_")
118 self._blend_path = os.path.join(self._temp_dir, "source.blend")
119 bpy.ops.wm.save_as_mainfile(filepath=self._blend_path, copy=True)
120
121 def _start_next(self):
122 """Start baking next queued material."""
123 if not self._queue:
124 self._started = False
125 return
126 item = self._queue.pop(0)
127
128 if self._blend_path is None or not os.path.exists(self._blend_path):
129 self._save_blend()
130
131 output_path = os.path.join(self._temp_dir, f"bake_{item['mat_name']}.png")
132 item["output_path"] = output_path
133
134 script = _BAKE_SCRIPT.format(
135 obj_name=item["obj_name"],
136 mat_name=item["mat_name"],
137 size=item["size"],
138 output=output_path.replace("\\", "\\\\"),
139 )
140 script_path = os.path.join(self._temp_dir, "bake_script.py")
141 with open(script_path, "w") as f:
142 f.write(script)
143
144 self._process = subprocess.Popen(
145 [bpy.app.binary_path, "--background", self._blend_path,
146 "--python", script_path],
147 stdout=subprocess.PIPE, stderr=subprocess.PIPE
148 )
149 self._current = item
150 print(f"BlenderBridge: Baking '{item['mat_name']}' in background ({item['size']}x{item['size']})")
151
152 def _load_result(self, item):
153 """Load baked image from disk into bpy.data.images."""
154 output = item["output_path"]
155 if not os.path.exists(output):
156 print(f"BlenderBridge: Bake output not found: {output}")
157 return None
158 img_name = f"_bb_bake_{item['mat_name']}"
159 if img_name in bpy.data.images:
160 bpy.data.images.remove(bpy.data.images[img_name])
161 img = bpy.data.images.load(output)
162 img.name = img_name
163 self._results[item["mat_key"]] = img_name
164 print(f"BlenderBridge: Loaded baked texture '{img_name}'")
165 return img_name
166
167
168_BAKE_SCRIPT = '''
169import bpy, sys
170
171obj_name = "{obj_name}"
172mat_name = "{mat_name}"
173size = {size}
174output = "{output}"
175
176scene = bpy.context.scene
177scene.render.engine = "CYCLES"
178scene.cycles.samples = 1
179scene.cycles.device = "CPU"
180
181obj = bpy.data.objects.get(obj_name)
182mat = bpy.data.materials.get(mat_name)
183
184if obj is None or mat is None:
185 print(f"Object '{{obj_name}}' or material '{{mat_name}}' not found")
186 sys.exit(1)
187
188if not obj.data.uv_layers:
189 print(f"No UV layers on '{{obj_name}}'")
190 sys.exit(1)
191
192# Create bake target image
193bake_img = bpy.data.images.new("_bake_target", size, size, alpha=True)
194
195# Add temp bake node
196tree = mat.node_tree
197bake_node = tree.nodes.new("ShaderNodeTexImage")
198bake_node.image = bake_img
199tree.nodes.active = bake_node
200
201bpy.ops.object.select_all(action="DESELECT")
202obj.select_set(True)
203bpy.context.view_layer.objects.active = obj
204
205try:
206 bpy.ops.object.bake(type="DIFFUSE", pass_filter={{"COLOR"}}, margin=2)
207 bake_img.filepath_raw = output
208 bake_img.file_format = "PNG"
209 bake_img.save()
210 print(f"Bake complete: {{output}}")
211except Exception as e:
212 print(f"Bake error: {{e}}")
213 sys.exit(1)
214'''
blender_bridge_addon.zip +0 -0
(no content changes — binary or rename)
src/blender_bridge.cpp +6 -3
……@@ -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.15.0: Server started (shm=", _shm_name, ", sock=", socket_path, ", regions=2x", (int)_region_size, ")");
267 UtilityFunctions::print("BlenderBridge v0.16.0: Server started (shm=", _shm_name, ", sock=", socket_path, ", regions=2x", (int)_region_size, ")");
268268 return OK;
269269}
270270
……@@ -867,10 +867,12 @@ void BlenderBridge::import_changes(Node *root) {
867867 cursor += 4;
868868
869869 // Bounds check: ensure position data fits within the entry
870 if (vert_count == 0 || cursor + vert_count * 12 > data_end) {
870 if (vert_count == 0) {
871 // Empty mesh — no geometry data in blob, skip to material
872 } else if (cursor + vert_count * 12 > data_end) {
871873 UtilityFunctions::printerr("BlenderBridge: vert_count out of bounds: ", (int)vert_count);
872874 continue;
873 }
875 } else {
874876
875877 PackedVector3Array positions;
876878 positions.resize(vert_count);
……@@ -1121,6 +1123,7 @@ void BlenderBridge::import_changes(Node *root) {
11211123 }
11221124
11231125 }
1126 } // end else (vert_count > 0)
11241127 }
11251128 }
11261129