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
9 changed files with 540 additions and 91 deletions

1
.gitignore vendored Normal file
View File

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

View File

@@ -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,
) )

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -3,16 +3,51 @@ import os
from . import utils from . import utils
# -- Render-callback handlers -------------------------------------------------
@bpy.app.handlers.persistent @bpy.app.handlers.persistent
def on_render_complete(scene, depsgraph=None): def on_render_complete(scene, depsgraph=None):
SCENERENDERER_OT_render_all._is_rendering = False SCENERENDERER_OT_render_all._is_rendering = False
@bpy.app.handlers.persistent @bpy.app.handlers.persistent
def on_render_cancel(scene, depsgraph=None): def on_render_cancel(scene, depsgraph=None):
SCENERENDERER_OT_render_all._is_rendering = False SCENERENDERER_OT_render_all._is_rendering = False
SCENERENDERER_OT_render_all._cancel_requested = True 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): class SCENERENDERER_OT_render_all(bpy.types.Operator):
bl_idname = "scenerenderer.render_all" bl_idname = "scenerenderer.render_all"
bl_label = "Render All Scenes" bl_label = "Render All Scenes"
@@ -24,10 +59,12 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator):
_index = 0 _index = 0
_total = 0 _total = 0
_orig_lock = None _orig_lock = None
_is_rendering = False _is_rendering = False
_cancel_requested = False _cancel_requested = False
# ---- modal loop ---------------------------------------------------------
def modal(self, context, event): def modal(self, context, event):
if event.type == 'ESC' or self._cancel_requested: if event.type == 'ESC' or self._cancel_requested:
self.report({'WARNING'}, "Render batch cancelled") self.report({'WARNING'}, "Render batch cancelled")
@@ -57,6 +94,8 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator):
return {'PASS_THROUGH'} return {'PASS_THROUGH'}
# ---- cleanup ------------------------------------------------------------
def finish(self, context): def finish(self, context):
if self._timer: if self._timer:
context.window_manager.event_timer_remove(self._timer) context.window_manager.event_timer_remove(self._timer)
@@ -71,8 +110,10 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator):
if on_render_cancel in bpy.app.handlers.render_cancel: if on_render_cancel in bpy.app.handlers.render_cancel:
bpy.app.handlers.render_cancel.remove(on_render_cancel) bpy.app.handlers.render_cancel.remove(on_render_cancel)
# ---- per-scene render ---------------------------------------------------
def _render_scene(self, context, scene_name): def _render_scene(self, context, scene_name):
settings = context.scene.scene_renderer settings = SCENERENDERER_OT_render_all._cached_settings
scene = bpy.data.scenes.get(scene_name) scene = bpy.data.scenes.get(scene_name)
if not scene: if not scene:
print(f" [SKIP] Scene not found: {scene_name}") print(f" [SKIP] Scene not found: {scene_name}")
@@ -93,43 +134,153 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator):
SCENERENDERER_OT_render_all._is_rendering = False SCENERENDERER_OT_render_all._is_rendering = False
return 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 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 = scene.cycles
c.samples = 1 if settings.test_mode else settings.samples
if settings.exposure != 0.0: # Engine & basics
scene.view_settings.exposure = settings.exposure r.engine = 'CYCLES'
scene.cycles.film_exposure = 2.0 ** settings.exposure r.film_transparent = settings["film_transparent"]
if settings.look != 'None':
scene.view_settings.look = settings.look
if settings.test_mode: # 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 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") render_dir = bpy.path.abspath("//renders")
if settings.test_mode: if settings["test_mode"]:
render_dir = os.path.join(render_dir, "test") render_dir = os.path.join(render_dir, "test")
os.makedirs(render_dir, exist_ok=True) os.makedirs(render_dir, exist_ok=True)
safe_name = scene.name.replace("/", "_").replace("\\", "_").replace(":", "_").strip() safe_name = (
scene.name
.replace("/", "_")
.replace("\\", "_")
.replace(":", "_")
.strip()
)
output_path = os.path.join(render_dir, safe_name) output_path = os.path.join(render_dir, safe_name)
if settings.skip_existing and os.path.exists(output_path + ".png"): fmt = settings["output_format"]
print(f" [SKIP] Already exists: {safe_name}.png") 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 SCENERENDERER_OT_render_all._is_rendering = False
return return
r.filepath = output_path scene.render.filepath = output_path
print(f"\n [{self._index}/{self._total}] Rendering: {scene.name}") # ---- confirmation dialog ------------------------------------------------
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): def invoke(self, context, event):
scenes = utils.get_scenes() scenes = utils.get_scenes()
@@ -139,9 +290,12 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator):
layout = self.layout layout = self.layout
scenes = utils.get_scenes() scenes = utils.get_scenes()
layout.label(text=f"Ready to render {len(scenes)} scenes:", icon='RENDER_STILL') layout.label(
text=f"Ready to render {len(scenes)} scenes:",
icon='RENDER_STILL',
)
box = layout.box() box = layout.box()
col = box.column(flow=True) col = box.column()
for s in scenes[:8]: for s in scenes[:8]:
col.label(text=f" * {s}", icon='SCENE_DATA') col.label(text=f" * {s}", icon='SCENE_DATA')
if len(scenes) > 8: if len(scenes) > 8:
@@ -149,9 +303,54 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator):
layout.separator() layout.separator()
layout.label(text="Click OK to start.", icon='INFO') layout.label(text="Click OK to start.", icon='INFO')
# ---- execute (entry point) ----------------------------------------------
def execute(self, context): def execute(self, context):
settings = context.scene.scene_renderer 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: if on_render_complete not in bpy.app.handlers.render_complete:
bpy.app.handlers.render_complete.append(on_render_complete) bpy.app.handlers.render_complete.append(on_render_complete)
if on_render_cancel not in bpy.app.handlers.render_cancel: if on_render_cancel not in bpy.app.handlers.render_cancel:
@@ -199,12 +398,16 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator):
return {'CANCELLED'} return {'CANCELLED'}
print(f"\n{'='*50}") print(f"\n{'='*50}")
print(f" Scene Renderer -- {self._total} scenes" print(
f"{' (TEST MODE)' if settings.test_mode else ''}") f" Scene Renderer -- {self._total} scenes"
f"{' (TEST MODE)' if settings.test_mode else ''}"
)
print(f"{'='*50}") print(f"{'='*50}")
context.window_manager.modal_handler_add(self) context.window_manager.modal_handler_add(self)
self._timer = context.window_manager.event_timer_add(0.5, window=context.window) self._timer = context.window_manager.event_timer_add(
0.5, window=context.window
)
context.window.cursor_set('WAIT') context.window.cursor_set('WAIT')
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}

