diff --git a/blender_compass.py b/blender_compass.py index 148bfec..27ead50 100644 --- a/blender_compass.py +++ b/blender_compass.py @@ -1,21 +1,291 @@ -import bpy -import os -import mathutils -from bpy.props import StringProperty -from bpy.types import Panel, Operator -import math bl_info = { "name": "Compass Render", # Main display name "author": "Bryce", # Your name/credit - "version": (1, 0, 0), # Version number - "blender": (2, 80, 0), # Minimum Blender version + "version": (1, 1, 0), # Version number + "blender": (4, 3, 0), # Minimum Blender version "location": "Properties > Render Properties > Multi-Angle Renderer", - "description": "Your custom description here", + "description": "Multi-angle animation renderer with Line Art support", "warning": "Beta version - use with caution", # Optional warning "doc_url": "https://your-documentation.com", # Optional docs link "category": "Render", # Category in add-ons list } +import bpy +import os +import math +import mathutils +from bpy.props import StringProperty, FloatProperty, BoolProperty, EnumProperty +from bpy.types import Panel, Operator + + +class RENDER_OT_setup_compositor(Operator): + """Setup compositor for multi-pass rendering with Line Art""" + bl_idname = "render.setup_compositor" + bl_label = "Setup Multi-Pass Compositor" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + scene = context.scene + props = scene.multi_angle_props + + # Enable compositor + scene.use_nodes = True + scene.render.use_compositing = True + + # Clear existing nodes + scene.node_tree.nodes.clear() + + # Enable required render passes + scene.view_layers[0].use_pass_z = True # Depth + + # Create compositor nodes + nodes = scene.node_tree.nodes + links = scene.node_tree.links + + # Create input node for main render + render_layers = nodes.new(type='CompositorNodeRLayers') + render_layers.location = (0, 0) + render_layers.layer = "ViewLayer" + + # Create file output nodes for each pass + # Main image output + image_output = nodes.new(type='CompositorNodeOutputFile') + image_output.location = (600, 300) + image_output.label = "Image Output" + image_output.base_path = "" # Will be set during render + image_output.file_slots.clear() + image_output.file_slots.new("image") + image_output.format.file_format = 'PNG' + + # Depth output with improved mapping + depth_map_range = nodes.new(type='CompositorNodeMapRange') + depth_map_range.location = (200, 100) + depth_map_range.inputs['From Min'].default_value = 0.0 + depth_map_range.inputs['From Max'].default_value = 10.0 + depth_map_range.inputs['To Min'].default_value = 0.0 + depth_map_range.inputs['To Max'].default_value = 1.0 + depth_map_range.use_clamp = True + + depth_output = nodes.new(type='CompositorNodeOutputFile') + depth_output.location = (600, 100) + depth_output.label = "Depth Output" + depth_output.base_path = "" + depth_output.file_slots.clear() + depth_output.file_slots.new("depth") + depth_output.format.file_format = 'PNG' + + # Line Art setup if enabled + lineart_output = None + lineart_mix_node = None + + if props.use_lineart: + # Create Line Art grease pencil output + lineart_output = nodes.new(type='CompositorNodeOutputFile') + lineart_output.location = (600, -100) + lineart_output.label = "LineArt Output" + lineart_output.base_path = "" + lineart_output.file_slots.clear() + lineart_output.file_slots.new("lineart") + lineart_output.format.file_format = 'PNG' + + # Find or create Grease Pencil Line Art object + gp_obj = self.ensure_lineart_object(context) + + # Create viewer node for Line Art (Grease Pencil objects render through different path) + # We'll use a separate view layer for Line Art + lineart_view_layer = None + for vl in scene.view_layers: + if vl.name == "LineArt_Layer": + lineart_view_layer = vl + break + + if not lineart_view_layer: + lineart_view_layer = scene.view_layers.new("LineArt_Layer") + # Configure LineArt layer to only show line art + for collection in lineart_view_layer.layer_collection.children: + if "LineArt" not in collection.name: + collection.holdout = True + + # Line Art render layer node + lineart_render_layers = nodes.new(type='CompositorNodeRLayers') + lineart_render_layers.location = (0, -200) + lineart_render_layers.layer = "LineArt_Layer" + + # Mix node to remove Line Art from main image if needed + if props.separate_lineart: + lineart_mix_node = nodes.new(type='CompositorNodeMixRGB') + lineart_mix_node.location = (400, 300) + lineart_mix_node.blend_type = 'SUBTRACT' + lineart_mix_node.inputs['Fac'].default_value = 1.0 + + # Connect nodes + # Main image + if lineart_mix_node and props.separate_lineart: + # Remove Line Art from main image + links.new(render_layers.outputs['Image'], lineart_mix_node.inputs['Image1']) + self.report({'INFO'}, f"Rendering {lineart_mix_node.inputs}") + if 'lineart_render_layers' in locals(): + links.new(lineart_render_layers.outputs['Image'], lineart_mix_node.inputs['Image2']) + links.new(lineart_mix_node.outputs['Color'], image_output.inputs['image']) + else: + links.new(render_layers.outputs['Image'], image_output.inputs['image']) + + # Depth with improved mapping + links.new(render_layers.outputs['Depth'], depth_map_range.inputs['Value']) + links.new(depth_map_range.outputs['Value'], depth_output.inputs['depth']) + + # Line Art output + if lineart_output and 'lineart_render_layers' in locals(): + links.new(lineart_render_layers.outputs['Image'], lineart_output.inputs['lineart']) + + # Store node references for later use + scene['multiangle_image_output'] = image_output.name + scene['multiangle_depth_output'] = depth_output.name + scene['multiangle_depth_map_range'] = depth_map_range.name + + if lineart_output: + scene['multiangle_lineart_output'] = lineart_output.name + + self.report({'INFO'}, "Multi-pass compositor setup complete") + return {'FINISHED'} + + def ensure_lineart_object(self, context): + """Create or get Line Art Grease Pencil object with robust version handling""" + scene = context.scene + + # Look for existing Line Art object + gp_obj = None + for obj in scene.objects: + if obj.type == 'GREASEPENCIL' and 'LineArt' in obj.name: + gp_obj = obj + break + + if not gp_obj: + # Create new Grease Pencil object for Line Art + # Try multiple methods for different Blender versions + gp_data = None + self.report({'INFO'}, 'HERE') + # Method 1: Try Grease Pencil v3 (Blender 4.3+) + if hasattr(bpy.data, 'grease_pencils_v3'): + try: + self.report({'INFO'}, 'grease pencil v3') + gp_data = bpy.data.grease_pencils_v3.new("LineArt_GP") + except: + pass + + # Method 2: Try legacy grease pencils collection + if not gp_data and hasattr(bpy.data, 'grease_pencils'): + try: + self.report({'INFO'}, 'grease pencil GP') + gp_data = bpy.data.grease_pencils.new("LineArt_GP") + except: + pass + + # Method 3: Try operator methods + if not gp_data: + try: + # Try modern operator name + bpy.ops.object.grease_pencil_add(type='EMPTY') + gp_obj = context.active_objecthas + gp_obj.name = "LineArt_Object" + except: + try: + # Try legacy operator name + bpy.ops.object.gpencil_add(type='EMPTY') + gp_obj = context.active_object + gp_obj.name = "LineArt_Object" + except: + # Create empty as fallback and warn user + bpy.ops.object.empty_add(type='PLAIN_AXES') + gp_obj = context.active_object + gp_obj.name = "LineArt_Object_EMPTY" + self.report({'WARNING'}, "Could not create Grease Pencil object. Created empty instead.") + return gp_obj + + # If we have gp_data, create the object + if gp_data: + gp_obj = bpy.data.objects.new("LineArt_Object", gp_data) + bpy.context.scene.collection.objects.link(gp_obj) + + # Create Line Art collection if it doesn't exist + lineart_collection = None + for collection in bpy.data.collections: + if collection.name == "LineArt": + lineart_collection = collection + break + + if not lineart_collection: + lineart_collection = bpy.data.collections.new("LineArt") + scene.collection.children.link(lineart_collection) + + # Move object to Line Art collection + if gp_obj and gp_obj.name in scene.collection.objects: + scene.collection.objects.unlink(gp_obj) + lineart_collection.objects.link(gp_obj) + + self.report({'INFO'}, f"HERE {gp_obj.type}") + # Only proceed with modifier setup if we have a proper Grease Pencil object + if not gp_obj or gp_obj.type != 'GREASEPENCIL': + return gp_obj + + # Ensure Line Art modifier exists + lineart_mod = None + + # Check if object has grease_pencil_modifiers (new GP3) or modifiers (legacy) + modifiers_collection = gp_obj.modifiers + valid_types = ['LINEART'] + + for mod in modifiers_collection: + if mod.type in valid_types: + lineart_mod = mod + break + + if not lineart_mod: + # Add Line Art modifier with proper error handling + try: + # Try new Grease Pencil 3 modifier first + lineart_mod = gp_obj.modifiers.new(name="LineArt", type='LINEART') + except (AttributeError, TypeError): + try: + # Try legacy GP modifier + lineart_mod = gp_obj.modifiers.new(name="LineArt", type='GP_LINEART') + except (AttributeError, TypeError): + self.report({'ERROR'}, "Could not create Line Art modifier. Unsupported Blender version.") + return gp_obj + + # Configure Line Art modifier + if lineart_mod: + lineart_mod.source_type = 'SCENE' + lineart_mod.use_contour = True + lineart_mod.use_crease = True + lineart_mod.thickness = 3 + + # Set performance settings if available + if hasattr(lineart_mod, 'use_back_face_culling'): + lineart_mod.use_back_face_culling = True + + # Set crease threshold if available + props = scene.multi_angle_props + if hasattr(lineart_mod, 'crease_threshold'): + lineart_mod.crease_threshold = math.radians(props.lineart_crease_angle) + + # Ensure material exists + if not gp_obj.data.materials: + mat = bpy.data.materials.new(name="LineArt_Material") + bpy.data.materials.create_gpencil_data(mat) + #mat.is_grease_pencil = True + + # Set material properties with error handling for different GP versions + if hasattr(mat, 'grease_pencil'): + mat.grease_pencil.color = (0, 0, 0, 1) # Black lines + mat.grease_pencil.show_stroke = True + mat.grease_pencil.show_fill = False + + gp_obj.data.materials.append(mat) + + return gp_obj + + class RENDER_OT_multi_angle(Operator): """Render animation from multiple angles""" bl_idname = "render.multi_angle" @@ -24,12 +294,20 @@ class RENDER_OT_multi_angle(Operator): def execute(self, context): scene = context.scene - animation_name = scene.multi_angle_props.animation_name + props = scene.multi_angle_props + animation_name = props.animation_name if not animation_name: self.report({'ERROR'}, "Please enter an animation name") return {'CANCELLED'} + # Check if compositor is set up for multi-pass + use_multipass = (scene.use_nodes and + 'multiangle_image_output' in scene and + 'multiangle_depth_output' in scene) + + use_lineart = props.use_lineart and 'multiangle_lineart_output' in scene + # Find camera and its parent camera = None camera_parent = None @@ -60,6 +338,9 @@ class RENDER_OT_multi_angle(Operator): # Store original rotation original_rotation = camera_parent.rotation_euler.copy() + # Store original active view layer + original_view_layer = context.view_layer.name + # Define 8 angles (in degrees) and their directory names angles_and_dirs = [ (0, "s"), # South @@ -90,11 +371,37 @@ class RENDER_OT_multi_angle(Operator): self.report({'ERROR'}, f"Could not create directory: {e}") return {'CANCELLED'} + # Get compositor output nodes if multi-pass is enabled + output_nodes = {} + depth_map_node = None + if use_multipass: + try: + output_nodes = { + 'image': scene.node_tree.nodes[scene['multiangle_image_output']], + 'depth': scene.node_tree.nodes[scene['multiangle_depth_output']] + } + if use_lineart: + output_nodes['lineart'] = scene.node_tree.nodes[scene['multiangle_lineart_output']] + + if 'multiangle_depth_map_range' in scene: + depth_map_node = scene.node_tree.nodes[scene['multiangle_depth_map_range']] + except KeyError: + self.report({'WARNING'}, "Compositor nodes not found. Run 'Setup Multi-Pass Compositor' first.") + use_multipass = False + + # Update depth mapping range if specified + if depth_map_node and props.depth_range > 0: + depth_map_node.inputs['From Max'].default_value = props.depth_range + total_frames = scene.frame_end - scene.frame_start + 1 total_renders = len(angles_and_dirs) * total_frames current_render = 0 - self.report({'INFO'}, f"Starting multi-angle render: {len(angles_and_dirs)} angles") + render_type = "multi-pass " if use_multipass else "" + if use_lineart: + render_type += "with Line Art " + + self.report({'INFO'}, f"Starting multi-angle {render_type}render: {len(angles_and_dirs)} angles") # Render from each angle for angle_deg, direction in angles_and_dirs: @@ -102,6 +409,12 @@ class RENDER_OT_multi_angle(Operator): angle_dir = os.path.join(animation_dir, direction) try: os.makedirs(angle_dir, exist_ok=True) + if use_multipass: + # Create subdirectories for each pass + os.makedirs(os.path.join(angle_dir, "image"), exist_ok=True) + os.makedirs(os.path.join(angle_dir, "depth"), exist_ok=True) + if use_lineart: + os.makedirs(os.path.join(angle_dir, "lineart"), exist_ok=True) except OSError as e: self.report({'ERROR'}, f"Could not create directory {angle_dir}: {e}") continue @@ -113,8 +426,25 @@ class RENDER_OT_multi_angle(Operator): # Update scene bpy.context.view_layer.update() - # Set output path for this angle - scene.render.filepath = os.path.join(angle_dir, f"{animation_name}_{direction}_") + if use_multipass: + # Set up compositor output paths for each pass + base_filename = f"{animation_name}_{direction}_" + + output_nodes['image'].base_path = os.path.join(angle_dir, "image") + os.sep + output_nodes['image'].file_slots[0].path = base_filename + + output_nodes['depth'].base_path = os.path.join(angle_dir, "depth") + os.sep + output_nodes['depth'].file_slots[0].path = base_filename + + if use_lineart: + output_nodes['lineart'].base_path = os.path.join(angle_dir, "lineart") + os.sep + output_nodes['lineart'].file_slots[0].path = base_filename + + # Don't set scene.render.filepath as compositor handles output + scene.render.filepath = "" + else: + # Set output path for regular rendering + scene.render.filepath = os.path.join(angle_dir, f"{animation_name}_{direction}_") self.report({'INFO'}, f"Rendering {direction} angle ({angle_deg}°)") @@ -128,9 +458,16 @@ class RENDER_OT_multi_angle(Operator): # Restore original settings camera_parent.rotation_euler = original_rotation scene.render.filepath = original_filepath + context.window.view_layer = scene.view_layers[original_view_layer] + + # Reset compositor output paths if multi-pass was used + if use_multipass: + for node in output_nodes.values(): + node.base_path = "" + bpy.context.view_layer.update() - self.report({'INFO'}, f"Multi-angle render complete! Files saved in: {animation_dir}") + self.report({'INFO'}, f"Multi-angle {render_type}render complete! Files saved in: {animation_dir}") return {'FINISHED'} @@ -140,6 +477,42 @@ class MultiAngleProperties(bpy.types.PropertyGroup): description="Name for the animation (will create folder with this name)", default="my_animation" ) + + depth_range: FloatProperty( + name="Depth Range", + description="Maximum depth value for depth mapping (adjust for better contrast)", + default=10.0, + min=0.1, + max=1000.0 + ) + + use_lineart: BoolProperty( + name="Enable Line Art", + description="Include Line Art pass in rendering", + default=False + ) + + separate_lineart: BoolProperty( + name="Separate Line Art", + description="Remove Line Art from main image pass", + default=True + ) + + lineart_thickness: FloatProperty( + name="Line Thickness", + description="Thickness of Line Art strokes", + default=3.0, + min=0.1, + max=20.0 + ) + + lineart_crease_angle: FloatProperty( + name="Crease Angle", + description="Angle threshold for crease lines (degrees)", + default=60.0, + min=0.0, + max=180.0 + ) class RENDER_PT_multi_angle_panel(Panel): @@ -164,9 +537,52 @@ class RENDER_PT_multi_angle_panel(Panel): layout.separator() + # Compositor setup section + compositor_box = layout.box() + compositor_box.label(text="Multi-Pass Setup:", icon='NODETREE') + + # Check if compositor is set up + use_multipass = (scene.use_nodes and + 'multiangle_image_output' in scene) + + use_lineart = props.use_lineart and 'multiangle_lineart_output' in scene + + if use_multipass: + compositor_box.label(text="✓ Multi-pass compositor ready", icon='CHECKMARK') + passes = ["Image", "Depth"] + if use_lineart: + passes.append("Line Art") + row = compositor_box.row() + row.label(text=f"Renders: {', '.join(passes)}") + else: + compositor_box.label(text="⚠ Multi-pass not configured", icon='ERROR') + + # Line Art settings + layout.separator() + lineart_box = layout.box() + lineart_box.label(text="Line Art Settings:", icon='GREASEPENCIL') + + row = lineart_box.row() + row.prop(props, "use_lineart") + + if props.use_lineart: + col = lineart_box.column(align=True) + col.prop(props, "separate_lineart") + col.prop(props, "lineart_thickness") + col.prop(props, "lineart_crease_angle") + + row = compositor_box.row() + row.scale_y = 1.5 + op = row.operator("render.setup_compositor", text="Setup Multi-Pass Compositor", icon='NODETREE') + + layout.separator() + # Animation name input layout.prop(props, "animation_name") + # Depth range setting + layout.prop(props, "depth_range") + # Display current settings col = layout.column(align=True) col.label(text="Current Settings:", icon='SETTINGS') @@ -185,14 +601,51 @@ class RENDER_PT_multi_angle_panel(Panel): frame_end = context.scene.frame_end total_frames = frame_end - frame_start + 1 col.label(text=f"Frames: {frame_start}-{frame_end} ({total_frames} total)") - col.label(text=f"Total renders: {total_frames * 8}") + + # Calculate total renders + pass_count = 1 # Always have image + if use_multipass: + pass_count = 2 # Image + Depth + if use_lineart: + pass_count = 3 # Image + Depth + Line Art + + total_renders = total_frames * 8 * pass_count + col.label(text=f"Total renders: {total_frames * 8} × {pass_count} passes = {total_renders}") layout.separator() # Render button row = layout.row() row.scale_y = 2.0 - row.operator("render.multi_angle", text="Render 8 Angles", icon='RENDER_ANIMATION') + button_text = "Render 8 Angles" + if use_multipass: + button_text += " (Multi-Pass" + if use_lineart: + button_text += " + Line Art" + button_text += ")" + else: + button_text += " (Image Only)" + + row.operator("render.multi_angle", text=button_text, icon='RENDER_ANIMATION') + + # Output structure info + layout.separator() + box = layout.box() + box.label(text="Output Structure:", icon='FILE_FOLDER') + + if use_multipass: + box.label(text="animation_name/") + passes_text = "image/, depth/" + if use_lineart: + passes_text += ", lineart/" + box.label(text=f" ├── s/{passes_text}") + box.label(text=f" ├── sw/{passes_text}") + box.label(text=f" └── ... (8 angles × {pass_count} passes)") + else: + box.label(text="animation_name/") + box.label(text=" ├── s/ (image files)") + box.label(text=" ├── sw/ (image files)") + box.label(text=" └── ... (8 angles)") # Angles info layout.separator() @@ -224,6 +677,7 @@ class RENDER_PT_multi_angle_panel(Panel): def register(): bpy.utils.register_class(MultiAngleProperties) + bpy.utils.register_class(RENDER_OT_setup_compositor) bpy.utils.register_class(RENDER_OT_multi_angle) bpy.utils.register_class(RENDER_PT_multi_angle_panel) bpy.types.Scene.multi_angle_props = bpy.props.PointerProperty(type=MultiAngleProperties) @@ -232,9 +686,10 @@ def register(): def unregister(): bpy.utils.unregister_class(RENDER_PT_multi_angle_panel) bpy.utils.unregister_class(RENDER_OT_multi_angle) + bpy.utils.unregister_class(RENDER_OT_setup_compositor) bpy.utils.unregister_class(MultiAngleProperties) del bpy.types.Scene.multi_angle_props if __name__ == "__main__": - register() + register()