---
title: "Blender conventions & driving Blender from an agent"
slug: /workflows/blender/
markdown: /workflows/blender.md
section: art-audio-animation
tier: 2
summary: "Export conventions for Unity, plus MCP, bpy scripts and headless runs."
sources: ['blender.md', 'blender-mcp.md']
canonical: https://unityworkflowlibrary.dev/workflows/blender/
read_first: /read-first.md
---

> Read first, on every prompt: [non-destructive rules](/read-first.md).

# Blender conventions & driving Blender from an agent

> Export conventions for Unity, plus MCP, bpy scripts and headless runs.

## Blender conventions (3D asset authoring for Unity)

> Load on demand for modeling / rigging / export work — not every turn. Targets game-ready output for a Unity DOTS + Latios Kinemation pipeline. The CONSUMING project pins its own character contract (exact skeleton names, deform material, blendshape driver) in its `docs/` — read that too before authoring a character.

> Driving Blender through an MCP bridge, `bpy` scripts or headless `blender -b`: the blender mcp page.

- **A blockout is NOT an asset — never present procedural/AI primitives as a finished piece; hold the
  project's quality bar.** A handful of cylinders/cubes joined in a script (or a raw AI-gen mesh) is a
  *blockout*, not a deliverable — label it as one or don't show it as "a first pass." The bar is the
  reference set / shipped project art (e.g. a modular kit ≈ 0.7–1.5k tris with authored
  albedo/normal/roughness), never first-week primitives. Scripted/procedural geometry earns its keep on
  **structured hard-surface form + the integration layer** — exact dims, pivots, grid-snap, FBX export,
  LOD, tier-wiring — it does **NOT** produce quality **organic / irregular / texture-heavy** assets
  (thatch, foliage, cloth, ornate detail), whose look lives in the **texture** (organic normal+albedo),
  in **AI-gen geometry + retopo**, or in a **commissioned/library asset** — not in hand-coded straw.
  Don't claim you can hand-author organic art: size the promise to what will actually ship, match a real
  reference instead of guessing the look, and reach for AI-gen/texture/commission when the piece is
  organic. (Seen in practice: pitched "I can author these directly," then shipped a 17-cylinder greybox
  as a "thatch foundation" — it read as a clumsy log table; "first week level, fiverr can do better.")

