⚠️ Alpha 内测版本:课程内容尚未完成,可能发生变更,欢迎通过 Issue 提交反馈。
跳到正文

L03 · 刚体物理与稳定仿真

课程状态: 讲义和可执行 notebook 均已提供。notebook 已在 CPU 和参考 AMD ROCm 平台上通过干净 kernel 验证;由于 CPU 仍是本讲的最低硬件要求,L03 状态为 cpu-verified

本讲定位

L02 让一个方块经历了声明、构建、步进和观测的完整生命周期。当时所有物理配置都保持 不变,以便把注意力集中在仿真程序的结构上。L03 开始有意识地改变部分参数,并回答一个 更难的问题:

当刚体落到桌面或在桌面上滑动时,哪些现象来自物理模型,哪些差异来自数值离散, 我们又需要什么证据才能谨慎地判断仿真是否稳定?

一张看起来合理的最终画面并不足以回答这个问题。方块最终可能停在桌面附近,但此前 短暂穿入桌面、丢失接触、发生反弹或持续振荡。反过来,接触点数量发生变化也不一定 表示方块真的离开了桌面。因此,本讲把时间序列和定义清楚的指标作为主要证据。

开始前,你应当能够:

  • 从干净 kernel 运行课程 notebook,并识别实际选择的 Genesis 后端;
  • gs.init → Scene → add_entity → build → step → state 阅读代码;
  • 区分 Morph、Material 和 Surface;
  • 读取未批处理刚体的位置和线速度。

本讲不需要机器人模型、逆运动学、数据集或学习策略。实验只使用 Genesis 内置 Box, 可以在 CPU 上完成;经过验证的 AMD GPU 可以加速同一实验,但不是学习本讲的前提。

学习目标

完成 L03 后,你应当能够:

  1. 将几何、密度、摩擦、dt、substeps、采样规则和颜色分别归入物理、数值、观测或 视觉信息;
  2. 根据一组 Genesis 配置计算仿真时长、内部时间步长、外层样本数和内部更新次数;
  3. 设计受控对照,只改变一个因素,同时保持场景、seed、初始状态和仿真时长一致;
  4. 结合高度、速度、接触、几何间隙、穿透代理值和沉降误差解释方块落体实验;
  5. 解释 Genesis 1.3.3 如何组合接触双方的摩擦系数,并用位置和速度轨迹比较停止距离;
  6. 区分实测现象、求解器内部量和普适物理结论,避免超出证据范围;
  7. 按明确顺序排查未发生接触、状态非有限、异常穿透、未能停止和 CPU/GPU 结果差异。

实验的四个层次

把输入和证据分成四层,可以让受控仿真实验更容易分析。

层次L03 中的例子它回答的问题
物理几何、rho、摩擦、重力、初始位姿和速度我们让引擎模拟了怎样的物理系统?
数值dt、substeps、内部时间步长引擎以多细的时间尺度近似系统演化?
观测外层采样时刻、停止阈值、持续窗口、绘图范围我们如何把离散记录转化成证据?
视觉Surface 颜色、图例颜色、示意图样式我们如何区分和呈现对象?

前三层会影响结论本身或证据力度。视觉层只帮助读者辨认对象,不会改变物理行为。把 方块涂成蓝色并不能让它变得更滑。

rho 属于物理层。对几何固定的 primitive,它会影响质量和惯量;但在本场景中,不能 据此推断不同密度的物体具有不同的重力加速度。两个主实验都会保持密度不变,以免它 干扰我们对接触或摩擦的判断。

本讲统一使用下面这条证据链:

text
物理配置 + 数值配置

固定 seed 和相同仿真时长

采样状态 + 接触证据 + 几何证据

单变量对照 + 有适用边界的结论

如果两个 case 同时改变摩擦、时间步长和初始速度,即使结果不同,也无法判断究竟是哪项 变化造成了差异。

时间离散:三个量,三种职责

Genesis 通过 SimOptions 暴露外层仿真时间步长和内部子步数:

python
from robo_genesis.course_utils import to_numpy

scene = gs.Scene(
    sim_options=gs.options.SimOptions(dt=0.01, substeps=2),
    rigid_options=gs.options.RigidOptions(enable_collision=True),
    show_viewer=False,
)

