跳转至

name: vrchat-4state-toggle-dissolve-system category: gaming description: VRChat 4状态循环换衣系统实战方案 — Int参数控制互斥 + Toggle菜单 + lilToon Dissolve。Cazalis zigai项目实战验证。 trigger: 用户要求渐变换衣、多套衣服互斥切换、溶解换装、4状态循环方案


VRChat 4状态循环换衣系统实战方案

基于 Cazalis zigai 项目实战验证(2026-05-13) 4套衣服:Origin(原皮) / Lujian(抹胸短裙) / Casual(休闲服) / Cardigan(开衫毛衣)

核心架构

每套衣服独立一个 Animator Layer(4状态循环)
一个 Int 参数(taozhuang)控制所有衣服互斥
菜单用 Toggle (type=102) 设 Int 值 — 不用 Button (type=101)
writeDefaultValues: A=true / B,C,D=false(A 必须用 Hide clip 恒定值,wd=true 才能安全归零)

关键教训

⚠️ 必须用 Toggle 不要用 Button

  • Button (type=101):脉冲触发,按下设值后立即恢复默认值 → 动画播不到1秒就跳回
  • Toggle (type=102):ON 时设 value,OFF 时设 0,保持状态 → 动画能正常保持
  • 如果多个 Toggle 绑定同一个 Int 参数,Toggle 切换 ON/OFF 时会互相写值,但 Int 互斥逻辑能处理

值映射

taozhuang Int 参数 (saved=true, default=0)
  0 = 原皮 (Origin)
  1 = 抹胸短裙 (Lujian)
  2 = 休闲服 (Casual)
  3 = 开衫毛衣 (Cardigan)
  4 = 全脱(可选)

动画 Clip 结构

ON.anim(单帧)

  • 路径:Assets/Cazalis/Animation/FX/xxx_ON.anim
  • 长度:0s(1帧,t=0)
  • 曲线:控制所有部件的 m_IsActive=1 + _DissolveParams.z=-0.5 + _DissolveParams.x=2(点形)
  • Origin 额外包含:Body_Base.blendShape.Foot_Heel = 100
  • 4套衣服都控相同的部件列表(Hair_Accessories, Cloth_Under_Shorts, Cloth_Socks, Cloth_Pumps, Cloth_HairRibbon, Cloth_DressSkirt, Cloth_DressRibbon, Cloth_Dress, Hair_Earring + 各套装独有部件)

