跳转至

name: vrchat-dissolve-transition-system category: gaming description: VRChat渐变换衣系统 — 基于lilToon Dissolve + 每套衣服独立Layer + Int参数控制切换。从B站教程视频"落羽松_Taxod"逐帧分析提取的完整方案。 trigger: 用户要求渐变换衣、溶解换装、多套衣服交叉渐变、Dissolve动画时使用


VRChat 渐变换衣系统 (Dissolve Transition)

基于 B站教程视频 [BV1Hz9fBuE29] 落羽松_Taxod 逐帧分析 + 评论区大佬方案整合

核心架构

每件/每套衣服独立一个 Animator Layer
Int 参数控制当前激活的套装
每个 Layer 4状态循环:A_Closed → B_Appearing → C_Opened → D_Disappearing → A_Closed
A_Closed: writeDefaultValues=true(Hide clip 恒定值,归零安全)
B/C/D: writeDefaultValues=false

⚠️ 本 skill 同时记录了早期2状态方案(Step 3)和成熟4状态方案(C# 模板)。 新建项目请统一使用4状态方案,2状态方案仅作参考。

依赖条件

  • Shader: lilToon 2.3.2+(视频中使用)
  • 核心参数: _DissolveParams (.z = 溶解进度, 0=显示, 1=完全溶解)
  • 溶解设置: 勾选「溶解有効」→ 形状选「线」或「盒」→ 设向量方向
  • 或者 Poiyomi Toon Shader: _DissolveProgress (0=显示, 1=消失)
  • 如果其他 Shader: 需要确认溶解属性名

操作流程

Step 1: 材质 — 打开 Dissolve

在 lilToon 材质 Inspector:

lilToon
  └── 溶解 (Dissolve)
      ├── [x] 溶解有効 ✅
      ├── 形状: 线 (Line) 或 盒 (Box)
      ├── 边界: 初始设 0.5 (动画会覆盖)
      ├── 模糊: 0.1-0.5 (控制边缘软度)
      └── 向量: X:0, Y:-1, Z:0 (从上往下)
├── 边界: 初始设 0.5 (动画会覆盖) ├── 模糊: 0.1-0.5 (控制边缘软度) └── 向量: X:0, Y:-1, Z:0 (从上往下) ```### Step 2: 录制 AnimationClip

每件衣服需要两个 clip:

Def_Boots_ON.anim    - 溶解出现 (DissolveParams.z: 1→0, 约0.3-0.5s)
Def_Boots_OFF.anim   - 溶解消失 (DissolveParams.z: 0→1, 约0.3-0.5s)

在 Animation 窗口录制两个属性: 1. Cloth_XXX : Material._DissolveParams.z — 溶解进度 2. Cloth_XXX : Game Object.Is Active — 切换结束时开关 (OFF时最后设为false, ON时开头设为true)

关键: ON 和 OFF 动画的方向一致 (都从下往上溶解/从上往下溶解),这样两套衣服同时播放 ON/OFF 时形成自然的交叉渐变。

Step 3: Animator Layer 结构

每套衣服独立一个 Layer。注意:以下为早期2状态方案,新建项目请用4状态方案(见下方 C# 模板)。

Layer: Def_Boots (权重=1, Override)  — 2状态方案(简单但不支持打断保护)
┌──────────────┐    Int == 1     ┌──────────────┐
│  Def_Boots_OFF│ ←────────────→ │ Def_Boots_ON │
│  (Idle, 隐藏) │    Int != 1    │  (Idle, 显示) │
└──────────────┘                 └──────────────┘
      ↑                                ↑
      └──── Entry (默认进入 OFF) ──────┘

2状态配置(writeDefaultValues=false): - ON 状态: motion = Def_Boots_ON.anim, writeDefaultValues = false - OFF 状态: motion = Def_Boots_OFF.anim, writeDefaultValues = false

4状态方案(推荐): 见下方 "MCP execute_code 实战" 节,A_Closed wd=true, B/C/D wd=false。

转换条件:

OFF → ON: Int Clothes == 1, HasExitTime=false, Duration=0
ON → OFF: Int Clothes != 1, HasExitTime=false, Duration=0

Step 4: Int 参数控制

VRCExpressionParameters:
  Clothes: Int, saved=true, default=0

参数值映射:
  0 = 裸体/默认 (所有衣服隐藏)
  1 = 套装A
  2 = 套装B
  3 = 套装C
Clothes: Int, saved=true, default=0

参数值映射: 0 = 裸体/默认 (所有衣服隐藏) 1 = 套装A 2 = 套装B 3 = 套装C ```### Step 5: 快速切换保护

如果在溶解动画播放中(ON/OFF动画未播完)突然切换: - 在 B 状态(穿衣溶解中)如果 Int != 目标值 → 打断进入 OFF - 在 D 状态(脱衣溶解中)如果 Int == 目标值 → 打断进入 ON - 效果: 快速连点时变为瞬间切换,不卡动画

Step 6: 菜单 Toggle

不需要复杂的 SubMenu。用 Button 或 Toggle 直接设 Int:

菜单结构:
  ┌─ 裸体 (Button → Clothes=0)
  ├─ 套装A (Button → Clothes=1)
  ├─ 套装B (Button → Clothes=2)
  └─ 套装C (Button → Clothes=3)

或者用 Toggle 组(互斥)。

注释区大佬补充

"每件衣服单独占一个layer,用int参数的equal和not equal作为条件,不从any出发而是enter->A.衣服关闭、穿衣ready-equal->B.溶解穿衣动画-与动画等长退出时间->C.衣服开启-脱衣ready-not equal->D.溶解脱衣动画-与动画等长退出时间->A.衣服关闭、穿衣ready"

"所有衣服的根骨骼必须一样,否则坐标模式下即使参数一样也会因为根骨骼位置不同而溶解边界不一致"

"录制溶解动画本身就很费手了,这样的重复工作交给脚本就好了"

与 m_IsActive 开关方案的区别

方案 效果 实现复杂度
m_IsActive 直接开关 瞬间切换,无过渡
Dissolve 双状态 每件有溶解过渡
4状态循环 + Int 多套交叉渐变,完整方案 高(脚本生成)

批量材质配置(MCP 远程操作)

发现问题:多套衣服材质往往没配置溶解

仅创建 dissolve clip 和 Animator Layer 不够——所有参与溶解的材质本身必须正确配置 dissolve 参数。常见问题:

  1. _DissolveEnabled 不存在 — lilToon 没有这个 shader property,mat.SetFloat("_DissolveEnabled", 1f) 无效。实际控制方式是 _DissolveParams.x > 0(x=1 线形, x=2 点形, x=3 盒形)+ 启用 GEOM_TYPE_BRANCH_DETAIL shader keyword
  2. 噪点图为 null — 必须显式设 mat.SetTexture("_DissolveNoiseMask", noiseTex)
  3. 溶解颜色错误_DissolveColor 控制溶解边缘的颜色,必须设为正确颜色
  4. 外部包的材质 — 服装包(如 choco/Seraphic Bloom Dress/、AyuElla/SilentSkinPeek/、Rest/Awakoi_Code/)的材质不能用固定路径访问,需通过场景中的 SMR GameObject 找到材质引用 ic Bloom Dress/、AyuElla/SilentSkinPeek/、Rest/Awakoi_Code/)的材质不能用固定路径访问,需通过场景中的 SMR GameObject 找到材质引用### 批量配置 C# 代码模板

