Files
blender-compass/blender_compass.py
2025-12-18 22:31:54 -08:00

732 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()