OFF.anim(2帧)

  • 长度:0.01666667s (1/60s)
  • t=0:_DissolveParams.z=1.7 + _DissolvePos.y=-1
  • t=0.01666667:m_IsActive=0
  • 真实的溶解过渡效果靠 Animator 状态间的 Transition Duration 实现 issolvePos.y=-1`
  • t=0.01666667:m_IsActive=0
  • 真实的溶解过渡效果靠 Animator 状态间的 Transition Duration 实现## Animator Layer 结构(4状态循环)
每个 Layer: 4S_XXX (Override, weight=1)

            Entry
    ┌─────────────────┐
    │  A_Closed_XXX   │  ← motion: Hide.anim (wd=true) ★ 必须用 Hide clip,不要用 OFF clip
    └────────┬────────┘
             │ taozhuang == N  (transition duration=1s, interruptionSource=CurrentStateThenNextState)
    ┌─────────────────┐
    │ B_Appearing_XXX │  ← motion: ON.anim (wd=false)
    └────────┬────────┘
     HasExitTime=1 (exitTime=1f, duration=0)  → 自动进C
    ┌─────────────────┐
    │  C_Opened_XXX   │  ← motion: ON.anim (wd=false)
    └────────┬────────┘
             │ taozhuang != N  (transition duration=1s, interruptionSource=CurrentStateThenNextState)
    ┌───────────────────┐
    │ D_Disappearing_XXX│  ← motion: OFF.anim (wd=false)
    └────────┬──────────┘
    HasExitTime=1 (exitTime=1f, duration=0)  → 自动回A

打断保护

⚠️ 非常重要:在有 duration 的 transition 上必须设 interruptionSource=CurrentStateThenNextState

在 A→B 和 C→D 两个有 duration 的 transition 上,必须设置 interruptionSource = CurrentStateThenNextState,否则打断过渡(B→A, D→C)不会生效!因为 Unity 的 interruptionSource 默认是 None,意味着 transition 播放期间不会检查其他 transition 条件。

详见 wiki Ch110 深度分析。

当前状态 条件 跳转到 效果
B (出现中) taozhuang != N A (关闭) 快速切走时立即隐藏
D (消失中) taozhuang == N C (开启) 快速切回时立即显示
(出现中) taozhuang != N A (关闭) 快速切走时立即隐藏
D (消失中) taozhuang == N C (开启) 快速切回时立即显示

当出现「溶解秒切,无渐变」时,按以下顺序排查(不要先怀疑 transition 配置!)

优先级 1:材质 shader variant 是否支持溶解(90% 的根因)

lilToon 在 Opaque 模式(_TransparentMode=0 + shader 名为 lilToonHidden/lilToonOutline)下,溶解代码被 shader variant 整体跳过#if LIL_RENDER != 0)。此时无论 _DissolveParams.z 如何动画都无视觉效果,m_IsActive 的 true→false 突变直接显隐 → 表现为「秒切」。

必须同时满足 3 条件溶解才生效: 1. mat.shader.name 包含 CutoutTransparent(不是裸 lilToon/lilToonOutline) 2. mat.GetFloat("_TransparentMode") != 0(1=Cutout, 2=Transparent) 3. mat.IsKeywordEnabled("GEOM_TYPE_BRANCH_DETAIL") = true 4. mat.GetVector("_DissolveParams").x == 2

排查脚本见 liltoon-dissolve-material-batch skill 的「验证代码」。

优先级 2:clip 是否包含 _DissolveParams.z 动画曲线 - 检查 AnimationUtility.GetCurveBindings(clip) 是否包含 material._DissolveParams.z - 检查关键帧值:ON=-0.5, OFF=1.7

优先级 3:Animator transition 配置 - 已实测的多个正常工作的 4S 层:interruptionSource 可以是 None(0)或 SourceThenDestination(2 in Unity 2022),两者都正常 - duration 必须 > 0(典型 1.0~1.2s) - hasFixedDuration = true

⚠️ 反向归纳禁令:发现某层是「少数派」配置时,先确认多数派配置是什么、是否也都正常工作,再判断少数派是否异常。不要看见 2 个层用 A 配置、1 个层用 B 配置就立刻假设 B 是错的。

旧错误结论已废弃(曾误认为 interruptionSource = None 是必须,实测发现 None 和 SourceThenDestination 都能正常工作)。

AnimatorConditionMode 枚举值 interruptionSource = None` 是必须,实测发现 None 和 SourceThenDestination 都能正常工作)。

AnimatorConditionMode 枚举值| 数值 | 模式 | 说明 | |------|------|------| | 1 | Equals (==) | 等于阈值 | | 2 | NotEqual (!=) | 不等于阈值 | | 3 | Greater (>) | 大于阈值 | | 4 | Less (<) | 小于阈值 | | 5 | GreaterOrEqual (>=) | 大于等于阈值 | | 6 | LessOrEqual (<=) | 小于等于阈值 | | 7 | Always | 无条件触发 | 小于阈值 | | 5 | GreaterOrEqual (>=) | 大于等于阈值 | | 6 | LessOrEqual (<=) | 小于等于阈值 | | 7 | Always | 无条件触发 |### ⚠️ Hide Clip 是必需的(不是可选的)

A_Closed 状态必须使用独立的 Hide clip,不能使用 OFF clip 代替。原因:

  • Hide clip:1 帧恒定值(z=1.7, x=2, y=0, w=0.1, m_IsActive=0),只有 1 个关键帧。wd=true 进入时直接设好终态。
  • OFF clip:2 帧过渡值(t=0 z=1.7, t=0.0167 m_IsActive=0)。如果用 OFF clip 做 A 的 motion,wd=true 会播放 OFF clip 的完整过渡(虽然只有 1/60s 但可能导致短暂抖动)。
  • 直接后果:如果 A 用 OFF clip 替换 Hide clip,首次进入 A 或从 D→A 过渡结束时,可能看到 z 值短暂跳跃(因为 OFF clip 内 m_IsActive=0 在 t=0.0167 才生效)。

