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 名为 lilToon 或 Hidden/lilToonOutline)下,溶解代码被 shader variant 整体跳过(#if LIL_RENDER != 0)。此时无论 _DissolveParams.z 如何动画都无视觉效果,m_IsActive 的 true→false 突变直接显隐 → 表现为「秒切」。
必须同时满足 3 条件溶解才生效:
1. mat.shader.name 包含 Cutout 或 Transparent(不是裸 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 的致命陷阱
如果 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_DressRibbonCloth_DressSkirtCloth_PumpsCloth_SocksHair_AccessoriesHair_EarringCloth_Under_ShortsCloth_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();
- [[vrchat-dissolve-transition-system]] — 更完整的溶解换衣系统(含材质配置)
- [[vrchat-toggle-parameter-linkage]] — Toggle 参数链路
- [[vrchat-unity-mcp]] — Unity MCP 远程操作