9 Commits

Author SHA1 Message Date
dc934fd3ac 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
2026-06-03 18:43:29 +02:00
7f5a70da8a fix: add color_management follow_scene and remove redundant film_exposure 2026-06-03 18:38:40 +02:00
d2f4af51e1 feat: add additional render options (noise threshold, denoising, max bounces) 2026-06-03 01:04:41 +02:00
024f6f9ad4 fix(utils): disable destructive modifier override correction that was clearing material assignments 2026-06-02 23:22:49 +02:00
d40e3f0a0c fix: resolve layout flow error and add fallback logic for AgX color management look enums 2026-06-02 23:14:28 +02:00
49cd08494b fix(render): cache settings at batch start so they apply consistently to all scenes 2026-06-02 23:08:33 +02:00
634700e4db feat(ui): add dedicated cancel button for batch rendering 2026-06-02 23:07:09 +02:00
d1a5655464 fix(utils): only assign socket values if they differ to prevent library override reload wipes 2026-06-02 23:04:18 +02:00
5bf8f48db0 fix: protect Material/Object sockets from library override re-evaluation
When fix_lib_overrides() writes to any mod[sid] on a GeoNode modifier,
Blender can re-evaluate ALL library overrides on that modifier, wiping
Material and Object sockets back to their library defaults (NONE).

Fix: snapshot all fragile sockets (NodeSocketMaterial, NodeSocketObject)
before any modifications, then restore them if they got wiped.
2026-06-02 22:54:56 +02:00
10 changed files with 429 additions and 487 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
__pycache__/

View File

@@ -16,7 +16,6 @@ classes = (
properties.SceneRendererSettings,
operators.SCENERENDERER_OT_render_all,
operators.SCENERENDERER_OT_cancel_batch,
operators.SCENERENDERER_OT_launch_swarm,
panel.SCENERENDERER_PT_panel,
)

Binary file not shown.

Binary file not shown.

Binary file not shown.

413
operators.py Normal file
View File

@@ -0,0 +1,413 @@
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'}

View File

@@ -93,36 +93,25 @@ class SCENERENDERER_PT_panel(bpy.types.Panel):
# --- Render button ---
layout.separator()
row = layout.row(align=True)
row.scale_y = 2.0
from . import operators
if operators.g_is_rendering:
scene_name = ""
if len(operators.g_scenes_to_render) > 0 and operators.g_render_index > 0:
scene_name = operators.g_scenes_to_render[operators.g_render_index - 1]
layout.label(text=f"Rendering: {scene_name} ({operators.g_render_index}/{len(operators.g_scenes_to_render)})", icon='SCENE_DATA')
if operators.g_progress_text:
layout.label(text=operators.g_progress_text, icon='TIME')
else:
layout.label(text="Initializing...", icon='TIME')
from .operators import SCENERENDERER_OT_render_all
is_running = getattr(SCENERENDERER_OT_render_all, "_timer", None) is not None
row = layout.row(align=True)
row.scale_y = 2.0
if is_running:
row.operator(
"scenerenderer.cancel_batch",
text="Cancel Render",
text="Cancel Batch Render",
icon='CANCEL',
)
layout.label(
text="Press ESC to force stop the active frame.",
icon='INFO',
)
else:
row = layout.row(align=True)
row.scale_y = 2.0
op1 = row.operator("scenerenderer.render_all", text="Current Scene", icon='RENDER_STILL')
op1.render_only_current = True
op2 = row.operator("scenerenderer.render_all", text="All Scenes", icon='RENDER_ANIMATION')
op2.render_only_current = False
layout.separator()
row = layout.row(align=True)
row.scale_y = 2.0
row.operator("scenerenderer.launch_swarm", text="Start Render Swarm (Network)", icon='NODETREE')
row.operator(
"scenerenderer.render_all",
text="Render All Scenes",
icon='RENDER_STILL',
)

View File