这组配置对应:

text
外层 dt       = 0.01 s
substeps       = 2
内部 dt       = dt / substeps = 0.005 s

课程使用 substep_dt 表示这个内部时间步长。它是由前两个参数计算得到的描述量, 不是第三个独立输入。

dt:外层边界

每调用一次 scene.step(),仿真时间就前进 dt 秒。如果每次调用后读取一次状态,外层 采样间隔也就是 dt。到了 L04 的关节控制和 L05 的观测,在最直接的单步循环中, Python 更新控制目标或读取传感器的频率也由这条外层边界决定。

执行 n_steps 次外层步进时:

text
simulated_time = n_steps × dt
external_samples = n_steps              # 每次 step 后记录一次时

因此,不同时间配置必须在相同仿真时长下比较,不能只让它们执行相同 step 数。例如, dt=0.01 时运行 100 步代表 1 秒,而 dt=0.02 时运行 100 步已经代表 2 秒。

substeps:内部分辨率

在一个外层 step 内,Genesis 1.3.3 会执行配置数量的内部子步。内部时间步长和更新次数为:

text
substep_dt = dt / substeps
internal_updates = n_steps × substeps

dt 不变时增加 substeps,会让求解器使用更细的内部时间分辨率,但不会改变 Python 侧的观测与命令更新频率。更多子步也意味着更多内部计算,因此它是一项数值取舍,不能 简单理解为“免费让所有稳定性指标都单调变好”。

内部时间步长相同,不等于实验接口相同

比较下面两组配置:

Casedtsubstepssubstep_dt1.5 s 内的样本数
N10.01 s10.010 s150
N40.02 s20.010 s75

它们的内部时间步长相同,在 1.5 秒内执行的内部更新总数也相同,但外层采样频率不同。 N1 在 0.01 秒处的样本没有对应的 N4 样本;计算轨迹差异时,只能对齐 0.02、0.04、 0.06 秒这样的共同采样时刻。

当后续课程开始在外层 step 边界更新动作和读取传感器时,这个区别会更加重要。L03 先 建立时间概念,L04 再把它应用到控制。

接触是一种数值近似

两个刚体网格在真实物理意义上不应占据同一空间,但离散求解器不会连续观测和求解每个 瞬间。它以有限时间步推进、检测接触,并在内部时间分辨率上施加约束。因此,即使仿真 总体可用,也可能测到少量重叠。

这并不意味着可以忽略穿透,而是要求我们准确说明测量方法及其局限。

基于几何的穿透代理值

落体实验使用水平固定桌面和初始姿态与坐标轴对齐的方块。方块理想静止时的中心高度为:

text
expected_center_z
    = table_center_z + table_height / 2 + cube_size / 2

notebook 根据每个外层 step 后记录的状态计算:

text
penetration_proxy
    = max(0, expected_center_z - minimum_sampled_center_z)

这个值可以比较四组受控 case,但它不是求解器精确的连续时间穿透深度:

  • 轨迹只在每个外层 dt 后观测一次;
  • 两个样本之间更深的瞬时事件可能被遗漏;
  • 公式假设方块大致保持实验预期的轴对齐姿态;
  • 它由中心高度推导,而不是直接读取求解器内部接触数据。

因此,应称它为“穿透代理值”,而不是笼统地称为“穿透深度”。

Contact count 是辅助证据

runner 会读取动态方块与桌面之间的接触:

python
contacts = cube.get_contacts(with_entity=table)
contact_count = int(contacts["position"].shape[0])

contact count 描述的是当前采样时刻所报告接触流形中的接触数量。随着参与接触的角点、 表面或求解器接触点发生变化,这个数量也可能改变。第一次接触之后出现一次零接触, 并不足以证明方块离开了桌面。

因此,实验还会计算方块底部与桌面顶部之间的间隙:

text
clearance = cube_center_z - cube_size / 2 - table_top_z

只有当 contact count 为零,并且 clearance 大于事先声明的小容差时,该样本才计为观测 到的几何分离。即便如此,分离时长仍受到外层 dt 的量化限制。

用多项证据理解稳定性