推荐做法:每套衣服创建 4 个 clip:ON.anim, OFF.anim, Hide.anim, Show.anim(其中 Show.anim 等同于 ON.anim,用于需要独立保持 clip 的场景)。

状态 clip 用途
A_Closed Hide.anim z=1.7 恒定(隐藏),m_IsActive=0
B_Appearing ON.anim z=-0.5 恒定(显示)+ m_IsActive=1
C_Opened ON.anim 或 Show.anim 与 B 共用 ON 或独立 Show clip
D_Disappearing OFF.anim z=1.7 + m_IsActive=0(2 帧过渡)

每个 Int 值触发的状态变化

taozhuang 从 0→1:

Layer 旧状态 触发 新状态
4S_Origin C_Opened (显示) taozhuang != 0 D→A (隐藏)
4S_Lujian A_Closed (隐藏) taozhuang == 1 B→C (显示)
4S_Casual A_Closed (隐藏) 无变化 保持A
4S_Cardigan A_Closed (隐藏) 无变化 保持A

为什么不会打架:A_Closed 只有 taozhuang == N 一条出去的路,不会被 != 误触发。 ardigan | A_Closed (隐藏) | 无变化 | 保持A |

为什么不会打架:A_Closed 只有 taozhuang == N 一条出去的路,不会被 != 误触发。### ⚠️ 中间态问题:Toggle OFF→写0→ON→写value

切换套装时(如从 Origin 切到 Lujian),两个事件在同一帧发生: 1. Origin 的 Toggle OFF → taozhuang = 0(中间态!) 2. Lujian 的 Toggle ON → taozhuang = 1(最终值)

taozhuang 短暂变为 0 再变为 1,这是正常的 VRChat 行为: - 本地:两个 SetFloat 在同一帧执行,最终值(1)胜出,Animator 按最终值评估状态 - 远程客户端:网络同步可能只看到最终值,或看到短暂 0(取决于同步帧率) - 肉眼可见性:通常 < 1 帧(0.016s),不可见。但如果 4S 层 C→D transition 有 duration,短暂触发可能产生可见闪烁。 - 缓解:打断保护过渡(B→A, D→C)设 duration=0 可减少影响

菜单设置

  • 路径:Assets/AvatarData/.../Menu/Wardrobe/ClothType_未分类.asset
  • 每个按钮:type=102 (Toggle), param=taozhuang, val=0/1/2/3/4
  • 根菜单:Assets/Cazalis/Animation/EXMenu/Cazalis_Menu_Modified.asset
  • 入口:根菜单 control "换装" → 子菜单 ClothType_未分类

如果 SubMenu 入口("换装"按钮)设置了 parameter.name = "taozhuang": - 进入子菜单时写 taozhuang = control.value(通常是 0) - 退出子菜单时写 taozhuang = 0 → 覆盖了用户通过 Toggle 选择的值! - 结果:用户在子菜单内选了套装,退出后 taozhuang 被恢复为 0 → 触发 Origin 层

必须确保:SubMenu 入口的 parameter.name 为空(不绑定)。 如果非要设,用一个独立的 Bool 参数(不是 taozhuang)。

⚠️ Toggle value=0 永远不工作

如果某件衣服的 Toggle 设 control.value = 0: - Toggle 逻辑:currentVal == 0 → 已 ON → 点击 OFF → 写 0 → 值仍 0 → 显示 ON → 循环 - 用 value=1 的 Toggle,对应 taozhuang=1(实际衣服索引 1)

