VRChat 服装穿脱开关 + 溶解动画 + 多套换装系统
整合版本: 2026-05-09
适用对象: 有 VRChat 模体修改需求的用户
核心功能: 内衣/内裤/整套衣服溶解穿脱 + 多套服装切换 + 第三方发型/衣服整合
目录
- 系统架构总览
- 溶解动画核心原理
- 材质隔离(必须!)
- 内衣/内裤 Toggle 实现
- 整套衣服穿透 Toggle
- 多套服装切换系统
- 非本素体发型/衣服整合
- Expression Menu 配置
- Animator 状态机设计
- 常见坑点与排错
- MCP 操作流程
1. 系统架构总览
1.1 目标功能
| 功能 | 实现方式 | 关键技术 |
|---|---|---|
| 内衣穿脱 | lilToon 溶解动画 | _DissolveParams.z (Progress) |
| 内裤穿脱 | lilToon 溶解动画 | _DissolveParams.z (Progress) |
| 整套衣服穿透 | lilToon 溶解动画 | _DissolveParams.z (Progress) |
| 多套服装切换 | MA MenuItem Toggle + 独立材质 | 材质隔离 + 参数唯一命名 |
| 第三方发型 | Parent to Bone + 独立 PhysBone | 骨骼挂载 + PhysBone 配置 |
| 第三方衣服 | Parent to Bone + 独立材质 | 骨骼挂载 + 材质隔离 |
1.2 核心原则
🔴 黄金法则:ObjectToggle 与溶解动画不能共存!
ObjectToggle 会瞬间设置
GameObject.active = false,溶解动画来不及播放。必须仅用 Animator 参数驱动_DissolveParams.z。
1.3 系统流程图
用户点击 VRChat 菜单按钮
↓
MA MenuItem (Toggle, Bool 参数: "Cazalis_UW_Bra")
↓
NDMF 构建阶段注册参数
↓
Animator FX Layer 接收参数变化
↓
Transition (Duration=0.3s) 触发
↓
AnimationClip 播放: _DissolveParams.z: 0 → 1 (或反向)
↓
lilToon Shader 溶解: 噪点纹理驱动边缘溶解
↓
视觉效果: 粒子状消散 (约 0.3 秒)
2. 溶解动画核心原理
反向) ↓ lilToon Shader 溶解: 噪点纹理驱动边缘溶解 ↓ 视觉效果: 粒子状消散 (约 0.3 秒)
---
## 2. 溶解动画核心原理### 2.1 lilToon 溶解参数解析(源码验证)
lilToon 的溶解系统由 `_DissolveParams` (Vector4) 控制:
| 分量 | 字段 | 含义 | 正确值 | 动画目标? |
|------|------|------|--------|-----------|
| `.x` (R) | Mode | 溶解模式 | **必须为 1**(启用 2D Mask) | ❌ 固定 |
| `.y` (G) | SubMode | 子模式 | 0 | ❌ 固定 |
| `.z` (B) | **Progress** | **溶解进度** | **0 → 1** | ✅ **动画此值** |
| `.w` (A) | Softness | 边缘柔和度 | 0.1 | ❌ 固定 |
> ⚠️ **历史教训**:之前动画 `.x` 分量导致溶解模式被设为 0(关闭),动画静默失败!Progress 才是溶解进度。
### 2.2 溶解公式(lilToon 源码)
```hlsl
// 核心公式 (lil_common_functions.hlsl)
dissolveAlpha = 1.0 - saturate(abs(dissolveMaskVal + dissolveNoise - dissolveParams.b) / dissolveParams.a);
// 其中:
// dissolveParams.b = _DissolveParams.z (Progress/Threshold)
// dissolveParams.a = _DissolveParams.w (Softness)
// dissolveMaskVal = 噪点纹理值
// dissolveNoise = _DissolveNoiseStrength
2.3 材质必须配置
// ✅ 正确配置
mat.SetFloat("_TransparentMode", 1); // Cutout 模式
mat.SetVector("_DissolveParams", new Vector4(1, 0, 0, 0.1f)); // Mode=1, Progress=0
mat.SetTexture("_DissolveNoiseMask", dissolveNoiseTex); // 噪点纹理
// ❌ 错误配置
mat.SetFloat("_DissolveParams.x", 0); // Mode=0 = 关闭溶解!
2.4 噪点纹理要求
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 尺寸 | 256x256 或 512x512 | 足够覆盖 UV |
| 格式 | RGBA32 | 需要多通道支持边缘颜色 |
| 类型 | Perlin/Voronoi Noise | 避免规则图案 |
| 边缘 | Seamless 循环 | 防止接缝突兀 |
3. 材质隔离(必须!)
道支持边缘颜色 | | 类型 | Perlin/Voronoi Noise | 避免规则图案 | | 边缘 | Seamless 循环 | 防止接缝突兀 |
3. 材质隔离(必须!)### 3.1 为什么必须隔离
原版模型材质通常被多个部件共用。例如 Cazalis 的 Cazalis_Cloth.mat 被以下 7 个部件共享:
Cazalis_Cloth.mat (共享材质)
├─ Cloth_Dress (连衣裙)
├─ Cloth_DressRibbon (裙蝴蝶结)
├─ Cloth_DressSkirt (裙摆)
├─ Cloth_HairRibbon (头发蝴蝶结)
├─ Cloth_Socks (袜子)
├─ Cloth_Under_Bra (内衣) ← 需要独立溶解
└─ Cloth_Under_Shorts (内裤) ← 需要独立溶解
不隔离的后果: - 修改内衣溶解 → 裙子一起消失 - 修改裙子颜色 → 袜子也变色
3.2 隔离流程
// 1. 创建独立材质副本
var origMat = AssetDatabase.LoadAssetAtPath<Material>("Assets/.../Cazalis_Cloth.mat");
var braMat = Object.Instantiate(origMat); // 实例化(不是复制文件)
AssetDatabase.CreateAsset(braMat, "Assets/.../Cazalis_Underwear_Bra.mat");
var shortsMat = Object.Instantiate(origMat);
AssetDatabase.CreateAsset(shortsMat, "Assets/.../Cazalis_Underwear_Shorts.mat");
// 2. 分配到目标网格
var braMesh = root.transform.Find("Cloth_Under_Bra");
braMesh.GetComponent<SkinnedMeshRenderer>().sharedMaterial = braMat;
var shortsMesh = root.transform.Find("Cloth_Under_Shorts");
shortsMesh.GetComponent<SkinnedMeshRenderer>().sharedMaterial = shortsMat;
// 3. 配置溶解参数(仅影响独立副本)
braMat.SetFloat("_TransparentMode", 1);
braMat.SetVector("_DissolveParams", new Vector4(1, 0, 0, 0.1f));
braMat.SetTexture("_DissolveNoiseMask", dissolveNoiseTex);
4. 内衣/内裤 Toggle 实现
4.1 参数命名规范
✅ 推荐命名:
Cazalis_UW_Bra (内衣)
Cazalis_UW_Shorts (内裤)
Cazalis_Cloth_Dress (整套裙子)
❌ 禁止命名(与原版冲突):
Bra / Shorts / Dress / Toggle / IsToggled
❌ 禁止命名(与原版冲突): Bra / Shorts / Dress / Toggle / IsToggled ```### 4.2 AnimationClip 创建
需要 4 个 AnimationClip:
| 文件名 | 属性 | 值 | 含义 |
|---|---|---|---|
Bra_ON.anim |
_DissolveParams.z |
0 | 穿着可见 |
Bra_OFF.anim |
_DissolveParams.z |
1 | 完全溶解 |
Shorts_ON.anim |
_DissolveParams.z |
0 | 穿着可见 |
Shorts_OFF.anim |
_DissolveParams.z |
1 | 完全溶解 |
// 创建 Bra_ON.anim
var clip = new AnimationClip();
clip.name = "Bra_ON";
clip.legacy = false;
clip.frameRate = 60;
var curve = new AnimationCurve();
curve.AddKey(0f, 0f); // _DissolveParams.z = 0 (穿着)
curve.AddKey(0.3f, 0f); // 保持 0.3 秒
// 关键!绑定到 SkinnedMeshRenderer.material
var binding = new EditorCurveBinding {
path = "Cloth_Under_Bra", // GameObject 路径
type = typeof(SkinnedMeshRenderer), // 组件类型
propertyName = "material._DissolveParams.z" // 材质属性 + 分量
};
AnimationUtility.SetEditorCurve(clip, binding, curve);
AssetDatabase.CreateAsset(clip, "Assets/.../Dissolve/Bra_ON.anim");
4.3 Animator 状态机
Underwear_Dissolve Layer (weight=1.0, Additive)
├── Bra_ON ──[Bra=true, 0.3s]──▶ Bra_OFF
├── Bra_OFF ──[Bra=false, 0.3s]──▶ Bra_ON
├── Shorts_ON ──[Shorts=true, 0.3s]──▶ Shorts_OFF
└── Shorts_OFF ──[Shorts=false, 0.3s]──▶ Shorts_ON
// 创建独立 AnimatorController
var ctrl = new AnimatorController();
ctrl.AddParameter("Cazalis_UW_Bra", AnimatorControllerParameterType.Bool);
ctrl.AddParameter("Cazalis_UW_Shorts", AnimatorControllerParameterType.Bool);
// 添加 Layer (weight=1, Additive)
var layer = new AnimatorControllerLayer();
layer.name = "Underwear_Dissolve";
layer.defaultWeight = 1.0f;
layer.stateMachine = new AnimatorStateMachine();
// 添加状态和过渡...
ctrl.AddLayer(layer);
AssetDatabase.CreateAsset(ctrl, "Assets/.../UnderwearDissolve_Merge.controller");
// 添加 MA MergeAnimator 组件
var maType = System.Type.GetType("nadena.dev.modular_avatar.core.ModularAvatarMergeAnimator");
var mergeAnimator = root.AddComponent(maType);
var so = new UnityEditor.SerializedObject(mergeAnimator);
so.FindProperty("animator").objectReferenceValue = ctrl;
so.FindProperty("layerType").intValue = 4; // FX
so.FindProperty("type").intValue = 0; // Additive
so.FindProperty("pathMode").intValue = 0; // AvatarRoot
so.ApplyModifiedProperties();
5. 整套衣服穿透 Toggle
5.1 与内衣的区别
| 特性 | 内衣/内裤 | 整套衣服 |
|---|---|---|
| 目标对象 | 单一部件 | 多个部件组合 |
| 溶解时机 | 同时溶解 | 可选择同时或分组 |
| 切换服装 | 不涉及 | 涉及换装 |
| --- | ---------- | ---------- |
| 目标对象 | 单一部件 | 多个部件组合 |
| 溶解时机 | 同时溶解 | 可选择同时或分组 |
| 切换服装 | 不涉及 | 涉及换装 |
整套衣服的穿透开关实际上是一个"快速卸装"按钮,可以同时溶解多个部件:
// AnimationClip: AllCloth_OFF.anim
// 同时动画多个对象的 _DissolveParams.z
var binding1 = new EditorCurveBinding {
path = "Cloth_Dress",
type = typeof(SkinnedMeshRenderer),
propertyName = "material._DissolveParams.z"
};
AnimationUtility.SetEditorCurve(clip, binding1, curve); // 值从 0 → 1
var binding2 = new EditorCurveBinding {
path = "Cloth_DressRibbon",
type = typeof(SkinnedMeshRenderer),
propertyName = "material._DissolveParams.z"
};
AnimationUtility.SetEditorCurve(clip, binding2, curve);
6. 多套服装切换系统
6.1 设计思路
多套服装切换的关键是:同一时间只有一套服装的溶解值为 0(可见),其他套为 1(溶解)
服装套数命名示例:
Outfit_School (校服) → 参数: Outfit_School
Outfit_Cafe (咖啡装) → 参数: Outfit_Cafe
Outfit_Swimsuit (泳装) → 参数: Outfit_Swimsuit
6.2 状态机设计
Outfit_Selector Layer (weight=1.0)
├── School_Visible ──[School=true]──▶ School_Visible
├── School_Hidden ──[School=false]──▶ School_Hidden
│
├── Cafe_Visible ──[Cafe=true]──▶ Cafe_Visible
├── Cafe_Hidden ──[Cafe=false]──▶ Cafe_Hidden
│
└── Swimsuit_Visible ──[Swimsuit=true]──▶ Swimsuit_Visible
Swimsuit_Hidden ──[Swimsuit=false]──▶ Swimsuit_Hidden
切换逻辑: 点击 "校服" → 动画所有其他套溶解(1→1) + 校服显现(1→0)
6.3 材质管理
每套服装需要独立的材质实例:
7. 非本素体发型/衣服整合
件用) Outfit_Cafe_Mat (仅给咖啡装部件用) Outfit_Swimsuit_Mat (仅给泳装部件用)
第三方模型包 (FBX/VRM) ↓ 在 Unity 中导入 ↓ 找到发型/衣服网格 ↓ Parent to 目标骨骼 (例如 "J_Head" 或 "J_Spine") ↓ 调整位置/旋转/缩放 ↓ 配置 PhysBone (如果是头发/裙子) ↓ 配置材质 (独立副本) ↓ 集成到 Toggle 系统### 7.2 骨骼挂载
#### 发型挂载点
| 发型类型 | 挂载骨骼 | 说明 |
|----------|----------|------|
| 短发 | J_Head | 直接在头上 |
| 中长发 | J_Head 或 J_Sec_Fcl_HairBack1 | 考虑头发重量 |
| 长发 | J_Spine 或专用 Hair Root | 需要 PhysBone |
```csharp
// 找到目标骨骼
var targetBone = root.transform.Find("J_Head");
// 加载第三方发型
var hairPrefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/.../Hair_Pack/Hair_Long.prefab");
var hairInstance = Object.Instantiate(hairPrefab);
// 挂载到骨骼
hairInstance.transform.SetParent(targetBone);
// 调整本地位置(根据模型调整)
hairInstance.transform.localPosition = new Vector3(0, 0.05f, 0);
hairInstance.transform.localRotation = Quaternion.identity;
hairInstance.transform.localScale = Vector3.one;
| 衣服类型 | 挂载骨骼 | 说明 |
|---|---|---|
| 上衣 | J_Spine 或 J_Chest | 跟随躯干 |
| 裙子 | J_Hips 或专用 Skirt Root | 可能需要 PhysBone |
| 裤子 | J_Hips 或 J_UpperLeg | 跟随髋部 |
// 找到衣服挂载点
var clothRoot = root.transform.Find("J_Spine"); // 或专用 "Cloth_Root"
// 加载第三方衣服
var clothPrefab = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/.../Cloth_Pack/Dress.prefab");
var clothInstance = Object.Instantiate(clothPrefab);
clothInstance.transform.SetParent(clothRoot);
clothInstance.transform.localPosition = Vector3.zero;
clothInstance.transform.localRotation = Quaternion.identity;
7.3 PhysBone 配置(发型/裙子)
第三方发型/裙子通常没有 PhysBone,需要手动添加:
// 为头发添加 PhysBone
var hairRoot = hairInstance.transform.Find("Hair_Root"); // 或根对象
var pb = hairRoot.gameObject.AddComponent<VRCPhysBone>();
pb.rootBone = "Hair_Root";
pb.pull = 0.4f;
pb.spring = 0.35f;
pb.stiffness = 0.15f;
pb.gravity = 0.08f;
pb.immobileType = VRCPhysBoneBase.ImmobileType.None;
// 配置碰撞
pb.collisionFilter = new VRCPhysBoneBase.CollisionFilter {
colliders = new List<VRCPhysBoneColliderBase>(),
collisionCheck = true
};
整合后的发型/衣服需要加入现有的溶解 Toggle 系统:
// 1. 创建独立材质
var hairMat = Object.Instantiate(originalHairMat);
AssetDatabase.CreateAsset(hairMat, "Assets/.../Hair_Long_Mat.mat");
hairInstance.GetComponent<SkinnedMeshRenderer>().sharedMaterial = hairMat;
// 2. 配置溶解参数
hairMat.SetFloat("_TransparentMode", 1);
hairMat.SetVector("_DissolveParams", new Vector4(1, 0, 0, 0.1f));
hairMat.SetTexture("_DissolveNoiseMask", dissolveNoiseTex);
// 3. 添加到 Animator 层(复用现有层或新建)
// 动画绑定路径 = 相对于 AvatarRoot 的路径
// 例如: "Hair_Long/Hair Strand 1"
7.5 整合后验证清单
| 检查项 | 验证方法 |
|---|---|
| 骨骼绑定正确 | 移动头部,头发跟随 |
| 权重正常 | 不应有穿模或脱节 |
| PhysBone 物理 | 头发/裙子应自然摆动 |
| 溶解 Toggle | 点击按钮后正确溶解/显现 |
8. Expression Menu 配置
8.1 MA MenuItem 配置
通过 MCP 或手动添加 MA MenuItem:
// 创建菜单项
var menuItem = root.transform.Find("Outfit_Menu").gameObject;
var mi = menuItem.AddComponent<maMenuItemType);
// Toggle 配置
var so = new UnityEditor.SerializedObject(mi);
so.FindProperty("m_control.type").enumValueIndex = 1; // Toggle
so.FindProperty("m_parameter.name").stringValue = "Cazalis_UW_Bra";
so.FindProperty("label").stringValue = "内衣";
so.FindProperty("isDefault").boolValue = true; // 默认穿着
so.ApplyModifiedProperties();
根菜单
├── 内衣 (Toggle, Param=Cazalis_UW_Bra)
├── 内裤 (Toggle, Param=Cazalis_UW_Shorts)
├── 整套 (Toggle, Param=Cazalis_Cloth_All)
└── 服装切换 (SubMenu)
├── 校服 (Toggle, Param=Outfit_School)
├── 咖啡装 (Toggle, Param=Outfit_Cafe)
└── 泳装 (Toggle, Param=Outfit_Swimsuit)
8.3 参数容量
VRC Expression Parameters 上限 256 bits:
| 类型 | 大小 |
|---|---|
| Bool | 1 bit |
| Int | 8 bits |
| Float | 8 bits |
多套服装 + 内衣内裤需要计算好参数数量。
9. Animator 状态机设计
9.1 层结构
FX Controller
├── Base Layer (原版)
├── Underwear_Dissolve Layer (MA MergeAnimator, weight=1, Additive)
│ ├── Bra_ON / Bra_OFF
│ └── Shorts_ON / Shorts_OFF
└── Outfit_Selector Layer (MA MergeAnimator, weight=1, Additive)
├── School_ON / School_OFF
├── Cafe_ON / Cafe_OFF
└── Swimsuit_ON / Swimsuit_OFF
9.2 Transition 配置
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Has Exit Time | False | 点击立即响应 |
| Transition Duration | 0.3s | 溶解速度 |
| Fixed Duration | True | 恒定过渡时间 |
9.3 Default State
默认状态必须是 ON(穿着/可见):
10. 常见坑点与排错
10.1 溶解不生效
| 可能原因 | 检查方法 | 解决方案 |
|---|---|---|
_TransparentMode 不是 1 |
检查材质 Inspector | mat.SetFloat("_TransparentMode", 1) |
_DissolveParams.x 不是 1 |
检查材质 Inspector | _DissolveParams.x 必须为 1 |
| 动画绑定到错误的分量 | 检查 AnimationClip 曲线 | 绑定 _DissolveParams.z |
| 材质被其他对象共享 | 检查 SMR sharedMaterial | 材质隔离 |
| 到错误的分量 | 检查 AnimationClip 曲线 | 绑定 _DissolveParams.z |
| 材质被其他对象共享 | 检查 SMR sharedMaterial | 材质隔离 |
| 可能原因 | 解决方案 |
|---|---|
| 参数名与原版冲突 | 使用唯一前缀(如 Cazalis_) |
| MA MergeAnimator 未添加 | 正确添加组件并配置 |
| FX 层未启用 | 在 AvatarDescriptor 中启用 FX 层 |
| ObjectToggle 与溶解冲突 | 移除 ObjectToggle,仅用 Animator |
10.3 溶解方向反了
| 现象 | 原因 | 解决 |
|---|---|---|
| 点击后反而脱掉 | ON 动画的 _DissolveParams.z = 1 |
ON 应该是 0,OFF 应该是 1 |
| 默认状态是脱掉的 | Default State 设成了 OFF | Default State 设为 ON |
10.4 第三方发型/衣服位置错
| 问题 | 解决方案 |
|---|---|
| 位置偏移 | 调整 localPosition |
| 旋转错误 | 调整 localRotation |
| 缩放不对 | 调整 localScale 或重新导入 |
| 穿模 | 添加/调整 PhysBone Collider |
11. MCP 操作流程
11.1 前提条件
- Unity MCP 服务器已配置
- 会话已建立
- 目标 Avatar 已导入 Unity
11.2 标准操作顺序
1. 材质隔离
└── 创建材质副本 → 分配到目标网格 → 配置溶解参数
2. 创建噪点纹理
└── 生成 Perlin Noise PNG → 导入 Unity → 分配到材质
3. 创建 AnimationClip
└── 4个Clip (Bra/Shorts ON/OFF) → 绑定 _DissolveParams.z
4. 配置 Animator
└── 创建独立 Controller → 添加参数/层/状态/过渡
5. 添加 MA MergeAnimator
└── 指向独立 Controller → layerType=FX, type=Additive
6. 配置 Menu
└── 添加 MA MenuItem → 设置 Toggle 参数 → 设置 isDefault=true
7. 构建测试
└── NDMF Build → 上传 VRChat → 游戏内测试
11.3 MCP 关键代码片段
oggle 参数 → 设置 isDefault=true
- 构建测试
└── NDMF Build → 上传 VRChat → 游戏内测试
### 11.3 MCP 关键代码片段#### 材质隔离 ```csharp var orig = AssetDatabase.LoadAssetAtPath<Material>("Assets/.../Cazalis_Cloth.mat"); var braMat = Object.Instantiate(orig); AssetDatabase.CreateAsset(braMat, "Assets/.../Cazalis_Underwear_Bra.mat"); braMesh.GetComponent<SkinnedMeshRenderer>().sharedMaterial = braMat; braMat.SetFloat("_TransparentMode", 1); braMat.SetVector("_DissolveParams", new Vector4(1, 0, 0, 0.1f)); braMat.SetTexture("_DissolveNoiseMask", dissolveNoiseTex);
创建动画
var clip = new AnimationClip();
clip.name = "Bra_ON"; clip.legacy = false;
var curve = new AnimationCurve();
curve.AddKey(0f, 0f);
var binding = EditorCurveBinding.FloatCurve("Cloth_Under_Bra", typeof(SkinnedMeshRenderer), "material._DissolveParams.z");
AnimationUtility.SetEditorCurve(clip, binding, curve);
AssetDatabase.CreateAsset(clip, "Assets/.../Bra_ON.anim");
添加 MA MergeAnimator
var maType = System.Type.GetType("nadena.dev.modular_avatar.core.ModularAvatarMergeAnimator");
var merge = root.AddComponent(maType);
var so = new UnityEditor.SerializedObject(merge);
so.FindProperty("animator").objectReferenceValue = ctrl;
so.FindProperty("layerType").intValue = 4;
so.FindProperty("type").intValue = 0;
so.FindProperty("pathMode").intValue = 0;
so.ApplyModifiedProperties();
ype").intValue = 0; so.FindProperty("pathMode").intValue = 0; so.ApplyModifiedProperties(); ```
---## 附录 A:参考文档
附录 B:关键参数速查
| 用途 | 属性 | 值 |
|---|---|---|
| 启用溶解 | _DissolveParams.x |
1 |
| 溶解进度 | _DissolveParams.z |
0→1 |
| 边缘柔和 | _DissolveParams.w |
0.1 |
| Cutout 模式 | _TransparentMode |
1 |
| 噪点纹理 | _DissolveNoiseMask |
Texture |
| 噪点强度 | _DissolveNoiseStrength |
0.05~0.1 |
本文档整合自 VRChat 模体修改实战知识库 | 最后更新: 2026-05-09