Skip to content

第 26 章 网球场环境构建与球物理

本章定位:这是网球机器人综合项目的物理基石。前 25 章积累了完整的环境构建、训练管线和诊断能力——但那些项目的"目标"都是静态的或缓变的(地形不动、命令缓变)。从本章开始,目标在高速运动:一颗时速 20-40 m/s 的网球飞过来,机器人必须在几百毫秒内感知、预测、决策和行动。这种"目标在飞"的设定,会放大前面每一个模块的设计缺陷——坐标系一旦含糊,Ch27 的感知标注就错;球的自由度一旦遗漏,Ch27 的轨迹预测就错;reward 语义一旦与物理几何不一致,Ch28 的策略就会学到错误目标。本章的产物不是"好看的场景",而是后续所有模块共同依赖的数据协议

前置依赖:Ch04(Manager-Based 架构)、Ch05(Obs/Action 设计)、Ch06(Reward/Termination 设计)、Ch15(自定义环境)、MuJoCo XML 基础(freejoint / contact / condim / friction)、经典力学(抛体运动 / 接触恢复系数)

关键参考:🔧 mjlab Tennis Launcher 内置示例 · ✅ HUSKY(RSS'26,github.com/TeleHuman/humanoid_skateboarding,基于 mjlab 的人形全身控制范式)· ✅ LATENT(arXiv:2603.12686,github.com/GalaxyGeneralRobotics/LATENT,人形网球,MuJoCo JAX,2000 Hz 仿真)· 📄 ETH 羽毛球(Science Robotics, 2025,ANYmal-D 四足+球拍)· 📄 Phybot 人形羽毛球(arXiv:2511.11218,三阶段 curriculum)· 📄 HITTER(arXiv:2508.21043,人形乒乓球,Unitree G1,50 Hz 控制)· 📄 CyboRacket(arXiv:2603.14605,人形球类感知-动作框架)


前置自测

📋 答不出 ≥ 3 题 → 先回 Ch04/Ch15 复习

  1. [Ch04] mjlab 的九大 Manager 中,哪个 Manager 负责在 episode reset 时设置随机初始条件?
  2. [Ch15] 从零构建一个自定义环境需要几步?最容易遗漏的环节是什么?
  3. [MuJoCo] freejoint 赋予 body 几个自由度?如果去掉 freejoint,body 会怎样?
  4. [物理] 抛体运动中,忽略空气阻力时水平方向为什么是匀速直线运动?竖直方向的加速度是多少?
  5. [Ch05] env_origins 在多环境并行中的作用是什么?observation 读取和 state 写入时分别如何处理?

本章目标

学完本章后,你应该能够:

  1. 画出 court-local 坐标系,标注球场中心、网、baseline、service line 的精确位置和 ITF 标准尺寸
  2. 解释 为什么 MuJoCo 中网球必须使用 freejoint,以及 condimfrictionsolrefsolimp 四个接触参数的物理含义和调参方法
  3. 写出 八维发球命令的速度分解公式,解释 \(v_x\) 为什么带负号
  4. 实现 三层空气动力学模型(真空 → 二次阻力 → Magnus 力),并用实验验证每层的增量贡献
  5. uv run 命令运行从 smoke test 到弹道验证的完整实验序列
  6. 设计 后续 strike task(Ch28)的机器人+球拍 MJCF 建模方案,规划阶段化引入路线

26.1 综合项目为什么从环境开始 ⭐

这一节解决什么问题:为什么不直接写控制算法,而要花一整章讨论"场景搭建"?

动机:环境是跨模块的数据契约

综合项目与单一任务的本质区别在于模块间耦合。Ch13 的四足 velocity tracking 只有一个模块——所有 observation、reward、termination 都由同一个 env_cfg 定义,出了问题改一处即可。网球项目则不同——感知(Ch27)、预测(Ch27)、控制(Ch28)三个模块共享同一套坐标系、同一组物理参数、同一个 reset 协议。如果球场坐标在环境层定义得含糊,感知模块可能把 \(y\) 轴正方向解释反,预测模块可能把发球方向搞错,控制模块可能把落点区域画偏。

这些错误不会在单个模块的 loss 曲线上显现——检测 loss 可能很低,预测 RMSE 可能在正常范围,控制 reward 可能缓慢上升——但系统级测试时策略会持续打空。

类比理解:这就像建筑施工中的地基和图纸。地基歪了一度,一楼看不出来,到二十楼就偏了好几米。网球场环境就是综合项目的地基,坐标系和物理参数就是施工图纸。后续每个模块都在这张图纸上施工,图纸错了,模块做得再精致也对不齐。这个类比的边界在于:建筑地基歪了无法修正,而仿真环境可以修改——但修改环境意味着所有下游模块都要重新验证,代价随项目规模指数增长。

本质洞察:综合项目的环境不是"好看的场景渲染", 而是跨感知、预测、控制、训练共同依赖的数据协议。 坐标和语义一旦含糊,后续模块会用更复杂的模型把错误放大, 而非修正错误。

如果不从环境开始会怎样

反事实 1:先写控制再补环境。 控制算法基于某种隐含的坐标假设开发。等到集成测试时发现感知模块的坐标和控制模块的坐标不一致。此时修改任何一方都可能引入新 bug,因为两个模块的内部逻辑已经各自"适应"了各自的坐标约定。这种 bug 在强化学习中尤其隐蔽——策略可能学到了补偿坐标偏差的行为,修正坐标后反而性能下降,开发者陷入"改了环境策略就崩"的困境。

反事实 2:不验证 reward 语义就开始训练。 把"球落在发球区"写进 reward 配置,但代码里的区域其实是整条 near-side backcourt(合并了两个 service box 且不区分 deuce/ad)。策略学会把球打到深区,日志显示 reward 很高——但拿标准 ITF 发球规则一检查,落点根本不是合法发球。

反事实 3:不验证 freejoint 就写轨迹预测。 球被建成普通 body 而非 freejoint body。MuJoCo 中没有 joint 的 body 相对于其 parent 是刚性焊接的——无论你怎么写入速度,球都不会飞。训练日志不会报错,observation 里的速度可能短暂变化(因为写入操作改了状态向量),但视觉上球始终留在原地。PPO 不会自动发现这种 bug。

当前事实边界

src/mjlab/tasks/tennis/ 中的 Phase 1 示例提供了以下能力:注册了 Mjlab-Tennis-Launcher task,加载了 tennis_court.xml,创建了一个 freejoint 网球,在 reset 时采样八维 LaunchCommand,观测球的位置、线速度、角速度,有 near-side 合并 service-area reward,有 timeout / 撞网 / 停止 / 越界 termination。但它没有机器人,没有球拍,没有可学习动作空间(actions 字典为空),没有训练成功的击球策略。本章始终区分两类对象:当前仓库已经存在的对象,以及后续综合项目应该设计的对象。

前沿系统的仿真频率选择

在设计环境参数之前,有必要了解前沿系统对仿真频率的要求——因为这直接决定了 timestepdecimation 的配置。

系统 物理引擎 仿真频率 控制频率 球速范围 接触时间
LATENT(网球,arXiv:2603.12686) MuJoCo JAX 2000 Hz 50 Hz 15-30 m/s 3-5 ms
HITTER(乒乓球,arXiv:2508.21043) Isaac Lab 50 Hz 5-15 m/s ~1 ms
ETH 羽毛球(Science Robotics'25) Isaac Gym 1000 Hz 100 Hz ≤12 m/s ~5 ms
Phybot 羽毛球(arXiv:2511.11218) Isaac Gym 1000 Hz 50 Hz ≤10 m/s ~5 ms
当前 mjlab Tennis MuJoCo Warp 500 Hz 100 Hz 20-45 m/s 2-4 ms

LATENT 明确指出:"simulation frequency is set to 2000 Hz to accurately model ball-racket and ball-ground contact"。这不是随意选择——网球拍-球接触仅持续 3-5 ms,如果仿真步长为 0.001 s(1000 Hz),一次接触只有 3-5 个仿真步来解算接触力,可能不足以准确捕捉冲量传递。2000 Hz(0.0005 s 步长)保证了每次接触至少有 6-10 个仿真步。

当前 mjlab Tennis 的 timestep=0.002 对应 500 Hz——对纯弹道飞行足够,但对球拍-球接触可能不足。后续 Ch28 进入击球训练时需要考虑提高仿真频率。

Isaac Lab 中的网球环境对照

当前 mjlab 提供了 Tennis Launcher 内置示例,但 Isaac Lab 没有对应的内置网球任务。如果你需要在 Isaac Lab 中搭建类似环境,核心差异在于:

维度 mjlab(MuJoCo) Isaac Lab(PhysX)
模型格式 MJCF XML + MjSpec Python USD + Python spawn cfg
碰撞参数 condim / friction / solref / solimp PhysicsMaterial(static_friction / dynamic_friction / restitution)
自由体 freejoint RigidObjectCfg(根 prim 需应用 USD RigidBodyAPI)
外力施加 mjData/Entity 写外力 buffer set_external_force_and_torque() + write_data_to_sim()
接触检测 mjData.contact + 语义检测 ContactSensorCfg
仿真频率 直接设 timestep 通过 PhysxCfg.dtsim.substeps 控制

Isaac Lab 的 PhysX 接触模型与 MuJoCo 有本质区别——PhysX 使用 restitution coefficient 直接控制弹跳(对应 COR),而 MuJoCo 使用 solref 间接控制。这意味着在 Isaac Lab 中标定 COR 更直接——直接设 restitution = 0.75 即可,不需要像 MuJoCo 那样通过 solref 迂回标定。

但 Isaac Lab 的一个劣势是 PhysX 对薄体碰撞的处理不如 MuJoCo——PhysX 在某些配置下会出现"ghost contacts"(幽灵接触),特别是高速小物体撞击薄面时。这在网球+球拍场景中可能是问题。LATENT 选择 MuJoCo(而非 Isaac Gym/PhysX)正是因为 MuJoCo 的接触求解器在这种场景下更稳定。

双框架的教学价值在于:如果你的策略在两个物理引擎中都表现良好,说明策略学到了"任务本质"而非"引擎特性"——这是 Sim2Real 鲁棒性的一个重要指标。Ch23(Sim2Real)中讨论的 sim-to-sim 交叉验证就是基于这个理念。

⚠️ 常见陷阱

  • 环境中的坐标系、接触参数、termination 语义是所有后续模块的根基
  • 正确认知:环境构建是综合项目中杠杆效应最大的环节——这里每节省 1 小时的仔细验证,后面可能付出 10 小时的调试代价

⚠️ 编程陷阱:在 Phase 1(无 action)task 上跑 PPO 训练 - actions: dict = {} 意味着策略没有动作维度,训练无意义 - 正确做法:Phase 1 只用 zero/random agent 做物理验证

练习

  1. [思考题] 如果你的团队有三个人分别负责感知、预测、控制模块,他们需要共享哪些环境信息?列出至少 5 项。如果其中一项在环境层定义含糊,给出一个具体的 bug 场景。
  2. [设计题] 对比 LATENT 的 2000 Hz 仿真和当前 mjlab Tennis 的 500 Hz。如果一颗球以 30 m/s 速度撞上球拍,在两种频率下各有多少仿真步用于解算接触?哪种可能导致接触力不准?

上节建立了"环境是数据协议"的认知。接下来我们进入协议的第一个核心元素——球场的几何建模和坐标系约定。这决定了后续所有模块读写数据的"坐标语言"。

26.2 网球场 MJCF 几何建模——坐标系与碰撞分离 ⭐⭐

这一节解决什么问题:从 MJCF XML 设计角度理解网球场的几何建模,建立全项目共享的坐标系约定。

动机:球场不是装饰——它是坐标系约定

网球场是整个项目的坐标参考系。当你说"球落在发球区"、"球过了网"、"机器人在底线附近"时,这些语义全部依赖于球场几何定义的坐标系。如果球场坐标系改了,所有 reward、termination、observation 的几何判断都要同步修改。

当前 mjlab Tennis Launcher 的 XML 把 court center 放在世界原点 \((0, 0, 0)\): - \(x\) 轴:沿球场长边。球从远端(\(x > 0\))飞向近端(\(x < 0\))。网在 \(x = 0\) - \(y\) 轴:沿球场宽边。右侧为正 - \(z\) 轴:向上。球场地面在 \(z = 0\)

ITF 标准网球场的关键几何数字(这些数字在后续 reward、termination 和感知标注中反复出现):

元素 坐标 / 尺寸 精确值 在代码中的用法
球场总长 \(x\) 轴方向 23.77 m(78 feet) env_spacing 下限
半场长 中线到 baseline 11.885 m 发球初始位置
单打半宽 中线到单打边线 4.115 m reward box \(y\) 范围
双打半宽 中线到双打边线 5.485 m oob termination \(y\) 范围
发球线距网 网到 service line 6.40 m reward box \(x\) 范围
网高(中央) \(z\) 0.914 m(3 feet) 撞网 termination 阈值
网高(柱端) \(z\) 1.067 m(3.5 feet) 网 geom 高度
<!-- tennis_court.xml 的核心结构(简化) -->
<mujoco model="tennis_court">
  <worldbody>
    <body name="court" pos="0 0 0">
      <!-- 球场地面 -->
      <geom name="court_surface" type="plane" size="12 6 0.01"
            rgba="0.2 0.4 0.7 1" condim="3"
            friction="0.6 0.005 0.001"/>

      <!-- 网(碰撞 + 视觉) -->
      <geom name="net_collision" type="box"
            pos="0 0 0.457" size="0.01 5.5 0.457"
            contype="1" conaffinity="1"
            rgba="1 1 1 0.3"/>

      <!-- 底线(仅视觉,contype=0 不参与碰撞) -->
      <geom name="baseline_far" type="box"
            pos="11.885 0 0.001" size="0.025 5.485 0.001"
            contype="0" conaffinity="0"
            rgba="1 1 1 1"/>

      <!-- 发球线(仅视觉) -->
      <geom name="service_line_near" type="box"
            pos="-6.40 0 0.001" size="0.025 4.115 0.001"
            contype="0" conaffinity="0"
            rgba="1 1 1 1"/>

      <!-- ... 更多线条和网柱 ... -->
    </body>
  </worldbody>
</mujoco>

碰撞分离原则:视觉 geom 和物理 geom 的分离

反事实推理:如果把所有球场线条的 contype 都设为 1(参与碰撞),会发生什么?球滚过底线时会被一条 0.05 m 宽、0.002 m 高的白线"绊住"——球会被线的边缘碰撞体弹起来,产生完全不真实的物理行为。在现实中,球场线条是漆在地面上的——它们没有物理高度,不会影响球的运动。

碰撞分离原则:

geom 类型 contype conaffinity 用途
球场地面(court_surface) 1 1 球弹跳的物理接触面
网碰撞体(net_collision) 1 1 球-网接触检测
所有线条(baseline/service/center 等) 0 0 仅视觉渲染
网柱(net_post) 1 1 偶尔球击中网柱
网带(net_tape) 0 0 仅视觉(网顶白带)

这个分离在 Isaac Lab 中同样适用——虽然 USD 格式的碰撞配置方式不同(通过 Physics API 的 Collider 组件),但核心思想一致:渲染几何体和碰撞几何体分离。

网的建模:刚性近似与薄体问题

网是球场中物理建模最棘手的元素。真实的网球网是柔性编织物,球撞网后会产生变形和能量吸收。但在仿真中引入柔性网会极大增加计算复杂度——织物仿真本身就是一个完整的研究方向。

工程决策:用刚性 box 近似网。这意味着球撞网后会弹回(而非被网兜住),但对 RL 训练来说,撞网后的行为不重要——因为撞网就是 termination,episode 结束了。重要的是检测球是否撞了网

网 box 的参数选择需要权衡三个因素:

<!-- 网碰撞体的参数:
     pos 网中心高度 = 0.914/2 = 0.457;
     size 为 half-size(半厚度 1cm, 半宽 5.5m, 半高 0.457m);
     condim=1 仅法向力(球撞网不需要摩擦);solref 0.001 为硬接触 -->
<geom name="net_collision" type="box"
      pos="0 0 0.457"
      size="0.01 5.5 0.457"
      contype="1" conaffinity="1"
      condim="1"
      solref="0.001 1.0"
      rgba="1 1 1 0.3"/>

网的厚度(size[0] = 0.01 m = 1 cm):这是物理碰撞体的厚度,不是视觉厚度。太薄(< 1 mm)→ 高速球可能穿过(tunnel effect);太厚(> 5 cm)→ 球在接近网顶时被过早拦截(本应过网的球被厚 box 拦住)。1 cm 是一个合理的折中。

网的 condim=1:仅法向力。球撞网后反弹方向由法向决定即可——不需要球在网面上"滑动"(condim=3)。这也减少了碰撞求解的计算量。

语义撞网检测的必要性:即使有了刚性网 box,高速球(> 25 m/s)在 500 Hz 仿真下单步移动 5 cm,仍然可能穿过 1 cm 厚的 box。因此 26.6 节的语义撞网检测是必要的安全层——它检查球心轨迹是否穿过 \(x=0\) 平面,不依赖物理碰撞。

球场标线的 site 定义

除了 geom(有碰撞和视觉属性),MuJoCo 还提供 site——一种只有位置和方向信息但无碰撞和质量的标记点。site 非常适合定义球场的关键几何位置,供后续的 reward、observation 和 termination 计算引用:

<!-- 关键位置 site(不参与物理,仅提供位置参考) -->
<site name="net_center" pos="0 0 0.914" type="sphere" size="0.01"/>
<site name="service_box_center_deuce" pos="-3.2 -2.06 0" type="sphere" size="0.01"/>
<site name="service_box_center_ad" pos="-3.2 2.06 0" type="sphere" size="0.01"/>
<site name="baseline_near_center" pos="-11.885 0 0" type="sphere" size="0.01"/>
<site name="baseline_far_center" pos="11.885 0 0" type="sphere" size="0.01"/>

在代码中可以通过 env.scene.get_site_position("net_center") 获取这些位置——比硬编码数字更健壮(修改 XML 后代码自动更新)。AGILE(Ch25)使用类似的 site/descriptor 方法来管理环境的语义几何。

env_spacing 与多环境布局

多环境并行时,每个环境的球场在世界坐标中偏移 env_originsenv_spacing 必须大于球场对角线长度,否则球飞出一个环境后可能进入相邻环境的碰撞区域。

# env_spacing 计算
import math
court_diagonal = math.sqrt(23.77**2 + 10.97**2)  # ≈ 26.2 m
# 需要留余量(球可能飞出底线 3-5 m)
recommended_spacing = 30.0  # 当前设置

反事实推理:如果 env_spacing = 15.0(小于球场长度 23.77 m),多环境时球场会重叠。球从 env 0 飞出底线后可能进入 env 1 的碰撞区域,产生"幽灵碰撞"——球突然改变方向或被不存在的障碍物弹回。这种 bug 极难定位,因为单环境测试完全正常。

如果坐标系搞反会怎样

反事实推理:假设你把发射方向写成 \(+x\)(球从近端向远端飞)。球会从 baseline 向场外飞。reward 仍然计算 near-side service area(\(x < 0\)),于是 reward 长期为零。你可能误以为速度太快或角度太高,实际上只是符号错了。这种 bug 极难从数值日志发现——你需要看 viewer 才能注意到球飞错了方向。

在 HUSKY(RSS'26)的代码中,坐标系约定通过注释和常量定义在文件开头——这是一个值得借鉴的工程实践:把坐标约定作为代码级文档固化下来,而不是只写在 README 里。

⚠️ 常见陷阱

⚠️ 编程陷阱:球场线条 geom 的 contype 未设为 0 - 后果:球滚过线条时被"绊住"或不自然弹起 - 正确做法:所有视觉装饰 geom 设 contype=0, conaffinity=0

⚠️ 概念误区:认为 condim 对所有碰撞面应该相同 - 球场地面用 condim=3(切向摩擦 + 法向),网面可能需要 condim=1(仅法向)因为球-网接触不需要切向摩擦建模 - 正确做法:按物理语义选择 condim

⚠️ 编程陷阱:env_spacing 太小导致球飞到邻场 - 后果:多环境时球场重叠产生幽灵碰撞 - 正确做法:env_spacing ≥ court_diagonal + max_ball_overshoot,当前 30.0 m 是安全的

⚠️ 思维陷阱:只在单环境下验证物理 - 有些 bug 只在多环境时暴露(env_origins 处理、spacing 不足) - 正确做法:验证流程必须包含 --num-envs 16 测试

练习

  1. [手算题] 标准网球场单打区域的面积是多少平方米?near-side 两个 service box 的合并面积是多少?如果 reward 区域设为合并 service box,策略学到的"合法发球"占标准单打区域的百分比?
  2. [操作题] 修改 tennis_court.xml 中 baseline 的 contype 从 0 改为 1,在 viewer 中观察球滚过底线时的行为变化。恢复后记录差异。
  3. [设计题] 如果要在 Isaac Lab 中实现同样的网球场,USD 格式下如何实现"碰撞分离"?提示:查阅 PhysX 的 Collider 组件和 visual_material

球场定义了坐标系,接下来定义坐标系中的运动物体——网球。网球作为自由刚体,它的 MuJoCo 建模方式直接决定了后续所有弹道计算和接触物理的基础。

26.3 网球自由体 Entity 与 freejoint 物理 ⭐⭐

这一节解决什么问题:从 MuJoCo 物理建模角度理解网球的自由刚体表示,掌握 freejoint、质量、惯量和碰撞参数的工程含义。

动机:为什么网球必须是 freejoint body

在 MuJoCo 中,body 的运动自由度由其 joint 类型决定。没有 joint 的 body 相对于 parent body 是刚性焊接(welded)的——即使你在代码中向它写入速度,它也不会移动。网球需要在三维空间中自由平移和旋转,因此必须使用 freejoint——它赋予 body 完整的 6 个自由度(3 平移 + 3 旋转)。

反事实推理:如果球没有 freejoint 会怎样?球被建成 court body 的子 body 并且没有 joint,它就被焊接在球场上。你在 _resample_command() 中写入的初始速度会修改 qvel 数组的对应位置,但 MuJoCo 在下一个仿真步中会因为"这个 body 没有 joint"而忽略速度——球就像被钉在初始位置上。如果你只看 WandB 日志而不看 viewer,ball_velocity obs 可能在 reset 后的第一步显示非零值(因为 qvel 被写入了),但第二步就变成零了。这种 bug 极其隐蔽。

freejoint 的状态表示

自由球体的状态由两个 MuJoCo 数组描述:

数组 维度 含义 顺序
qpos 7 位置 + 姿态四元数 [x, y, z, qw, qx, qy, qz]
qvel 6 线速度 + 角速度 [vx, vy, vz, wx, wy, wz]

关键工程陷阱:MuJoCo 的四元数顺序是 [qw, qx, qy, qz](标量在前),而 PyBullet、ROS tf2 等系统使用 [qx, qy, qz, qw](标量在后)。如果你从其他框架迁移代码并把四元数顺序搞混,球的初始姿态就是错的。对于球体来说,视觉上可能看不出区别(球是各向同性的),但 body frame 的方向会错——这意味着 ball_angular_velocity 在 body frame 下的读数和 world frame 的转换关系全部错位。在 LATENT 的代码中,作者专门在 README 中标注了四元数顺序约定——这是跨框架协作时的必要文档。

球体物理参数

当前 _get_ball_spec() 在 Python 中构造 MjSpec(而不是从 XML 加载),球体参数直接写在任务代码中。这种"代码构造"而非"XML 加载"的方式有一个重要优势:参数可以在运行时通过 CLI 或配置 override 修改,不需要编辑 XML 文件。

# mjlab Tennis Launcher 中球体 Entity 的创建(简化)
def _get_ball_spec(self):
    spec = MjSpec()
    body = spec.worldbody.add_body(name="ball")
    body.add_freejoint(name="ball_freejoint")
    body.add_geom(
        name="ball_geom",
        type="sphere",
        size=[_BALL_RADIUS],  # 0.033 m(直径 6.6 cm)
        mass=_BALL_MASS,       # 0.057 kg
        condim=3,              # 3-DOF 接触:法向 + 切向滑动摩擦
        friction=[0.6, 0.005, 0.001],  # [sliding, torsional, rolling];注意 condim=3 下仅 sliding(0.6) 生效,后两者需 condim>=4/6 才起作用
        rgba=[0.8, 1.0, 0.0, 1.0],     # 网球黄色
    )
    return spec

MjSpec API 解析——这段代码做了什么:

  1. MjSpec() 创建一个空的 MuJoCo 模型规格(相当于一个空 XML)
  2. spec.worldbody.add_body("ball") 在 worldbody 下添加一个 body——这个 body 是球场中"移动的东西"
  3. body.add_freejoint("ball_freejoint") 赋予 body 完整 6-DOF 自由度——这一行是球能飞的根本原因
  4. body.add_geom(...) 定义球的物理属性——形状(sphere)、大小(0.033 m 半径)、质量(0.057 kg)、碰撞参数

如果忘记 add_freejoint:body 相对于 worldbody 是 welded(刚性焊接)。球永远不会移动——即使你在代码中写入速度,MuJoCo 也会在下一步忽略它。这是 Phase 1 验证中最容易遗漏的一行代码。

为什么用 Python 动态构造而非 XML? 两种方式在功能上等价。XML 适合静态模型(球场、网)——几何形状固定,不需要运行时修改。Python API(MjSpec)适合动态生成的模型——例如你可能想在不同实验中用不同半径的球,通过 CLI --ball-radius 0.04 修改。mjlab 的 EntityCfg 支持两种方式混用——court 用 XML,ball 用 MjSpec。

每个参数的物理含义和选择依据:

半径 _BALL_RADIUS = 0.033 m(对应直径 6.6 cm)。ITF 规格要求球直径 6.54-6.86 cm,取中值。这个尺寸对仿真有一个关键影响:球太小,高速碰撞时可能在一个 timestep 内穿过薄几何体(tunnel effect)。在 timestep=0.002 s 下,速度 30 m/s 的球单步移动 0.06 m——接近球直径的两倍。如果网的碰撞体厚度小于 0.06 m,球可能穿网。

质量 _BALL_MASS = 0.057 kg(57 g)。ITF 规格要求 56.0-59.4 g。质量影响两个方面:(1) 惯量矩阵决定角速度对外力矩的响应——球越轻,旋转加速越快;(2) 球-拍质量比约 1:6(球拍 ~300 g),高刚度接触在这种质量比下可能产生数值振荡。

惯量矩阵的自动计算:MuJoCo 从 masstype=sphere 自动计算转动惯量 \(I = \frac{2}{5}mr^2\)。对标准网球:\(I = \frac{2}{5} \times 0.057 \times 0.033^2 = 2.48 \times 10^{-5}\) kg·m²。你不需要手动指定——只有非均质体(如球拍,质心不在几何中心)才需要手动通过 inertia 属性调整。

condim=3:三维接触模型——1 维法向力 + 2 维切向滑动摩擦力。这对球-地面接触基本合适:切向滑动摩擦既让滑动的球减速,也在接触点耦合球的线速度与角速度(topspin 弹跳所需)。但要注意,condim=3 不包含扭转摩擦(torsional,需 condim=4)和滚动摩擦(rolling,需 condim=6)——所以一个纯滚动的球在 condim=3 平面上不会因滚动阻力而停下(只有滑动阶段才被滑动摩擦减速)。如果改为 condim=1(仅法向),球落地后会像冰上一样无限滑动。如果需要真正的滚动衰减/停球,应使用 condim=6 或额外的线速度/角速度阻尼、事件近似;对网球这类只需弹跳定性合理的场景,condim=3 通常已足够。

condim 选择的决策树:

球-地面:condim=3(需要滑动摩擦让球减速和改变方向)
球-网:  condim=1(仅需法向弹回,不需要球在网上"滑动")
球-球拍:condim=3(需要摩擦来传递旋转——正手 topspin 靠的就是摩擦)
球-网柱:condim=1(仅需弹回)

friction=[0.6, 0.005, 0.001]:三个分量对应 MuJoCo 接触模型的三种摩擦,但实际生效的维数由 condim 决定——condim=3 只用第一个(sliding),第二、三个分别要 condim>=4condim=6 才被使用。

分量 类型 物理含义 何时生效
第一个 滑动摩擦(sliding) 0.6 球与地面的滑动阻力系数。硬地约 0.5-0.7,草地更高 condim>=3
第二个 扭转摩擦(torsional) 0.005 球绕法向旋转时的阻力。对球体影响小 condim>=4
第三个 滚动摩擦(rolling) 0.001 球滚动时的阻力。太大球会很快停住 condim=6

这些参数不是精确网球材质模型——它们是 MuJoCo 接触模型中的近似。真实网球的弹跳行为远比这复杂(线毛摩擦、球体变形、场地纹理),但对于 RL 训练来说,只要弹跳定性合理(弹起方向、高度、速度衰减趋势)就足够了——策略会通过 domain randomization 适应真实物理的偏差。

为什么用 sphere 而非 mesh

MuJoCo 的 primitive collision(sphere、box、cylinder、capsule)比 mesh collider 效率更高、数值更稳定。网球是近似球体,用 sphere geom 既准确又快速。如果后续需要给球加 seam texture(球的缝线标记,用于旋转可视化)或 spin marker,可以加一个额外的 visual geom(contype=0)用 mesh 实现,物理 geom 保持 sphere。

在 Isaac Lab(PhysX)中,球体的建模方式类似但有细节差异。PhysX 使用 SphereGeometry 作为碰撞形状,但材质参数(restitution、friction)通过 PhysicsMaterialCfg 单独配置:

# Isaac Lab 中球体 Entity 的配置(伪代码)
class BallCfg(RigidObjectCfg):
    spawn = UsdFileCfg(
        usd_path="tennis_ball.usd",
        rigid_props=RigidBodyPropertiesCfg(
            solver_position_iteration_count=4,
            solver_velocity_iteration_count=1,
        ),
        collision_props=CollisionPropertiesCfg(
            contact_offset=0.002,
            rest_offset=0.0,
        ),
        mass_props=MassPropertiesCfg(mass=0.057),
    )
    init_state = RigidObjectCfg.InitialStateCfg(
        pos=(11.0, 0.0, 2.5),
    )

solref 与 solimp:接触刚度和阻尼

除了 friction,MuJoCo 接触模型还有两个关键参数组:solrefsolimp。它们控制接触力的"感觉"——球落地后弹起有多高、有多快。

solref = [timeconst, dampratio]:定义接触约束的参考动力学。timeconst 是接触的时间常数(秒),dampratio 是阻尼比。直觉:timeconst 越小,接触越"硬"(弹起越快);dampratio 越大,能量耗散越多(弹起越低)。

MuJoCo 的两种 solref 格式:如果两个值都是正的(如 [0.02, 1.0]),MuJoCo 按 timeconst/dampratio 模式解释。如果两个值都是负的(如 [-3500, -2.0]),MuJoCo 按 direct format 解释——直接指定约束的刚度(stiffness)和阻尼。当前 mjlab tennis 仓库使用 direct format solref=(-3500.0, -2.0)——这组值经过调节,使 2.54 m 落球的弹跳接近真实网球量级。教学中建议先用 timeconst/dampratio 格式(更直观),熟悉后可切换到 direct format(更精确控制)。

# 不同 solref 对球弹跳的影响
# 硬接触(快弹起,高恢复)
solref_hard = [0.001, 1.0]   # 1 ms 时间常数,临界阻尼

# 默认接触
solref_default = [0.02, 1.0]  # 20 ms 时间常数

# 软接触(慢弹起,低恢复)
solref_soft = [0.05, 1.5]    # 50 ms,过阻尼

solimp = [d_min, d_max, width, midpoint, power]:定义接触穿透-力响应曲线的形状。当前仓库中的球-地面接触使用 solimp=(0.95, 0.99, 0.001, 0.5, 2.0)——前两个值 (0.95, 0.99) 定义约束穿透的响应范围,后三个控制约束力的缩放行为。这些参数与 solref 共同决定了弹跳表现。对大多数教学用途,使用默认值即可;只有在弹跳行为明显不合理时才需要调整。

网球的弹跳恢复系数(Coefficient of Restitution, COR)在不同场地类型上差异显著:

场地类型 COR 范围 弹起高度比 对应 solref dampratio
硬地(Hard Court) 0.73-0.75 ~55% 1.0-1.2
草地(Grass) 0.60-0.65 ~40% 1.5-1.8
红土(Clay) 0.80-0.85 ~65% 0.8-1.0

COR 和 MuJoCo 的 solref 之间不是简单的线性关系——需要通过实验标定。方法:从标准高度(ITF 规定 2.54 m = 100 inches)自由落体,测量弹起高度比。在 MuJoCo 中:

# COR 标定实验
# 1. 从 2.54 m 高度释放球(初速 0),关闭空气阻力
# 2. 记录第一次弹起后的最高点高度 h_bounce
# 3. COR = sqrt(h_bounce / h_drop)
# 4. 调整 solref 直到 COR 在目标范围内

双重解读:球体参数的"物理精度"与"训练鲁棒性"

对球体参数有两种对立的设计哲学:

  • 物理精度派:精确标定 COR、friction、drag 系数,让仿真尽可能逼近真实物理。这需要大量实验数据和繁琐的标定流程。
  • 训练鲁棒性派:参数取大致合理的范围,然后用 domain randomization(Ch08)在训练中随机化这些参数。策略学会在参数不确定性下工作,Sim2Real 时自然适应真实物理。

LATENT 采用后者——他们的论文明确指出"dynamics randomization of the ball"是 Sim2Real 成功的必要条件,去掉后成功率从 91% 骤降到 14-29%。这意味着精确标定球参数的价值远低于在训练中随机化它们。本章的球体参数选择一个"合理的默认值"即可——后续 Ch28 会用 DR 覆盖这些参数的不确定性。

本质洞察:仿真物理参数不需要完美匹配真实世界—— 它们需要定性合理(球弹起而非穿地、旋转方向正确), 然后用 Domain Randomization 让策略对参数偏差鲁棒。 精力应该花在 DR 范围设计上,而非精确标定上。

⚠️ 常见陷阱

⚠️ 编程陷阱:ball pose 写入的四元数顺序错误 - MuJoCo qpos 用 [x,y,z,qw,qx,qy,qz],PyBullet/ROS 用 [x,y,z,qx,qy,qz,qw] - 后果:球的初始 body frame 方向错误,body-frame 观测(角速度)全部失真 - 正确做法:在代码中用常量或 utility 函数显式标注四元数约定

⚠️ 概念误区:认为球的惯量矩阵需要精确计算 - 对均质球体,\(I = \frac{2}{5}mr^2\) 完全足够 - MuJoCo 从 mass 和 geom shape 自动计算惯量,不需要手动指定 - 只有非均质体(如球拍,质心不在几何中心)才需要手动调整惯量

⚠️ 编程陷阱:condim=1 导致球在地面上永不停止 - condim=1 只有法向力,没有切向摩擦——球落地后会像冰上一样无限滑动 - 正确做法:球-地面接触至少 condim=3

⚠️ 思维陷阱:花大量时间精确标定接触参数 - 精确标定的价值远低于 domain randomization - 正确做法:定性合理即可(球弹起、摩擦减速),然后在训练中随机化

练习

  1. [手算题] 对均质球体(\(m = 0.057\) kg,\(r = 0.033\) m),计算转动惯量 \(I = \frac{2}{5}mr^2\)。如果球以 200 rad/s 的角速度旋转(对应约 1900 rpm,网球 topspin 的典型值),其旋转动能是多少?与 20 m/s 直线运动的动能相比如何?
  2. [实验题] 在 mjlab 中运行 COR 标定实验:从 2.54 m 高度释放球,记录弹起高度。然后分别尝试 solref=[0.005, 1.0]solref=[0.02, 1.0]solref=[0.02, 1.5],观察 COR 如何变化。
  3. [设计题] 如果要为 Isaac Lab 创建相同的球体 Entity,列出需要配置的所有 PhysX 参数及其对应的 MuJoCo 参数。哪些参数有直接对应?哪些没有?

球体定义了"什么在飞",接下来定义"怎么飞起来"——LaunchCommand 是控制球初始状态的命令生成器,它决定了每个 episode 的随机发球参数。

26.4 LaunchCommand:八维随机发球命令 ⭐⭐⭐

这一节解决什么问题:理解发球命令的八维参数空间、速度分解公式,以及如何在 mjlab 中实现 CommandTerm。

动机:为什么需要随机发球

如果每次 episode 都用完全相同的发球参数(固定速度、固定角度、固定位置),策略只需要学会"对这一种球做出反应"。这样的策略在真实场景中面对不同的来球时会完全失效。随机发球确保策略需要泛化到各种来球条件。

回顾 Ch06:velocity tracking 任务中,VelocityCommand 在每个 episode 随机采样目标速度。Tennis Launcher 的 LaunchCommand 类比为"发球版的 VelocityCommand"——只是参数空间从 3 维速度变成了 8 维发球参数。

八维参数空间

维度 参数 单位 默认范围 物理含义
0 speed m/s [20, 45] 初速大小
1 elevation deg [-8, 2] 仰角(正值向上)
2 azimuth deg [-10, 10] 左右散布角
3 x m [10.5, 11.5] 发球 x 位置(远端 baseline 附近)
4 y_offset m [-3, 3] 发球 y 偏移(横向位置)
5 height m [1.5, 3.0] 发球高度
6 topspin rad/s [-100, 300] 上旋角速度(正值 topspin)
7 sidespin rad/s [-100, 100] 侧旋角速度

每个参数范围的物理依据

参数范围不是随意选择的——每个都有物理或规则依据:

speed [20, 45] m/s:20 m/s 对应慢速二发(约 72 km/h),45 m/s 对应中速一发(约 162 km/h)。职业球员一发速度可达 60+ m/s(216 km/h),但对教学项目来说 45 m/s 已经足够快。低于 20 m/s 的球在 Phase 1 中可能因为太慢而被阻力大幅减速,弹道变得不像"发球"。

elevation [-8, 2] deg:负值表示向下倾斜——这是真实发球的典型角度。发球者站在 baseline 后方,球拍击球点高于网(约 2.5-3.0 m),因此发球方向是向下的。-8° 约是一发的典型角度(快速平击),2° 是 kick serve(带上旋的缓速发球,球稍微向上以确保过网后大幅下坠)。如果设为正大值(如 15°),球会飞得很高,轨迹像高抛弧线球而非发球——不符合网球物理。

azimuth [-10, 10] deg:左右散布角。10° 对应在对角 service box 方向的散布。如果设得太大(> 20°),球可能飞出球场侧面。

x [10.5, 11.5] m:发球位置在远端 baseline(\(x = 11.885\) m)附近。范围 [10.5, 11.5] 允许发球位置有约 1 m 的前后随机性——模拟不同发球站位。如果设得太小(如 5.0 m),球从半场发出,不是合法发球。

height [1.5, 3.0] m:发球击球点高度。1.5 m 对应矮个球员的较低击球点,3.0 m 对应高个球员的高抛击球点。LATENT 的 G1 机器人身高 127 cm,击球点约在 2.0-2.5 m(考虑手臂上举和球拍长度)。

topspin [-100, 300] rad/s:正值为上旋。300 rad/s ≈ 2864 rpm,是中等强度的 topspin。职业球员的 topspin 可达 4000+ rpm,但在仿真中过高的旋转可能导致 Magnus 力不稳定。负值(-100 rad/s)对应 backspin(下旋发球,如 slice serve)。

sidespin [-100, 100] rad/s:侧旋。正值让球向右偏,负值向左偏。100 rad/s 是温和的侧旋——足以让球在飞行中偏移 0.5-1 m。

为什么参数范围需要物理约束

反事实推理:如果把 speed_range 设为 [0, 100] 会怎样?低速球(< 5 m/s)几乎不会飞过网——它们在阻力下快速减速并落在发球者面前。这种"无效发球"占据了大量训练样本但不提供有用的学习信号。高速球(> 60 m/s)可能因为仿真精度不足(timestep 太大)产生不稳定的碰撞行为。参数范围应该覆盖"物理合理"的区间,避免在无效区域浪费训练资源。

对 Ch28 的击球训练来说,还需要额外考虑curriculum 对参数范围的控制:训练初期用较窄的范围(例如 speed [25, 30]、topspin [0, 0])让策略先学会应对简单来球,逐步扩大范围提高难度。这是 CurriculumManager 的职责——它不改变 LaunchCommand 的代码,只改变传入的参数范围配置。

# Curriculum 控制发球参数范围的示例(Ch28 预规划)
class TennisCurriculumCfg:
    class Stage0:
        """静态球——球不动,只训练移动到位"""
        speed_range = (0.0, 0.0)
        elevation_range = (0.0, 0.0)
        topspin_range = (0.0, 0.0)

    class Stage1:
        """慢速直线球"""
        speed_range = (15.0, 20.0)
        elevation_range = (-5.0, 0.0)
        topspin_range = (0.0, 0.0)

    class Stage2:
        """中速带旋转"""
        speed_range = (20.0, 30.0)
        elevation_range = (-8.0, 2.0)
        topspin_range = (0.0, 150.0)

    class Stage3:
        """完整随机"""
        speed_range = (20.0, 45.0)
        elevation_range = (-8.0, 2.0)
        topspin_range = (-100.0, 300.0)
        sidespin_range = (-100.0, 100.0)

速度分解公式

从球面坐标(speed, elevation, azimuth)分解为笛卡尔速度(\(v_x, v_y, v_z\)):

\[v_x = -\text{speed} \cdot \cos(\text{elev}) \cdot \cos(\text{azim})$$ $$v_y = \text{speed} \cdot \cos(\text{elev}) \cdot \sin(\text{azim})$$ $$v_z = \text{speed} \cdot \sin(\text{elev})\]

为什么 \(v_x\) 带负号? 因为球从远端(\(x > 0\))飞向近端(\(x < 0\)),即沿 \(-x\) 方向运动。speed 是正数,\(\cos(\text{elev}) \cdot \cos(\text{azim})\) 也是正数(仰角和方位角都不大),所以需要手动加负号确保 \(v_x < 0\)

如果忘了这个负号——球会从远端 baseline 向场外飞(\(+x\) 方向),永远到不了近端。reward(基于 near-side service area,\(x < 0\))永远为零。这是 Phase 1 验证中最常见的 bug。

工程实现:CommandTerm

LaunchCommand 继承 CommandTerm,实现 _resample_command() 方法。核心逻辑在 _resample_command(env_ids) 中——注意 env_ids 参数是关键:它告诉你哪些环境需要重新采样(因为不是所有环境同时 reset)。

class LaunchCommand(CommandTerm):
    """Tennis ball launch command term."""

    def __init__(self, cfg, env):
        super().__init__(cfg, env)
        self.ball = env.scene["ball"]
        # 预分配 launch_params 张量 [num_envs, 8]
        self.launch_params = torch.zeros(env.num_envs, 8, device=env.device)

    def _resample_command(self, env_ids: torch.Tensor):
        """采样发球参数并写入球的物理状态。"""
        n = len(env_ids)
        device = env_ids.device

        # Step 1: 采样八维参数
        speed = torch.rand(n, device=device) * (self.cfg.speed_range[1] - self.cfg.speed_range[0]) + self.cfg.speed_range[0]
        elev_deg = torch.rand(n, device=device) * (self.cfg.elevation_range[1] - self.cfg.elevation_range[0]) + self.cfg.elevation_range[0]
        azim_deg = torch.rand(n, device=device) * (self.cfg.azimuth_range[1] - self.cfg.azimuth_range[0]) + self.cfg.azimuth_range[0]
        x_pos = torch.rand(n, device=device) * (self.cfg.x_range[1] - self.cfg.x_range[0]) + self.cfg.x_range[0]
        y_off = torch.rand(n, device=device) * (self.cfg.y_offset_range[1] - self.cfg.y_offset_range[0]) + self.cfg.y_offset_range[0]
        height = torch.rand(n, device=device) * (self.cfg.height_range[1] - self.cfg.height_range[0]) + self.cfg.height_range[0]
        topspin = torch.rand(n, device=device) * (self.cfg.topspin_range[1] - self.cfg.topspin_range[0]) + self.cfg.topspin_range[0]
        sidespin = torch.rand(n, device=device) * (self.cfg.sidespin_range[1] - self.cfg.sidespin_range[0]) + self.cfg.sidespin_range[0]

        # Step 2: 角度转弧度
        elev = torch.deg2rad(elev_deg)
        azim = torch.deg2rad(azim_deg)

        # Step 3: 速度分解(注意 vx 的负号!)
        vx = -speed * torch.cos(elev) * torch.cos(azim)
        vy =  speed * torch.cos(elev) * torch.sin(azim)
        vz =  speed * torch.sin(elev)

        # Step 4: 构造 pose [x, y, z, qw, qx, qy, qz]
        pose = torch.zeros(n, 7, device=device)
        pose[:, 0] = x_pos
        pose[:, 1] = y_off
        pose[:, 2] = height
        pose[:, 3] = 1.0  # qw = 1(单位四元数,无旋转)

        # ⚠️ 关键:加 env_origins!
        pose[:, :3] += self._env.scene.env_origins[env_ids]

        # Step 5: 构造 velocity [vx, vy, vz, wx, wy, wz]
        vel = torch.zeros(n, 6, device=device)
        vel[:, 0] = vx
        vel[:, 1] = vy
        vel[:, 2] = vz
        vel[:, 3] = 0.0        # wx = 0(绕 x 轴角速度)
        vel[:, 4] = -topspin    # wy(topspin 绕 y 轴,负号因为 topspin 让球向下弯曲)
        vel[:, 5] = sidespin    # wz(sidespin 绕 z 轴)

        # Step 6: 写入球的物理状态
        self.ball.write_root_link_pose_to_sim(pose, env_ids)
        self.ball.write_root_link_velocity_to_sim(vel, env_ids)

        # 保存参数供 observation 使用
        self.launch_params[env_ids] = torch.stack(
            [speed, elev_deg, azim_deg, x_pos, y_off, height, topspin, sidespin], dim=1
        )

    def _update_command(self, dt):
        """发球命令在 episode 内不更新。"""
        pass

    def _update_metrics(self):
        pass

env_origins 的加减法则

这是多环境并行中最容易出错的地方之一。 法则很简单但必须严格遵守:

  • 写入物理状态时:加 env_origins。因为 MuJoCo 的状态是 world frame,而你的参数是 court-local frame
  • 读取 observation 时:减 env_origins。因为策略需要看到的是 court-local 坐标
# ✅ 写入时加
pose[:, :3] += env.scene.env_origins[env_ids]

# ✅ 读取时减
ball_local_pos = ball_world_pos - env.scene.env_origins

反事实推理:如果写入时忘了加 env_origins——所有环境的球都出现在 world frame 的同一个位置(env 0 的位置)。其他环境的球场上看不到球。如果读取时忘了减 env_origins——reward 计算中的坐标是 world frame,对 env 0 正确但对其他环境偏移了 env_spacing 的整数倍。env 0 的 reward 正常,其他环境的 reward 可能全为零(因为偏移后坐标超出 reward box)。

topspin 角速度的方向约定

vel[:, 4] = -topspin 中的负号需要解释。网球的 topspin(上旋)让球在飞行中更快下坠(Magnus 力指向下方),在弹跳后加速前进。物理上,topspin 是球绕 \(y\) 轴的旋转——但方向取决于右手定则:如果球沿 \(-x\) 方向飞行,topspin 应该让球顶部向前旋转(就像向前滚动的车轮)。在右手坐标系中,这对应 \(\omega_y < 0\)。因此正的 topspin 参数需要转化为负的 \(\omega_y\)

反事实推理:如果把 topspin 的符号搞反——Magnus 力方向反转,球不是更快下坠而是更"飘"。策略学到的旋转效应与真实网球完全相反。这种 bug 在数值上不明显(球还是会飞、会弹),但 Sim2Real 时策略对旋转球的响应全部反向。

与 velocity command 的架构对比

对比维度 VelocityCommand(Ch06) LaunchCommand
参数维度 3(vx, vy, yaw_rate) 8(speed, elev, azim, x, y, h, ts, ss)
更新时机 每个 resampling 周期 仅在 episode reset
策略能改变 command 吗 不能(外部给定) 不能(外部给定)
参数空间结构 连续均匀 连续均匀,但有物理约束
后续扩展 固定 Ch28 可能让策略选择回球目标

⚠️ 常见陷阱

⚠️ 编程陷阱:pose 写入前忘记加 env_origins - 后果:多环境时所有球都出现在第一个 env 的位置 - 正确做法:pose[:, :3] += env.scene.env_origins[env_ids]

⚠️ 概念误区:把角度单位搞混 - elevation_rangeazimuth_range 在配置中是度数,代码中必须转弧度再做三角函数 - 如果直接对度数做 torch.sin(15),得到的是 sin(15 rad) ≈ 0.65(约 37 度的效果),球飞得异常高 - 自检方法:elevation=90 度时 \(v_x\) 应该约等于 0(球直接向上飞)

⚠️ 编程陷阱:tyro CLI 参数不加引号 - --env.commands.launch.speed-range [20.0, 45.0](无引号)被 shell 拆成多个参数 - 正确做法:加引号 "[20.0, 45.0]"

⚠️ 思维陷阱:认为 _update_command() 需要每步更新发球参数 - 发球是一次性事件——球射出后参数不再改变 - 与 velocity command 不同,LaunchCommand 的 _update_command() 是空函数

练习

  1. [手算题] 给定 speed = 30 m/s, elevation = 5 deg, azimuth = 0 deg, height = 2.5 m, x = 11.0 m,忽略空气阻力:(a) 计算 \(v_x, v_y, v_z\);(b) 计算落地时间 \(t_{\text{land}}\)(令 \(z(t) = 0\));(c) 计算落点 \(x\) 坐标;(d) 判断球在 \(x=0\)(网)处的高度是否超过 0.914 m。
  2. [验证题] 运行固定参数发球实验,每 50 step 打印球的 local position。确认球从 \(x > 0\)\(x < 0\) 运动,落地点在 reward 区间内。
  3. [设计题] 如果要扩展 LaunchCommand 支持"slice serve"(球有侧旋和下旋的组合),需要修改哪些参数?旋转轴应该是什么方向?

发球命令定义了"球怎么射出去",但射出后球如何飞行取决于空气动力学。下一节从真空抛体到完整 Magnus 力,逐层建立弹道物理模型。

26.5 弹道物理:三层空气动力学模型 ⭐⭐⭐

这一节解决什么问题:建立从简到繁的三层空气动力学模型,理解每层的物理含义和工程实现。

动机:真空抛体为什么不够

回顾高中物理的抛体运动方程:

\[x(t) = x_0 + v_{x0} \cdot t, \quad y(t) = y_0 + v_{y0} \cdot t, \quad z(t) = z_0 + v_{z0} \cdot t - \frac{1}{2}g t^2\]

这个模型假设球只受重力。但真实网球在空气中飞行时受到两种额外力:阻力(与速度方向相反,减速球)和 Magnus 力(垂直于速度和自旋轴,改变弹道弯曲度)。在 20-45 m/s 的球速下,阻力可以让球水平速度衰减 30-50%——忽略阻力会严重高估球的飞行距离。

类比理解:真空抛体就像在太空中扔球——弧线完全由初速和重力决定。现实中的网球更像在水中扔石头——水的阻力和漩涡力(类比 Magnus 力)显著改变轨迹。这个类比的边界在于:空气密度远低于水,所以空气动力学的影响虽然显著但不像水中那么极端。

第一层:真空抛体(无外力)

禁用空气动力学事件时,MuJoCo 只对球施加重力。球的运动满足上述简单方程。

# 真空抛体实验
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.commands.launch.speed-range "[30.0, 30.0]" \
  --env.commands.launch.elevation-range "[5.0, 5.0]" \
  --env.commands.launch.azimuth-range "[0.0, 0.0]" \
  --env.commands.launch.topspin-range "[0.0, 0.0]" \
  --env.commands.launch.sidespin-range "[0.0, 0.0]" \
  --env.events.ball-aerodynamics.params.enabled False

在这个设置下,球以 30 m/s、5° 仰角射出,水平速度 \(v_x \approx -29.9\) m/s 保持不变(无阻力),球的飞行距离仅由重力和初始高度决定。

sanity check\(v_{z0} = 30 \times \sin(5°) \approx 2.62\) m/s,初始高度 \(h_0 = 2.5\) m。落地时间 \(t = \frac{v_{z0} + \sqrt{v_{z0}^2 + 2gh_0}}{g} \approx \frac{2.62 + \sqrt{6.86 + 49.05}}{9.81} \approx 1.03\) s。落点 \(x = 11.0 - 29.9 \times 1.03 \approx -19.8\) m。这远超球场长度——球会飞出底线很远。这证明了在真空中 30 m/s 发球会飞太远,空气阻力是必需的。

第二层:二次阻力(Drag Force)

空气阻力与速度的平方成正比,方向与速度相反:

\[\mathbf{F}_{\text{drag}} = -\frac{1}{2} \rho C_d A \|\mathbf{v}\| \mathbf{v}\]

其中 \(\rho\) 是空气密度(海平面约 1.225 kg/m³),\(C_d\) 是阻力系数(网球约 0.55),\(A\) 是迎风面积(\(\pi r^2 \approx 3.42 \times 10^{-3}\) m²)。

为什么是二次阻力而非线性? 线性阻力 \(F = -bv\)(Stokes drag)只在低雷诺数下适用(\(Re < 1\),极低速度或极小物体)。网球的雷诺数 \(Re = \rho v d / \mu \approx 1.3 \times 10^5\),远超此范围。在这个区间,空气对物体的阻力从粘性主导变成惯性主导,阻力与 \(v^2\) 成正比。如果错误地用线性阻力模拟网球飞行,阻力会被严重低估,仿真中球会飞得比真实远得多。

对标准网球参数,\(\frac{1}{2}\rho C_d A \approx 1.15 \times 10^{-3}\) N·s²/m²。在 30 m/s 时,阻力 \(F_d \approx 1.15 \times 10^{-3} \times 900 \approx 1.04\) N——比重力 \(mg = 0.057 \times 9.81 \approx 0.56\) N 还大!这意味着在高速飞行时,阻力是主导力,球的减速非常显著。

阻力对飞行距离的定量影响

为了直观理解阻力的影响,考虑一个水平发球(elevation = 0)从 11 m 处以不同速度射出的情况。忽略重力只看水平方向:

初速 (m/s) 衰减时间常数 τ (s) 到达 x=0(飞行 11m)时的速度 (m/s) 速度衰减比
20 2.48 16.0 20%
30 1.65 24.0 20%
40 1.24 32.0 20%
50 0.99 40.0 20%

注意一个反直觉的结论:在固定飞行距离下(这里是 11 m),二次阻力造成的速度衰减比例与初速无关——因为由 \(v(x)=v_0\exp(-\tfrac{\rho C_d A}{2m}x)\) 可知,速度比 \(v/v_0=\exp(-x\cdot\rho C_d A/2m)=\exp(-11/49.5)\approx0.80\) 是常数。也就是说每种初速到达对面都约剩 80%、衰减约 20%。但若改为比较固定飞行时间,由于 \(v_0\) 越大、相同时间内走过的距离越远、经历的累积阻力冲量越大,则速度越快衰减比例越大。无论哪种口径,阻力都解释了为什么真空模型下球会飞出底线很远,而有阻力后落点合理——阻力不仅让球飞得近,还改变了弹道的形状(从"对称抛物线"变为"压缩的不对称弧线")。

反事实推理:如果你在没有阻力的环境中训练策略(enabled=False),策略会学到"球飞得很远、速度不衰减"的物理模型。当你在有阻力的环境中评估时——或更糟,在真实世界中部署——策略的所有时间预判都会是错的(真实球比策略预期的"慢得多"),导致持续性的"挥拍太早"。这就是为什么 26.9 节的三层对照实验如此重要——它确保你的策略在正确的物理模型上训练。

推导:一维匀速阻力下的速度衰减

忽略重力,只考虑水平方向的阻力。分离变量法:

\[m\frac{dv}{dt} = -\frac{1}{2}\rho C_d A v^2\]
\[\frac{dv}{v^2} = -\frac{\rho C_d A}{2m} dt\]

积分得到:

\[v(t) = \frac{v_0}{1 + t/\tau}, \quad \tau = \frac{2m}{\rho C_d A v_0}\]

位移的积分:

\[x(t) = \int_0^t v(s) ds = v_0 \tau \ln(1 + t/\tau)\]

这个对数关系意味着:飞行距离的增长随时间越来越慢——球不会飞到无穷远,即使初速很大。在 \(t \to \infty\) 时,\(x \to \infty\)(理论上),但实际上重力会让球在有限时间内落地。

对标准网球在 30 m/s 下,\(\tau = \frac{2 \times 0.057}{1.225 \times 0.55 \times 3.42 \times 10^{-3} \times 30} \approx 1.65\) s。飞行 0.8 秒后,水平速度从 30 m/s 衰减到 \(30/(1+0.8/1.65) \approx 20.1\) m/s,水平位移 \(30 \times 1.65 \times \ln(1+0.8/1.65) \approx 19.8\) m。考虑到球需要从 \(x=11\) 飞到 \(x \approx -8\)(底线附近),需要飞行约 19 m 的水平距离——这与 0.8 秒的飞行时间和阻力衰减是一致的。

第三层:Magnus 力(旋转球的偏转力)

旋转球在空气中受到垂直于速度和旋转轴方向的 Magnus 力:

\[\mathbf{F}_{\text{Magnus}} = C_L \frac{4\pi r^3}{3} \rho (\boldsymbol{\omega} \times \mathbf{v})\]

其中 \(C_L\) 是升力系数(取 1.0 作为 Magnus 比例),\(r\) 是球半径,\(\boldsymbol{\omega}\) 是角速度。

C_L 的物理意义与饱和行为:真实网球的 Magnus 升力系数 \(C_L\) 在高旋转时会饱和——即 \(C_L\) 不是常数而是 \(\omega\) 的函数,在高转速下趋向上限(约 0.3-0.4)。当前仿真使用 \(C_L = 1.0\) 作为缩放因子结合体积项近似 Magnus 力,源码中可配置 max_lift_coefficient 参数来限制 Magnus 力幅度。如果发现高 topspin 下球弧线异常夸张(弹道过度弯曲),应检查 lift_coefficient 是否过大或添加 clamp 饱和。

Magnus 力的效果: - Topspin(上旋):让球更快下坠——球弧线更弯,弹跳后加速前进 - Backspin(下旋):让球更慢下坠——球弧线更平,弹跳后减速 - Sidespin(侧旋):让球横向偏移——球在空中"拐弯"

对标准网球参数(\(r = 0.033\) m,\(\rho = 1.225\) kg/m³,\(m \approx 0.057\) kg),在 \(v = 20\) m/s 和 \(\omega = 200\) rad/s 时,\(|\mathbf{F}_{\text{Magnus}}| \approx 0.74\) N——约为重力(\(\approx 0.56\) N)的 130%。这意味着纯 topspin 网球向下的有效合力可达约 2.3 倍重力(重力 + 向下的 Magnus 力),弧线会显著更弯。注意这里取 \(C_L = 1.0\) 作为缩放因子,幅度偏大;真实网球的 \(C_L\) 在高转速下饱和(约 0.3–0.4),实际 Magnus 力会更小。

旋转角速度的衰减

在真实物理中,空气阻力不仅减速球的线速度,也会减缓旋转。旋转阻力矩的简化模型是:

\[\boldsymbol{\tau}_{\text{spin\_drag}} = -C_r \rho r^5 |\boldsymbol{\omega}| \boldsymbol{\omega}\]

其中 \(C_r\) 是旋转阻力系数。当前 mjlab Tennis 的 BallAerodynamics 事件没有实现旋转衰减——角速度在飞行过程中不变。这是一个简化,但对短距离飞行(< 20 m)的影响较小。如果需要更精确的模型(例如远距离 rally),可以在 set_external_force_and_torque 中同时设置 torques 参数。

弹跳后的旋转效应

当旋转球落地弹跳时,MuJoCo 的摩擦接触模型会自动处理旋转和线速度之间的耦合——切向摩擦力将旋转动量转化为线速度变化。具体地:

  • Topspin 球弹跳后:球顶部向前旋转,摩擦力给球一个向前的冲量 → 弹跳后水平速度增加
  • Backspin 球弹跳后:球底部向前旋转(等效于顶部向后),摩擦力给球一个向后的冲量 → 弹跳后水平速度减少甚至方向反转
  • Sidespin 球弹跳后:球弹跳后产生横向偏移

这些效应在 condim=3 的球-地面接触中自然出现——MuJoCo 的接触求解器自动计算摩擦力对线速度和角速度的耦合影响。不需要在代码中手动实现。但如果 condim=1(仅法向),这些效应全部消失——球弹跳后的旋转不会影响线速度。

# 验证 topspin 弹跳效应的实验
# 预期:topspin 与无 topspin 两组弹跳后 vx/出射角/角速度有明显差异
#       (增减取决于滑移方向 rω vs vx、摩擦与接触模型,不一定是"加速")
# 1. topspin=0 的弹跳
# 2. topspin=300 的弹跳
# 比较弹跳后的 vx 变化

EventTerm 的完整代码实现

# mjlab BallAerodynamics EventTerm 的核心计算(简化)
class BallAerodynamics(EventTerm):
    def __call__(self, env, params):
        if not params.get("enabled", True):
            return

        ball = env.scene["ball"]
        vel = ball.data.root_lin_vel_w      # [num_envs, 3] world frame
        ang_vel = ball.data.root_ang_vel_w  # [num_envs, 3] world frame
        speed = vel.norm(dim=-1, keepdim=True)

        # 阻力
        rho = params["air_density"]     # 1.225 kg/m³
        Cd = params["drag_coefficient"] # 0.55
        A = math.pi * params["ball_radius"]**2
        drag_force = -0.5 * rho * Cd * A * speed * vel

        # Magnus 力
        CL = params["lift_coefficient"]  # 1.0
        r = params["ball_radius"]
        volume = (4.0/3.0) * math.pi * r**3
        magnus_force = CL * volume * rho * torch.cross(ang_vel, vel, dim=-1)

        # 合力作为外力施加到球上
        total_force = drag_force + magnus_force
        ball.set_external_force_and_torque(
            forces=total_force,
            torques=torch.zeros_like(total_force),
            body_ids=[0],
            env_ids=torch.arange(env.num_envs, device=env.device),
        )

代码解析——三个关键设计决策:

  1. speed * vel vs speed^2 * vel/speed:前者等价于后者但避免了除以 speed(当 speed=0 时除零)。speed * vel 的量级是 \(|v|^2 \hat{v}\),正好是二次阻力公式中的 \(|v| \mathbf{v}\)
  2. torch.cross(ang_vel, vel) 的叉积顺序\(\boldsymbol{\omega} \times \mathbf{v}\) 给出的力方向满足 Magnus 效应的物理方向。如果写成 torch.cross(vel, ang_vel),方向反了——topspin 球会向上"飘"而非向下"坠"。
  3. torques=torch.zeros_like(total_force):当前不施加旋转阻力矩。如果需要实现旋转衰减,这里应该填入 \(\boldsymbol{\tau}_{\text{spin\_drag}}\)

空气动力学参数的 Domain Randomization 接口

为 Ch28 的训练预留 DR 接口——在 EventManager 中,BallAerodynamics 的参数可以通过配置随机化:

# 预留的 DR 配置(Ch28 使用)
class BallAerodynamicsDRCfg:
    drag_coefficient_range: tuple = (0.4, 0.7)   # ±27% 围绕 0.55
    lift_coefficient_range: tuple = (0.5, 1.5)    # ±50% 围绕 1.0
    air_density_range: tuple = (1.1, 1.35)        # ±10% 围绕 1.225
    # 不随机化重力(g 在地球表面变化 < 0.5%,忽略)

随机化的范围需要物理合理——\(C_d\) 对不同表面粗糙度的网球确实在 0.4-0.7 范围内变化;\(C_L\) 的有效值取决于旋转速率和 Reynolds 数,1.0 只是近似中心值。

三层对照实验

这是本章最关键的物理验证实验。通过对比三层模型的弹道差异,你可以直观理解每层空气动力学的增量贡献。

# 实验 1:真空(无 drag 无 Magnus)
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.events.ball-aerodynamics.params.enabled False

# 实验 2:仅 drag(无 Magnus)
# 需要在代码中暂时注释掉 Magnus 力计算,或设 lift_coefficient = 0
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.events.ball-aerodynamics.params.lift-coefficient 0.0

# 实验 3:完整模型(drag + Magnus)
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.events.ball-aerodynamics.params.enabled True

固定 speed=30, elevation=5, azimuth=0, topspin=200, sidespin=0,预期观察:

模型 预期飞行距离 水平速度衰减 弧线形态
真空 远超球场(~20 m 过底线) 无衰减 标准抛物线
仅 drag 适中(落在球场内) 约 30-40% 压缩抛物线
drag + topspin 更短 约 30-40% 弧线更弯(更快下坠)

为什么不实现完整 CFD

反事实推理:如果用计算流体力学(CFD)精确模拟网球周围的气流——计算量比 RL 训练本身还大几个数量级。一颗球的一次飞行可能需要几分钟 CFD 时间,而 RL 训练需要数十亿步。这就像用显微镜观察建筑地基——精度完全过剩,速度完全不足。

二次阻力 + Magnus 近似对 RL 训练来说已经足够好。LATENT 和 ETH 羽毛球等顶会系统都使用类似的简化空气动力学模型,并通过 domain randomization 处理模型误差。

⚠️ 常见陷阱

⚠️ 编程陷阱:阻力系数参数混淆 - 有些文献给出的是 \(\frac{1}{2}\rho C_d A\) 的整体值,有些给出的是单独的 \(C_d\) - 正确做法:在代码中显式注释每个参数的含义和单位

⚠️ 概念误区:Magnus 力只有旋转球才有 - 正确——但要注意 MuJoCo 中球的角速度不一定来自发球参数,也可能来自弹跳 - 球弹跳后可能获得旋转(通过摩擦),即使发球时无旋

⚠️ 编程陷阱:Magnus 力方向定义中叉积顺序错误 - omega × vv × omega 方向相反! - 正确做法:torch.cross(ang_vel, vel, dim=-1) 而非 torch.cross(vel, ang_vel, dim=-1) - 如果搞反,topspin 球会"飘"而非"坠"——与物理直觉完全矛盾

⚠️ 思维陷阱:认为空气动力学参数需要精确标定 - 与球体参数同理——训练时用 DR 随机化 \(C_d\)\(C_L\) 比精确标定更重要

练习

  1. [推导题] 对匀速直线飞行(忽略重力),推导二次阻力下速度衰减的解析解 \(v(t) = v_0/(1+t/\tau)\)。计算 \(\tau\) 对标准网球在 30 m/s 时的数值。
  2. [实验题] 运行三层对照实验,记录三种模型下球的落点 x 坐标和弹起高度。定量对比差异。
  3. [跨章综合题] 结合 Ch08(Domain Randomization):如果要在训练中随机化空气动力学参数,应该随机化 \(C_d\) 还是 \(\rho\)?推荐的 DR 范围是什么?为什么不随机化 \(g\)

弹道模型描述了球在空中的行为。接下来关注球着地瞬间的行为——接触物理。弹跳的高度、方向和速度衰减直接影响后续 Ch27 的轨迹预测和 Ch28 的击球时机判断。

26.6 接触物理验证:球-地面、球-网、球-球拍 ⭐⭐

这一节解决什么问题:系统性验证三种关键接触的物理行为,建立接触参数调优的方法论。

动机:接触是最敏感的物理环节

弹道飞行是"光滑"的微分方程——小误差不会放大。但接触是不连续事件——球拍碰到球的那一帧,接触力可以是零(没碰到)或几百牛顿(碰到了)。接触参数的微小变化可能导致出球方向差 10 度或速度差 20%。这就是为什么前沿系统(LATENT)要用 2000 Hz 仿真频率——在接触瞬间需要足够多的仿真步来准确解算接触力。

球-地面接触验证

标准落地弹跳实验(ITF 标准):

# ITF 弹跳测试:从 2.54 m(100 inches)高度自由落体
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.commands.launch.speed-range "[0.0, 0.0]" \
  --env.commands.launch.elevation-range "[0.0, 0.0]" \
  --env.commands.launch.azimuth-range "[0.0, 0.0]" \
  --env.commands.launch.x-range "[0.0, 0.0]" \
  --env.commands.launch.y-offset-range "[0.0, 0.0]" \
  --env.commands.launch.height-range "[2.54, 2.54]" \
  --env.commands.launch.topspin-range "[0.0, 0.0]" \
  --env.commands.launch.sidespin-range "[0.0, 0.0]" \
  --env.events.ball-aerodynamics.params.enabled False

通过标准:球落地后明显弹起。弹起高度约为落下高度的 53-58%(对应 COR 0.73-0.76 的硬地场)。如果弹起太高(COR > 0.85),增大 solrefdampratio(耗散更多能量);如果弹起太低(COR < 0.5),减小 dampratio 或减小 timeconst。如果球不弹起而是"粘"在地面上——检查 solreftimeconst 是否太大(太"软"的接触会吸收所有动能)。

COR 标定的完整代码

"""COR calibration experiment for tennis ball-ground contact."""
import torch
import math

def calibrate_cor(env, drop_height=2.54, n_trials=10):
    """从标准高度释放球,测量 COR。

    Args:
        env: Tennis launcher environment instance
        drop_height: ITF standard = 2.54 m (100 inches)
        n_trials: 重复次数取平均

    Returns:
        mean_cor: 平均恢复系数
        std_cor: 标准差
    """
    cors = []

    for trial in range(n_trials):
        # 重置环境,球从 drop_height 静止释放
        env.reset()

        # 记录球的 z 坐标
        max_z_after_bounce = 0.0
        bounced = False
        prev_vz = 0.0

        for step in range(1000):
            obs, reward, done, info = env.step(torch.zeros(1, 0, device=env.device))
            ball_z = env.scene["ball"].data.root_pos_w[0, 2].item()
            ball_vz = env.scene["ball"].data.root_lin_vel_w[0, 2].item()
            ball_pos_local = ball_z - env.scene.env_origins[0, 2].item()

            # 检测第一次弹起:z 接近地面 + vz 从负变正
            if not bounced and ball_pos_local < 0.05 and prev_vz < 0 and ball_vz > 0:
                bounced = True
                max_z_after_bounce = ball_pos_local

            # 弹起后追踪最高点
            if bounced:
                max_z_after_bounce = max(max_z_after_bounce, ball_pos_local)
                # 检测弹起后开始下落 → 最高点已到
                if ball_vz < 0 and max_z_after_bounce > 0.01:
                    cor = math.sqrt(max_z_after_bounce / drop_height)
                    cors.append(cor)
                    break

            prev_vz = ball_vz

            if done[0]:
                break

    if cors:
        mean_cor = sum(cors) / len(cors)
        std_cor = (sum((c - mean_cor)**2 for c in cors) / len(cors)) ** 0.5
        return mean_cor, std_cor
    else:
        return 0.0, 0.0

# 运行标定
cor_mean, cor_std = calibrate_cor(env)
print(f"COR = {cor_mean:.3f} ± {cor_std:.3f}")
# 目标范围:0.73-0.76(硬地)
# 如果偏高:增大 solref dampratio
# 如果偏低:减小 solref dampratio 或减小 timeconst

COR 调优流程:如果标定结果不在目标范围内,按以下步骤调整:

# COR 调优扫描
solref_configs = [
    {"timeconst": 0.005, "dampratio": 0.8},
    {"timeconst": 0.005, "dampratio": 1.0},
    {"timeconst": 0.005, "dampratio": 1.2},
    {"timeconst": 0.01,  "dampratio": 0.8},
    {"timeconst": 0.01,  "dampratio": 1.0},
    {"timeconst": 0.01,  "dampratio": 1.2},
    {"timeconst": 0.02,  "dampratio": 0.8},
    {"timeconst": 0.02,  "dampratio": 1.0},
    {"timeconst": 0.02,  "dampratio": 1.2},
]

for cfg in solref_configs:
    # 修改球-地面接触的 solref
    env.scene["ball"].geom_solref[:] = torch.tensor(
        [cfg["timeconst"], cfg["dampratio"]]
    )
    cor, std = calibrate_cor(env)
    print(f"solref=[{cfg['timeconst']}, {cfg['dampratio']}]: "
          f"COR = {cor:.3f} ± {std:.3f}")

类比理解:COR 标定就像调音——你有一个"音叉"(ITF 标准弹跳高度),你需要调节"弦的张力"(solref)直到发出的"音高"(COR)匹配。这个过程是一次性的——标定好后不需要每次训练都重新做。但如果你后来改了地面 geom 的材质或 timestep,就需要重新标定。

斜入射弹跳验证

除了垂直落地的 COR 标定,还需要验证斜入射弹跳的行为。斜入射时球的出射角度和速度同时受法向恢复和切向摩擦影响。

# 斜入射测试:20 m/s,15° 入射角
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.commands.launch.speed-range "[20.0, 20.0]" \
  --env.commands.launch.elevation-range "[-15.0, -15.0]" \
  --env.commands.launch.azimuth-range "[0.0, 0.0]" \
  --env.commands.launch.height-range "[3.0, 3.0]" \
  --env.commands.launch.x-range "[5.0, 5.0]" \
  --env.commands.launch.topspin-range "[0.0, 0.0]"

预期行为:球以 15° 俯角入射后弹起,弹起角度应大于入射角度(因为法向 COR < 1 使法向速度减小,但切向速度因摩擦也减小但比例不同)。如果弹起角度几乎等于入射角度——说明接触模型接近完全弹性,可能需要增大阻尼。如果球弹起后几乎贴地前进——说明法向恢复太弱。

球-网接触的碰撞体穿透检测

高速球可能穿过薄网碰撞体。以下代码可以检测穿透事件:

def check_net_penetration(env, n_episodes=100):
    """检测球是否穿过网碰撞体(tunnel effect)。"""
    penetrations = 0
    total = 0

    for ep in range(n_episodes):
        env.reset()
        prev_x = 11.0  # 初始在远端

        for step in range(500):
            obs, reward, done, info = env.step(torch.zeros(1, 0, device=env.device))
            ball_x_local = (env.scene["ball"].data.root_pos_w[0, 0]
                          - env.scene.env_origins[0, 0]).item()

            # 检查是否穿过了 x=0 但没触发 net_contact termination
            if prev_x > 0.05 and ball_x_local < -0.05:
                ball_z = (env.scene["ball"].data.root_pos_w[0, 2]
                         - env.scene.env_origins[0, 2]).item()
                if ball_z < 0.95:  # 低于网高
                    # 球穿过了 x=0 且高度低于网 → 应该被拦住但没有
                    penetrations += 1

            prev_x = ball_x_local
            total += 1

            if done[0]:
                break

    print(f"Net penetration rate: {penetrations/max(total,1):.4f} "
          f"({penetrations}/{total})")
    # 如果 > 0.01(1%),需要增大网碰撞体厚度或提高仿真频率
    return penetrations / max(total, 1)

球-网接触验证

球-网接触需要特别处理,因为网的碰撞体是一个薄 box,而高速球可能在一个 timestep 内穿过这个 box(tunnel effect)。

语义撞网检测BallNetPlaneContact termination 不仅依赖 MuJoCo 的接触求解器,还额外检查球心轨迹是否穿过了 \(x = 0\) 平面且高度低于网高。这是一个语义保险层——即使物理接触检测失败(球穿过了薄 box),语义检测仍然能捕获撞网事件。

# 语义撞网检测的核心逻辑
class BallNetPlaneContact(TerminationTerm):
    def __call__(self, env) -> torch.Tensor:
        ball_pos = env.scene["ball"].data.root_pos_w[:, :3]
        ball_pos_local = ball_pos - env.scene.env_origins

        # 保存上一帧位置
        prev_x = self._prev_ball_x
        curr_x = ball_pos_local[:, 0]

        # 检查是否穿过 x=0 平面
        crossed_net = (prev_x > 0) & (curr_x < 0)

        # 在穿越点插值高度
        t = prev_x / (prev_x - curr_x + 1e-8)
        cross_z = self._prev_ball_z + t * (ball_pos_local[:, 2] - self._prev_ball_z)

        # 网高 0.914 m(中央)到 1.067 m(柱端),简化为统一高度
        hit_net = crossed_net & (cross_z < 0.95)  # 略高于 0.914 留裕量

        self._prev_ball_x = curr_x.clone()
        self._prev_ball_z = ball_pos_local[:, 2].clone()

        return hit_net

球-球拍接触预览

当前 Phase 1 没有球拍,但接触参数的设计思路需要提前规划。球拍-球接触是整个项目中最敏感的物理环节——出球速度和方向直接决定任务成功率。

球拍接触的三个挑战: 1. 质量比悬殊:球拍 ~300 g vs 球 ~57 g,高刚度接触可能产生数值振荡 2. 接触时间极短:真实约 3-5 ms,需要足够高的仿真频率来解算 3. 面法向敏感:球拍面偏 5 度,出球方向可能差 10 度

LATENT 的工程选择:2000 Hz 仿真频率,确保每次球拍-球接触至少有 6-10 个仿真步。如果使用 500 Hz(当前 mjlab 默认),一次 3 ms 的接触只有 1-2 个仿真步——接触力的解算精度严重不足。

Ch28 进入击球训练时,应将 timestep 从 0.002 调整到 0.0005(2000 Hz),或至少 0.001(1000 Hz)。对应地需要调整 decimation 保持控制频率不变(50 Hz 控制 → decimation = 40 @ 2000 Hz sim)。

⚠️ 常见陷阱

⚠️ 编程陷阱:court visual geoms 误参与 collision - 如果发球线的 contype 不为 0,球弹跳后可能被线条"绊住" - 正确做法:碰撞分离(26.2 节)

⚠️ 概念误区:只依赖 MuJoCo 接触检测来判断撞网 - 高速球可能穿过薄网碰撞体 - 正确做法:语义撞网检测作为保险层

⚠️ 思维陷阱:在 500 Hz 仿真下调试球拍接触参数 - 仿真频率不够高时,接触力不准,调参毫无意义 - 正确做法:先提高仿真频率到 1000-2000 Hz,再调接触参数

练习

  1. [实验题] 运行标准落地弹跳实验,测量 COR。然后把 friction 的第一个分量从 0.6 改为 0.2,重新测量。摩擦系数的变化对 COR 有影响吗?为什么?
  2. [设计题] 设计一个实验验证语义撞网检测是否比物理接触检测更可靠:固定参数让球以不同速度刚好过网或刚好撞网,比较两种检测方法的一致性。
  3. [思考题] 为什么 LATENT 选择 2000 Hz 而非更高的仿真频率?提示:更高频率意味着更多计算,需要在精度和训练吞吐量之间权衡。对 4096 环境并行 + 8 GPU 训练,仿真频率翻倍意味着什么?

物理验证完成后,需要理解这些物理组件在 mjlab 架构中是如何组装的。下一节追踪从任务注册到 termination 的完整代码路径。

26.7 mjlab 映射:从注册到 termination 全链路 ⭐⭐

这一节解决什么问题:按源码路径解释当前 Tennis Launcher 的完整 manager 架构,为后续新增 strike task 建立模式。

任务注册链路

# src/mjlab/tasks/tennis/__init__.py
from .tennis_launcher_env_cfg import tennis_launcher_env_cfg

register_mjlab_task(
    task_id="Mjlab-Tennis-Launcher",
    env_cfg_entry_point=tennis_launcher_env_cfg,
    rl_cfg_entry_point=tennis_launcher_rl_cfg,
)

注册系统在 src/mjlab/tasks/registry.py 中,load_env_cfg 返回 deepcopy,确保 CLI override 不会污染全局注册 cfg。后续新增的任何 tennis task(如 Mjlab-Tennis-Strike)都应通过同一 registry 进入。绕过 registry 自己 new env,意味着 uv run list-envsuv run playuv run train 等标准命令都找不到你的 task——等于脱离了整个 mjlab 工具链。

Manager 职责分工

Manager 当前 Tennis Launcher 内容 职责
Scene court entity + ball entity + terrain 定义物理世界中有哪些对象
Commands "launch": LaunchCommandCfg(...) 每个 episode 的随机发球参数
Observations "actor": {ball_pos, ball_vel, ball_ang_vel} 策略能看到什么
Actions {} (空) 策略能做什么——当前什么都不能做
Rewards "ball_in_box": RewardTermCfg(...) Phase 1 落点信号
Terminations timeout / net_contact / stopped / oob 何时结束 episode
Events BallAerodynamics 每步施加空气动力学外力
Curriculum (空) 后续 Ch28 添加
Metrics (空) 可添加落点统计

actions: dict = {} 是整个 Phase 1 的关键边界。PPO 不能学会不存在的击球动作。这就好比让一个没有手的人学习打字——键盘再好、教程再清楚,物理前提不满足就不可能完成任务。

Observation 的 frame 差异

当前 observation 中 ball_position 返回的是 court-local(world-aligned,即 world 坐标减 env_origins),而下面这版 ball_velocityball_angular_velocity 取的是 body frame。对 sphere 来说 body frame 的姿态视觉上不明显,但从 API 语义看 frame 不同。

# Observation terms 的完整实现
def ball_position(env) -> torch.Tensor:
    """球的 court-local 位置(world frame 减 env_origins)。"""
    ball_pos = env.scene["ball"].data.root_pos_w[:, :3]  # [N, 3] world
    return ball_pos - env.scene.env_origins  # court-local

def ball_velocity(env) -> torch.Tensor:
    """球的线速度(body frame)。"""
    return env.scene["ball"].data.root_lin_vel_b[:, :3]  # [N, 3] body

def ball_angular_velocity(env) -> torch.Tensor:
    """球的角速度(body frame)。"""
    return env.scene["ball"].data.root_ang_vel_b[:, :3]  # [N, 3] body

frame 一致性陷阱ball_position 返回 court-local(world-aligned),但 ball_velocity 返回 body frame。如果 Ch27 的 trajectory predictor 把 body frame 的速度当 world frame 用——当球有显著旋转时(body frame 和 world frame 不一致),预测器会产生系统性偏差。

建议修复(为 Ch27 预做准备):统一所有 obs 到 world-aligned frame:

def ball_velocity_world(env) -> torch.Tensor:
    """球的线速度(world frame)——推荐用于 predictor。"""
    return env.scene["ball"].data.root_lin_vel_w[:, :3]

def ball_angular_velocity_world(env) -> torch.Tensor:
    """球的角速度(world frame)。"""
    return env.scene["ball"].data.root_ang_vel_w[:, :3]

后续 Strike Task 需要的额外 Observation

当前 Phase 1 的 obs 只有球的状态——因为没有机器人。Ch28 的 strike task 需要更多 obs:

# Ch28 预期需要的 observation terms
class StrikeObservationsCfg:
    class ActorCfg:
        # 球状态(有噪声——模拟真实感知延迟和误差)
        ball_position = ObsTermCfg(func=ball_position, enable_corruption=True)
        ball_velocity = ObsTermCfg(func=ball_velocity_world, enable_corruption=True)
        ball_history = ObsTermCfg(func=ball_position_history, params={"length": 5})

        # 机器人本体状态
        joint_pos = ObsTermCfg(func=mdp.joint_pos_rel)
        joint_vel = ObsTermCfg(func=mdp.joint_vel_rel)
        base_lin_vel = ObsTermCfg(func=mdp.base_lin_vel)
        base_ang_vel = ObsTermCfg(func=mdp.base_ang_vel)
        projected_gravity = ObsTermCfg(func=mdp.projected_gravity)

        # 球拍状态
        racket_pos = ObsTermCfg(func=racket_face_position)
        racket_normal = ObsTermCfg(func=racket_face_normal)
        racket_vel = ObsTermCfg(func=racket_face_velocity)

        # 上一步动作
        last_action = ObsTermCfg(func=mdp.last_action)

    class CriticCfg:
        # Critic 看到完美球状态(privileged information)
        ball_position_perfect = ObsTermCfg(func=ball_position, enable_corruption=False)
        ball_velocity_perfect = ObsTermCfg(func=ball_velocity_world, enable_corruption=False)
        ball_angular_vel_perfect = ObsTermCfg(func=ball_angular_velocity_world)

        # 加上 actor 的所有 obs
        # ...(继承 ActorCfg 的内容)

注意 CriticCfgenable_corruption=False——这是 HITTER 和 LATENT 使用的 privileged critic 模式(Ch09 Teacher-Student 架构的变体)。训练时 critic 能看到完美信息(更准确地估计 value),部署时 actor 只用有噪声的观测。

自定义 Metrics 示例

对网球环境,以下自定义 metrics 对诊断特别有用:

class TennisMetricsCfg:
    # 落点统计
    landing_x = MetricTermCfg(
        func=lambda env: env._last_landing_x,  # 需要在 termination 中记录
    )
    landing_y = MetricTermCfg(
        func=lambda env: env._last_landing_y,
    )

    # 是否过网
    crossed_net = MetricTermCfg(
        func=lambda env: env._ball_crossed_net.float(),
    )

    # 飞行时间
    flight_time = MetricTermCfg(
        func=lambda env: env._flight_time,
    )

    # 弹跳次数
    bounce_count = MetricTermCfg(
        func=lambda env: env._bounce_count.float(),
    )

这些 metrics 在 WandB 的 Metrics/ 前缀下显示,可以用来分析发球参数分布和落点分布——即使没有策略训练,Phase 1 的这些统计量也能帮助验证物理参数是否合理。

Termination 的分类

termination 类别 触发条件 目的
time_out 仿真安全 达到 episode 时长上限 防止无限等待
ball_net_contact 任务语义 球心轨迹穿过网平面且高度低于网高 发球撞网
ball_stopped 任务语义 \(z < 0.05\) 且 speed \(< 0.5\) 球停止运动
ball_oob 仿真安全 $ x

time_outtime_out=True 设置(回顾 Ch06/Ch25):当 episode 因 timeout 结束时,PPO 会对最终状态做 value bootstrapping(\(V(s_T) \neq 0\)),因为这不是真正的"失败终止"。如果误设为 time_out=False,可能出现 Ch25 中模式九"自杀策略"。

Simulation 参数

class SimCfg:
    timestep: float = 0.002    # 500 Hz 物理积分
    render_interval: int = 2   # 每 2 步渲染一次
    gravity: tuple = (0.0, 0.0, -9.81)

class EnvCfg:
    decimation: int = 5        # 控制频率 = 500/5 = 100 Hz
    episode_length_s: float = 5.0  # 训练时 5 秒
    # play 时改为 8 秒(tennis_launcher_env_cfg(play=True))

高速小球对 timestep 敏感:ball radius 0.033 m、max speed 30 m/s、timestep 0.002 s → 单步移动 0.06 m,接近球直径。这就像数字信号的奈奎斯特定理——采样率不够高就丢失高频信息。如果 timestep 更大,球可能穿过薄网片。

Reward:Phase 1 落点信号

def ball_landed_in_service_box(env) -> torch.Tensor:
    """检查球是否落在 near-side service boxes 的合并区域。"""
    ball_pos = env.scene["ball"].data.root_pos_w[:, :3]
    ball_local = ball_pos - env.scene.env_origins

    in_x = (ball_local[:, 0] > -6.40) & (ball_local[:, 0] < 0.0)
    in_y = (ball_local[:, 1] > -4.115) & (ball_local[:, 1] < 4.115)
    in_z = ball_local[:, 2] < 0.1  # 接近地面

    return (in_x & in_y & in_z).float()

这个 reward 是合并的 service area——不区分 deuce court 和 ad court,不处理触网 let。对 Phase 1 来说这足够了(验证球能否落在目标区域),但 Ch28 的击球训练可能需要更精细的落点 reward。

Phase 1 reward 与 ITF 标准发球规则的五处差异(重要——避免把训练信号误当比赛规则):

差异项 ITF 标准 当前 Phase 1
左右分区 必须从 deuce side 发到对面 ad box(反之亦然) 合并左右,不区分 deuce/ad
对角规则 发球必须对角穿过中线 不检查对角
触网 let 球触网后落入 service box → 重发 不处理 let(撞网直接 termination)
线上判定 球落在线上算界内(hawk-eye 精度) 不处理线宽
一发二发 一发失误后有二发机会 不区分一发/二发

后续如果需要标准发球训练,必须新增独立的精确 reward 函数。

⚠️ 常见陷阱

⚠️ 思维陷阱:用训练曲线排查物理 bug - Phase 1 无 action,PPO 训练无意义 - 正确做法:Phase 1 只用 zero agent 做可视化和物理验证

⚠️ 编程陷阱:observation 中 ball_position 未减 env_origins - 后果:reward 计算中用的是 court-local 坐标(已减),但 obs 中给策略的是 world 坐标(未减)——坐标体系不一致 - 正确做法:确认 obs 函数内部是否已处理 env_origins

练习

  1. [追踪题] 追踪 env_origins 在 pose 写入和 reward 读取中的加减关系。画出 world frame 和 court-local frame 的转换路径图。
  2. [设计题] 如果你要在 Tennis Launcher 基础上加入静态挡板(固定位置的 racket geom),需要修改哪些 Manager 配置?不需要修改哪些?

Phase 1 只有球没有机器人。但本章需要为 Ch28 的击球训练提前规划机器人和球拍的 MJCF 建模方案——因为机器人的几何和质量会改变 scene 的碰撞动力学。

26.8 机器人+球拍 MJCF 建模路线 ⭐⭐⭐

这一节解决什么问题:为 Ch28 击球控制预留环境接口,规划球拍和机器人逐步引入的路径。

动机:为什么提前规划而非到 Ch28 再说

如果不提前规划,到 Ch28 时你可能发现球拍碰撞体和球场碰撞体冲突(condim 不兼容)、机器人的 env_origins 偏移和球场不一致、球拍的质量影响了球弹跳的接触参数。这些都是 scene 级别的问题,改起来可能需要重新验证本章的所有物理实验。提前规划不是"过度设计"——它是防止后续返工的最低成本投入。

球拍建模

球拍建模涉及三个核心工程问题:

碰撞形状选择:真实球拍有线床(椭圆面)、框架(椭圆环)和握柄(圆柱)。仿真第一版可以把线床简化成薄 box 或 ellipsoid,框架用 capsule 组合,握柄与机器人末端刚性连接(无 joint)。

<!-- 球拍简化 MJCF(附着在 robot wrist body 上) -->
<body name="racket" pos="0 0 0.2" euler="0 -15 0">
  <!-- 线床碰撞体:size 是 half-size,故全尺寸为 宽24cm × 长30cm × 厚1cm;
       mass 线床约 150g;friction 为球拍面摩擦;solref 0.001 为硬接触 -->
  <geom name="racket_face" type="box"
        size="0.12 0.15 0.005"
        mass="0.15"
        condim="3"
        friction="0.4 0.01 0.001"
        solref="0.001 1.0"
        rgba="0.9 0.9 0.9 0.5"/>

  <!-- 球拍面中心 site(用于 IK 目标) -->
  <site name="racket_face_center" pos="0 0 0"
        type="sphere" size="0.01" rgba="1 0 0 1"/>

  <!-- 球拍面法向 site(用于判断击球角度) -->
  <site name="racket_face_normal" pos="0 0 0.01"
        type="sphere" size="0.005" rgba="0 0 1 1"/>
</body>

关键参数:球拍面 box 的 size[2] = 0.005 m 是 half-size,对应完整厚度 0.01 m(10 mm)。在 2000 Hz 仿真下,30 m/s 球单步移动 0.015 m——约为完整面厚度的 1.5 倍,理论上仍可能穿透。MuJoCo 的 margin/gap 是接触检测阈值和 inactive contact 缓冲,可影响接触生成,但并不等价于连续碰撞检测,无法保证高速薄片不穿透。注意 MuJoCo 文档中的 CCD 指的是 Convex Collision Detection(GJK/EPA 等凸碰撞检测管线),而非"连续碰撞检测"。要可靠防穿透,应依赖更高仿真频率、增厚碰撞体、合理 margin、或语义穿越检测/自定义 swept test。

接触参数:球拍-球接触比球-地面接触更敏感——出球方向直接影响任务成功率。如果接触太硬(solref timeconst 太小),球拍和球的质量比悬殊(球拍 ~300 g,球 ~57 g),可能产生数值振荡。如果接触太软,球会"粘"在球拍上。摩擦系数过高会产生不稳定扭矩,过低则无法让球拍给球施加旋转。

机器人选择与集成

从前沿系统的实践来看,网球/球拍运动机器人有三种形态:

形态 代表系统 优势 劣势
人形 LATENT(G1)、HITTER(G1) 类人运动自然、全身协调 控制维度高、训练难
四足+手臂 ETH 羽毛球(ANYmal-D) 底盘稳定、手臂灵活 不自然、覆盖范围可能有限
固定底座机械臂 Google table tennis(Kuka) 控制简单 移动范围受限

对于教学项目,推荐从固定底座机械臂开始(最简单),验证击球物理后再升级到移动底盘。

Unitree G1 网球适配的工程细节

LATENT(arXiv:2603.12686)在 Unitree G1 上实现人形网球,其硬件适配涉及三个关键工程改动:

改动一:3D 打印球拍适配器。G1 的末端执行器(wrist 关节后的连接法兰)不是为持握球拍设计的。LATENT 团队设计了一个 3D 打印适配件,将标准网球球拍固定在右手末端——球拍柄轴线与前臂轴线成约 15° 夹角(模拟人类握拍角度)。这个夹角在 MJCF 中对应 euler="0 -15 0"

改动二:关节连接器加固。高速挥拍产生的惯性力和击球反力可能超过原装关节连接器的承受范围。LATENT 重新设计了末端关节连接器以提高刚度。在仿真中,这对应于 actuator 的 forcerangectrlrange 设置——如果这些范围太小,策略可能学到"不敢用力挥拍"的保守行为。

改动三:动捕标记优化。机器人表面有反光区域会干扰 OptiTrack 动捕系统。LATENT 在机器人表面贴了散光材料来消除反射。这在仿真中不需要处理,但提醒我们:真机部署时的 perception pipeline 需要额外工程关注。

在 mjlab 中集成 G1 + 球拍的 Scene 配置参考 HUSKY(RSS'26)的模式:

# mjlab 中 G1 + 球拍 Scene 配置(基于 HUSKY 范式)
class TennisStrikeSceneCfg(SceneCfg):
    # 地形
    terrain = TerrainImporterCfg(
        prim_path="/World/ground",
        terrain_type="plane",
    )

    # 球场(静态 Entity,无 joint)
    court = EntityCfg(
        prim_path="/World/court",
        spawn=MjcfFileCfg(mjcf_path="tennis_court.xml"),
    )

    # 球(freejoint Entity)
    ball = EntityCfg(
        prim_path="/World/ball",
        spawn=BallSpecCfg(),  # Python 动态构造
        init_state=EntityCfg.InitialStateCfg(
            pos=(11.0, 0.0, 2.5),
        ),
    )

    # 机器人(Articulation Entity)
    robot = ArticulationCfg(
        prim_path="/World/robot",
        spawn=MjcfFileCfg(mjcf_path="unitree_g1_with_racket.xml"),
        init_state=ArticulationCfg.InitialStateCfg(
            pos=(-8.0, 0.0, 0.0),   # 底线附近
            joint_pos={".*": 0.0},    # 默认站姿
        ),
        actuators={
            "legs": ImplicitActuatorCfg(
                joint_names_expr=[".*_hip_.*", ".*_knee_.*", ".*_ankle_.*"],
                stiffness=100.0, damping=5.0,
            ),
            "arms": ImplicitActuatorCfg(
                joint_names_expr=[".*_shoulder_.*", ".*_elbow_.*", ".*_wrist_.*"],
                stiffness=50.0, damping=3.0,
            ),
        },
    )

    # 环境配置
    num_envs: int = 4096
    env_spacing: float = 30.0

关键设计决策:机器人初始位置 pos=(-8.0, 0.0, 0.0) 放在近端底线附近——距离网约 8 m。这个位置的选择基于 LATENT 的实验:人形机器人的最大移动速度约 6 m/s,从底线到发球线(6.4 m)需要约 1 秒——而球从远端飞到近端约 0.5-1 秒。机器人需要在球飞行期间完成移动和挥拍准备。

动作空间设计预览

Ch28 的击球 task 需要定义 action space。前沿系统使用了不同的 action 表示:

系统 Action 表示 维度 控制频率 优劣
LATENT latent action(VAE decode → joint PD targets) ~16 50 Hz 自然、表达力高,但需要 motion data
HITTER 全关节 PD targets 29 50 Hz 简单直接,但可能产生不自然动作
ETH 羽毛球 全关节 PD targets + 步态相位 12+1 50 Hz 步态相位帮助协调
Phybot 全关节 PD targets 20+ 50 Hz 三阶段 curriculum 弥补了没有 motion prior

对教学项目,推荐使用全关节 PD targets(最透明、最容易调试),配合 Ch25 中的 action smoothness 正则化(L2C2、action_rate penalty)来确保动作平滑。如果有 motion capture 数据可用,可以像 LATENT 那样先学 latent action space 再做 high-level policy。

# 击球 task 的 action 配置预览(Ch28)
class StrikeActionsCfg:
    # 方案 A:全关节 PD targets
    joint_pos_target = ActionTermCfg(
        class_type=JointPositionActionCfg,
        asset_name="robot",
        joint_names=[".*"],
        scale=0.5,  # action rescaler β(Ch25)
    )

    # 方案 B:上下身分离(推荐用于人形)
    # 下肢用 velocity tracking policy(pretrained,frozen)
    # 上肢用 IK action(task space)
    upper_body_ik = ActionTermCfg(
        class_type=DifferentialIKActionCfg,
        asset_name="robot",
        joint_names=[".*_shoulder_.*", ".*_elbow_.*", ".*_wrist_.*"],
        body_name="racket_face_center",
    )

方案 B(上下身分离)是 HITTER 和 Phybot 的做法——下肢用 pretrained locomotion policy 保持平衡和移动,上肢用 IK 或 RL 控制球拍。这大幅降低了训练难度——RL 只需要学习"怎么挥拍"而非"怎么走路+挥拍"。

HUSKY 的 mjlab Scene 集成范式

HUSKY(RSS'26)是第一个公开的基于 mjlab 的人形+外部物体(滑板)集成项目。其 Scene 配置模式值得详细学习:

# HUSKY 的 Scene 配置关键模式(简化)
class HuskySceneCfg(SceneCfg):
    # 人形机器人
    robot = ArticulationCfg(
        prim_path="/World/robot",
        spawn=MjcfFileCfg(mjcf_path="g1.xml"),
    )

    # 外部物体(滑板)——作为独立的 RigidBody Entity
    skateboard = RigidObjectCfg(
        prim_path="/World/skateboard",
        spawn=MjcfFileCfg(mjcf_path="skateboard.xml"),
    )

    # 关键:机器人和滑板之间的接触由 MuJoCo 的 contact model 自动处理
    # 不需要显式定义"机器人站在滑板上"——只要碰撞体重叠,接触力就会产生

对网球项目,球拍有两种集成方式:

方式 A:球拍作为机器人 MJCF 的一部分(推荐)——在 g1_with_racket.xml 中把球拍 body 直接挂在 wrist body 下面。好处是机器人和球拍始终是一个 kinematic tree,IK 计算自然包含球拍。

方式 B:球拍作为独立 RigidBody + equality constraint——球拍是独立 Entity,用 MuJoCo 的 weld equality constraint 固定在 wrist 上。好处是可以在运行时动态 attach/detach 球拍。缺点是 equality constraint 有 softness(不是完美刚性连接),高速挥拍时球拍可能微微松动。

教学项目推荐方式 A(更简单、更稳定)。

球拍接触参数敏感性分析

球拍-球接触是整个项目最敏感的物理环节。以下是一个系统性的参数敏感性分析框架:

# 球拍-球接触参数敏感性扫描实验
import itertools

# 参数网格
solref_timeconst = [0.0005, 0.001, 0.005, 0.01]
solref_dampratio = [0.8, 1.0, 1.2, 1.5]
friction_sliding = [0.2, 0.4, 0.6, 0.8]

for tc, dr, fs in itertools.product(solref_timeconst, solref_dampratio, friction_sliding):
    # 配置球拍接触参数
    racket_params = {
        "solref": [tc, dr],
        "friction": [fs, 0.01, 0.001],
        "condim": 3,
    }
    # 固定球速和角度,记录出球速度和方向
    # 每组参数跑 100 次
    results = run_contact_experiment(racket_params, n_trials=100)
    print(f"tc={tc}, dr={dr}, fs={fs}: "
          f"mean_rebound_speed={results.speed:.1f}, "
          f"std_direction={results.dir_std:.1f}°")

这个实验在 Stage 1(静态球拍)就可以运行,不需要等到有 RL 策略。它回答的核心问题是:"给定接触参数,出球行为是否物理合理且数值稳定?"如果某组参数导致出球方向的标准差 > 10°,说明接触不稳定——RL 策略很难在这种噪声下学到精确控制。

阶段化引入路线

这是本节最重要的工程建议——不要试图一次性搭建完整的机器人+球拍+球+训练环境。按以下阶段逐步引入:

Stage 0: 当前 Phase 1 ─── 只有球 launcher(本章验证完成)
    ↓
Stage 1: + 静态 racket geom ─── 验证 ball-racket contact 参数
    ↓
Stage 2: + kinematic racket ─── 手动指定球拍轨迹,验证击球窗口
    ↓
Stage 3: + 固定底座机械臂 ─── 用 IK action 控制球拍
    ↓
Stage 4: + 移动底盘 ─── base-arm 协调
    ↓
Stage 5: + 视觉延迟和动作延迟 ─── Sim2Real 预处理
    ↓
Stage 6: + 训练 reward 和 curriculum ─── 完整 strike task

每个 Stage 的验收标准:

Stage 验收 验证方法
0 球弹道物理正确 三层对照实验(本章)
1 球拍接触不穿透、不"粘球" 固定球拍位置,让球以不同速度/角度撞击
2 手动挥拍能稳定回球 scripted 轨迹打 10 球,至少 7 球过网
3 IK 到目标位置精度 < 1 cm 给定固定目标,测量末端误差
4 底盘移动到位时手臂仍能完成 IK 动态目标,测量跟踪误差
5 加延迟后 Stage 3 仍能工作 与 Stage 3 对比
6 PPO 训练 reward 上升 标准训练曲线诊断(Ch25)

不要在第一版就引入柔性网——柔性网会增加接触复杂度,把调参焦点从击球转移到织物仿真。球网在第一版用刚性障碍处理即可。

Isaac Lab 中的机器人集成对比

在 Isaac Lab 中,机器人通过 ArticulationCfg 配置,球拍作为 end-effector 的附着 link:

# Isaac Lab 中机器人 + 球拍的配置(伪代码)
class TennisRobotCfg(ArticulationCfg):
    spawn = UsdFileCfg(
        usd_path="franka_panda_with_racket.usd",
        # 球拍已在 USD 中作为 end-effector 的子 link
    )
    actuators = {
        "arm": ImplicitActuatorCfg(
            joint_names_expr=["panda_joint.*"],
            stiffness=80.0,
            damping=4.0,
        ),
    }

Isaac Lab 的优势是 USD 格式原生支持复杂的视觉材质和碰撞网格,适合需要高质量渲染的项目。mjlab 的优势是 MjSpec API 可以在 Python 中动态构造机器人描述,适合需要频繁修改机器人结构的研究。

⚠️ 常见陷阱

⚠️ 思维陷阱:直接从 Stage 0 跳到 Stage 6 - 跳过中间验证阶段意味着出了 bug 时无法定位是物理接触问题还是策略问题 - 正确做法:严格按阶段推进,每个阶段必须通过验收才能进入下一阶段

⚠️ 编程陷阱:wrist pose 当 racket face pose - 球拍有长度和面法向偏移(euler="0 -15 0"),wrist 到位不代表 racket face 到位 - 正确做法:在 MJCF 中定义 racket_face_center site,IK target 指向该 site

⚠️ 概念误区:球拍线床厚度可以任意小 - 太薄(< 2 mm)在低频仿真下会导致穿透 - 正确做法:厚度 ≥ 单步球移动距离的 1/3

练习

  1. [设计题] 设计一个薄 box 球拍线床的 MuJoCo geom 参数(type、size、mass、condim、friction)。解释你选择每个参数的理由。
  2. [手算题] 如果球拍线床厚度为 0.005 m,球速为 40 m/s,物理 timestep 为 0.002 s,球在一个 timestep 内移动多少距离?这个距离和线床厚度的比值说明了什么问题?如果改为 0.0005 s timestep,比值变为多少?
  3. [跨章综合题] 回顾 HUSKY(RSS'26)的 Scene 配置方法。如果要用类似方式在 mjlab 中集成一个人形机器人(G1)+球拍+球场,Scene 配置中需要哪些 Entity?它们之间有碰撞交互关系吗?

设计好了环境结构,接下来用一组系统性实验来验证所有物理组件的正确性。这组实验是本章的"验收标准"——不通过就不应该进入 Ch27/Ch28。

26.9 最小可运行实验序列 ⭐⭐

这一节解决什么问题:给出从 smoke test 到弹道验证的完整实验序列,定义三层验收标准。

实验 26.1:确认任务注册

uv run list-envs | grep Tennis

通过标准:输出包含 Mjlab-Tennis-Launcher。如果没有——检查 __init__.py 中的 register_mjlab_task() 调用。

实验 26.2:导出 scene

uv run export-scene Mjlab-Tennis-Launcher --output-dir /tmp/mjlab_tennis_scene

通过标准:导出目录被创建,包含 court 和 ball,无 MuJoCo compile error。打开导出的 XML 确认 court geom 和 ball body 都存在。

定量验证:检查导出 XML 中的关键参数是否与配置一致:

import mujoco
model = mujoco.MjModel.from_xml_path("/tmp/mjlab_tennis_scene/scene.xml")

# 检查 ball 参数
ball_geom_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "ball_geom")
print(f"Ball radius: {model.geom_size[ball_geom_id, 0]}")  # 应为 0.033
print(f"Ball condim: {model.geom_condim[ball_geom_id]}")    # 应为 3

# 检查 court surface 碰撞配置
court_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "court_surface")
print(f"Court contype: {model.geom_contype[court_id]}")     # 应为 1

# 检查 baseline 不参与碰撞
baseline_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, "baseline_far")
print(f"Baseline contype: {model.geom_contype[baseline_id]}")  # 应为 0

实验 26.3:zero agent 可视化

uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 16 --viewer viser

通过标准:能看到球场;球从 \(x > 0\) 侧向 \(x < 0\) 运动(检查飞行方向!);多环境不重叠(检查 env_spacing!);reset 后球重新发射。

检查清单(逐项确认):

检查项 正常表现 异常表现及原因
球场可见 蓝色平面 + 白色线条 看不到 → XML 加载失败
球可见 黄色小球 看不到 → freejoint 缺失或 spawn 位置错
飞行方向 \(x > 0\)\(x < 0\) 反向 → \(v_x\) 符号错
多环境不重叠 16 个独立球场 重叠 → env_spacing 太小
弹跳后停止 球弹跳 1-3 次后停住 不弹跳 → solref 问题;不停 → friction 太小
Reset 正常 球停后重新发射 不 reset → termination 配置错

实验 26.4:固定参数单发球

uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.commands.launch.speed-range "[30.0, 30.0]" \
  --env.commands.launch.elevation-range "[5.0, 5.0]" \
  --env.commands.launch.azimuth-range "[0.0, 0.0]" \
  --env.commands.launch.y-offset-range "[0.0, 0.0]" \
  --env.commands.launch.topspin-range "[0.0, 0.0]" \
  --env.commands.launch.sidespin-range "[0.0, 0.0]"

通过标准:球沿 \(-x\) 方向飞行,弧线合理(先升后降),最终落在球场内或附近。如果球飞向场外(\(+x\))——\(v_x\) 符号错误。

定量验证:在 play 脚本中添加轨迹记录:

# 轨迹记录脚本
trajectory = []
for step in range(800):  # 8 秒 × 100 Hz
    obs, reward, done, info = env.step(zero_actions)
    ball_pos = env.scene["ball"].data.root_pos_w[0, :3].cpu().numpy()
    ball_vel = env.scene["ball"].data.root_lin_vel_w[0, :3].cpu().numpy()
    local_pos = ball_pos - env.scene.env_origins[0].cpu().numpy()
    trajectory.append({
        "step": step,
        "x": local_pos[0], "y": local_pos[1], "z": local_pos[2],
        "vx": ball_vel[0], "vy": ball_vel[1], "vz": ball_vel[2],
    })
    if done[0]:
        break

# 分析轨迹
import pandas as pd
df = pd.DataFrame(trajectory)
print(f"Initial vx: {df.iloc[0]['vx']:.2f} m/s (should be negative)")
print(f"Landing x: {df[df['z'] < 0.05].iloc[0]['x']:.2f} m")
print(f"Net crossing height: {df[df['x'].abs() < 0.1].iloc[0]['z']:.3f} m "
      f"(should be > 0.914)")
print(f"Max height: {df['z'].max():.2f} m")
print(f"Flight time: {df[df['z'] < 0.05].index[0] * 0.01:.2f} s")

实验 26.5:三层空气动力学对照

分别运行真空、仅 drag、完整三层模型(参见 26.5 节的命令),记录落点位置。

# 真空
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.events.ball-aerodynamics.params.enabled False \
  --env.commands.launch.speed-range "[30.0, 30.0]" \
  --env.commands.launch.elevation-range "[5.0, 5.0]" \
  --env.commands.launch.azimuth-range "[0.0, 0.0]" \
  --env.commands.launch.topspin-range "[0.0, 0.0]" \
  --env.commands.launch.sidespin-range "[0.0, 0.0]"

# 仅 drag
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.events.ball-aerodynamics.params.lift-coefficient 0.0 \
  --env.commands.launch.speed-range "[30.0, 30.0]" \
  --env.commands.launch.elevation-range "[5.0, 5.0]" \
  --env.commands.launch.azimuth-range "[0.0, 0.0]" \
  --env.commands.launch.topspin-range "[0.0, 0.0]" \
  --env.commands.launch.sidespin-range "[0.0, 0.0]"

# 完整(drag + topspin Magnus)
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.commands.launch.speed-range "[30.0, 30.0]" \
  --env.commands.launch.elevation-range "[5.0, 5.0]" \
  --env.commands.launch.azimuth-range "[0.0, 0.0]" \
  --env.commands.launch.topspin-range "[200.0, 200.0]" \
  --env.commands.launch.sidespin-range "[0.0, 0.0]"

通过标准和预期结果

模型 预期落点 x 预期飞行时间 弧线特征
真空 远超底线(-20 m 以外) ~1.0 s 标准抛物线
仅 drag 球场内(-5 ~ -10 m) ~0.8 s 更短、更"压缩"的抛物线
drag + topspin 更近(-3 ~ -7 m) ~0.7 s 弧线更弯(topspin 加速下坠)

如果三者无差异——BallAerodynamics 事件可能未正确注册或 enabled 参数未生效。

实验 26.6:落地弹跳 COR 标定

uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.commands.launch.speed-range "[0.0, 0.0]" \
  --env.commands.launch.elevation-range "[0.0, 0.0]" \
  --env.commands.launch.azimuth-range "[0.0, 0.0]" \
  --env.commands.launch.x-range "[0.0, 0.0]" \
  --env.commands.launch.y-offset-range "[0.0, 0.0]" \
  --env.commands.launch.height-range "[2.54, 2.54]" \
  --env.commands.launch.topspin-range "[0.0, 0.0]" \
  --env.commands.launch.sidespin-range "[0.0, 0.0]" \
  --env.events.ball-aerodynamics.params.enabled False

通过标准:球落地后明显弹起。弹起高度约为落下高度的 53-58%(对应 COR 0.73-0.76 的硬地场)。

实验 26.7:topspin 弹跳效应验证

# 带 topspin 的水平发球
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.commands.launch.speed-range "[20.0, 20.0]" \
  --env.commands.launch.elevation-range "[0.0, 0.0]" \
  --env.commands.launch.topspin-range "[300.0, 300.0]"

# 对照:无 topspin
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 1 \
  --env.commands.launch.speed-range "[20.0, 20.0]" \
  --env.commands.launch.elevation-range "[0.0, 0.0]" \
  --env.commands.launch.topspin-range "[0.0, 0.0]"

通过标准:topspin 球与无 topspin 球弹跳后的水平速度、出射角和角速度应有明显差异(旋转-线速度的摩擦耦合效应)。注意这里 \(r|\omega|=0.033\times300\approx9.9\) m/s \(<\) \(|v_x|=20\) m/s,接触点仍沿来球方向滑移,摩擦对水平速度的具体增减取决于滑移方向、摩擦系数与接触模型,不应预设"一定加速";只有当旋转足够大(\(r|\omega|>|v_x|\))等特定滑移条件下才可能净增大水平速度。验证时应记录弹跳前后的 \(v_x\)\(\omega_y\)。如果两组完全无差异——检查 condim 是否为 3(切向摩擦是传递旋转效应的必要条件)。

实验 26.8:多环境统计

# 随机发球,收集 1000 个 episode 的落点分布
uv run play Mjlab-Tennis-Launcher --agent zero --num-envs 256 \
  --max-episodes 1000 --headless True

在 headless 模式下记录每个 episode 的落点、飞行时间、是否过网、是否 oob:

# 统计分析代码
import matplotlib.pyplot as plt
import numpy as np

landing_x = np.array(all_landing_x)  # 从 metrics 收集
landing_y = np.array(all_landing_y)
crossed_net = np.array(all_crossed_net)

print(f"过网率: {crossed_net.mean():.1%}")
print(f"落点 x: mean={landing_x.mean():.2f}, std={landing_x.std():.2f}")
print(f"落点 y: mean={landing_y.mean():.2f}, std={landing_y.std():.2f}")

# 绘制落点分布散点图
fig, ax = plt.subplots(figsize=(10, 6))
ax.scatter(landing_x, landing_y, alpha=0.1, s=5)
# 画 service box
ax.add_patch(plt.Rectangle((-6.40, -4.115), 6.40, 8.23,
                            fill=False, edgecolor='red', linewidth=2))
ax.set_xlabel("x (m)")
ax.set_ylabel("y (m)")
ax.set_title("Ball Landing Distribution (N=1000)")
ax.set_aspect('equal')
plt.savefig("landing_distribution.png", dpi=150)

通过标准:落点分布应在球场范围内且合理散布。如果所有球都飞出同一个方向——参数范围设置可能有误。如果落点集中在一个点——随机化可能没有生效(range 两端相同)。

自动化三层验收脚本

将以上所有实验封装为一个验收脚本,改参数后一键运行:

#!/usr/bin/env python3
"""Tennis environment three-layer acceptance test."""

import subprocess, sys

def run_test(name, cmd, check_fn):
    """运行测试并报告结果。"""
    print(f"\n{'='*60}")
    print(f"TEST: {name}")
    print(f"{'='*60}")
    try:
        result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120)
        if result.returncode != 0:
            print(f"  ❌ FAIL: Command returned {result.returncode}")
            print(f"  stderr: {result.stderr[:500]}")
            return False
        passed = check_fn(result.stdout)
        print(f"  {'✅ PASS' if passed else '❌ FAIL'}")
        return passed
    except subprocess.TimeoutExpired:
        print(f"  ❌ FAIL: Timeout")
        return False

# Layer 1: 构建验收
results = []
results.append(run_test(
    "Task Registration",
    "uv run list-envs 2>&1 | grep Tennis",
    lambda out: "Mjlab-Tennis-Launcher" in out
))

results.append(run_test(
    "Scene Export",
    "uv run export-scene Mjlab-Tennis-Launcher --output-dir /tmp/tennis_test 2>&1",
    lambda out: "error" not in out.lower()
))

# Layer 2/3: 通过 headless play 验证
# ... (更多测试)

print(f"\n{'='*60}")
print(f"SUMMARY: {sum(results)}/{len(results)} tests passed")
if all(results):
    print("✅ All acceptance tests PASSED — ready for Ch27/Ch28")
else:
    print("❌ Some tests FAILED — fix before proceeding")

三层验收标准

第一层:构建验收——task id 能注册、scene 能导出、court XML 能编译、ball freejoint 能写入状态、多环境 origin 正确。对应实验 26.1-26.3。

第二层:轨迹验收——固定参数时轨迹可复现;speed/elevation/azimuth 改变后飞行特征显著变化;三层空气动力学模型有明显的增量差异。对应实验 26.4-26.5。

第三层:接触验收——落地弹跳 COR 在合理范围(0.55-0.80);topspin 弹跳效应可观测;timeout/oob termination 正常工作;落点分布合理。对应实验 26.6-26.8。

如果三层未通过,不应进入 Ch27/Ch28。后续会增加至少五个新不确定性(racket 几何、racket-ball contact、robot action、robot observation、击球 reward),让 bug 更难定位。

⚠️ 常见陷阱

⚠️ 思维陷阱:把验收当一次性检查 - 每次修改物理参数后都应重新运行验收——参数之间有耦合 - 正确做法:把验收实验写成脚本,改参数后一键运行

⚠️ 编程陷阱:tyro CLI range 参数不加引号 - --env.commands.launch.speed-range [20.0, 45.0](无引号)被 shell 拆成多个参数 - 正确做法:加引号 "[20.0, 45.0]"

⚠️ 概念误区:单环境通过就够了 - 有些 bug 只在多环境下暴露(env_origins、env_spacing) - 正确做法:验收必须包含 --num-envs 16--num-envs 256 测试

练习

  1. [操作题] 在 mjlab 中从实验 26.1 到 26.8 完整走一遍。在每个实验记录你看到了什么、判断标准是什么、结果是 pass 还是 fail。提交你的验收日志。
  2. [设计题] 完善上述自动化验收脚本,添加 Layer 2 和 Layer 3 的自动化测试。脚本应能自动启动 headless play、收集数据、判断 pass/fail。
  3. [跨章综合题] 结合 Ch25(训练诊断)中的验证流程:Phase 0-2(smoke test / zero agent / random agent)与本节的三层验收有什么对应关系?如何把两者统一为一个通用的环境验收框架?

最后一节跳出 mjlab 的具体实现,从更高的视角审视球类运动 RL 的前沿系统——理解它们的架构选择和工程权衡,为后续 Ch27/Ch28 的设计提供方向感。

26.10 球类运动 RL 的前沿系统:从 LATENT 到 HITTER ⭐⭐⭐

这一节解决什么问题:综述当前球类运动 RL 的顶会系统,提炼共性工程模式和架构选择。

动机:为什么需要了解前沿系统

本章到目前为止都在讲"怎么搭环境"。但环境的设计选择不是凭空决定的——它们受到最终训练目标的约束。了解前沿系统的架构,可以帮你在环境设计阶段就为后续训练"留好接口"。例如:如果知道 LATENT 用了 2000 Hz 仿真,你就不会把 timestep 设成 0.01;如果知道 ETH 羽毛球用了感知-控制一体的 RL policy,你就会在 observation 中提前加入球的飞行历史。

系统综述

LATENT:人形网球(arXiv:2603.12686,March 2026)

LATENT 是目前最完整的人形网球系统,在 Unitree G1 上实现了人-机多拍对打。核心架构是三层分层

  1. Motion Tracking Layer:从不完美的人类运动数据中学习 latent action space。5 小时业余选手动捕数据 → latent VAE → 动作原语库(正手、反手、横移、交叉步)
  2. High-Level Policy:PPO 策略在 latent space 中决策,输出 latent action + wrist correction
  3. Low-Level Controller:将 latent action decode 为 29-DoF 关节目标,PD 控制执行

训练管线详解

Step 1: Motion Retargeting
  人类动捕数据 → 重定向到 G1 骨架 → 运动片段库
  (正手挥拍 × 20 段,反手挥拍 × 15 段,横移 × 30 段,...)

Step 2: Low-Level Tracking Policy
  输入: (robot_state, target_next_frame)
  输出: joint_PD_targets
  训练: 在 MuJoCo JAX 中用 PPO,奖励 = 关节位置跟踪 + 平衡
  结果: 一个能跟踪任意运动片段的 low-level 策略

Step 3: Latent Action Space
  将运动片段库编码为 latent vectors(通过 VAE)
  每个 latent vector 对应一个运动原语
  Latent Action Barrier (LAB): 约束 latent vector 在训练数据覆盖的流形上

Step 4: High-Level Tennis Policy
  输入: (robot_state, ball_state, target_landing_point)
  输出: (latent_action, wrist_correction)
  训练: PPO,奖励 = 击球成功 + 落点精度 + 动作自然度
  关键: wrist_correction 是对 latent action 的在线修正——
       因为 latent 库中的动作是"通用挥拍",需要根据来球位置微调手腕

关键工程参数: - 机器人:Unitree G1(29 DoF,127 cm,~35 kg) - 仿真:MuJoCo JAX,2000 Hz,8 GPU 并行 - 控制:50 Hz high-level policy + 50 Hz low-level controller - 球速:peak > 15 m/s - 成功率:仿真 96.5%(2.5 m 内到目标),真机 91% 正手 / 78% 反手

Sim2Real 关键设计

# LATENT 的 Domain Randomization 配置(根据论文描述重构)
class LatentDRCfg:
    # 球物理 DR(必需,去掉后成功率 91% → 14-29%)
    ball_mass_range: tuple = (0.045, 0.070)       # ±20% 围绕 57g
    ball_restitution_range: tuple = (0.60, 0.85)  # COR
    ball_drag_coef_range: tuple = (0.40, 0.70)    # Cd
    ball_friction_range: tuple = (0.3, 0.8)

    # 球观测噪声 DR(必需,去掉后反手成功率 78% → 0%)
    ball_pos_noise_std: float = 0.02   # 2 cm 位置噪声
    ball_vel_noise_std: float = 0.5    # 0.5 m/s 速度噪声
    ball_obs_delay_range: tuple = (0, 3)  # 0-3 帧延迟

    # 机器人物理 DR
    joint_friction_range: tuple = (0.5, 1.5)  # 关节摩擦倍数
    motor_strength_range: tuple = (0.8, 1.2)   # 电机力矩倍数
    action_delay_range: tuple = (0, 2)          # 0-2 帧动作延迟

去掉 dynamics randomization,真机成功率从 91% → 14-29%。去掉 observation noise,反手成功率从 78% → 0%。这两个结果是整个球类运动 RL 领域最有说服力的消融实验之一。

对本章的启示:环境设计时应预留 dynamics randomization 接口——球的 mass、COR、\(C_d\)\(C_L\) 都需要可随机化。observation 中球的状态应预留 noise 注入接口。

HITTER:人形乒乓球(arXiv:2508.21043,August 2025)

HITTER 用分层规划+学习方法在 Unitree G1 上实现乒乓球。架构不同于 LATENT——它用 model-based planner 计算击球点和击球速度,然后用 RL policy 做全身控制。

架构对比

LATENT(端到端 RL + motion priors):
  ball_state → High-Level RL Policy → latent_action → Low-Level → joint_targets

HITTER(规划+学习混合):
  ball_state → Model-Based Planner → (hit_pos, hit_vel, hit_time)
                                          ↓
  robot_state + hit_target → RL Policy → joint_targets

HITTER 的优势是 planner 可以精确计算击球参数(利用弹道物理模型),RL 只需要学习"怎么让身体到达指定位置并以指定速度挥拍"——这比"从球状态直接到关节动作"的映射简单得多。缺点是 planner 的弹道模型精度限制了系统上限——如果真实弹道偏离模型太多,planner 给出的击球目标就是错的。

关键差异: - 感知:9 个 OptiTrack 相机 @ 360 Hz(外部动捕,非机载视觉) - 规划:解析弹道模型预测击球点 → 优化器计算球拍目标速度 - 控制:asymmetric actor-critic RL policy,50 Hz - 特色:critic 使用 privileged information(完美球状态),actor 只用可部署的观测

对本章的启示:observation 设计中需要区分 actor 和 critic 的 obs。critic 可以看到完美球状态(privileged),actor 只看到有噪声的估计——这是 Ch09 Teacher-Student 架构在球类运动中的直接应用。

ETH 羽毛球:四足+手臂(Science Robotics, 2025)

ETH 在 ANYmal-D 四足机器人上安装了动态臂+球拍,实现了人机协作羽毛球。这是一个统一端到端 RL 方法——不分 perception/planning/control 层,一个 policy 直接从观测输出全身动作。

关键特色: - 机载立体相机感知(非外部动捕)——这是与 LATENT/HITTER 最大的区别 - perception-aware training:训练时注入视觉跟踪误差模型(而非添加简单高斯噪声) - shuttlecock 速度 ≤ 12 m/s - 统一 policy 同时控制四条腿和手臂

感知-aware 训练的核心思想:

# ETH 的 perception-aware observation noise(概念重构)
def add_perception_aware_noise(ball_pos_true, ball_vel_true, robot_state):
    """根据机器人运动状态和球位置添加非均匀噪声。"""
    # 噪声随球距离增大而增大
    distance = (ball_pos_true - robot_head_pos).norm(dim=-1)
    pos_noise_std = 0.01 + 0.05 * (distance / 5.0)  # 近处 1cm,远处 6cm

    # 噪声在机器人快速运动时增大(相机模糊)
    robot_speed = robot_state[:, :3].norm(dim=-1)
    motion_blur_factor = 1.0 + 0.5 * robot_speed  # 速度越快噪声越大

    # 最终噪声
    total_noise_std = pos_noise_std * motion_blur_factor
    ball_pos_noisy = ball_pos_true + torch.randn_like(ball_pos_true) * total_noise_std.unsqueeze(-1)

    return ball_pos_noisy

对本章的启示:如果目标是端到端 policy(不分层),observation 需要包含球的飞行历史(多帧位置),而不只是当前帧。这要求环境提供 observation history buffer。

Phybot 人形羽毛球(arXiv:2511.11218,November 2025)

Phybot 使用三阶段 curriculum实现人形羽毛球,核心创新是无需运动先验

Stage 1: Footwork(步法)
  - 目标:学会移动到指定位置并保持平衡
  - Reward: 位置跟踪 + orientation 保持 + 能量最小化
  - 无球参与,纯 locomotion 任务
  - 训练 ~2000 iterations

Stage 2: Swing(挥拍)
  - 目标:在固定位置学会精准挥拍
  - 机器人位置固定(不需要移动),只训练上肢
  - Reward: 球拍面法向对准 + 球拍速度匹配 + 击中球
  - 球从固定位置投出(简化版 launcher)
  - 训练 ~3000 iterations

Stage 3: Task(全任务)
  - 目标:组合移动和挥拍完成完整击球
  - 加载 Stage 1 和 Stage 2 的 pretrained weights 作为初始化
  - Reward: 击球成功 + 落点精度 + 移动效率 + 动作平滑
  - 完整随机 launcher
  - 训练 ~5000 iterations

对本章的启示:Ch28 的 curriculum 设计可以参考这个三阶段模式。环境需要支持独立训练 footwork 和 swing 的子任务——即同一个场景配置可以注册为多个 task(不同的 reward 和 termination)。

在 mjlab 中实现多 task 注册:

# 同一个 scene,三个不同的 task(Ch28 预规划)
register_mjlab_task(
    task_id="Mjlab-Tennis-Footwork",
    env_cfg_entry_point=tennis_footwork_env_cfg,  # 只有 locomotion reward
)
register_mjlab_task(
    task_id="Mjlab-Tennis-Swing",
    env_cfg_entry_point=tennis_swing_env_cfg,      # 只有 swing reward,固定位置
)
register_mjlab_task(
    task_id="Mjlab-Tennis-Strike",
    env_cfg_entry_point=tennis_strike_env_cfg,     # 完整 task reward
)

共性工程模式

从以上系统中提炼出的共性模式:

模式 描述 在环境中的实现
高频仿真 ≥ 1000 Hz 物理,≤ 50 Hz 控制 timestep ≤ 0.001, decimation ≥ 20
分层架构 感知→规划→控制 或 high-level→low-level 多个 observation group + 分层 action
Privileged Critic Critic 看到完美信息,Actor 只看可部署信息 actor_obs 和 critic_obs 分离
Dynamics DR 随机化球的物理参数 EventManager 中加 DR term
Observation Noise 球状态观测添加噪声 obs term 配置 enable_corruption=True
Perception-Aware Noise 噪声大小取决于机器人状态 自定义 noise function
Staged Curriculum 从简单到复杂分阶段训练 CurriculumManager 多 stage
球的历史 多帧球状态作为 obs obs history buffer
Motion Priors 人类运动数据作为先验 latent action space 或 AMP reward

这些模式中的前四个直接影响环境设计——本章建立的环境需要为它们"留接口",即使 Phase 1 还不实现。

仿真频率 vs 训练吞吐量的权衡

所有前沿系统都使用 ≥ 1000 Hz 的仿真频率,但更高频率意味着更多计算。一个粗略的估算:

# 训练吞吐量估算
sim_freq = 2000  # Hz
control_freq = 50  # Hz
decimation = sim_freq / control_freq  # = 40

num_envs = 4096
steps_per_rollout = 24  # PPO rollout length

# 每个 rollout 的仿真步数
sim_steps_per_rollout = steps_per_rollout * decimation * num_envs
# = 24 × 40 × 4096 = 3,932,160 步

# 如果每个仿真步需要 0.1 ms(MuJoCo Warp on GPU)
time_per_rollout = sim_steps_per_rollout * 0.1e-3  # ≈ 393 s ← 太慢!

# 但 MuJoCo Warp 并行 4096 环境时是批量处理
# 实际时间 ≈ steps_per_rollout × decimation × 单步时间
# ≈ 24 × 40 × 0.1 ms = 96 ms ← 可接受

LATENT 在 8 GPU 上训练约需要 10-20 小时。单 GPU 上同样配置可能需要 3-5 天。教学项目可以用较低的仿真频率(1000 Hz 而非 2000 Hz)和较少的环境数(1024 而非 4096)来减少训练时间——在接触精度和训练吞吐量之间做权衡。

本质洞察:球类运动 RL 的核心难点不在于算法(PPO 足够), 而在于工程设计——仿真频率、观测设计、分层架构、 curriculum 策略、Sim2Real 随机化。 这些设计决策大部分在环境层做出,而非在算法层。

⚠️ 常见陷阱

⚠️ 思维陷阱:直接照搬前沿系统的参数 - LATENT 的 2000 Hz + 8 GPU 配置是生产级设置,教学项目的计算资源可能不够 - 正确做法:理解每个参数选择的理由,根据自己的条件做权衡(例如 1000 Hz + 单 GPU 也可以作为起点)

⚠️ 概念误区:认为分层系统总是比端到端好 - ETH 羽毛球用端到端 policy 也取得了好结果 - 分层和端到端各有优劣:分层更容易调试(可以独立验证每层),端到端可能达到更高性能(层间信息不损失) - 正确做法:教学项目先用分层(容易理解和调试),高级项目可以尝试端到端

⚠️ 思维陷阱:认为更多运动数据 = 更好的系统 - LATENT 只用了 5 小时的业余选手数据就达到了 91% 的真机成功率 - Phybot 完全不用运动数据(无 motion priors) - 正确做法:运动数据是锦上添花而非必需,纯 RL 也能工作

练习

  1. [分析题] 对比 LATENT(分层)和 ETH 羽毛球(端到端)的架构。列出各自的至少 3 个优势和 3 个劣势。如果你的计算资源有限(单 GPU),你会选择哪种架构?为什么?
  2. [设计题] 基于 Phybot 的三阶段 curriculum,为网球任务设计一个类似的多阶段训练计划。每个阶段的 reward、termination 和 observation 应该如何不同?
  3. [跨章综合题] 结合 Ch09(Teacher-Student)和本节的 Privileged Critic 模式:如果 critic 能看到完美球状态但 actor 只能看到有噪声的估计,蒸馏时 student actor 的 obs 应该包含什么?latency 如何处理?

Part VII 三章共享术语与常数对照表

以下是 Ch26-28 三章共用的物理常数、坐标约定和接口定义。修改任何一项时需要同步检查另外两章的引用是否一致。

物理常数

常数 符号 单位 来源 使用处
球半径 \(r\) 0.033 m ITF 中值 Ch26 geom / Ch27 Magnus
球质量 \(m\) 0.057 kg ITF 中值 Ch26 geom / Ch27 EKF / Ch28 DR
球直径 \(d\) 0.066 m \(2r\) Ch27 像素估算
空气密度 \(\rho\) 1.225 kg/m³ 标准大气 Ch26 drag / Ch27 EKF
阻力系数 \(C_d\) 0.55 无量纲 网球经验值 Ch26 drag / Ch27 EKF
截面积 \(A\) 3.42×10⁻³ \(\pi r^2\) Ch26 drag / Ch27 EKF
复合阻力常数 \(\alpha\) 0.0202 m⁻¹ \(\rho C_d A / (2m)\) Ch27 EKF with drag
COR(硬地) 0.73-0.76 无量纲 ITF + Cross 2003 Ch26 标定 / Ch27 弹跳
重力 \(g\) 9.81 m/s² 标准值 全部三章

坐标约定

约定 定义 备注
\(x\) 球场长边,远端 baseline \(x > 0\),近端 \(x < 0\) 网在 \(x = 0\)
\(y\) 球场短边,中线 \(y = 0\) 右侧 \(y > 0\)
\(z\) 竖直向上 地面 \(z = 0\)
球飞行方向 \(v_x < 0\)(从远端向近端) Ch26 速度分解带负号
court-local pos_w - env_origins 所有 obs / reward / predictor 统一使用
topspin 正方向 参数 topspin > 0\(\omega_y < 0\) Ch26 vel[:, 4] = -topspin

接口定义(Ch27 → Ch28)

字段 类型 含义 Ch28 使用
predicted_state_now [N, 6] EKF 估计的当前球 (pos3, vel3) 实时跟踪
impact_pos_c [N, 3] 预测击球点 (court-local) approach reward 目标
impact_vel_c [N, 3] 击球时球速 球拍对准方向
time_to_impact [N] 距击球剩余秒数 timing reward
uncertainty [N] 预测不确定性 (m) confidence gating
validity [N] bool 预测是否可用 give-up 决策

本章常见误解汇总

误解 正确理解
"环境构建是低技术含量的搭建工作" 环境是综合项目中杠杆效应最大的环节
"球体参数需要精确标定" 定性合理即可,DR 比精确标定更重要
"500 Hz 仿真对所有场景足够" 球拍-球接触需要 1000-2000 Hz
"所有 geom 都应该参与碰撞" 视觉装饰 geom 必须设 contype=0
"四元数顺序无所谓" MuJoCo 用 [qw,qx,qy,qz],搞混会导致 body frame 错误
"env_origins 只影响视觉" 它影响所有物理状态的读写
"阻力系数 Cd 是常数" Cd 与 Reynolds 数有关,但 RL 中简化为常数 + DR
"先写算法再调环境" 环境 bug 会被算法"学进去",修正代价极高
"分层系统总是比端到端好" 各有优劣,取决于任务复杂度和计算资源
"更多运动数据 = 更好的系统" LATENT 5 小时数据就够,Phybot 完全不用数据

本章建立的心智模型

读完本章后,你脑中应该有以下心智模型:

球场 MJCF
    ↓ 定义坐标系(x=长, y=宽, z=上, 网在x=0)
球体 Entity
    ↓ freejoint(6 DoF)+ sphere geom + 接触参数
LaunchCommand
    ↓ 八维参数 → 速度分解(注意 vx 负号!)→ 写入 pose+vel
BallAerodynamics
    ↓ 三层:重力 → +drag → +Magnus
接触验证
    ↓ COR 标定 + 语义撞网 + 碰撞分离
Manager 全链路
    ↓ Scene → Commands → Obs → (Actions=空) → Reward → Termination
验收实验
    ↓ 三层验收:构建 → 轨迹 → 接触
前沿系统参考
    ↓ 2000 Hz / Privileged Critic / Staged Curriculum / DR

如果你能在给定发球参数后 10 秒内画出球的大致弹道、指出落点区域、预测 reward 和 termination 的触发情况,说明本章的核心知识已经内化了。


本章小结

知识点总表

编号 知识点 核心要点 对应节 难度
1 环境是数据协议 坐标、物理参数、语义是跨模块共享契约 26.1
2 球场几何与坐标系 court center=(0,0,0),网在 x=0,far side 正 x 26.2 ⭐⭐
3 碰撞分离原则 视觉 geom 设 contype=0,只保留物理碰撞面 26.2 ⭐⭐
4 freejoint 物理 7 维 qpos + 6 维 qvel,四元数顺序 [qw,qx,qy,qz] 26.3 ⭐⭐
5 球体接触参数 condim/friction/solref/solimp 的物理含义 26.3 ⭐⭐
6 COR 标定方法 从 2.54 m 落体实验标定弹跳恢复系数 26.3, 26.6 ⭐⭐⭐
7 八维发球命令 speed/elev/azim/x/y/h/ts/ss → 速度分解 26.4 ⭐⭐⭐
8 env_origins 加减法则 写入加、读取减 26.4 ⭐⭐
9 二次阻力模型 $F_{drag} = -\frac{1}{2}\rho C_d A v v$
10 Magnus 力模型 \(F_{Magnus} = C_L V \rho (\omega \times v)\) 26.5 ⭐⭐⭐
11 语义撞网检测 轨迹穿越 x=0 且高度 < 网高 26.6 ⭐⭐
12 Manager 全链路 从注册到 termination 的完整数据流 26.7 ⭐⭐
13 球拍 MJCF 建模 薄 box + condim + 面厚度 vs timestep 26.8 ⭐⭐⭐
14 阶段化引入路线 7 阶段从纯球 → 完整 strike task 26.8 ⭐⭐⭐
15 三层验收标准 构建 → 轨迹 → 接触 26.9 ⭐⭐
16 前沿系统仿真频率 LATENT 2000 Hz / HITTER 2000 Hz / ETH 1000 Hz 26.10 ⭐⭐⭐
17 分层 vs 端到端架构 各有优劣,教学项目推荐分层 26.10 ⭐⭐⭐
18 DR 比精确标定更重要 LATENT 去掉 DR 成功率从 91% → 14% 26.10 ⭐⭐⭐

累积项目:本章新增模块

本课程的累积项目在前 25 章中积累了完整的环境构建、训练管线和诊断能力。本章新增的模块是球类运动环境底座——网球场坐标系、球体物理模型、发球命令生成器、弹道空气动力学、接触验证流程。

项目进度更新:

阶段 能力 新增于
环境构建 EntityCfg → SceneCfg → ManagerBasedRlEnvCfg → Registry Ch04, Ch15
Obs/Action 设计 五条原则 + 双框架配置 Ch05
Reward/Curriculum 四类奖励 + 渐进 curriculum Ch06
训练管线 PPO 超参 + 多后端适配 Ch07
Domain Randomization EventManager + 分阶段 DR Ch08
Teacher-Student 特权学习 + 蒸馏 Ch09
大规模训练 多 GPU + NaN 排查 + 性能优化 Ch24
训练诊断 九种模式 + 症状索引 + 验证流程 Ch25
球类环境底座 球场坐标 + 球物理 + 发球命令 + 弹道模型 + 接触验证 Ch26

本章代码产出清单

以下是本章涉及的所有代码文件和配置:

文件/模块 状态 功能
tennis_court.xml 已有(mjlab 内置) 球场 MJCF 定义
_get_ball_spec() 已有 球体 MjSpec 构造
LaunchCommand 已有 八维发球命令生成器
BallAerodynamics 已有 三层空气动力学事件
ball_landed_in_service_box() 已有 Phase 1 reward
BallNetPlaneContact 已有 语义撞网检测
calibrate_cor.py 本章新增(练习产出) COR 标定脚本
acceptance_test.py 本章新增(练习产出) 三层验收自动化
landing_distribution.py 本章新增(练习产出) 落点分布分析
unitree_g1_with_racket.xml Ch28 预规划 G1 + 球拍 MJCF
tennis_footwork_env_cfg.py Ch28 预规划 Footwork 子任务
tennis_strike_env_cfg.py Ch28 预规划 完整击球任务

延伸阅读

资料 地址 难度 与本章的关系
LATENT 代码库 github.com/GalaxyGeneralRobotics/LATENT ⭐⭐⭐ 人形网球完整系统,MuJoCo JAX 2000 Hz
HITTER 论文 arXiv:2508.21043 ⭐⭐⭐ 人形乒乓球,分层规划+学习
ETH 羽毛球 Science Robotics, 2025 ⭐⭐⭐ 四足+手臂端到端 RL,机载视觉
Phybot 人形羽毛球 arXiv:2511.11218 ⭐⭐⭐ 三阶段 curriculum,无 motion priors
CyboRacket arXiv:2603.14605 ⭐⭐ 人形球类感知-动作框架
HUSKY 代码库 github.com/TeleHuman/humanoid_skateboarding ⭐⭐ mjlab 人形+外部物体集成范式
ITF Rules of Tennis 2026 https://www.itftennis.com 球场标准尺寸、发球规则
MuJoCo XML Reference https://mujoco.readthedocs.io ⭐⭐ freejoint/contact/condim/friction/solref/solimp
MuJoCo Computation/Contact https://mujoco.readthedocs.io/en/stable/computation/ ⭐⭐⭐ 接触力求解器数学细节
Cross et al. 2003 "Measurements of tennis court speeds" ⭐⭐ 真实场地弹跳数据(COR 标定参考)
NASA Drag Equation https://www1.grc.nasa.gov/beginners-guide-to-aeronautics/drag-equation/ 二次阻力公式的物理推导
MuJoCo Menagerie github.com/google-deepmind/mujoco_menagerie ⭐⭐ 标准机器人 MJCF 模型库(含 Franka、G1 等)

🔧 故障排查手册

症状 可能原因 排查步骤 相关节
球向远端飞(不过网) \(v_x\) 符号错 1. 打印 velocity 张量 2. 检查速度分解中的负号 3. 确认 far side 是正 x 26.4
球落地后贴地前行不弹跳 solref 太软或多层碰撞面 1. 检查 court geoms 的 contype 2. 调整 solref dampratio 3. 运行 COR 标定实验 26.6
多环境时球飞到邻场 env_spacing 太小 1. 检查 env_spacing ≥ 30 2. 检查 env_origins 偏移是否正确 26.2
reward 全零 坐标系错或发射方向反 1. 打印 ball_local_pos 的 x/y/z 2. 确认球从 x>0 向 x<0 飞 3. 检查 reward box 范围 26.4
viewer 看不到球 camera 跟随配置错 1. 检查 ViewerConfig entity/body 2. 只看 env 0 3. 确认球 geom 的 rgba alpha > 0 26.9
CLI 参数不生效 range 参数未加引号 1. --help 查看参数名 2. 加引号 "[...]" 3. 检查 tyro 参数拼写 26.4
球弹跳异常高(COR > 0.85) solref dampratio 太小 1. 运行 COR 标定 2. 增大 dampratio 至 1.2-1.5 3. 重新标定确认 26.3, 26.6
Magnus 力方向反 叉积顺序错 1. 检查 omega × v vs v × omega 2. topspin 球应更快下坠而非更飘 3. 固定参数对比验证 26.5
episode 不结束 termination 阈值过宽 1. 检查 ball_stopped 的 speed 和 z 阈值 2. 检查 timeout 设置 3. 检查 oob 范围 26.7
球穿过网没触发 termination 网碰撞体太薄 + 语义检测缺失 1. 增大网 box 的 size[0] 2. 确认 BallNetPlaneContact 已注册 3. 提高仿真频率 26.6
topspin 对弹跳无影响 condim=1 导致无切向摩擦 1. 检查球-地面的 condim 2. 改为 condim=3 3. 重新运行 topspin 对照实验 26.5, 26.6
阻力开关不生效 BallAerodynamics event 未注册 1. 检查 env_cfg 的 events 配置 2. 确认 enabled 参数路径正确 3. 打印 event 调用日志 26.5
球初始位置在地面以下 env_origins 加减错误 1. 打印 world frame 和 local frame 的 ball_pos 2. 确认写入时加了 env_origins 26.4
多次 reset 后球行为变异 qpos/qvel 未完全重置 1. 检查 reset 时是否同时重置了 pose 和 velocity 2. 确认没有残留的角速度 26.4, 26.7

调参决策树

当实验结果不符合预期时,按以下顺序排查:

球不动?
  ├── 是 → 检查 freejoint(26.3)
  │
  └── 否 → 球飞错方向?
        ├── 是 → 检查 vx 符号(26.4)
        │
        └── 否 → 球飞太远或太近?
              ├── 太远 → 检查阻力是否启用(26.5)
              ├── 太近 → 检查 speed 范围 / elevation 角度
              │
              └── 距离合理 → 弹跳正常吗?
                    ├── 不弹 → 检查 solref / contype(26.6)
                    ├── 弹太高 → 增大 dampratio
                    │
                    └── 弹跳正常 → reward 正常吗?
                          ├── 全零 → 检查坐标系 / reward box(26.7)
                          │
                          └── 正常 → ✅ 环境验收通过

参数量纲速查表

参数 单位 默认值 物理范围 调参方向
speed_range m/s [20, 45] 0-70 越大球越快
elevation_range deg [-8, 2] -30 ~ 30 正值向上
azimuth_range deg [-10, 10] -30 ~ 30 正值向右
height_range m [1.5, 3.0] 0.5-4.0 击球点高度
topspin_range rad/s [-100, 300] -500 ~ 500 正值上旋
drag_coefficient 无量纲 0.55 0.3-0.8 越大阻力越大
lift_coefficient 无量纲 1.0 0.3-2.0 越大 Magnus 越强
ball_mass kg 0.057 0.04-0.07 越重阻力影响越小
solref_timeconst s 0.02 0.001-0.1 越小接触越"硬"
solref_dampratio 无量纲 1.0 0.5-2.0 越大能量耗散越多
timestep s 0.002 0.0005-0.005 越小越精确但越慢
decimation count 5 1-40 = sim_freq / control_freq
env_spacing m 30.0 ≥26.2 ≥ 球场对角线

给下一章的桥

本章建立了网球综合项目的物理底座——球场坐标系、球体物理、发球命令、弹道模型和接触验证。这些是后续所有模块共享的"数据契约"。

本章向 Ch27 传递的接口规格

接口 类型 格式 Ch27 如何使用
ball_position Observation [N, 3] court-local trajectory predictor 的输入
ball_velocity Observation [N, 3] world frame trajectory predictor 的输入
ball_angular_velocity Observation [N, 3] world frame 旋转效应建模的输入
court coordinate system Convention x=长, y=宽, 网=x=0 感知标注和预测坐标系
BallAerodynamics model Physics drag + Magnus 预测器的物理先验
launch_params Command [N, 8] 预测器评估时的 ground truth

本章向 Ch28 传递的接口规格

接口 类型 格式 Ch28 如何使用
Scene 配置 Architecture SceneCfg with court + ball 添加 robot entity
接触参数 Physics solref/friction/condim 球拍接触参数基线
发球命令 Command LaunchCommand + 8D ranges Curriculum 控制发球难度
验收标准 Engineering 三层验收 Ch28 每次改环境后重新验证
仿真频率建议 Config 1000-2000 Hz for strike timestep 和 decimation 配置
阶段化路线 Engineering Stage 0-6 按阶段引入机器人和训练

Ch27 将在这个底座上建立感知与轨迹预测模块——从球的物理状态出发,解决"机器人如何知道球在哪"和"球将飞到哪"两个核心问题。感知模块的输入是本章定义的 observation(ball_position, ball_velocity, ball_angular_velocity),输出是 Ch28 控制模块需要的 time_to_impact 和 predicted_landing_point。如果本章的坐标系定义含糊或 observation frame 不一致,Ch27 的预测器将从第一天起就带有系统性偏差。这就是为什么本章花了大量篇幅在坐标约定和 frame 一致性上——它们是感知模块的"输入规格书"。

Ch28 将在本章的物理验证基础上引入机器人和训练——按 26.8 节规划的 Stage 0-6 路线,逐步从"只有球"走向"完整的人形网球击球策略"。本章的三层验收标准是 Ch28 每次改动后的"回归测试"——确保物理底座始终正确。