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.
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.
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.
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).
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.
Problem: "is this file export-ready?" shouldn't require opening every object. One script, one verdict - the scripted version of the pre-flight checklist.
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.
Problem: the engine importer needs to know things per-asset (LOD group, collision type) and filenames can only carry so much.
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.
Problem: outsourced or jam-week files: unapplied transforms, stray parenting, orphaned data bloating the file.
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.
Problem: exporting 40 .blend files shouldn't require opening 40 .blend 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.glbGotcha: 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.
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.