Headless Blender rendering
Verified on Blender 5.1.0 (Linux, EEVEE, Radeon 780M iGPU via EGL). Version matters more than usual here: three of the APIs below changed between 4.3 and 5.1, and each one fails silently rather than raising.
Invocation
blender -b -noaudio --python render.py -- scene.json out/prefix_
Everything after -- is yours: argv = sys.argv[sys.argv.index("--") + 1:].
Keep a hard seam: the Python script draws what it is given and computes nothing. If the renderer ever calculates the thing being visualised, whatever test suite validates your simulation no longer covers what reaches the screen.
The API breaks that fail silently
1. Actions are slotted (4.4+)
action.fcurves is gone. The path is now layers -> strips -> channelbags ->
fcurves. Support both so you are not pinned to one version:
def iter_fcurves(obj):
ad = obj.animation_data
if not ad or not ad.action:
return
act = ad.action
legacy = getattr(act, "fcurves", None)
if legacy is not None:
yield from legacy
return
for layer in getattr(act, "layers", []):
for strip in layer.strips:
for bag in getattr(strip, "channelbags", []):
yield from bag.fcurves
2. The compositor is a node group hung off the scene
scene.node_tree no longer exists. It is scene.compositing_node_group, a
CompositorNodeTree in bpy.data.node_groups.
The trap: the obvious wiring — a NodeGroupInput feeding the tree — builds
without error and renders pure black. The render result does not arrive
through the group input. You still need a CompositorNodeRLayers inside the
group:
ng = bpy.data.node_groups.new("comp", "CompositorNodeTree")
ng.interface.new_socket("Image", in_out="OUTPUT", socket_type="NodeSocketColor")
gout = ng.nodes.new("NodeGroupOutput")
rl = ng.nodes.new("CompositorNodeRLayers") # <- required
glare = ng.nodes.new("CompositorNodeGlare")
ng.links.new(rl.outputs["Image"], glare.inputs["Image"])
ng.links.new(glare.outputs["Image"], gout.inputs[0])
scene.compositing_node_group = ng
Wrap the whole block in try/except and print on failure. A post-process
effect is never worth a failed render.
3. Glare settings are input sockets, and menus take labels
glare.glare_type = 'BLOOM' raises KeyError — there is no such property any
more. Settings are input sockets, and the menu sockets take the
human-readable label, not the old enum identifier:
glare.inputs["Type"].default_value = "Bloom" # NOT "BLOOM"
glare.inputs["Quality"].default_value = "Medium" # NOT "MEDIUM"
glare.inputs["Threshold"].default_value = 0.9
glare.inputs["Strength"].default_value = 0.45
glare.inputs["Size"].default_value = 7.0
Assigning the wrong string is a no-op that leaves the default glare type in
place. Nothing is raised. Set each inside its own try/except.
Camera orientation
A Blender camera looks down its local −Z. With euler (rx, 0, rz) that axis
maps to (−sin rz · sin rx, cos rz · sin rx, −cos rx), so:
def look_at(loc, target):
dx, dy, dz = (target[i] - loc[i] for i in range(3))
return (math.atan2(math.hypot(dx, dy), -dz), 0.0, math.atan2(-dx, dy))
Getting rz sign-flipped points the camera exactly away from the subject and
renders black, which is easy to misdiagnose as a lighting or compositor
problem. Verify by computing the forward vector independently and checking it
points at the target (error should be ~1e-16).
Field of view for portrait output
Blender fits the 36 mm sensor to the larger resolution axis. For a 1080x1920 frame that is the height, so the horizontal field is the narrow one:
long_side = max(w, h)
half_w = atan(36 * (w / long_side) / 2 / lens)
half_h = atan(36 * (h / long_side) / 2 / lens)
radius = margin * max(half_width / tan(half_w), half_height / tan(half_h))
Assuming the sensor maps to width is why subjects come out neatly framed vertically and sliced off at both sides.
Cost model: objects, not polygons
Scene-build time is dominated by object count, because each object carries its own material, action and visibility keyframes. Measured on one scene of 7,560 curve objects:
| Change | Build + render, 3 frames |
|---|---|
| baseline | 367 s |
| cache materials by spec | 292 s |
| one object per colour band instead of per line | 35 s |
Two fixes, in order of payoff:
Group splines. One curve object can hold any number of splines. Bucket your lines by material and emit one object per bucket:
cu = bpy.data.curves.new("c", "CURVE")
for pts in lines: # many splines, one object
sp = cu.splines.new("POLY")
sp.points.add(len(pts) - 1)
...
Cache materials. Colours off a continuous ramp give every object its own material. Round to 3 decimals and key a dict on the spec — visually identical, and thousands of materials collapse to dozens.
Also decimate curves: a fixed-step integrator emits far more points than a smooth-looking tube needs. ~200 control points is plenty.
Render settings that matter
S.render.engine = "BLENDER_EEVEE"
S.eevee.use_raytracing = False # ~3x faster; 0.24% pixel diff on emissive geometry
cu.bevel_resolution = 2 # do NOT drop this - facets are visible
S.view_settings.view_transform = "AgX" # rolls emissive cores to white, not clipped primaries
use_raytracing = False is the single biggest speed win for glowing/emissive
work and is essentially free visually. Bevel resolution is not.
Visibility windows
Show an object only within [f0, f1] by keyframing hide_render and
hide_viewport, then forcing constant interpolation so it snaps rather
than fading:
for fc in iter_fcurves(obj):
if fc.data_path in ("hide_render", "hide_viewport"):
for kp in fc.keyframe_points:
kp.interpolation = "CONSTANT"
Parallelism
Blender leaves most of a machine idle. Running several episodes as separate processes beat one process substantially (16-core Ryzen 7 8845HS, 780M iGPU):
| workers | throughput |
|---|---|
| 1 | 1.05 fps |
| 2 | 1.57 fps |
| 3 | 1.86 fps |
| 4 | 2.11 fps |
Build scenes serially in the parent process, then hand the render out to a thread pool of subprocesses. Watch memory: a heavy scene JSON times four adds up.
Never trust a zero exit code
A render can write every frame and still be black — bad camera, misconfigured compositor, dropped lights, or a diverged solver producing coordinates of 1e245 that Blender builds a scene from without complaint.
Two checks, both cheap:
- Clear the frame directory before rendering. Otherwise a crashed run "succeeds" by counting the previous run's PNGs, and requires an exact frame count afterwards.
- Measure luma on the first, middle and last frame:
ffmpeg -i f.png -vf signalstats,metadata=print:file=- -f null -and fail the episode ifYMAXis low orYAVGis ~0.