L03 不用单一阈值定义“稳定仿真”,而是联合阅读下面这些信号:

证据它测量什么单独使用时不能证明什么
有限的 zvz 轨迹记录状态在数值上仍可使用接触行为正确
穿透代理值最低采样中心相对理想静止高度的偏差精确的连续时间穿透
接触是否存在外层采样时刻是否报告接触是否真实分离或反弹
几何分离无接触且底部间隙为正持续时间短于 dt 的事件
向上速度第一次接触后的向上运动没有间隙证据时的真实分离
尾段沉降误差声明窗口内平均最终高度的偏差被均值掩盖的振荡不存在
完整轨迹瞬时穿透、反弹和沉降过程已模拟时长之外的行为

可信的结论应说明观察到了哪些证据,以及观测窗口有多长。“最终画面看起来没问题” 不是这样的结论。

Part A:2×2 接触实验

Part A 让同一个方块落到同一张固定桌面,并模拟 1.5 秒。几何、密度、摩擦、重力、 初始位姿、seed 和精度全部保持不变,只改变 dt 与 substeps:

Casedtsubstepssubstep_dt用途
N10.01 s10.010 s较细的外层节奏,基准内部分辨率
N20.01 s20.005 s固定 dt,单独考察增加 substeps
N30.02 s10.020 s最粗的内部分辨率
N40.02 s20.010 s单独考察增加 substeps,并与 N1 匹配内部步长

运行前先预测:

  1. 哪些 case 有 150 个外层样本,哪些只有 75 个?
  2. 固定 dt 增加 substeps 时,你预计穿透代理值如何变化?
  3. N1 与 N4 在共同采样时刻的状态是否应当接近?
  4. substep_dt 相同,是否代表两条完整数组长度相同?
  5. 如果某个 case 的穿透代理值更小,却出现短暂几何分离,能否只凭一个数字判断它 “更稳定”?

相关 Genesis 操作

可复用 runner 负责场景构建和步进,其核心物理路径保持得很短:

python
scene = gs.Scene(
    sim_options=gs.options.SimOptions(dt=dt, substeps=substeps),
    rigid_options=gs.options.RigidOptions(enable_collision=True),
    show_viewer=False,
)

table = scene.add_entity(
    gs.morphs.Box(size=(0.9, 0.6, 0.05), pos=(0.35, 0.0, 0.70), fixed=True),
    material=gs.materials.Rigid(friction=0.8),
)
cube = scene.add_entity(
    gs.morphs.Box(size=(0.08, 0.08, 0.08), pos=(0.35, 0.0, 1.0)),
    material=gs.materials.Rigid(rho=500.0, friction=0.5),
)

scene.build()
for sample_index in range(n_steps):
    scene.step()
    z[sample_index] = float(to_numpy(cube.get_pos()).reshape(-1)[2])
    vz[sample_index] = float(to_numpy(cube.get_vel()).reshape(-1)[2])
    contact_count[sample_index] = int(cube.get_contacts(
        with_entity=table
    )["position"].shape[0])

notebook 会显式展示每个 case 的配置、子进程命令、保存字段、指标计算和绘图逻辑。 场景构建在 Python 包中只保留一份可复用实现,避免中英文 notebook 逐渐变成两套不同的 物理实验。

按正确顺序阅读对照结果

四组 case 全部结束后,按照下面的顺序分析:

  1. 核对每组实际 backend、dt、substeps、substep_dt、仿真时长和样本数。
  2. 先检查数组 shape 和数值有限性,再解释任何图表。
  3. 比较 N1 与 N2、N3 与 N4;每一对都固定外层 dt,只改变内部分辨率。
  4. 只在共同采样时刻比较 N1 与 N4。相同 substep_dt 用来检查无控制刚体轨迹是否 接近,同时保留两者外层采样不同这一事实。
  5. 联合阅读高度、速度、接触、间隙和尾段指标。
  6. 把结论限定在当前场景和锁定引擎版本内,不要把它写成“增加 substeps 一定能解决 所有接触问题”的定律。

摩擦属于接触对

gs.materials.Rigid(friction=...) 为实体的刚体几何指定摩擦系数。接触处的运动取决于 两侧几何,而不是只取决于运动中的那个物体。

