refactor: move addon files to scene_renderer subfolder
This commit is contained in:
210
operators.py
210
operators.py
@@ -1,210 +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_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 = context.scene.scene_renderer
|
|
||||||
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
|
|
||||||
|
|
||||||
if settings.exposure != 0.0:
|
|
||||||
scene.view_settings.exposure = settings.exposure
|
|
||||||
scene.cycles.film_exposure = 2.0 ** settings.exposure
|
|
||||||
if settings.look != 'None':
|
|
||||||
scene.view_settings.look = settings.look
|
|
||||||
|
|
||||||
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(flow=True)
|
|
||||||
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
|
|
||||||
|
|
||||||
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'}
|
|
||||||
30
panel.py
30
panel.py
@@ -1,30 +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")
|
|
||||||
layout.prop(settings, "samples")
|
|
||||||
|
|
||||||
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
|
|
||||||
row.operator("scenerenderer.render_all", text="Render All Scenes", icon='RENDER_STILL')
|
|
||||||
@@ -1,43 +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,
|
|
||||||
)
|
|
||||||
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',
|
|
||||||
)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
bl_info = {
|
bl_info = {
|
||||||
"name": "Scene Renderer",
|
"name": "Scene Renderer",
|
||||||
"author": "Marc Mintel",
|
"author": "Marc Mintel",
|
||||||
"version": (1, 0, 0),
|
"version": (2, 0, 0),
|
||||||
"blender": (4, 2, 0),
|
"blender": (4, 2, 0),
|
||||||
"location": "View3D > Sidebar > Scene Renderer",
|
"location": "View3D > Sidebar > Scene Renderer",
|
||||||
"description": "Render all scenes to //renders/ relative to the blend file",
|
"description": "Render all scenes to //renders/ relative to the blend file",
|
||||||
@@ -15,6 +15,7 @@ from . import properties, operators, panel
|
|||||||
classes = (
|
classes = (
|
||||||
properties.SceneRendererSettings,
|
properties.SceneRendererSettings,
|
||||||
operators.SCENERENDERER_OT_render_all,
|
operators.SCENERENDERER_OT_render_all,
|
||||||
|
operators.SCENERENDERER_OT_cancel_batch,
|
||||||
panel.SCENERENDERER_PT_panel,
|
panel.SCENERENDERER_PT_panel,
|
||||||
)
|
)
|
||||||
|
|
||||||
413
scene_renderer/operators.py
Normal file
413
scene_renderer/operators.py
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
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'}
|
||||||
|
|
||||||
|
|
||||||
|
_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'}
|
||||||
123
scene_renderer/panel.py
Normal file
123
scene_renderer/panel.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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
|
||||||
225
scene_renderer/properties.py
Normal file
225
scene_renderer/properties.py
Normal 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',
|
||||||
|
)
|
||||||
90
scene_renderer/utils.py
Normal file
90
scene_renderer/utils.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import bpy
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def silence_puresky():
|
||||||
|
removed = 0
|
||||||
|
for handler_list in [bpy.app.handlers.depsgraph_update_pre,
|
||||||
|
bpy.app.handlers.depsgraph_update_post]:
|
||||||
|
for h in handler_list[:]:
|
||||||
|
if "puresky" in str(h).lower() or "PureSky" in str(h):
|
||||||
|
try:
|
||||||
|
handler_list.remove(h)
|
||||||
|
removed += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_fragile_sockets(obj, mod, iface):
|
||||||
|
"""Snapshot Material/Object sockets before modifying any override."""
|
||||||
|
FRAGILE_TYPES = {'NodeSocketMaterial', 'NodeSocketObject'}
|
||||||
|
snapshot = {}
|
||||||
|
for item in iface.items_tree:
|
||||||
|
if item.item_type != 'SOCKET' or item.in_out != 'INPUT':
|
||||||
|
continue
|
||||||
|
if item.socket_type not in FRAGILE_TYPES:
|
||||||
|
continue
|
||||||
|
sid = item.identifier
|
||||||
|
if sid in mod and mod[sid] is not None:
|
||||||
|
snapshot[sid] = mod[sid]
|
||||||
|
# Debug output to verify we grab the material
|
||||||
|
# print(f" [SNAPSHOT] {obj.name} -> {item.name}: {mod[sid]}")
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_fragile_sockets(mod, snapshot):
|
||||||
|
"""Restore Material/Object sockets that got wiped by override re-eval."""
|
||||||
|
restored = 0
|
||||||
|
for sid, value in snapshot.items():
|
||||||
|
current = mod[sid] if sid in mod else None
|
||||||
|
# Force restore if the current value no longer matches our snapshot
|
||||||
|
if current != value:
|
||||||
|
try:
|
||||||
|
mod[sid] = value
|
||||||
|
restored += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [RESTORE FAILED] {sid}: {e}")
|
||||||
|
return restored
|
||||||
|
|
||||||
|
|
||||||
|
def fix_lib_overrides():
|
||||||
|
# Disabled as forcing "True" or "Index" onto generic modifier properties
|
||||||
|
# destroys the library overrides and clears all material assignments.
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def fix_missing_images():
|
||||||
|
fixed = 0
|
||||||
|
for img in bpy.data.images:
|
||||||
|
if img.source == 'FILE' and img.filepath:
|
||||||
|
if getattr(img, "packed_file", None):
|
||||||
|
continue
|
||||||
|
abs_path = (bpy.path.abspath(img.filepath, library=img.library)
|
||||||
|
if img.library else bpy.path.abspath(img.filepath))
|
||||||
|
if not os.path.exists(abs_path):
|
||||||
|
img.source = 'GENERATED'
|
||||||
|
img.generated_width = 8
|
||||||
|
img.generated_height = 8
|
||||||
|
img.generated_type = 'BLANK'
|
||||||
|
img.generated_color = (1.0, 0.0, 1.0, 1.0)
|
||||||
|
fixed += 1
|
||||||
|
return fixed
|
||||||
|
|
||||||
|
|
||||||
|
def init_metal_gpu():
|
||||||
|
try:
|
||||||
|
prefs = bpy.context.preferences.addons['cycles'].preferences
|
||||||
|
prefs.compute_device_type = 'METAL'
|
||||||
|
prefs.get_devices()
|
||||||
|
for dev in prefs.devices:
|
||||||
|
dev.use = True
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_scenes():
|
||||||
|
return [s.name for s in bpy.data.scenes
|
||||||
|
if not s.name.startswith("_") and s.name != "_Shots"]
|
||||||
115
utils.py
115
utils.py
@@ -1,115 +0,0 @@
|
|||||||
import bpy
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def silence_puresky():
|
|
||||||
removed = 0
|
|
||||||
for handler_list in [bpy.app.handlers.depsgraph_update_pre,
|
|
||||||
bpy.app.handlers.depsgraph_update_post]:
|
|
||||||
for h in handler_list[:]:
|
|
||||||
if "puresky" in str(h).lower() or "PureSky" in str(h):
|
|
||||||
try:
|
|
||||||
handler_list.remove(h)
|
|
||||||
removed += 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return removed
|
|
||||||
|
|
||||||
|
|
||||||
def fix_lib_overrides():
|
|
||||||
fixed = 0
|
|
||||||
for obj in bpy.data.objects:
|
|
||||||
if not obj.modifiers:
|
|
||||||
continue
|
|
||||||
for mod in obj.modifiers:
|
|
||||||
if mod.type != 'NODES' or not mod.node_group:
|
|
||||||
continue
|
|
||||||
if not hasattr(mod.node_group, 'interface'):
|
|
||||||
continue
|
|
||||||
|
|
||||||
ng = mod.node_group
|
|
||||||
name_to_sid = {}
|
|
||||||
iface = getattr(ng, 'interface', None)
|
|
||||||
if iface and hasattr(iface, 'items_tree'):
|
|
||||||
for item in iface.items_tree:
|
|
||||||
if item.item_type == 'SOCKET' and item.in_out == 'INPUT':
|
|
||||||
name_to_sid[item.name] = (item.identifier, item.socket_type)
|
|
||||||
|
|
||||||
for item in (getattr(iface, 'items_tree', []) if iface else []):
|
|
||||||
if item.item_type != 'SOCKET' or item.in_out != 'INPUT':
|
|
||||||
continue
|
|
||||||
sid = item.identifier
|
|
||||||
if sid not in mod:
|
|
||||||
continue
|
|
||||||
stored = mod[sid]
|
|
||||||
st = item.socket_type
|
|
||||||
tt = type(stored).__name__
|
|
||||||
if st == 'NodeSocketBool' and tt == 'int':
|
|
||||||
mod[sid] = bool(stored)
|
|
||||||
fixed += 1
|
|
||||||
elif st == 'NodeSocketMenu' and tt == 'int':
|
|
||||||
mod[sid] = int(stored)
|
|
||||||
fixed += 1
|
|
||||||
|
|
||||||
ng_name = ng.name
|
|
||||||
if ng_name == "Add Shader v1.0":
|
|
||||||
if "Filter by ID" in name_to_sid:
|
|
||||||
mod[name_to_sid["Filter by ID"][0]] = True
|
|
||||||
fixed += 1
|
|
||||||
if "ID" in name_to_sid and name_to_sid["ID"][1] == "NodeSocketString":
|
|
||||||
mod[name_to_sid["ID"][0]] = "Index"
|
|
||||||
fixed += 1
|
|
||||||
elif ng_name in ["Add Shaders by Index v1.0", "Make Material", "Make Labels"]:
|
|
||||||
if "ID" in name_to_sid and name_to_sid["ID"][1] == "NodeSocketString":
|
|
||||||
mod[name_to_sid["ID"][0]] = "Index"
|
|
||||||
fixed += 1
|
|
||||||
if ng_name == "Make Labels" and "Shrinkwrap" in name_to_sid:
|
|
||||||
mod[name_to_sid["Shrinkwrap"][0]] = True
|
|
||||||
fixed += 1
|
|
||||||
elif ng_name == "Make attached":
|
|
||||||
if "Reset Children" in name_to_sid:
|
|
||||||
mod[name_to_sid["Reset Children"][0]] = True
|
|
||||||
fixed += 1
|
|
||||||
if "Separate Children" in name_to_sid:
|
|
||||||
mod[name_to_sid["Separate Children"][0]] = True
|
|
||||||
fixed += 1
|
|
||||||
elif ng_name == "Proxy v1.0":
|
|
||||||
if "Reset Children" in name_to_sid:
|
|
||||||
mod[name_to_sid["Reset Children"][0]] = True
|
|
||||||
fixed += 1
|
|
||||||
return fixed
|
|
||||||
|
|
||||||
|
|
||||||
def fix_missing_images():
|
|
||||||
fixed = 0
|
|
||||||
for img in bpy.data.images:
|
|
||||||
if img.source == 'FILE' and img.filepath:
|
|
||||||
if getattr(img, "packed_file", None):
|
|
||||||
continue
|
|
||||||
abs_path = (bpy.path.abspath(img.filepath, library=img.library)
|
|
||||||
if img.library else bpy.path.abspath(img.filepath))
|
|
||||||
if not os.path.exists(abs_path):
|
|
||||||
img.source = 'GENERATED'
|
|
||||||
img.generated_width = 8
|
|
||||||
img.generated_height = 8
|
|
||||||
img.generated_type = 'BLANK'
|
|
||||||
img.generated_color = (1.0, 0.0, 1.0, 1.0)
|
|
||||||
fixed += 1
|
|
||||||
return fixed
|
|
||||||
|
|
||||||
|
|
||||||
def init_metal_gpu():
|
|
||||||
try:
|
|
||||||
prefs = bpy.context.preferences.addons['cycles'].preferences
|
|
||||||
prefs.compute_device_type = 'METAL'
|
|
||||||
prefs.get_devices()
|
|
||||||
for dev in prefs.devices:
|
|
||||||
dev.use = True
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_scenes():
|
|
||||||
return [s.name for s in bpy.data.scenes
|
|
||||||
if not s.name.startswith("_") and s.name != "_Shots"]
|
|
||||||
Reference in New Issue
Block a user