搞机器人训练有一段时间之后,你会发现真正的瓶颈往往不在算法,而在"环境"。早期我习惯用传统物理引擎搭仿真环境,每个接触参数、摩擦系数、电机力矩都要手工标定,调三天可能还是跟真实世界对不上。后来接触到世界模型(World Model)的思路,环境的状态演化不再靠一堆经验公式硬解,而是用一个神经网络模型根据历史状态和动作直接预测下一步,仿真一下子变得灵活很多。
我这篇文章想聊的,是一个我自己跑通的小项目:用 C# 在 Unity 里写一个 WorldSim 客户端,通过标准网络接口连接部署好的世界模拟器服务,然后在这里面训练机器人策略。整个项目最核心的问题不是"怎么训练",而是"Unity 这个前端怎么跟世界模拟器高效、稳定地通信",以及"怎么把模型返回的状态和动作可靠地落到场景里"。
这套方案适合正在做机器人强化学习、具身智能仿真,或者想把 Unity 当作交互可视化前端,但后端环境用神经网络模拟器的开发者。不需要你已经很懂世界模型内部结构,只要会基本的 C# 和 Unity 操作,就能照着把链路跑通。
1. 项目全景:世界模拟器客户端到底解决什么问题
1.1 世界模拟器和传统物理引擎是两条路
传统仿真器(比如基于刚体动力学的那类)的做法是:你写好几套动力学方程,设置好重力、摩擦、电机模型,然后每个物理帧解算一次。优点是确定性好、可控性强,缺点是要把环境"真实"到足够细节,需要花非常多精力调参。尤其是有接触、形变、流体或者复杂传感器的情况下,每一步都可能引入误差。
世界模拟器的做法完全不同。它的核心是一个从海量轨迹数据里训练出来的模型,输入是最近若干步的观察和动作,输出是下一帧的观察、奖励、终止标志。你可以把它理解成"一个会预测画面和状态演化的黑盒子"。它不一定精确遵守物理公式,但只要你给它的历史轨迹足够多样,它能学到比手工模型更贴近真实的数据分布。
用在客户端里,最大的变化是:仿真器不再需要在自己的机器上安装大规模物理引擎。你只需要维护一个 Unity 场景作为"渲染和交互层",真正的环境演化发生在服务端。客户端每次把当前状态和上一步动作发给服务端,拿回下一帧状态和奖励,再更新场景。
1.2 为什么客户端选 C# + Unity
我以前也试过用 Python 脚本配合仿真器训练,但放在真机和机器人项目里就会发现,Python 前端跟 Unity 场景之间的桥接成本很高。要么通过外部进程通信,要么嵌入一堆中间件,数据同步稍不注意就乱掉。
C# 的优势在这里就非常明显:Unity 本身就是 C# 生态,控制循环、场景加载、相机渲染、碰撞检测全都可以在一个进程里搞定。客户端模块可以直接作为 Unity 组件挂在场景里,也可以封装成独立的 .NET 类库,给不同项目复用。
另外一个更实际的原因是:机器人训练往往要跑大量并行环境。如果每个环境都是独立的 Unity 实例,那 C# 客户端的内存占用和启动速度会比 Python 方案好控制得多。而且后续如果要把训练好的策略部署到边缘设备,C# 侧的逻辑几乎可以原样带过去,不用重新写一套。
1.3 整体架构与链路沟通
整个系统的层次大致是这样的:
- Unity 场景中运行机器人模型,负责渲染显示和简单物理展示;
- C# 客户端从场景中采集观察数据,比如关节角度、末端执行器位置、虚拟相机画面,编码成请求消息;
- 请求通过网络发送给世界模拟器服务端;
- 服务端用训练好的模型预测下一状态、奖励、终止信号,返回给客户端;
- 客户端把返回的状态更新到场景,把奖励交给训练算法,决定下一步动作。
要注意这里其实存在两个循环:一个是 Unity 的渲染循环(通常是 60FPS),另一个是训练用的 step 循环(比如 10Hz 或者 20Hz)。这两个频率必须解耦,否则一旦网络慢一点,整个渲染都会被拖垮。
我在项目里采用的策略是:Unity 的渲染循环照常跑,但每个 step 只由训练逻辑触发。拿到结果之后,不是直接强制把所有关节坐标瞬时跳过去,而是通过插值让画面平滑过渡,等下一个 step 结果回来再继续更新。这样既能保证训练数据的节奏稳定,也能让可视化看起来不抽搐。
2. 客户端核心模块拆解
2.1 会话管理与连接生命周期
世界模拟器不是一个"无状态"的 API。模型需要依赖最近 N 步的观察和动作来做预测,所以服务端必须为每个机器人环境维护一段上下文。这就是为什么我们不能像调用普通接口一样每次发一个独立请求,而是要先创建会话。
所以客户端的第一个模块就是会话管理。典型流程是:启动时给服务端发送一个握手请求,内容包括环境名称、随机种子、上下文长度、动作空间声明、观察空间声明。服务端会返回一个 session_id,之后的每次 step 都要带上这个 ID。
我建议在握手阶段就尽量把环境信息说全,比如动作维度是 6 还是 8、观察空间是纯向量还是向量加图像、上下文多长。服务端会根据这些信息分配模型实例,或者在本地做输入校验。早期我试过把动作维度写错,结果模型返回的动作长度对不上,客户端一解析就崩。现在都会在建会话时保存一份元信息,每次请求前校验一次。
会话还涉及超时处理。模型服务是共享的,可能有其他任务排队,如果 30 秒内没有成功握手,我这边就会主动重试,而不是无限等下去。整体生命周期分为 idle、connected、stepping、terminated 四种状态,状态切换都打日志。这个日志在后面排查问题时非常有帮助。
2.2 观察空间与动作空间的数据契约
做客户端最烦的事情,就是服务端和 Unity 双方对"观察"和"动作"的定义不一致。我踩过最深的坑是:Unity 里关节顺序跟服务端模型训练的关节顺序完全不一样,结果动作全都对不上,机器人原地抖成筛子。
所以我在项目里把数据契约固定成了显式字段。观察空间通常包含几个部分:
- 关节角度向量,浮点数组,例如 6 轴机械臂就是 6 个值;
- 关节速度向量,可选项,用于给模型提供动态信息;
- 末端执行器的位置和姿态,通常 7 个值(位置 3 个,四元数 4 个);
- 可选图像观察,比如前视相机,降采样到 64x64 并编码成 RGB 数组。
动作空间则看任务类型。如果是机械臂抓取,可能是 6 维末端速度;如果是轮式机器人,可能是线速度和角速度两维。服务端模型训练时用的动作往往是连续归一化区间 [-1, 1],客户端发送前要把实际控制量映射到这个区间。
我在 C# 里用可序列化类来定义请求结构,方便 Json 序列化和反序列化:
[Serializable] public class ObservationStep { public int step_id; public float[] joint_positions; public float[] joint_velocities; public float[] end_effector_pose; public byte[] rgb_image; public float reward; public bool terminated; public bool truncated; } [Serializable] public class WorldStepRequest { public string session_id; public int step_id; public float[] action; public ObservationStep observation; public float reward; public bool done; }这里有个细节:reward 和 done 是当前 step 结束后客户端上报给服务端的信息,还是服务端返回给客户端的信息?不同项目流派不一致。我这边采用的是"服务端返回候选状态和奖励,客户端确认后回传"的方式,类似两阶段确认,能够减少状态漂移。如果服务端是一次性返回全部消息,那客户端就不需要回传 reward 和 done,但需要做好本地存储。
2.3 网络层选型:WebSocket 还是 HTTP
客户端最常见的选择是 HTTP 轮询和 WebSocket 长连接。刚开始我直接用 HTTP POST,每个 step 发一个请求,逻辑是最简单的,但跑起来发现两个问题:一是每次请求都要重新建立 TCP 连接,高频训练时开销不小;二是 HTTP 的无状态性让上下文管理很别扭,虽然可以靠 session_id 硬凑,但调试起来很费劲。
改用 WebSocket 之后,握手只做一次,之后整个训练过程中客户端和服务端保持一条长连接,step 请求作为消息发过去,响应作为消息收回来。延迟低了很多,也容易实现服务端的主动推送,比如环境异常时直接给客户端发终止消息。
gRPC 我也考虑过。如果你的服务端是 Python 训练框架,gRPC 的性能确实更好,而且有强类型接口定义。但在 Unity 环境里,要引入 gRPC C# 依赖、生成 protobuf 代码,配置成本比 WebSocket 高不少。对于大多数机器人训练场景,WebSocket 的吞吐量已经够用。所以最终采用 WebSocket 为主通道,HTTP 只保留一个健康检查接口。
网络层的核心设计是一个请求队列。同一个会话内,不能同时发送两个 step,否则上下文顺序就乱了。我用了一个异步信号量来控制,确保一个 session 同时只有一个 pending 请求。
2.4 线程模型:Unity 主线程与网络线程的协作
这是整个客户端最容易翻车的地方。Unity 的 API 大部分只能在主线程调用,而网络回调发生在后台线程。如果直接在回调里操作 Transform,Unity 会直接报错或者出现随机性崩溃。
我采用的模式是:网络线程只负责收消息、反序列化数据,然后把结果放进一个线程安全的队列;Unity 主线程在每一帧的 Update 里检查队列,把结果取出来应用。这样两边互不阻塞,主线程也不会因为网络等待而卡死。C# 里可以用ConcurrentQueue做这个缓冲:
private ConcurrentQueue<WorldStepResult> _resultQueue = new ConcurrentQueue<WorldStepResult>(); private async Task ReceiveLoopAsync() { while (_running) { var message = await _ws.ReceiveAsync(...); var result = JsonConvert.DeserializeObject<WorldStepResult>(message); _resultQueue.Enqueue(result); } } private void Update() { while (_resultQueue.TryDequeue(out var result)) { ApplySimulationResult(result); } }要注意async/await里的上下文。Unity 里默认的SynchronizationContext会试图回到主线程,但在 WebSocket 回调里不一定可靠。我更习惯在库内部使用ConfigureAwait(false),明确不依赖主线程上下文,数据统一通过队列投递。
另外,物理引擎的固定更新和网络 step 不一定完全同频。我一般不把模拟器 step 放在 FixedUpdate 里,而是单独维护一个训练步计数器,在 Update 里累加时间,达到设定步间隔时触发一次 step。这样即使渲染帧率波动,训练频率也能较稳定。
3. 实操步骤:搭一个能跑通的最小客户端
3.1 环境与依赖准备
我用的是 Unity 2022 LTS,纯 C# 项目。需要安装的依赖包括:
- WebSocket 客户端库,比如 websocket-sharp,方便处理长连接;
- Newtonsoft.Json,用于消息序列化;
- 如果涉及图像,还需要引入一个简单的图像缩放方法,直接把 Texture2D 缩放为 64x64 像素并读像素值。
在 Unity 里使用这些库时,建议单独建一个WorldSimClient类,不直接挂在 MonoBehaviour 上,这样可以做单元测试。MonoBehaviour 只负责调用这个客户端,并把结果映射到场景物体。
3D 场景方面,我建了一个简单的地面和一个简易机械臂模型,机械臂的每个关节都加了一个JointController组件,在接受到目标角度时用插值方式转过去。这个阶段不需要物理引擎参与太多,主要是为了让观察数据有实际来源。
3.2 首次握手与环境初始化
第一次连接服务端的代码其实不多,但信息要完整。我会在握手消息里把环境类型、动作维度、观察维度、上下文长度一起传过去:
public async Task<SessionInfo> OpenSessionAsync(string envName, int seed, int maxContext = 64) { var handshake = new { type = "handshake", env = envName, seed = seed, max_context_length = maxContext, action_space = "continuous:6", observation_space = "vector:12+image:64x64x3" }; var response = await PostJsonAsync("/v1/worldsim/session", handshake); var session = JsonConvert.DeserializeObject<SessionInfo>(response); if (string.IsNullOrEmpty(session.session_id)) { throw new InvalidOperationException("握手失败,未返回 session_id"); } this.session_id = session.session_id; this.context_length = maxContext; return session; }这里的max_context_length是给模型的历史步数。我常用的值是 64 步,如果步频率是 10Hz,相当于模型能看到过去 6.4 秒的运动历史。对于简单抓取任务,32 步就够;对于需要记忆障碍物位置的任务,可能需要 128 步以上。时长越长,服务端计算量和延迟都会增大,所以不要盲目堆。
环境种子 seed 也很关键。我用同一个 seed 来固定场景初始位置,这样调试同一个 episode 时,每次复现初始条件一致,方便定位问题。等到正式训练时再随机化 seed。
3.3 执行一次标准 step
握手之后,训练循环就很简单了。每次 step 的流程是:
- 从场景采集观察:读取各个关节当前角度,读取末端执行器位置,如果有相机就渲染一帧;
- 从训练算法拿到动作向量,归一化到 [-1, 1] 区间,同时也保留一个实际控制量用于场景驱动;
- 构造
WorldStepRequest,带上 session_id 和 step_id; - 发送给服务端,等待返回结果;
- 服务端返回下一帧观察、候选动作、奖励、终止标志;
- 在 Unity 主线程里更新场景,把奖励传给训练算法。
核心的 step 调用长这样:
public async Task<WorldStepResult> StepAsync(WorldStepRequest request) { await _stepSemaphore.WaitAsync(); try { var json = JsonConvert.SerializeObject(request); var responseJson = await _ws.SendAndReceiveAsync(json); var result = JsonConvert.DeserializeObject<WorldStepResult>(responseJson); if (result.step_id != request.step_id + 1) { throw new ProtocolException($"step_id 不连续: 请求 {request.step_id}, 返回 {result.step_id}"); } return result; } finally { _stepSemaphore.Release(); } }step_id校验是我的一个强迫症。因为一旦网络丢包或者重连,很容易出现上下文错位。如果发现返回的 step_id 跟预期不一致,我宁可让这段 episode 直接作废重置,也不继续往下跑,否则模型接收到的上下文是断裂的,后面所有状态预测都会慢慢漂移。
拿到下一步状态后,应用到场景也有讲究。不要直接设置 Transform 的 position 和 rotation,除非你用的是纯运动学模式。更稳妥的方式是让关节控制器生成速度指令,在 FixedUpdate 里执行。这个过渡过程会让可视化更平滑,也可以在一定程度上模拟电机延迟。
3.4 终止与重置逻辑
世界模拟器返回的终止标志有两种:terminated 表示任务真的达到目标或者失败,比如抓住物体或者机械臂撞到桌面;truncated 表示只是达到了最大步长,比如跑了 100 步还没有完成任务。客户端处理这两种情况的方式不一样。
terminated 时,这段轨迹的价值已经明确,训练算法会进行更新,然后重新初始化环境。truncated 时,只是当前 episode 被截断,环境状态不需要重置,通常从当前状态继续跑,或者设置一个 done 标志让算法知道本次 rollout 结束但不影响模型上下文。
我在客户端里专门维护了一个 episode 计数器,每遇到 terminated 就递增。在日志里会把每一步的 terminated、truncated、reward 都打出来,方便回放分析。
重置环境时,要向服务端发送 reset 请求。有些服务端实现会直接复用同一个 session,只是把内部上下文清空;有些则要求关闭旧 session 重新握手。我遇到比较多的是第一种:发送一个type=reset的消息,附带新的随机种子,服务端返回初始观察。在客户端里,reset 也要带上完整的会话状态,不能直接在 WebSocket 上发一个裸字符串就完事。
4. 常见问题与排查经验
4.1 连接超时或握手被拒
表现:客户端一直卡在 OpenSessionAsync,或者收到connection refused。
- 第一步先确认服务端地址对不对。本地调试时我用
ws://127.0.0.1:8443/v1/worldsim,注意是 ws 不是 wss,wss 需要额外证书配置。 - 第二步确认服务端模型实例是否就绪。很多模型服务要等权重加载完成才能接受握手,这时候客户端重试几次就好了。
- 第三步检查 action_space 和 observation_space 描述是否跟服务端注册的环境一致。不一致会被直接拒掉。我自己就犯过把观察空间写成
vector:12,实际模型是vector:12+image:64x64x3的低级错误。
建议客户端做一个启动自检:先发一个 ping 或 health 请求,成功后再握手。如果握手失败,把服务端返回的错误信息完整打印出来,不要只打印异常堆栈。很多时候服务端已经把失败原因写进响应体里了。
4.2 step 乱序与上下文断裂
如果不用请求队列,网络并发很容易导致同一个 session 的多个 step 同时在服务端被处理,返回结果乱序,模型看到的上下文就变成"上一秒动作 A、下一秒动作 C",预测立刻乱掉。
我的解决方案就是前面提到的信号量。每个 session 一把锁,同一时刻只能有一个未完成的 step 请求。这个方案会稍微降低吞吐,但训练的稳定性比吞吐量重要得多。尤其当你同时开 20 个并行环境时,每个环境独立一把锁,总吞吐量其实也不低。
另外,客户端一定要校验返回的 step_id。如果发现中间跳号,立即终止当前 episode,重新 reset。不要尝试"继续跑看看",上下文错位不是一两个 step 能自动恢复的,硬撑只会让整段轨迹变成噪声。
4.3 Unity 物理引擎跟模拟器状态打架
现象:机器人模型在场景里抖动、穿模、或者关节不断抽搐。
常见原因是物理引擎自动解算每个刚体的位置,而你同时又用外部返回的状态去覆盖 Transform,两者形成了竞争。比如 FixedUpdate 里刚体根据速度推了一步位置,你又在 Update 里把它置回模型预测的位置,两个逻辑互相拉扯,物体就会抖。
我后来把控制模式分成两类:
- 纯运动学模式:读取模拟器返回的目标状态后,直接在场景里设置关节位置,关掉该关节的刚体模拟,或者把刚体设为 kinematic。这个模式适合快速验证训练逻辑。
- 动力学模式:模拟器返回的不是绝对位置,而是目标速度,Unity 关节驱动根据目标速度计算力,实现更接近真实电机控制。这个模式更接近 sim2real,但也要处理物理引擎和状态覆盖的冲突。
对于大多数训练实验,优先用纯运动学模式跑通,然后再换成动力学模式。一上来就搞动力学,出现问题往往分不清是模型问题还是控制问题。
4.4 动作越界和 NaN 问题
奖励变成 NaN、动作变成 NaN 是整个训练里最头疼的问题之一。根源不一定在客户端,但客户端要做好防御。
- 发送动作前检查数值是否都在 [-1, 1] 范围内,不在就做 clip;
- 检查关节角度是否在合理范围内,比如超过正负 180 度就报警重置;
- 对模型返回的状态做合法性检查,出现 NaN 或 Inf 立即终止当前 episode,并且记录是第几步、当时输入是什么,方便复现。
客户端里我会加一层简单的数据校验函数:
public static bool IsStateValid(WorldStepResult result) { if (result == null) return false; foreach (var v in result.observation.joint_positions) { if (float.IsNaN(v) || float.IsInfinity(v)) return false; } return true; }训练算法看到 NaN 可能还能自我调节,但如果 Unity 场景里出现 NaN,Transform 会直接退化,后续全部乱套。所以客户端必须尽早拦截。
4.5 网络抖动与断线重连
本地调试时网络很稳,但一旦把模拟器部署到远程服务器或者容器里,网络抖动就频繁出现。表现是某个 step 连续超时,然后客户端抛出异常,整个训练循环中断。
我的应对策略是设置合理的超时时间,比如 10 秒,超过之后自动触发重连。重连之后 session 通常已经失效,所以要重新握手,并且把当前 episode 的轨迹数据保存到本地,防止训练数据丢失。
还有一个容易被忽略的问题:如果 WebSocket 底层连接断了,Unity 不会立刻知道。我加了一个每 5 秒一次的心跳消息,服务端也返回 pong,连续三次没收到 pong 就主动重连。这样比等到真正发数据时才发现断线要可靠很多。
4.6 模拟速度慢导致训练效率低
网络模型推理通常比传统物理仿真慢,尤其是图像输入的世界模型。如果客户端每个 step 都同步等待网络返回,训练吞吐量可能只有几个 Hz,根本喂不饱强化学习算法。
我后面的做法是引入异步步进:客户端可以同时维护多个环境实例,每个环境按照自己的节奏向服务端发 step,服务端有足够的并发能力时就自动利用起来了。Unity 侧不必强求完全同步,每个环境的更新可以在自己的 Update 里独立进行。
另一个技巧是降低图像分辨率。64x64x3 的 RGB 图比 128x128 少四倍数据量,带宽和模型推理时间都能明显下降。对大多数控制任务,64x64 足够用了,只有在需要精细纹理辨识时才提高到 128。
5. 我调试这类客户端的一些真实心得
5.1 先写一个 Mock WorldSim 服务端
真实模型服务往往并发高、负载大,客户端出问题时很难判断是自己请求格式错了还是服务端模型问题。我在项目里写了一个本地 Mock 服务端,逻辑很简单:收到 step 请求后,把上一帧的观察加上一个小量扰动作为下一帧状态,奖励给一个固定值,done 在指定步数时置为 true。
用 Mock 服务端可以进行以下几项验证:
- 序列化反序列化是否正确;
- 并发控制是否生效;
- step_id 校验逻辑是否会误报;
- Unity 场景更新是否跟手。
把基础链路跑通之后,再切换到真实服务端。这样排错范围会缩小很多,也避免因为网络问题浪费模型服务方的算力。
Mock 服务端代码量不大,可以用 .NET 的HttpListener实现,也可以嵌入一个简易 WebSocket 服务。核心只要保证消息格式一致,逻辑简单即可。
5.2 全程记录轨迹,而不只是终端日志
训练过程中最容易忽视的是上下文漂移。我建议客户端每步都往本地文件写一份轨迹记录,包含原始请求和原始响应。这样如果后面训练效果异常,可以精确回放到某一步,看看模型是否在某段历史输入下发生了预测偏差。
轨迹记录我一般以 episode 为单位,文件名是episode_{id}.jsonl,每行一个 step。通过回放这些数据,不用跑 Unity 也能分析问题。这个习惯是从数据处理项目里带过来的,但对客户端调试同样有效。
5.3 用插值让画面稳定,别让网络延迟直接暴露给可视化
网络再快也有延迟,如果拿到结果后直接把关节目标位置瞬移过去,画面上会看到明显的卡顿和突变。我在关节控制器里加了一个指数平滑或者线性插值,目标位置变化时,关节角度在几个物理帧内逐渐靠近目标。
这一步不是为了骗自己,而是为了给训练算法提供更平稳的状态观察。如果状态值在 Unity 侧因为插值而延迟,那么采集到的观察就跟模型预测的状态有细微偏差。好在这个偏差足够小,且模型本身也学习到了延迟因素,所以对最终训练影响不大。但画面稳定之后,发现问题会容易很多。
5.4 多环境并行时的资源分配
并行跑多个 Unity 实例对显卡和内存压力很大。我后来调整为:可视化实例只开一两个,用于观察行为;训练用的并行环境全部跑"无渲染模式",也就是不创建相机渲染,只执行逻辑和通信。无渲染模式在资源占用上会下降很多,训练吞吐量反而提升明显。
服务端资源同样要注意。每个 session 都会占据一定的模型上下文显存,并行环境数量要跟服务端的 batch 能力匹配。我一般先做压力测试:从 4 个并行开始,逐渐增加,观察请求延迟和成功率,找到一个拐点,然后稳定在拐点附近使用。
5.5 状态合法性监控
世界模拟器本质上是一个生成模型,存在"幻觉"的可能性。如果它预测的状态超出了物理可行范围,比如关节瞬间飞到十万八千里,客户端不能盲目接受。
我加了一个合法性监控模块,对每个返回状态做物理范围检查。只要连续十步的状态都在合理区间内,并且奖励变化幅度没有异常,就认为模型输出稳定。一旦出现异常状态,立即重置该 episode,并且在日志里标记为suspect_episode。这一步不算复杂,但对训练稳定性的提升非常明显。
6. 后续可以怎么扩展
这个客户端框架做出来后,再往里加东西就方便多了。比如我最近在尝试加入领域随机化:每次重置环境时,随机化地面摩擦、物体大小、光照条件,同时通过配置消息传给服务端,让世界模型也能感知到这些变化。客户端只要在 reset 消息里多带一个domain_params字段就行。
再比如把多台 Unity 场景连到同一个模型服务端,每个场景对应一个 session,这样可以同时采集多条轨迹。真正需要大规模并行训练时,Unity 客户端可以运行在云端容器里,不需要显示器,只要有一个虚拟渲染管线就能完成数据采集。
如果你正好在规划类似的机器人仿真训练平台,我的建议是不要一开始就追求大而全的框架,先把"Unity 场景、客户端通信、模型服务端、训练算法"这条最小链路跑通,然后逐步增加并行环境、数据回放、领域随机化这些功能。万变不离其宗,核心还是那一条稳定可靠的 step 链路。