feat: initial commit for scene renderer addon
This commit is contained in:
37
__init__.py
Normal file
37
__init__.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
bl_info = {
|
||||||
|
"name": "Scene Renderer",
|
||||||
|
"author": "Marc Mintel",
|
||||||
|
"version": (1, 0, 0),
|
||||||
|
"blender": (4, 2, 0),
|
||||||
|
"location": "View3D > Sidebar > Scene Renderer",
|
||||||
|
"description": "Render all scenes to //renders/ relative to the blend file",
|
||||||
|
"category": "Render",
|
||||||
|
}
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
from . import properties, operators, panel
|
||||||
|
|
||||||
|
|
||||||
|
classes = (
|
||||||
|
properties.SceneRendererSettings,
|
||||||
|
operators.SCENERENDERER_OT_render_all,
|
||||||
|
panel.SCENERENDERER_PT_panel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
for cls in classes:
|
||||||
|
bpy.utils.register_class(cls)
|
||||||
|
bpy.types.Scene.scene_renderer = bpy.props.PointerProperty(
|
||||||
|
type=properties.SceneRendererSettings
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
del bpy.types.Scene.scene_renderer
|
||||||
|
for cls in reversed(classes):
|
||||||
|
bpy.utils.unregister_class(cls)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
register()
|
||||||
210
operators.py
Normal file
210
operators.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
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
Normal file
30
panel.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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')
|
||||||
43
properties.py
Normal file
43
properties.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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',
|
||||||
|
)
|
||||||
115
utils.py
Normal file
115
utils.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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