- **Move detail from geometry → texture via AI retexture (Meshy Retexture API) — automatable headless from Blender.** When an AI-gen / photogrammetry mesh must ship low-poly, decimation destroys its *geometric* relief (planks, ornament) and the shading goes wavy/smeared — the fix is to bake that detail into a **normal map** on the low-poly, NOT to keep the polys. Meshy does it via `POST https://api.meshy.ai/openapi/v1/retexture`: `model_url` = a base64 **data URI** of a *geometry-only* GLB (export `export_materials='NONE'` + `export_texcoords=True` → small + keeps UVs; no public hosting needed), `image_style_url` (data URI of a style/reference image) and/or `text_style_prompt` for the look, `enable_original_uv=true` (texture onto the mesh's existing UVs) + `enable_pbr=true` (generates normal/roughness/metallic). `import requests` works in Blender's bundled Python, so export→POST→poll(`GET` the task by its id → `model_urls.glb`)→import all runs inside one Blender script. The API key lives in the project's **gitignored** secrets file (e.g. `docs/apikeys.txt`) — read it at call time, **never commit or echo it**. (Added.)

- **RTS buildings need a square plan — stretch is salvage, not a design.** Halls/barracks/towers that land as long rectangles (`min(W,D) < ~0.85 × max`) read as a bar, not a building. Prompt the concept as **square plan** (width ≈ depth). If a landed mesh is already a long hall, `normalize_building.py … square` stretches the short footprint axis to 1:1 — use that only as salvage. A stretch over ~1.6× warps doors/windows; regenerate a square concept instead of stretching a stick. (a mobile strategy game.)
- **Scale & orientation are non-negotiable.** Model in **metres** (1 Blender unit = 1 m), real-world sized (adult human ≈ 1.7–1.9 m). **Apply all transforms before export** (`Ctrl+A` → Rotation & Scale) so the asset ships at scale (1,1,1) / identity rotation — an unapplied scale becomes a baked `0.01` armature and an import-scale headache. Export FBX with `apply_unit_scale=True`; import with `useFileScale=true, globalScale=1`. Pivot/origin at the **feet** for characters and floor props (the pivot is the gameplay anchor), centred for held items.
- **Armature bone-span ≠ skinned mesh height.** Head/Foot bones sit inside the volume; helm, hair, and boot soles live *outside* the chain. Copying a pack rig onto a new body must scale by **visual mesh height**, not by `max(bone.z)-min(bone.z)`. Filling the mesh with the bone chain overscales limbs and is the usual "slightly larger character, fists miss the mesh" bind failure. Measure both numbers; print the ratio. See the ai modelgen page SKELETON-FIT-LANDMARKS.

- **HUMANOID-CLIPS-NOT-BLENDER-ORIGINAL (HARD) — Unity Humanoid pack clips are not a Blender original.** Asset-store / project infantry walks (`animationType: Humanoid`, `clip.isHumanMotion`) retarget through a Unity Avatar. Assigning that FBX action onto the pack mesh in Blender plays 3ds Max locals on a 0.0254-scale Bip001 — A-pose shuffle, T-lock, legs folding back. That wreck is not the store animation, and it must not be the bind/scale/overlay control. Blender control = the **mesh FBX at authored rest** (no action, no apply-transform, no muted object loc/rot/scale, no Copy Rotation, no T-pose-as-rest). Clip evidence = Unity Inspector or `AnimationMode` on a Humanoid UnitSkin. Bone-head deltas and MCP viewport screenshots are not look evidence (numbers can “move” while the mesh is still T-posed; viewport captures go stale). Named fail: `BLENDER_HUMANOID_CLIP`. (a mobile strategy game: every dest was judged against a mutated clip-driven WK; disk clips were untouched.)

- **Export only what ships.** Export set = `{ARMATURE, MESH}` — no cameras, lights, empties, or hidden helper geo. Name the mesh object meaningfully (it becomes the Unity mesh name). No leftover `.001` duplicates. Purge orphan data (`File → Clean Up`) before export — the cost you skip in cleanup you pay as an import-time or runtime surprise.

- **Topology: quads, deform-aware, no hidden faces.** All-quads (tris only where nothing deforms); never n-gons. Add 2–3 edge loops at every bend (elbows, knees, shoulders, hips, knuckles) so deformation doesn't pinch. Manifold; consistent outward normals (recalc `Shift+N`), no flipped faces. No interior/occluded geometry. For low-poly stylized, carry the look with **hard-edge shading + custom vertex normals**, not polygon density.

- **Poly budgets (tris, LOD0) — these are FLOORS to profile UP from, never ceilings to force the art down to.** Stylized character ≈ 1.5–8k; hero/player ≈ 8–20k; small prop ≈ 0.2–1.5k; simple hard-surface structure piece ≈ 1–5k — **BUT a unique-textured or organic (AI-gen + baked-normal) kit piece routinely runs 3–5× that (≈10–20k) and that is correct, not bloat.** Match the **reference/sibling part of the set** — the shipped neighbours ARE the real budget; never shave one piece below its set to hit a generic number. At crowd scale characters live at the LOW end (every tri is paid per instance per frame), and **GPU instancing reuses one mesh cheaply but every instance still pays its per-tri cost** — so the levers at scale are shared mesh + shared material + **LODs**, NOT shaving an already-reasonable mesh. **LOD anything that appears in numbers or at distance** (LOD0/1/2 ≈ 100/50/20% tris), authored by decimate + cleanup, not auto-only. (Corrected: a flat "1–5k structure piece" reading was contradicting shipped 9–21k foundations and pushing needless quality loss on the entry tier.)

- **UVs: pack tight, atlas hard, uniform texel density.** Unique-textured surfaces get non-overlapping shells in 0–1, seams hidden on backs/insides. Keep texel density uniform within an asset and consistent across a set (nothing reads sharper than its neighbour). Share **one texture/atlas per character or prop set** — the cost is draw calls + texture bindings, not polys. Don't author a lightmap UV2 on skinned/dynamic meshes.

- **Decimating / low-polyrizing a mesh = REBUILD the UVs the same pass — never ship the source's UVs on the reduced mesh.** A decimate (or any heavy topology change) leaves the old UVs draped over moved/collapsed verts → the texture smears, pinches, or starbursts. After reducing, either Smart-UV-Project a fresh unwrap, or (if retexturing via an AI service like Meshy) let that step build the UVs (`enable_original_uv=false`) — reusing the dense/soup source UVs is the cause of the smear, not the texture. Same rule applies when you MOVE verts on an already-textured mesh (e.g. widening an opening): the UVs don't follow, so relax them (UV → Minimize Stretch) or re-unwrap the affected shells the same pass. (Surfaced: widening a stone doorway opening stretched its baked texture hard; fixed by remap + Minimize Stretch.) **A decimate also INVALIDATES the mesh's custom split normals** (every Meshy/FBX import carries them) — symptom: lighting/shading artifacts (gradients, banding, fake "shadows") visible in EEVEE **material preview** and in **Unity**, but NOT in Cycles **rendered** (Cycles silently tolerates broken custom normals; EEVEE + game engines don't). This recurs on EVERY low-poly pass of an imported mesh, so make it part of the decimate step: `mesh.customdata_custom_splitnormals_clear` → `normals_make_consistent(inside=False)` → `shade_auto_smooth(~35°)`, plus `dissolve_degenerate` for the zero-area faces the collapse leaves. To confirm a suspected "baked-in shadow" is really just normals/viewport-AO and NOT in the texture, view the albedo **flat/unlit** (Workbench → color_type=TEXTURE, light=FLAT) — if the shadow vanishes there, the texture is clean. (Surfaced: every metal HQ low-poly pass showed preview-only lighting artifacts = decimate-invalidated custom normals.) **And RE-SET the origin/pivot to the gameplay anchor as the FINAL step of the pass** — decimate, bake-duplicate, modifier-apply, and a mis-targeted `origin_set` all silently leave the origin at world-zero (the transform handle floats away from the mesh), which reads as a broken pivot in-engine. Verify the origin's offset from the anchor (foundation deck / wall + doorway BASE = bottom-centre) is (0,0,0); re-set via cursor→anchor→`origin_set(type='ORIGIN_CURSOR')` if not. (Surfaced: the metal doorway's origin ended at world (0,0,0) after the sizing pass even though the wall's was fine.)

