# Blender Headless Render

> Render animations from Blender headlessly via a Python scene description - EEVEE setup, the Blender 4.4/5.x API breaks (slotted actions, compositor node groups, glare sockets), camera orientation maths, and the object-count cost model that decides whether a scene builds in 30 seconds or 6 minutes.

- Skill: `bizpers11991-code/blender-headless-render` (Agent Skill)
- Install (CLI): `npx skillmds@latest add bizpers11991-code/blender-headless-render`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bizpers11991-code/blender-headless-render/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: bizpers11991-code (https://skillmd.com/u/bizpers11991-code)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/bizpers11991-code/blender-headless-render

---


# 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

```sh
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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

```python
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:

```python
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 if `YMAX` is low or `YAVG` is ~0.

