walk_these_ways项目学习记录第七篇(通过行为多样性 (MoB) 实现地形泛化)--核心环境下
的强大不只在于它能跑 Isaac Gym,而在于它把“任务目标、物理随机性、执行器动力学和接触几何”全部揉进了同一个闭环。reward 决定你想让机器人成为什么样;randomization 决定这个能力是否能泛化;terrain 决定它面对的世界有多难;torque 生成链则决定策略输出如何真正变成可执行的物理动作。terrain.py。
核心环境下篇:LeggedRobot 的 reward、随机化、地形与 torque 生成
上一篇我们把 legged_robot.py 的主循环走了一遍,回答了“动作进来之后,到 observation 出去之前,中间发生了什么”。这一篇继续往下挖,但重点不再是时序主线,而是四个更底层的问题:
- reward 到底是如何组织和计算的
- torque 是如何从动作一路生成出来的,
actuator_net在哪一层介入 - 物理随机化到底随机了什么、什么时候生效
- 地形和机器人环境是怎样真正被创建出来的
这四部分合起来,基本就构成了这个环境的“物理真实性”和“训练目标”两大支柱。
一、reward 不是写死在 compute_reward() 里,而是“配置驱动 + 容器分发”
先看 legged_robot.py:1385 的 _prepare_reward_function():
from go2_gym.envs.rewards.corl_rewards import CoRLRewards
reward_containers = {"CoRLRewards": CoRLRewards}
self.reward_container = reward_containers[self.cfg.rewards.reward_container_name](self)
# 先根据配置选择 reward 容器
# 这里不是把 reward 逻辑直接写死在环境类里,而是交给一个独立的奖励类管理
接着它会做一件很关键的事:
for key in list(self.reward_scales.keys()):
scale = self.reward_scales[key]
if scale == 0:
self.reward_scales.pop(key)
else:
self.reward_scales[key] *= self.dt
# 所有 reward scale 先乘上 dt
# 这意味着 reward 更接近“时间积分意义上的密度项”,而不是与仿真步长强耦合的瞬时值
然后按名字自动绑定奖励函数:
if not hasattr(self.reward_container, '_reward_' + name):
print(...) #如果在 reward_container 这个对象里找不到名为 _reward_xxx 的方法,就打印警告
else:
self.reward_names.append(name)
self.reward_functions.append(getattr(self.reward_container, '_reward_' + name))
# 配置里只要某个 scale 非零,环境就会去奖励容器里找同名函数
# 例如 tracking_lin_vel -> _reward_tracking_lin_vel
所以这个环境的 reward 设计不是“手写一大坨 if/else”,而是:
配置文件决定哪些奖励项启用,corl_rewards.py 提供这些奖励项的具体数学定义。
二、compute_reward():总 reward 是如何一项项累加出来的
主入口在 legged_robot.py:263:
self.rew_buf[:] = 0.
self.rew_buf_pos[:] = 0.
self.rew_buf_neg[:] = 0.
# 每一步开始先清空总奖励、正奖励、负奖励缓冲区
接着逐项计算:
for i in range(len(self.reward_functions)):
name = self.reward_names[i]
rew = self.reward_functions[i]() * self.reward_scales[name]
self.rew_buf += rew
self.episode_sums[name] += rew
# 每个 reward 函数先算出“原始指标”,再乘对应 scale,最后累加进总 reward
这里有一个小细节很重要:
if name in ['tracking_contacts_shaped_force', 'tracking_contacts_shaped_vel']:
self.command_sums[name] += self.reward_scales[name] + rew
else:
self.command_sums[name] += rew
# 接触塑形类奖励会被特殊处理,用于后续 command curriculum 统计
然后环境再根据配置决定要不要做“正奖励化”:
if self.cfg.rewards.only_positive_rewards:
self.rew_buf[:] = torch.clip(self.rew_buf[:], min=0.)
elif self.cfg.rewards.only_positive_rewards_ji22_style:
self.rew_buf[:] = self.rew_buf_pos[:] * torch.exp(self.rew_buf_neg[:] / self.cfg.rewards.sigma_rew_neg)
# 第一种是直接把总 reward 的负值裁成 0
# 第二种更柔和:正奖励保留,负奖励通过指数因子压缩
# 这样既能防止 early termination 阶段 reward 过于负面,又不至于完全丢掉惩罚信号
从强化学习角度看,这意味着环境不是简单做“奖励加和”,而是在做一层reward shaping 后处理,目的是让训练初期更稳定。
三、corl_rewards.py:reward 的物理含义到底是什么
真正的奖励定义在 corl_rewards.py。
最基础的两项是速度跟踪:
lin_vel_error = torch.sum(torch.square(self.env.commands[:, :2] - self.env.base_lin_vel[:, :2]), dim=1)
return torch.exp(-lin_vel_error / self.env.cfg.rewards.tracking_sigma)
# 奖励机器人追踪目标线速度
# 误差越小,reward 越接近 1;误差越大,reward 以高斯形式衰减
ang_vel_error = torch.square(self.env.commands[:, 2] - self.env.base_ang_vel[:, 2])
return torch.exp(-ang_vel_error / self.env.cfg.rewards.tracking_sigma_yaw)
# 奖励机器人追踪目标偏航角速度
# 本质上鼓励“想转多快就能转多快”
一些稳定性惩罚项也很典型:
return torch.square(self.env.base_lin_vel[:, 2])
# 惩罚机身 z 方向速度
# 物理含义:不希望机器人上下乱弹
return torch.sum(torch.square(self.env.projected_gravity[:, :2]), dim=1)
# 惩罚 projected_gravity 在 x/y 上的偏移
# projected_gravity 越偏,说明机身越倾斜
# 所以这一项本质上是在鼓励“保持平”
执行器相关代价项也很明确:
return torch.sum(torch.square(self.env.torques), dim=1)
# 惩罚大力矩输出
# 对应物理直觉是:高能耗、猛蹬猛拽的动作通常不够优雅,也不利于真实部署
return torch.sum(torch.square(self.env.last_actions - self.env.actions), dim=1)
# 惩罚动作变化过快
# 物理上相当于抑制关节目标角的突变,减少抖动
当前这个项目最有特色的,是和 gait/contact 相关的奖励项。
例如接触力塑形:
foot_forces = torch.norm(self.env.contact_forces[:, self.env.feet_indices, :], dim=-1)
desired_contact = self.env.desired_contact_states
reward += - (1 - desired_contact[:, i]) * (
1 - torch.exp(-1 * foot_forces[:, i] ** 2 / self.env.cfg.rewards.gait_force_sigma))
# 如果某条腿按目标节律“此时不该着地”,但它却产生了较大接触力,就惩罚
# 本质是在用目标步态节律约束足端接触时序
以及足端速度塑形:
foot_velocities = torch.norm(self.env.foot_velocities, dim=2).view(self.env.num_envs, -1)
reward += - desired_contact[:, i] * (
1 - torch.exp(-1 * foot_velocities[:, i] ** 2 / self.env.cfg.rewards.gait_vel_sigma))
# 如果某条腿按目标节律“此时应该着地支撑”,但足端速度还很大,就惩罚
# 物理直觉是:支撑相应该稳,摆动相才该快
还有一个很值得写进博客的是 Raibert heuristic:
err_raibert_heuristic = torch.abs(desired_footsteps_body_frame - footsteps_in_body_frame[:, :, 0:2])
reward = torch.sum(torch.square(err_raibert_heuristic), dim=(1, 2))
# 这里把当前落脚点和“理想落脚点”作比较
# 理想落脚点由目标速度、步频、步宽、步长共同决定
# 本质是在鼓励更像经典腿式机器人控制理论中的合理落脚策略
所以这套 reward 不是简单地“奖励速度”,而是在同时塑造:
- 机身姿态
- 能耗和力矩
- 关节平滑性
- 脚的接触时序
- 脚的摆动轨迹
- 落脚位置是否合理
这正是现代 locomotion 环境的典型特点:reward 是一个行为风格约束器,不只是任务完成器。
四、torque 生成链:action -> joint target -> control model -> torque
现在回到控制链本身。核心函数是 legged_robot.py:907 的 _compute_torques()。
第一步先把网络输出动作缩放成关节目标偏移:
actions_scaled = actions[:, :12] * self.cfg.control.action_scale
actions_scaled[:, [0, 3, 6, 9]] *= self.cfg.control.hip_scale_reduction
# 策略输出通常在 [-1,1] 一类范围
# 这里把它缩放成真实关节角偏移量
# 髋关节单独缩小,是因为髋关节过大摆动更容易造成不稳定
第二步处理控制延迟:
if self.cfg.domain_rand.randomize_lag_timesteps:
self.lag_buffer = self.lag_buffer[1:] + [actions_scaled.clone()]
self.joint_pos_target = self.lag_buffer[0] + self.default_dof_pos
# 动作不会立刻生效,而是先进 lag buffer
# 当前生效的是若干步前的目标角,模拟控制时滞
这一步的物理含义非常重要:
真实机器人里,从策略输出到电机真正执行,中间存在通信、驱动器和机械响应延迟。这个环境不是忽略它,而是直接建模它。
五、actuator_net 在 torque 链路里的位置
当前环境支持两种控制类型:
- 理想 PD
actuator_net
如果是 P:
torques = self.p_gains * self.Kp_factors * (
self.joint_pos_target - self.dof_pos + self.motor_offsets
) - self.d_gains * self.Kd_factors * self.dof_vel
# 标准 PD:位置误差乘 Kp,速度反馈乘 Kd
# 本质上是“弹簧 + 阻尼器”式控制
如果是 actuator_net:
self.joint_pos_err = self.dof_pos - self.joint_pos_target + self.motor_offsets
self.joint_vel = self.dof_vel
torques = self.actuator_network(
self.joint_pos_err,
self.joint_pos_err_last,
self.joint_pos_err_last_last,
self.joint_vel,
self.joint_vel_last,
self.joint_vel_last_last
)
# actuator net 不直接吃 policy action
# 它吃的是关节位置误差、速度,以及它们的时间历史
# 也就是说,它学习的是“执行器动力学映射”,而不是“策略本身”
这一点特别值得强调:
actuator_net 位于 policy 和 physics 之间。
它不是高层策略,也不是 reward 模块,而是一层更接近真实电机的低层近似器。
它的作用是把“目标关节行为”转换成“更接近真实机器人的力矩响应”。
从系统结构看,整条链路是:
policy action -> target joint position -> actuator net / PD -> torque -> physics
而不是很多人直觉上的 “policy action -> torque”。
六、actuator_net 是什么时候加载进环境的
这一层不是在 _compute_torques() 里动态加载的,而是在 buffer 初始化阶段就挂进环境了,见 legged_robot.py:1238:
actuator_path = '.../resources/actuator_nets/unitree_go1.pt'
actuator_network = torch.jit.load(actuator_path).to(self.device)
# 直接加载一个 TorchScript 执行器网络
# 这意味着它可以高效地在 GPU 上大批量推理
然后包装成一个批处理函数:
xs = torch.cat((joint_pos.unsqueeze(-1),
joint_pos_last.unsqueeze(-1),
joint_pos_last_last.unsqueeze(-1),
joint_vel.unsqueeze(-1),
joint_vel_last.unsqueeze(-1),
joint_vel_last_last.unsqueeze(-1)), dim=-1)
torques = actuator_network(xs.view(self.num_envs * 12, 6))
# 每个关节输入 6 维历史特征:3 帧位置误差 + 3 帧速度
# 输出该关节当前应有的力矩
这个设计本身就说明了:
环境作者认为真实执行器不是一个静态线性 PD,而是一个带历史依赖的动态系统。
七、随机化:环境到底随机了什么
随机化逻辑分成三大类。
1. 刚体属性随机化
if cfg.domain_rand.randomize_base_mass:
self.payloads[env_ids] = ...
# 给机身附加载荷,模拟不同负重或机身参数误差
if cfg.domain_rand.randomize_com_displacement:
self.com_displacements[env_ids, :] = ...
# 随机偏移质心位置
# 物理上这会改变机器人惯性和姿态稳定性
if cfg.domain_rand.randomize_friction:
self.friction_coeffs[env_ids, :] = ...
# 随机摩擦系数,模拟不同材质地面或足底接触条件
if cfg.domain_rand.randomize_restitution:
self.restitutions[env_ids] = ...
# 随机恢复系数,模拟接触弹性差异
2. DOF/执行器属性随机化
if cfg.domain_rand.randomize_motor_strength:
self.motor_strengths[env_ids, :] = ...
# 电机输出强度随机化,模拟个体差异或硬件老化
if cfg.domain_rand.randomize_motor_offset:
self.motor_offsets[env_ids, :] = ...
# 电机零偏随机化,模拟编码器零点不准或装配误差
if cfg.domain_rand.randomize_Kp_factor:
self.Kp_factors[env_ids, :] = ...
if cfg.domain_rand.randomize_Kd_factor:
self.Kd_factors[env_ids, :] = ...
# 相当于随机化底层控制器刚度和阻尼
3. 重力与外部扰动随机化
external_force = torch.rand(3, ...) * (max_gravity - min_gravity) + min_gravity
self.gravities[:, :] = external_force.unsqueeze(0)
# 这里不是直接改地球重力常数,而是叠加一个额外“外力式重力扰动”
# 本质上是在让机器人适应轻微的姿态偏置和持续外力
所以这一整套 randomization,覆盖的是:
- 地面接触条件
- 机器人刚体质量分布
- 执行器输出特性
- 控制参数
- 外力和重力环境
这就是 sim-to-real 的核心来源之一。
八、随机化什么时候生效
随机化不是只在 reset 生效,也不完全是每步都生效。
在 reset 时
self._call_train_eval(self._randomize_dof_props, env_ids)
if self.cfg.domain_rand.randomize_rigids_after_start:
self._call_train_eval(self._randomize_rigid_body_props, env_ids)
# 环境 reset 时,机器人可能带着一套新的动力学参数重新开始
在运行过程中周期生效
env_ids = (self.episode_length_buf % int(self.cfg.domain_rand.rand_interval) == 0).nonzero(...).flatten()
self._call_train_eval(self._randomize_dof_props, env_ids)
# 每隔 rand_interval,就刷新一批执行器参数
在环境创建最初也会先随机一次
self._call_train_eval(self._randomize_rigid_body_props, torch.arange(self.num_envs, device=self.device))
self._randomize_gravity()
# 在机器人第一次被放入世界之前,就先给每个环境分配一套物理参数
这说明 randomization 并不是“单一时刻注入”的,而是贯穿:
- 环境初始创建
- episode reset
- 训练过程中的定期刷新
九、地形与环境创建:_create_envs() 真正把机器人放进世界里
环境构造主入口在 legged_robot.py:1481。
先加载机器人资产:
self.robot_asset = self.gym.load_asset(self.sim, asset_root, asset_file, asset_options)
# 从 URDF/MJCF 加载机器人模型
# 这一步决定了关节数、刚体数、碰撞体、惯性参数等基础结构
然后获取 body / dof 名称:
body_names = self.gym.get_asset_rigid_body_names(self.robot_asset)
self.dof_names = self.gym.get_asset_dof_names(self.robot_asset)
# 后面很多奖励、接触判定和关节控制,都是通过这些名字做索引的
再为所有并行环境准备初始原点:
self._call_train_eval(self._get_env_origins, torch.arange(self.num_envs, device=self.device))
# 如果是 rough terrain,就从 terrain map 中读取每个子环境的 origin
# 如果是平地,就按网格排布
接着在循环里,真正为每个 env 创建一个机器人副本:
env_handle = self.gym.create_env(self.sim, env_lower, env_upper, int(np.sqrt(self.num_envs)))
# 在大仿真世界中切出一个子环境槽位
rigid_shape_props = self._process_rigid_shape_props(rigid_shape_props_asset, i)
self.gym.set_asset_rigid_shape_properties(self.robot_asset, rigid_shape_props)
# 给第 i 个环境的机器人注入对应的摩擦/恢复系数等接触参数
anymal_handle = self.gym.create_actor(env_handle, self.robot_asset, start_pose, "anymal", i, ...)
# 真正把机器人 actor 放到子环境里
_create_envs() 的核心不是直接生成地形,而是先加载机器人 asset 作为模板,读取其 DOF、刚体和名称等结构信息;然后结合已经创建好的 terrain,为每个并行 env 选定 origin 和初始 pose,再把同一个 robot asset 实例化成不同 actor,并为每个 actor 注入对应的接触参数、刚体属性和关节属性。
这一步的关键不是“复制机器人”,而是:
每个并行环境里的机器人虽然来自同一个 asset,但它们的接触参数、质量、质心、初始位置都可能不同。
这正是大规模并行 + domain randomization 能成立的原因。
十、Terrain:地形不是一张图,而是一整套“可采样的任务空间”
地形的构造在 terrain.py。
这个项目里的 terrain 先生成一个统一的高度场:
self.height_field_raw = np.zeros((self.tot_rows, self.tot_cols), dtype=np.int16)
# 整个大地图先是一个二维高度矩阵
# 每个子环境只是这张大地图中的一小块
然后按配置决定生成方式:
if cfg.curriculum:
self.curriculum(cfg)
elif cfg.selected:
self.selected_terrain(cfg)
else:
self.randomized_terrain(cfg)
# 地形可以是课程式递增难度,也可以是指定类型,或者完全随机采样
真正的局部地形构造在 make_terrain() 中:
terrain_utils.pyramid_sloped_terrain(...)
terrain_utils.pyramid_stairs_terrain(...)
terrain_utils.discrete_obstacles_terrain(...)
terrain_utils.stepping_stones_terrain(...)
terrain_utils.random_uniform_terrain(...)
# 这些函数分别对应斜坡、楼梯、离散障碍、梅花桩、随机起伏地形
如果 mesh_type == "trimesh":
self.vertices, self.triangles = terrain_utils.convert_heightfield_to_trimesh(...)
# 高度场最终会再被三角化,变成可供物理引擎做碰撞的 mesh
所以这个环境里的 terrain 不只是“机器人脚下的背景板”,而是:
- 决定初始出生点
- 决定接触高度和碰撞反馈
- 决定局部观测中的地形高度采样
- 决定课程学习中“环境难度”如何演化
十一、把这几条线串起来看:环境到底在优化什么世界里的什么行为
如果把本篇的四条主线合起来,LeggedRobot 做的事情其实很清楚:
terrain决定机器人站在怎样的几何世界里randomization决定这个世界和机器人本身有多少物理不确定性_compute_torques()决定 policy action 如何通过控制器和 actuator model 变成真实力矩compute_reward()决定这些动作在这个不确定世界里被如何评价
于是,这个环境训练的就不再是“在固定仿真里跑快一点”,而是:
在带有复杂地形、接触不确定性、执行器延迟和物理参数漂移的世界里,学会一种既能完成命令、又符合目标步态结构、还能保持稳定和可部署性的运动方式。
结语
这一篇如果用一句话总结,就是:
LeggedRobot 的强大不只在于它能跑 Isaac Gym,而在于它把“任务目标、物理随机性、执行器动力学和接触几何”全部揉进了同一个闭环。
reward 决定你想让机器人成为什么样;
randomization 决定这个能力是否能泛化;
terrain 决定它面对的世界有多难;
torque 生成链则决定策略输出如何真正变成可执行的物理动作。
更多推荐



所有评论(0)