过程梳理

Metric cache

Metric Cache 在这个工程中是一个预计算的环境真值与场景快照,专门为了加速 PDM (Predictive Drive Model) 评分过程而设计的。

它的核心作用是:解耦了繁重的 NuPlan 数据加载与实时特征计算。在评估(Inference/Evaluation)阶段,模型不需要去查原始的 NuPlan 数据库,而是直接读取这个 Cache 来获取所有的环境约束和评判标准。

以下是详细的工程视角解析:

  1. 字段定义 一个 Python dataclass 对象,被序列化为 .pkl 文件(LZMA压缩)。

它包含以下核心字段:

  • file_path: 原始文件路径。
  • trajectory: PDM-Closed Planner 生成的参考轨迹。这是一个基于规则(IDM)生成的“专家演示”轨迹,用于作为 Baseline 对比。
  • ego_state: 自车的初始状态(位置、速度、朝向)。
  • observation: 未来时刻的动态障碍物真值 (PDMObservation)。包含周围车辆、行人的未来轨迹(已插值到 10Hz)。
  • centerline: 车道中心线 (PDMPath)。用于判断自车是否在正确路线上以及计算行驶进度。
  • route_lane_ids: 导航路径上的车道 ID 列表。
  • drivable_area_map: 可行驶区域图 (PDMDrivableMap)。用于快速碰撞检测,判断自车是否驶出道路。

  1. 生产 生成逻辑在 metric_cache_processor.py 中。

当你运行 run_metric_caching.py 时,流程如下:

  1. 加载场景: 从 NuPlan 数据库读取原始 Scenario。
  2. 运行 PDM-Closed Planner: 也就是运行一个基于 IDM (Intelligent Driver Model) 的规则算法,生成一条“参考轨迹”并存入 trajectory 字段。
  3. 插值真值 (Ground Truth): 将场景中所有其他 Agent 的真实轨迹插值到 10Hz(0.1秒间隔),存入 observation。这是为了在评估时进行高精度的碰撞检测。
  4. 提取地图: 提取周围的可行驶区域多边形和中心线,存入 drivable_area_map 和 centerline。
  5. 序列化: 将上述所有对象打包,压缩保存为 Cache 文件。

  1. 使用 在推理/评估脚本 run_pdm_score.py (调用 pdm_score.py) 中:

  2. 加载 Cache: 脚本首先读取 Metric Cache 文件,瞬间恢复场景上下文。

  3. 模型推理: 你的 AI Agent(如 TransFuser, DiffusionDrive)仅基于当前的输入生成一条 预测轨迹 (pred_trajectory)。

  4. 双轨模拟 (Simulation): 代码实际上会同时模拟两条轨迹:

  • Index 0: Cache 中的 trajectory (规则算法生成的 Reference)。
  • Index 1: 模型生成的 pred_trajectory (你的 Agent)。
  1. 打分 (Scoring): PDMScorer 使用 Cache 中的信息对你的轨迹进行“判卷”:
  • 碰撞检测: 拿你的轨迹去撞 Cache 中的 observation(障碍物真值)。
  • 出界检测: 拿你的轨迹去比对 Cache 中的 drivable_area_map。
  • 进度计算: 拿你的轨迹投影到 Cache 中的 centerline 看走了多远。

  1. 语义信息对应表

字段 对应语义 / 评估用途 observation 动态障碍物真值 (Ground Truth Future)。用于计算 TTC (Time-To-Collision) 和碰撞率。如果在 Cache 的观测中某时刻某位置有车,而你预测的轨迹也到了那里,就是碰撞。 centerline 导航与道路中心。用于计算 Progress (进度指标) 和 Driving Direction Compliance (是否逆行)。 drivable_area_map 静态道路边界。用于计算 Drivable Area Compliance (是否冲出路面/上马路牙子)。 trajectory 规则算法 Baseline。即 PDM-Closed (IDM) 跑出来的结果。通常用来验证评估器本身是否正常,或者作为对比基线。

后训练

生成打分pair