View File

@@ -12,19 +12,106 @@ class SCENERENDERER_PT_panel(bpy.types.Panel):
layout = self.layout layout = self.layout
settings = context.scene.scene_renderer settings = context.scene.scene_renderer
# --- General toggles ---
row = layout.row(align=True) row = layout.row(align=True)
row.prop(settings, "test_mode", toggle=True) row.prop(settings, "test_mode", toggle=True)
row.prop(settings, "skip_existing", toggle=True) row.prop(settings, "skip_existing", toggle=True)
layout.prop(settings, "use_cpu") layout.prop(settings, "use_cpu")
layout.prop(settings, "samples")
# --- 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 = layout.box()
box.label(text="Color", icon='SHADING_RENDERED') box.label(text="Color", icon='SHADING_RENDERED')
box.prop(settings, "exposure", slider=True) box.prop(settings, "exposure", slider=True)
box.prop(settings, "look") box.prop(settings, "look")
# --- Render button ---
layout.separator() layout.separator()
row = layout.row(align=True) row = layout.row(align=True)
row.scale_y = 2.0 row.scale_y = 2.0
row.operator("scenerenderer.render_all", text="Render All Scenes", icon='RENDER_STILL')
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

@@ -2,6 +2,9 @@ import bpy
class SceneRendererSettings(bpy.types.PropertyGroup): class SceneRendererSettings(bpy.types.PropertyGroup):
# --- General ---
test_mode: bpy.props.BoolProperty( test_mode: bpy.props.BoolProperty(
name="Test Mode", name="Test Mode",
description="Render only the first scene at 10% resolution with 1 sample", description="Render only the first scene at 10% resolution with 1 sample",
@@ -17,11 +20,190 @@ class SceneRendererSettings(bpy.types.PropertyGroup):
description="Force CPU rendering instead of GPU", description="Force CPU rendering instead of GPU",
default=False, 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( samples: bpy.props.IntProperty(
name="Samples", name="Max Samples",
description="Cycles render samples", description="Maximum number of Cycles render samples",
default=4096, min=1, max=65536, 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( exposure: bpy.props.FloatProperty(
name="Exposure", name="Exposure",
description="Exposure boost in stops", description="Exposure boost in stops",

View File

@@ -16,68 +16,43 @@ def silence_puresky():
return removed return removed
def fix_lib_overrides(): def _snapshot_fragile_sockets(obj, mod, iface):
fixed = 0 """Snapshot Material/Object sockets before modifying any override."""
for obj in bpy.data.objects: FRAGILE_TYPES = {'NodeSocketMaterial', 'NodeSocketObject'}
if not obj.modifiers: snapshot = {}
for item in iface.items_tree:
if item.item_type != 'SOCKET' or item.in_out != 'INPUT':
continue continue
for mod in obj.modifiers: if item.socket_type not in FRAGILE_TYPES:
if mod.type != 'NODES' or not mod.node_group: continue
continue sid = item.identifier
if not hasattr(mod.node_group, 'interface'): if sid in mod and mod[sid] is not None:
continue snapshot[sid] = mod[sid]
# Debug output to verify we grab the material
# print(f" [SNAPSHOT] {obj.name} -> {item.name}: {mod[sid]}")
return snapshot
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 []): def _restore_fragile_sockets(mod, snapshot):
if item.item_type != 'SOCKET' or item.in_out != 'INPUT': """Restore Material/Object sockets that got wiped by override re-eval."""
continue restored = 0
sid = item.identifier for sid, value in snapshot.items():
if sid not in mod: current = mod[sid] if sid in mod else None
continue # Force restore if the current value no longer matches our snapshot
stored = mod[sid] if current != value:
st = item.socket_type try:
tt = type(stored).__name__ mod[sid] = value
if st == 'NodeSocketBool' and tt == 'int': restored += 1
mod[sid] = bool(stored) except Exception as e:
fixed += 1 print(f" [RESTORE FAILED] {sid}: {e}")
elif st == 'NodeSocketMenu' and tt == 'int': return restored
mod[sid] = int(stored)
fixed += 1
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
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(): def fix_missing_images():