// 通过场景中的 SMR 找到材质来修改(路径分散在外部包时必须用此方法)
var avatar = UnityEngine.GameObject.Find("avatar_name");
var smrs = avatar.GetComponentsInChildren<UnityEngine.SkinnedMeshRenderer>(true);
var noiseTex = AssetDatabase.LoadAssetAtPath<Texture2D>("Assets/.../Noise.png");
var color = new Color(0.22f, 0.48f, 0.91f); // #387AE9

// 每套衣服的 SMR path 列表(来自 Layer 18 的 AnyState clip)
string[] clothPaths = { "Cloth_Dress", "Cloth_Pumps", "Cloud/Outer", ... };

foreach (var smr in smrs) {
    // 构建 avatar 相对路径
    var path = smr.gameObject.name;
    // ... 向上遍历 parent 构建完整路径

    if (!clothPaths.Contains(path)) continue;

    foreach (var mat in smr.sharedMaterials) {
        if (mat == null) continue;

        // 设 x=2(点形),保持已有 z 值不动
        mat.SetFloat("_DissolveParams.x", 2f);
        mat.SetTexture("_DissolveNoiseMask", noiseTex);
        mat.SetFloat("_DissolveNoiseStrength", 0.3f);
        mat.SetColor("_DissolveColor", color);
        mat.EnableKeyword("GEOM_TYPE_BRANCH_DETAIL");
        EditorUtility.SetDirty(mat);
    }
}
AssetDatabase.SaveAssets();
M_TYPE_BRANCH_DETAIL"); EditorUtility.SetDirty(mat); } } AssetDatabase.SaveAssets(); ```### 快速验证材质状态

// 检查配置结果
foreach (var smr in allSmrs) {
    var path = GetPath(smr, avatar);
    if (!isClothingPart(path)) continue;
    foreach (var mat in smr.sharedMaterials) {
        var dp = mat.GetVector("_DissolveParams");
        // dp.x=2(点形), dp.z=-0.5(可见) or 0.5(default)
        var noise = mat.GetTexture("_DissolveNoiseMask");
        // noise 不为 null, _DissolveNoiseStrength=0.3
    }
}

通用溶解 Clip(不控制 m_IsActive)

当多套衣服共用相同的 SMR 集合时(所有衣服控制相同 31 个 SMR,只是不同的 m_IsActive 状态),可以创建一套通用 dissolve clip,只用 _DissolveParams 曲线,不加 m_IsActive 曲线

  • Universal_Dissolve_ON.anim: _DissolveParams.z 1.7 → -0.5(溶解→可见)
  • Universal_Dissolve_OFF.anim: _DissolveParams.z -0.5 → 1.7(可见→溶解)
  • Universal_Dissolve_Show.anim: _DissolveParams.z = -0.5 恒定(保持可见)

m_IsActive 完全由底层的 Wardrobe AnyState layer(通常是 Layer 18)控制。dissolve layer 使用 Override blending 在更上层。

