Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 73 additions & 5 deletions .claude/skills/blender-to-unity/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,22 @@ directly.
- Both `mcp__blender__*` tools and MCP for Unity tools are connected.
- A model exists in the Blender scene (confirm with `mcp__blender__get_scene_info` /
`mcp__blender__get_object_info`). If empty, stop and tell the user — this skill does not generate models.
- **`import_model_file` is in the `asset_gen` tool group, which is off by default** (only `core`
loads). Enable it first with `manage_tools` (enable the `asset_gen` group), **or** call the C#
handler straight through `batch_execute` —
`{"tool":"import_model_file","params":{"sourcePath":...,"name":...,"outputFolder":...}}` (camelCase)
— which dispatches by name regardless of group gating.

## Steps
1. **Resolve the Unity project path.** Read `mcpforunity://editor/state` for the project
root (the editor dataPath's parent). Decide the export format: **FBX by default**
(built-in importer, zero extra dependencies). Use glTF/.glb only if glTFast is installed
and PBR fidelity matters.
root (the editor dataPath's parent). Decide the export format:
- **GLB (glTFast) when the model has a rig, animation, PBR (metallic/roughness), emission,
or transparency** — glTFast carries all of these automatically, no post-processing
(see [references/bridge-fidelity.md](references/bridge-fidelity.md)). Multi-material zones
survive either format, so they alone don't force GLB.
- **FBX otherwise** — when glTFast isn't installed, the model is plain geometry, or you
specifically need the built-in importer's humanoid-avatar pipeline. FBX drops emission/metallic
(Step 5 restores emission) and surfaces animation only with `animation_type` set (Step 3).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
2. **Export from Blender to a temp path** via `mcp__blender__execute_blender_code`:
```python
import bpy, os, tempfile
Expand All @@ -28,9 +38,15 @@ directly.
apply_unit_scale=True, bake_space_transform=True)
print(out)
```
(glTF branch: `bpy.ops.export_scene.gltf(filepath=out_glb, export_format='GLB')`.)
(glTF branch: `out_glb = os.path.join(tempfile.gettempdir(), "blender_to_unity.glb")`, then
`bpy.ops.export_scene.gltf(filepath=out_glb, export_format='GLB', use_active_scene=True)`
— its default `use_active_scene=False` can silently export a *different* open scene.)
3. **Import into Unity** with `import_model_file`:
`import_model_file(source_path=<temp path>, name=<asset name>, target_size=<final size in meters>)`.
For a **rigged/animated FBX**, also pass `animation_type="generic"` (or `"humanoid"`; `"legacy"`
targets the old Animation-component system) — the importer defaults to `"none"`, which
deliberately imports the mesh with **zero animation clips**. GLB ignores this (glTFast imports
animation itself), so it's an FBX-only knob.
It returns `{ asset_path, asset_guid }`. Pass `target_size` as the intended final size, but treat
it only as a hint: it rescales at import solely when the project's **Auto-normalize** pref is on,
and even then is unreliable for Blender FBX (see the **Scale** note). Step 4 does the reliable
Expand All @@ -49,7 +65,37 @@ directly.
float target = 2f; // intended size in meters
if (maxDim > 0.0001f) go.transform.localScale *= target / maxDim;
```
5. **Verify** with `manage_camera(action="screenshot", include_image=true)` and report the
5. **Restore emission FBX dropped (FBX path).** Blender scenes commonly store their color/glow
in *material emission* (and other Principled-node inputs). FBX carries base/diffuse color but
**not emission**, so neon / "Tron" scenes import as dark bodies with black accents. If the
import looks flat vs. Blender, restore it:
a. Dump the emissive materials from Blender via `execute_blender_code`:
```python
import bpy, json
out = {}
for m in bpy.data.materials:
if not (m.use_nodes and m.node_tree): continue
col, s = (0, 0, 0), 0.0
p = next((n for n in m.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None)
es = next((k for k in ('Emission Color', 'Emission') if p and k in p.inputs), None) # 4.x / 3.x name
if es:
col = tuple(p.inputs[es].default_value)[:3]
s = float(p.inputs['Emission Strength'].default_value)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
e = next((n for n in m.node_tree.nodes if n.type == 'EMISSION'), None)
if e and s == 0:
col = tuple(e.inputs['Color'].default_value)[:3]; s = float(e.inputs['Strength'].default_value)
if s > 0 and sum(col) > 0.01:
out[m.name] = [round(col[0], 3), round(col[1], 3), round(col[2], 3), round(s, 3)]
print(json.dumps(out))
```
b. In Unity (`execute_code`): extract the FBX's materials so they're editable
(`AssetDatabase.ExtractAsset` per `Material`, then `ImportAsset(fbx, ForceUpdate)`), then for
each dumped name set `_EmissionColor = color * Mathf.Clamp(strength*0.45f, 1.5f, 5f)`,
`EnableKeyword("_EMISSION")`, and `globalIlluminationFlags = RealtimeEmissive`. Match names
**case-insensitively** — FBX mangles case and can split one material into variants
(`EdgeCyan` → `EdgeCyan` + `EDGE_CYAN`); set every match. Add a global Bloom volume and enable
the camera's `renderPostProcessing` so the emission actually glows.
6. **Verify** with `manage_camera(action="screenshot", include_image=true)` and report the
asset path + a screenshot.

## Notes
Expand All @@ -59,8 +105,30 @@ directly.
unreliable for Blender FBX (it can over- or under-shoot, e.g. a `target_size=2` model measured 200 m).
The robust fix is the Step 4 measure-bounds-then-set-`localScale` routine, which hits the target
size deterministically regardless of the import scale or the Auto-normalize pref.
- **Materials & emission.** FBX carries base/diffuse color and transforms fine, but **drops
emission and any node-based color** — so Blender neon / "Tron" scenes (color stored in
emission) import as dark bodies with black accents. Two fixes: **(a) prefer glTF/GLB when glTFast
is installed** — glTF's PBR model carries `emissiveFactor` + `KHR_materials_emissive_strength`
natively, so emission survives with no post-step (`bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', use_active_scene=True)`);
**(b) with FBX, run Step 5** to dump Blender's emission and reapply it. Also check the mesh for a
Comment thread
coderabbitai[bot] marked this conversation as resolved.
`color_attributes` (vertex color) layer — the *data* survives FBX, but URP Lit won't *display* it;
that needs a vertex-color-reading shader, not `_EmissionColor`.
- FBX is the default because glTFast is optional in MCP for Unity. If the import errors with
"GLB import requires glTFast", re-export as FBX (or install glTFast from the Dependencies tab).
- **Format fidelity.** [references/bridge-fidelity.md](references/bridge-fidelity.md) is a tested
matrix of what each format carries. Summary: **GLB (glTFast)** keeps textures, metallic/roughness,
emission, transparency, and animation automatically; **FBX** drops metallic + emission, needs
`ModelImporter.animationType = Generic` to surface transform animation, and needs material/texture
extraction to assign an embedded texture. Both bake modifiers and carry geometry + vertex-color
*data* (URP Lit won't *display* vertex colors). Neither carries procedural/node materials — bake
them to image textures in Blender first.
- **Animation doesn't auto-play.** An imported clip won't move the model until an `AnimatorController`
drives it — the placed model has an `Animator` with a **null controller**, so it looks frozen. Build
one with `manage_animation` (`controller_create` → add the clip as a looping state → `controller_assign`
onto the instance) rather than hand-rolling `execute_code`. See bridge-fidelity gotcha #7.
- **Multi-material zones survive.** A mesh split into material slots in Blender (e.g. skin/shirt/pants
regions) imports as submeshes with one material each — base colors carry over GLB natively — so you
can "dress" a model with material zones and it arrives intact.
- Keep one model per handoff; for batches, repeat the loop with distinct names.
- This skill never sends API keys or file bytes over the MCP bridge — Unity reads the file from disk.
- `import_model_file` copies only the single source file; for multi-file exports (a text `.gltf` with an external `.bin`, or an `.obj` with a sibling `.mtl`/textures), zip them first and pass the `.zip` — a bare `.gltf`/`.obj` will lose its sidecars.
106 changes: 106 additions & 0 deletions .claude/skills/blender-to-unity/references/bridge-fidelity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Blender → Unity Bridge — Fidelity Test Results

Empirical results from probing what survives the Blender→Unity file handoff. The bridge is a
file export/import seam (Blender writes FBX/glTF, Unity reads it), so fidelity = whatever those
interchange formats + Unity's importers support — not a BlenderMCP limitation.

**Test env:** Blender (BlenderMCP) → Unity 6000.4.11f1, URP, glTFast installed. FBX via the
built-in model importer; GLB via glTFast. Method: one controlled object per feature, exported
**both** ways (`export_scene.fbx` and `export_scene.gltf` GLB), imported both, inspected the
resulting mesh/material/animation assets.

## Results

| Probe | FBX | GLB (glTFast) |
|---|---|---|
| Mesh geometry (verts, normals, UVs) | ✅ | ✅ |
| Object hierarchy + transforms | ✅ | ✅ |
| Base / diffuse color | ✅ | ✅ (values shift sRGB↔linear, e.g. `0.20`→`0.48`; visually correct) |
| **Procedural / node material** (noise→color) | ❌ → flat default grey | ❌ → flat default white — **but bake it to a texture first (below) → ✅ arrives intact** |
| **Image texture** (checker on base color) | ❌ embedded but **not assigned** to `_BaseMap` | ✅ `baseColorTexture` = embedded image |
| **Vertex colors — data** | ✅ `mesh.colors` present | ✅ present |
| Vertex colors — *display* | ❌ URP Lit ignores them | ✅ glTFast shader displays them (confirmed — white-base mesh rendered its vertex colors) |
| **Alpha / transparency** | ❌ stays **Opaque** (alpha kept in color, surface not switched, rq 2000) | ✅ **Transparent** (Surface=1, rq 3000) |
| **Metallic / roughness** | ❌ metallic reset to 0 on URP conversion | ✅ `metal=1.0`, `rough=0.15` |
| **Modifier** (Subsurf, unapplied) | ✅ baked on export (8→384 verts) | ✅ baked (8→384 verts) |
| **Animation** (object transform) | ⚠️ in file, but importer default `animationType=None` → 0 clips; pass `animation_type="generic"` → 1 clip | ✅ auto-imported (1 clip) |
| **Armature + skinning** | ✅ skinned mesh, 3 bind poses | ✅ skinned mesh, 3 bind poses |
| **Skeletal animation** | ✅ (pass `animation_type="generic"`/`"humanoid"`) | ✅ auto (Animator + clip) |
| **Multi-material zones** (submeshes) | ✅ submeshes + a material each | ✅ submeshes + a material each (base colors native) |
| **Shape keys → blend shapes** | ✅ (Bulge, Spike) | ✅ (Bulge, Spike) |
| **Custom properties** | ➖ FBX user-props (not surfaced by default) | ➖ glTF `extras` (needs `export_extras`; not auto-exposed) |
| **Emission** (from the city scene) | ❌ dropped | ✅ native (`emissiveFactor` + `KHR_materials_emissive_strength`) |
| **Scale** | oversized, needs normalize | oversized (larger), needs normalize |

Legend: ✅ transfers · ❌ lost · ⚠️ needs an import setting · ➖ partial/shader-dependent.

## Verdict

**GLB via glTFast is materially higher-fidelity than FBX** for this pipeline: textures,
transparency, metallic/roughness, emission, and animation all transfer automatically. FBX only
ties on geometry, vertex-color *data*, and modifier baking, and it wins only where you need the
built-in importer's rig/humanoid pipeline or want URP-Lit-native materials (e.g. for URP fog).

Neither format carries **procedural/node materials** — those must be **baked to image textures in
Blender first**. Neither shows **vertex colors** without a vertex-color-reading shader.

## Gotchas discovered (worth automating around)

1. **`export_scene.gltf` defaults `use_active_scene=False`.** It exported the *wrong* scene (a
different open scene) — 4.7 MB instead of 65 KB. Always pass **`use_active_scene=True`**.
2. **FBX `embed_textures=True` embeds but doesn't assign.** The image lands inside the FBX but
Unity's URP material conversion leaves `_BaseMap` empty. Extract materials/textures and assign,
or just use GLB.
3. **FBX animation needs a non-`None` rig mode.** The clip data is in the file, but the importer's
default `animationType = None` yields **zero clips**. `import_model_file` takes
`animation_type="generic" | "humanoid" | "legacy"` to set this at import time (previously you had
to hand-edit the `ModelImporter` after the fact, which is what surfaced this whole gotcha). GLB is
unaffected — glTFast imports animation itself.
4. **FBX drops metallic and emission** in the built-in→URP material conversion; GLB keeps both.
5. **Both need a scale normalize** (measure world bounds → set `localScale`); GLB tends to land
even larger than FBX.
6. **Rigs & morphs need "no-apply" export.** For skinned / shape-key meshes, skip
`bake_space_transform` (FBX) and `export_apply` (glTF) — applying modifiers bakes away the
armature deform and morph targets. Use `use_mesh_modifiers=False` (FBX) / `export_apply=False`
(glTF). Static-geometry exports still want the apply (it bakes Subsurf etc.).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
7. **Imported animation doesn't auto-play.** The clip imports fine but the placed model just has an
`Animator` with a **null controller**, so it looks frozen in edit mode. To *see* it: assign an
`AnimatorController` referencing the clip (set the clip's `loopTime` for continuous playback) and
enter **Play mode** (or drive it via the Animation window / Timeline / legacy `Animation`). This
is a Unity playback detail, not a transfer failure — verified the bone rotated 37°→6° while
playing and looped 15×.

## Recovering procedural / node materials (bake)

Neither format carries node graphs, but you can **bake them to an image texture in Blender**, then
they export as an ordinary texture. Verified: a `Voronoi → ColorRamp` material baked to 512px and
arrived in Unity with `baseColorTexture` assigned and the pattern visible.

Recipe (Cycles bake):
1. Give the object a real UV unwrap — `bpy.ops.uv.smart_project(...)`.
2. Add an empty Image + Image Texture node to the material and set it **active** (the bake target):
`nt.nodes.active = texnode`.
3. `scene.render.engine='CYCLES'`; select the object as active; then
`bpy.ops.object.bake(type='DIFFUSE', pass_filter={'COLOR'}, margin=4, use_clear=True)`.
4. Rewire the baked texture node into **Base Color** (replacing the procedural chain); `image.pack()`.
5. Export GLB as usual. (Bake `EMIT` for glow-driven looks; `COMBINED` to bake full lighting in.)

## Capture more in one export (GLB flags)

Enable the extra channels you want in `export_scene.gltf(..., export_format='GLB', use_active_scene=True, ...)`:
- `export_apply=True` — bakes modifiers (Subsurf etc.) — **static geometry only**; skinned / shape-key
meshes need `export_apply=False` or the armature deform and morph targets bake away (gotcha 6)
- `export_animations=True` — **active or NLA-stashed** actions → clips; unassociated actions are
dropped (Stash / Push Down them onto the object they animate first)
- `export_morph=True` — **shape keys → Unity blend shapes**
- `export_skins=True` — **armature / skinning**
- `export_tangents=True` — normal-map-ready meshes
- `export_extras=True` — Blender **custom properties**
- `export_cameras=True`, `export_lights=True` — cameras/lights come across (units differ — treat as reference, retune in Unity)

## Practical rules

- **Materials/textures/PBR/emission/animation matter → GLB** (glTFast installed).
- **Rigs/humanoid/URP-Lit-native materials matter → FBX** (then extract materials, set animation type).
- **Anything procedural/simulated (node materials, geometry nodes, particles, physics) → bake in
Blender first** (bake to textures, apply/realize modifiers, convert particles to mesh).
1 change: 1 addition & 0 deletions MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public sealed class AssetGenJob
public float Progress;
public string Format;
public float TargetSize = 1f;
public string AnimationType; // FBX/OBJ rig mode: null/none | generic | humanoid | legacy
public string AssetPath;
public string AssetGuid;
public string Error;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ private static void ApplyModelImporterSettings(string rel, AssetGenJob job)
if (!(AssetImporter.GetAtPath(rel) is ModelImporter importer)) return;
importer.useFileScale = true;
importer.materialImportMode = ModelImporterMaterialImportMode.ImportStandard;
importer.animationType = ModelImporterAnimationType.None;
importer.animationType = ParseAnimationType(job?.AnimationType);

if (AssetGenPrefs.AutoNormalize && job.TargetSize > 0f)
{
Expand All @@ -168,6 +168,24 @@ private static void ApplyModelImporterSettings(string rel, AssetGenJob job)
importer.SaveAndReimport();
}

/// <summary>
/// Map the caller's rig mode to a <see cref="ModelImporterAnimationType"/>. Defaults to
/// <c>None</c> (no rig) when unset — pass "generic"/"humanoid" to surface a rigged FBX/OBJ's
/// AnimationClips, which the None default otherwise hides. "legacy" selects Unity's
/// legacy Animation system — rarely needed.
/// </summary>
internal static ModelImporterAnimationType ParseAnimationType(string value)
{
switch ((value ?? string.Empty).Trim().ToLowerInvariant())
{
case "generic": return ModelImporterAnimationType.Generic;
case "humanoid":
case "human": return ModelImporterAnimationType.Human;
case "legacy": return ModelImporterAnimationType.Legacy;
default: return ModelImporterAnimationType.None;
}
}

private static float ComputeMaxDimension(string rel)
{
try
Expand Down
6 changes: 5 additions & 1 deletion MCPForUnity/Editor/Tools/AssetGen/ImportModelFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ public static object HandleCommand(JObject @params)
string destRel = StageUnderAssets(srcAbs, baseName, ext, p.Get("outputFolder"));
AssetDatabase.Refresh();

var job = new AssetGenJob { TargetSize = p.GetFloat("targetSize", 1f) ?? 1f };
var job = new AssetGenJob
{
TargetSize = p.GetFloat("targetSize", 1f) ?? 1f,
AnimationType = p.Get("animationType"),
};
AssetGenJob result = ModelImportPipeline.ImportInto(job, destRel);

if (result == null || result.State == AssetGenJobState.Failed)
Expand Down
7 changes: 6 additions & 1 deletion Server/src/cli/commands/asset_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,20 @@ def import_model(
@click.option("--name", default=None, help="Base name for the imported asset.")
@click.option("--output-folder", default=None, help="Destination folder under Assets/.")
@click.option("--target-size", default=None, type=float, help="Normalize largest dimension (meters).")
@click.option("--animation-type", "animation_type", default=None,
type=click.Choice(["none", "generic", "humanoid", "legacy"]),
help="FBX/OBJ rig mode: generic/humanoid surface animation clips; "
"legacy selects Unity's legacy Animation system (glTF ignores this).")
@handle_unity_errors
def import_model_file(source_path, name, output_folder, target_size):
def import_model_file(source_path, name, output_folder, target_size, animation_type):
"""Import a local 3D model file (e.g. a Blender export) into the Unity project."""
config = get_config()
params = {
"sourcePath": source_path,
"name": name,
"outputFolder": output_folder,
"targetSize": target_size,
"animationType": animation_type,
}
params = {k: v for k, v in params.items() if v is not None}
result = run_command("import_model_file", params, config)
Expand Down
Loading
Loading