12. UdonSharp,长得像 C# 但它有脾气
第 11 章学的 C# 语法是标准的,能在任何 C# 项目里通用。但放进 VRChat 世界项目里,就会遇到一些奇怪现象:明明照着 C# 教程写的,编译报错;明明写法跟普通 Unity 项目一样,运行时行为不一样;明明项目里其它人都这么写过的工具方法,这里就是塞不进去。
这些不是读者的错,是 UdonSharp 自己的脾气。这一章把已知的脾气一条条摊开,写脚本时少一份惊吓。
为什么会有这些限制
Section titled “为什么会有这些限制”第 2 章铺过沙箱的来历:VRChat 客户端要执行陌生人写的代码,必须把可调用的 API 限制在一个白名单上,否则任何一个公开世界里都可能藏着木马。
这个白名单叫 Class Exposure Tree。写下的 C# 会先被 UdonSharp 编译器翻译成 Udon Assembly(一种字节码),再交给 VRChat 自己写的 Udon 虚拟机执行。它没有走普通 .NET 项目那条「直接编成 IL,跑在 .NET 运行时上」的路。这条多绕几道的路,正是「这段普通 C# 写不通」这类现象的源头。
理解这条流水线之后,下面三类限制就有头绪了:
1. .NET / Unity 的某些 API 不在白名单 → 调不到2. UdonSharp 编译器还没支持某些 C# 特性 → 编译报错3. 工程化层面的几条额外约束 → 写法上要绕一下下面分别拆。
第一类:白名单里没有的 API
Section titled “第一类:白名单里没有的 API”VRChat 暴露的 API 大致是:UnityEngine.* 的常用部分(不是全部)、VRC.* 的世界 API、System 命名空间的少量基础类型。其余基本访问不到。
以下是写脚本时大概率会撞上的,按重要性排:
不可用:System.IO.* 读写本地磁盘不可用:System.Net.* 发起网络请求不可用:System.Reflection 反射不可用:System.Diagnostics.Process 启动外部程序不可用:System.Threading 多线程、Task、async/await不可用:System.Linq LINQ 查询语法不可用:System.Collections.Generic.List<T> / Dictionary<TKey, TValue> 等泛型集合文件、网络、外部进程访问不到,是沙箱的硬性边界。设计世界的逻辑时不要假设能读取本地文件、不要假设能调用外部 Web API。需要持久化数据用 VRChat 自己的 Player Persistence API(SDK 3.7.4 起),需要外部数据走 VRCUrl + VRCStringDownloader 这套受限通道。
判断某个 API 能不能调,最稳的是打开 VRChat SDK Control Panel → Udon → Class Exposure Tree 查。它列出 Udon 当前暴露的类、方法和属性。另一个办法是直接写进 UdonSharp 脚本里编译一次,编译器会告诉哪一行调用不在白名单上。
第二类:UdonSharp 编译器还没支持的 C# 特性
Section titled “第二类:UdonSharp 编译器还没支持的 C# 特性”这些不是沙箱阻拦,是 UdonSharp 编译器自身能力的边界。常见的有:
不支持:自定义泛型类(class Foo<T> { ... })和大部分自定义泛型方法不支持:interface(接口)不支持:try / catch / finally(异常处理)不支持:async / await不支持:yield return(即不能写迭代器、协程)不支持:event 关键字不支持:自定义 indexer(this[int i])不支持:自定义 operator overload(== + - 等)不支持:UdonSharpBehaviour 上写 static 方法不建议:按普通 C# 的数据类思路 new 自定义 class受限: ref / out 参数(基本场景可用,复杂场景仍可能编译失败)受限: switch 表达式(C# 8 的 switch e => 写法)普通 C# 教程很爱用的 LINQ(list.Where(x => x > 0).Select(x => x.Name))和 async(await Task.Delay(1000))在 UdonSharp 里都用不了。前者是因为 LINQ 内部依赖大量泛型和委托,后者是因为整个并发体系被禁。
字符串内插 $"score = {score}" 在当前版本是可用的。早期版本不支持,老社区帖子可能写「不能用」,按当前 SDK 跑一下编译器就知道。
VRChat 世界要做「等一秒之后做某事」,常用的写法是 SendCustomEventDelayedSeconds:
SendCustomEventDelayedSeconds(nameof(OnTimerFinished), 1f);这是 UdonSharpBehaviour 自己提供的方法,绕开了 .NET 的 Task / Coroutine 体系,在 Udon 虚拟机里实现「过 N 秒之后调你这个方法」。功能上等价于 Coroutine(普通 Unity 项目里写异步等待用的协程),写法不同。
没有 try-catch,要靠提前判空
Section titled “没有 try-catch,要靠提前判空”普通 C# 里写「先尝试做某件事,出错了兜住」常用 try-catch:
// UdonSharp 里写不通try{ target.SetActive(false);}catch (NullReferenceException){ Debug.Log("target 没填");}UdonSharp 不支持这个语法。运行时如果 target 是 null,脚本会抛 NullReferenceException,当前事件会中断,Console 里会留下红字。读者第一次撞上时常以为「按钮按了没反应」是交互没触发,其实是脚本执行到空引用那一行就停了。
替代写法是先判后用:
if (target != null){ target.SetActive(false);}else{ Debug.Log("target 没填");}在调用之前加一行 != null 检查,能把这类空引用挡在事件中断之前。
普通 C# 的数据类思路在这里走不通
Section titled “普通 C# 的数据类思路在这里走不通”普通 C# 里很常见的写法,是先写一个纯数据类,再 new 出许多个对象:
public class Enemy{ public string name; public int hp;}
Enemy e = new Enemy();UdonSharp 官方文档明确支持的是自定义 UdonSharpBehaviour 之间互相引用、访问字段、调用方法;完整的普通 C# 对象模型不是它的基础工作流。本书不把「自己写数据 class 再 new 一堆实例」当成可用能力来教。需要表达一组数据时,三条路更稳:
- 多个并行数组代替一个对象数组:
string[] enemyNames; int[] enemyHps;,索引对齐。简单粗暴但够用。 - 把数据做成 UdonSharpBehaviour 字段,挂在场景物体上,通过 Inspector 引用。适合每个「实例」都对应一个真实 GameObject 的场景。
- 用 VRChat SDK 提供的
DataList和DataDictionary(VRC.SDK3.Data命名空间)。它们是 VRChat 给 Udon 准备的动态列表和字典,能装混合类型的数据,能当List<T>/Dictionary<TKey, TValue>的替代。
// 用法示意using VRC.SDK3.Data;
DataList scores = new DataList();scores.Add(10);scores.Add(20);int first = scores[0].Int; // 取出来时要从 DataToken 里解出原值官方 UdonSharp 文档对集合的口径很直接:当前只支持数组 T[],List<T> 还不能当作可用能力。需要动态列表或键值表时,优先用 DataList / DataDictionary。
第三类:工程化层面的几条额外约束
Section titled “第三类:工程化层面的几条额外约束”下面这几条来自工作流约束:UdonSharp 把 C# 文件组织进 Udon 程序资产时,对脚本布局和实例化方式有几条额外要求。它们不是语法报错,也不在白名单层面。第一次撞上会让人懵,知道之后绕开就好。
纯静态类必须放在 Plugins/ 文件夹
Section titled “纯静态类必须放在 Plugins/ 文件夹”只装常量、不继承 UdonSharpBehaviour 的纯 C# 类,比如:
public static class WorldConstants{ public const float DefaultWalkSpeed = 2f; public const int MaxPlayers = 32;}这种类如果直接放在 Assets/Scripts/ 这类普通目录下,UdonSharp 编译器会报错:
Script without UdonSharpBehaviour definition should not have an associated UdonSharpProgramAsset解决办法是把这个文件移到 Assets/Plugins/ 目录下。Plugins 是 Unity 的特殊目录,里面的脚本走另一条编译路径,UdonSharp 不会要求它必须是 UdonSharpBehaviour。这些类仍然可以被 UdonSharp 脚本正常引用、正常用 WorldConstants.MaxPlayers 取常量。
UdonSharpBehaviour 上的 static 方法不可用
Section titled “UdonSharpBehaviour 上的 static 方法不可用”UdonSharp 不支持在 UdonSharpBehaviour 子类里写 static 方法:
public class Helper : UdonSharpBehaviour{ // 编译过不去 public static int Add(int a, int b) => a + b;}如果确实需要工具方法,两条路:
- 把这些方法做成普通实例方法,通过场景里的某个 GameObject 上挂的脚本去调用。
- 把这些方法挪到
Assets/Plugins/下的纯静态类里(不继承 UdonSharpBehaviour)。
运行时不能 AddComponent UdonSharpBehaviour
Section titled “运行时不能 AddComponent UdonSharpBehaviour”普通 Unity 项目里写 gameObject.AddComponent<MyBehaviour>() 是常规做法。UdonSharp 项目里这条路在运行时走不通:UdonSharp 的脚本被打包成 Udon 程序资产,需要在编辑期就跟 GameObject 绑定好,运行时新加挂不上去。
要做「按需出现的物体」的常见做法是:场景里预先放一个挂好脚本的物体,禁用着;用 SetActive(true) 启用即可。
一些惯用法上的小差别
Section titled “一些惯用法上的小差别”除了上面三大类,还有一组细节差别,写脚本一两次就习惯了:
// 1. 初始化统一用 Start。// 字段初始化器会在编译时求值,只适合简单默认值。// 依赖场景对象、GetComponent、Inspector 引用的初始化逻辑放进 Start。private void Start() { ... }
// 2. Unity 标准生命周期方法(Start / Update / FixedUpdate / LateUpdate 等)// 不写 override。UdonSharpBehaviour 里这几个不是基类里的虚方法。private void Update() { ... }
// 3. UdonSharpBehaviour 自己定义的虚方法(Interact / OnPlayerJoined / OnPlayerLeft 等// VRChat 专属事件)必须写 public override。public override void Interact() { ... }public override void OnPlayerJoined(VRCPlayerApi player) { ... }
// 4. Inspector 上看见的 public 字段都会被序列化到场景里。// 不希望被外部修改的字段用 [SerializeField] private 替代。[SerializeField] private float speed = 2f;
// 5. 跨脚本调用别的 UdonSharpBehaviour 上的方法,// 用 SendCustomEvent("方法名") 或者直接拿到引用调方法。// SendCustomEvent 只能调用 public 方法;字符串名字写错了运行时才会报错。otherBehaviour.SendCustomEvent(nameof(OtherBehaviour.DoSomething));「哪些写 override、哪些不写」这条规则最容易混:Unity 自己的生命周期方法不写 override,VRChat 专属事件写 override。看到老的代码风格不一样很正常,这本书里统一用上面这套。
性能不是最后才管的事
Section titled “性能不是最后才管的事”普通 Unity C# 里「先写出来,慢了再优化」常常说得通。UdonSharp 里这句话要谨慎。官方性能页给出的量级是:同一段逻辑跑在 Udon 里,可能比普通 C# 慢 200 到 1000 倍。零基础阶段不需要学复杂优化,但要先避开几种高成本写法:
不要:在 Update 里遍历很多物体不要:在 Update 里反复 GetComponent不要:每帧跨 UdonBehaviour 调 SendCustomEvent不要:把一堆方法都写成 public可以:Start 里缓存组件引用可以:低频事件里处理逻辑可以:能用动画、Collider、现成组件解决的事先不用脚本GetComponent 尤其要注意。普通 Unity 里它已经不算便宜;在 UdonSharp 里获取另一个 UdonSharpBehaviour 时,背后还要额外检查 UdonBehaviour 和脚本类型。需要引用别的物体时,本书前面推荐的 Inspector 拖拽引用不是只为方便,也是为了少写运行时查找。
public 方法也不是越多越好。SendCustomEvent 和 SendCustomNetworkEvent 能调用的目标方法必须是 public,所以真正要给外部调用的才设为 public;只在本脚本里用的工具方法设成 private。这样更清楚,也能少一点 Udon 查找开销。
一份排坑顺序
Section titled “一份排坑顺序”写脚本时撞墙了,按下面顺序排查能省不少时间:
1. 看 Console 里有没有红字。 报错有:先按报错信息修。多数报错说人话。 报错没:进 2。
2. 看脚本是否真的挂在了想要它执行的 GameObject 上。 GameObject 名字对、脚本拖错位置,都是常见的。
3. 看 Inspector 里每个引用槽是否真的指向了目标物体。 槽位是 None 时调用会 NullReferenceException, 当前事件会中断,按按钮看起来就像没反应。
4. 看用到的 API 是否在白名单。 打开 VRChat SDK Control Panel → Udon → Class Exposure Tree, 或在 UdonSharp 脚本里写一次让编译器告诉哪行不在白名单上。
5. 看是否撞上 UdonSharp 的特性限制(async/yield/泛型/static/try-catch/...)。 撞上了换 SendCustomEventDelayedSeconds、判空、DataList 等替代写法。
6. 看是否撞上工程化层面的约束(纯静态类没放在 Plugins/、运行时 AddComponent 等)。 按本章第三节的写法绕开。
7. 都不是的话,加 Debug.Log 把变量值打出来。 `Debug.Log("到这了,值是 " + score);`第 40 章会再展开一节关于调试的方法,那一节会专门讲怎么读 Console、怎么用 Debug.Log 拆问题。
- UdonSharp 官方文档
- UdonSharp Random Tips & Performance Pointers
- VRChat Creator Docs:Data Containers
- VRChat Creator Docs:Player Persistence
- VRChat Creator Docs:String Loading
- VRChat Creator Docs:ClientSim
这一章在脑子里留下来什么
Section titled “这一章在脑子里留下来什么”第 10 到 12 章铺完之后,应该能拿到的:
- 知道程序是按顺序执行的指令、由变量、判断、循环、函数、类组合而成。
- 能看懂一段 UdonSharp 脚本:哪里是字段、哪里是方法、哪里在调用别的物体、什么时候会被执行。
- 知道 UdonSharp 不是普通 C#,遇到「怎么不行」时大致能猜到方向。
- 撞墙时不慌,知道按上面那份顺序排查。
这一章里那份「白名单 / 编译器特性 / 工程化约束」三类清单是当字典用的。撞上具体怪现象时,知道大概去哪一类里翻,比把条目逐字记下来管用。