关键:必须动画 _DissolveParams 全部 4 个组件(x,y,z,w),即使 x 和 w 是常数。Unity 的动画系统可能会优化掉恒定曲线,但显式动画化所有组件可确保 shader 正确更新。 动画 _DissolveParams 全部 4 个组件(x,y,z,w),即使 x 和 w 是常数。Unity 的动画系统可能会优化掉恒定曲线,但显式动画化所有组件可确保 shader 正确更新。## 录制动画替代方案(脚本生成)

由于逐件录制 AnimationClip 非常费手,推荐用 C# Editor 脚本生成:

var clip = new AnimationClip();
var binding = new EditorCurveBinding {
    path = "Cloth_Dress",
    propertyName = "material._DissolveParams.z",
    type = typeof(SkinnedMeshRenderer)
};
var curve = new AnimationCurve();
curve.AddKey(0f, 1.7f);  // 0秒: 完全溶解
curve.AddKey(1.5f, -0.5f); // 1.5秒: 完全显示
AnimationUtility.SetEditorCurve(clip, binding, curve);
AssetDatabase.CreateAsset(clip, "Assets/FX/Universal_Dissolve_ON.anim");

注意: AnimationClipSettings 在 Unity 2022 中的 API 不同,不需要手动设 loopTime 也会默认不循环。

关键教训:每套衣服必须有自己的 dissolve clip

ationClipSettings 在 Unity 2022 中的 API 不同,不需要手动设 loopTime 也会默认不循环。

关键教训:每套衣服必须有自己的 dissolve clip### ⚠️ 致命陷阱:_AlphaMaskMode = 0 时 _AlphaMaskValue 动画完全无效

这是 VRChat 溶解效果中最容易被忽略的配置问题。

lilToon 的 _AlphaMaskValue 不是一个独立工作的 alpha 参数——它受 _AlphaMaskMode(Shader Property _AlphaMaskMode)控制:

// lilToon 源码 (lil_common_frag.hlsl 第465-475行)
if(_AlphaMaskMode)  // ← 如果 _AlphaMaskMode == 0,整个模块被跳过!
{
    float alphaMask = saturate(alphaMaskTex * _AlphaMaskScale + _AlphaMaskValue);
    if(_AlphaMaskMode == 1) fd.col.a = alphaMask;       // 覆盖
    if(_AlphaMaskMode == 2) fd.col.a = fd.col.a * alphaMask; // 乘算
    if(_AlphaMaskMode == 3) fd.col.a = saturate(fd.col.a + alphaMask); // 加算
    if(_AlphaMaskMode == 4) fd.col.a = saturate(fd.col.a - alphaMask); // 减算
}
模式 说明 推荐溶解配置
0 禁用 AlphaMask 模块完全跳过,_AlphaMaskValue 动画不生效
1 覆盖 fd.col.a = alphaMask _AlphaMask贴图=全黑, _AlphaMaskScale=0, 动画_AlphaMaskValue 1→0
2 乘算 fd.col.a = fd.col.a × alphaMask ✅ 与主贴图原有 alpha 相乘
3 加算 fd.col.a = saturate(fd.col.a + alphaMask) 不推荐
4 减算 fd.col.a = saturate(fd.col.a - alphaMask) 不推荐

如果使用 _AlphaMaskMode = 1(覆盖)且 _AlphaMask 贴图为全黑: - alphaMask = saturate(0.0 × scale + value) = saturate(value) - _AlphaMaskValue = 1.0fd.col.a = 1.0(显示) - _AlphaMaskValue = 0.0fd.col.a = 0.0(隐藏)

如果使用 _DissolveParams.z(lilToon 官方溶解方案),则不受 _AlphaMaskMode 影响。Dissolve 模块是独立的(lil_common_frag.hlsl 第 478-519 行)。 arams.z(lilToon 官方溶解方案),则不受 _AlphaMaskMode 影响。Dissolve 模块是独立的(lil_common_frag.hlsl 第 478-519 行)。验证代码**(需要 MCP Unity 连接):

var mat = AssetDatabase.LoadAssetAtPath<Material>("Assets/YourPath/YourMaterial.mat");
float mode = mat.GetFloat("_AlphaMaskMode");
float maskScale = mat.GetFloat("_AlphaMaskScale");
float maskValue = mat.GetFloat("_AlphaMaskValue");
Debug.Log($"_AlphaMaskMode={mode} _AlphaMaskScale={maskScale} _AlphaMaskValue={maskValue}");
"); Debug.Log($"_AlphaMaskMode={mode} _AlphaMaskScale={maskScale} _AlphaMaskValue={maskValue}"); ```### 常见的 _AlphaMaskValue 不生效原因

# 现象 根因
1 动画 _AlphaMaskValue 1→0,画面无变化 _AlphaMaskMode = 0(最常见)
2 动画 _AlphaMaskValue 1→0,仍显示 AlphaMask 贴图为全白:alphaMask = 1×1+0 = 1
3 溶解有边缘锯齿 使用 Cutout 模式而非 Transparent
4 溶解后残影 _Cutoff 太低,没有 clip 近透明像素

