第 21 章 · 参数化 Network Event 与决策表
这一章解决:把「请求传一两个简单参数」从「写到同步字段再触发」简化到「直接在事件签名里传」。配合本章决策表,能选清楚每一段同步该走哪条通道。
先看一下
闭环 2 的请求字段 requestType + requestPayload 当时是为了承载「参数」:玩家想加几分、想切到几号队伍、想选什么角色。这一份方案在第 12 章被立起来时,SendCustomNetworkEvent 还不支持参数,只能传方法名。SDK 3.8.1 起加了 [NetworkCallable],方法签名里写参数,调用时直接传值。
参数化事件不是「替代 requestType + requestPayload」的新通道。它有自己的限制和适合的场景:参数个数上限、单次载荷上限、迟入恢复仍然不行。这一章把它和现有通道并排。
这一章会拿到什么
- 参数化
SendCustomNetworkEvent的完整签名与硬性限制 - 同一段「请求加 1 分」三种写法对照(同步字段触发 / 参数化事件 / Object Sync)
- 本部锚点决策表:七条通道按四个轴横向对比,第 22–24 章 + 闭环 3 都引这张表
- 一段「写新功能时通道选错的常见症状」自检
依赖前面
- 第 1 章
SendCustomNetworkEvent的「事件不重放」结论 - 第 2 章
NetworkEventTarget.All/Owner区别 - 第 4 章
[UdonSynced]同步变量同步模式与序列化回调 - 第 12 章请求式架构
requestType+requestPayload字段 - 第 19 章状态 vs 命令五维度
参数化版本和无参版本共用同一个 SendCustomNetworkEvent。差别在被调方法上:被调方法标 [NetworkCallable] 后,调用方传参时按位置传。
using VRC.SDK3.UdonNetworkCalling; // [NetworkCallable] 的命名空间,需 SDK ≥ 3.8.1using VRC.Udon.Common.Interfaces; // NetworkEventTarget
// 调用方(任何客户端,把请求送给某玩家的 Owner 端)target.SendCustomNetworkEvent( NetworkEventTarget.Owner, nameof(NetGiveGift), receiverPlayerId, // 第一个参数 giftType, // 第二个参数 quantity); // 第三个参数
// 被调方(在 target 这个 UdonBehaviour 上)[NetworkCallable]public void NetGiveGift(int receiverPlayerId, byte giftType, int quantity){ if (!Networking.IsOwner(gameObject)) return; // 在 Owner 上跑 // 业务逻辑}[NetworkCallable] 的方法在调用时按签名顺序填参数。和无参版本一样,事件不重放给迟入玩家。NetworkEventTarget 有 All / Others / Owner / Self 四种;本章主要用 Owner 发送请求,用 All 播瞬时反馈。
[NetworkCallable] 的硬限制
Section titled “[NetworkCallable] 的硬限制”本次校验以 VRChat Creator Docs 的英文 Network Events 页面为准:
| 项目 | 限制 |
|---|---|
| 参数个数 | 最多 8 个 |
| 单次载荷 | 总大小上限 16 KB;超过 1024 bytes 会内部分片,接收端重组成一次方法调用,但分片会计入速率限制 |
| 参数类型 | 与同步变量同一类可同步类型:bool / char / byte / sbyte / short / ushort / int / uint / long / ulong / float / double / Vector2 / Vector3 / Vector4 / Quaternion / string / VRCUrl / Color / Color32,及上述类型的数组 |
| 不支持的参数 | 自定义 class / struct、UdonBehaviour 引用、嵌套数组、DataDictionary / DataList、VRCPlayerApi(传 int playerId,远端用 VRCPlayerApi.GetPlayerById 还原) |
| 方法形态 | 必须 public、无返回值,不能是 static / virtual / override |
| 重载 / 默认参数 | 不支持成员重载,不支持 params,不支持默认参数 |
| 速率 | 默认 5 events/s;[NetworkCallable(maxEventsPerSecond: X)] 可设 1–100;全局 outgoing event 约 100 events/s;总出站数据硬上限约 18 KB/s,实际常见上限约 8–10 KB/s |
写参数化事件前先按这张表过一遍。自定义 class 之类的限制经常让初学者写出「这不就编译失败了」的代码,UdonSharp 编译器会给提示,但消息有时候不直观(说「类型不支持序列化」,原因可能是嵌套字段里有 UdonBehaviour)。
同一段「请求加 1 分」三种写法
Section titled “同一段「请求加 1 分」三种写法”下面把闭环 2 的「按 Score 按钮加 1 分」用三种通道分别写一遍骨架。差别集中在「请求怎么送过去」。
写法 1:同步字段触发(闭环 2 现状)
Section titled “写法 1:同步字段触发(闭环 2 现状)”// 客户端 A(玩家本人):改自己 PlayerLobbyState 的请求字段public void RequestScore(){ if (!Networking.IsOwner(gameObject)) return; requestId++; requestType = REQ_SCORE; requestPayload = 0; RequestSerialization();}
// GameState(轮询所有玩家的 PlayerLobbyState)private void TickRequests(){ foreach (var p in players) { var pls = GetPLS(p); if (pls.RequestId > lastProcessedRequestId[p.playerId]) DispatchByType(pls.RequestType, pls.RequestPayload, p); }}特点:迟入恢复天然(迟入玩家进来读 requestId 立刻知道当前进度),幂等天然(按 requestId 比较即可),但轮询有成本(第二部第 12 章已经讨论过:Update 里的轮询不要每帧跑,按需触发)。
写法 2:参数化事件触发
Section titled “写法 2:参数化事件触发”// 客户端 A(玩家本人):直接给 GameState 发参数化事件public void RequestScore(){ gameState.SendCustomNetworkEvent( NetworkEventTarget.Owner, nameof(GameState.NetRequestScore), Networking.LocalPlayer.playerId, 1); // 加 1 分}
// GameState[NetworkCallable]public void NetRequestScore(int playerId, int delta){ if (!Networking.IsOwner(gameObject)) return; if (TooHot(playerId)) return; // GameState 端冷却 totalScore += delta; RequestSerialization();}特点:代码短、没有轮询,但幂等不天然(事件本身没有 requestId,TooHot 只是冷却,不是幂等),迟入玩家收不到 Owner 转移瞬间在路上的事件,16 KB 单次上限对单参数通常没影响但对密集参数化要算一下。
写法 3:状态字段直接同步(PlayerObject 模型)
Section titled “写法 3:状态字段直接同步(PlayerObject 模型)”// PlayerLobbyState 上加一个字段[UdonSynced] private int personalScore;
// 玩家本人按 Score 按钮直接写自己字段public void RequestScore(){ if (!Networking.IsOwner(gameObject)) return; personalScore += 1; RequestSerialization(); // GameState 不参与;UI 端按 playerId 加总}这一版完全跳过 GameState,把分数写成「每位玩家自己的字段」。第 17 章已经讨论过:分数归个人时这是合适写法,归队伍 / 全局时不合适。
| 维度 | 写法 1 同步字段 | 写法 2 参数化事件 | 写法 3 PlayerObject |
|---|---|---|---|
| 迟入恢复 | 当前状态字段自然支持,请求字段只作触发 | 不支持,事件不重放 | 自然支持,PlayerObject 字段同步 |
| 幂等 | requestId 比较 | 自己加 requestId 字段 | 不需要,玩家本人独占字段 |
| GameState 介入 | 是,需要轮询 | 是,单次回调 | 否 |
| 代码量 | 多(请求字段 + 轮询) | 少 | 最少 |
| 调试 | requestId 历史可见 | 事件历史不可见 | 字段值可见 |
| 适合 | 跨 Owner 写权 + 迟入要看到 | 一次性反馈、不影响状态恢复 | 玩家私有状态 |
每一种都不是「最好」,按这一段同步的需求选。
通道决策表(本部锚点)
Section titled “通道决策表(本部锚点)”第 19 章列了五维度,第 20 章给了三个工具。现在把维度铺到具体通道,给出本部锚点决策表。后续章节(第 22–24 章 + 闭环 3)回链这张表。
| 通道 | 字节范围 | 迟入恢复 | 改动频率 | 适合场景 | 不适合场景 |
|---|---|---|---|---|---|
| 本地变量(不同步) | — | 不需要 | 任意 | 纯本地 UI、客户端预测、临时状态 | 任何需要其他客户端看到的状态 |
SendCustomNetworkEvent(无参) | 极小 | 不支持 | 中(有总速率预算) | 一次性反馈:飘字、音效、按钮特效 | 任何需要状态恢复的字段 |
SendCustomNetworkEvent(参数化) | 单次 ≤ 16 KB,>1 KB 分片 | 不支持 | 中 | 「请求一个动作 + 一两个简单参数」 | 状态恢复、自定义结构 |
[UdonSynced] 基础类型 | 几到几十 byte | 自然支持 | 受同步模式预算 | 当前状态:分数、阶段、计时 | 高频小变化(撞速率) |
[UdonSynced] 数组(int / byte / float) | 数组长度 × 元素大小 | 自然支持 | 中等 | 全员状态:每位玩家的分 / 队伍归属 | 不规则结构 |
VRCJson 字符串字段 | 字符串长度,建议 ≤ 几 KB | 自然支持 | 低 | 复杂结构:棋盘、配置、复盘数据 | 高频改动、追求字节经济 |
VRCPlayerObject | 按字段累加 | 自然支持 | 中等 | per-player 状态、玩家个人输入 / UI 桥 | 全局共享状态、长期存档本身 |
VRC Object Sync | 按 Transform / Rigidbody | 自然支持 | 高(每帧) | 物理对象、抓取、被丢出去的物体 | Udon 状态 |
怎么看这张表:写新一段同步前,先问下面四个问题,按表查。
- 迟入玩家要不要看到这段状态?要 → 排除两条事件通道。
- 每秒改几次?高频 → 倾向 Object Sync 或 PlayerObject;低频 → 几乎所有通道都行。
- 单次字节多大?> 1 KB → 倾向
VRCJson字符串或拆字段;到几十 KB 时先拆,接近官方 manual sync 单次上限(约 280 KB)时必须拆。 - 状态归谁?全局 →
GameState的同步字段;玩家个人 →PlayerObject;物理 →Object Sync。
四个问题不是按顺序回答完才能选,而是哪个问题先把通道淘汰掉就先看哪个。
通道选错的常见症状
Section titled “通道选错的常见症状”下面这些症状提示你写新功能时通道选错了,回头查决策表。
症状 1:「迟入玩家看不到飘字 / 战报」。事件通道被孤立用,没补状态。修法:要么把事件触发的瞬时反馈接受迟入收不到(飘字 / 音效 / 短特效都属此类),要么把事件转成状态(命令日志,第 22 章)。
症状 2:「按按钮 5 秒分数加得慢」。事件通道撞到了 per-event 或全局速率限制,消息开始排队。修法:把高频改动从事件挪到同步字段(让 manual sync 自己合并),或加冷却。
症状 3:「同步字段总是慢一拍」。同步字段在 continuous 模式下每帧合并,每次只发一部分。如果改动太频繁,远端总是落后几帧。修法:要么切 manual sync 控制发送时机,要么把高频字段挪到 VRC Object Sync(如果是 Transform),要么降低改动频率。
症状 4:「VRCJson 字段超过几 KB 后同步变慢,甚至排队」。通常先撞到 per-object rate limit 和总带宽压力,不是等到约 280 KB 单次上限才出问题。修法:把 JSON 拆成增量(只同步「变化的子树」),或者降级到字节打包(第 23 章)。
症状 5:「Owner 转移期间状态不对」。多半和参数化事件 + 没有 requestId 有关。修法:状态恢复必须靠同步字段,不靠事件。事件转换为命令字段或加 requestId 幂等。
挑一条试。
- 把闭环 2 的
RequestScore改成参数化事件版本(写法 2)。在不加requestId字段的前提下,能复现「按一下加了两分」吗?为什么? - 设计一个「玩家点击地板让全场闪一下颜色」的功能。事件 / 状态 / 字段 / 参数化事件四种通道选哪个?给出三句话的理由。
- 一个「房间公告板」字段,玩家能输入一行字让所有玩家看到。如果字符串可能到 5 KB(粘贴一段 Markdown),按决策表查应该走哪条通道?答错的话会撞什么限制?
照决策表选完通道后,按这张表跑一遍:
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. 多客户端 Build & Test | 写法 2 / 写法 3 都能跑出加分 | … |
| 2. 迟入玩家加入 | 写法 2:迟入玩家看不到事件历史;写法 1 / 3:迟入玩家立刻看到当前分数 | … |
| 3. Owner 转移瞬间 | 写法 2:路上的事件可能丢;写法 1 / 3:字段照常同步 | … |
| 4. 长按 Score 按钮 | 写法 2:被事件速率限制排队;写法 1 + GameState 冷却:被冷却拍平 | … |
| 5. 故意发 8 个参数 | UdonSharp 编译通过;运行时正常触发 | … |
| 6. 故意发自定义 struct | UdonSharp 编译失败,给「类型不支持序列化」消息 | … |
- 能默写
[NetworkCallable]的四条硬限制(参数个数 / 载荷 / 类型 / 可见性) - 能解释参数化事件仍然不重放给迟入玩家
- 能在「请求一个动作」场景下从三种写法中选一个,并说出依据
- 能拿决策表对一段新同步做四问检查
- 能识别五个「通道选错」的症状,并对每个给出修法
- VRChat Creator Docs · Network Events —
SendCustomNetworkEvent/[NetworkCallable]/ 参数化事件 / 速率限制的官方说明。 - VRChat Creator Docs · Network Specs and Tips — 同步字段字节预算、manual / continuous 限制。
- 第 2 章 · 四种通道:Local、Event、Variable、Object Sync —
NetworkEventTarget与「事件不重放」的来源。 - 第 12 章 · 请求式架构 — 写法 1 的母本。
- 第 17 章 · 计分、奖励与结算 — 个人分 vs 队伍分,对应写法 3 的取舍。
- 第 19 章 · 状态同步还是命令同步 — 五维度判断的母本。
- 第 22 章 · 命令模式与事件日志 — 把「事件转状态」做成可重放结构。
- 附录 · 术语表 —
Network Event/Network Callable/Synced Variable的客观定义。