大致看了下sampler里面的内容: Key: trajectory_enc, Value type: <class ’numpy.ndarray’>, Shape: (128, 9, 4) Key: action, Value type: <class ’numpy.ndarray’>, Shape: (128, 8) Key: action_probs, Value type: <class ’numpy.ndarray’>, Shape: (128, 8, 1) Key: scores, Value type: <class ’numpy.ndarray’>, Shape: (128, 6) Key: key_agent_corners, Value type: <class ’numpy.ndarray’>, Shape: (128, 40, 2, 4, 2) Key: key_agent_labels, Value type: <class ’numpy.ndarray’>, Shape: (128, 40, 2) Key: ego_areas, Value type: <class ’numpy.ndarray’>, Shape: (128, 40, 2) Sampling Cache (sampling_cache.pkl) 的内部结构

  • trajectory_enc (128, 9, 4):

    • 128: 采样个数 $K=128$。对于同一个 input context,生成了 128 条轨迹。
    • 9: 轨迹点的数量。根据之前的代码分析,这应该是 4秒 的预测,每 0.5秒 一个点 (包含T=0),所以是 9 个点。
    • 4: 状态维度,通常是 [x, y, heading, velocity]
  • action (128, 8):

    • 长度为 8 的动作序列。这是一个 Vector Quantized (VQ) 模型。
    • 模型输出的不是连续坐标,而是 8 个离散的 Token ID (对应 4秒,0.5s 一步)。
    • 这是 Transformer 进行 Autoregressive 生成的核心目标。
  • action_probs (128, 8, 1):

    • 生成这串 Token 时,模型给出的 Log Probability (置信度)。用于后续 importance sampling 或者作为 Reference LogProb 的基准。
  • scores (128, 6):

    • 重点。这是用 PDMScorer 打出来的分。
    • 128: 每条轨迹都有分。
    • 6: 这是一个 Multi-dimension Score。根据 pdm_score.py 的返回,这 6 个分量是:
      1. no_at_fault_collisions (是否碰撞)
      2. drivable_area_compliance (是否出界)
      3. ego_progress (进度)
      4. time_to_collision (TTC)
      5. comfort (舒适度)
      6. score (最终加权总分) -> DPO 选 Winner/Loser 主要看这个(通常是最后一列)。
  • key_agent_corners & key_agent_labels & ego_areas: 可能是用于debug,post training 用不太到

Post training pipeline

基于 Vector Quantization (VQ, 离散动作空间) 的 DPO 实现,而非高斯回归。

  1. 输入数据准备 输入的一个 Batch 包含了堆叠好的 Winner 和 Loser 数据(Batch Size x 2)。
  • action_logits: 模型输出的 Logits。

    • Shape: (Batch*2, Num_Poses, Action_Bins)。例如 (256, 8, 8192)。
    • 这里 Batch*2 是因为 Winner 和 Loser 被拼在了一起。
    • Num_Poses=8 是时间步, Action_Bins 是 VQ Codebook 大小。
  • targets[“action”]: 真实的离散动作序列 ID。

    • Shape: (Batch*2, 8)。对应 Winner 和 Loser 的 Token ID。
  • targets[“action_probs”]: 采样时记录的、由 Reference Model (旧模型) 生成时的概率。

    • 这也是 DPO 中用于 $$\pi_{ref}$$ (Reference Policy) 的概率。这样就避免了在训练时还要跑一个冻结的模型来算概率,直接查表即可。
  1. 概率 Gather (Select LogProb)

1. 整理形状,把 Batch 和 2 (Pair) 维度拆开

pair_actions = targets[“action”][bs_indices, :].reshape(-1, 2, num_poses) pair_policy_action_prob = torch.gather( action_logits[bs_indices].reshape(-1, 2, num_poses, config.action_bins), 3, pair_actions[…, None].long() ).squeeze(-1) 这段代码的作用是:查表。 模型输出了 8192 个类别的概率,但我只关心 pair_actions (Winner/Loser 实际选的那个 Token) 对应的概率是多少。

结果 pair_policy_action_prob 的形状是 (Batch, 2, Num_Poses),代表 Winner 和 Loser 在每一步的概率。

  1. Log Probability 求和

DPO 要求的是整条轨迹 (Trajectory) 的联合概率。对于自回归模型(比如 GPT),联合概率等于每一步概率的乘积,取 Log 后就是求和。

2. 取 Log 并对时间步求和 (Sum over time steps)

winner_policy_logprob = torch.log(pair_policy_action_prob[:, 0]).sum(-1)[mask] loser_policy_logprob = torch.log(pair_policy_action_prob[:, 1]).sum(-1)[mask]

