bl_info = { "name": "Compass Render", # Main display name "author": "Bryce", # Your name/credit "version": (1, 2, 0), # Version number (bumped) "blender": (4, 3, 0), # Minimum Blender version "location": "Properties > Render Properties > Multi-Angle Renderer", "description": "Multi-angle animation renderer with Line Art + optional OpenPose layer output", "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 def find_openpose_view_layer(scene: bpy.types.Scene): """ Tries to find an OpenPose View Layer by common names. Customize this list if your pipeline uses a specific layer name. """ candidates = { "OpenPose", "OpenPose_Layer", "OpenPoseLayer", "OPENPOSE", "openpose", "openpose_layer", } for vl in scene.view_layers: if vl.name in candidates: return vl return None class RENDER_OT_setup_compositor(Operator): """Setup compositor for multi-pass rendering with Line Art (and optional OpenPose)""" bl_idname = "render.setup_compositor" bl_label = "Setup Multi-Pass Compositor" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): lineart_view_layer = None openpose_view_layer = None 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" # 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: 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) # We'll use a separate view layer for Line Art 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']) # Optional OpenPose layer support openpose_output = None openpose_view_layer = find_openpose_view_layer(scene) if openpose_view_layer is not None: openpose_output = nodes.new(type='CompositorNodeOutputFile') openpose_output.location = (600, -300) openpose_output.label = "OpenPose Output" openpose_output.base_path = "" openpose_output.file_slots.clear() openpose_output.file_slots.new("openpose") openpose_output.format.file_format = 'PNG' openpose_render_layers = nodes.new(type='CompositorNodeRLayers') openpose_render_layers.location = (0, -400) openpose_render_layers.layer = openpose_view_layer.name # Links links.new(render_layers.outputs['Depth'], depth_map_range.inputs['Value']) links.new(depth_map_range.outputs['Value'], depth_output.inputs['depth']) if lineart_output and 'lineart_render_layers' in locals(): links.new(lineart_render_layers.outputs['Image'], lineart_output.inputs['lineart']) if openpose_output and 'openpose_render_layers' in locals(): links.new(openpose_render_layers.outputs['Image'], openpose_output.inputs['openpose']) # 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 if openpose_output: scene['multiangle_openpose_output'] = openpose_output.name self.report({'INFO'}, "Multi-pass compositor setup complete") if openpose_view_layer is not None: self.report({'INFO'}, f"OpenPose layer detected: '{openpose_view_layer.name}' (output enabled)") else: # Ensure stale key doesn't linger if user removed the layer if 'multiangle_openpose_output' in scene: del scene['multiangle_openpose_output'] self.report({'INFO'}, "OpenPose layer not found (output not enabled)") 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: gp_data = None # Method 1: Try Grease Pencil v3 (Blender 4.3+) if hasattr(bpy.data, 'grease_pencils_v3'): try: 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: gp_data = bpy.data.grease_pencils.new("LineArt_GP") except: pass # Method 3: Try operator methods if not gp_data: try: bpy.ops.object.grease_pencil_add(type='EMPTY') gp_obj = context.active_object # FIXED typo gp_obj.name = "LineArt_Object" except: try: bpy.ops.object.gpencil_add(type='EMPTY') gp_obj = context.active_object gp_obj.name = "LineArt_Object" except: 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) # 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) # Configure the Line Art modifier if lineart_mod: lineart_mod.source_type = 'SCENE' lineart_mod.use_contour = True lineart_mod.use_crease = True lineart_mod.target_layer = gp_layer.name lineart_mod.target_material = gp_obj.data.materials[0] return gp_obj class RENDER_OT_multi_angle(Operator): """Render animation from multiple angles""" bl_idname = "render.multi_angle" bl_label = "Render Multi-Angle Animation" bl_options = {'REGISTER', 'UNDO'} def execute(self, context): scene = context.scene 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 use_openpose = use_multipass and ('multiangle_openpose_output' in scene) and (find_openpose_view_layer(scene) is not None) # Find camera and its parent camera = None camera_parent = None if scene.camera: camera = scene.camera if camera.parent: camera_parent = camera.parent if not camera or not camera_parent: for obj in scene.objects: if obj.type == 'CAMERA' and obj.parent: camera = obj camera_parent = obj.parent scene.camera = camera break if not camera: self.report({'ERROR'}, "No camera found in the scene") return {'CANCELLED'} if not camera_parent: self.report({'ERROR'}, "Camera must be parented to an empty object") return {'CANCELLED'} original_rotation = camera_parent.rotation_euler.copy() original_view_layer = context.view_layer.name angles_and_dirs = [ (0, "s"), (45, "sw"), (90, "w"), (135, "nw"), (180, "n"), (225, "ne"), (270, "e"), (315, "se") ] original_filepath = scene.render.filepath base_path = bpy.path.abspath(original_filepath) if base_path: base_dir = os.path.dirname(base_path) else: base_dir = bpy.path.abspath("//") animation_dir = os.path.join(base_dir, animation_name) try: os.makedirs(animation_dir, exist_ok=True) except OSError as e: 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 use_openpose: output_nodes['openpose'] = scene.node_tree.nodes[scene['multiangle_openpose_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 use_openpose = False use_lineart = 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 render_type = "multi-pass " if use_multipass else "" if use_lineart: render_type += "with Line Art " if use_openpose: render_type += "with OpenPose " self.report({'INFO'}, f"Starting multi-angle {render_type}render: {len(angles_and_dirs)} angles") for angle_deg, direction in angles_and_dirs: angle_dir = os.path.join(animation_dir, direction) try: os.makedirs(angle_dir, exist_ok=True) if use_multipass: 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) if use_openpose: os.makedirs(os.path.join(angle_dir, "openpose"), exist_ok=True) except OSError as e: self.report({'ERROR'}, f"Could not create directory {angle_dir}: {e}") continue angle_rad = mathutils.Matrix.Rotation(math.radians(angle_deg), 4, 'Z') camera_parent.rotation_euler = angle_rad.to_euler() bpy.context.view_layer.update() if use_multipass: 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 if use_openpose: output_nodes['openpose'].base_path = os.path.join(angle_dir, "openpose") + os.sep output_nodes['openpose'].file_slots[0].path = base_filename scene.render.filepath = "" else: scene.render.filepath = os.path.join(angle_dir, f"{animation_name}_{direction}_") self.report({'INFO'}, f"Rendering {direction} angle ({angle_deg}°)") bpy.ops.render.render(animation=True) current_render += total_frames progress = (current_render / total_renders) * 100 self.report({'INFO'}, f"Progress: {progress:.1f}% - Completed {direction}") # 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_type}render complete! Files saved in: {animation_dir}") return {'FINISHED'} class MultiAngleProperties(bpy.types.PropertyGroup): animation_name: StringProperty( name="Animation Name", 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): """Multi-Angle Renderer Panel""" bl_label = "Multi-Angle Renderer" bl_idname = "RENDER_PT_multi_angle" bl_space_type = 'PROPERTIES' bl_region_type = 'WINDOW' bl_context = "render" def draw(self, context): layout = self.layout scene = context.scene props = scene.multi_angle_props box = layout.box() box.label(text="Setup Requirements:", icon='INFO') box.label(text="• Camera must be parented to an Empty") box.label(text="• Empty should be at scene center") box.label(text="• Set your frame range before rendering") layout.separator() compositor_box = layout.box() compositor_box.label(text="Multi-Pass Setup:", icon='NODETREE') use_multipass = (scene.use_nodes and 'multiangle_image_output' in scene) use_lineart = props.use_lineart and 'multiangle_lineart_output' in scene use_openpose = use_multipass and ('multiangle_openpose_output' in scene) and (find_openpose_view_layer(scene) is not None) if use_multipass: compositor_box.label(text="✓ Multi-pass compositor ready", icon='CHECKMARK') passes = ["Image", "Depth"] if use_lineart: passes.append("Line Art") if use_openpose: passes.append("OpenPose") compositor_box.label(text=f"Renders: {', '.join(passes)}") else: compositor_box.label(text="⚠ Multi-pass not configured", icon='ERROR') 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 row.operator("render.setup_compositor", text="Setup Multi-Pass Compositor", icon='NODETREE') layout.separator() layout.prop(props, "animation_name") layout.prop(props, "depth_range") col = layout.column(align=True) col.label(text="Current Settings:", icon='SETTINGS') if context.scene.camera: camera = context.scene.camera if camera.parent: col.label(text=f"Camera: {camera.name}") col.label(text=f"Parent: {camera.parent.name}") else: col.label(text="⚠ Camera has no parent!", icon='ERROR') else: col.label(text="⚠ No active camera!", icon='ERROR') frame_start = context.scene.frame_start 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)") pass_count = 1 if use_multipass: pass_count = 2 if use_lineart: pass_count += 1 if use_openpose: pass_count += 1 total_renders = total_frames * 8 * pass_count col.label(text=f"Total renders: {total_frames * 8} × {pass_count} passes = {total_renders}") layout.separator() row = layout.row() row.scale_y = 2.0 button_text = "Render 8 Angles" if use_multipass: button_text += " (Multi-Pass" if use_lineart: button_text += " + Line Art" if use_openpose: button_text += " + OpenPose" button_text += ")" else: button_text += " (Image Only)" row.operator("render.multi_angle", text=button_text, icon='RENDER_ANIMATION') 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/" if use_openpose: passes_text += ", openpose/" 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)") layout.separator() box = layout.box() box.label(text="Render Angles:", icon='CAMERA_DATA') split = box.split(factor=0.5) col1 = split.column() col2 = split.column() angles_info = [ ("S (0°)", "South"), ("SW (45°)", "Southwest"), ("W (90°)", "West"), ("NW (135°)", "Northwest"), ("N (180°)", "North"), ("NE (225°)", "Northeast"), ("E (270°)", "East"), ("SE (315°)", "Southeast") ] for i, (angle, desc) in enumerate(angles_info): if i < 4: col1.label(text=f"{angle}") else: col2.label(text=f"{angle}") 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) 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()