scripts/_bpyx.py
"""_bpyx - tiny shared helpers for blender-agent-skills scripts (Blender 5.2+).
Source of truth: shared/_bpyx.py. Copies in skills/*/scripts/ are synced by
tools/sync_shared.py - never edit a copy.
Every skill script follows one contract:
run(**kwargs) -> dict # call from MCP / Python (import the module)
blender -b f.blend -P x.py -- --arg v # CLI: prints one line "@@JSON@@{...}"
"""
import argparse
import json
import sys
import bpy
MARK = "@@JSON@@"
def argv():
"""Arguments after '--' (Blender ignores them)."""
return sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
def _default(o):
if hasattr(o, "__len__") and not isinstance(o, str):
try:
return [round(v, 5) if isinstance(v, float) else v for v in o]
except TypeError:
pass
if isinstance(o, (set, frozenset)):
return sorted(o)
return str(o)
def emit(data):
"""Print compact JSON on one marked line (parsed by run_in_blender.py)."""
print(MARK + json.dumps(data, separators=(",", ":"), default=_default), flush=True)
return data
def parser(doc):
"""ArgumentParser whose help is the script docstring's first line."""
return argparse.ArgumentParser(description=(doc or "").strip().splitlines()[0])
def cli(run, p):
"""CLI entry: parse args after '--', run, emit JSON. Exit 1 on failure."""
args = p.parse_args(argv())
try:
result = run(**vars(args))
save = getattr(args, "save", None)
if save:
bpy.ops.wm.save_as_mainfile(filepath=bpy.path.abspath(save))
result["saved"] = save
emit(result)
except Exception as e: # report, don't dump a traceback into agent context
emit({"ok": False, "error": f"{type(e).__name__}: {e}"})
if bpy.app.background:
sys.exit(1)
def add_common(p, save=True):
"""--objects / --save flags shared by most scripts."""
p.add_argument("--objects", nargs="*", default=None,
help="object names (default: selected, else all meshes)")
if save:
p.add_argument("--save", default=None, help="save .blend to this path after run")
return p
def objects(names=None, types=("MESH",)):
"""Resolve names -> objects. No names: selected of `types`, else all visible of `types`."""
if names:
missing = [n for n in names if n not in bpy.data.objects]
if missing:
raise KeyError(f"objects not found: {missing}")
return [bpy.data.objects[n] for n in names]
pool = [o for o in bpy.context.scene.objects if not types or o.type in types]
sel = [o for o in pool if o.select_get()]
return sel or [o for o in pool if o.visible_get()]
def object_mode():
"""Leave edit/sculpt/pose modes; most data API work needs OBJECT mode."""
ob = bpy.context.view_layer.objects.active
if ob and ob.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
def select_only(objs, active=None):
"""Select exactly `objs` and make `active` (default first) active."""
for o in bpy.context.view_layer.objects:
o.select_set(False)
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = active or (objs[0] if objs else None)
def node(tree, type_):
"""First node of `type_` (e.g. 'BSDF_PRINCIPLED'). Names are localized - never look up by name."""
return next((n for n in tree.nodes if n.type == type_), None)
def enum_ids(owner, prop):
"""Valid identifiers of an enum property (works on types or instances)."""
return [i.identifier for i in owner.bl_rna.properties[prop].enum_items]
def set_enum(owner, prop, value):
"""Assign an enum; on failure raise listing valid values (dynamic enums under-report)."""
try:
setattr(owner, prop, value)
except TypeError as e:
raise ValueError(f"{prop}={value!r} invalid: {e}") from None
def mesh_tris(ob, evaluated=True):
"""Triangle count (evaluated = with modifiers)."""
if evaluated:
eo = ob.evaluated_get(bpy.context.evaluated_depsgraph_get())
me = eo.to_mesh()
try:
me.calc_loop_triangles()
return len(me.loop_triangles)
finally:
eo.to_mesh_clear()
me = ob.data
me.calc_loop_triangles()
return len(me.loop_triangles)
def r(v, n=4):
"""Round a float or a vector for compact JSON."""
if isinstance(v, float):
return round(v, n)
return [round(x, n) for x in v]
scripts/turntable.py
"""Look-dev scene in one call: framed camera, light rig (studio 3-point / HDRI / both), optional
cyclorama backdrop and a looping turntable (camera + lights orbit; the asset is untouched).
Usage:
blender -b f.blend -P turntable.py -- --objects Hero [--rig studio|hdri|both] [--hdri path.exr]
[--hdri-strength 1] [--frames 120] [--lens 85] [--backdrop] [--elevation 15] [--save out.blend]
MCP: import turntable; print(turntable.run(objects=["Hero"], rig="studio", frames=120))
Then render with blender-rendering/render_batch.py (e.g. --frames 1-120 --format MP4).
"""
import math
import os
import sys
import bmesh
import bpy
from mathutils import Matrix, Vector
sys.path.insert(0, os.path.dirname(__file__))
import _bpyx as bx # noqa: E402
PREFIX = "LD_"
def _bounds(obs):
bpy.context.view_layer.update()
pts = [o.matrix_world @ Vector(c) for o in obs for c in o.bound_box]
lo = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts)))
hi = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts)))
return lo, hi
def _clear():
for o in [o for o in bpy.data.objects if o.name.startswith(PREFIX)]:
data = o.data
bpy.data.objects.remove(o)
if data is not None and data.users == 0:
for coll in (bpy.data.lights, bpy.data.cameras, bpy.data.meshes):
if data.name in coll and coll[data.name] == data:
coll.remove(data)
break
def _area(name, power, size, loc, target, col, color=(1, 1, 1)):
ld = bpy.data.lights.new(PREFIX + name, "AREA")
ld.energy, ld.size, ld.color = power, size, color
ld.shape = "DISK"
lo = bpy.data.objects.new(PREFIX + name, ld)
lo.location = loc
lo.rotation_euler = (target - Vector(loc)).to_track_quat("-Z", "Y").to_euler()
col.objects.link(lo)
return lo
def _backdrop(lo, hi, col):
w = max(hi.x - lo.x, hi.y - lo.y, hi.z - lo.z) * 6
bm = bmesh.new()
a = bm.verts.new((-w / 2, -w / 2, 0))
b = bm.verts.new((w / 2, -w / 2, 0))
c = bm.verts.new((w / 2, w / 2, 0))
d = bm.verts.new((-w / 2, w / 2, 0))
e = bm.verts.new((w / 2, w / 2, w * 0.6))
f = bm.verts.new((-w / 2, w / 2, w * 0.6))
bm.faces.new((a, b, c, d))
bm.faces.new((d, c, e, f))
me = bpy.data.meshes.new(PREFIX + "Backdrop")
bm.to_mesh(me)
bm.free()
ob = bpy.data.objects.new(PREFIX + "Backdrop", me)
ob.location = (((lo.x + hi.x) / 2), ((lo.y + hi.y) / 2), lo.z)
bev = ob.modifiers.new("Curve", "BEVEL")
bev.width, bev.segments, bev.limit_method = w * 0.25, 16, "ANGLE"
for p in me.polygons:
p.use_smooth = True
mat = bpy.data.materials.new(PREFIX + "Backdrop")
bsdf = bx.node(mat.node_tree, "BSDF_PRINCIPLED")
bsdf.inputs["Base Color"].default_value = (0.18, 0.18, 0.18, 1) # 18% grey
bsdf.inputs["Roughness"].default_value = 0.8
me.materials.append(mat)
col.objects.link(ob)
return ob
def run(objects=None, rig="studio", hdri=None, hdri_strength=1.0, frames=120, lens=85.0,
backdrop=False, elevation=15.0, **_):
bx.object_mode()
obs = bx.objects(objects, types=("MESH", "CURVE", "FONT", "CURVES", "ARMATURE"))
_clear()
sc = bpy.context.scene
col = bpy.data.collections.get(PREFIX + "Lookdev") or bpy.data.collections.new(PREFIX + "Lookdev")
if col.name not in sc.collection.children:
sc.collection.children.link(col)
lo, hi = _bounds(obs)
center = (lo + hi) / 2
radius = max((hi - lo).length / 2, 1e-3)
pivot = bpy.data.objects.new(PREFIX + "Pivot", None)
pivot.location = center
col.objects.link(pivot)
cam_d = bpy.data.cameras.new(PREFIX + "Cam")
cam_d.lens = lens
fov = 2 * math.atan(cam_d.sensor_width / (2 * lens)) # horizontal (sensor_fit AUTO, landscape)
aspect = sc.render.resolution_x / sc.render.resolution_y
fov = min(fov, 2 * math.atan(math.tan(fov / 2) / aspect)) if aspect >= 1 else fov * aspect
dist = radius / math.sin(fov / 2) * 1.1 # fits the bounding sphere on the narrower axis
cam_d.clip_start, cam_d.clip_end = dist / 100, dist * 20
cam = bpy.data.objects.new(PREFIX + "Cam", cam_d)
el = math.radians(elevation)
cam.location = center + Vector((0, -math.cos(el), math.sin(el))) * dist
cam.rotation_euler = (center - cam.location).to_track_quat("-Z", "Y").to_euler()
col.objects.link(cam)
sc.camera = cam
rigged = [cam]
nlights = 0
if rig in ("studio", "both"):
d = dist * 0.8
key_power = 60 * d * d
for name, az, elv, k, size in (("Key", -45, 35, 1.0, 1.0), ("Fill", 50, 15, 0.35, 1.5),
("Rim", 160, 40, 0.8, 0.6)):
a, e = math.radians(az), math.radians(elv)
loc = center + Vector((math.sin(a) * math.cos(e), -math.cos(a) * math.cos(e), math.sin(e))) * d
rigged.append(_area(name, key_power * k, radius * size, loc, center, col))
nlights += 1
world = sc.world or bpy.data.worlds.new("World")
sc.world = world
wt = world.node_tree
bg = bx.node(wt, "BACKGROUND")
out_n = bx.node(wt, "OUTPUT_WORLD")
if rig in ("hdri", "both") and hdri:
env = next((n for n in wt.nodes if n.type == "TEX_ENVIRONMENT"), None) or wt.nodes.new("ShaderNodeTexEnvironment")
env.image = bpy.data.images.load(bpy.path.abspath(hdri), check_existing=True)
wt.links.new(env.outputs["Color"], bg.inputs["Color"])
bg.inputs["Strength"].default_value = hdri_strength
nlights += 1
else:
for l in list(bg.inputs["Color"].links):
wt.links.remove(l)
bg.inputs["Color"].default_value = (0.05, 0.05, 0.05, 1)
bg.inputs["Strength"].default_value = 1.0 if rig == "hdri" else 0.5
if out_n and not out_n.inputs["Surface"].is_linked:
wt.links.new(bg.outputs["Background"], out_n.inputs["Surface"])
if backdrop:
rigged.append(_backdrop(lo, hi, col))
bpy.context.view_layer.update() # pivot matrix must be evaluated before parenting
for o in rigged: # camera, lights and backdrop orbit together; the asset stays untouched
o.parent = pivot
o.matrix_parent_inverse = pivot.matrix_world.inverted()
if frames and frames > 1:
sc.frame_start, sc.frame_end = 1, frames
pivot.rotation_euler = (0, 0, 0)
pivot.keyframe_insert("rotation_euler", index=2, frame=1)
pivot.rotation_euler = (0, 0, 2 * math.pi) # full turn lands on frame N+1
pivot.keyframe_insert("rotation_euler", index=2, frame=frames + 1)
for fc in _fcurves(pivot):
for kp in fc.keyframe_points:
kp.interpolation = "LINEAR"
return {"ok": True, "camera": cam.name, "lens": lens, "distance": round(dist, 3), "lights": nlights,
"frames": frames, "pivot": pivot.name, "collection": col.name,
"note": "frame N+1 == frame 1, so 1..N loops seamlessly"}
def _fcurves(ob):
from bpy_extras import anim_utils
ad = ob.animation_data
cb = anim_utils.action_get_channelbag_for_slot(ad.action, ad.action_slot)
return list(cb.fcurves) if cb else []
if __name__ == "__main__":
p = bx.add_common(bx.parser(__doc__))
p.add_argument("--rig", default="studio", choices=["studio", "hdri", "both"])
p.add_argument("--hdri", default=None)
p.add_argument("--hdri-strength", type=float, default=1.0)
p.add_argument("--frames", type=int, default=120)
p.add_argument("--lens", type=float, default=85.0)
p.add_argument("--backdrop", action="store_true")
p.add_argument("--elevation", type=float, default=15.0)
bx.cli(run, p)
SKILL.md
---
name: blender-lighting-lookdev
description: Lighting, cameras and look-dev in Blender 5.2 - studio 3-point and product lighting, HDRI environments, sun/sky exteriors, interiors, light linking, physically based light units, camera focal lengths/composition/DOF, cyclorama backdrops, looping turntables for asset presentation. Use when presenting assets, setting up scenes for rendering, or judging materials under consistent light.
license: MIT
metadata:
author: blender-agent-skills
version: "1.2.0"
---
# Lighting & Look-dev
The execution model is in `blender-core`. Render settings are in `blender-rendering`. Full time-of-day, night and fog environments are in `blender-time-of-day`.
## One call: presentation scene
```
scripts/turntable.py -- --objects Hero --rig studio --backdrop --frames 120 --lens 85
scripts/turntable.py -- --objects Hero --rig both --hdri //hdri/studio_small_09_2k.exr --hdri-strength 0.8
```
This builds a camera framed to the bounding sphere (on the narrower frame axis), a key/fill/rim area-light rig scaled to the asset, an optional 18%-grey cyclorama, and a pivot that orbits camera + lights + backdrop in a seamless loop. **The asset itself isn't modified.** Everything lives in the `LD_Lookdev` collection, and re-running replaces it. Set the render resolution **before** running, because framing depends on the aspect ratio.
## Lighting recipes
| Situation | Setup |
|---|---|
| Asset / product review | 3-point: key 45° side, 30–40° up; fill opposite at ⅓ power and lower; rim behind for silhouette. Large soft area lights (size ≈ object), neutral grey backdrop, Khronos PBR Neutral view |
| Reflective product (metal, glass, car paint) | large softboxes/strip lights *reflected* in the surface, plus a black/white gradient environment. Shape the reflections, not the illumination |
| Exterior day | Sun light (angle 0.5° for sharp, 2–5° for softer) + sky (`ShaderNodeTexSky`, `sky_type` `MULTIPLE_SCATTERING`/`SINGLE_SCATTERING`; 5.x replaced "Nishita"; `PREETHAM`/`HOSEK_WILKIE` legacy) with `sun_elevation`/`sun_rotation` matching the Sun light, or an HDRI of the matching time of day |
| Interior | HDRI/sky through the windows + portal area lights in the openings, bounces ≥ 4, then practical lights (lamps) at real wattage |
| Character / portrait | soft key from above-front, a reflector-like fill, rim or hair light, an eye catchlight. 85–135 mm lens |
| Stylized / game screenshot | match the engine: one directional + ambient/HDRI, no fancy GI, so it looks like it will in-game |
- **HDRI**: Poly Haven (CC0) via the blender-mcp `search_assets`/`import_asset`, or `ShaderNodeTexEnvironment` + Mapping (Z rotation aligns the sun). Strength 0.5–1.5. Clamp sun hotspots if they cause fireflies.
- **Light linking** (4.0+, Cycles + EEVEE): set `light_obj.light_linking.receiver_collection` / `blocker_collection` (on the light **object**) to light only what you choose, like a hero rim that doesn't touch the backdrop. Portals: `light.cycles.is_portal = True` on area lights in window openings.
- **Units**: point/spot/area in Watts (a 60 W bulb ≈ point light 40–60 W in Blender), sun in W/m² (irradiance; 1000 ≈ physical midday, but use 3–10 with AgX for natural results), emission strength ≈ W/m². Keep lights physically plausible and control the overall image with exposure.
- **Shadows**: light size controls softness. A bigger, closer light is softer. Contact shadows should exist where objects touch, so check that nothing floats (`blender-asset-qa` `ground_z`).
## Camera rules
- Focal length: 24–35 mm environments/interiors (keep verticals straight: camera level, or shift instead of tilting), 50 mm natural, **85–135 mm products and portraits** (no distortion), 200 mm+ compressed telephoto.
- Composition: rule of thirds / centered symmetric for products, horizon off-center, leading lines, headroom. Show the silhouette (3/4 view at slight elevation 10–25°).
- DOF (`cam.data.dof.use_dof`, `focus_object`, `aperture_fstop`): f/2.8–5.6 for products at real scale. DOF depends on scale, so a wrong scene scale ruins it.
- Clip start/end tight around the subject (the script does this), which avoids precision artifacts.
- Sensor fit AUTO uses the larger axis. Portrait renders need framing on the width.
## Look-dev procedure (material approval)
1. Neutral setup: `--rig studio` or a neutral HDRI, grey backdrop, Khronos PBR Neutral or AgX (pick one and keep it).
2. Add a calibration chart object if needed (a grey 18% sphere, a chrome sphere, a Macbeth chart) next to the asset.
3. Turntable at 24–30 fps, 120–240 frames, 85 mm. Render previews with `render_batch.py --preset preview --format MP4`.
4. Check the materials under 2–3 different HDRIs. A good PBR material holds up in all of them (`blender-pbr-materials` sanity checks).
## Gotchas
- World lighting is invisible if `world.node_tree` has no Background → World Output link (the script ensures one).
- EEVEE needs `use_raytracing` for good reflections. Light probes (Volume/Sphere/Plane) still help static GI in interiors.
- Area lights that are too small and bright make fireflies in Cycles. Increase the size, decrease the power.
- A turntable that rotates the **asset** changes its transforms and animation data, which is why this rig orbits the camera instead.