同样处理 Reference Prob (直接从 target 里读出来的,不用重算)

winner_ref_logprob = torch.log(pair_ref_action_prob[:, 0]).sum(-1)[mask] loser_ref_logprob = torch.log(pair_ref_action_prob[:, 1]).sum(-1)[mask]

  1. 核心公式 (DPO Loss)

标准的 DPO Loss 公式:

$$L_{DPO} = - \log \sigma \left( \beta \left( \log \frac{\pi_\theta(y_w|x)}{\pi_{ref}(y_w|x)} - \log \frac{\pi_\theta(y_l|x)}{\pi_{ref}(y_l|x)} \right) \right)$$

在代码中对应:

dpo_loss = - torch.log( F.sigmoid( beta * ( (winner_policy_logprob - winner_ref_logprob) - # Log Ratio of Winner (loser_policy_logprob - loser_ref_logprob) # Log Ratio of Loser ) ) + 1e-15 # 数值稳定性 eps ).mean()

  1. 辅助指标 (Rewards)

为了监控训练效果,额外计算了 Implicit Reward (隐式奖励): $$r(x, y) = \beta (\log \pi_\theta(y|x) - \log \pi_{ref}(y|x))$$

chosen_rewards = beta * (winner_policy_logprob - winner_ref_logprob).detach() rejected_rewards = beta * (loser_policy_logprob - loser_ref_logprob).detach()

  • chosen_rewards: 模型觉得 Winner 轨迹有多“好”。
  • rejected_rewards: 模型觉得 Loser 轨迹有多“好”。
  • 训练目标是让 chosen 越来越高,rejected 越来越低,margin 越来越大。

教科书式的 Discrete DPO (离散 DPO) 实现。它将连续的轨迹规划问题通过 VQ-VAE 转化为了离散 Token 预测问题,从而可以直接套用 LLM 领域的 DPO 算法。很漂亮的设计。

不同的action解码

除了离散 Token 化(State VQ),主流的端到端自动驾驶(E2E AD)还有另一种也是更传统的范式:直接回归 (Direct / Continuous Regression)。

比如 Waymo 的 MTP (MultiPath)、Uber 的 MultiPath++,或者传统的 TransFuser(不带 VQ 的版本),它们直接输出坐标点。

对于这类模型,要应用 DPO,计算概率 $$\log P(y|x)$$ 的方法如下:

  1. 假设高斯分布 (Gaussian Assumption)

这是最通用的做法。我们假设模型的输出(轨迹点)服从高斯分布(正态分布)。

如果模型输出的是一条确定的轨迹 $$\mu(x)$$(deterministic),我们通常假设它隐含了一个固定的方差 $$\sigma^2 = 1$$(或者超参数 $$\beta$$)。

此时,轨迹 $$y$$ 的对数似然 $$\log P(y|x)$$ 等价于 负的均方误差 (Negative MSE / L2 Distance):

$$\log P(y|x) \propto - \frac{1}{2\sigma^2} | y - \mu_\theta(x) |^2$$

  • $$y$$: Ground Truth 轨迹(DPO 里的 Sampled Winner/Loser)。
  • $$\mu_\theta(x)$$: 当前 Policy Model 预测出的轨迹。

DPO Loss 公式就会变成(Regression DPO):

$$L_{DPO-Reg} = - \log \sigma \left( \beta \left( [ -|y_w - \mu_\theta|^2 + |y_w - \mu_{ref}|^2 ] - [ -|y_l - \mu_\theta|^2 + |y_l - \mu_{ref}|^2 ] \right) \right)$$

直观解释: DPO 会奖励模型去“拉近”它与 $$y_w$$ 的 L2 距离,同时“推远”它与 $$y_l$$ 的 L2 距离。(这其实有点像 Contrastive Loss / Triplet Loss)。

  1. 混合高斯分布 (GMM / MDN)

更高级的模型(比如 Trajectron++, MultiPath)不仅输出一个点,而是输出一个高斯混合分布的参数:$(\pi_k, \mu_k, \Sigma_k)$。即预测多条模态及其概率和协方差。

此时概率密度计算公式为: $$P(y|x) = \sum_{k=1}^{K} \pi_k \cdot \mathcal{N}(y | \mu_k, \Sigma_k)$$

你要计算 Sampled 轨迹 $y$(比如 Winner)在这个混合分布下的概率,直接代入上述公式求 LogSumExp 即可。

伪代码

伪代码:Continuous Regression DPO

def get_log_prob(model_output, target_traj): # 用 L2 距离的负数作为 log prob 的代理 # model_output: [Batch, Time, 2] # target_traj: [Batch, Time, 2] mse = ((model_output - target_traj) ** 2).sum(-1).mean(-1) return -mse # 误差越大,概率越小

winner_log_prob = get_log_prob(policy_model(x), winner_traj) loser_log_prob = get_log_prob(policy_model(x), loser_traj)

… 计算 ref_model 的 …

… 代入 DPO 公式 …

  1. Diffusion model 类

扩散模型(Diffusion Model)解码出的轨迹属于 连续值(Continuous) 类别,但计算概率的方式非常特殊。 它既不是简单的 Softmax(离散),也不仅仅是简单的 MSE(普通回归),而是 去噪误差(Denoising Error)。

  1. 轨迹类型:Continuous(连续空间) 扩散模型生成的直接是具体的 $$(x, y)$$ 坐标数值,而不是 token ID。 所以在物理层面,它和 Regression(回归)模型一样,输出的是连续的轨迹曲线。

  1. 怎么算概率

对于 DPO 来说,我们需要计算 $$\log P(\text{trajectory})$$。 扩散模型并没有一个直接输出“概率值”的函数(像 Softmax 那样)。它的“概率”是通过 逆向去噪过程(Reverse Process) 定义的。

在 Diffusion-DPO (Wallace et al., 2023) 等算法中,我们使用 去噪损失(Denoising Loss) 作为负对数概率(Negative Log-Likelihood, NLL)的那个“替代品”。

核心公式 如果我们把扩散模型看作一个从噪声构建数据的过程,那么一条轨迹 $$x$$ 的“可能性”高低,取决于模型能不能准确地还原它。

$$\log P_\theta(x) \approx - \underbrace{\mathbb{E}{t, \epsilon} \left[ | \epsilon - \epsilon\theta(x_t, t) |^2 \right]}_{\text{去噪重建误差}}$$

  • 含义:如果模型能很好地预测把 $$x$$ 加噪后的噪声 $$\epsilon$$,说明模型认为这条轨迹 $$x$$ 是“符合分布的”(高概率的)。
  • 计算方式:
    1. 取一条生成的轨迹 $$x$$(比如 Winner 轨迹)。
    2. 随机采样时间步 $$t$$ 和噪声 $$\epsilon$$。
    3. 计算模型预测的噪声 $$\hat{\epsilon}$$ 和真实噪声 $$\epsilon$$ 的 L2 距离 (MSE)。
    4. 这个 L2 误差越小,代表该轨迹的 概率 $$P(x)$$ 越高。

  1. Diffusion 的 DPO 怎么做?

基于上面的原理,如果我们要对扩散模型做 DPO,不需要把它变成离散 Token,而是直接比较 Winner 和 Loser 的去噪难易程度。

直观逻辑:

  • Winner 轨迹:我希望它更容易被去噪(误差更小)。
  • Loser 轨迹:我希望它更难被去噪(误差更大,或者不下降)。

Loss 函数形式 (简化版):

$$L_{\text{DPO}} = - \log \sigma \left( \beta \left[ \underbrace{| \text{Error}{\text{Loser}} |^2}{\text{Loser去噪误差}} - \underbrace{| \text{Error}{\text{Winner}} |^2}{\text{Winner去噪误差}} \right] \right)$$

  • 注:这里是用 误差(Error) 来代替 $$-\log P$$。
  • 如果 Winner 的误差 小于 Loser 的误差,Sigmoid 内部就是正数,Loss 就小。
  1. 总结对比

模型类型 本质 优缺点 输出形式 概率计算方式 ($$\log P$$) DPO 优化目标 离散 (Token) 分类问题

  • 天生多模
  • 概率精确
  • 鲁棒性强

  • 精度丢失
  • 维度灾难
  • 要想效果好,需要对空间环境做变化(否则就是闭眼开车) (在智驾领域还好)

Token ID Gather(Softmax(logits)) 增加 Winner Token 的 Logit 回归 (Regression) 估计问题

  • 精度准确
  • 推理速度快

  • 平均诅咒
  • 需人工假设 $$(x,y)$$ 坐标 -MSE(pred, target) (假设高斯分布) 拉近 Winner 坐标距离 扩散 (Diffusion)

生成问题 (学习梯度场)

  • 多模 + 精度
  • 物理一致性

  • 超参敏感
  • 慢 (这些都有办法解决) $$(x,y)$$ 坐标

-MSE(pred_noise, noise) (去噪能力) 让 Winner 更容易被“去噪还原”

在此代码库中的情况: 目前的 dpo_loss.py 还是针对 离散版 (TransFuser/GenMDriver) 设计的。如果想在这个库里做基于 Diffusion 的 DPO,需要重写 Loss 函数,把输入从 action_logits 改成扩散模型的 noise_pred,并计算噪声的 L2 差值。

RL算法的适配

当前navsim中的RL

当前的通过sampling获取traj score pair 的方法,这确实是 Offline RL(离线强化学习) 的一种变体,但在学术定义上,它更贴切的分类是 Offline-to-Online (Iterative) Contextual Bandits。

  1. 为什么说它是 “Bandit” 而不是标准的 “RL”? 标准的 RL(如 PPO, DQN)是 MDP(马尔可夫决策过程):
  • $$t=0$$:观察路况 -> 决定油门 -> 环境变了
  • $$t=1$$:观察新路况 -> 决定转向 -> 环境又变了

而在 diffusiondrive(以及大多数 End-to-End Log Replay 方案)中:

  • $$t=0$$:观察路况 -> 直接生成未来 8 秒的完整轨迹 (Trajectory as an Action)。
  • 交互消失了:模型做完决策后,并没有“根据第 1 秒的位置再决定第 2 秒怎么走”。
  • 环境反馈延迟:只有整条轨迹画完了,Evaluator 才会告诉你“第 5 秒撞了”。

所以,这本质上是一个 Contextual Bandit(上下文多臂老虎机) 问题:

  • 老虎机(Environment):当前的交通场景。
  • 拉杆(Action):你生成的某一条轨迹。
  • 金币(Reward):PDM Score。
  • Offline:你先把杆子拉了几万次(Sampling),记下哪根杆子出金币(Score Pair),然后关起门来根据账本通过 DPO 更新策略。
  1. 与纯offline的不同

  2. Level 1: 场景数据 (Prompt)

  • 来自 NuPlan 真实日志。这是死的,模型无法改变路网和其他车的初始位置。
  1. Level 2: 经验数据 (Experience)
  • 来自 run_sampling.py。这是活的(Self-Generated)。
  • 关键点:这比传统的 Offline RL(只用人类驾驶数据)更强。传统的 Offline RL 只能学习“人类是怎么成功的”;而这种 Self-Generated Offline RL 可以学习**“我自己是怎么失败的”**(比如模型生成了一条看起来很顺但在第3秒擦挂的轨迹,这成了绝佳的 Loser 样本)。
  1. 架构优势 拆分成 Sampling (CPU) 和 Training (GPU) 如果做成 Online RL(一边跑 Simulator 一边 Update),你会遇到巨大的工程瓶颈: 特性 Online RL (如 Gym) 本工程的 Offline Sampling 模式 瓶颈 CPU/IO 阻塞:GPU 经常要等 Simulator 算碰撞、算分,显卡利用率极低。 流水线:CPU 集群疯狂采样存硬盘(Cache),GPU 对着 Cache 狂练,利用率 100%。 数据 Sample 完一次就丢(On-policy)。 Sample 一次可以反复用(Off-policy / Reuse)。 稳定性 容易训崩,因为某些 Batch 采样质量差。 全局视角:可以在 Training 前清洗 Cache,比如通过 PairCacheOnlyDataset 剔除那些 Winner/Loser 此分差距不大的无效样本。

  2. 总结

这种 Generate -> Score -> Train 的循环(也被称为 Expert Iteration 或 Self-Training)是目前解决大模型(LLM/VLM/E2E-AD)对齐问题的标准范式(DeepSeek-R1 也是类似的思路,先生成推理链,验证对错,再强化)。

工程上的启示: 如果你想改进效果,不要只盯着 Loss 函数。在这个框架下,Sampling(模拟考试)的质量决定了天花板。

  • 如果采样出来的 $$K$$ 条轨迹全是垃圾,DPO 只有垃圾里的“高个子”作为 Winner,模型学不到好东西。
  • 这也是为什么在这个 repo 里,Sampling 阶段通常会混入一些单纯的 Human Trajectory 或者 Rule-based Trajectory 作为 “Gold Winner”,防止模型在“比烂”中迷失。

如何打破天花板

在自动驾驶领域,直接上 Online RL 往往是为了解决 10% 的问题,引入了 90% 的工程灾难。

要打破 Sampling 的天花板(即:生成的最好的轨迹也不够好),核心在于探索(Exploration)的能力,而不在于是不是 Online。

核心矛盾 目前的瓶颈公式: $$\text{Training Ceiling} = \max(\text{Samples}) $$ 如果你的模型很烂,Sample 出来的 16 条轨迹全是垃圾,DPO 只是在“垃圾分类”,模型确实学不到东西。

打破这个天花板的三个方案:


方案一:Iterative Self-Improvement (类 AlphaGo Zero / DeepSeek-R1 模式) 这是最工程化、最稳健的打破天花板方式。不需要改代码架构,只需要改流程。

原来的流程(One-shot):

  1. 初始模型 $$\pi_0$$ -> Sample -> 数据集 $$D_0$$
  2. 用 $$D_0$$ 训练 -> 得到模型 $$\pi_1$$ -> 结束

改进流程(Iterative):

  1. 用 $$\pi_1$$ 再去 Sample(这时候它变强了,能探索到 $$\pi_0$$ 这种“菜鸟”去不了的优质状态空间) -> 得到数据集$$D_1$$
  2. 用 $$D_1$$ 训练 -> 得到 $$\pi_2$$ 。
  3. 循环 $$N$$ 次。

每一轮迭代,Sampling 的分布都在向更优区域移动。第一轮可能最好的是“不撞车但很慢”,第二轮可能就能探索出“既快又不撞车”的轨迹。这本质上就是 Off-policy RL 的多轮迭代,但保持了 Engineering Friendly(GPU 满载训练,不用等 Simulator)。


方案二:Test-Time Compute / Search (用算力换智能) 另外一种思考:模型 Sample 不出好轨迹,可能是在 Sampling 时太“随意”了。 现在的 Sampling 仅仅是加了点随机噪声。我们可以引入搜索算法:

  1. Guided Sampling (扩散模型特权):
  • 在扩散生成的反向去噪过程中,加入一个轻量级的 Cost Function 引导(比如简单的距离场 Cost)。
  • 让生成的轨迹生来就避障,强行拔高 Sampling 的下限。
  1. Tree Search (MCTS):
  • 如果算力允许,不要只 Sample 16 条。Sample 1000 条,用一个快速的 Value Model 预筛选出 10 条,再交给昂贵的 PDM Score 去算分。
  • 这等于在造数据阶段就进行了“推理/思考”,把这个思考的结果提炼给 Model 学习。

方案三:Expert Injection (引入外援) 打破天花板最快的方法是——抄作业。

目前的 Winner 只能从模型自己生成的 $$K$$ 条里选。 Hack 方法:在 Sampling 阶段,强制混入我们已知的强力规则算法(比如基于优化的 Planner,或者 lattice planner)生成的轨迹。

  • 规则算法的那条轨迹就会成为 Pair 中的 Winner。
  • DPO 训练时,模型就会被迫学习:“哦,原来这种情况应该像规则算法那样走”。
  • 久而久之,模型汲取了规则算法的精华,并且由于神经网络的拟合能力,还能泛化到规则算法处理不了的场景。

Online RL可能存在的问题

在 NuPlan/NavSim 这种环境下,Online RL 有两个致命坑:

  1. 虚假的交互 (The Simulator Flaw)
  • NuPlan 大部分是 Log Replay。也就是说,你左转撞向另一辆车,那辆车不会躲(它是录像回放,它只会按照历史轨迹把你撞死)。
  • Online RL 会非常敏锐地发现这一点,并学会一种过度保守的策略(只要有车动我就不动,因为它是鬼车),或者过度激进(趁录像里的车还没动,赶紧钻过去)。这学出来的策略在真车上没法得用。
  1. 吞吐量
  • Online RL 要求:Generator 走一步 -> Simulator 算一步 -> Update 一次。
  • Simulator 跑在 CPU 上,由于要算碰撞,通常只有 10-20 Hz。GPU 会为了等这一个 Step 闲置 90% 的时间。