File size: 8,766 Bytes
26cd2e7
 
 
 
3204f43
 
9e0f375
 
3204f43
 
9e0f375
 
26cd2e7
 
 
 
 
 
9e0f375
 
26cd2e7
 
 
3204f43
 
 
 
 
 
9e0f375
 
 
 
 
26cd2e7
 
 
 
 
9e0f375
 
 
 
 
 
 
 
 
 
 
 
7a8d450
 
 
 
 
9e0f375
 
 
 
 
 
 
 
 
 
26cd2e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3204f43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26cd2e7
 
 
 
 
 
 
 
3204f43
 
 
26cd2e7
 
 
 
 
 
 
 
 
707f074
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26cd2e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e0f375
 
 
26cd2e7
 
 
 
 
9e0f375
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
"""
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}")