Files
blender-compass/blender_compass.py
2025-09-10 09:19:51 -07:00

696 lines
27 KiB
Python
Raw 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, 1, 0), # Version number
"blender": (4, 3, 0), # Minimum Blender version
"location": "Properties > Render Properties > Multi-Angle Renderer",
"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"
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
# Find camera and its parent
camera = None
camera_parent = None
# Look for active camera first
if scene.camera:
camera = scene.camera
if camera.parent:
camera_parent = camera.parent
# If no active camera or no parent, try to find a parented camera
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 # Set as active 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'}
# 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
(45, "sw"), # Southwest
(90, "w"), # West
(135, "nw"), # Northwest
(180, "n"), # North
(225, "ne"), # Northeast
(270, "e"), # East
(315, "se") # Southeast
]
# Store original output path
original_filepath = scene.render.filepath
base_path = bpy.path.abspath(original_filepath)
# Create main animation directory if it doesn't exist
if base_path:
base_dir = os.path.dirname(base_path)
else:
base_dir = bpy.path.abspath("//") # Use blend file directory
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 '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
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:
# Create subdirectory for this angle
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
# Set rotation (convert degrees to radians)
angle_rad = mathutils.Matrix.Rotation(math.radians(angle_deg), 4, 'Z')
camera_parent.rotation_euler = angle_rad.to_euler()
# Update scene
bpy.context.view_layer.update()
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}°)")
# Render animation
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
# Instructions
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 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')
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)")
# 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
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()
box = layout.box()
box.label(text="Render Angles:", icon='CAMERA_DATA')
# Create two columns for the angles
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()