| … | … | @@ -11,7 +11,7 @@ Usage: |
| 11 | 11 | |
| 12 | 12 | import bpy |
| 13 | 13 | |
| 14 | | ADDON_VERSION = "0.14.1" |
| 14 | ADDON_VERSION = "0.15.0" |
| 15 | 15 | |
| 16 | 16 | # Support hot-reload (F3 > Reload Scripts) |
| 17 | 17 | if "shm_protocol" in locals(): |
| … | … | @@ -39,6 +39,8 @@ _detector: ChangeDetector | None = None |
| 39 | 39 | _baker: TileBaker | None = None |
| 40 | 40 | _timer_running = False |
| 41 | 41 | _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 |
| 42 | 44 | |
| 43 | 45 | SHM_NAME = "/blender_bridge" |
| 44 | 46 | SHM_SIZE = 24 * 1024 * 1024 # 24 MB (8 MB region 0 + 8 MB region 1 + 8 MB tile ring) |
| … | … | @@ -57,16 +59,20 @@ def _make_poll_timer(generation: int): |
| 57 | 59 | return _poll_timer |
| 58 | 60 | |
| 59 | 61 | |
| 60 | | def _request_texture_registrations(): |
| 61 | | """Scan Blender scene for materials with image textures and request VT registration.""" |
| 62 | | global _conn, _baker |
| 62 | def _scan_material_textures(): |
| 63 | """Scan Blender scene for materials with textures and request VT registration. |
| 64 | |
| 65 | Handles image textures (existing pipeline), procedural textures (bake first), |
| 66 | and ColorRamp chains (trace upstream recursively). |
| 67 | """ |
| 68 | global _conn, _baker, _sent_procedurals, _baked_images |
| 63 | 69 | |
| 64 | 70 | if _conn is None or _baker is None: |
| 65 | 71 | return |
| 66 | 72 | |
| 67 | 73 | import bpy |
| 68 | 74 | |
| 69 | | requested = set() |
| 75 | image_requested = set() |
| 70 | 76 | for obj in bpy.data.objects: |
| 71 | 77 | if obj.type != "MESH" or obj.active_material is None: |
| 72 | 78 | continue |
| … | … | @@ -74,25 +80,166 @@ def _request_texture_registrations(): |
| 74 | 80 | if not mat.use_nodes or not mat.node_tree: |
| 75 | 81 | continue |
| 76 | 82 | for node in mat.node_tree.nodes: |
| 77 | | if node.type == "BSDF_PRINCIPLED": |
| 78 | | # Check if Base Color has an image texture connected |
| 79 | | base_color_input = node.inputs.get("Base Color") |
| 80 | | if base_color_input is None or not base_color_input.is_linked: |
| 81 | | continue |
| 82 | | for link in base_color_input.links: |
| 83 | | from_node = link.from_node |
| 84 | | if from_node.type == "TEX_IMAGE" and from_node.image is not None: |
| 85 | | img = from_node.image |
| 86 | | img_name = img.name |
| 87 | | if img_name not in requested and img_name not in _baker._texture_ids: |
| 88 | | requested.add(img_name) |
| 89 | | img_w, img_h = img.size[0], img.size[1] |
| 90 | | msg = f"request_texture:{img_name},{img_w},{img_h}" |
| 91 | | _conn.send_message(msg.encode()) |
| 92 | | print(f"BlenderBridge: Requesting texture registration for '{img_name}' ({img_w}x{img_h})") |
| 93 | | |
| 94 | | if requested: |
| 95 | | print(f"BlenderBridge: Requested {len(requested)} texture(s)") |
| 83 | if node.type != "BSDF_PRINCIPLED": |
| 84 | continue |
| 85 | base_color = node.inputs.get("Base Color") |
| 86 | if base_color is None or not base_color.is_linked: |
| 87 | continue |
| 88 | for link in base_color.links: |
| 89 | _handle_texture_node(obj, mat, link.from_node, image_requested) |
| 90 | |
| 91 | if image_requested: |
| 92 | print(f"BlenderBridge: Requested {len(image_requested)} texture(s)") |
| 93 | |
| 94 | |
| 95 | _PROCEDURAL_TYPES = { |
| 96 | "TEX_NOISE", "TEX_VORONOI", "TEX_GRADIENT", "TEX_CHECKER", |
| 97 | "TEX_WAVE", "TEX_BRICK", "TEX_MAGIC", "TEX_MUSGRAVE", |
| 98 | "TEX_WHITE_NOISE", |
| 99 | } |
| 100 | |
| 101 | |
| 102 | def _handle_texture_node(obj, mat, from_node, image_requested): |
| 103 | """Route a texture node to the appropriate handler.""" |
| 104 | global _conn, _baker, _sent_procedurals, _baked_images |
| 105 | |
| 106 | # Image textures → existing VT pipeline (no bake) |
| 107 | if from_node.type == "TEX_IMAGE" and from_node.image is not None: |
| 108 | img = from_node.image |
| 109 | img_name = img.name |
| 110 | if img_name not in image_requested and img_name not in _baker._texture_ids: |
| 111 | image_requested.add(img_name) |
| 112 | img_w, img_h = img.size[0], img.size[1] |
| 113 | msg = f"request_texture:{img_name},{img_w},{img_h}" |
| 114 | _conn.send_message(msg.encode()) |
| 115 | print(f"BlenderBridge: Requesting image texture '{img_name}' ({img_w}x{img_h})") |
| 116 | return |
| 117 | |
| 118 | # ColorRamp → trace upstream for the actual texture node |
| 119 | if from_node.type == "VALTORGB": |
| 120 | fac_input = from_node.inputs.get("Fac") |
| 121 | if fac_input and fac_input.is_linked: |
| 122 | for inner_link in fac_input.links: |
| 123 | _handle_texture_node(obj, mat, inner_link.from_node, image_requested) |
| 124 | return |
| 125 | |
| 126 | # Procedural textures → bake to image, then VT pipeline |
| 127 | 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") |
| 140 | |
| 141 | |
| 142 | TILE_RING_SIZE = 8 * 1024 * 1024 # 8 MB tile ring (fixed, does not grow) |
| 143 | |
| 144 | def _on_shm_resized(new_size: int, new_region_size: int): |
| 145 | """Called by ChangeDetector after SHM resize. Notifies Godot and updates tile baker.""" |
| 146 | global _conn, _baker, _shm |
| 147 | if _conn is not None: |
| 148 | msg = f"shm_resized:{new_size},{new_region_size}" |
| 149 | _conn.send_message(msg.encode()) |
| 150 | # Re-initialize tile baker with new ring buffer offset |
| 151 | if _baker is not None and _shm is not None: |
| 152 | tile_ring_offset = new_region_size * 2 |
| 153 | tile_ring_size = new_size - tile_ring_offset |
| 154 | if tile_ring_size > 0: |
| 155 | _baker.init_ring_buffer(tile_ring_offset, tile_ring_size) |
| 156 | print(f"BlenderBridge: Tile ring moved to offset {tile_ring_offset}") |
| 157 | |
| 158 | |
| 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}'") |
| 96 | 243 | |
| 97 | 244 | |
| 98 | 245 | def _poll_timer_impl(generation: int): |
| … | … | @@ -141,8 +288,8 @@ def _poll_timer_impl(generation: int): |
| 141 | 288 | _detector.suppress(set(_importer._id_to_name.values()), 5) |
| 142 | 289 | got_incoming = True |
| 143 | 290 | print(f"BlenderBridge: Synced scene from Godot") |
| 144 | | # Scan Blender scene for image textures and request VT registration |
| 145 | | _request_texture_registrations() |
| 291 | # Scan Blender scene for textures (image + procedural) and request VT registration |
| 292 | _scan_material_textures() |
| 146 | 293 | elif text in ("updated", "transform_update"): |
| 147 | 294 | # Incremental update from Godot (region 0) |
| 148 | 295 | if _importer is not None: |
| … | … | @@ -186,6 +333,10 @@ def _poll_timer_impl(generation: int): |
| 186 | 333 | for img_name in dirty_images: |
| 187 | 334 | _baker.bake_full_image(img_name) |
| 188 | 335 | |
| 336 | # Re-bake procedural textures if shading changed (parameter edits) |
| 337 | if _detector is not None and _detector.had_shading_update(): |
| 338 | _rebake_procedurals() |
| 339 | |
| 189 | 340 | # Flush any pending tile_ready messages from the baker |
| 190 | 341 | if _baker is not None: |
| 191 | 342 | for tile_msg in _baker.get_pending_messages(): |
| … | … | @@ -217,11 +368,12 @@ def _attempt_reconnect(): |
| 217 | 368 | _detector.unregister() |
| 218 | 369 | _detector = ChangeDetector(_shm, MAX_OBJECTS) |
| 219 | 370 | _detector.register() |
| 371 | _detector.set_resize_callback(_on_shm_resized) |
| 220 | 372 | |
| 221 | 373 | # Re-initialize tile baker |
| 222 | 374 | from .shm_protocol import REGION_SIZE |
| 223 | 375 | tile_ring_offset = REGION_SIZE * 2 |
| 224 | | tile_ring_size = SHM_SIZE - tile_ring_offset |
| 376 | tile_ring_size = TILE_RING_SIZE |
| 225 | 377 | _baker = TileBaker(_shm, tile_size=128, max_mips=8) |
| 226 | 378 | _baker.init_ring_buffer(tile_ring_offset, tile_ring_size) |
| 227 | 379 | |
| … | … | @@ -330,6 +482,10 @@ class BRIDGE_OT_connect(bpy.types.Operator): |
| 330 | 482 | |
| 331 | 483 | def execute(self, context): |
| 332 | 484 | global _shm, _conn, _importer, _detector, _baker, _timer_running, _timer_generation |
| 485 | global _sent_procedurals, _baked_images |
| 486 | |
| 487 | _sent_procedurals = set() |
| 488 | _baked_images = {} |
| 333 | 489 | |
| 334 | 490 | # Clean up any stale state from previous connection |
| 335 | 491 | if _detector is not None: |
| … | … | @@ -362,11 +518,12 @@ class BRIDGE_OT_connect(bpy.types.Operator): |
| 362 | 518 | _importer = SceneImporter(_shm, MAX_OBJECTS) |
| 363 | 519 | _detector = ChangeDetector(_shm, MAX_OBJECTS) |
| 364 | 520 | _detector.register() |
| 521 | _detector.set_resize_callback(_on_shm_resized) |
| 365 | 522 | |
| 366 | 523 | # Set up tile baker (ring buffer after both regions) |
| 367 | 524 | from .shm_protocol import REGION_SIZE |
| 368 | 525 | tile_ring_offset = REGION_SIZE * 2 # 16 MB offset |
| 369 | | tile_ring_size = SHM_SIZE - tile_ring_offset # remaining 8 MB |
| 526 | tile_ring_size = TILE_RING_SIZE |
| 370 | 527 | _baker = TileBaker(_shm, tile_size=128, max_mips=8) |
| 371 | 528 | _baker.init_ring_buffer(tile_ring_offset, tile_ring_size) |
| 372 | 529 | |
| … | … | @@ -390,6 +547,7 @@ class BRIDGE_OT_disconnect(bpy.types.Operator): |
| 390 | 547 | |
| 391 | 548 | def execute(self, context): |
| 392 | 549 | global _shm, _conn, _importer, _detector, _baker, _timer_running |
| 550 | global _sent_procedurals, _baked_images |
| 393 | 551 | |
| 394 | 552 | if _detector is not None: |
| 395 | 553 | _detector.unregister() |
| … | … | @@ -397,6 +555,8 @@ class BRIDGE_OT_disconnect(bpy.types.Operator): |
| 397 | 555 | |
| 398 | 556 | _baker = None |
| 399 | 557 | _importer = None |
| 558 | _sent_procedurals = set() |
| 559 | _baked_images = {} |
| 400 | 560 | |
| 401 | 561 | if _conn is not None: |
| 402 | 562 | _conn.close() |
| … | … | @@ -515,6 +675,7 @@ def register(): |
| 515 | 675 | |
| 516 | 676 | def unregister(): |
| 517 | 677 | global _shm, _conn, _importer, _detector, _baker, _timer_running |
| 678 | global _sent_procedurals, _baked_images |
| 518 | 679 | |
| 519 | 680 | # Clean up connection |
| 520 | 681 | if _detector is not None: |
| … | … | @@ -522,6 +683,8 @@ def unregister(): |
| 522 | 683 | _detector = None |
| 523 | 684 | _baker = None |
| 524 | 685 | _importer = None |
| 686 | _sent_procedurals = set() |
| 687 | _baked_images = {} |
| 525 | 688 | if _conn is not None: |
| 526 | 689 | _conn.close() |
| 527 | 690 | _conn = None |