feat: extend render options with resolution, bounces, clamping, denoiser, output format

Added properties:
- Resolution override (width, height, percentage)
- Min samples + time limit for adaptive sampling
- Denoiser type (OIDN / OptiX) + denoise input passes
- Individual light bounces (diffuse, glossy, transmission, transparent, volume)
- Clamping (direct, indirect)
- Film transparent toggle + pixel filter width
- Output format (PNG, JPEG, TIFF, EXR, EXR Multilayer)
- Color depth (8/16 bit)

Version bump to 2.0.0
This commit is contained in:
2026-06-03 18:43:29 +02:00
parent 7f5a70da8a
commit dc934fd3ac
5 changed files with 434 additions and 43 deletions

View File

@@ -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'}