@@ -1,460 +0,0 @@
import bpy
import os
import subprocess
import tempfile
import sys
from . import utils
# Global state to track background rendering across the UI
g_render_process = None
g_render_log = None
g_is_rendering = False
g_cancel_requested = False
# Global state for the queue
g_scenes_to_render = []
g_render_index = 0
g_temp_blend = ""
g_temp_script = ""
g_render_dir = ""
g_progress_text = ""
g_log_seek_pos = 0
def cleanup_process():
global g_render_process, g_render_log
if g_render_process is not None:
if g_render_process.poll() is None:
g_render_process.terminate()
g_render_process.wait()
g_render_process = None
if g_render_log is not None:
try:
g_render_log.close()
except Exception:
pass
g_render_log = None
def cleanup_all():
global g_is_rendering, g_cancel_requested
global g_temp_blend, g_temp_script, g_progress_text, g_log_seek_pos
cleanup_process()
if g_temp_blend and os.path.exists(g_temp_blend):
try:
os.remove(g_temp_blend)
except Exception:
pass
if g_temp_script and os.path.exists(g_temp_script):
try:
os.remove(g_temp_script)
except Exception:
pass
for sc in bpy.data.scenes:
sc.render.use_lock_interface = False
g_is_rendering = False
g_cancel_requested = False
g_progress_text = ""
g_log_seek_pos = 0
# Force UI update
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
area.tag_redraw()
def render_queue_timer():
global g_render_process, g_is_rendering, g_cancel_requested
global g_scenes_to_render, g_render_index, g_render_dir, g_temp_blend, g_temp_script
global g_progress_text, g_log_seek_pos
if g_cancel_requested:
print(" [CANCELLED] User aborted background render.")
cleanup_all()
return None
# Tail the log for progress updates
if g_render_dir:
log_path = os.path.join(g_render_dir, "render_log.txt")
if os.path.exists(log_path):
try:
with open(log_path, "r") as f:
f.seek(g_log_seek_pos)
new_data = f.read()
g_log_seek_pos = f.tell()
if new_data:
lines = new_data.strip().split('\\n')
for line in reversed(lines):
if " | Time:" in line or " | Sample" in line or " | Rendered" in line:
parts = line.split(" | ")
clean_parts = [p for p in parts if "Time:" in p or "Remaining:" in p or "Sample" in p or "Rendered" in p]
if clean_parts:
g_progress_text = " | ".join(clean_parts)
break
except Exception:
pass
if g_render_process is not None:
retcode = g_render_process.poll()
if retcode is None:
# Still running
return 0.5
# Process finished
if retcode != 0:
print(f" [ERROR] Render failed with code {retcode} for {g_scenes_to_render[g_render_index-1]}")
else:
print(f" [DONE] {g_scenes_to_render[g_render_index-1]}")
cleanup_process()
# Start next
if g_render_index >= len(g_scenes_to_render):
print(f" [FINISHED] All {len(g_scenes_to_render)} scenes rendered successfully.")
cleanup_all()
return None
scene_name = g_scenes_to_render[g_render_index]
g_render_index += 1
try:
settings = SCENERENDERER_OT_render_all._cached_settings
safe_name = scene_name.replace("/", "_").replace("\\\\", "_").replace(":", "_").strip()
output_path = os.path.join(g_render_dir, safe_name)
fmt = settings["output_format"]
from .operators import _FORMAT_TO_EXT
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}")
return 0.1 # Skip immediately
print(f"\\n [{g_render_index}/{len(g_scenes_to_render)}] Rendering Subprocess: {scene_name}")
blender_bin = bpy.app.binary_path
cmd = [
"nice", "-n", "19",
blender_bin,
"-b", g_temp_blend,
"-P", g_temp_script,
"--", scene_name, output_path
]
log_path = os.path.join(g_render_dir, "render_log.txt")
global g_render_log
g_render_log = open(log_path, "a")
g_render_log.write(f"\\n\\n--- STARTING RENDER: {scene_name} ---\\n")
g_render_log.write(f"Command: {' '.join(cmd)}\\n")
g_render_log.flush()
g_render_process = subprocess.Popen(cmd, stdout=g_render_log, stderr=subprocess.STDOUT)
except Exception as e:
print(f" [ERROR] Failed to start {scene_name}: {e}")
# Force UI update
for window in bpy.context.window_manager.windows:
for area in window.screen.areas:
area.tag_redraw()
return 0.5
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"
@classmethod
def poll(cls, context):
return g_is_rendering
def execute(self, context):
global g_cancel_requested
g_cancel_requested = True
self.report({'INFO'}, "Batch render cancellation requested.")
return {'FINISHED'}
class SCENERENDERER_OT_launch_swarm(bpy.types.Operator):
bl_idname = "scenerenderer.launch_swarm"
bl_label = "Launch Render Swarm"
bl_description = "Start the Render Swarm Companion App as Host"
def execute(self, context):
# Ensure file is saved
if not bpy.data.is_saved:
self.report({'ERROR'}, "Please save your .blend file first!")
return {'CANCELLED'}
bpy.ops.wm.save_mainfile()
filepath = bpy.data.filepath
render_dir = bpy.path.abspath("//renders")
os.makedirs(render_dir, exist_ok=True)
# Determine path to swarm app
swarm_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "scene-renderer-swarm"))
if not os.path.exists(swarm_dir):
self.report({'ERROR'}, f"Swarm App not found at {swarm_dir}")
return {'CANCELLED'}
import platform
if platform.system() == "Windows":
script = os.path.join(swarm_dir, "run.bat")
else:
script = os.path.join(swarm_dir, "run.sh")
cmd = [
script,
"--blend", filepath,
"--out", render_dir,
"--start", str(context.scene.frame_start),
"--end", str(context.scene.frame_end)
]
try:
# We use Popen so we don't block Blender
subprocess.Popen(cmd, cwd=swarm_dir)
self.report({'INFO'}, "Render Swarm Host launched!")
except Exception as e:
self.report({'ERROR'}, f"Failed to launch Swarm: {e}")
return {'FINISHED'}
_FORMAT_TO_EXT = {
'PNG': '.png',
'JPEG': '.jpg',
'TIFF': '.tif',
'OPEN_EXR': '.exr',
'OPEN_EXR_MULTILAYER': '.exr',
}
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/ via Background Subprocess"
bl_options = {'REGISTER'}
render_only_current: bpy.props.BoolProperty(
name="Only Current Scene",
default=False,
options={'HIDDEN'},
)
# Cache for settings
_cached_settings = {}
@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"]
if settings["test_mode"]:
r.resolution_percentage = 10
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'
depth = settings["color_depth"]
r.image_settings.color_depth = '16' if depth == '8' else depth
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"]
c.time_limit = settings["time_limit"] if settings["time_limit"] > 0.0 else 0.0
c.use_denoising = settings["use_denoising"]
if c.use_denoising:
c.denoiser = settings["denoiser"]
c.denoising_input_passes = settings["denoising_input_passes"]
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"]
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
c.filter_width = settings["film_filter_width"]
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:
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:
pass
def invoke(self, context, event):
global g_is_rendering
if g_is_rendering:
self.report({'WARNING'}, "Already rendering in background!")
return {'CANCELLED'}
# Bypass the dialog entirely so we don't block the UI thread modal context!
return self.execute(context)
def execute(self, context):
global g_is_rendering, g_cancel_requested
global g_scenes_to_render, g_render_index, g_render_dir, g_temp_blend, g_temp_script
settings = context.scene.scene_renderer
g_is_rendering = True
g_cancel_requested = False
SCENERENDERER_OT_render_all._cached_settings = {
"test_mode": settings.test_mode,
"skip_existing": settings.skip_existing,
"use_cpu": settings.use_cpu,
"override_resolution": settings.override_resolution,
"resolution_x": settings.resolution_x,
"resolution_y": settings.resolution_y,
"resolution_percentage": settings.resolution_percentage,
"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,
"use_denoising": settings.use_denoising,
"denoiser": settings.denoiser,
"denoising_input_passes": settings.denoising_input_passes,
"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,
"use_clamping": settings.use_clamping,
"clamp_direct": settings.clamp_direct,
"clamp_indirect": settings.clamp_indirect,
"film_transparent": settings.film_transparent,
"film_filter_width": settings.film_filter_width,
"output_format": settings.output_format,
"color_depth": settings.color_depth,
"exposure": settings.exposure,
"look": settings.look,
}
silenced = utils.silence_puresky()
utils.fix_lib_overrides()
utils.fix_missing_images()
if not settings.use_cpu:
utils.init_metal_gpu()
for sc in bpy.data.scenes:
sc.render.use_lock_interface = True
sc.cycles.device = 'CPU' if settings.use_cpu else 'GPU'
self._apply_render_settings(sc, SCENERENDERER_OT_render_all._cached_settings)
bpy.context.view_layer.update()
bpy.context.evaluated_depsgraph_get().update()
g_render_dir = bpy.path.abspath("//renders")
if settings.test_mode:
g_render_dir = os.path.join(g_render_dir, "test")
os.makedirs(g_render_dir, exist_ok=True)
# We must save the current state so the background process has it.
# This causes a tiny hiccup (saving) but it's required.
temp_blend_fd, g_temp_blend = tempfile.mkstemp(suffix=".blend", prefix="sr_temp_")
os.close(temp_blend_fd)
bpy.ops.wm.save_as_mainfile(filepath=g_temp_blend, copy=True)
temp_script_fd, g_temp_script = tempfile.mkstemp(suffix=".py", prefix="sr_script_")
with os.fdopen(temp_script_fd, 'w') as f:
f.write("""import bpy
import sys
try:
args = sys.argv[sys.argv.index("--") + 1:]
scene_name = args[0]
output_path = args[1]
except ValueError:
print("Error: Scene name and output path required")
sys.exit(1)
scene = bpy.data.scenes.get(scene_name)
if not scene:
sys.exit(1)
cam = scene.objects.get("Profile")
if cam:
scene.camera = cam
scene.render.filepath = output_path
bpy.ops.render.render(write_still=True, scene=scene_name)
""")
if self.render_only_current:
g_scenes_to_render = [context.scene.name]
else:
g_scenes_to_render = utils.get_scenes()
if settings.test_mode:
g_scenes_to_render = g_scenes_to_render[:1]
g_render_index = 0
if len(g_scenes_to_render) == 0:
self.report({'WARNING'}, "No scenes to render!")
cleanup_all()
return {'CANCELLED'}
log_path = os.path.join(g_render_dir, "render_log.txt")
with open(log_path, "w") as f:
f.write("=== SCENE RENDERER LOG ===\\n")
print(f"\\n{'='*50}")
print(f" Scene Renderer (Timers) -- {len(g_scenes_to_render)} scenes")
print(f"{'='*50}")
# Use global timers instead of modal to prevent dialog/UI thread lockups!
bpy.app.timers.register(render_queue_timer)
return {'FINISHED'}