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
413 lines
14 KiB
Python
413 lines
14 KiB
Python
import bpy
|
|
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"
|
|
bl_description = "Abort the current batch rendering process"
|
|
|
|
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.",
|
|
)
|
|
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"
|
|
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
|
|
|
|
# ---- modal loop ---------------------------------------------------------
|
|
|
|
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'}
|
|
|
|
# ---- cleanup ------------------------------------------------------------
|
|
|
|
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)
|
|
|
|
# ---- per-scene render ---------------------------------------------------
|
|
|
|
def _render_scene(self, context, scene_name):
|
|
settings = SCENERENDERER_OT_render_all._cached_settings
|
|
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
|
|
|
|
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
|
|
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}' / "
|
|
f"'{agx_look}' not found, skipping."
|
|
)
|
|
|
|
# ---- 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()
|
|
)
|
|
output_path = os.path.join(render_dir, safe_name)
|
|
|
|
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
|
|
|
|
scene.render.filepath = output_path
|
|
|
|
# ---- confirmation dialog ------------------------------------------------
|
|
|
|
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()
|
|
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')
|
|
|
|
# ---- execute (entry point) ----------------------------------------------
|
|
|
|
def execute(self, context):
|
|
settings = context.scene.scene_renderer
|
|
|
|
# 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,
|
|
}
|
|
|
|
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'} |