参数配置

  • VRCExpressionParameters:Assets/Cazalis/Animation/EXMenu/Cazalis_Parameter_Modified.asset
  • taozhuang:Int, saved=true, default=0
  • FX Controller:Assets/Cazalis/Animation/Animator/Cazalis_FX_Modified.controller
  • 4S_ 层在 controller 末尾(l24-l27,但重建后会变化) s/Cazalis/Animation/Animator/Cazalis_FX_Modified.controller`
  • 4S_ 层在 controller 末尾(l24-l27,但重建后会变化)## ON/OFF Clip 录制的属性

每套衣服控制以下 GameObject:

公共部件(所有套装)

  • Cloth_Dress (m_IsActive + _DissolveParams)
  • Cloth_DressRibbon
  • Cloth_DressSkirt
  • Cloth_Pumps
  • Cloth_Socks
  • Hair_Accessories
  • Hair_Earring
  • Cloth_Under_Shorts
  • Cloth_HairRibbon

Origin 独有

  • Cazalis4 Variant/ 系列(Garter, Niso, Niso_Lace, Outer, Shoes, Shoulder, Skirt, SkirtUnder, Choker, Necklace, Arm)

Lujian(抹胸短裙)独有

  • 同上 Cazalis4 Variant 系列

Casual(休闲服)独有

  • SilentSkinPeek_Cazalis_Stripe_White/ 系列

Cardigan(开衫毛衣)独有

  • Cloud/ 系列(Upper_Leg_Ribbon, Tops_Ribbon, Tops, Pants, Outer_Ribbon, Outer, Boots)
  • Cloth_Under_Bra

Dissolve 材质配置

  • 渲染模式:Cutout(Hidden/lilToonCutout)或 TwoPass Transparent
  • 溶解方式:UV
  • 形状:点 (Point, _DissolveParams.x=2)
  • 坐标:X=0, Y=1 (_DissolvePos)
  • 噪点图:Assets/Cazalis/Cazalis_Reflection_Noise
  • 噪点强度:0.3
  • 纹理颜色:#387AE9
  • 边界可见:-0.5(完全显现)
  • 边界溶解:1.7(完全溶解)
  • Shader Keyword:GEOM_TYPE_BRANCH_DETAIL
  • _DissolveParams 格式:{r: 2, g: 0, b: -0.5(default)/1.7(dissolved), a: 0} EOM_TYPE_BRANCH_DETAIL`
  • _DissolveParams 格式:{r: 2, g: 0, b: -0.5(default)/1.7(dissolved), a: 0}## C# 创建 4 状态循环的完整模板

var ctrl = AssetDatabase.LoadAssetAtPath<AnimatorController>("path/to/FX.controller");
float td = 1.0f;

string[][] sets = new string[][] {
    new string[] {"Origin", "0", "path/to/ON.anim", "path/to/OFF.anim"},
    new string[] {"Lujian", "1", "path/to/ON.anim", "path/to/OFF.anim"},
    // ...
};

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]);

    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;

    var hideC = AssetDatabase.LoadAssetAtPath<AnimationClip>(set[2].Replace("ON.anim", "Hide.anim")); // Hide clip 或 fallback
    var A = sm.AddState("A_Closed_" + nm); A.motion = hideC != null ? hideC : ofC; A.writeDefaultValues = true;  // A 用 Hide clip(wd=true 归零安全)
    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;

    sm.AddEntryTransition(A);
D_Disappearing_" + nm); D.motion = ofC; D.writeDefaultValues = false;

    sm.AddEntryTransition(A);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");

    var b2c = B.AddTransition(C);
    b2c.hasExitTime = true; b2c.exitTime = 1f; b2c.hasFixedDuration = true; b2c.duration = 0f;

    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");

    var d2a = D.AddTransition(A);
    d2a.hasExitTime = true; d2a.exitTime = 1f; d2a.hasFixedDuration = true; d2a.duration = 0f;

    var b2a = B.AddTransition(A);
    b2a.hasExitTime = false; b2a.hasFixedDuration = true; b2a.duration = 0f;
    b2a.AddCondition(AnimatorConditionMode.NotEqual, (float)iv, "taozhuang");

    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(); ```## 相关技能

  • [[vrchat-dissolve-transition-system]] — 更完整的溶解换衣系统(含材质配置)
  • [[vrchat-toggle-parameter-linkage]] — Toggle 参数链路
  • [[vrchat-unity-mcp]] — Unity MCP 远程操作