TA logoThe Technical Artist
Home/Blender/bpy Cookbook
blender - reference
bpy recipes
problem -> code -> gotcha

The bpy Cookbook

Workingtutorial~3 min readreviewed 2026-07-05

Recipes, not lessons: each one is a real problem, the code that solves it, and the gotcha that would otherwise eat your hour. Run them from Blender's Scripting tab; graduate them into an add-on when the team wants buttons. More one-liners live in the Python Snippet Library.

Recipe 1 - Batch-Export Every Collection To FBX

Problem: twenty props in one .blend, the engine wants twenty files, and File->Export twenty times is how souls die. Pairs with the collections-as-export-groups habit.

bpy - batch exporter
import bpy, os

OUT = bpy.path.abspath("//exports") # folder next to the .blend
os.makedirs(OUT, exist_ok=True)

for col in bpy.data.collections:
 if col.name.startswith("_") or not col.objects:
 continue # _source, _reference etc. stay home
 bpy.ops.object.select_all(action="DESELECT")
 for ob in col.objects:
 ob.select_set(True)
 bpy.ops.export_scene.fbx(
 filepath=os.path.join(OUT, col.name + ".fbx"),
 use_selection=True,
 apply_scale_options="FBX_SCALE_ALL",
 bake_space_transform=True,
 add_leaf_bones=False)
 print("exported", col.name)

Gotcha: objects in collections excluded from the view layer can't be selected - and silently export as empty files. Either keep exportables enabled, or link objects into an export view layer first. The _ prefix convention gives artists an obvious "not for export" switch.

Recipe 2 - Auto-Generate An LOD Chain

Problem: the engine wants LOD1-LOD3 and nobody budgeted artist time for making them. Decimate gets you serviceable distance-LODs for props (heroes still deserve hands).

bpy - LOD chain from selected
import bpy

RATIOS = {1: 0.5, 2: 0.25, 3: 0.1} # LOD index -> triangle ratio

for src in list(bpy.context.selected_objects):
 if src.type != "MESH": continue
 for lod, ratio in RATIOS.items():
 dup = src.copy()
 dup.data = src.data.copy()
 dup.name = f"{src.name}_LOD{lod}"
 bpy.context.scene.collection.objects.link(dup)

 mod = dup.modifiers.new("decimate", "DECIMATE")
 mod.ratio = ratio
 bpy.context.view_layer.objects.active = dup
 bpy.ops.object.modifier_apply(modifier=mod.name)
 src.name = f"{src.name}_LOD0"

Gotcha: decimate scrambles UV seams at aggressive ratios - spot-check LOD2/3 with the UV grid texture before shipping. Naming matters: Unreal auto-imports _LOD0...3 suffixed meshes as one mesh with LODs if "Import LODs" is on. That filename convention is the pipeline.

Recipe 3 - The Scene Audit Report

Problem: "is this file export-ready?" shouldn't require opening every object. One script, one verdict - the scripted version of the pre-flight checklist.

bpy - audit before export
import bpy

problems = []
for ob in bpy.data.objects:
 if ob.type != "MESH": continue
 tris = sum(len(p.vertices) - 2 for p in ob.data.polygons)
 if not ob.data.uv_layers: problems.append((ob.name, "no UVs"))
 if tris > 10000: problems.append((ob.name, f"{tris} tris"))
 if any(abs(s - 1.0) > 1e-4 for s in ob.scale):
 problems.append((ob.name, f"unapplied scale {tuple(ob.scale)}"))
 if not ob.data.materials: problems.append((ob.name, "no material"))
 if "." in ob.name: problems.append((ob.name, "dupe-suffix name (.001)"))

if problems:
 print(f"- {len(problems)} problem(s) -")
 for name, what in problems: print(f" {name}: {what}")
else:
 print("clean. export away.")

Gotcha: the .001 check earns its place - duplicate-suffixed names become duplicate-suffixed assets in-engine, and six months later someone is diffing SM_Crate.001 against SM_Crate at 11pm. Catch it at the door.

Recipe 4 - Custom Properties -> Engine Metadata

Problem: the engine importer needs to know things per-asset (LOD group, collision type) and filenames can only carry so much.

bpy - author metadata
import bpy

META = {"lod_group": "prop_small", "collision": "convex", "author": "ta_pipeline"}

for ob in bpy.context.selected_objects:
 for k, v in META.items():
 ob[k] = v
# Export with "Custom Properties" ticked. In Unreal, read via the FBX import
# pipeline or asset metadata; in Unity, via an AssetPostprocessor's userData.

Gotcha: properties added via UI sliders can carry min/max UI metadata that confuses some importers - plain ob["key"] = value assignments export cleanest. The full three-DCC metadata story is Rosetta task 5.

Recipe 5 - Fix The Common Sins In One Pass

Problem: outsourced or jam-week files: unapplied transforms, stray parenting, orphaned data bloating the file.

bpy - the janitor
import bpy

# apply transforms on all meshes
bpy.ops.object.select_all(action="DESELECT")
for ob in bpy.data.objects:
 if ob.type == "MESH":
 ob.select_set(True)
if bpy.context.selected_objects:
 bpy.context.view_layer.objects.active = bpy.context.selected_objects[0]
 bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)

# purge orphaned meshes/materials/images (the file-size mystery, solved)
bpy.ops.outliner.orphans_purge(do_recursive=True)
print("janitor done.")

Gotcha: don't apply location on modular kit pieces - their offset from origin is often intentional pivot placement. Rotation and scale, always; location, look first.

Recipe 6 - Headless Blender (The Build-Farm Move)

Problem: exporting 40 .blend files shouldn't require opening 40 .blend files.

shell + bpy - batch over files
# export_one.py - runs INSIDE blender
import bpy, sys
out = sys.argv[sys.argv.index("--") + 1]
bpy.ops.export_scene.gltf(filepath=out)

# then from any shell / CI job / watch folder:
# blender -b props/crate.blend -P export_one.py -- D:/out/crate.glb

Gotcha: everything after -- is yours; everything before belongs to Blender. Wrap the shell loop in Python with subprocess (see the snippet library) and you've built a content build farm - pair it with a watch folder and it runs itself.

The One Gotcha To Rule Them All: Context

When a script that works in the Scripting tab fails from an add-on or headless run with context is incorrect - you've met bpy's signature quirk. bpy.ops.* operators are UI actions; they assume an active object, a mode, sometimes literally a mouse-over-viewport. Three escapes, in order: prefer data access over operators (ob.name = "x" needs no context; bpy.ops.object.rename does); set the context explicitly (view_layer.objects.active = ob) before operators you must use; and for the stubborn ones, bpy.context.temp_override(...). If you learn one deep thing about bpy, learn this - it's the difference between scripts that work in demos and tools that work in pipelines.