现象:切换衣服时显示错乱(切到原皮显示其他衣服,切到开衫毛衣显示原皮等)

根因:所有 layer 使用了同一个 dissolve clip,而这个 clip 的 m_IsActive 曲线是硬编码给某一套衣服的(比如原皮的8个SMR)。当露肩短裙的 layer 播放这个 clip 时,它会强制激活原皮的 SMR,导致错乱。

解决方案:每套衣服生成自己独立的 ON/OFF/Show clip,只控制自己那套衣服的 active SMR。

材质配置:SetFloat/SetVector 可能不生效的问题

某些路径下的材质(常见于第三方服装包如 Assets/choco/Assets/AyuElla/)对 C# 的 SetFloat / SetVector 调用无响应——即使通过 SerializedObject 写入也不刷新。解决方案是直接编辑 .mat 文件:

var fullPath = Application.dataPath + "/../" + assetPath;
fullPath = Path.GetFullPath(fullPath);
var content = File.ReadAllText(fullPath);
content = Regex.Replace(content, @"_DissolveParams:\s*\{[^}]*\}", 
    "_DissolveParams: {r: 2, g: 0, b: -0.5, a: 0}");
File.WriteAllText(fullPath, content);
AssetDatabase.Refresh();

.mat 文件中 _DissolveParams 的 YAML 格式:_DissolveParams: {r: 2, g: 0, b: -0.5, a: 0}

修改文件后必须调用 AssetDatabase.Refresh() 才能让 Unity 重新加载。

lilToon Shader 切换注意事项

Shader.Find("Hidden/lilToonCutout") 可能不工作。安全做法是从已有材质获取 shader 引用:

var refMat = AssetDatabase.LoadAssetAtPath<Material>("Assets/.../RefMat.mat");
var cutoutShader = refMat.shader;
targetMat.shader = cutoutShader;
al>("Assets/.../RefMat.mat"); var cutoutShader = refMat.shader; targetMat.shader = cutoutShader; ```### lilToon 渲染模式

期望模式 Shader 名称
不透明 lilToon
镂空(Cutout) Hidden/lilToonCutout
透明单面 Hidden/lilToonTransparent
透明双面 Hidden/lilToonTwoPassTransparent
轮廓线 Hidden/lilToonOutline
宝石 Hidden/lilToonGem

_TransparentMode 参数:0=不透明, 1=Cutout, 2=Transparent

溶解材质完整配置参数

项目
渲染模式 Cutout 或 Transparent(TwoPass)
溶解方式 UV
形状 点(Point) — _DissolveParams.x=2
坐标 X=0, Y=1 — _DissolvePos=(0,1,0,0)
噪点图 Cazalis_Reflection_Noise
噪点强度 0.3
纹理颜色 #387AE9 — Color(0.22,0.48,0.91)
边界可见 -0.5(材质默认值,场景中可见)
边界溶解 1.7(动画 clip 中使用)
Shader关键字 GEOM_TYPE_BRANCH_DETAIL

lilToon Dissolve 参数速查

参数 用途 典型值
_DissolveParams.x 形状: 1=线, 2=点, 3=盒 2 (点)
_DissolveParams.z 边界/进度: -0.5=可见, 1.7=完全溶解 动画中变化
_DissolveNoiseMask 噪点纹理 Cazalis_Reflection_Noise
_DissolveNoiseStrength 噪点强度 0.3
_DissolveColor 溶解边缘颜色 #387AE9
_DissolveUV 使用哪套 UV (0=UV0, 1=UV1) 0
GEOM_TYPE_BRANCH_DETAIL shader keyword (必须启用) mat.EnableKeyword(...)
(0=UV0, 1=UV1) 0
GEOM_TYPE_BRANCH_DETAIL shader keyword (必须启用) mat.EnableKeyword(...)
- ❌ mat.SetFloat("_DissolveEnabled", 1f) — 无效,不存在这个 property
- ❌ 材质路径写死 — 外部包材质在不同目录下(choco/、AyuElla/、Rest/),必须通过场景对象找
- ❌ 多个同名材质(如 Cloud.mat)有不同的 UV 设置 — 不能用一个改全部
- ✅ _DissolveParams.z 初始值不重要 — dissolve clip 会覆盖
- ✅ animation clip 的 material._DissolveParams.z 使用 typeof(SkinnedMeshRenderer) 绑定
- ⚠️ C# SetFloat/SetVector 可能不生效 — 某些外部包的材质(Assets/choco/、Assets/AyuElla/)对 SetFloat/SetVector 调用无响应,即使通过 SerializedObject 写入也不刷新。解决方案:直接编辑 .mat 文件内容(正则替换)+ AssetDatabase.Refresh()
- ⚠️ _DissolveParams 在 .mat 文件中以 YAML 格式存储_DissolveParams: {r: 2, g: 0, b: 1.7, a: 0} — 直接编辑文件后必须 Refresh 才能生效
- ⚠️ dissolve layer 的 Appear/Disappear 状态使用带 m_IsActive 的 clip — 这与底层的 Wardrobe layer (Layer 18) 同时控制 m_IsActive。在 Override blending 下,dissolve layer(更高 index)控制实际显示。如果用不带 m_IsActive 的通用 clip,需要确保底部 Wardrobe layer 正确设了 m_IsActive
- ⚠️ 溶解隐藏 ≠ 禁用 Contact — ContactReceiver/Sender 在材质透明/溶解状态下仍然工作。只有 m_IsActive=false 才停止 Contact 检测。如果服装溶解后有 Contact Sender,会产生「幽灵碰撞」。所有 Contact 组件应放在不会被溶解 Disable 的根节点上

⚠️ PhysBone 与 Dissolve 的关键交互

核心认知: Dissolve(溶解动画)只影响材质显示,不会停止 PhysBone 物理模拟

操作 PhysBone 行为
只动画 _DissolveParams.z(材质溶解) ❌ PhysBone 继续运行
动画结束同时 m_IsActive = false ✅ PhysBone 立即停止
Renderer.enabled = false ❌ PhysBone 继续运行

这就是为什么 dissolve OFF 动画的最后必须设置 m_IsActive = false——不仅仅是隐藏渲染,而是让 PhysBone 组件也停止工作。

如果不设 m_IsActive:溶解后的衣服虽然看不见,但布料物理仍在每帧计算,多个不可见的衣服叠加会造成显著的 CPU 浪费,特别是在 Quest 端。 PhysBone 组件也停止工作。

如果不设 m_IsActive:溶解后的衣服虽然看不见,但布料物理仍在每帧计算,多个不可见的衣服叠加会造成显著的 CPU 浪费,特别是在 Quest 端。### 安全做法:将 PhysBone 放在子 GameObject 上

如果由于架构限制不能直接禁用主服装 GameObject,可以将 PhysBone 放在一个专门控制的子对象上:

ClothingSet_A (始终 active,供应 SMR)
├── SkinnedMeshRenderer (dissolve 在材质上)
├── _PhysBoneHolder (由 dissolve clip 控制)
│   ├── VRCPhysBone (裙摆物理)
│   └── VRCPhysBone (头饰物理)

在 dissolve OFF clip 中控制 _PhysBoneHolder.m_IsActive = false(溶解开始就禁用),在 dissolve ON clip 中在渲染出现前 m_IsActive = true。 ip 中控制 _PhysBoneHolder.m_IsActive = false(溶解开始就禁用),在 dissolve ON clip 中在渲染出现前 m_IsActive = true。### C# 脚本生成 Hide/ON/OFF/Show clip(远程 MCP 操作)

当通过 MCP 远程生成 AnimationClip 时,关键技巧:

  1. 获取ON clip的绑定路径:用 AnimationUtility.GetCurveBindings(onClip) 获取所有SMR路径,然后用HashSet去重得到唯一SMR列表
  2. Hide clip内容:每个SMR设置 _DissolveParams.x=2, y=0, z=1.7, w=0 + m_IsActive=0(都是恒定值在0秒)
  3. ON clip内容:从另一个已有31SMR的clip复制全部曲线(见下方复制方法)
  4. 曲线复制:删除目标clip所有曲线 → 遍历源clip的绑定复制曲线 → EditorUtility.SetDirty(dstClip)
  5. 保存AssetDatabase.CreateAsset(clip, path)AssetDatabase.SaveAssets()AssetDatabase.Refresh()
  6. 绑定到Layers.state.motion = clipEditorUtility.SetDirty(ctrl)AssetDatabase.SaveAssets()

C#代码模板(创建Hide clip):

// 从ON clip获取所有SMR路径
var bindings = AnimationUtility.GetCurveBindings(onClip);
var paths = new HashSet<string>();
foreach (var b in bindings) paths.Add(b.path);
eBindings(onClip);
var paths = new HashSet<string>();
foreach (var b in bindings) paths.Add(b.path);// 创建Hide clip
var hideClip = new AnimationClip();
hideClip.frameRate = 60;
foreach (var p in paths) {
    // _DissolveParams.xyzw 全部恒定
    float[] vals = {2f, 0f, 1.7f, 0.1f};  // w=0.1(不要用0,会锯齿)
    string[] props = {"material._DissolveParams.x","material._DissolveParams.y","material._DissolveParams.z","material._DissolveParams.w"};
    for (int i = 0; i < 4; i++) {
        var curve = new AnimationCurve();
        curve.AddKey(0f, vals[i]);
        var binding = new EditorCurveBinding();
        binding.path = p; binding.propertyName = props[i];
        binding.type = typeof(SkinnedMeshRenderer);
        AnimationUtility.SetEditorCurve(hideClip, binding, curve);
    }
    // m_IsActive = 0
    var ac = new AnimationCurve();
    ac.AddKey(0f, 0f);
    var ab = new EditorCurveBinding();
    ab.path = p; ab.propertyName = "m_IsActive"; ab.type = typeof(GameObject);
    AnimationUtility.SetEditorCurve(hideClip, ab, ac);
}
AssetDatabase.CreateAsset(hideClip, "Assets/.../Hide.anim");
SetEditorCurve(hideClip, ab, ac); } AssetDatabase.CreateAsset(hideClip, "Assets/.../Hide.anim"); ```### 曲线复制覆盖(修复clip SMR数量不匹配)

