7 Commits

Author SHA1 Message Date
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
8 changed files with 130 additions and 72 deletions

View File

@@ -15,6 +15,7 @@ from . import properties, operators, panel
classes = (
properties.SceneRendererSettings,
operators.SCENERENDERER_OT_render_all,
operators.SCENERENDERER_OT_cancel_batch,
panel.SCENERENDERER_PT_panel,
)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -12,6 +12,16 @@ 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"
@@ -72,7 +82,7 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator):
bpy.app.handlers.render_cancel.remove(on_render_cancel)
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)
if not scene:
print(f" [SKIP] Scene not found: {scene_name}")
@@ -100,26 +110,43 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator):
r.film_transparent = True
c = scene.cycles
c.samples = 1 if settings.test_mode else settings.samples
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':
scene.view_settings.look = settings.look
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:
if settings["test_mode"]:
r.resolution_percentage = 10
render_dir = bpy.path.abspath("//renders")
if settings.test_mode:
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"):
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
@@ -141,7 +168,7 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator):
layout.label(text=f"Ready to render {len(scenes)} scenes:", icon='RENDER_STILL')
box = layout.box()
col = box.column(flow=True)
col = box.column()
for s in scenes[:8]:
col.label(text=f" * {s}", icon='SCENE_DATA')
if len(scenes) > 8:
@@ -152,6 +179,21 @@ class SCENERENDERER_OT_render_all(bpy.types.Operator):
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:

View File

@@ -17,7 +17,18 @@ class SCENERENDERER_PT_panel(bpy.types.Panel):
row.prop(settings, "skip_existing", toggle=True)
layout.prop(settings, "use_cpu")
layout.prop(settings, "samples")
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')
@@ -27,4 +38,13 @@ class SCENERENDERER_PT_panel(bpy.types.Panel):
layout.separator()
row = layout.row(align=True)
row.scale_y = 2.0
row.operator("scenerenderer.render_all", text="Render All Scenes", icon='RENDER_STILL')
# 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

@@ -22,6 +22,26 @@ class SceneRendererSettings(bpy.types.PropertyGroup):
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",

View File

@@ -16,68 +16,43 @@ def silence_puresky():
return removed
def fix_lib_overrides():
fixed = 0
for obj in bpy.data.objects:
if not obj.modifiers:
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
for mod in obj.modifiers:
if mod.type != 'NODES' or not mod.node_group:
continue
if not hasattr(mod.node_group, 'interface'):
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
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
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
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():