feat: initial commit for scene renderer addon

This commit is contained in:
2026-06-02 22:51:30 +02:00
commit 074a590194
5 changed files with 435 additions and 0 deletions

37
__init__.py Normal file
View 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
View 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
View 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
View 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
View 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"]