特性驱动架构:从拖拽引用到声明式 Udon 开发
本文档系统分析 VVMW 工程中的 C# 特性体系,通过源码追踪揭示复杂特性如何从简单声明解析而来,最终实现声明式、高可维护性的 Udon 工程。
冷知识:VVMW工程也就是VizVid播放器~~~~
第1层:设计哲学层
1.1 VVMW 的设计目标与 Udon 约束
VVMW 是一个视频播放器系统,面临三个 Udon 核心约束:
┌─────────────────────────────────────────────────────────────────────┐
│ Udon 约束三角 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 约束1:拖拽引用繁琐 │
│ "Core 需要被 PlayerHandler、FrontendHandler、UIHandler... 引用" │
│ → 手动拖拽 10+ 个组件? │
│ │
│ ↓ │
│ │
│ 约束2:事件订阅重复 │
│ "每个组件都要监听 OnVideoPlay、OnVideoPause、OnVideoEnd..." │
│ → 每个组件写 3 遍相同的订阅代码? │
│ │
│ ↓ │
│ │
│ 约束3:序列化逻辑分散 │
│ "Loop 属性改变时需要同步、UI 需要更新、AudioLink 需要同步..." │
│ → 在 N 个地方写相同的同步逻辑? │
│ │
└─────────────────────────────────────────────────────────────────────┘
VVMW 的解法
| 约束 | 传统方案 | VVMW 方案 |
|---|---|---|
| 拖拽引用 | 手动拖拽 10+ 次 | [Locatable] + [Resolve] 自动定位 |
| 事件订阅 | 每个组件重复订阅 | [BindUdonSharpEvent] 声明式绑定 |
| 序列化逻辑 | 散落在各处 | [FieldChangeCallback] 属性化 |
核心思路:用声明替代命令,用标记替代调用。
1.2 声明式 vs 命令式:设计范式转变
命令式风格(传统 Udon)
public class MyUI : UdonSharpBehaviour {
public Core core; // 需要手动拖拽赋值
void Start() {
// 手动订阅事件
core.OnVideoPlay += OnVideoPlay;
core.OnVideoPause += OnVideoPause;
core.OnVideoEnd += OnVideoEnd;
}
public void OnVideoPlay() { /* 更新 UI */ }
public void OnVideoPause() { /* 更新 UI */ }
public void OnVideoEnd() { /* 更新 UI */ }
}
问题:
- 每增加一个监听事件,需要修改 Start 方法
- 组件未初始化时事件可能为 null
- 拖拽引用容易遗漏
声明式风格(VVMW)
public class MyUI : UdonSharpBehaviour {
[SerializeField, LocalizedLabel(Key = "JLChnToZ.VRC.VVMW.Core")]
[Resolve(nameof(handler) + "." + nameof(FrontendHandler.core), HideInInspectorIfResolvable = true)]
[Locatable, BindUdonSharpEvent(
nameof(OnVideoPlay),
nameof(OnVideoPause),
nameof(OnVideoEnd)
)] Core core;
// 事件方法声明即可,编辑器自动完成订阅
public void OnVideoPlay() { /* 更新 UI */ }
public void OnVideoPause() { /* 更新 UI */ }
public void OnVideoEnd() { /* 更新 UI */ }
}
优势:
- 引用赋值由编辑器自动完成
- 事件订阅由特性声明指定
- 代码意图清晰,易于维护
1.3 特性系统的三层架构
VVMW 的特性系统分为三个层次:
┌─────────────────────────────────────────────────────────────────────┐
│ 特性系统三层架构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 第1层:声明层(开发者编写) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ [Locatable] ← "我需要被自动定位" │ │
│ │ [Resolve(...)] ← "帮我解析这个引用" │ │
│ │ [BindUdonSharpEvent(...)]← "帮我订阅这些事件" │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 第2层:编辑器层(VRC.Foundation 实现) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ LocatableDrawer ← 检测拖拽 → 自动解析 → 填充引用 │ │
│ │ ResolveDrawer ← 查找 GameObject → 填充 _GO 字段 │ │
│ │ BindUdonSharpEventDrawer ← 反射方法 → 订阅事件 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 第3层:运行时层(Udon VM 执行) │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ [UdonSynced] + Property ← 网络同步 + 业务逻辑 │ │
│ │ FieldChangeCallback ← 值变化 → 回调触发 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
关键洞察:开发者的声明只是"意图",真正的绑定工作由编辑器在第2层完成。
第2层:核心特性详解与源码追踪
2.1 引用解析三剑客
2.1.1 Locatable — 自动定位
设计动机:解决预制体实例化后引用丢失的问题。
源码追踪:特性声明
来自 AutoPlayOnNearV2.cs 第 16-18 行:
[SerializeField, LocalizedLabel(Key = "JLChnToZ.VRC.VVMW.Core")]
[Resolve(nameof(handler) + "." + nameof(FrontendHandler.core), HideInInspectorIfResolvable = true)]
[Locatable] Core core;
源码追踪:特性定义
VRC.Foundation 中的 LocatableAttribute 定义:
// JLChnToZ.VRC.Foundation/LocatableAttribute.cs(VRC.Foundation 源码)
namespace JLChnToZ.VRC.Foundation {
public enum InstaniatePrefabHierachyPosition { Before, After }
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class LocatableAttribute : Attribute {
/// <summary>缺失时实例化的预制体路径</summary>
public string InstaniatePrefabPath { get; set; }
/// <summary>预制体实例化位置</summary>
public InstaniatePrefabHierachyPosition InstaniatePrefabPosition { get; set; }
}
}
参数解析:
| 参数 | 类型 | 说明 |
|---|---|---|
InstaniatePrefabPath |
string | 缺失时实例化的预制体路径 |
InstaniatePrefabPosition |
enum | 预制体实例化位置(Before/After) |
Udon 适配:
- UdonSharp 编译时字段自动解析
- 支持场景中定位和预制体实例化两种模式
2.1.2 Resolve — 引用解析
设计动机:替代手动拖拽引用,支持条件隐藏。
源码追踪:特性声明
来自 VideoPlayerHandler.cs 第 34、40-42 行:
// 解析自身组件
[SerializeField, HideInInspector, Resolve(".")] Animator animator;
[SerializeField, HideInInspector, Resolve(".")] BaseVRCVideoPlayer videoPlayer;
[SerializeField, HideInInspector, Resolve(".")] new Renderer renderer;
// 解析其他字段引用的 GameObject
[SerializeField, HideInInspector, Resolve(nameof(primaryAudioSource))] GameObject primaryAudioSourceGO;
源码追踪:复杂表达式解析
来自 AutoPlayOnNearV2.cs 第 17 行:
[Resolve(nameof(handler) + "." + nameof(FrontendHandler.core), HideInInspectorIfResolvable = true)]
这段代码解析了链式引用:
handler→FrontendHandler字段.core→FrontendHandler的core属性- 最终找到
Core实例
源码追踪:特性定义
// JLChnToZ.VRC.Foundation/ResolveAttribute.cs
namespace JLChnToZ.VRC.Foundation {
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class ResolveAttribute : Attribute {
/// <summary>要解析的路径,如 "." 表示自身,"nameof(core)" 表示某字段</summary>
public string Path { get; }
/// <summary>默认为 true,仅在字段为空时解析</summary>
public bool NullOnly { get; set; } = true;
/// <summary>解析成功后隐藏字段</summary>
public bool HideInInspectorIfResolvable { get; set; }
public ResolveAttribute(string path) => Path = path;
}
}
参数解析:
| 参数 | 类型 | 说明 |
|---|---|---|
Path |
string | 解析路径 |
NullOnly |
bool | 默认为 true,仅在字段为空时解析 |
HideInInspectorIfResolvable |
bool | 解析成功后隐藏字段 |
Udon 适配:
- Path 支持
nameof()表达式和字符串拼接 - 自动查找引用的 GameObject 并填充
2.1.3 BindUdonSharpEvent — UdonSharp 事件绑定
设计动机:自动化 UdonSharpBehaviour 事件订阅。
源码追踪:特性声明
来自 FrontendHandler.cs 第 20-34 行:
[LocalizedLabel(Key = "JLChnToZ.VRC.VVMW.Core"), Locatable, BindUdonSharpEvent(
nameof(_OnRangeLoopToggled),
nameof(_OnScreenSharedPropertiesChanged),
nameof(_OnSpeedChange),
nameof(_OnSyncOffsetChange),
nameof(_OnTitleData),
nameof(_OnVideoBeginLoad),
nameof(_OnVideoError),
nameof(_OnVolumeChange),
nameof(OnVideoReady),
nameof(OnVideoStart),
nameof(OnVideoPlay),
nameof(OnVideoPause),
nameof(OnVideoEnd)
), SingletonCoreControl] public Core core;
这个声明绑定了 13 个事件!如果用传统方式,需要写 13 次手动订阅。
源码追踪:特性定义
// JLChnToZ.VRC.Foundation/BindUdonSharpEventAttribute.cs
namespace JLChnToZ.VRC.Foundation {
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class BindUdonSharpEventAttribute : Attribute {
public string[] EventNames { get; }
public BindUdonSharpEventAttribute(params string[] eventNames) => EventNames = eventNames;
}
}
源码追踪:编辑器解析逻辑
VRC.Foundation 的编辑器会在运行时通过反射找到这些方法:
// 伪代码:VRC.Foundation 编辑器的 BindUdonSharpEvent 解析逻辑
void ProcessBindUdonSharpEvent(FieldInfo field, BindUdonSharpEventAttribute attr) {
var target = field.GetValue(component) as UdonSharpBehaviour;
foreach (var eventName in attr.EventNames) {
// 通过反射找到事件方法
var method = target.GetType().GetMethod(eventName,
BindingFlags.Public | BindingFlags.Instance);
if (method != null) {
// 自动订阅(通过 VRC.Foundation 的内部机制)
SubscribeToEvent(target, eventName);
}
}
}
Udon 适配:
- 事件方法必须声明为
public - 编辑器通过反射找到同名字方法并自动订阅
- 支持多个事件一次性绑定
2.2 属性化同步
FieldChangeCallback — 字段变化回调
设计动机:将业务逻辑封装在属性中,而非散落在各处。
源码追踪:特性声明
来自 Core.cs 第 49-50 行:
[UdonSynced, FieldChangeCallback(nameof(Loop))]
bool loop;
源码追踪:属性实现
来自 Core.cs 第 116-128 行:
public bool Loop {
get => loop;
set {
bool wasLoop = loop;
loop = value;
// 业务逻辑 1:同步到 Handler
if (Utilities.IsValid(activeHandler)) activeHandler.Loop = value;
// 业务逻辑 2:网络同步
if (synced && wasLoop != value && Networking.IsOwner(gameObject))
RequestSerialization();
// 业务逻辑 3:AudioLink 同步
#if AUDIOLINK_V1
if (IsAudioLinked()) audioLink.SetMediaLoop(value ? MediaLoop.LoopOne : MediaLoop.None);
#endif
}
}
特性定义:
// JLChnToZ.VRC.Foundation/FieldChangeCallbackAttribute.cs
namespace JLChnToZ.VRC.Foundation {
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false)]
public class FieldChangeCallbackAttribute : Attribute {
public string CallbackPropertyName { get; }
public FieldChangeCallbackAttribute(string callbackPropertyName) =>
CallbackPropertyName = callbackPropertyName;
}
}
原理图解:
┌─────────────────────────────────────────────────────────────────────┐
│ FieldChangeCallback 原理 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 传统方式: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ loop = true; ← 直接赋值,无业务逻辑 │ │
│ │ SyncToHandler(); ← 手动调用业务逻辑 │ │
│ │ RequestSerialization(); ← 手动网络同步 │ │
│ │ SyncAudioLink(); ← 手动 AudioLink 同步 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ FieldChangeCallback 方式: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Loop = true; ← 通过属性赋值 │ │
│ │ ↓ │ │
│ │ 属性 setter 被调用 │ │
│ │ ↓ │ │
│ │ 所有业务逻辑自动执行(同步 Handler、同步网络、同步 AudioLink) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 源码映射: │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ [UdonSynced, FieldChangeCallback(nameof(Loop))] │ │
│ │ bool loop; ← backing field,存储实际值 │ │
│ │ │ │
│ │ public bool Loop { ← 属性,触发 [FieldChangeCallback] │ │
│ │ get => loop; ← 读取 backing field │ │
│ │ set { ... } ← 赋值触发业务逻辑 │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Udon 适配:
[UdonSynced]字段 +[FieldChangeCallback]实现网络同步与业务逻辑联动- 赋值通过属性触发,确保业务逻辑不遗漏
2.3 BindEvent — Unity 事件绑定
设计动机:自动绑定 Unity 组件的事件(如 Button.onClick)到方法。
源码追踪:特性声明
[RequireComponent(typeof(Button))]
[BindEvent(typeof(Button), nameof(Button.onClick), nameof(_OnClick))]
public class ButtonEntry : UdonSharpBehaviour {
public void _OnClick() { ... }
}
源码追踪:完整使用实例
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using VRC.SDKBase;
using UdonSharp;
using JLChnToZ.VRC.Foundation;
using JLChnToZ.VRC.Foundation.I18N;
namespace JLChnToZ.VRC.VVMW {
[UdonBehaviourSyncMode(BehaviourSyncMode.NoVariableSync)]
[RequireComponent(typeof(Button))]
[DisallowMultipleComponent]
[BindEvent(typeof(Button), nameof(Button.onClick), nameof(_OnClick))]
[AddComponentMenu("VizVid/Components/Button Entry")]
[DefaultExecutionOrder(3)]
public class ButtonEntry : UdonSharpBehaviour {
LanguageManager manager;
[SerializeField, LocalizedLabel] GameObject buttonGO;
[SerializeField, HideInInspector, Resolve(nameof(buttonGO), NullOnly = false)] Text buttonText;
[SerializeField, HideInInspector, Resolve(nameof(buttonGO), NullOnly = false)] TextMeshProUGUI buttonTMPro;
/// <summary>回调目标</summary>
[LocalizedLabel] public UdonSharpBehaviour callbackTarget;
/// <summary>回调事件名</summary>
[LocalizedLabel] public string callbackEventName;
/// <summary>变量名</summary>
[LocalizedLabel] public string callbackVariableName;
/// <summary>自定义数据</summary>
[LocalizedLabel] public object callbackUserData;
/// <summary>本地化文本参数</summary>
public object[] Args {
get => args;
set { args = value; _OnLanguageChanged(); }
}
/// <summary>本地化文本键</summary>
public string Key {
get => key;
set { key = value; _OnLanguageChanged(); }
}
/// <summary>本地化文本</summary>
public string Text {
get {
if (Utilities.IsValid(buttonText)) return buttonText.text;
if (Utilities.IsValid(buttonTMPro)) return buttonTMPro.text;
return "";
}
}
/// <summary>语言管理器</summary>
public LanguageManager LanguageManager {
get => manager;
set {
manager = value;
if (Utilities.IsValid(manager)) manager._AddListener(this);
_OnLanguageChanged();
}
}
void _OnLanguageChanged() {
var result = manager.GetLocale(key);
if (Utilities.IsValid(args) && args.Length > 0)
result = string.Format(result, args);
if (Utilities.IsValid(buttonText)) buttonText.text = result;
if (Utilities.IsValid(buttonTMPro)) buttonTMPro.text = result;
}
/// <summary>按钮点击回调入口</summary>
public void _OnClick() {
if (!Utilities.IsValid(callbackTarget)) return;
if (!string.IsNullOrEmpty(callbackVariableName))
callbackTarget.SetProgramVariable(callbackVariableName, callbackUserData);
if (!string.IsNullOrEmpty(callbackEventName))
callbackTarget.SendCustomEvent(callbackEventName);
}
}
}
特性定义:
// JLChnToZ.VRC.Foundation/BindEventAttribute.cs
namespace JLChnToZ.VRC.Foundation {
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class BindEventAttribute : Attribute {
public Type ComponentType { get; }
public string EventMemberName { get; }
public string CallbackMethodName { get; }
public BindEventAttribute(Type componentType, string eventMemberName, string callbackMethodName) {
ComponentType = componentType;
EventMemberName = eventMemberName;
CallbackMethodName = callbackMethodName;
}
}
}
参数解析:
| 参数 | 类型 | 说明 |
|---|---|---|
| 第1参数 | Type | 目标组件类型(如 typeof(Button)) |
| 第2参数 | string | 事件成员名称(如 nameof(Button.onClick)) |
| 第3参数 | string | 回调方法名称 |
2.4 本地化支持
LocalizedLabel — 本地化标签
设计动机:支持多语言 UI 显示。
源码追踪:简单用法
来自 AbstractMediaPlayerHandler.cs 第 29-34 行:
[LocalizedLabel]
#if COMPILER_UDONSHARP
public
#else
[SerializeField] internal
#endif
string playerName = "";
源码追踪:指定 key
来自 FrontendHandler.cs 第 20 行:
[LocalizedLabel(Key = "JLChnToZ.VRC.VVMW.Core")] public Core core;
编译期条件(核心技巧):
[LocalizedLabel]
#if COMPILER_UDONSHARP
public // UdonSharp:公开访问
#else
[SerializeField] internal // 编辑器:可序列化
#endif
string playerName = "";
这确保了:
- UdonSharp 编译时:字段是
public,供其他 UdonSharpBehaviour 访问 - 编辑器编译时:字段是
[SerializeField] internal,可在 Inspector 中编辑
LocalizedEnum — 本地化枚举
源码实例(FrontendHandler.cs):
[SerializeField, LocalizedLabel, LocalizedEnum]
internal CoreMatchingStrategy coreControlStrategy = CoreMatchingStrategy.All;
2.5 编辑器增强
SingletonCoreControl — 单例核心控制
设计动机:编辑器据此提供特殊交互(如一键定位 Core)。
源码追踪:特性声明
来自 FrontendHandler.cs 第 34 行:
[SingletonCoreControl] public Core core;
源码追踪:编辑器类型发现
来自 EditorBase.cs 第 84-94 行:
if (type.IsSubclassOf(typeof(UdonSharpBehaviour))) {
var fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
if (fields.Length == 0) continue;
foreach (var field in fields) {
// 查找:Core 类型 + SingletonCoreControl 特性
if (field.FieldType == typeof(Core) &&
field.GetCustomAttribute<SingletonCoreControlAttribute>() != null) {
// 建立映射:脚本类型 → (Core字段, 编辑器类型)
controllableTypes[type] = (field, editorType);
break;
}
}
}
特性定义:
// JLChnToZ.VRC.Foundation/SingletonCoreControlAttribute.cs
namespace JLChnToZ.VRC.Foundation {
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)]
public class SingletonCoreControlAttribute : Attribute { }
}
HelpURL — 帮助链接
设计动机:在 Inspector 中添加文档链接。
源码实例(Core.cs 第 28 行):
[HelpURL("https://xtlcdn.github.io/VizVid/docs/#vvmw-game-object")]
public partial class Core : UdonSharpBehaviour { }
第3层:编译器条件架构
这是 VVMW 的精髓:同一份源码,两个身份。
3.1 COMPILER_UDONSHARP 的双模式编译
#if COMPILER_UDONSHARP
public // UdonSharp:运行时公开访问
#else
internal protected // 编辑器工具:程序集内访问
#endif
Core core;
编译目标:
┌─────────────────────────────────────────────────────────────────────┐
│ 双模式编译架构 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 源码文件 │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ #if COMPILER_UDONSHARP │ │
│ │ public Core core; ← 编译为 Udon Bytecode │ │
│ │ #else │ │
│ │ internal Core core; ← 编译为 .NET 程序集 │ │
│ │ #endif │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ↓ ↓ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Udon Bytecode │ │ .NET 程序集 │ │
│ │ (VRChat 运行时) │ │ (Unity 编辑器) │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
3.2 条件可见性模式
VVMW 定义了四种条件可见性模式:
// 模式 1:简单 public/internal
#if COMPILER_UDONSHARP
public
#else
internal
#endif
void MyMethod() { }
// 模式 2:带 protected
#if COMPILER_UDONSHARP
public
#else
internal protected
#endif
Core core;
// 模式 3:完全相反
#if COMPILER_UDONSHARP
internal
#else
public
#endif
Core core;
3.3 跨模式设计实例
VizVidBehaviour — 行为基类
来自 VizVidBehaviour.cs:
namespace JLChnToZ.VRC.VVMW {
// 运行时:普通 UdonSharpBehaviour 基类
public abstract class VizVidBehaviour : UdonSharpBehaviour {
public virtual void OnVideoStart() { }
public virtual void OnVideoPlay() { }
public virtual void OnVideoPause() { }
public virtual void OnVideoEnd() { }
}
#if UNITY_EDITOR && !COMPILER_UDONSHARP
// 编辑器:额外实现接口
public abstract partial class VizVidBehaviour : IVizVidCompoonent {
Core IVizVidCompoonent.Core => core;
}
#endif
}
AbstractMediaPlayerHandler — 媒体处理器
来自 AbstractMediaPlayerHandler.cs:
// 运行时:声明属性
#if COMPILER_UDONSHARP
public
#else
internal protected
#endif
override bool IsActive { get => isActive; set => isActive = value; }
// 编辑器:声明字段
#if COMPILER_UDONSHARP
[NonSerialized] public
#else
internal protected
#endif
Core core;
第4层:编辑器工具链
4.1 IPreprocessor — 场景预处理器
设计动机:在场景加载前执行数据预处理。
接口定义:
// JLChnToZ.VRC.Foundation/IPreprocessor.cs
namespace JLChnToZ.VRC.Foundation {
public interface IPreprocessor {
int Priority { get; }
void OnPreprocess(Scene scene);
}
}
源码追踪:VVMW 实现
来自 CoreConfigPreprocessor.cs 第 1-41 行:
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using UdonSharpEditor;
using JLChnToZ.VRC.Foundation.Editors;
namespace JLChnToZ.VRC.VVMW.Editors {
internal class CoreConfigPreprocessor : IPreprocessor {
public int Priority => 99; // 执行优先级
public void OnPreprocess(Scene scene) {
// 遍历场景中所有 Core 组件
foreach (var core in scene.IterateAllComponents<Core>()) {
// === 预处理 1:音量持久化 ===
#if VRC_ENABLE_PLAYER_PERSISTENCE
if (core.enablePersistence) {
var pathStack = new Stack<string>();
for (var transform = core.transform; transform; transform = transform.parent)
pathStack.Push(transform.name);
var path = string.Join("/", pathStack);
core.volumePersistenceKey = $"VVMW:{path}:Volume";
core.mutedPersistenceKey = $"VVMW:{path}:Muted";
}
#endif
// === 预处理 2:音频控制器收集 ===
var audioControllers = new List<AbstractAudioController>();
var audioSources = new HashSet<AudioSource>(core.audioSources);
foreach (var audioSource in core.audioSources) {
if (audioSource == null || !audioSource.TryGetComponent(out AbstractAudioController controller))
continue;
// 自动注入 Core 引用
controller.core = core;
audioSources.Remove(audioSource);
audioControllers.Add(controller);
// 同步到 Udon
UdonSharpEditorUtility.CopyProxyToUdon(controller);
}
core.audioSources = new AudioSource[audioSources.Count];
audioSources.CopyTo(core.audioSources);
core.audioControllers = audioControllers.ToArray();
// === 预处理 3:区域配置检测 ===
core.hasRegion = ActiveRegionConfig.GetRegionConfigs(core).Count > 0;
// === 预处理 4:默认音量处理 ===
if (!core.muteOnOutOfRange) core.outOfRangeVolume = 1f;
// === 预处理 5:同步到 Udon ===
UdonSharpEditorUtility.CopyProxyToUdon(core);
}
}
}
}
预处理流程图解:
┌─────────────────────────────────────────────────────────────────────┐
│ IPreprocessor 执行流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 场景加载前 │
│ │ │
│ ↓ │
│ VRCFoundation.Preprocessors.OnPreprocess(scene) │
│ │ │
│ ├── CoreConfigPreprocessor.Priority = 99 │
│ │ │ │
│ │ ↓ │
│ │ 1. 音量持久化路径生成 │
│ │ 2. 音频控制器收集 + Core 注入 │
│ │ 3. 区域配置检测 │
│ │ 4. 默认值处理 │
│ │ 5. CopyProxyToUdon 同步 │
│ │ │
│ └── [其他 Preprocessor...] │
│ │
│ ↓ │
│ 场景加载完成(数据已预处理) │
│ │
└─────────────────────────────────────────────────────────────────────┘
使用场景:
- 引用填充(查找依赖组件)
- 数据校验(确保配置完整)
- 性能优化(预计算、缓存)
4.2 CustomEditor — 自定义编辑器
设计动机:提供品牌化的 Inspector UI。
源码追踪:编辑器基类
来自 EditorBase.cs 第 14-224 行(核心部分):
namespace JLChnToZ.VRC.VVMW.Editors {
public abstract class VVMWEditorBase : Editor {
// === 静态资源 ===
const string bannerTextureGUID = "e8354bc2ac14e86498c0983daf484661";
const string iconGUID = "a24ecd1d23cca9e46871bc17dfe3bd46";
const string fontGUID = "088cf7162d0a81c46ad54028cfdcb382";
// === 类型映射 ===
protected static readonly Dictionary<Type, (FieldInfo fieldInfo, Type editorType)> controllableTypes = new Dictionary<Type, (FieldInfo, Type)>();
protected static readonly Dictionary<Type, Type> editorTypes = new Dictionary<Type, Type>();
// === 启动时类型收集 ===
[InitializeOnLoadMethod]
static void Init() {
AssemblyReloadEvents.afterAssemblyReload += GatherControlledTypes;
GatherControlledTypes();
}
// === 类型发现核心逻辑 ===
static void GatherControlledTypes() {
controllableTypes.Clear();
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
// 遍历所有程序集中的所有类型
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
foreach (var type in assembly.GetTypes()) {
if (type.IsAbstract) continue;
// === 查找 UdonSharpBehaviour ===
if (type.IsSubclassOf(typeof(UdonSharpBehaviour))) {
var fields = type.GetFields(flags);
foreach (var field in fields) {
// 查找:Core 类型 + SingletonCoreControl 特性
if (field.FieldType == typeof(Core) &&
field.GetCustomAttribute<SingletonCoreControlAttribute>() != null) {
editorTypes.TryGetValue(type, out var editorType);
controllableTypes[type] = (field, editorType);
break;
}
}
}
}
}
// === Banner 绘制 ===
protected static void DrawBanner() {
// 绘制 Logo
var bannerRect = new Rect(...);
GUI.DrawTexture(bannerRect, bannerTexture);
// 绘制版本号
var versionStyle = new GUIStyle(EditorStyles.whiteLargeLabel) { ... };
GUI.Label(new Rect(...), $"v{selfUpdater.CurrentVersion}", versionStyle);
}
// === Inspector GUI ===
public override void OnInspectorGUI() {
// 绘制 Banner
DrawBanner();
// UdonSharp 特殊处理
if (isUdonSharp) {
if (UdonSharpGUI.DrawDefaultUdonSharpBehaviourHeader(target, true, false))
return;
}
DrawInspectorGUI();
}
}
}
源码追踪:Core 编辑器实现
来自 CoreEditor.cs 第 138-150 行:
public override void DrawEmbeddedInspectorGUI() {
if (HandleDragDrop()) return;
var autoPlayControllerEditor = GetAutoPlayControllerEditor();
if (autoPlayControllerEditor != null)
autoPlayControllerEditor.serializedObject.Update();
DrawCommonSettings(autoPlayControllerEditor); // 通用设置
HorizontalLine();
DrawDefaultBehaviourSettings(autoPlayControllerEditor); // 默认行为
HorizontalLine();
DrawAdvancedSettings(autoPlayControllerEditor); // 高级设置
if (autoPlayControllerEditor != null)
autoPlayControllerEditor.serializedObject.ApplyModifiedProperties();
}
4.3 启动钩子
InitializeOnLoadMethod
设计动机:编辑器启动时执行初始化。
源码追踪
来自 EditorBase.cs 第 30-34 行:
[InitializeOnLoadMethod]
static void Init() {
// 订阅程序集重载事件
AssemblyReloadEvents.afterAssemblyReload += GatherControlledTypes;
// 执行类型收集
GatherControlledTypes();
}
DidReloadScripts
设计动机:脚本重新编译后执行刷新。
源码追踪(来自其他 VVMW 组件):
[DidReloadScripts]
public static void RefreshAll() {
regionConfigTable.Clear();
foreach (var config in FindObjectsOfType<ActiveRegionConfig>(true))
config.Register();
}
第6层:代码流程解析
本章通过完整流程图和伪代码,详细解析 VRC.Foundation 特性系统如何从"声明"变成"功能"。
6.1 完整生命周期总览
┌─────────────────────────────────────────────────────────────────────────────┐
│ VRC.Foundation 特性系统完整生命周期 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 阶段 1:编辑器启动(一次性) │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ [InitializeOnLoadMethod] │ │
│ │ ↓ │ │
│ │ GatherControlledTypes() │ │
│ │ ↓ │ │
│ │ 遍历所有程序集 → 找到 UdonSharpBehaviour → 查找 [SingletonCoreControl] │ │
│ │ ↓ │ │
│ │ 建立映射:controllableTypes[type] = (fieldInfo, editorType) │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 阶段 2:场景加载前(每个场景一次) │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ IPreprocessor.OnPreprocess(scene) │ │
│ │ ↓ │ │
│ │ CoreConfigPreprocessor.OnPreprocess() │ │
│ │ ↓ │ │
│ │ 遍历场景中所有 Core → 填充 audioControllers → CopyProxyToUdon() │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 阶段 3:组件检视(持续) │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ CustomEditor.OnInspectorGUI() │ │
│ │ ↓ │ │
│ │ DrawEmbeddedInspectorGUI() │ │
│ │ ↓ │ │
│ │ PropertyDrawer 处理特性字段 │ │
│ │ ├── LocatableDrawer → 拖拽检测 → 自动解析 │ │
│ │ ├── ResolveDrawer → 引用解析 → 填充 GameObject │ │
│ │ └── BindUdonSharpEventDrawer → 反射方法 → 订阅事件 │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 阶段 4:运行时执行(Udon VM) │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Udon Bytecode 执行 │ │
│ │ ↓ │ │
│ │ [UdonSynced] + [FieldChangeCallback] → 属性赋值触发业务逻辑 │ │
│ │ BindUdonSharpEvent 订阅的方法 → 事件触发时自动调用 │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6.2 GatherControlledTypes — 类型发现流程
6.2.1 源码追踪
来自 EditorBase.cs 第 30-34、55-94 行:
// === 启动时注册 ===
[InitializeOnLoadMethod]
static void Init() {
// 订阅程序集重载事件(脚本修改后自动重新收集)
AssemblyReloadEvents.afterAssemblyReload += GatherControlledTypes;
// 立即执行一次
GatherControlledTypes();
}
// === 类型收集核心逻辑 ===
static void GatherControlledTypes() {
controllableTypes.Clear();
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
// 遍历所有已加载的程序集
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
foreach (var type in assembly.GetTypes()) {
if (type.IsAbstract) continue;
// === 步骤 1:查找 UdonSharpBehaviour ===
if (type.IsSubclassOf(typeof(UdonSharpBehaviour))) {
var fields = type.GetFields(flags);
if (fields.Length == 0) continue;
// === 步骤 2:遍历字段查找 [SingletonCoreControl] ===
foreach (var field in fields) {
// 查找条件:Core 类型 + SingletonCoreControl 特性
if (field.FieldType == typeof(Core) &&
field.GetCustomAttribute<SingletonCoreControlAttribute>() != null) {
// === 步骤 3:建立映射 ===
editorTypes.TryGetValue(type, out var editorType);
controllableTypes[type] = (field, editorType);
break;
}
}
}
// === 步骤 4:收集 CustomEditor 类型 ===
if (type.IsSubclassOf(typeof(VVMWEditorBase))) {
var customEditorAttribute = type.GetCustomAttribute<CustomEditor>();
if (customEditorAttribute == null) continue;
var inspectedType = inspectedTypeField.GetValue(customEditorAttribute) as Type;
// 更新映射
if (controllableTypes.TryGetValue(inspectedType, out var value))
controllableTypes[inspectedType] = (value.fieldInfo, type);
editorTypes[inspectedType] = type;
// 处理继承关系
if ((bool)editorForChildClassesField.GetValue(customEditorAttribute)) {
inheritedEditors[inspectedType] = type;
// 为所有子类建立映射...
}
}
}
}
6.2.2 流程图解
┌─────────────────────────────────────────────────────────────────────┐
│ GatherControlledTypes 执行流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 启动时触发 │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ for each assembly in AppDomain.CurrentDomain.GetAssemblies() │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ for each type in assembly.GetTypes() │ │
│ │ │ │ │
│ │ ├───[否]─ type.IsSubclassOf(UdonSharpBehaviour)? ──┐ │ │
│ │ │ │ │
│ │ │ [是] │ │
│ │ │ │ │ │
│ │ │ ↓ │ │
│ │ │ for each field in type.GetFields() │ │
│ │ │ │ │ │
│ │ │ ├───[否]─ field 是 Core 类型? ──┐ │ │
│ │ │ │ │ │ │
│ │ │ │ [是] │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ ↓ │ │ │
│ │ │ │ [否]─ 有 SingletonCoreControl? ─→ 跳过 │ │
│ │ │ │ │ │ │
│ │ │ │ [是] │ │
│ │ │ │ │ │ │
│ │ │ │ ↓ │ │
│ │ │ │ 建立映射: controllableTypes[type] = ... │ │
│ │ │ │ │ │
│ │ └───[是]─ 是 VVMWEditorBase? │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ 收集 CustomEditor 类型 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ↓ │
│ 完成:controllableTypes 和 editorTypes 已填充 │
│ │
└─────────────────────────────────────────────────────────────────────┘
6.3 Locatable + Resolve — 引用解析完整流程
6.3.1 从声明到解析的完整链路
以 AutoPlayOnNearV2.cs 第 16-21 行为例:
// 开发者编写的声明
[SerializeField, LocalizedLabel(Key = "JLChnToZ.VRC.VVMW.Core")]
[Resolve(nameof(handler) + "." + nameof(FrontendHandler.core), HideInInspectorIfResolvable = true)]
[Locatable] Core core;
// 隐式声明(编辑器自动生成)
[SerializeField, HideInInspector, Resolve(nameof(core))] GameObject coreGO;
[SerializeField, HideInInspector, Resolve(nameof(handler))] GameObject handlerGO;
[SerializeField, HideInInspector, BindUdonSharpEvent(nameof(_OnMatchingCoresChanged))]
ActiveRegionManager activeRegionManager;
6.3.2 ResolveDrawer 解析流程
┌─────────────────────────────────────────────────────────────────────┐
│ ResolveDrawer 解析流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 属性绘制器被调用 │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ public override void OnGUI(Rect position, SerializedProperty│ │
│ │ property, GUIContent label) │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ var attribute = fieldInfo.GetCustomAttribute<ResolveAttribute>();│
│ │ │ │ │
│ │ ↓ │ │
│ │ // 获取 Path │ │
│ │ var path = attribute.Path; // 如 "handler.core" 或 "." │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ↓ │
│ 2. Path 解析 │
│ │ │
│ ├─── "." ───────────────────────────→ 解析自身组件 │
│ │ │
│ ├─── "nameof(core)" ────────────────→ 解析 core 字段 │
│ │ │
│ └─── "handler.core" ────────────────→ 链式解析(见下方) │
│ │
│ 3. 链式解析(handler.core) │
│ │ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 步骤 1: 解析 "handler" → FrontendHandler 实例 │ │
│ │ ↓ │ │
│ │ 步骤 2: 访问 ".core" → FrontendHandler.core → Core 实例 │ │
│ │ ↓ │ │
│ │ 步骤 3: 获取 Core 的 GameObject │ │
│ │ ↓ │ │
│ │ 步骤 4: 赋值给 coreGO 字段 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ↓ │
│ 4. 条件隐藏 │
│ │ │
│ if (attribute.HideInInspectorIfResolvable && resolved) │
│ editorGUI.BeginDisabledGroup(true); │
│ │ │
│ ↓ │
│ 5. 绘制 UI 并返回 │
│ │
└─────────────────────────────────────────────────────────────────────┘
6.3.3 LocatableDrawer 拖拽检测流程
┌─────────────────────────────────────────────────────────────────────┐
│ LocatableDrawer 拖拽检测流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ OnGUI() 被调用 │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ var e = Event.current; │ │
│ │ │ │
│ │ switch (e.type) { │ │
│ │ case EventType.DragUpdated: │ │
│ │ // 拖拽进入 │ │
│ │ if (IsValidDrag(e)) { │ │
│ │ DragAndDrop.visualMode = DragAndDropVisualMode.Link;│ │
│ │ e.Use(); │ │
│ │ } │ │
│ │ break; │ │
│ │ │ │
│ │ case EventType.DragPerform: │ │
│ │ // 拖拽释放 │ │
│ │ if (IsValidDrag(e)) { │ │
│ │ var target = DragAndDrop.objectReferences[0]; │ │
│ │ ResolveAndAssign(target); // 解析并赋值 │ │
│ │ e.Use(); │ │
│ │ } │ │
│ │ break; │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ↓ │
│ ResolveAndAssign(): │
│ │ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ if (NullOnly && currentValue != null) return; │ │
│ │ │ │
│ │ var resolved = Resolve(Path); // 解析引用 │ │
│ │ if (resolved != null) { │ │
│ │ serializedObject.Update(); │ │
│ │ property.objectReferenceValue = resolved; │ │
│ │ serializedObject.ApplyModifiedProperties(); │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
6.4 BindUdonSharpEvent — 事件订阅完整流程
6.4.1 源码追踪:特性处理逻辑
// 伪代码:VRC.Foundation 编辑器的 BindUdonSharpEvent 处理逻辑
void ProcessBindUdonSharpEvent(UdonSharpBehaviour component, FieldInfo field,
BindUdonSharpEventAttribute attr) {
// === 步骤 1:获取字段值(被绑定的 Core 实例) ===
var targetComponent = field.GetValue(component) as UdonSharpBehaviour;
if (targetComponent == null) return;
// === 步骤 2:获取事件发送者(Core 或其基类)===
var eventSender = GetEventSender(targetComponent);
if (eventSender == null) return;
// === 步骤 3:遍历特性中声明的所有事件名 ===
foreach (var eventName in attr.EventNames) {
// === 步骤 4:通过反射查找回调方法 ===
var callbackMethod = component.GetType().GetMethod(
eventName,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly
);
if (callbackMethod == null) {
Debug.LogWarning($"Method '{eventName}' not found on {component.GetType().Name}");
continue;
}
// === 步骤 5:注册到事件系统 ===
RegisterEventListener(eventSender, eventName, component, callbackMethod);
}
}
6.4.2 流程图解
┌─────────────────────────────────────────────────────────────────────┐
│ BindUdonSharpEvent 事件订阅完整流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 场景加载 / 组件添加 │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ for each UdonSharpBehaviour in scene │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ for each field in behaviour.GetType().GetFields() │ │
│ │ │ │ │
│ │ ├───[否]─ 有 BindUdonSharpEvent? ──┐ │ │
│ │ │ │ │ │
│ │ │ [是] │ │ │
│ │ │ │ │ │ │
│ │ │ ↓ │ │ │
│ │ │ 获取 attr.EventNames[] │ │
│ │ │ │ │ │ │
│ │ │ ↓ │ │ │
│ │ │ for each eventName in attr.EventNames │ │
│ │ │ │ │ │ │
│ │ │ ↓ │ │ │
│ │ │ var method = component.GetMethod(eventName) │ │
│ │ │ │ │ │ │
│ │ │ ├───[失败]─ 跳过 │ │ │
│ │ │ │ │ │ │
│ │ │ [成功] │ │ │
│ │ │ │ │ │ │
│ │ │ ↓ │ │ │
│ │ │ // 注册到 VRC.Foundation 事件系统 │ │
│ │ │ EventRegistry.Register(component, eventName, method) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ↓ │
│ 事件触发时(Core.SendEvent("_OnMatchingCoresChanged")) │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ var listeners = EventRegistry.GetListeners("_OnMatching...") │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ for each listener in listeners │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ listener.method.Invoke(listener.component, null); │ │
│ │ // → AutoPlayOnNearV2._OnMatchingCoresChanged() 被调用 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
6.5 FieldChangeCallback — 属性化同步流程
6.5.1 编译时 vs 运行时
┌─────────────────────────────────────────────────────────────────────┐
│ FieldChangeCallback 编译时 vs 运行时 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 编译时(UdonSharp 编译器) │ │
│ │ │ │
│ │ 源码: │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ [UdonSynced, FieldChangeCallback(nameof(Loop))] │ │ │
│ │ │ bool loop; │ │ │
│ │ │ │ │ │
│ │ │ public bool Loop { │ │ │
│ │ │ get => loop; │ │ │
│ │ │ set { │ │ │
│ │ │ loop = value; │ │ │
│ │ │ if (Utilities.IsValid(activeHandler)) │ │ │
│ │ │ activeHandler.Loop = value; │ │ │
│ │ │ if (synced && ... && Networking.IsOwner(...)) │ │ │
│ │ │ RequestSerialization(); │ │ │
│ │ │ } │ │ │
│ │ │ } │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ UdonSharp 编译器: │ │
│ │ - 识别 [UdonSynced] → 生成同步逻辑 │ │
│ │ - 识别 [FieldChangeCallback] → 生成 setter 包装 │ │
│ │ - 将所有对 loop 的赋值改为 Loop = value │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 运行时(Udon VM) │ │
│ │ │ │
│ │ 玩家 A 调用: Loop = true; │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ Udon Bytecode: │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ // setter 被调用 │ │ │
│ │ │ set_Loop(true); │ │ │
│ │ │ // setter 内部: │ │ │
│ │ │ loop = true; │ │ │
│ │ │ activeHandler.Loop = true; // 同步到视频处理器 │ │ │
│ │ │ if (owner) RequestSerialization(); // 网络同步 │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ RequestSerialization() → 序列化数据 → 发送给其他客户端 │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ 玩家 B 收到同步 → OnDeserialization() → Loop 属性被赋值 │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ B 的 activeHandler.Loop 也被设置为 true │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
6.5.2 核心代码映射
来自 Core.cs 第 49-50、116-128 行:
// === 声明 ===
[UdonSynced, FieldChangeCallback(nameof(Loop))]
bool loop;
// === 属性实现 ===
public bool Loop {
get => loop;
set {
bool wasLoop = loop;
loop = value;
// 业务逻辑 1:同步到视频处理器
if (Utilities.IsValid(activeHandler)) activeHandler.Loop = value;
// 业务逻辑 2:网络同步
if (synced && wasLoop != value && Networking.IsOwner(gameObject))
RequestSerialization();
// 业务逻辑 3:AudioLink 同步
#if AUDIOLINK_V1
if (IsAudioLinked()) audioLink.SetMediaLoop(value ? MediaLoop.LoopOne : MediaLoop.None);
#endif
}
}
编译后(伪代码):
// UdonSharp 生成的 setter
void set_Loop(bool value) {
// 调用属性 setter
this.Loop.set(value); // 触发完整业务逻辑
}
// 当需要同步 loop 时,直接调用 setter
void SyncLoopToNetwork() {
set_Loop(true); // 自动触发 RequestSerialization()
}
6.6 完整示例:AutoPlayOnNearV2 的特性解析全过程
6.6.1 原始声明
来自 AutoPlayOnNearV2.cs 第 16-22 行:
[SerializeField, LocalizedLabel(Key = "JLChnToZ.VRC.VVMW.Core")]
[Resolve(nameof(handler) + "." + nameof(FrontendHandler.core), HideInInspectorIfResolvable = true)]
[Locatable] Core core;
[SerializeField, HideInInspector, Resolve(nameof(core))] GameObject coreGO;
[SerializeField, HideInInspector, Resolve(nameof(handler))] GameObject handlerGO;
[SerializeField, HideInInspector, BindUdonSharpEvent(nameof(_OnMatchingCoresChanged))]
ActiveRegionManager activeRegionManager;
6.6.2 解析时间线
┌─────────────────────────────────────────────────────────────────────────────┐
│ AutoPlayOnNearV2 特性解析完整时间线 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ T0: 编辑器启动 │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ [InitializeOnLoadMethod] Init() │ │
│ │ ↓ │ │
│ │ GatherControlledTypes() │ │
│ │ ↓ │ │
│ │ 发现 AutoPlayOnNearV2 是 UdonSharpBehaviour │ │
│ │ ↓ │ │
│ │ 查找字段: │ │
│ │ - handler (FrontendHandler) + 无特性 → 跳过 │ │
│ │ - core (Core) + [Locatable] → 建立映射 │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ T1: 场景加载 │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ IPreprocessor.OnPreprocess(scene) │ │
│ │ ↓ │ │
│ │ CoreConfigPreprocessor.OnPreprocess() │ │
│ │ ↓ │ │
│ │ 为场景中每个 Core: │ │
│ │ - 收集 audioControllers │ │
│ │ - 检测 hasRegion │ │
│ │ - CopyProxyToUdon() │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ T2: AutoPlayOnNearV2 被添加到 GameObject │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ ResolveDrawer 处理 core 字段: │ │
│ │ - Path = "handler.core" │ │
│ │ - 链式解析: handler → FrontendHandler.core → Core │ │
│ │ - 赋值: core = resolvedCore │ │
│ │ ↓ │ │
│ │ ResolveDrawer 处理 coreGO 字段: │ │
│ │ - Path = "core" │ │
│ │ - 解析: core.gameObject │ │
│ │ - 赋值: coreGO = core.gameObject │ │
│ │ ↓ │ │
│ │ ResolveDrawer 处理 handlerGO 字段: │ │
│ │ - Path = "handler" │ │
│ │ - 解析: handler.gameObject │ │
│ │ - 赋值: handlerGO = handler.gameObject │ │
│ │ ↓ │ │
│ │ BindUdonSharpEventDrawer 处理 activeRegionManager: │ │
│ │ - eventName = "_OnMatchingCoresChanged" │ │
│ │ - 反射查找方法: AutoPlayOnNearV2._OnMatchingCoresChanged │ │
│ │ - 注册到事件系统 │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ T3: 运行时执行 │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ AutoPlayOnNearV2.OnEnable() │ │
│ │ ↓ │ │
│ │ isSynced = core.IsSynced; // 使用自动解析的 core 引用 │ │
│ │ ↓ │ │
│ │ ... │ │
│ │ ↓ │ │
│ │ ActiveRegionManager._UpdateMatching() │ │
│ │ ↓ │ │
│ │ SendEvent("_OnMatchingCoresChanged"); │ │
│ │ ↓ │ │
│ │ VRC.Foundation 事件系统 │ │
│ │ ↓ │ │
│ │ AutoPlayOnNearV2._OnMatchingCoresChanged() 被调用 │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
6.6.3 隐式字段的生成逻辑
// 开发者只需声明带特性的字段
[Locatable] Core core;
// 编辑器自动"生成"(或理解为处理)隐式字段
[SerializeField, HideInInspector, Resolve(nameof(core))] GameObject coreGO;
// 这个"生成"是在什么时机发生的?
//
// 答案:在 PropertyDrawer 处理时动态创建,而不是真正修改源文件。
//
// 机制:
// 1. EditorBase.GatherControlledTypes() 扫描所有类型
// 2. 发现 AutoPlayOnNearV2 有 [Locatable] Core core
// 3. 在编辑器中为其创建对应的 _GO 字段引用
// 4. PropertyDrawer 检测到 [Resolve(nameof(core))] GameObject coreGO
// 5. 解析 core 并赋值给 coreGO
6.7 场景预处理完整流程
6.7.1 源码追踪
来自 CoreConfigPreprocessor.cs 第 11-39 行:
public void OnPreprocess(Scene scene) {
// === 遍历场景中所有 Core 组件 ===
foreach (var core in scene.IterateAllComponents<Core>()) {
// === 预处理 1:音量持久化路径 ===
#if VRC_ENABLE_PLAYER_PERSISTENCE
if (core.enablePersistence) {
// 生成层级路径作为持久化 key 前缀
var pathStack = new Stack<string>();
for (var transform = core.transform; transform; transform = transform.parent)
pathStack.Push(transform.name);
var path = string.Join("/", pathStack);
core.volumePersistenceKey = $"VVMW:{path}:Volume";
core.mutedPersistenceKey = $"VVMW:{path}:Muted";
}
#endif
// === 预处理 2:音频控制器收集 ===
var audioControllers = new List<AbstractAudioController>();
var audioSources = new HashSet<AudioSource>(core.audioSources);
foreach (var audioSource in core.audioSources) {
if (audioSource == null ||
!audioSource.TryGetComponent(out AbstractAudioController controller))
continue;
// 自动注入 Core 引用
controller.core = core;
audioSources.Remove(audioSource);
audioControllers.Add(controller);
// 关键:将编辑器数据同步到 Udon
UdonSharpEditorUtility.CopyProxyToUdon(controller);
}
core.audioSources = new AudioSource[audioSources.Count];
audioSources.CopyTo(core.audioSources);
core.audioControllers = audioControllers.ToArray();
// === 预处理 3:区域配置检测 ===
core.hasRegion = ActiveRegionConfig.GetRegionConfigs(core).Count > 0;
// === 预处理 4:默认值处理 ===
if (!core.muteOnOutOfRange) core.outOfRangeVolume = 1f;
// === 预处理 5:同步到 Udon ===
UdonSharpEditorUtility.CopyProxyToUdon(core);
}
}
6.7.2 流程图解
┌─────────────────────────────────────────────────────────────────────┐
│ 场景预处理执行流程 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 场景加载前(Build 或 Play) │
│ │ │
│ ↓ │
│ VRCFoundation.Preprocessors.OnPreprocess(scene) │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ CoreConfigPreprocessor.OnPreprocess(scene) │ │
│ │ │ │ │
│ │ ↓ │ │
│ │ for each core in scene.IterateAllComponents<Core>() │ │
│ │ │ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ 步骤 1: 持久化路径生成 │ │ │
│ │ │ if (enablePersistence) │ │ │
│ │ │ path = "Parent/Child/Core" │ │ │
│ │ │ volumePersistenceKey = "VVMW:path:Volume" │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ 步骤 2: 音频控制器收集 │ │ │
│ │ │ audioControllers = [] │ │ │
│ │ │ for each audioSource in core.audioSources │ │ │
│ │ │ if (has AbstractAudioController) { │ │ │
│ │ │ controller.core = core │ │ │
│ │ │ audioControllers.Add(controller) │ │ │
│ │ │ CopyProxyToUdon(controller) │ │ │
│ │ │ } │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ 步骤 3: 区域配置检测 │ │ │
│ │ │ hasRegion = GetRegionConfigs(core).Count > 0 │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ 步骤 4: 同步到 Udon │ │ │
│ │ │ CopyProxyToUdon(core) │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ↓ │
│ 场景加载完成(所有数据已预处理) │
│ │
└─────────────────────────────────────────────────────────────────────┘
6.8 CopyProxyToUdon — 数据同步机制
6.8.1 机制解释
┌─────────────────────────────────────────────────────────────────────┐
│ CopyProxyToUdon 机制 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ UdonSharp 的双对象模型: │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Editor 中的对象 │ │
│ │ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ ProxyObject │ ←──→ │ UdonBehaviour │ │ │
│ │ │ (MonoBehaviour) │ │ (UdonSharp) │ │ │
│ │ │ │ │ │ │ │
│ │ │ core (字段) │ │ _core (Udon变量) │ │ │
│ │ │ audioControllers │ │ _audioControllers│ │ │
│ │ │ hasRegion │ │ _hasRegion │ │ │
│ │ └──────────────────┘ └──────────────────┘ │ │
│ │ ↑ ↑ │ │
│ │ │ │ │ │
│ │ └──── CopyProxyToUdon() ────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 为什么需要这个? │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 1. UdonSharp 字段在编辑器中显示为 Proxy 对象 │ │
│ │ 2. 预处理修改的是 Editor 中的数据 │ │
│ │ 3. 需要同步到 UdonBehaviour 的内部变量 │ │
│ │ 4. CopyProxyToUdon() 就是做这个同步 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
第5层:工程实践
5.1 特性组合公式
基础组合:Core 引用(完整版)
来自 AutoPlayOnNearV2.cs 第 16-21 行:
// 声明层:开发者编写的特性组合
[SerializeField, LocalizedLabel(Key = "JLChnToZ.VRC.VVMW.Core")]
[Resolve(nameof(handler) + "." + nameof(FrontendHandler.core), HideInInspectorIfResolvable = true)]
[Locatable] Core core;
// 隐藏的 GameObject 引用(自动生成)
[SerializeField, HideInInspector, Resolve(nameof(core))] GameObject coreGO;
[SerializeField, HideInInspector, Resolve(nameof(handler))] GameObject handlerGO;
[SerializeField, HideInInspector, BindUdonSharpEvent(nameof(_OnMatchingCoresChanged))]
ActiveRegionManager activeRegionManager;
解析:这个组合实际上是 5 个特性的协同工作:
[LocalizedLabel]— 本地化显示名称[Resolve(...)]— 解析链式引用handler.core[HideInInspectorIfResolvable = true]— 解析成功后隐藏[Locatable]— 支持自动定位[BindUdonSharpEvent(...)]— 自动订阅事件
简化组合:仅自动定位
[Locatable] public Core core;
扩展组合:带事件和配置
[SerializeField, LocalizedLabel]
[Locatable, BindUdonSharpEvent(
nameof(OnVideoPlay),
nameof(OnVideoPause),
nameof(OnVideoEnd)
)]
public Core core;
public void OnVideoPlay() { /* 处理播放 */ }
public void OnVideoPause() { /* 处理暂停 */ }
public void OnVideoEnd() { /* 处理结束 */ }
5.2 避坑指南
坑1:Udon 不支持反射
问题:BindUdonSharpEvent 依赖反射,但 Udon VM 不支持。
解决:特性仅在编辑器编译时生效,运行时事件通过 UdonSharp 生成的代码处理。
坑2:特性顺序敏感
问题:[Resolve] 必须在 [HideInInspector] 之后才能正确隐藏。
正确:
[SerializeField, HideInInspector, Resolve(nameof(core))] GameObject coreGO;
坑3:编译器符号不一致
问题:COMPILER_UDONSHARP 与 UDONSHARP 可能不同。
VVMW 用法:
#if COMPILER_UDONSHARP
public
#else
internal
#endif
坑4:编辑器代码无法访问 Udon 字段
问题:编辑器程序集看不到 Udon 字段。
解决:使用双模式声明,或通过 UdonSharpEditorUtility 访问。
坑5:Locatable 预制体路径错误
问题:InstaniatePrefabPath 路径不正确导致实例化失败。
检查:确保路径与 VPM 包中的实际路径匹配。
5.3 最简实现模板
模板1:声明式 UdonSharpBehaviour
using UnityEngine;
using UdonSharp;
using VRC.SDKBase;
using JLChnToZ.VRC.Foundation;
public class MyComponent : UdonSharpBehaviour {
// 声明:需要自动定位的 Core
[SerializeField, LocalizedLabel(Key = "MyProject.Core")]
[Locatable, BindUdonSharpEvent(
nameof(OnVideoPlay),
nameof(OnVideoPause)
)]
public Core core;
// 事件处理方法(声明即可,编辑器自动订阅)
public void OnVideoPlay() {
Debug.Log("Video playing");
}
public void OnVideoPause() {
Debug.Log("Video paused");
}
}
模板2:属性化同步字段
using UnityEngine;
using VRC.SDKBase;
using UdonSharp;
using JLChnToZ.VRC.Foundation;
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class MySyncComponent : UdonSharpBehaviour {
// 同步字段 + 回调
[UdonSynced, FieldChangeCallback(nameof(MyValue))]
private int myValue = 0;
public int MyValue {
get => myValue;
set {
if (myValue == value) return;
myValue = value;
OnValueChanged();
if (Networking.IsOwner(gameObject))
RequestSerialization();
}
}
void OnValueChanged() {
Debug.Log($"Value changed to: {myValue}");
}
}
模板3:Unity 事件绑定
using UnityEngine;
using UnityEngine.UI;
using UdonSharp;
using JLChnToZ.VRC.Foundation;
[RequireComponent(typeof(Button))]
[BindEvent(typeof(Button), nameof(Button.onClick), nameof(_OnClick))]
public class MyButtonHandler : UdonSharpBehaviour {
public void _OnClick() {
Debug.Log("Button clicked!");
}
}
模板4:编辑器工具类
#if UNITY_EDITOR
using UnityEditor;
[InitializeOnLoadMethod]
static class MyEditorInit {
static MyEditorInit() {
// 编辑器启动时执行
Debug.Log("Editor initialized");
}
}
#endif
模板5:场景预处理器
#if UNITY_EDITOR
using UnityEngine;
using UnityEngine.SceneManagement;
using JLChnToZ.VRC.Foundation;
public class MyPreprocessor : IPreprocessor {
public int Priority => 50; // 执行优先级,越小越早
public void OnPreprocess(Scene scene) {
foreach (var component in scene.IterateAllComponents<MyComponent>()) {
// 预处理逻辑
component.Initialize();
}
}
}
#endif
5.4 特性速查表
┌─────────────────────────────────────────────────────────────────────────┐
│ 特性速查表 │
├──────────────────┬──────────────────────────────────────────────────────┤
│ 特性 │ 用法 │
├──────────────────┼──────────────────────────────────────────────────────┤
│ LocalizedLabel │ [LocalizedLabel] / [LocalizedLabel(Key = "...")] │
│ LocalizedEnum │ [LocalizedEnum] │
│ Locatable │ [Locatable] / [Locatable(InstaniatePrefabPath=...)] │
│ Resolve │ [Resolve(".")] / [Resolve(nameof(field))] │
│ BindUdonSharpEvent│ [BindUdonSharpEvent(nameof(Method1), nameof(Method2))]│
│ BindEvent │ [BindEvent(typeof(Button), nameof(Button.onClick), │
│ │ nameof(OnClick))] │
│ FieldChangeCallback│ [FieldChangeCallback(nameof(Property))] │
│ SingletonCoreControl│ [SingletonCoreControl] │
│ HideInInspector │ [HideInInspector] │
│ HelpURL │ [HelpURL("https://...")] │
│ DefaultExecutionOrder│ [DefaultExecutionOrder(0)] │
├──────────────────┼──────────────────────────────────────────────────────┤
│ 编辑器特性 │ 用法 │
├──────────────────┼──────────────────────────────────────────────────────┤
│ InitializeOnLoadMethod│ [InitializeOnLoadMethod] │
│ DidReloadScripts │ [DidReloadScripts] │
│ CustomEditor │ [CustomEditor(typeof(Component))] │
│ IPreprocessor │ 实现接口 + 注册 │
└──────────────────┴──────────────────────────────────────────────────────┘
附录:VVMW 特性系统全景图
┌─────────────────────────────────────────────────────────────────────────┐
│ VRC.Foundation 特性系统 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 命名空间 │
│ ├── JLChnToZ.VRC.Foundation → 特性定义 + 核心功能 │
│ ├── JLChnToZ.VRC.Foundation.I18N → 本地化功能 │
│ └── JLChnToZ.VRC.Foundation.Editors → 编辑器工具 │
│ │
│ 特性分类 │
│ ├── 引用绑定 │
│ │ ├── [Locatable] 自动定位与实例化 │
│ │ ├── [Resolve] 引用解析 │
│ │ └── [BindUdonSharpEvent] UdonSharp 事件绑定 │
│ ├── 字段回调 │
│ │ └── [FieldChangeCallback] 字段变化回调 │
│ ├── 本地化 │
│ │ ├── [LocalizedLabel] 本地化标签 │
│ │ └── [LocalizedEnum] 本地化枚举 │
│ ├── 组件标记 │
│ │ └── [SingletonCoreControl] 单例核心控制 │
│ └── 编辑器工具 │
│ ├── IPreprocessor 场景预处理器 │
│ ├── InitializeOnLoadMethod 编辑器启动钩子 │
│ └── DidReloadScripts 脚本重载钩子 │
│ │
└─────────────────────────────────────────────────────────────────────────┘
附录:源码索引
本文档引用的 VVMW 源码文件:
| 文件 | 行号 | 引用内容 |
|---|---|---|
AutoPlayOnNearV2.cs |
16-22 | Locatable + Resolve + BindUdonSharpEvent 组合 |
VideoPlayerHandler.cs |
34, 40-42 | Resolve 解析自身组件 |
FrontendHandler.cs |
20-34 | BindUdonSharpEvent 13 事件绑定 |
Core.cs |
49-50, 116-128 | FieldChangeCallback 属性化同步 |
ButtonEntry.cs |
1-109 | BindEvent Unity 事件绑定完整示例 |
AbstractMediaPlayerHandler.cs |
29-34 | LocalizedLabel 编译期条件 |
EditorBase.cs |
14-224 | VVMWEditorBase 编辑器基类 |
CoreEditor.cs |
1-983 | Core 自定义编辑器完整实现 |
CoreConfigPreprocessor.cs |
1-41 | IPreprocessor 场景预处理 |
参考来源:VVMW 源码分析(Core.cs、FrontendHandler.cs、VideoPlayerHandler.cs、AbstractMediaPlayerHandler.cs、VizVidBehaviour.cs、ButtonEntry.cs、EditorBase.cs、CoreEditor.cs、CoreConfigPreprocessor.cs、AutoPlayOnNearV2.cs 等)
No comments to display
No comments to display