commit 074a590194a46bbc08ef8fec64fc376cff1d60bc Author: Marc Mintel Date: Tue Jun 2 22:51:30 2026 +0200 feat: initial commit for scene renderer addon diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..3e434ea --- /dev/null +++ b/__init__.py @@ -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() \ No newline at end of file diff --git a/operators.py b/operators.py new file mode 100644 index 0000000..2ce6f82 --- /dev/null +++ b/operators.py @@ -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'} \ No newline at end of file diff --git a/panel.py b/panel.py new file mode 100644 index 0000000..2583aa1 --- /dev/null +++ b/panel.py @@ -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') \ No newline at end of file diff --git a/properties.py b/properties.py new file mode 100644 index 0000000..465d799 --- /dev/null +++ b/properties.py @@ -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', + ) \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..52eca86 --- /dev/null +++ b/utils.py @@ -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"] \ No newline at end of file