- **Flat building-part faces read WAVY after a collapse decimate — fix it as a NORMALS problem + a genuinely-planar face, NOT by snapping verts to a plane.** AI-gen/photogrammetry meshes (Meshy etc.) carry ±several-mm of surface noise plus custom split normals; at HQ density it's imperceptible, but a COLLAPSE decimate turns a nominally-flat panel into coarse, unevenly-distributed triangles whose interpolated vertex normals render soft triangular/wavy **shadow patches**. This shows even in **Workbench/solid** — but solid mode is itself normal-shaded, so "it shows in solid mode" does NOT prove it's geometry. **Root cause = normals (uneven triangulation + imported split normals interacting with vertex-normal interpolation), not polygon density.** The proper fix, in order: (1) the standard NORMALS step — `customdata_custom_splitnormals_clear` → `normals_make_consistent(inside=False)` → Shade Smooth + Auto Smooth ~30–45° → `dissolve_degenerate` (add a **Weighted Normal** modifier if a flat face still shimmers); (2) keep the big flats genuinely PLANAR — author / `limited_dissolve` the flat deck-top + vertical sides into clean quads/n-gons (uniform up-normals) rather than leaving decimated tri-soup there; (3) the **baked normal map** from the HQ makes any residual micro-waver moot. **Do NOT run a per-vert "snap front/back faces to a median plane" pass** — deprecated: it papers over the method, is fragile on organic/tilted decks, destroys relief you baked from, and isn't on the undo stack. Still-useful diagnostic: to prove a suspected "baked-in shadow" is normals/viewport-AO and not the texture, view the albedo **flat/unlit** (Workbench → color_type=TEXTURE, light=FLAT) — if it vanishes there, the texture is clean. (History: a median-plane snap pass was added to fight this; root-caused to normals + tri-soup on flat faces — the snap was treating the symptom.)

- **PBR materials are engine-authored — never bake Blender's Principled into the asset.** Preview in Blender, but the shippable material is the **Unity** material the data/logic side owns and can swap (see unity.md "data and visuals stay separate"). Textures power-of-two; albedo / normal / MRA(metal-rough-AO) set; normal-map green-channel handedness matching the engine (verify, don't assume). Aim for **one SkinnedMeshRenderer + one material** per character — combine before adding renderers.

- **Rig: match the target skeleton, don't invent one.** A character that must reuse existing animation **shares the project's skeleton** — identical bone names, hierarchy, rest pose. Where the project rigs **Unity Humanoid**, reuse via the Humanoid **Avatar** (retargets clips across proportions — the robust default); only match exact bone *paths* where the project uses **Generic**. Author in a consistent **A-pose or T-pose**. Max **4 weights/vertex**, normalized, no stray micro-weights; the hand/grip bones MUST be weighted (weapon & IK mounts ride them).
- **Extra-cranial hats / caps / horns are Head-only.** Nearest-segment and heat-map binds treat a wide brim as clavicle or upper-arm, so the hat squashes when arms swing. After bind, assign the hat island (crown + hanging brim) **100% Head**; do not retouch shoulders or hands. Proof: bake rest vs mid-walk and measure those verts in Head-local — max delta should be ~0.

- **⚠ Blender uniquifies duplicate bone names on import** (`Thumb_01` → `Thumb_01_1` for the second hand). On a re-export this silently renames bones: **harmless for Humanoid** (mapped by role), but it breaks Generic path-bound clips and `CopyFromOtherAvatar`. Rename back, or stay on Humanoid.

