feat: initial commit for scene renderer addon
This commit is contained in:
37
__init__.py
Normal file
37
__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
bl_info = {
|
||||
"name": "Scene Renderer",
|
||||
"author": "Marc Mintel",
|
||||
"version": (1, 0, 0),
|
||||
"blender": (4, 2, 0),
|
||||
"location": "View3D > Sidebar > Scene Renderer",
|
||||
"description": "Render all scenes to //renders/ relative to the blend file",
|
||||
"category": "Render",
|
||||
}
|
||||
|
||||
import bpy
|
||||
from . import properties, operators, panel
|
||||
|
||||
|
||||
classes = (
|
||||
properties.SceneRendererSettings,
|
||||
operators.SCENERENDERER_OT_render_all,
|
||||
panel.SCENERENDERER_PT_panel,
|
||||
)
|
||||
|
||||
|
||||
def register():
|
||||
for cls in classes:
|
||||
bpy.utils.register_class(cls)
|
||||
bpy.types.Scene.scene_renderer = bpy.props.PointerProperty(
|
||||
type=properties.SceneRendererSettings
|
||||
)
|
||||
|
||||
|
||||
def unregister():
|
||||
del bpy.types.Scene.scene_renderer
|
||||
for cls in reversed(classes):
|
||||
bpy.utils.unregister_class(cls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
register()
|
||||
210
operators.py
Normal file
210
operators.py
Normal file
@@ -0,0 +1,210 @@
|
||||
import bpy
|
||||
import os
|
||||
from . import utils
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def on_render_complete(scene, depsgraph=None):
|
||||
SCENERENDERER_OT_render_all._is_rendering = False
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def on_render_cancel(scene, depsgraph=None):
|
||||
SCENERENDERER_OT_render_all._is_rendering = False
|
||||
SCENERENDERER_OT_render_all._cancel_requested = True
|
||||
|
||||
|
||||
class SCENERENDERER_OT_render_all(bpy.types.Operator):
|
||||
bl_idname = "scenerenderer.render_all"
|
||||
bl_label = "Render All Scenes"
|
||||
bl_description = "Render all scenes to //renders/"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
_timer = None
|
||||
_scenes = []
|
||||
_index = 0
|
||||
_total = 0
|
||||
_orig_lock = None
|
||||
|
||||
_is_rendering = False
|
||||
_cancel_requested = False
|
||||
|
||||
def modal(self, context, event):
|
||||
if event.type == 'ESC' or self._cancel_requested:
|
||||
self.report({'WARNING'}, "Render batch cancelled")
|
||||
self.finish(context)
|
||||
return {'CANCELLED'}
|
||||
|
||||
if event.type == 'TIMER':
|
||||
if self._is_rendering:
|
||||
return {'PASS_THROUGH'}
|
||||
|
||||
if self._index >= self._total:
|
||||
self.report({'INFO'}, f"All {self._total} scenes rendered")
|
||||
self.finish(context)
|
||||
return {'FINISHED'}
|
||||
|
||||
scene_name = self._scenes[self._index]
|
||||
self._index += 1
|
||||
|
||||
try:
|
||||
SCENERENDERER_OT_render_all._is_rendering = True
|
||||
self._render_scene(context, scene_name)
|
||||
except Exception as e:
|
||||
SCENERENDERER_OT_render_all._is_rendering = False
|
||||
self.report({'ERROR'}, f"Failed {scene_name}: {e}")
|
||||
|
||||
context.area.tag_redraw()
|
||||
|
||||
return {'PASS_THROUGH'}
|
||||
|
||||
def finish(self, context):
|
||||
if self._timer:
|
||||
context.window_manager.event_timer_remove(self._timer)
|
||||
self._timer = None
|
||||
context.window.cursor_set('DEFAULT')
|
||||
if self._orig_lock is not None:
|
||||
for sc in bpy.data.scenes:
|
||||
sc.render.use_lock_interface = self._orig_lock
|
||||
|
||||
if on_render_complete in bpy.app.handlers.render_complete:
|
||||
bpy.app.handlers.render_complete.remove(on_render_complete)
|
||||
if on_render_cancel in bpy.app.handlers.render_cancel:
|
||||
bpy.app.handlers.render_cancel.remove(on_render_cancel)
|
||||
|
||||
def _render_scene(self, context, scene_name):
|
||||
settings = context.scene.scene_renderer
|
||||
scene = bpy.data.scenes.get(scene_name)
|
||||
if not scene:
|
||||
print(f" [SKIP] Scene not found: {scene_name}")
|
||||
SCENERENDERER_OT_render_all._is_rendering = False
|
||||
return
|
||||
|
||||
if scene.name == "_Shots":
|
||||
SCENERENDERER_OT_render_all._is_rendering = False
|
||||
return
|
||||
|
||||
bpy.context.window.scene = scene
|
||||
|
||||
cam = scene.objects.get("Profile")
|
||||
if cam:
|
||||
scene.camera = cam
|
||||
elif not scene.camera:
|
||||
print(f" [SKIP] No camera in {scene.name}")
|
||||
SCENERENDERER_OT_render_all._is_rendering = False
|
||||
return
|
||||
|
||||
r = scene.render
|
||||
r.engine = 'CYCLES'
|
||||
r.image_settings.file_format = 'PNG'
|
||||
r.image_settings.color_mode = 'RGBA'
|
||||
r.film_transparent = True
|
||||
|
||||
c = scene.cycles
|
||||
c.samples = 1 if settings.test_mode else settings.samples
|
||||
|
||||
if settings.exposure != 0.0:
|
||||
scene.view_settings.exposure = settings.exposure
|
||||
scene.cycles.film_exposure = 2.0 ** settings.exposure
|
||||
if settings.look != 'None':
|
||||
scene.view_settings.look = settings.look
|
||||
|
||||
if settings.test_mode:
|
||||
r.resolution_percentage = 10
|
||||
|
||||
render_dir = bpy.path.abspath("//renders")
|
||||
if settings.test_mode:
|
||||
render_dir = os.path.join(render_dir, "test")
|
||||
os.makedirs(render_dir, exist_ok=True)
|
||||
|
||||
safe_name = scene.name.replace("/", "_").replace("\\", "_").replace(":", "_").strip()
|
||||
output_path = os.path.join(render_dir, safe_name)
|
||||
|
||||
if settings.skip_existing and os.path.exists(output_path + ".png"):
|
||||
print(f" [SKIP] Already exists: {safe_name}.png")
|
||||
SCENERENDERER_OT_render_all._is_rendering = False
|
||||
return
|
||||
|
||||
r.filepath = output_path
|
||||
|
||||
print(f"\n [{self._index}/{self._total}] Rendering: {scene.name}")
|
||||
with context.temp_override(window=context.window, area=context.area, region=context.region):
|
||||
bpy.ops.render.render('INVOKE_DEFAULT', write_still=True, scene=scene.name)
|
||||
print(f" [STARTED] {scene.name}")
|
||||
|
||||
def invoke(self, context, event):
|
||||
scenes = utils.get_scenes()
|
||||
return context.window_manager.invoke_props_dialog(self, width=300)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
scenes = utils.get_scenes()
|
||||
|
||||
layout.label(text=f"Ready to render {len(scenes)} scenes:", icon='RENDER_STILL')
|
||||
box = layout.box()
|
||||
col = box.column(flow=True)
|
||||
for s in scenes[:8]:
|
||||
col.label(text=f" * {s}", icon='SCENE_DATA')
|
||||
if len(scenes) > 8:
|
||||
col.label(text=f" ... and {len(scenes) - 8} more")
|
||||
layout.separator()
|
||||
layout.label(text="Click OK to start.", icon='INFO')
|
||||
|
||||
def execute(self, context):
|
||||
settings = context.scene.scene_renderer
|
||||
|
||||
if on_render_complete not in bpy.app.handlers.render_complete:
|
||||
bpy.app.handlers.render_complete.append(on_render_complete)
|
||||
if on_render_cancel not in bpy.app.handlers.render_cancel:
|
||||
bpy.app.handlers.render_cancel.append(on_render_cancel)
|
||||
|
||||
SCENERENDERER_OT_render_all._is_rendering = False
|
||||
SCENERENDERER_OT_render_all._cancel_requested = False
|
||||
|
||||
silenced = utils.silence_puresky()
|
||||
if silenced:
|
||||
print(f" [FIX] Silenced {silenced} PureSky handlers.")
|
||||
|
||||
fxd = utils.fix_lib_overrides()
|
||||
print(f"Fixed {fxd} lib-override parameters")
|
||||
|
||||
img_fxd = utils.fix_missing_images()
|
||||
if img_fxd:
|
||||
print(f"Fixed {img_fxd} missing images with magenta dummies")
|
||||
|
||||
if not settings.use_cpu:
|
||||
metal_ok = utils.init_metal_gpu()
|
||||
if metal_ok:
|
||||
print("Initialized Metal GPU")
|
||||
else:
|
||||
print("Metal GPU init failed, using whatever is available")
|
||||
|
||||
self._orig_lock = bpy.data.scenes[0].render.use_lock_interface
|
||||
for sc in bpy.data.scenes:
|
||||
sc.render.use_lock_interface = True
|
||||
sc.cycles.device = 'CPU' if settings.use_cpu else 'GPU'
|
||||
|
||||
bpy.context.view_layer.update()
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
depsgraph.update()
|
||||
|
||||
self._scenes = utils.get_scenes()
|
||||
if settings.test_mode:
|
||||
self._scenes = self._scenes[:1]
|
||||
|
||||
self._total = len(self._scenes)
|
||||
self._index = 0
|
||||
|
||||
if self._total == 0:
|
||||
self.report({'WARNING'}, "No scenes to render!")
|
||||
return {'CANCELLED'}
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f" Scene Renderer -- {self._total} scenes"
|
||||
f"{' (TEST MODE)' if settings.test_mode else ''}")
|
||||
print(f"{'='*50}")
|
||||
|
||||
context.window_manager.modal_handler_add(self)
|
||||
self._timer = context.window_manager.event_timer_add(0.5, window=context.window)
|
||||
context.window.cursor_set('WAIT')
|
||||
|
||||
return {'RUNNING_MODAL'}
|
||||
30
panel.py
Normal file
30
panel.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import bpy
|
||||
|
||||
|
||||
class SCENERENDERER_PT_panel(bpy.types.Panel):
|
||||
bl_label = "Scene Renderer"
|
||||
bl_idname = "SCENERENDERER_PT_panel"
|
||||
bl_space_type = 'VIEW_3D'
|
||||
bl_region_type = 'UI'
|
||||
bl_category = "Scene Renderer"
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
settings = context.scene.scene_renderer
|
||||
|
||||
row = layout.row(align=True)
|
||||
row.prop(settings, "test_mode", toggle=True)
|
||||
row.prop(settings, "skip_existing", toggle=True)
|
||||
|
||||
layout.prop(settings, "use_cpu")
|
||||
layout.prop(settings, "samples")
|
||||
|
||||
box = layout.box()
|
||||
box.label(text="Color", icon='SHADING_RENDERED')
|
||||
box.prop(settings, "exposure", slider=True)
|
||||
box.prop(settings, "look")
|
||||
|
||||
layout.separator()
|
||||
row = layout.row(align=True)
|
||||
row.scale_y = 2.0
|
||||
row.operator("scenerenderer.render_all", text="Render All Scenes", icon='RENDER_STILL')
|
||||
43
properties.py
Normal file
43
properties.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import bpy
|
||||
|
||||
|
||||
class SceneRendererSettings(bpy.types.PropertyGroup):
|
||||
test_mode: bpy.props.BoolProperty(
|
||||
name="Test Mode",
|
||||
description="Render only the first scene at 10% resolution with 1 sample",
|
||||
default=False,
|
||||
)
|
||||
skip_existing: bpy.props.BoolProperty(
|
||||
name="Skip Existing",
|
||||
description="Skip scenes whose render file already exists",
|
||||
default=True,
|
||||
)
|
||||
use_cpu: bpy.props.BoolProperty(
|
||||
name="CPU Only",
|
||||
description="Force CPU rendering instead of GPU",
|
||||
default=False,
|
||||
)
|
||||
samples: bpy.props.IntProperty(
|
||||
name="Samples",
|
||||
description="Cycles render samples",
|
||||
default=4096, min=1, max=65536,
|
||||
)
|
||||
exposure: bpy.props.FloatProperty(
|
||||
name="Exposure",
|
||||
description="Exposure boost in stops",
|
||||
default=0.0, soft_min=-3.0, soft_max=3.0,
|
||||
)
|
||||
look: bpy.props.EnumProperty(
|
||||
name="Color Look",
|
||||
items=[
|
||||
('None', "None", ""),
|
||||
('Very Low Contrast', "Very Low Contrast", ""),
|
||||
('Low Contrast', "Low Contrast", ""),
|
||||
('Medium Low Contrast', "Medium Low Contrast", ""),
|
||||
('Medium Contrast', "Medium Contrast", ""),
|
||||
('Medium High Contrast', "Medium High Contrast", ""),
|
||||
('High Contrast', "High Contrast", ""),
|
||||
('Very High Contrast', "Very High Contrast", ""),
|
||||
],
|
||||
default='None',
|
||||
)
|
||||
115
utils.py
Normal file
115
utils.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import bpy
|
||||
import os
|
||||
|
||||
|
||||
def silence_puresky():
|
||||
removed = 0
|
||||
for handler_list in [bpy.app.handlers.depsgraph_update_pre,
|
||||
bpy.app.handlers.depsgraph_update_post]:
|
||||
for h in handler_list[:]:
|
||||
if "puresky" in str(h).lower() or "PureSky" in str(h):
|
||||
try:
|
||||
handler_list.remove(h)
|
||||
removed += 1
|
||||
except Exception:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
def fix_lib_overrides():
|
||||
fixed = 0
|
||||
for obj in bpy.data.objects:
|
||||
if not obj.modifiers:
|
||||
continue
|
||||
for mod in obj.modifiers:
|
||||
if mod.type != 'NODES' or not mod.node_group:
|
||||
continue
|
||||
if not hasattr(mod.node_group, 'interface'):
|
||||
continue
|
||||
|
||||
ng = mod.node_group
|
||||
name_to_sid = {}
|
||||
iface = getattr(ng, 'interface', None)
|
||||
if iface and hasattr(iface, 'items_tree'):
|
||||
for item in iface.items_tree:
|
||||
if item.item_type == 'SOCKET' and item.in_out == 'INPUT':
|
||||
name_to_sid[item.name] = (item.identifier, item.socket_type)
|
||||
|
||||
for item in (getattr(iface, 'items_tree', []) if iface else []):
|
||||
if item.item_type != 'SOCKET' or item.in_out != 'INPUT':
|
||||
continue
|
||||
sid = item.identifier
|
||||
if sid not in mod:
|
||||
continue
|
||||
stored = mod[sid]
|
||||
st = item.socket_type
|
||||
tt = type(stored).__name__
|
||||
if st == 'NodeSocketBool' and tt == 'int':
|
||||
mod[sid] = bool(stored)
|
||||
fixed += 1
|
||||
elif st == 'NodeSocketMenu' and tt == 'int':
|
||||
mod[sid] = int(stored)
|
||||
fixed += 1
|
||||
|
||||
ng_name = ng.name
|
||||
if ng_name == "Add Shader v1.0":
|
||||
if "Filter by ID" in name_to_sid:
|
||||
mod[name_to_sid["Filter by ID"][0]] = True
|
||||
fixed += 1
|
||||
if "ID" in name_to_sid and name_to_sid["ID"][1] == "NodeSocketString":
|
||||
mod[name_to_sid["ID"][0]] = "Index"
|
||||
fixed += 1
|
||||
elif ng_name in ["Add Shaders by Index v1.0", "Make Material", "Make Labels"]:
|
||||
if "ID" in name_to_sid and name_to_sid["ID"][1] == "NodeSocketString":
|
||||
mod[name_to_sid["ID"][0]] = "Index"
|
||||
fixed += 1
|
||||
if ng_name == "Make Labels" and "Shrinkwrap" in name_to_sid:
|
||||
mod[name_to_sid["Shrinkwrap"][0]] = True
|
||||
fixed += 1
|
||||
elif ng_name == "Make attached":
|
||||
if "Reset Children" in name_to_sid:
|
||||
mod[name_to_sid["Reset Children"][0]] = True
|
||||
fixed += 1
|
||||
if "Separate Children" in name_to_sid:
|
||||
mod[name_to_sid["Separate Children"][0]] = True
|
||||
fixed += 1
|
||||
elif ng_name == "Proxy v1.0":
|
||||
if "Reset Children" in name_to_sid:
|
||||
mod[name_to_sid["Reset Children"][0]] = True
|
||||
fixed += 1
|
||||
return fixed
|
||||
|
||||
|
||||
def fix_missing_images():
|
||||
fixed = 0
|
||||
for img in bpy.data.images:
|
||||
if img.source == 'FILE' and img.filepath:
|
||||
if getattr(img, "packed_file", None):
|
||||
continue
|
||||
abs_path = (bpy.path.abspath(img.filepath, library=img.library)
|
||||
if img.library else bpy.path.abspath(img.filepath))
|
||||
if not os.path.exists(abs_path):
|
||||
img.source = 'GENERATED'
|
||||
img.generated_width = 8
|
||||
img.generated_height = 8
|
||||
img.generated_type = 'BLANK'
|
||||
img.generated_color = (1.0, 0.0, 1.0, 1.0)
|
||||
fixed += 1
|
||||
return fixed
|
||||
|
||||
|
||||
def init_metal_gpu():
|
||||
try:
|
||||
prefs = bpy.context.preferences.addons['cycles'].preferences
|
||||
prefs.compute_device_type = 'METAL'
|
||||
prefs.get_devices()
|
||||
for dev in prefs.devices:
|
||||
dev.use = True
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_scenes():
|
||||
return [s.name for s in bpy.data.scenes
|
||||
if not s.name.startswith("_") and s.name != "_Shots"]
|
||||
Reference in New Issue
Block a user