在本课程锁定的刚体接触实现中,Genesis 1.3.3 会先应用运行时 friction ratio,再取 两侧滑动摩擦系数中的较大值。当前实验的默认 ratio 为一,因此规则可以写成:

text
effective_pair_friction = max(table_friction, cube_friction)

这是 Genesis 1.3.3 的实现规则,不是所有物理引擎都遵守的普适定律。引擎版本变化后, 必须重新核对。L11 会使用运行时 friction ratio 做域随机化;同一接触对规则解释了为 什么只改变一个接触面,有时不会产生任何效果。

基准参数让这个行为可以直接验证:

通道桌面摩擦方块摩擦接触对有效摩擦系数
低摩擦方块0.500.100.50
高摩擦方块0.500.800.80

如果只把桌面摩擦从 0.50 改为 0.30,两个方块保持不变:

通道修改后的桌面摩擦方块摩擦接触对有效摩擦系数
低摩擦方块0.300.100.30
高摩擦方块0.300.800.80

因此,低摩擦通道应当发生变化,而高摩擦通道可以作为近似不变的对照。实验检查这个 方向性预测,但不要求所有支持平台都得到某个硬编码停止距离。

Surface 颜色不参与上述计算。橙色和蓝色只用于在图中区分两条通道;交换颜色不应交换 物理结果。

Part B:摩擦与持续停止距离

Part B 使用一张桌面和两个相同方块。几何、密度、位姿、dt=0.01、substeps=2、 初始水平速度和测量时长全部固定。基准实验中只有两个方块自身的摩擦系数不同。

测量开始前先建立稳定接触

方块一开始位于几何上的静止高度,但求解器仍需要建立接触流形。runner 会先推进一小段 沉降时间,再把此后的时刻定义为测量零点,然后为两个自由刚体设置相同水平速度:

python
for _ in range(settle_steps):
    scene.step()

initial_velocity = np.array([2.0, 0.0, 0.0, 0.0, 0.0, 0.0])
cube_low.set_dofs_velocity(initial_velocity)
cube_high.set_dofs_velocity(initial_velocity)

如果省略沉降阶段,初始接触瞬态就会混入滑动对照。

查看结果前先定义“停止”

一个速度样本接近零,可能只是过零、振荡或数值波动。实验必须在运行前声明速度阈值和 持续窗口。只有从某个样本开始,整个持续窗口都满足

text
abs(vx) < stop_speed

才能把这个样本判为停止起点。如果测量窗口内始终没有满足条件,停止时间和停止距离应 保持为不可用,而不是用最后一个样本编造结果。

如果在索引 i 检测到持续停止:

text
stop_time = time[i]
stop_distance = x[i] - x[0]

runner 还会记录角速度。摩擦作用在接触位置,自由方块可能把一部分平移运动转化为转动。 因此,停止时间和停止距离不一定按照直觉给两个 case 排出相同顺序。应当联合阅读完整的 x(t)vx(t) 和角速度轨迹,并以本实验要求的停止距离作为主要比较量。

不要把某个教科书式质点停止公式反过来套用到这条轨迹,以此估计 Genesis 的摩擦系数。 这里模拟的是带转动动力学的自由刚体,接触由离散求解器处理。

只改变一个因素的桌面实验

完成基准实验后,动手对照只把桌面摩擦从 0.50 改为 0.30。运行前先预测:

  • 哪条通道的接触对有效摩擦系数会变化;
  • 哪个方块应当滑得更远;
  • 哪条通道应当近似不变;
  • 为什么只改变颜色不会影响任何结果。

只有在方块属性、数值设置、沉降时长、初始速度、seed 和测量时长全部不变时,这个对照 才有效。

配套 notebook 流程