当发现某套衣服的溶解clip控制的SMR数量少于应有的(例如ON clip只有5SMR但应有31SMR),可以直接从正确的源clip复制全部曲线覆盖:

// 删除dst所有曲线
var dstBindings = AnimationUtility.GetCurveBindings(dstClip);
foreach (var b in dstBindings)
    AnimationUtility.SetEditorCurve(dstClip, b, null);

// 复制src所有曲线到dst
var srcBindings = AnimationUtility.GetCurveBindings(srcClip);
foreach (var b in srcBindings) {
    var curve = AnimationUtility.GetEditorCurve(srcClip, b);
    AnimationUtility.SetEditorCurve(dstClip, b, curve);
}
EditorUtility.SetDirty(dstClip);

MCP远程操作时的C#编码陷阱

当通过MCP协议远程执行C#代码时,中文/日文字符串字面量在JSON传递过程中会被破坏。解决方案:

  1. 不要用中文/日文字符串字面量来匹配文件名或路径名
  2. Directory.GetFiles() + Path.GetFileName() + 字节匹配来识别特定文件:
    var files = Directory.GetFiles(fxDir, "*.anim");
    foreach (var f in files) {
        string name = Path.GetFileName(f);
        byte[] nameBytes = Encoding.UTF8.GetBytes(name);
        // 用字节前缀匹配日文UTF-8编码的文件名
        bool isTarget = nameBytes.Length >= 9 
            && nameBytes[0]==0xE4 && nameBytes[1]==0xBC && nameBytes[2]==0x91; // 休
    }
    
  3. 输出用纯ASCII+数字编码,不要直接用中文名称——可通过hex encode或索引输出
  4. 文件名匹配也用 Contains("_Hide.") 等ASCII子串来过滤
  5. s.state.name 含中文导致C#返回null时,改用state索引而不是名称 e或索引输出
  6. 文件名匹配也用 Contains("_Hide.") 等ASCII子串来过滤
  7. s.state.name 含中文导致C#返回null时,改用state索引而不是名称### 检查已有溶解层L18的兼容性

当项目已有基于AnyState+m_IsActive的底层Layer(如WardrobeCloth Layer 18),添加Dissolve Layer(L21-24)时注意:

底层L18特性: - writeDefaultValues=true,用AnyState控制所有SMR的m_IsActive - 当Int变化时立即切换(无过渡) - 控制全部31个SMR

高层Dissolve层(L21-24)特性: - writeDefaultValues=false,Override模式 - 每套衣服控制自己的SMR溶解 - 有1秒过渡动画

交互逻辑: - Override模式下高层(L21-24)覆盖低层(L18) - Hide clip的m_IsActive=0覆盖L18的m_IsActive值 - Show/ON/OFF clip的m_IsActive值覆盖L18 - L18负责初始状态和瞬间切换的m_IsActive,L21-24负责溶解过渡 - 实际工作中,L18的m_IsActive在切换时瞬间变化,然后L21-24的过渡动画逐步覆盖——最终L21-24的终态胜出

验证检查: - 所有L21-24的4个状态(Hide/Appear/Show/Disappear)都应有motion clip,不能有null - Hide状态的motion以前常被忽略(=null),但必须补上 - 各衣服的ON/OFF/Hide/Show clip控制的SMR数量必须一致

验证清单

  • Shader 已启用 Dissolve(材质 Inspector 确认)
  • ON/OFF/Show/Hide AnimationClip 已生成(每套4个)
  • 每套衣服的clip 控制相同数量的SMR(验证曲线数/5是否一致)
  • 所有4个状态(Hide/Appear/Show/Disappear)都有 非null的motion
  • Int 参数已添加到 FX Controller + VRCExpressionParameters
  • 每层转换条件使用 Int Equal/Not Equal
  • 菜单使用 Button 或互斥 Toggle 设 Int 值
  • 所有衣服根骨骼一致(否则溶解边界会错位)
  • writeDefaultValues = false(B/C/D dissolve层), A_Closed = true(Hide clip 归零安全)
  • 底层L18(WardrobeCloth)的writeDefaultValues = true(与dissolve层配合)

⚠️ 架构陷阱:两套控制系统打架

VRChat 换装系统最常见的 Bug 来自两套独立的控制系统对同一属性(m_IsActive)的冲突写入。 true(与dissolve层配合)

⚠️ 架构陷阱:两套控制系统打架

VRChat 换装系统最常见的 Bug 来自两套独立的控制系统对同一属性(m_IsActive)的冲突写入。### ⚠️ 上传时的镜像克隆风险(2026-05-21 新增)

通过 C# API 创建 Layer/Controller 后直接上传到 VRChat 时请注意:

  • Editor 中 Play Mode 测试正常 ≠ 上传后正常运行
  • VRChat SDK 在构建时会进行镜像克隆(Mirror Clone) — 为镜像场景创建 Controller 独立副本
  • 以下情况会导致镜像克隆失败:
  • 参数类型无效 — 如 Int 参数被误设为 Float 或 Trigger
  • 状态机为 null — Layer 创建时 stateMachine 必须非 null
  • 状态没有 motion — 任何状态都必须有非 null 的 motion clip
  • AnimatorStateMachine 未通过 AddObjectToAsset 注册

