""" Render script executed inside a fresh ``blender -b scene.blend --python render_script.py`` subprocess. Not imported by the app. Usage (args after ``--``): out_path engine samples res_x res_y [cam_mode azimuth elevation zoom focal] [mode fps frame_start frame_end] engine: CYCLES_GPU | CYCLES_CPU | EEVEE | WORKBENCH cam_mode: "scene" (use/repair the scene camera) | "orbit" (place a camera on an orbit around the scene bounds at the given azimuth/elevation, zoom factor and focal length) mode: "still" (default) | "anim" (H.264 MP4 via Blender's FFmpeg encoder; fps / frame_start / frame_end of 0 mean "use the scene's own settings") """ import sys import bpy # type: ignore # noqa: F401 - only importable inside Blender MAX_ANIM_FRAMES = 600 argv = sys.argv[sys.argv.index("--") + 1:] out_path, engine = argv[0], argv[1] samples, res_x, res_y = int(argv[2]), int(argv[3]), int(argv[4]) cam_mode = argv[5] if len(argv) > 5 else "scene" if len(argv) > 9: cam_azimuth, cam_elevation = float(argv[6]), float(argv[7]) cam_zoom, cam_focal = float(argv[8]), float(argv[9]) else: cam_azimuth, cam_elevation, cam_zoom, cam_focal = 315.0, 30.0, 1.0, 50.0 if len(argv) > 13: render_mode = argv[10] arg_fps, arg_f_start, arg_f_end = int(argv[11]), int(argv[12]), int(argv[13]) else: render_mode, arg_fps, arg_f_start, arg_f_end = "still", 0, 0, 0 scene = bpy.context.scene scene.render.resolution_x = res_x scene.render.resolution_y = res_y scene.render.resolution_percentage = 100 if render_mode == "anim": if arg_fps > 0: scene.render.fps = arg_fps if arg_f_start > 0: scene.frame_start = arg_f_start if arg_f_end > 0: scene.frame_end = arg_f_end if scene.frame_end <= scene.frame_start: scene.frame_end = scene.frame_start + max(scene.render.fps * 2, 24) if scene.frame_end - scene.frame_start + 1 > MAX_ANIM_FRAMES: scene.frame_end = scene.frame_start + MAX_ANIM_FRAMES - 1 # Blender 5.x selects video output via media_type; older builds via file_format. if hasattr(scene.render.image_settings, "media_type"): scene.render.image_settings.media_type = "VIDEO" else: scene.render.image_settings.file_format = "FFMPEG" scene.render.ffmpeg.format = "MPEG4" scene.render.ffmpeg.codec = "H264" scene.render.ffmpeg.constant_rate_factor = "MEDIUM" scene.render.ffmpeg.audio_codec = "NONE" # Blender appends its own frame-range suffix; render to a prefix and rename after. _out_base = out_path[:-4] if out_path.lower().endswith(".mp4") else out_path scene.render.filepath = _out_base else: scene.render.image_settings.file_format = "PNG" scene.render.filepath = out_path def _scene_bounds(): import mathutils points = [] for obj in scene.objects: if obj.type in {"MESH", "CURVE", "FONT", "SURFACE", "META"} and obj.visible_get(): for corner in obj.bound_box: points.append(obj.matrix_world @ mathutils.Vector(corner)) if not points: return mathutils.Vector((0, 0, 0)), 2.0 lo = mathutils.Vector((min(p[i] for p in points) for i in range(3))) hi = mathutils.Vector((max(p[i] for p in points) for i in range(3))) center = (lo + hi) / 2 size = max((hi - lo).length, 0.5) return center, size def _setup_orbit_camera(center, size): """Place a dedicated camera on an orbit around the scene bounds, aimed at center.""" import math import mathutils az = math.radians(cam_azimuth) el = math.radians(max(-89.0, min(89.0, cam_elevation))) radius = (size * 1.6) / max(cam_zoom, 0.2) + 0.4 location = mathutils.Vector(( center.x + radius * math.cos(el) * math.cos(az), center.y + radius * math.cos(el) * math.sin(az), center.z + radius * math.sin(el), )) cam = bpy.data.objects.get("OrbitCamera") if cam is None or cam.type != "CAMERA": cam_data = bpy.data.cameras.new("OrbitCamera") cam = bpy.data.objects.new("OrbitCamera", cam_data) scene.collection.objects.link(cam) cam.location = location cam.data.lens = max(10.0, min(250.0, cam_focal)) cam.data.clip_end = max(100.0, size * 10) cam.rotation_euler = (center - location).to_track_quat("-Z", "Y").to_euler() scene.camera = cam def _ensure_camera_and_light(): """Guarantee a renderable scene: camera framing the content, a light, a world.""" import math import mathutils center, size = _scene_bounds() if cam_mode == "orbit": _setup_orbit_camera(center, size) elif scene.camera is None: cam_data = bpy.data.cameras.new("AutoCamera") cam = bpy.data.objects.new("AutoCamera", cam_data) scene.collection.objects.link(cam) direction = mathutils.Vector((1.0, -1.0, 0.65)).normalized() cam.location = center + direction * (size * 1.8 + 0.5) look = center - cam.location cam.rotation_euler = look.to_track_quat("-Z", "Y").to_euler() cam_data.clip_end = max(100.0, size * 10) scene.camera = cam else: # An agent-placed camera sometimes points nowhere near the content, which # yields a black frame. If the scene center is behind the camera or far off # the view axis, re-aim (keep the location) and back off if too close. cam = scene.camera view_dir = (cam.matrix_world.to_quaternion() @ mathutils.Vector((0, 0, -1))).normalized() to_center = center - cam.matrix_world.translation if to_center.length < size * 0.5: cam.location = center - view_dir * (size * 1.8 + 0.5) to_center = center - cam.matrix_world.translation if to_center.length > 1e-6 and view_dir.dot(to_center.normalized()) < math.cos( math.radians(45) ): cam.rotation_euler = to_center.to_track_quat("-Z", "Y").to_euler() cam.data.clip_end = max(cam.data.clip_end, size * 10) scene.render.film_transparent = False if not any(o.type == "LIGHT" for o in scene.objects): sun_data = bpy.data.lights.new("AutoSun", type="SUN") sun_data.energy = 3.0 sun = bpy.data.objects.new("AutoSun", sun_data) scene.collection.objects.link(sun) sun.location = center + mathutils.Vector((0, 0, size * 3 + 2)) sun.rotation_euler = (math.radians(35), math.radians(10), math.radians(25)) if scene.world is None: scene.world = bpy.data.worlds.new("AutoWorld") scene.world.use_nodes = True bg = scene.world.node_tree.nodes.get("Background") if bg is not None and tuple(bg.inputs[0].default_value)[:3] == (0.0, 0.0, 0.0): bg.inputs[0].default_value = (0.05, 0.05, 0.06, 1.0) def _configure_cycles_gpu() -> str: prefs = bpy.context.preferences.addons["cycles"].preferences for device_type in ("OPTIX", "CUDA"): try: prefs.compute_device_type = device_type except TypeError: continue try: prefs.get_devices() except Exception as ex: # noqa: BLE001 print(f"get_devices({device_type}) failed: {ex}") gpus = [d for d in prefs.devices if d.type == device_type] if gpus: for d in prefs.devices: d.use = d.type in {device_type, "CPU"} scene.cycles.device = "GPU" print(f"Cycles using {device_type}: {[d.name for d in gpus]}") return device_type scene.cycles.device = "CPU" print("No GPU compute device found, falling back to CPU") return "CPU" _ensure_camera_and_light() if engine.startswith("CYCLES"): scene.render.engine = "CYCLES" scene.cycles.samples = samples scene.cycles.use_denoising = True if engine == "CYCLES_GPU": used = _configure_cycles_gpu() else: scene.cycles.device = "CPU" used = "CPU" elif engine == "WORKBENCH": scene.render.engine = "BLENDER_WORKBENCH" used = "WORKBENCH" else: scene.render.engine = "BLENDER_EEVEE_NEXT" scene.eevee.taa_render_samples = samples used = "EEVEE" if render_mode == "anim": bpy.ops.render.render(animation=True) # Locate whatever Blender actually wrote (it may add a frame-range suffix) # and move it to the exact requested path. import glob import os if not os.path.exists(out_path): candidates = sorted( glob.glob(scene.render.filepath + "*"), key=os.path.getmtime, reverse=True ) candidates = [c for c in candidates if c.lower().endswith((".mp4", ".mov", ".avi"))] if candidates: os.replace(candidates[0], out_path) else: bpy.ops.render.render(write_still=True) print(f"RENDER_DONE device={used} mode={render_mode} path={out_path}")