2 Commits

11 changed files with 815 additions and 366 deletions

Binary file not shown.

View File

@@ -1,252 +0,0 @@
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_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'}
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 = 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
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"]
c.use_adaptive_sampling = settings["use_noise_threshold"]
if c.use_adaptive_sampling:
c.adaptive_threshold = settings["noise_threshold"]
c.use_denoising = settings["use_denoising"]
if hasattr(c, 'max_bounces'):
c.max_bounces = settings["max_bounces"]
if settings["exposure"] != 0.0:
scene.view_settings.exposure = settings["exposure"]
scene.cycles.film_exposure = 2.0 ** 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
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.")
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()
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
# 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.
SCENERENDERER_OT_render_all._cached_settings = {
"test_mode": settings.test_mode,
"skip_existing": settings.skip_existing,
"use_cpu": settings.use_cpu,
"samples": settings.samples,
"use_noise_threshold": settings.use_noise_threshold,
"noise_threshold": settings.noise_threshold,
"use_denoising": settings.use_denoising,
"max_bounces": settings.max_bounces,
"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

@@ -1,50 +0,0 @@
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")
box = layout.box()
box.label(text="Cycles Render", icon='SCENE_DATA')
box.prop(settings, "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 = 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
# 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')
else:
row.operator("scenerenderer.render_all", text="Render All Scenes", icon='RENDER_STILL')

View File

@@ -1,63 +0,0 @@
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,
)
use_noise_threshold: bpy.props.BoolProperty(
name="Use Noise Threshold",
description="Stop sampling when the noise is below this threshold",
default=True,
)
noise_threshold: bpy.props.FloatProperty(
name="Noise Threshold",
description="Noise threshold for adaptive sampling",
default=0.01, min=0.0001, max=1.0, precision=4,
)
use_denoising: bpy.props.BoolProperty(
name="Use Denoising",
description="Enable denoising for final render",
default=True,
)
max_bounces: bpy.props.IntProperty(
name="Max Bounces",
description="Total maximum number of light bounces",
default=12, min=0, max=1024,
)
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',
)

View File

@@ -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",
@@ -16,6 +16,7 @@ classes = (
properties.SceneRendererSettings,
operators.SCENERENDERER_OT_render_all,
operators.SCENERENDERER_OT_cancel_batch,
operators.SCENERENDERER_OT_launch_swarm,
panel.SCENERENDERER_PT_panel,
)

460
scene_renderer/operators.py Normal file
View File

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

128
scene_renderer/panel.py Normal file
View File

@@ -0,0 +1,128 @@
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
# --- 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()
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, "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()
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')
row = layout.row(align=True)
row.scale_y = 2.0
row.operator(
"scenerenderer.cancel_batch",
text="Cancel Render",
icon='CANCEL',
)
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')

View File

@@ -0,0 +1,225 @@
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",
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,
)
# --- 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="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",
default=True,
)
noise_threshold: bpy.props.FloatProperty(
name="Noise Threshold",
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="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",
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',
)