import bpy from bpy.types import Panel, Operator, PropertyGroup from bpy.props import StringProperty, IntProperty, BoolProperty, EnumProperty, PointerProperty from bpy.utils import register_class, unregister_class # Module-level caches prevent enum item lists being garbage-collected mid-draw, # which would crash Blender when dynamic EnumProperty items are used. _socket_items_cache = [('0', 'Select a node', '', 0)] _uv_items_cache = [('__active__', '(Active UV)', '', 0)] def _get_socket_items(self, context): global _socket_items_cache node = getattr(context, 'active_node', None) if context else None if node is None: _socket_items_cache = [('0', 'Select a node', '', 0)] return _socket_items_cache items = [] for i, socket in enumerate(node.outputs): if (socket.bl_idname not in ('NodeSocketShader', 'NodeSocketVirtual') and not socket.hide and socket.enabled): items.append((str(i), socket.name, socket.name, len(items))) if not items: _socket_items_cache = [('0', 'No bakeable outputs', '', 0)] return _socket_items_cache _socket_items_cache = items return _socket_items_cache def _get_uv_items(self, context): global _uv_items_cache obj = context.active_object if context else None items = [('__active__', '(Active UV)', 'Use whichever UV map is currently active on the object', 0)] if obj and obj.type == 'MESH': for i, layer in enumerate(obj.data.uv_layers): items.append((layer.name, layer.name, layer.name, i + 1)) _uv_items_cache = items return _uv_items_cache class SimpleBakeNodeBakeProps(PropertyGroup): socket: EnumProperty( name="Socket", description="Output socket to bake", items=_get_socket_items, ) image_name: StringProperty( name="Image Name", default="NodeBake", description="Name for the baked image in the blend file", ) width: IntProperty(name="Width", default=1024, min=1, max=16384) height: IntProperty(name="Height", default=1024, min=1, max=16384) float_buffer: BoolProperty( name="32-bit Float", default=False, description="Use a 32-bit float image buffer (higher precision, larger memory footprint)", ) uv_map: EnumProperty( name="UV Map", description="UV map to bake to. Defaults to whichever map is currently active on the object", items=_get_uv_items, ) replace_with_image: BoolProperty( name="Replace with Image Texture", default=False, description=( "After baking, insert an Image Texture node and reconnect it " "to wherever this socket was feeding" ), ) def _find_active_mat_output(nodes): """Return the active Material Output node, or any Material Output, or None.""" active = next((n for n in nodes if n.bl_idname == 'ShaderNodeOutputMaterial' and n.is_active_output), None) if active: return active return next((n for n in nodes if n.bl_idname == 'ShaderNodeOutputMaterial'), None) class SIMPLEBAKE_OT_NodeBake(Operator): bl_idname = "simplebake.node_bake" bl_label = "Bake Node Output" bl_description = ( "Bake the selected output socket of the active node to an image texture. " "The render engine is temporarily switched to Cycles for baking" ) @classmethod def poll(cls, context): sd = getattr(context, 'space_data', None) if sd is None or sd.type != 'NODE_EDITOR': return False if sd.tree_type != 'ShaderNodeTree': return False if context.active_object is None: return False node = context.active_node if node is None: return False return any( s.bl_idname not in ('NodeSocketShader', 'NodeSocketVirtual') for s in node.outputs ) # ------------------------------------------------------------------ # Material helpers # ------------------------------------------------------------------ def _silence_material(self, mat): """ Temporarily wire a zero-emission shader into mat's Material Output so it contributes nothing to an EMIT bake. Returns a restore tuple. """ nt = mat.node_tree mat_out = _find_active_mat_output(nt.nodes) if mat_out is None: return None orig = mat_out.inputs['Surface'].links[0].from_socket if mat_out.inputs['Surface'].is_linked else None emit = nt.nodes.new('ShaderNodeEmission') emit.inputs['Color'].default_value = (0.0, 0.0, 0.0, 1.0) emit.inputs['Strength'].default_value = 0.0 nt.links.new(emit.outputs['Emission'], mat_out.inputs['Surface']) return (nt, mat_out, emit, orig) def _restore_material(self, restore_data): if restore_data is None: return nt, mat_out, emit_node, orig_socket = restore_data nt.nodes.remove(emit_node) if orig_socket is not None: nt.links.new(orig_socket, mat_out.inputs['Surface']) # ------------------------------------------------------------------ # Operator execute # ------------------------------------------------------------------ def execute(self, context): props = context.scene.SimpleBakeNodeBake space = context.space_data node = context.active_node node_location = (node.location[0], node.location[1]) # Must be at the root of the node tree, not inside a group if space.edit_tree is not space.node_tree: self.report({'ERROR'}, "Exit the node group before baking") return {'CANCELLED'} mat = space.id if not isinstance(mat, bpy.types.Material): self.report({'ERROR'}, "Could not determine the active material") return {'CANCELLED'} obj = context.active_object mat_names = {s.material.name for s in obj.material_slots if s.material} if mat.name not in mat_names: self.report({'ERROR'}, f"'{mat.name}' is not assigned to the active object") return {'CANCELLED'} if not obj.data.uv_layers: self.report({'ERROR'}, "The active object has no UV map — add one before baking") return {'CANCELLED'} # Activate the chosen UV map, remember original to restore later chosen_uv = props.uv_map orig_uv_index = obj.data.uv_layers.active_index if chosen_uv != '__active__': idx = next((i for i, l in enumerate(obj.data.uv_layers) if l.name == chosen_uv), None) if idx is None: self.report({'ERROR'}, f"UV map '{chosen_uv}' not found on object") return {'CANCELLED'} obj.data.uv_layers.active_index = idx # Resolve the target socket socket_idx = int(props.socket) if socket_idx >= len(node.outputs): self.report({'ERROR'}, "Socket index out of range — refresh by reselecting the node") return {'CANCELLED'} target_socket = node.outputs[socket_idx] if target_socket.bl_idname in ('NodeSocketShader', 'NodeSocketVirtual'): self.report({'ERROR'}, "Cannot bake a Shader or Virtual socket") return {'CANCELLED'} # Remember downstream links for the optional reconnect. # Store names/identifiers rather than live Python wrapper references — # those can become stale after node tree modifications during the bake. downstream = [(lnk.to_node.name, lnk.to_socket.identifier) for lnk in target_socket.links] # Create the output image image_name = props.image_name.strip() or f"{node.name}_{target_socket.name}" existing = bpy.data.images.get(image_name) if existing: bpy.data.images.remove(existing) image = bpy.data.images.new( image_name, width=props.width, height=props.height, alpha=False, float_buffer=props.float_buffer, ) # --- Set up the target material --- nt = mat.node_tree nodes = nt.nodes links = nt.links mat_out = _find_active_mat_output(nodes) created_mat_out = False if mat_out is None: mat_out = nodes.new('ShaderNodeOutputMaterial') mat_out.location = (300.0, 300.0) created_mat_out = True orig_surface = mat_out.inputs['Surface'].links[0].from_socket if mat_out.inputs['Surface'].is_linked else None # Temporary nodes emit_node = nodes.new('ShaderNodeEmission') emit_node.location = (mat_out.location[0] - 220, mat_out.location[1] - 80) img_node = nodes.new('ShaderNodeTexImage') img_node.name = "SB_NodeBake_Image" img_node.image = image img_node.location = (mat_out.location[0] - 420, mat_out.location[1] - 160) # Make img_node the active image node (Cycles writes bake output here) for n in nodes: n.select = False img_node.select = True nodes.active = img_node links.new(target_socket, emit_node.inputs['Color']) links.new(emit_node.outputs['Emission'], mat_out.inputs['Surface']) # --- Silence all other materials so they don't pollute the EMIT bake --- silence_restores = [] for slot in obj.material_slots: if slot.material and slot.material.name != mat.name: rd = self._silence_material(slot.material) if rd: silence_restores.append(rd) # --- Save scene state --- orig_engine = context.scene.render.engine orig_active_obj = context.view_layer.objects.active orig_selected = list(context.selected_objects) orig_mode = context.mode # Configure for bake context.scene.render.engine = 'CYCLES' for o in list(context.selected_objects): o.select_set(False) obj.select_set(True) context.view_layer.objects.active = obj if orig_mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT') # --- Bake --- success = False try: bpy.ops.object.bake(type='EMIT', use_clear=True) success = True except Exception as exc: self.report({'ERROR'}, f"Bake failed: {exc}") # --- Restore UV map --- obj.data.uv_layers.active_index = orig_uv_index # --- Restore scene state --- context.scene.render.engine = orig_engine context.view_layer.objects.active = orig_active_obj for o in orig_selected: try: o.select_set(True) except Exception: pass # --- Restore silenced materials --- for rd in silence_restores: self._restore_material(rd) # --- Restore target material --- nodes.remove(emit_node) if created_mat_out: nodes.remove(mat_out) elif orig_surface is not None: links.new(orig_surface, mat_out.inputs['Surface']) # --- Handle image node --- # Always remove the bake-helper image node — it was only needed for Cycles # to know where to write the result. If replacing, a fresh node is created # below at the correct position, avoiding any stale Python wrapper issues. bake_helper = nodes.get("SB_NodeBake_Image") or img_node nodes.remove(bake_helper) if not success: bpy.data.images.remove(image) return {'CANCELLED'} if props.replace_with_image: final_node = nodes.new('ShaderNodeTexImage') final_node.image = image final_node.parent = node.parent image_node_height = max( float(getattr(final_node, "height", 0.0) or 0.0), float(final_node.dimensions[1] or 0.0), ) if image_node_height <= 0.0: image_node_height = 260.0 final_node.location = (node_location[0], node_location[1] + image_node_height + 80.0) if chosen_uv != '__active__': uv_node = nodes.new('ShaderNodeUVMap') uv_node.uv_map = chosen_uv uv_node.parent = final_node.parent uv_node.location = (final_node.location[0] - 220, final_node.location[1] - 100) links.new(uv_node.outputs['UV'], final_node.inputs['Vector']) for node_name, sock_id in downstream: to_node = nodes.get(node_name) if to_node is None: continue to_socket = next((s for s in to_node.inputs if s.identifier == sock_id), None) if to_socket is not None: links.new(final_node.outputs['Color'], to_socket) self.report({'INFO'}, f"Baked '{target_socket.name}' → '{image_name}'") return {'FINISHED'} class SIMPLEBAKE_PT_NodeBake(Panel): bl_label = "Node Bake" bl_idname = "SIMPLEBAKE_PT_NodeBake" bl_space_type = 'NODE_EDITOR' bl_region_type = 'UI' bl_category = "SimpleBake" @classmethod def poll(cls, context): sd = context.space_data return ( sd.type == 'NODE_EDITOR' and sd.tree_type == 'ShaderNodeTree' and context.active_object is not None ) def draw(self, context): layout = self.layout props = context.scene.SimpleBakeNodeBake node = context.active_node if node is None: layout.label(text="Select a node to bake", icon='INFO') return valid_outputs = [ s for s in node.outputs if s.bl_idname not in ('NodeSocketShader', 'NodeSocketVirtual') ] if not valid_outputs: layout.label(text="No bakeable outputs on this node", icon='ERROR') return layout.label(text=node.name, icon='NODE') layout.prop(props, "socket") layout.prop(props, "uv_map") layout.prop(props, "image_name") row = layout.row(align=True) row.prop(props, "width", text="W") row.prop(props, "height", text="H") layout.prop(props, "float_buffer") layout.prop(props, "replace_with_image") layout.separator() row = layout.row() row.scale_y = 1.4 row.operator("simplebake.node_bake", text="Bake Node Output", icon='RENDER_STILL') classes = [ SimpleBakeNodeBakeProps, SIMPLEBAKE_OT_NodeBake, SIMPLEBAKE_PT_NodeBake, ] def register(): for cls in classes: register_class(cls) bpy.types.Scene.SimpleBakeNodeBake = PointerProperty(type=SimpleBakeNodeBakeProps) def unregister(): del bpy.types.Scene.SimpleBakeNodeBake for cls in reversed(classes): unregister_class(cls)