⚠️ VRCFury LayerToTree 改写(2026-05-21 新增)

如果项目使用 VRCFury,构建时会自动执行 LayerToTreeService: - FX Controller 中所有 Layer 的状态机结构被展开为 Direct BlendTree - Editor 中看到的状态机结构 ≠ 运行时的状态机结构 - 这意味着 Editor 中调试通过的复杂状态机,上传后变为等价的 BlendTree - 对功能正确性无影响,但对调试策略有影响(不能用 Editor Animator 窗口调试运行时行为)

如果不用 VRCFury(仅使用原生 VRCSDK),FX Controller 的状态机结构保持原样。

症状

  • 溶解动画播了(能看到过渡),但最终状态不对
  • 昨晚成功了但重启后不行
  • 某个参数 toggle 了但视觉上没变化
  • 不同衣服的部件交叉显示(A衣服显示B衣服的部件)

典型架构冲突

Layer[19-22] Dissolve层        Layer[23] WardrobeCloth层
─────────────────────          ─────────────────────
由 Bool 参数控制                 由 Int 参数控制
改 _DissolveParams + m_IsActive  只改 m_IsActive
writeDefault=True                 writeDefault=True
Override 模式                     Override 模式(更高 index)

问题:两套系统都写 m_IsActive。Layer 19 设 m_IsActive=0,Layer 23 同时设 m_IsActive=1因为 Layers 默认按 index 顺序计算,高 index 的 Override 层胜出,所以 Layer 23(index 23)的 m_IsActive 会覆盖 Layer 19(index 19)的值。 默认按 index 顺序计算,高 index 的 Override 层胜出**,所以 Layer 23(index 23)的 m_IsActive 会覆盖 Layer 19(index 19)的值。### 检查方法

// 检查每层动画是否写了冲突的 m_IsActive
for each layer in controller.layers {
    for each state in layer.states {
        clip = state.motion
        bindings = AnimationUtility.GetCurveBindings(clip)
        if any binding.propertyName == "m_IsActive":
            log(layer.name + " state " + state.name + " writes m_IsActive")
    }
}

解决方案

方案A(推荐):让 WardrobeCloth 层不写 m_IsActive - Wardrobe 层只做 _DissolveParams 动画,m_IsActive 全部由溶解层控制 - 或者 Wardrobe 层的 clip 里只保留 dissolve 相关的属性

方案B:统一参数系统 - 只用 Int(Wardrobe_Int1)控制一切 - 溶解层也从 Int 推导,去掉 4 个独立的 Bool 参数 - 用 AnyState 条件统一切换

方案C:分离控制角色 - WardrobeCloth 负责不含 dissolve 的部件(如基础 Body、不可溶解的配件) - Dissolve 层只负责可溶解的衣服部件 - 两部分不要控制相同的 GameObject

快速定位哪层在覆盖

  1. 检查编辑器运行时 Animator 面板:逐层查看当前状态,确认哪个状态在激活
  2. 逐层禁用测试:临时把 Layer 23 weight 设为 0,看溶解是否正常;再把 Layer 19-22 weight 设为 0,看 Wardrobe 切换是否正常
  3. 代码检查:看 WardrobeCloth 层的 clip 里有没有写 Origin_ON/Lujian_ON/Casual_ON/Cardigan_ON 等参数名(通常没有——这才是问题的根源)

相关资源

  • [[vrchat-toggle-parameter-linkage]] — Toggle-参数-菜单联动
  • [[vrchat-dress-bra-link-layer]] — Dress-Bra Layer 联动
  • [[vrchat-toggle-dissolve-system]] — 更基础的 Toggle+Dissolve 实现

MCP execute_code 实战:4 状态循环远程搭建

2026-05-13 实战验证:通过 Unity MCP 的 execute_code 远程创建 4 个完整 4 状态循环 Layer,不修改原有 Layer。 de 实战:4 状态循环远程搭建

2026-05-13 实战验证:通过 Unity MCP 的 execute_code 远程创建 4 个完整 4 状态循环 Layer,不修改原有 Layer。### 关键 API 陷阱

