name: animatorcontroller-csharp-pitfalls description: Critical pitfalls when modifying Unity AnimatorController via C# API — struct value types, sub-asset serialization, and safe alternatives tags: [unity, animator, animator-controller, csharp, vrchat, mcp]
Unity AnimatorController C# API Pitfalls
The Problem
Modifying UnityEditor.Animations.AnimatorController via C# execute_code through MCP is fragile. Several common operations silently fail or produce corrupted results. This skill documents the pitfalls and safe alternatives.
silently fail or produce corrupted results. This skill documents the pitfalls and safe alternatives.## Critical Pitfall #1: AnimatorCondition is a VALUE-TYPE struct
❌ WRONG — does NOT persist:
var tr = state.AddTransition(otherState);
tr.conditions = new AnimatorCondition[1];
tr.conditions[0].parameter = "Dress_ON"; // ❌ This modifies a COPY
tr.conditions[0].mode = AnimatorConditionMode.If; // ❌ Does NOT persist
tr.conditions[0].threshold = 0; // ❌ Does NOT persist
The conditions array returns copies of structs. Modifying array elements has no effect on the transition.
✅ CORRECT — use array initializer:
var c1 = new UnityEditor.Animations.AnimatorCondition();
c1.parameter = "Dress_ON";
c1.mode = UnityEditor.Animations.AnimatorConditionMode.If; // Bool: triggered when true
c1.threshold = 0;
var t1 = offState.AddTransition(onState);
t1.conditions = new UnityEditor.Animations.AnimatorCondition[] { c1 };
✅ CORRECT — or use object initializer:
t1.conditions = new UnityEditor.Animations.AnimatorCondition[] {
new UnityEditor.Animations.AnimatorCondition {
parameter = "Dress_ON",
mode = UnityEditor.Animations.AnimatorConditionMode.IfNot,
threshold = 0
}
};
❌ WRONG:
var tr = state.transitions[0];
tr.conditions[0].mode = UnityEditor.Animations.AnimatorConditionMode.If; // ❌ Doesn't save
tr.duration = 1.5f; // ❌ Also doesn't save — struct copy!
Even with EditorUtility.SetDirty() and AssetDatabase.SaveAssets(), the value-type struct copy issue prevents this from working on already-created transitions.
❌ SerializedObject m_AnimatorLayers path also unreliable:
// This approach frequently fails with NullReferenceException in Unity 2022
var so = new UnityEditor.SerializedObject(ctrl);
var layers = so.FindProperty("m_AnimatorLayers"); // ❌ Returns null or wrong path
m_AnimatorLayers in some Unity 2022 builds, making this approach unreliable across versions.
✅ WORKING APPROACH: Delete and recreate ALL transitions from the state. This is the only reliable way to change transition properties (duration, exitTime, conditions):
// 1. Remove ALL existing transitions
while (state.transitions.Length > 0) {
state.RemoveTransition(state.transitions[0]);
}
ansitions
while (state.transitions.Length > 0) {
state.RemoveTransition(state.transitions[0]);
}// 2. Recreate transition with correct properties SET IMMEDIATELY
var t = state.AddTransition(destinationState, hasExitTime);
t.duration = 1.5f; // ✅ Set right after creation
t.exitTime = 1f;
t.hasExitTime = true;
t.conditions = new UnityEditor.Animations.AnimatorCondition[] {
new UnityEditor.Animations.AnimatorCondition {
parameter = "MyParam",
mode = UnityEditor.Animations.AnimatorConditionMode.Equals,
threshold = 0
}
};
Key: AddTransition() returns a direct reference — properties set in the same call scope persist correctly.
Modifying transitions AFTER they've been added to the state is what breaks (struct is copied again on access).
transitions AFTER they've been added to the state is what breaks (struct is copied again on access).## Critical Pitfall #3: BlendTree sub-assets need explicit serialization
When creating a nested BlendTree inside a Controller, it must be added as a sub-asset of the Controller — otherwise it won't be saved:
❌ WRONG:
var innerBt = new UnityEditor.Animations.BlendTree();
innerBt.blendParameter = "Shorts_ON";
innerBt.AddChild(offClip, 0f);
innerBt.AddChild(onClip, 1f);
parentBt.AddChild(innerBt, 0f); // ❌ innerBt has NO AssetPath
✅ CORRECT:
var innerBt = new UnityEditor.Animations.BlendTree();
innerBt.blendParameter = "Shorts_ON";
innerBt.AddChild(offClip, 0f);
innerBt.AddChild(onClip, 1f);
UnityEditor.AssetDatabase.AddObjectToAsset(innerBt, controller); // ✅ Must add!
parentBt.AddChild(innerBt, 0f);
Verification: Always check AssetDatabase.GetAssetPath(blendTree) after creating — it should return the Controller's path, not null.
Database.GetAssetPath(blendTree)` after creating — it should return the Controller's path, not null.## Critical Pitfall #4: Layer insertion destroys ordering
❌ FRAGILE — avoid if possible:
// Removing a layer then re-inserting at a specific index
ctrl.layers = modifiedLayers; // Can silently corrupt state machines
Safe alternative: Before modifying layers, read the full structure, check if the layer already exists at the right index, and only modify in-place when possible.
Safer approach — rebuild whole layer from scratch:
// Clear old layer array
ctrl.layers = new AnimatorControllerLayer[] { ... };
// Or better: create the exact array you want, not insert/remove
Safe Alternative: Use Unity Editor UI directly
For complex AnimatorController modifications, use the Unity Editor UI (Animator Window) instead of C# API:
- Open the Animator Controller asset
- Add/remove layers via the Layers tab
- Create states, transitions, and conditions visually
- Assign clips via drag-and-drop
This avoids ALL the serialization and value-type pitfalls above.
- Assign clips via drag-and-drop
This avoids ALL the serialization and value-type pitfalls above.## Critical Pitfall #5: State Machine sub-asset creation needs explicit AddObjectToAsset
When creating AnimatorState, AnimatorStateMachine, or BlendTree objects programmatically, they MUST be added as sub-assets of the Controller, or they won't be saved:
// ❌ WRONG — created objects vanish on save
var sm = new UnityEditor.Animations.AnimatorStateMachine();
sm.name = "MyLayer";
// ✅ CORRECT
var sm = new UnityEditor.Animations.AnimatorStateMachine();
sm.name = "MyLayer";
UnityEditor.AssetDatabase.AddObjectToAsset(sm, controller);
// Same for each state:
var myState = new UnityEditor.Animations.AnimatorState();
myState.name = "MyState";
myState.motion = myClip;
myState.writeDefaultValues = false;
UnityEditor.AssetDatabase.AddObjectToAsset(myState, controller);
sm.AddState(myState, new UnityEngine.Vector3(300, 200, 0));
Critical Pitfall #6: MCP Roslyn sandbox blocks System.Reflection
When using execute_code (C# Roslyn) through MCP, any code using System.Reflection (GetMethods(), GetField(), etc.) will compile but may crash at runtime or be blocked by the sandbox's safety checks.
❌ Avoid in MCP execute_code:
✅ Do direct API calls instead:
// Use known API directly — no reflection needed
state.RemoveTransition(state.transitions[0]);
var t = state.AddTransition(destState, true);
A proven architecture for VRChat clothing dissolve layers:
Hide → (Int==N) → Appear (play dissolve ON) → (auto after 1.5s) → Show (keep visible)
↑ ↓ ↓
| (Int!=N: interrupt) (Int!=N: interrupt)
| ↓ ↓
← (Int==N: interrupt) Disappear (play dissolve OFF) → (auto after 1.5s) → Hide
Key properties:
- writeDefaultValues = false for all states
- duration = 1.5 for all transitions
- Appear→Show and Disappear→Hide use exitTime=1f, hasExitTime=true (auto-proceed)
- The conditional transitions (Hide→Appear, Show→Disappear, etc.) use hasExitTime=false
- Universal dissolve clips (ON/OFF/Show) shared across layers — only control _DissolveParams.z, NOT m_IsActive (which belongs in the base wardrobe layer)
The Unity MCP manage_animation tool (when available) provides higher-level operations:
Check if it has a controller_* or layer_* sub-action before writing raw C#.
etc.
...
}
Check if it has a `controller_*` or `layer_*` sub-action before writing raw C#.## AnimatorState Transition API Reference
| Method | Signature | Notes |
|--------|-----------|-------|
| `AddTransition` | `(AnimatorState destState)` | Returns new `AnimatorStateTransition` |
| `AddTransition` | `(AnimatorState destState, bool defaultExitTime)` | Returns new `AnimatorStateTransition` |
| `AddTransition` | `(AnimatorStateMachine destSM)` | Returns new `AnimatorStateTransition` |
| `AddTransition` | `(AnimatorStateMachine destSM, bool defaultExitTime)` | Returns new `AnimatorStateTransition` |
| `AddExitTransition` | `()` | Returns `AnimatorStateTransition` to exit |
| `AddExitTransition` | `(bool defaultExitTime)` | Returns `AnimatorStateTransition` to exit |
| `RemoveTransition` | `(AnimatorStateTransition transition)` | Removes by reference |
**IMPORTANT**: `RemoveTransition` removes by **reference**, not index. When deleting multiple transitions, always remove `transitions[0]` in a while loop — do NOT cache indices because the array shrinks.
| Enum Value | Integer | Meaning for Float/Int | Meaning for Bool |
|---|---|---|---|
| `If` | 1 | value **>** threshold | **true** (threshold IGNORED) |
| `IfNot` | 2 | value **≤** threshold | **false** (threshold IGNORED) |
| `Greater` | 3 | value > threshold | ❌ Invalid for Bool |
| `Less` | 4 | value < threshold | ❌ Invalid for Bool |
| `Equals` | 8 | value == threshold | ❌ Invalid (Unity auto-converts to If) |
| `NotEqual` | 6 | value != threshold | ❌ Invalid (Unity auto-converts to IfNot) |
converts to If) |
| `NotEqual` | 6 | value != threshold | ❌ Invalid (Unity auto-converts to IfNot) |**⚠️ CRITICAL: For Bool parameters, `threshold` is COMPLETELY IGNORED by Unity's Animator runtime.**
From decompiled Unity source code — the Animator engine checks `GetBool(paramName)` directly without reading the threshold field when mode is `If` or `IfNot` and the parameter type is Bool.
Unity Editor also silently corrects invalid mode+type combinations (e.g., `Equals` + Bool → `If`).
**Bool parameter rules:**
- `If` → triggered when Bool == **true** (threshold value does not matter)
- `IfNot` → triggered when Bool == **false** (threshold value does not matter)
- Do NOT use `Greater`/`Less`/`Equals`/`NotEqual` with Bool parameters
**For Int parameters:**
- `If` → triggered when value > threshold (treats Int as float internally)
- `IfNot` → triggered when value ≤ threshold
- `Equals` → triggered when value == threshold (use with Int for proper equality check)
≤ threshold
- `Equals` → triggered when value == threshold (use with Int for proper equality check)## Direct BlendTree Pitfalls
When creating a Direct BlendTree programmatically:
❌ **WRONG — threshold is not used in Direct mode:**
```csharp
var bt = new BlendTree { blendType = BlendTreeType.Direct };
bt.AddChild(clip, 0.5f); // threshold=0.5 is IGNORED in Direct mode!
✅ CORRECT — use directBlendParameter:
var bt = new BlendTree { blendType = BlendTreeType.Direct };
bt.children = new ChildMotion[] {
new ChildMotion {
motion = clipA,
threshold = 0f, // IGNORED in Direct mode
directBlendParameter = "Param_A" // ★ This is what matters
},
new ChildMotion {
motion = clipB,
threshold = 0f, // IGNORED in Direct mode
directBlendParameter = "Param_B" // ★ This is what matters
}
};
AssetDatabase.AddObjectToAsset(bt, controller); // Must add as sub-asset
Additive nature: Direct BlendTree blends are additive with no normalization. If two children both have their parameters at 1.0, both animations play at 100% simultaneously (they accumulate, not average). their parameters at 1.0, both animations play at 100% simultaneously (they accumulate, not average).## Critical Pitfall #7: AnimationClip material._* property binding triggers renderer.material (NOT sharedMaterial)
When an AnimationClip has curves bound to material._PropertyName, the Unity animation system internally calls renderer.material (NOT sharedMaterial) during playback. This automatically instantiates the material — creating a unique copy. This is by design:
// Unity internal behavior when sampling an AnimationClip with material._* binding:
void ApplyMaterialProperty(GameObject go, string propertyName, float value)
{
var renderer = go.GetComponent<Renderer>();
// ★ ALWAYS calls .material (not .sharedMaterial) — this creates an instance
var mat = renderer.material; // ← auto-instantiates if currently shared
mat.SetFloat(propertyName, value);
}
Implications:
- Multiple Renderers sharing the same sharedMaterial will each get their own material instance when the Animator drives material._* properties
- VRChat SDK's build process mirrors this behavior — it automatically clones materials for Renderers with material._* bindings
- This means you DON'T need to manually call renderer.material = new Material(...) — the animation system does it for you
- However, you CANNOT use MaterialPropertyBlock as an alternative — the Animator system has no MPB support
r, you CANNOT use MaterialPropertyBlock as an alternative — the Animator system has no MPB support## Critical Pitfall #8: Animator drives material._* → renderer.material bypasses MaterialPropertyBlock
MaterialPropertyBlock (MPB) works at the GPU level without creating material instances, but it has a fundamental limitation in VRChat:
| Capability | MaterialPropertyBlock | renderer.material (Animator path) |
|---|---|---|
| Animator support | ❌ Not supported | ✅ Native |
| Shader Keyword | ❌ No toggle support | ✅ Full support |
| Memory | ~0 overhead | ~2-5KB per instance |
| Shared material | Preserved | Broken (new instance) |
| VRChat AV3 C# access | ❌ No C# execution | ✅ Animator-driven |
| Build pipeline | Needs custom plugin | Automatic (VRChat SDK) |
Conclusion for VRChat: The material._* animation path (with auto-cloning in build) is the ONLY viable approach for animating material properties in Avatar 3.0. MaterialPropertyBlock is fundamentally incompatible with the Animator-driven workflow.
Verification Checklist After Any Modification
-
AssetDatabase.GetAssetPath(newBlendTree)returns the controller path (not null) - Transition conditions are correct when reloaded from disk
- All Layer indices match expected positions
- Controller compiles without errors in VRChat SDK Control Panel
- BlendTree children have correct
directBlendParametervalues - No stale entries (e.g.,
Shorts_Subwithdp=Blend) remain ectdirectBlendParametervalues - No stale entries (e.g.,
Shorts_Subwithdp=Blend) remain## Persistence Failure: 7 Root Causes (Quick Reference)
When a C# modification of .controller doesn't survive editor restart, check in this order:
| # | Cause | Symptom | Fix |
|---|---|---|---|
| 1 | Sub-asset not added to main asset | Object exists in memory but vanishes on save | Call AssetDatabase.AddObjectToAsset(subObj, controller) |
| 2 | Missing SetDirty | Asset file doesn't change on disk | Call EditorUtility.SetDirty(controller) before SaveAssets() |
| 3 | AnimatorCondition is a VALUE struct | tr.conditions[0].mode = X modifies a COPY |
Reassign entire array: tr.conditions = newArray |
| 4 | Modified a clone, not the original | Instantiate/new creates independent copy, LoadAssetAtPath is safe for the main instance |
Always use AssetDatabase.LoadAssetAtPath<>() to get the real asset |
| 5 | Unity internal cache bug | Changes via SerializedObject don't take effect | Force cache refresh: AnimationUtility.SetAnimationClipSettings(clip, ...) |
| 6 | Missing AssetDatabase Refresh | Batch edits partially saved | Use StartAssetEditing/StopAssetEditing + SaveAssets + Refresh() |
| 7 | PPtr silently discarded on restart | States/transitions disappear after re-open, no error logged | Check .controller YAML directly: verify m_DstState fileID points to an existing localID |
| gged | Check .controller YAML directly: verify m_DstState fileID points to an existing localID |
Most reliable verification: After saving, open the .controller file in a text editor and search for your transition's YAML block. Verify m_DstState and m_DstStateMachine fileIDs point to real state/stateMachine localIDs in the file. |
|
m_DstState and m_DstStateMachine fileIDs point to real state/stateMachine localIDs in the file.### ⚠️ VRChat 构建时镜像克隆风险 |
当通过 C# API 修改 Controller 后上传到 VRChat,还需注意:
- Editor 中 Play Mode 正常 ≠ 上传后正常
- VRChat SDK 在构建时会进行镜像克隆(Mirror Clone) — 为镜像场景创建 Controller 独立副本
- 以下情况会导致镜像克隆失败(参考
VFController.cs:312-313): - 参数类型无效(通过
RemoveInvalidParameters()和RemoveWrongParamTypes()检查) - 状态机为 null
- 状态没有 motion(null motion)
- VRCFury 的
LayerToTreeService会将所有 FX Layer 展开为 Direct BlendTree — Editor 中的状态机结构 ≠ 运行时的状态机结构
Quick YAML Verification (Linux)
# Check interruptionSource on all transitions
grep -A2 "m_InterruptionSource" YourController.controller | grep -v "^--$"
# Check for null-target transitions
grep -B1 "m_DstState: {fileID: 0}" YourController.controller | head -20
# Count transitions
grep -c "AnimatorStateTransition:" YourController.controller
# Check WriteDefaultValues
grep -B2 "m_WriteDefaultValues:" YourController.controller | grep -E "m_Name:|m_WriteDefaultValues:"