Tier 2 · Art and 3D asset production
Blender conventions & driving Blender from an agent
Export conventions for Unity, plus MCP, bpy scripts and headless runs.
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,
bpyscripts or headlessblender -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 (exportexport_materials='NONE'+export_texcoords=True→ small + keeps UVs; no public hosting needed),image_style_url(data URI of a style/reference image) and/ortext_style_promptfor the look,enable_original_uv=true(texture onto the mesh's existing UVs) +enable_pbr=true(generates normal/roughness/metallic).import requestsworks in Blender's bundled Python, so export→POST→poll(GETthe 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 … squarestretches 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 baked0.01armature and an import-scale headache. Export FBX withapply_unit_scale=True; import withuseFileScale=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 orAnimationModeon 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.001duplicates. 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°), plusdissolve_degeneratefor 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-targetedorigin_setall 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_dissolvethe 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_1for the second hand). On a re-export this silently renames bones: harmless for Humanoid (mapped by role), but it breaks Generic path-bound clips andCopyFromOtherAvatar. 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: ModelImporterglobalScale=1,bakeAxisConversion=false(the bake belongs on the Blender side; Unity's bakeAxisConversion re-flips it the wrong way). Caveat:bake_space_transformcan 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 flat0.5scalar), 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 (aSeparate Colornode 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 keep0.5and 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 + the0.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=Falsewhen 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
.blendlives outsideAssets/(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
.blendon disk and no autosave (checkbpy.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: removenukes the user's orphan datablocks too). Recovery if you do delete: rawbpy.data.*.removeis not a memfile-undo step, sobpy.ops.ed.redocan 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 theirAiDoorwaywork; recovered viaed.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 runblender <dup>.blend --background --factory-startup --python reduce.pyso 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=…), NOTbpy.opsin 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 OWNrig_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 (andclip.SampleAnimationbypasses 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
ryis 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 —BakeMeshbounds 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: assigninganimation_data.actiondoes NOT evaluate untilanimation_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
bpyscripts, or with a headlessblender -brun. 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
.blendresets 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.pyspeaks 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_codeis sandboxed: noos,openorsubprocess.
- Its
- 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),.blendsummaries, 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_clitools 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.
- Look up any API you are not certain of here before writing
- 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/listat 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_DIRandBLENDER_PATH.--checkprints 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-blenderwith envUV_PYTHON_PREFERENCE=only-managed,BLENDER_HOST=localhost,BLENDER_PORT=9878(the port map) andDISABLE_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-mcpis now a wrapper that installsmcp-for-blender. Configure the new name. - Add-on:
uvx mcp-for-blender install-addoncopiesblender_mcp.pyinto 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_codecall that registers abpy.app.timerscallback. The callback runsaddon_utils.disableandaddon_utils.enable, thenbpy.ops.blendermcp.start_serverabout 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 to9876. - 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 :9876before you blame the bridge. - blend-ai: a git clone plus
uv syncfor 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
.mcpbrelease, which is a uv project: unzip it and runuv run --directory <dir> blender-mcpwithBLENDER_MCP_PORT. That avoids the packagedblender-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_sandboxsays 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:
- A health script that pings every port, each in its own protocol.
Or, from inside the MCP client, call
get_addon_statusorexecute_blender_code("print('ok')")with a short timeout. get_viewport_screenshot. It must return animagecontent block.- Record
bpy.app.version_string,bpy.data.filepathandbpy.data.is_dirtyin 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_inforequires auser_promptargument. 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.dimensionsandmatrix_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_angleandcalc_normals_splitare gone. - Use
shade_auto_smoothorshade_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). Legacybl_infoadd-ons still load. Unity's direct.blendimport 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:
bglis removed.- Dictionary access to
bpy.propsproperties is removed. scene.node_treebecomesscene.compositing_node_group.BLENDER_EEVEE_NEXTbecomesBLENDER_EEVEE.action.fcurvesis removed; use slots and channelbags (bpy_extras.anim_utils).- Prefer the data API (
bpy.data,bmesh,obj.modifiers.new) overbpy.ops. Operators check the active area, mode and selection, and fail withpoll failed, context is incorrect. - When you must use an operator, set
view_layer.objects.activeand the selection explicitly first, thenmode_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-startupkeeps the developer's add-ons, including the MCP servers that auto-start on 9876, out of the batch process.- In background mode,
bpy.context.scenecan beNone: usebpy.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
bpywheel on PyPI (5.2.x needs Python 3.13 exactly; 4.5.x needs 3.11) runsbpywithout 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.003becomesSphere003, 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(orfilepath_rawplus 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: onlyblender-export,blender-uv-texturingandblender-materials.- Its FBX recipe turns on
bake_space_transformfor 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.