- **Export FBX for Unity with "Apply Transform" so it imports NORMALIZED (identity transform).**
  Blender (Z-up) ↔ Unity (Y-up) + FBX unit handling means a naive export lands in Unity with a baked
  **270° X-rotation and a ~100× root scale** (and often 1/100 size) — the mesh is then NOT normalized
  and you're forced to hack per-prefab transforms (rot/scale on the renderer), which the next person has
  to reverse-engineer. **Fix it at EXPORT, not in Unity:** enable **`bake_space_transform=True`** (the
  *Apply Transform* checkbox) so the axis+scale conversion is baked into the vertex data and Unity
  imports at **identity** (position 0 / rotation 0 / scale 1) at correct real-world size. Verified
  recipe: `export_scene.fbx(apply_unit_scale=True, apply_scale_options='FBX_SCALE_ALL',
  bake_space_transform=True, axis_forward='-Z', axis_up='Y', use_tspace=True, use_mesh_modifiers=True,
  object_types={'MESH'})` — with the object's transforms already applied (`Ctrl+A`) and origin at the
  gameplay anchor (feet/base). **Recalc normals outward** (`mesh.normals_make_consistent(inside=False)`)
  AND export tangents (`use_tspace=True`) or normal-mapped / lit materials render with
  inverted/garbled shading. Unity side: ModelImporter `globalScale=1`, `bakeAxisConversion=false` (the
  bake belongs on the Blender side; Unity's bakeAxisConversion re-flips it the wrong way). Caveat:
  `bake_space_transform` can introduce a negative scale if a non-uniform parent scale survives — apply
  ALL transforms first and verify normals after import. (Seen in practice: naive export → 0.03-size,
  sideways, 270°/100× AI foundations; Apply Transform fixed it to clean identity 3×2×3.)

- **Run the 3-part PREP PASS on every game model BEFORE export — SIZE, NORMALS, ROUGHNESS, in order:**
  (1) **SIZE** to the gameplay contract / grid (real-world dims; this project's building parts:
  foundations **3×3×2**, walls **3×0.25×3** = footprint × height), origin at the gameplay anchor (a
  foundation's DECK, a wall's BASE). (2) **NORMALS** — clear custom split normals + recalc outward
  (`mesh.normals_make_consistent(inside=False)`) + auto-smooth; AI/photogrammetry meshes ship bad
  split-normals that survive a *plain* recalc and read as inverted-face **dark patches**. (3) **ROUGHNESS
  / LIGHTING** — run this on EVERY part of EVERY tier, one at a time; never trust the import default.
  glTF/Meshy imports land at **Roughness 0.5** (and a baked-base-color-only part keeps that flat `0.5`
  scalar), which reads **too shiny / plasticky** for wood, stone and organic surfaces. Set a matte
  roughness that suits the surface — kit reference values: **wood / stone ≈ 0.8, thatch / cloth / other
  organic ≈ 0.85–0.9**; **real metal** keeps Metallic 1 + a lower roughness, everything else **Metallic 0**.
  Fix the gloss with **roughness, NOT by killing Specular** (leave Specular / IOR Level at the dielectric
  default ≈ 0.5). ⚠ A part whose roughness is driven by a **Meshy PBR map** (a `Separate Color` node feeding
  the Roughness input; map mean ≈ 0.6–0.9) is already correct — leave it; only the **flat-scalar** parts
  need raising. The Principled **scalar value is meaningless when the input is map-linked** — check whether
  Roughness is *linked* before you trust the number it reports. The trap: a few parts in a set silently keep
  `0.5` and slip through while their siblings are already matte (the wood **stairs + ramp + door** did, vs
  the already-correct wood foundation/wall) — so check each part individually, then **VERIFY VISUALLY** under
  an even studio light (isolate the row, look for specular sheen), not just by reading the value. **Match the
  whole SET to its reference part, not just per-part:** a sibling whose roughness rides a **PBR roughness
  map** reads glossier than a flat-scalar sibling and looks like a different material / colour under light
  even at matched albedo — unify the finish (and, for a colour-matched kit, the albedo) to the reference
  part (e.g. wood parts → the foundation's flat roughness 0.8 / metallic 0, keep the normal map). When you
  **measure** a texture's colour to compare, read it at **FULL resolution** — strided sub-sampling aliases
  against the grain/strand pattern and fabricates colour deltas that aren't real — and compare by **per-pixel HUE**, not RGB mean: two woods can
  share an RGB average yet read as visibly different colours (one more orange, one more red) because only the
  hue differs, so an RGB-mean check passes while the eye sees a mismatch. THEN the
  Apply-Transform export. Skip the pass and the model reads as "wrong in-engine": too-tall/sunk
  (size+pivot), dark patches (normals), or shiny/plasticky (roughness). (Standing rule; per-part
  lighting pass + the `0.5`-import trap + kit roughness reference values added from the wood-tier
  stairs/ramp gloss.)

- **Export FBX, in-place, deterministic.** FBX 2020+; `add_leaf_bones=False`; `axis_forward=-Z, axis_up=Y`; `bake_anim=False` when the engine owns the clips. Locomotion/combat clips are **in-place** (root motion ≈ 0) — translation is the sim's job, not the clip's. Importer per project: shared-rig characters → **Humanoid** + `optimizeGameObjects=true` (MANDATORY for GPU-skinning frameworks like Kinemation — strips bone GameObjects but keeps the skeleton blob); bespoke-skeleton creatures → **Generic**. Leave **Import BlendShapes ON**.

- **Body morphs / variants = shape keys (blendshapes), authored from bone weights.** Sculpt morphs (fat, buff, tall, age) as named **shape keys** (`Fat`, `Buff`, …) on the base mesh. Localize each by the **bone weights** of its region (belly → Hips/Spine; bulk → chest/shoulders) so hands/feet/face stay put. Shape keys export as Unity BlendShapes and **GPU-deform at runtime through Kinemation's blendshape pass** — but the weights must be **driven from data** (a per-character body-type value applied by a presentation system), never hard-set on the mesh. Reserve **mesh-swap variants** (apply the key, export a sibling mesh on the same skeleton) for changes too big for a blendshape. Keep variant vertex counts identical to the base.

- **Modular clothes/armor: separate meshes on the SAME skeleton, body hidden underneath.** Each wearable is its own mesh skinned to the **same bones + bind pose** as the body, so it rides every clip with zero extra animation (this is the whole point of skeleton-sharing). **Hide/cull the body region a garment covers** (body-part toggle or hidden-mesh mask) to kill poke-through; model garments slightly proud of the skin. Wearables carry the **same blendshapes** as the body (or are re-fit) so they morph WITH a fat/buff body. The model is a prefab reference the item/data side swaps — never bundle the mesh into the item data type.

- **Source `.blend` lives outside `Assets/` (or gitignored).** Keep editable sources in a non-imported `_ArtSource/`-style folder; Unity imports the exported **FBX**, not the `.blend` (avoids Blender-as-a-build-dependency + repo bloat). Mirror vendor convention — Synty/Blink packs ship FBX, no sources.

- **Driving a live Blender via tools (MCP / scripting): NEVER mutate the user's scene.** Their open file
  may hold unsaved, irreplaceable work and there may be no `.blend` on disk and no autosave (check
  `bpy.data.filepath` / `bpy.data.is_dirty`). Import references into a **throwaway scene** (`bpy.data.scenes.new`)
  or an isolated collection, measure/build there, and remove **only what you created, by EXACT name** —
  **never a blanket orphan-data purge** (`for blk in coll: if blk.users==0: remove` nukes the user's
  orphan datablocks too). Recovery if you do delete: raw `bpy.data.*.remove` is **not** a memfile-undo
  step, so `bpy.ops.ed.redo` can rebuild the last operator snapshot — redo forward, then re-clean by
  exact name without purging. (Seen in practice: imported refs into the user's live scene, then a
  name-guarded cleanup + orphan purge deleted their `AiDoorway` work; recovered via `ed.redo`.)

- **Heavy reduce / remesh / bake runs HEADLESS on a DUPLICATE — never through an interactive scripting bridge (MCP) on the main thread.** Blender's Python is single-threaded and not thread-safe: a synchronous heavy `bmesh` / Decimate / Remesh / bake on a dense mesh blocks redraw and can OOM-crash the editor (Blender's own tracker logs remesh/decimate memory-spike crashes), and threading around it corrupts Blender's drawing code. For anything above ~100k tris — and ALWAYS for AI-gen/photogrammetry sources (often 0.2–2M tris) — `wm.save_as_mainfile(copy=True)` a working duplicate (this does NOT disturb the user's live session), then run `blender <dup>.blend --background --factory-startup --python reduce.py` so a crash can't take the live editor and the GUI overhead is gone. Use the modifier/data API (`obj.modifiers.new('Decimate','DECIMATE'); m.ratio=…`), NOT `bpy.ops` in loops; keep the high-poly **untouched as the bake reference**; save each stage (raw → reduced → baked) to a versioned file. The MCP/live bridge is for LIGHT edits + inspection only (a one-inch vert nudge, measuring, a framed preview render). (Seen in practice: repeatedly: a ~185k-tri interactive op crashed Blender 3×; a 1.85M-tri foundation is 10× that.)