错误写法 正确写法
sm.AddEntryTransition(new AnimatorTransition(){destinationState=stateA}) sm.AddEntryTransition(stateA) — 直接传 AnimatorState 对象
st.state.GetTransitions() st.state.transitions — 直接属性不是方法
sm.GetEntryTransitions() 不存在此方法,用 sm.entryTransitions(如果可访问)或直接通过 GetField("m_EntryTransitions", ...)
yTransitions()| 不存在此方法,用sm.entryTransitions(如果可访问)或直接通过GetField("m_EntryTransitions", ...)` ### C# 创建 4 状态循环的完整代码模板

var ctrl = AssetDatabase.LoadAssetAtPath<AnimatorController>("path/to/FX.controller");
float td = 1.0f; // 溶解过渡时长

// 服装配置
string[][] sets = new string[][] {
    new string[] {"OutfitName", "1", "Assets/.../ON.anim", "Assets/.../OFF.anim"},
    // 更多... [name, intValue, onPath, offPath]
};

foreach (var set in sets) {
    string nm = set[0]; int iv = int.Parse(set[1]);
    var onC = AssetDatabase.LoadAssetAtPath<AnimationClip>(set[2]);
    var ofC = AssetDatabase.LoadAssetAtPath<AnimationClip>(set[3]);
    if (onC == null || ofC == null) continue;

    var layer = new AnimatorControllerLayer();
    layer.name = "4S_" + nm; // 加前缀避免与原有层冲突
    layer.defaultWeight = 1f;
    layer.blendingMode = AnimatorLayerBlendingMode.Override;

    var sm = new AnimatorStateMachine();
    sm.name = layer.name + "_SM";
    layer.stateMachine = sm;

    // 创建 4 个状态
    var A = sm.AddState("A_Closed_" + nm); A.motion = ofC; A.writeDefaultValues = false;
    var B = sm.AddState("B_Appearing_" + nm); B.motion = onC; B.writeDefaultValues = false;
    var C = sm.AddState("C_Opened_" + nm); C.motion = onC; C.writeDefaultValues = false;
    var D = sm.AddState("D_Disappearing_" + nm); D.motion = ofC; D.writeDefaultValues = false;

    // Entry → A
    sm.AddEntryTransition(A);
+ nm); D.motion = ofC; D.writeDefaultValues = false;

    // Entry → A
    sm.AddEntryTransition(A);// A → B: Int == N
    var a2b = A.AddTransition(B);
    a2b.hasExitTime = false; a2b.hasFixedDuration = true; a2b.duration = td;
    a2b.interruptionSource = TransitionInterruptionSource.CurrentStateThenNextState; // ⚠️ 打断保护不可缺少
    a2b.AddCondition(AnimatorConditionMode.Equals, (float)iv, "taozhuang");

    // B → C: 播完自动进(HasExitTime)
    var b2c = B.AddTransition(C);
    b2c.hasExitTime = true; b2c.exitTime = 1f; b2c.hasFixedDuration = true; b2c.duration = 0f;

    // C → D: Int != N
    var c2d = C.AddTransition(D);
    c2d.hasExitTime = false; c2d.hasFixedDuration = true; c2d.duration = td;
    c2d.interruptionSource = TransitionInterruptionSource.CurrentStateThenNextState; // ⚠️ 打断保护不可缺少
    c2d.AddCondition(AnimatorConditionMode.NotEqual, (float)iv, "taozhuang");

    // D → A: 播完自动回
    var d2a = D.AddTransition(A);
    d2a.hasExitTime = true; d2a.exitTime = 1f; d2a.hasFixedDuration = true; d2a.duration = 0f;

    // 打断保护: B → A (快速切走)
    var b2a = B.AddTransition(A);
    b2a.hasExitTime = false; b2a.hasFixedDuration = true; b2a.duration = 0f;
    b2a.AddCondition(AnimatorConditionMode.NotEqual, (float)iv, "taozhuang");

    // 打断保护: D → C (快速切回)
    var d2c = D.AddTransition(C);
    d2c.hasExitTime = false; d2c.hasFixedDuration = true; d2c.duration = 0f;
    d2c.AddCondition(AnimatorConditionMode.Equals, (float)iv, "taozhuang");

    ctrl.AddLayer(layer);
}
EditorUtility.SetDirty(ctrl);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
yer(layer); } EditorUtility.SetDirty(ctrl); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); ```### 不修改原有 Layer 的策略

当项目已有两状态 Dissolve 层(如 l18-l21)时,新 4 状态循环层不要删除或修改原有层,而是:

  1. 在 Controller 末尾添加新层(AddLayer 自动追加到最后)
  2. 新层用前缀命名(如 4S_)与原有层区分
  3. 新层使用跟原有层相同的 Int 参数(如 taozhuang
  4. 原有层保持不动,通过 Override 机制(高 index 胜出)让新层覆盖
  5. 如果原有层 writeDefaultValues=true,新层 writeDefaultValues=false 时,新层的终态会覆盖原有层的值

动画 clip 分析实战经验

当通过 MCP execute_code 检查 clip 内容时:

// 读取 clip 的所有曲线
var bindings = AnimationUtility.GetCurveBindings(clip);
foreach (var b in bindings) {
    var curve = AnimationUtility.GetEditorCurve(clip, b);
    // curve.keys 获取所有关键帧
    for (int ki = 0; ki < curve.keys.Length; ki++) {
        var k = curve.keys[ki];
        // k.time, k.value — 关键帧时间和值
    }
}

关键发现:现有的 OFF.anim 有 2 个关键帧—— - t=0: _DissolveParams.z=1.7 + _DissolvePos.y=-1 - t=0.01666667: m_IsActive=0 这意味着溶解在第 1 帧就完成(1/60s),真正的过渡效果来自 Animator 状态间的 Transition Duration

中文路径/名称导致的 JSON 序列化问题

当通过 MCP(JSON 协议)传回 C# 执行结果时,如果包含中文字符(如层名 Cloth_抹胸短裙_Dissolve),data["data"]["result"] 可能返回空。输出必须用纯 ASCII

  • 用索引代替中文名:"l" + i + " st=" + nst 而不是 l.name
  • 文件名用 Path.GetFileName() + 字节匹配识别
  • 状态机名用数字 + 简短 ASCII 前缀