| … | … | @@ -11,7 +11,7 @@ Usage: |
| 11 | 11 | |
| 12 | 12 | import bpy |
| 13 | 13 | |
| 14 | | ADDON_VERSION = "0.15.0" |
| 14 | ADDON_VERSION = "0.16.0" |
| 15 | 15 | |
| 16 | 16 | # Support hot-reload (F3 > Reload Scripts) |
| 17 | 17 | if "shm_protocol" in locals(): |
| … | … | @@ -22,10 +22,12 @@ if "shm_protocol" in locals(): |
| 22 | 22 | importlib.reload(scene_import) # noqa: F821 |
| 23 | 23 | importlib.reload(change_detector) # noqa: F821 |
| 24 | 24 | importlib.reload(tile_baker) # noqa: F821 |
| 25 | importlib.reload(procedural_baker) # noqa: F821 |
| 25 | 26 | |
| 26 | | from . import change_detector, connection, scene_import, shm_protocol, tile_baker |
| 27 | from . import change_detector, connection, procedural_baker, scene_import, shm_protocol, tile_baker |
| 27 | 28 | from .change_detector import ChangeDetector |
| 28 | 29 | from .connection import BridgeConnection |
| 30 | from .procedural_baker import ProceduralBaker |
| 29 | 31 | from .scene_import import SceneImporter |
| 30 | 32 | from .shm_protocol import ShmRegion |
| 31 | 33 | from .tile_baker import TileBaker |
| … | … | @@ -37,10 +39,9 @@ _conn: BridgeConnection | None = None |
| 37 | 39 | _importer: SceneImporter | None = None |
| 38 | 40 | _detector: ChangeDetector | None = None |
| 39 | 41 | _baker: TileBaker | None = None |
| 42 | _proc_baker: ProceduralBaker | None = None |
| 40 | 43 | _timer_running = False |
| 41 | 44 | _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 |
| 44 | 45 | |
| 45 | 46 | SHM_NAME = "/blender_bridge" |
| 46 | 47 | SHM_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(): |
| 65 | 66 | Handles image textures (existing pipeline), procedural textures (bake first), |
| 66 | 67 | and ColorRamp chains (trace upstream recursively). |
| 67 | 68 | """ |
| 68 | | global _conn, _baker, _sent_procedurals, _baked_images |
| 69 | global _conn, _baker, _proc_baker |
| 69 | 70 | |
| 70 | 71 | if _conn is None or _baker is None: |
| 71 | 72 | return |
| … | … | @@ -101,7 +102,7 @@ _PROCEDURAL_TYPES = { |
| 101 | 102 | |
| 102 | 103 | def _handle_texture_node(obj, mat, from_node, image_requested): |
| 103 | 104 | """Route a texture node to the appropriate handler.""" |
| 104 | | global _conn, _baker, _sent_procedurals, _baked_images |
| 105 | global _conn, _baker, _proc_baker |
| 105 | 106 | |
| 106 | 107 | # Image textures → existing VT pipeline (no bake) |
| 107 | 108 | 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): |
| 123 | 124 | _handle_texture_node(obj, mat, inner_link.from_node, image_requested) |
| 124 | 125 | return |
| 125 | 126 | |
| 126 | | # Procedural textures → bake to image, then VT pipeline |
| 127 | # Procedural textures → queue for non-blocking background bake |
| 127 | 128 | 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) |
| 140 | 131 | |
| 141 | 132 | |
| 142 | 133 | TILE_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): |
| 156 | 147 | print(f"BlenderBridge: Tile ring moved to offset {tile_ring_offset}") |
| 157 | 148 | |
| 158 | 149 | |
| 159 | | BAKE_SIZE = 1024 |
| 160 | | |
| 161 | | |
| 162 | | def _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 | | |
| 225 | | def _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 | | |
| 245 | 150 | def _poll_timer_impl(generation: int): |
| 246 | 151 | """Called by Blender's timer to poll for updates from Godot.""" |
| 247 | 152 | global _conn, _shm, _importer, _detector, _baker, _timer_running |
| … | … | @@ -333,9 +238,28 @@ def _poll_timer_impl(generation: int): |
| 333 | 238 | for img_name in dirty_images: |
| 334 | 239 | _baker.bake_full_image(img_name) |
| 335 | 240 | |
| 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 | |
| 336 | 253 | # Re-bake procedural textures if shading changed (parameter edits) |
| 337 | 254 | 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() |
| 339 | 263 | |
| 340 | 264 | # Flush any pending tile_ready messages from the baker |
| 341 | 265 | if _baker is not None: |
| … | … | @@ -370,11 +294,16 @@ def _attempt_reconnect(): |
| 370 | 294 | _detector.register() |
| 371 | 295 | _detector.set_resize_callback(_on_shm_resized) |
| 372 | 296 | |
| 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. |
| 374 | 299 | tile_ring_offset = _shm._region_size * 2 |
| 375 | 300 | 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) |
| 378 | 307 | |
| 379 | 308 | _conn.clear_reconnect() |
| 380 | 309 | print("BlenderBridge: Reconnected successfully") |
| … | … | @@ -480,13 +409,13 @@ class BRIDGE_OT_connect(bpy.types.Operator): |
| 480 | 409 | bl_label = "Connect to Godot" |
| 481 | 410 | |
| 482 | 411 | 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 |
| 488 | 414 | |
| 489 | 415 | # Clean up any stale state from previous connection |
| 416 | if _proc_baker is not None: |
| 417 | _proc_baker.reset() |
| 418 | _proc_baker = None |
| 490 | 419 | if _detector is not None: |
| 491 | 420 | _detector.unregister() |
| 492 | 421 | _detector = None |
| … | … | @@ -526,6 +455,9 @@ class BRIDGE_OT_connect(bpy.types.Operator): |
| 526 | 455 | _baker = TileBaker(_shm, tile_size=128, max_mips=8) |
| 527 | 456 | _baker.init_ring_buffer(tile_ring_offset, tile_ring_size) |
| 528 | 457 | |
| 458 | # Set up non-blocking procedural baker |
| 459 | _proc_baker = ProceduralBaker() |
| 460 | |
| 529 | 461 | # Start polling timer with new generation (old timers auto-stop |
| 530 | 462 | # when they detect their generation is stale) |
| 531 | 463 | _timer_generation += 1 |
| … | … | @@ -545,17 +477,17 @@ class BRIDGE_OT_disconnect(bpy.types.Operator): |
| 545 | 477 | bl_label = "Disconnect" |
| 546 | 478 | |
| 547 | 479 | 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 |
| 550 | 481 | |
| 551 | 482 | if _detector is not None: |
| 552 | 483 | _detector.unregister() |
| 553 | 484 | _detector = None |
| 554 | 485 | |
| 555 | 486 | _baker = None |
| 487 | if _proc_baker is not None: |
| 488 | _proc_baker.reset() |
| 489 | _proc_baker = None |
| 556 | 490 | _importer = None |
| 557 | | _sent_procedurals = set() |
| 558 | | _baked_images = {} |
| 559 | 491 | |
| 560 | 492 | if _conn is not None: |
| 561 | 493 | _conn.close() |
| … | … | @@ -614,6 +546,21 @@ class BRIDGE_OT_finalize(bpy.types.Operator): |
| 614 | 546 | return {"FINISHED"} |
| 615 | 547 | |
| 616 | 548 | |
| 549 | class 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 | |
| 617 | 564 | # ── UI Panel ───────────────────────────────────────────────────────────────── |
| 618 | 565 | |
| 619 | 566 | |
| … | … | @@ -639,6 +586,16 @@ class BRIDGE_PT_panel(bpy.types.Panel): |
| 639 | 586 | layout.operator("bridge.import_scene", icon="FILE_REFRESH") |
| 640 | 587 | layout.operator("bridge.finalize", icon="EXPORT") |
| 641 | 588 | |
| 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 | |
| 642 | 599 | # Status info |
| 643 | 600 | box = layout.box() |
| 644 | 601 | box.label(text="Status: Connected", icon="CHECKMARK") |
| … | … | @@ -663,6 +620,7 @@ _classes = ( |
| 663 | 620 | BRIDGE_OT_disconnect, |
| 664 | 621 | BRIDGE_OT_import_scene, |
| 665 | 622 | BRIDGE_OT_finalize, |
| 623 | BRIDGE_OT_bake_textures, |
| 666 | 624 | BRIDGE_PT_panel, |
| 667 | 625 | ) |
| 668 | 626 | |
| … | … | @@ -673,17 +631,17 @@ def register(): |
| 673 | 631 | |
| 674 | 632 | |
| 675 | 633 | def 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 |
| 678 | 635 | |
| 679 | 636 | # Clean up connection |
| 680 | 637 | if _detector is not None: |
| 681 | 638 | _detector.unregister() |
| 682 | 639 | _detector = None |
| 683 | 640 | _baker = None |
| 641 | if _proc_baker is not None: |
| 642 | _proc_baker.reset() |
| 643 | _proc_baker = None |
| 684 | 644 | _importer = None |
| 685 | | _sent_procedurals = set() |
| 686 | | _baked_images = {} |
| 687 | 645 | if _conn is not None: |
| 688 | 646 | _conn.close() |
| 689 | 647 | _conn = None |