配套 notebook 发布后,将按以下顺序执行:

  1. 报告版本、请求的 backend mode、渲染模式、子进程实际后端和输出目录;
  2. 展示 N1–N4 配置并记录运行前预测;
  3. 在隔离进程中分别运行四组接触 case;
  4. 校验并读取结构化 .npz 结果;
  5. 启用渲染时展示真实的 Genesis 初始/最终相机画面,否则展示明确标注的状态示意图, 并同时展示指标表以及对齐的高度、速度和接触曲线;
  6. 运行双通道摩擦基准实验;
  7. 比较真实场景画面、位移、线速度、角速度和持续停止结果;
  8. 只把桌面摩擦改为 0.30,再次比较数值结果和最终场景画面;
  9. 输出 L03 CHECK: PASSED 或具体失败条件。

Genesis 初始化具有进程级状态。使用独立进程,可以让多个 dt/substeps 配置从同一本 notebook 运行,而不要求你在每个 case 之间重启 kernel。这只是执行细节,不是本讲主题; 学习重点是传入每个进程的配置,以及进程返回的证据。

仿真后端与渲染能力是两个独立选择。核心路径仍只使用状态数据,并可在 ROBO_GENESIS_RENDER=0 时运行;该分支会打印明确的渲染 SKIP,并根据实测位置绘制 初始/最终示意图。设置 ROBO_GENESIS_RENDER=1 后,每个隔离进程都会在 build() 前 加入相机并返回经过校验的初始/最终 RGB 画面;相机或渲染一旦失败就会停止运行,不会 静默退回示意图。两条路径都保留轨迹图作为定量证据,相机画面则用于展示 Genesis 场景 实际呈现的状态。

面向学习者的默认模式会在可用时选择已经验证的 AMD 后端,否则使用 CPU;显式 CPU 模式构成本讲的 cpu-ok 路径。如果已经选择 AMD 而运行失败,错误必须保持可见;静默 改用 CPU 会让后端验证结论失效。

如何判断实验成功

最终结果应当证明下面这些条件,但不依赖某一组完全固定的浮点数:

  • 每个子进程都报告请求模式和实际后端,并成功退出;
  • 每个子进程都报告请求的渲染模式;启用时返回非空且数值有限的 RGB 画面,关闭时返回 明确的空画面字段并打印渲染 SKIP
  • 所有必需数组长度正确且数值有限;
  • 每个接触 case 都覆盖 1.5 秒,所以样本数会随 dt 改变;
  • 计算得到的 substep_dt 和内部更新次数与 case 表一致;
  • 每个 case 中方块都接触桌面,并且没有穿过桌面;
  • dt 固定时,更细的内部时间步长会降低当前锁定场景的穿透代理值;
  • N1 与 N4 在共同采样时刻的状态差异位于预先确定的容差内,同时外层样本数仍然不同;
  • 解释联合使用轨迹、接触、间隙和沉降证据,而不是把某一个指标直接称为“稳定性”;
  • 摩擦基准实验中,接触对有效摩擦系数较低的通道在持续停止前移动得更远;
  • 降低桌面摩擦会按预测方向改变低摩擦通道,同时让高摩擦通道近似不变;
  • 生成的 .npz 和图像只留在配置的输出目录中,不进入版本控制。

CPU 与 AMD 结果可能存在细小差异。容差应描述事先认可的数值范围,不能在看到失败后 临时放宽,只为了让检查通过。调查后端差异前,应当保留原始配置和证据。

常见失败与诊断

不同 case 覆盖了不同仿真时长

检查 n_steps × dt,不要只检查 n_steps。根据同一个目标时长重新计算步数,并要求 乘积与目标时长一致。

一次修改了太多输入

打印完整 case 配置并逐字段比较。N1→N2 或 N3→N4 只能改变 substeps;桌面摩擦练习 只能改变桌面摩擦。

substep_dt 相同,数组长度却不同

这正是 N1 与 N4 的预期结果。它们的内部时间步长相同,但外层 dt 和样本数不同。 计算轨迹差异前,应先对齐共同时间戳。

没有报告接触

先检查桌面顶部、方块底部、初始高度、重力、总时长和 enable_collision=True,再查看 高度轨迹,确认方块是否真的到达桌面。不要一开始就放宽容差。

Contact count 短暂变为零

检查同一采样时刻的底部间隙和垂直速度。只有接触条件与几何条件同时支持时,才能把它 计为观测到的分离。

状态变为非有限值,或方块穿过桌面