- **HUMANOID-ANIM-NEVER-PROCEDURAL — a humanoid character's animation set comes from a mocap-grade
  source, NEVER hand-scripted procedural keyframes; and generator-rigged characters play PER-UNIT
  baked clips as GENERIC, not cross-unit Humanoid retargets.** Sine-curve walk cycles and
  phase-windowed attack poses read robotic to any developer eye no matter how carefully tuned — the
  wasted day is authoring them at all (developer verdict on a 36-clip procedural set: "worst animations
  I've ever seen"). Correct pipeline for AI-gen bipeds: upload the CLEANED mesh to the generator's
  auto-rig (Meshy: `POST /openapi/v1/rigging`, GLB data URI + `height_meters`, ~5 cr, Mixamo-named
  skeleton, walk/run included; face must point +Z — Blender's -Y-forward exports to glTF as exactly
  that), then buy each unit's clips ON ITS OWN `rig_task_id` (~3 cr each) and import as Unity
  **Generic** — proportion-correct and grounded with no offsets. Do NOT convert generator rigs to
  Unity Humanoid to share one clip set: the muscle conversion of their bind poses collapsed a leg
  on EVERY unit tested while every avatar read "valid+human" (the failure is invisible to avatar
  checks, only playback shows it). Four paired traps: (1) auto-riggers FAIL on leg-hidden meshes
  (floor-length robes) — deterministically, so don't reroll; such units import a clean scripted
  Blender rig as Humanoid and retarget VENDOR humanoid pack clips via project-owned COPIES (root Y
  = Feet; never edit vendor importers — other content depends on them); (2) WEAPON CURATION:
  auto-skinners bind hanging weapons to LEG bones (sword shards toward the leg in every clip) —
  re-bind weapon-region verts carrying non-arm influences to the hand bone in an AssetPostprocessor
  keyed to the rig files, and verify weapon-follows-hand at an attack peak before staging; same
  class Blender-side: give wing/tail bones exclude-zones so they can't grab head/torso verts;
  (3) QA humanoid poses with the PlayableGraph STILL ALIVE — destroying the graph snaps a humanoid
  to the collapsed muscle-zero pose, so post-destroy renders show fake breakage (and
  `clip.SampleAnimation` bypasses humanoid root handling entirely); (4) editor-scene test-object
  renames/refs set without dirtying are silently lost on play-mode serialize — Undo.RecordObject +
  SetDirty + save before judging play. Scripted Blender rigs remain correct ONLY for creatures no
  auto-rig supports (winged flyers, quadrupeds, rider+mount). (Seen in a mobile strategy game: full
  demon-army cycle: procedural set rejected → Humanoid cross-retarget shipped a missing leg per
  unit → per-unit Generic clips + vendor-clips-on-Blender-rig god + weapon postprocessor landed.)

- **Rig-authoring iterations stay in ONE Blender session from the source mesh — NEVER continue by
  re-importing your own exported FBX; and verify REST pose + WORLD-space bounds after every export.**
  Three paired landmines from iterating a rigged+animated FBX (each produced a differently-broken
  wyvern): (1) Blender's FBX importer RE-BASES bone axes/rolls on import, so a re-imported armature
  is NOT the one you authored — re-exporting it (or "fixing" it with object/data rotations) yields
  tipped animations or a scrambled bind pose (rest pose lies on its side while every CLIP samples
  fine, or vice versa). To add animations later, rebuild deterministically: keep the rig+animation
  authoring fully SCRIPTED, strip the imported armature/actions, keep only the mesh, re-run the
  build scripts, export ONCE. (2) A bone's local Y axis points ALONG the bone — euler-keying `ry`
  is pure roll/twist: the channel animates but the limb goes nowhere (a flap that reads 58° while
  the wingtip is frozen). Key limbs as world-axis rotations converted into bone-local space:
  `q_local = R_rest⁻¹ · R_world(axis,θ) · R_rest`. (3) Verification must check the REST pose
  (`smr.bounds`, world) AND clip samples transformed to WORLD space — `BakeMesh` bounds are in the
  renderer NODE's local space, and the exporter parks the axis-conversion on different nodes run to
  run, so local-space numbers flip between exports while the rendered result is identical (both
  false alarms and missed real breaks). Blender 4.4+ scripting: assigning `animation_data.action`
  does NOT evaluate until `animation_data.action_slot = action.slots[0]` is also set. (Seen in a mobile strategy game: wyvern: three broken exports before the one-pass rebuild.)

**BLENDER-SCALE-NEEDS-VIEW-LAYER-UPDATE .** A scale set after `bpy.ops.mesh.primitive_cube_add` is not in
`matrix_world` until `bpy.context.view_layer.update`; a guide render framed a slab as a 1 m cube. Flush the
view layer before reading bounds.

## Driving Blender from an agent (MCP, scripts, headless)

> Load when an agent drives Blender: through an MCP bridge, through `bpy` scripts, or with a headless
> `blender -b` run. Asset conventions (scale, topology, UVs, the FBX recipe, rigs) live in
> the blender page; AI-generated meshes in the ai modelgen page. Surveyed.

## Pick the tool (BLENDER-MCP-TOOLING)

| Need | Use | Why |
| --- | --- | --- |
| Live inspect, small edits, screenshots, asset search | **`mcp-for-blender`** (ahujasid, formerly `blender-mcp`; ~29k stars) | Largest user base. 32 tools, but mesh, modifier and node work all goes through `execute_blender_code`. |
| API and manual lookup, `.blend` summaries, window and area screenshots | **Blender Lab MCP** (`projects.blender.org/lab/blender_mcp`, maintained by Blender core developers) | Includes the Python API docs and the manual. It is a Blender 5.1+ Extension. It has no asset or export tools. |
| A structured tool per job (UVs, sculpt, mesh-quality report) | `HoldMyBeer-gg/blend-ai` (175 tools, stdlib-only add-on, 1190 CI tests) | The best-engineered alternative, but its tool list costs a lot of context. |
| Heavy work: decimate, remesh, bake, render, batch export | **Headless `blender -b`**, never the live bridge | The bridge runs on Blender's main thread, so a long operation blocks every tool and can crash the developer's editor (the blender page). |

Other servers checked are forks, abandoned, or very small (VxASI, BlenderGPT, GenesisCore, poly-mcp,
djeada, open-mcp).

## Three servers, one Blender: port map and split (BLENDER-MCP-PORT-MAP, developer)

run `mcp-for-blender`, blend-ai and Blender Lab together "for maximum tooling".
They share one Blender, so the ports are fixed and agents follow one split.

| Port | Server | Why this port |
| --- | --- | --- |
| **9876** | blend-ai | Its MCP server has no port option and always connects to 9876, whatever its README says about "your configured port". |
| **9877** | Blender Lab | Stored in the add-on preferences; the client gets it from `BLENDER_MCP_PORT`. The server stays off until Allow Online Access is on. |
| **9878** | mcp-for-blender | Set by `BLENDER_PORT` on the client and by the add-on's scene property. |

- **Both add-ons store their port on the Scene with a 9876 default.** Any new or opened `.blend` resets it,
  and mcp-for-blender auto-starts first, so it takes 9876 and blend-ai's calls reach the wrong protocol.
  - The ONE owner of the map is `tools/blender/mcp_port_owner.py`, a Blender startup script.
  - On every load it re-asserts the ports, moves mcp-for-blender to 9878 and starts blend-ai, which has no auto-start.
  - Never fix a port by hand in the N-panel: the next file load undoes it.
- **Health:** `python tools/blender/mcp_health.py` speaks each server's own wire protocol on each port.
  A port that answers the wrong protocol is a collision, not a dead Blender.
- **Split — which server for what:**
  - **blend-ai:** typed tools for modelling, mesh editing, modifiers, UVs (SLIM), materials and shader
    nodes, sculpt, lighting, cameras, and `analyze_mesh_quality`.
    - Its `execute_blender_code` is sandboxed: no `os`, `open` or `subprocess`.
  - **mcp-for-blender:** asset search and import (Poly Haven, Sketchfab, Poly Pizza, Hyper3D, Hunyuan3D),
    `export_scene`, `bpy_api_lookup`, and **unsandboxed** Python for file I/O and anything blend-ai has no tool for.
  - **Blender Lab:** API and manual lookup (`search_api_docs`, `get_python_api_docs`, `search_manual_docs`),
    `.blend` summaries, window and area screenshots, and jumping to an object or editor.
    - Look up any API you are not certain of here before writing `bpy`.
    - **Never call its `*_for_cli` tools on Windows.** It starts background Blender without detaching stdin
      from the MCP pipe, and the call never returns: measured 0 answers in 90 s with 1.0.3.
      Use the headless recipe instead.
  - **Headless `blender -b`:** heavy jobs.
  - All three queue on ONE Blender main thread, so a long call on one stalls the others. Never run them in parallel.
- **Context cost, measured from `tools/list` at about 4 characters per token:**
  - mcp-for-blender: 32 tools, about 10k tokens
  - blend-ai: 175 tools, about 35k tokens
  - Blender Lab: 26 tools, about 5k tokens
  - Together that is ~50k tokens on every turn for a client that loads tool schemas eagerly.
  - So wire them **per project** (`BLENDER-MCP-PER-PROJECT`), never in user or global scope.

## Wiring a project (BLENDER-MCP-PER-PROJECT)

- One setup script per project wires all three servers from one server list.
  It writes or merges three files:
  - `.mcp.json` (Claude Code)
  - `.codex/config.toml` (Codex; loaded only when the project is trusted)
  - `opencode.json` (OpenCode)
- The script is idempotent. Other servers are kept, and Codex gets a marked block that a re-run replaces.
- Machine paths come from `BLENDER_MCP_UVX`, `BLEND_AI_DIR`, `BLENDER_LAB_DIR` and `BLENDER_PATH`.
  `--check` prints them.
- A Blender server defined in both user and project scope gets flagged by Claude Code as a duplicate endpoint.
  Keep it in project scope only.
- The Blender-side install is once per machine.

## Install and update (BLENDER-MCP-INSTALL)

- **Server:** `uvx --python 3.11 mcp-for-blender` with env `UV_PYTHON_PREFERENCE=only-managed`,
  `BLENDER_HOST=localhost`, `BLENDER_PORT=9878` (the port map) and `DISABLE_TELEMETRY=true`.
  - Telemetry is on by default and posts to a hard-coded endpoint.
  - On Windows, give the client the full path to `uvx.exe`. GUI-launched clients do not inherit PATH (`spawn uvx ENOENT`).
  - The old package name `blender-mcp` is now a wrapper that installs `mcp-for-blender`. Configure the new name.
- **Add-on:** `uvx mcp-for-blender install-addon` copies `blender_mcp.py` into the add-ons folder and keeps a `.bak`.
  - The server never updates the add-on itself. It only logs a version mismatch.
  - After an update, verify the server version **and** the add-on version the live Blender has loaded:
    `addon_utils.module_bl_info(sys.modules["blender_mcp"])["version"]`.
    An updated file on disk is not the code that is running.
- **Reload in a live session without a restart:** send one `execute_blender_code` call that registers a
  `bpy.app.timers` callback. The callback runs `addon_utils.disable` and `addon_utils.enable`, then
  `bpy.ops.blendermcp.start_server` about 1 s later.
  - Disabling the add-on inside the call itself would kill the socket that is serving the call.
  - For an operator that needs a window, wrap it in `with bpy.context.temp_override(window=wm.windows[0])`.
- **PORT-9876-IS-CONTESTED:** `mcp-for-blender`, Blender Lab, blend-ai, djeada and open-mcp all default to `9876`.
  - When two of them auto-start, the first to bind wins. The other one is silently dead.
  - Its client then talks to the wrong protocol: calls hang or return garbage.
  - Assign ports through `BLENDER-MCP-PORT-MAP`.
  - Check who owns a port with `netstat -ano | findstr :9876` before you blame the bridge.
- **blend-ai:** a git clone plus `uv sync` for the server (`uv run --directory <clone> blend-ai`). The
  release zip is installed as an Extension.
  - An **upgrade** needs a full Blender restart: its module cache survives Reload Scripts.
  - A first install works live.
- **Blender Lab requires "Allow Online Access"** (Preferences → System) to start its server
  ("Online access must be enabled"). That is a privacy setting, so the developer turns it on, not the agent.
  Its client is the `.mcpb` release, which is a uv project: unzip it and run
  `uv run --directory <dir> blender-mcp` with `BLENDER_MCP_PORT`. That avoids the packaged
  `blender-mcp.exe`, which Windows Smart App Control can block.
- The socket has **no authentication**. Keep it on localhost.
  - `BLENDER_MCP_SAFE_MODE=1` (mcp-for-blender) blocks file, network and process access in executed code.
  - Blender Lab's `weak_sandbox` says in its own name that it is not a sandbox.

## Health check before work (BLENDER-MCP-HEALTH-FIRST)

A bridge can accept the TCP connection and still never answer. Two open mcp-for-blender issues (#339,
#357) report every call hanging about 5 minutes on Windows with Blender 5.1 and 5.2. On one Windows
machine with Blender 5.1.2, server 2.0.3 and add-on 1.7, calls answered correctly. So probe first and
do not assume either result:

1. A health script that pings every port, each in its own protocol.
   Or, from inside the MCP client, call `get_addon_status` or `execute_blender_code("print('ok')")` with a short timeout.
2. `get_viewport_screenshot`. It must return an `image` content block.
3. Record `bpy.app.version_string`, `bpy.data.filepath` and `bpy.data.is_dirty` in the task notes.
   - An unsaved dirty session means the developer's work is at risk: follow the live-scene rule in the blender page.
   - Save a copy (`wm.save_as_mainfile(copy=True)`) before running any destructive code.

- In 2.0.x, `get_scene_info` **requires** a `user_prompt` argument. Called with `{}`, it fails with a
  pydantic "Field required" error. That is not a dead bridge.
- If the probe hangs, fall back to headless. Do not retry in a loop.

## Verify with numbers and a picture (BLENDER-VERIFY-NUMBERS-AND-PIXELS)

- End every visible change with a screenshot or a thumbnail render.
- Also read the facts a screenshot hides:
  - `obj.dimensions` and `matrix_world` (scale 1 and rotation 0 after applying transforms)
  - vertex and triangle counts, material slots, UV layer names
  - non-manifold, loose and zero-area counts (`bmesh`)
  - origin offset from the gameplay anchor
- A viewport capture can be stale, and it shows solid or EEVEE shading. Judge normals with the flat or
  unlit check in the blender page.
- A rig is judged by numbers first: bone facing and ground contact.

## Writing bpy that runs on this Blender (BPY-VERSION-DRIFT)

Model memory is full of pre-4.x `bpy`. Check the API before writing (`bpy_api_lookup` in mcp-for-blender,
or `search_api_docs` in Blender Lab), and code to `bpy.app.version`:

- **4.0:** operators no longer take a context dictionary. Use `with bpy.context.temp_override(...)`.
- **4.1:** `use_auto_smooth`, `auto_smooth_angle` and `calc_normals_split` are gone.
  - Use `shade_auto_smooth` or `shade_smooth_by_angle`, which add a "Smooth by Angle" modifier.
  - Read normals with `mesh.corner_normals`.
- **4.2:** add-ons are Extensions (`blender_manifest.toml`). Legacy `bl_info` add-ons still load.
  Unity's direct `.blend` import breaks on 4.2+, so always export FBX or GLB explicitly.
- **4.4:** assigning an action also needs `action_slot` (the blender page).
- **5.0:**
  - `bgl` is removed.
  - Dictionary access to `bpy.props` properties is removed.
  - `scene.node_tree` becomes `scene.compositing_node_group`.
  - `BLENDER_EEVEE_NEXT` becomes `BLENDER_EEVEE`.
  - `action.fcurves` is removed; use slots and channelbags (`bpy_extras.anim_utils`).
- Prefer the data API (`bpy.data`, `bmesh`, `obj.modifiers.new`) over `bpy.ops`. Operators check the
  active area, mode and selection, and fail with `poll failed, context is incorrect`.
  - When you must use an operator, set `view_layer.objects.active` and the selection explicitly first,
    then `mode_set`.

## Headless runs (BLENDER-HEADLESS-RECIPE)

- `blender -b <copy>.blend --factory-startup --python-exit-code 1 --python job.py -- <args>`
  - Without `--python-exit-code`, a script that raises still exits 0. The run looks green and did nothing.
  - `--factory-startup` keeps the developer's add-ons, including the MCP servers that auto-start on 9876,
    out of the batch process.
- In background mode, `bpy.context.scene` can be `None`: use `bpy.data.scenes[0]`.
  - There is no window or area, so viewport operators and screenshots fail.
  - Verify with a Workbench or Cycles render written to a file, then read the file.
- The `bpy` wheel on PyPI (5.2.x needs Python 3.13 exactly; 4.5.x needs 3.11) runs `bpy` without a Blender
  install. It suits CI, and it has no GUI.
- Export through the live bridge times out at about 15–30 s on real scenes. Run exports and bakes
  headless, and write large results to a temp file instead of returning them over the socket.

## Export and bake facts that are not in the blender page (BLENDER-UNITY-EXPORT-EXTRAS)

- **FBX Apply Transform (`bake_space_transform`) is experimental and broken with armatures and animation**
  (Blender manual).
  - Keep the identity-import recipe in the blender page for static meshes.
  - For rigs, export without it and let Unity's **Bake Axis Conversion** clear the root −90° X.
  - Rigs: Add Leaf Bones off, Only Deform Bones on.
- **GLB for Unity:** `export_scene.gltf(export_format='GLB', export_yup=True, export_apply=True)`, imported with glTFast.
  - Only the Principled BSDF with image textures survives the export. Bake procedural Noise and Color Ramp first.
  - Names change on export: `Sphere.003` becomes `Sphere003`, and spaces become `_`. Match by the exported name.
- **Baking (Cycles only):**
  - The target Image Texture node must be the **active** node in every material slot.
  - High-to-low bakes use Selected to Active with a cage or extrusion.
  - A baked image lives only in memory until `image.save` (or `filepath_raw` plus save). Unsaved, it
    is lost with the session.
  - Blender bakes OpenGL (Y+) normal maps, which is what Unity expects.
- **Lightmap UV2** for static meshes is its own non-overlapping layer. Author it, or turn on Unity's
  Generate Lightmap UVs. Never reuse a tiled UV0.

## Skills worth pairing with the MCP (skills.sh, by installs)

- `vladmdgolam/agent-skills` → `blender-mcp`: health check first, headless exports, fixes for
  context and poll errors.
- `RobLe3/cc-blender-skill`: only `blender-export`, `blender-uv-texturing` and `blender-materials`.
  - Its FBX recipe turns on `bake_space_transform` for armatures. Override that as described above.
- `affaan-m/ecc` → `blender-motion-state-inspection`: read the rig as numbers before trusting a picture.
- Skip the web-pipeline, Flue and CLI-harness skills unless you use that exact tool.
