diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/__init__.py b/__init__.py index a1c6e78..c8d545e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,7 +1,7 @@ bl_info = { "name": "Scene Renderer", "author": "Marc Mintel", - "version": (1, 0, 0), + "version": (2, 0, 0), "blender": (4, 2, 0), "location": "View3D > Sidebar > Scene Renderer", "description": "Render all scenes to //renders/ relative to the blend file", diff --git a/operators.py b/operators.py index f7779e2..5719ce5 100644 --- a/operators.py +++ b/operators.py @@ -3,15 +3,21 @@ import os from . import utils +# -- Render-callback handlers ------------------------------------------------- + @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 + +# -- Cancel operator ---------------------------------------------------------- + class SCENERENDERER_OT_cancel_batch(bpy.types.Operator): bl_idname = "scenerenderer.cancel_batch" bl_label = "Cancel Batch Render" @@ -19,10 +25,29 @@ class SCENERENDERER_OT_cancel_batch(bpy.types.Operator): def execute(self, context): SCENERENDERER_OT_render_all._cancel_requested = True - self.report({'INFO'}, "Batch render cancellation requested. Will stop after current frame or on ESC.") + self.report( + {'INFO'}, + "Batch render cancellation requested. " + "Will stop after current frame or on ESC.", + ) return {'FINISHED'} +# -- File-extension helper ---------------------------------------------------- + +# Why a map instead of relying on Blender's default: the extension must match +# the actual format we configure (e.g. EXR → .exr, not .png). +_FORMAT_TO_EXT = { + 'PNG': '.png', + 'JPEG': '.jpg', + 'TIFF': '.tif', + 'OPEN_EXR': '.exr', + 'OPEN_EXR_MULTILAYER': '.exr', +} + + +# -- Main render operator ----------------------------------------------------- + class SCENERENDERER_OT_render_all(bpy.types.Operator): bl_idname = "scenerenderer.render_all" bl_label = "Render All Scenes" @@ -34,10 +59,12 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator): _index = 0 _total = 0 _orig_lock = None - + _is_rendering = False _cancel_requested = False + # ---- modal loop --------------------------------------------------------- + def modal(self, context, event): if event.type == 'ESC' or self._cancel_requested: self.report({'WARNING'}, "Render batch cancelled") @@ -67,6 +94,8 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator): return {'PASS_THROUGH'} + # ---- cleanup ------------------------------------------------------------ + def finish(self, context): if self._timer: context.window_manager.event_timer_remove(self._timer) @@ -81,6 +110,8 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator): if on_render_cancel in bpy.app.handlers.render_cancel: bpy.app.handlers.render_cancel.remove(on_render_cancel) + # ---- per-scene render --------------------------------------------------- + def _render_scene(self, context, scene_name): settings = SCENERENDERER_OT_render_all._cached_settings scene = bpy.data.scenes.get(scene_name) @@ -103,60 +134,153 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator): 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.image_settings.color_management = 'FOLLOW_SCENE' - r.film_transparent = True + self._apply_render_settings(scene, settings) + self._setup_output_path(scene, settings) + 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}") + + # ---- apply all settings to scene ---------------------------------------- + + @staticmethod + def _apply_render_settings(scene, settings): + r = scene.render c = scene.cycles + + # Engine & basics + r.engine = 'CYCLES' + r.film_transparent = settings["film_transparent"] + + # Resolution override + if settings["override_resolution"]: + r.resolution_x = settings["resolution_x"] + r.resolution_y = settings["resolution_y"] + r.resolution_percentage = settings["resolution_percentage"] + + # Test-mode shrinks resolution to 10% + if settings["test_mode"]: + r.resolution_percentage = 10 + + # Output format + fmt = settings["output_format"] + r.image_settings.file_format = fmt + r.image_settings.color_management = 'FOLLOW_SCENE' + + if fmt in ('PNG', 'TIFF', 'OPEN_EXR'): + r.image_settings.color_depth = settings["color_depth"] + if fmt in ('PNG',): + r.image_settings.color_mode = 'RGBA' + elif fmt in ('JPEG',): + r.image_settings.color_mode = 'RGB' + elif fmt in ('TIFF',): + r.image_settings.color_mode = 'RGBA' + elif fmt in ('OPEN_EXR', 'OPEN_EXR_MULTILAYER'): + r.image_settings.color_mode = 'RGBA' + # EXR supports 16 or 32 bit float + depth = settings["color_depth"] + r.image_settings.color_depth = '16' if depth == '8' else depth + + # Sampling c.samples = 1 if settings["test_mode"] else settings["samples"] + + if settings["min_samples"] > 0: + c.adaptive_min_samples = settings["min_samples"] + c.use_adaptive_sampling = settings["use_noise_threshold"] if c.use_adaptive_sampling: c.adaptive_threshold = settings["noise_threshold"] + + if settings["time_limit"] > 0.0: + c.time_limit = settings["time_limit"] + else: + c.time_limit = 0.0 + + # Denoising c.use_denoising = settings["use_denoising"] + if c.use_denoising: + c.denoiser = settings["denoiser"] + c.denoising_input_passes = settings["denoising_input_passes"] + + # Light bounces if hasattr(c, 'max_bounces'): c.max_bounces = settings["max_bounces"] - + if hasattr(c, 'diffuse_bounces'): + c.diffuse_bounces = settings["diffuse_bounces"] + if hasattr(c, 'glossy_bounces'): + c.glossy_bounces = settings["glossy_bounces"] + if hasattr(c, 'transmission_bounces'): + c.transmission_bounces = settings["transmission_bounces"] + if hasattr(c, 'transparent_max_bounces'): + c.transparent_max_bounces = settings["transparent_max_bounces"] + if hasattr(c, 'volume_bounces'): + c.volume_bounces = settings["volume_bounces"] + + # Clamping + if settings["use_clamping"]: + c.sample_clamp_direct = settings["clamp_direct"] + c.sample_clamp_indirect = settings["clamp_indirect"] + else: + c.sample_clamp_direct = 0.0 + c.sample_clamp_indirect = 0.0 + + # Film filter + c.filter_width = settings["film_filter_width"] + + # Color management if settings["exposure"] != 0.0: scene.view_settings.exposure = settings["exposure"] + if settings["look"] != 'None': target_look = settings["look"] try: scene.view_settings.look = target_look except TypeError: - # Fallback for Blender 4.0+ AgX Color Management + # Fallback for Blender 4.0+ AgX agx_look = f"AgX - {target_look}" if target_look == "Medium Contrast": agx_look = "AgX - Base Contrast" try: scene.view_settings.look = agx_look except TypeError: - print(f" [WARNING] Look '{target_look}' or '{agx_look}' not found, skipping look assignment.") + print( + f" [WARNING] Look '{target_look}' / " + f"'{agx_look}' not found, skipping." + ) - if settings["test_mode"]: - r.resolution_percentage = 10 + # ---- build output path -------------------------------------------------- + def _setup_output_path(self, scene, settings): 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() + 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") + fmt = settings["output_format"] + ext = _FORMAT_TO_EXT.get(fmt, '.png') + + if settings["skip_existing"] and os.path.exists(output_path + ext): + print(f" [SKIP] Already exists: {safe_name}{ext}") SCENERENDERER_OT_render_all._is_rendering = False return - r.filepath = output_path + scene.render.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}") + # ---- confirmation dialog ------------------------------------------------ def invoke(self, context, event): scenes = utils.get_scenes() @@ -166,7 +290,10 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator): layout = self.layout scenes = utils.get_scenes() - layout.label(text=f"Ready to render {len(scenes)} scenes:", icon='RENDER_STILL') + layout.label( + text=f"Ready to render {len(scenes)} scenes:", + icon='RENDER_STILL', + ) box = layout.box() col = box.column() for s in scenes[:8]: @@ -176,20 +303,50 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator): layout.separator() layout.label(text="Click OK to start.", icon='INFO') + # ---- execute (entry point) ---------------------------------------------- + def execute(self, context): settings = context.scene.scene_renderer - - # Cache the settings exactly as they were when the user pressed "Render All Scenes". - # This prevents the settings from reverting to defaults when Blender switches the active scene context. + + # Cache ALL settings at batch start so they survive scene-context + # switches during the modal loop. SCENERENDERER_OT_render_all._cached_settings = { "test_mode": settings.test_mode, "skip_existing": settings.skip_existing, "use_cpu": settings.use_cpu, + # Resolution + "override_resolution": settings.override_resolution, + "resolution_x": settings.resolution_x, + "resolution_y": settings.resolution_y, + "resolution_percentage": settings.resolution_percentage, + # Sampling "samples": settings.samples, + "min_samples": settings.min_samples, "use_noise_threshold": settings.use_noise_threshold, "noise_threshold": settings.noise_threshold, + "time_limit": settings.time_limit, + # Denoising "use_denoising": settings.use_denoising, + "denoiser": settings.denoiser, + "denoising_input_passes": settings.denoising_input_passes, + # Bounces "max_bounces": settings.max_bounces, + "diffuse_bounces": settings.diffuse_bounces, + "glossy_bounces": settings.glossy_bounces, + "transmission_bounces": settings.transmission_bounces, + "transparent_max_bounces": settings.transparent_max_bounces, + "volume_bounces": settings.volume_bounces, + # Clamping + "use_clamping": settings.use_clamping, + "clamp_direct": settings.clamp_direct, + "clamp_indirect": settings.clamp_indirect, + # Film + "film_transparent": settings.film_transparent, + "film_filter_width": settings.film_filter_width, + # Output + "output_format": settings.output_format, + "color_depth": settings.color_depth, + # Color "exposure": settings.exposure, "look": settings.look, } @@ -241,12 +398,16 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator): return {'CANCELLED'} print(f"\n{'='*50}") - print(f" Scene Renderer -- {self._total} scenes" - f"{' (TEST MODE)' if settings.test_mode else ''}") + 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) + 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 index b86006f..f0bc962 100644 --- a/panel.py +++ b/panel.py @@ -12,39 +12,106 @@ class SCENERENDERER_PT_panel(bpy.types.Panel): layout = self.layout settings = context.scene.scene_renderer + # --- General toggles --- row = layout.row(align=True) row.prop(settings, "test_mode", toggle=True) row.prop(settings, "skip_existing", toggle=True) layout.prop(settings, "use_cpu") + + # --- Resolution --- box = layout.box() - box.label(text="Cycles Render", icon='SCENE_DATA') + row = box.row() + row.prop(settings, "override_resolution") + if settings.override_resolution: + col = box.column(align=True) + col.prop(settings, "resolution_x") + col.prop(settings, "resolution_y") + col.prop(settings, "resolution_percentage", slider=True) + + # --- Sampling --- + box = layout.box() + box.label(text="Sampling", icon='OUTLINER_OB_LIGHT') box.prop(settings, "samples") - + box.prop(settings, "min_samples") + row = box.row() row.prop(settings, "use_noise_threshold", text="Noise Threshold") sub = row.row() sub.active = settings.use_noise_threshold sub.prop(settings, "noise_threshold", text="") - - box.prop(settings, "use_denoising") - box.prop(settings, "max_bounces") + box.prop(settings, "time_limit") + + # --- Denoising --- + box = layout.box() + box.label(text="Denoising", icon='MOD_SMOOTH') + box.prop(settings, "use_denoising") + if settings.use_denoising: + box.prop(settings, "denoiser") + box.prop(settings, "denoising_input_passes") + + # --- Light Bounces --- + box = layout.box() + box.label(text="Light Bounces", icon='LIGHT_SUN') + box.prop(settings, "max_bounces") + col = box.column(align=True) + col.prop(settings, "diffuse_bounces") + col.prop(settings, "glossy_bounces") + col.prop(settings, "transmission_bounces") + col.prop(settings, "transparent_max_bounces") + col.prop(settings, "volume_bounces") + + # --- Clamping --- + box = layout.box() + row = box.row() + row.prop(settings, "use_clamping") + if settings.use_clamping: + col = box.column(align=True) + col.prop(settings, "clamp_direct") + col.prop(settings, "clamp_indirect") + + # --- Film --- + box = layout.box() + box.label(text="Film", icon='CAMERA_DATA') + box.prop(settings, "film_transparent") + box.prop(settings, "film_filter_width") + + # --- Output --- + box = layout.box() + box.label(text="Output", icon='OUTPUT') + box.prop(settings, "output_format") + # Color depth only for formats that support it + if settings.output_format in ('PNG', 'TIFF', 'OPEN_EXR'): + box.prop(settings, "color_depth") + + # --- Color Management --- box = layout.box() box.label(text="Color", icon='SHADING_RENDERED') box.prop(settings, "exposure", slider=True) box.prop(settings, "look") + # --- Render button --- layout.separator() row = layout.row(align=True) row.scale_y = 2.0 - - # Check if rendering is running by accessing the timer on the operator + from .operators import SCENERENDERER_OT_render_all is_running = getattr(SCENERENDERER_OT_render_all, "_timer", None) is not None - + if is_running: - row.operator("scenerenderer.cancel_batch", text="Cancel Batch Render", icon='CANCEL') - layout.label(text="Press ESC to force stop the active frame.", icon='INFO') + row.operator( + "scenerenderer.cancel_batch", + text="Cancel Batch Render", + icon='CANCEL', + ) + layout.label( + text="Press ESC to force stop the active frame.", + icon='INFO', + ) else: - row.operator("scenerenderer.render_all", text="Render All Scenes", icon='RENDER_STILL') \ No newline at end of file + 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 index c837f1f..d4430c2 100644 --- a/properties.py +++ b/properties.py @@ -2,6 +2,9 @@ import bpy class SceneRendererSettings(bpy.types.PropertyGroup): + + # --- General --- + test_mode: bpy.props.BoolProperty( name="Test Mode", description="Render only the first scene at 10% resolution with 1 sample", @@ -17,11 +20,46 @@ class SceneRendererSettings(bpy.types.PropertyGroup): description="Force CPU rendering instead of GPU", default=False, ) + + # --- Resolution --- + + override_resolution: bpy.props.BoolProperty( + name="Override Resolution", + description="Override the per-scene resolution with a global value", + default=False, + ) + resolution_x: bpy.props.IntProperty( + name="Resolution X", + description="Horizontal render resolution", + default=1920, min=1, max=32768, + ) + resolution_y: bpy.props.IntProperty( + name="Resolution Y", + description="Vertical render resolution", + default=1080, min=1, max=32768, + ) + resolution_percentage: bpy.props.IntProperty( + name="Resolution %", + description="Resolution scale percentage", + default=100, min=1, max=100, + subtype='PERCENTAGE', + ) + + # --- Sampling --- + samples: bpy.props.IntProperty( - name="Samples", - description="Cycles render samples", + name="Max Samples", + description="Maximum number of Cycles render samples", default=4096, min=1, max=65536, ) + min_samples: bpy.props.IntProperty( + name="Min Samples", + description=( + "Minimum number of samples before adaptive sampling can stop. " + "Set to 0 to use Blender default" + ), + default=0, min=0, max=65536, + ) use_noise_threshold: bpy.props.BoolProperty( name="Use Noise Threshold", description="Stop sampling when the noise is below this threshold", @@ -32,16 +70,140 @@ class SceneRendererSettings(bpy.types.PropertyGroup): description="Noise threshold for adaptive sampling", default=0.01, min=0.0001, max=1.0, precision=4, ) + time_limit: bpy.props.FloatProperty( + name="Time Limit", + description=( + "Maximum render time per frame in seconds. " + "0 = no limit" + ), + default=0.0, min=0.0, max=86400.0, + ) + + # --- Denoising --- + use_denoising: bpy.props.BoolProperty( name="Use Denoising", description="Enable denoising for final render", default=True, ) + denoiser: bpy.props.EnumProperty( + name="Denoiser", + description="Which denoiser to use", + items=[ + ('OPENIMAGEDENOISE', "OpenImageDenoise", "Intel Open Image Denoise (CPU)"), + ('OPTIX', "OptiX", "NVIDIA OptiX (GPU only)"), + ], + default='OPENIMAGEDENOISE', + ) + denoising_input_passes: bpy.props.EnumProperty( + name="Denoise Passes", + description="Passes used by the denoiser for better quality", + items=[ + ('RGB', "Color", "Use only color data"), + ('RGB_ALBEDO', "Color + Albedo", "Use color and albedo data"), + ('RGB_ALBEDO_NORMAL', "Color + Albedo + Normal", + "Use color, albedo, and normal data (best quality)"), + ], + default='RGB_ALBEDO_NORMAL', + ) + + # --- Light Bounces --- + max_bounces: bpy.props.IntProperty( - name="Max Bounces", + name="Total", description="Total maximum number of light bounces", default=12, min=0, max=1024, ) + diffuse_bounces: bpy.props.IntProperty( + name="Diffuse", + description="Maximum number of diffuse bounces", + default=4, min=0, max=1024, + ) + glossy_bounces: bpy.props.IntProperty( + name="Glossy", + description="Maximum number of glossy bounces", + default=4, min=0, max=1024, + ) + transmission_bounces: bpy.props.IntProperty( + name="Transmission", + description="Maximum number of transmission bounces", + default=12, min=0, max=1024, + ) + transparent_max_bounces: bpy.props.IntProperty( + name="Transparent", + description="Maximum number of transparent bounces", + default=8, min=0, max=1024, + ) + volume_bounces: bpy.props.IntProperty( + name="Volume", + description="Maximum number of volume scattering bounces", + default=0, min=0, max=1024, + ) + + # --- Clamping --- + + use_clamping: bpy.props.BoolProperty( + name="Use Clamping", + description="Clamp sample values to reduce fireflies", + default=False, + ) + clamp_direct: bpy.props.FloatProperty( + name="Direct Light", + description=( + "Clamp value for direct light samples. " + "0 = no clamping" + ), + default=0.0, min=0.0, max=1e8, + ) + clamp_indirect: bpy.props.FloatProperty( + name="Indirect Light", + description=( + "Clamp value for indirect light samples. " + "0 = no clamping" + ), + default=10.0, min=0.0, max=1e8, + ) + + # --- Film --- + + film_transparent: bpy.props.BoolProperty( + name="Transparent Background", + description="Render the background as transparent", + default=True, + ) + film_filter_width: bpy.props.FloatProperty( + name="Pixel Filter Width", + description="Pixel filter width for anti-aliasing", + default=1.5, min=0.01, max=10.0, + ) + + # --- Output --- + + output_format: bpy.props.EnumProperty( + name="Format", + description="Output file format", + items=[ + ('PNG', "PNG", "PNG image with alpha support"), + ('JPEG', "JPEG", "JPEG image (no alpha)"), + ('TIFF', "TIFF", "TIFF image"), + ('OPEN_EXR', "OpenEXR", "OpenEXR HDR image"), + ('OPEN_EXR_MULTILAYER', "OpenEXR Multilayer", + "OpenEXR with all render passes"), + ], + default='PNG', + ) + color_depth: bpy.props.EnumProperty( + name="Color Depth", + description="Bit depth per channel", + items=[ + ('8', "8 bit", "8 bits per channel"), + ('16', "16 bit", "16 bits per channel"), + ], + default='16', + ) + + # --- Color Management --- + exposure: bpy.props.FloatProperty( name="Exposure", description="Exposure boost in stops",