找到第一个异常样本,记录对应 case 和 backend,再把它的 substep_dt 与成功 case 比较。保留子进程的 stdout 和 stderr,不要捕获失败后替换成一个成功 case。

方块始终没有满足停止条件

检查测量时长、速度阈值、持续窗口、接触状态和完整速度轨迹。应当报告“在测量窗口内 没有停止”,不能把最终位移伪装成停止距离。

改变一个摩擦值却没有效果

按 Genesis 1.3.3 的 max 规则计算两个接触对的有效摩擦系数。接触另一侧原本就可能 占主导。这就是实验把桌面摩擦从 0.50 降到 0.30 的原因:不能假设修改任意一侧都会 改变运动。

请求 AMD 运行却报告 CPU

检查 PyTorch 的 HIP 构建信息、设备可见性、请求模式和子进程打印的实际后端。不能把 CPU 输出当作 AMD 运行证据。还要记住,ROCm 设备通过 PyTorch 的 cuda 命名空间暴露, 只看命名空间无法判断硬件是不是 NVIDIA。

CPU 与 AMD 结果存在细小差异

确认 seed、精度、引擎版本、case 配置和时长一致。先比较方向性关系和预先确定的容差, 再比较打印出的十进制字符串。如果定性关系发生变化,应同时保留两套结果并调查原因, 而不是隐藏其中一套。

检查点与练习

概念检查

先独立回答,再回看前面的表格:

  1. dt=0.02、substeps=2 时,substep_dt 是多少?1.5 秒内有多少个外层样本和 内部更新?
  2. 为什么不能让每组时间配置都运行相同数量的外层 step?
  3. 为什么 N1 与 N4 只能在共同采样时刻比较?
  4. contact_count == 0 能否单独证明方块发生反弹?
  5. 桌面摩擦为 0.50、方块摩擦为 0.10 时,Genesis 1.3.3 在本实验中采用的接触对 有效摩擦系数是多少?
  6. 为什么把桌面降到 0.30 会影响低摩擦通道,却不会影响系数为 0.80 的高摩擦方块?
  7. 为什么交换橙色和蓝色 Surface 不会改变轨迹?
  8. 为什么一个低于阈值的速度样本不能证明已经持续停止?

动手练习

先运行摩擦基准实验,再只把桌面摩擦从 0.50 改为 0.30。第二次运行前,写下预期的 接触对有效摩擦系数和每条通道停止距离的变化方向。运行后:

  1. 从打印配置确认没有其他输入发生变化;
  2. 比较完整 vx(t) 曲线,而不是只看最终值;
  3. 报告每条通道的持续停止距离;如果测量窗口内没有停止,应明确说明;
  4. 解释每项预测是否得到证据支持;
  5. 说明结论适用的引擎版本和参数范围。

作为扩展,可以增加 dt=0.01、substeps=4 的第五组接触 case。保持 1.5 秒时长和所有 物理参数不变,先预测哪些指标可能改善、哪些没有保证,以及配置需要增加多少内部计算。 把结果视为新增的一个数据点,而不是普适的收敛证明。

小结与后续衔接

  • 物理参数描述被模拟的系统,数值参数定义时间离散,观测规则定义证据,视觉参数只 影响呈现。
  • dt 是外层仿真、命令和采样边界;substeps 细分边界内部的求解过程, substep_dt = dt / substeps
  • 公平的数值对照使用相同仿真时长,并在共同采样时刻对齐轨迹。
  • 接触稳定性需要多项信号。穿透代理值、contact count、间隙、速度、沉降误差和完整 轨迹都有各自局限。
  • Genesis 1.3.3 在应用运行时 ratio 后,使用刚体接触对两侧较大的滑动摩擦系数;这是 特定版本行为。
  • 持续停止需要事先声明速度阈值和持续窗口。自由方块可能转动,因此应联合阅读位置、 线速度和角速度。
  • 结论应说明支持它的场景、引擎版本、后端、配置和观测窗口。

L04 会把外层时间步长连接到关节命令周期,并比较目标状态与机器人真实运动。L07 会把 接触对推理应用到桌面、物体和夹爪。L11 将通过运行时域随机化再次讨论摩擦,并说明为 什么必须考虑所有相关接触面。

资料来源