传感器管线#

本页解释 scene.step() 与传感器 read() 返回的值之间发生了什么:传感器管理器如何每步更新所有传感器,不同类型的传感器如何共享开销大的计算,以及缓冲如何让 read() 成为常数时间的内存查询,而不是一次全新的采集。它是 自定义传感器 的运行时对照篇,后者介绍添加传感器时需要重写哪些 hook。至于面向使用者的一面(挂载传感器、推进仿真、读取张量),请从 传感器总览 开始。

读取是查询,不是采集#

真实机器人不会在每次控制循环迭代时从模拟导线上拉取数值。嵌入式固件按自己的频率异步采样硬件,处理信号,并把数字快照写入共享内存。应用程序的 read() 返回当前存在的那份快照,它不会触发采集。

Genesis World 正是这样建模的。管理器在每次 scene.step() 时计算一次每个传感器的值并保存下来;read() 和 read_ground_truth() 都是对这份存储的视图。由此有两个推论,管线其余部分的存在就是为了保持它们:

  • 一个步内的读取是幂等的。 同一时间步内两次调用 read() 返回相同的值,因为两次调用之间没有任何重算。控制器、日志记录器和可视化器都可以读取同一个传感器,既不增加开销,也不会产生分歧。

  • 误差在捕获时固化。 噪声、偏置和漂移在快照写入时就被烘焙进去,而不是在读取时采样。因此带延迟的读取返回的是传感器过去产生的那份确切的带噪值,而不是新采的样本。

管理器拥有什么#

SensorManager(genesis/engine/sensors/sensor_manager.py)拥有全部传感器存储,并驱动逐步更新。传感器本身不持有逐步缓冲区;它们贡献的是管理器存储中的一段切片,以及一组计算 hook。

管理器这样组织存储:同一类的所有传感器在一次批处理中完成更新。它按类对传感器分组,类内每个实例占据该类缓存中一段连续的切片:

# SensorManager.build: sort each class by entity so instances on the same
# entity occupy a contiguous slice, then assign each sensor its cache offset.
for sensors in self._sensors_by_type.values():
    sensors.sort(key=lambda s: s._options.entity_idx)

因为切片连续,一个 kernel 就能处理整个类。这也是 dtype 按类统一的原因:同一类的每个实例共享同一种 dtype,按类的切片才能保持为一个缓冲区中的单一连续区间。相比之下,shape 是按实例的,所以光线投射器的 pattern 或温度网格的分辨率这类选项可以改变返回形状,而不破坏批处理。

概念上,管理器维护四类存储:

  • 每类返回缓存(per-class return cache): read() 和 read_ground_truth() 所查看的缓冲区,位于传感器声明的返回空间。形状为 ([n_envs,] class_cache_size)。

  • 每 dtype 中间缓存: 计算 hook 读写所用的逐步工作缓冲区,此时尚未转换到返回空间。

  • 每 dtype 时间线环: 成对的真值与测量值环形缓冲区,保存噪声前、变换后的值,使有状态滤波器无需自己保存状态就能读取之前的步。

  • 每类返回空间环: 成对的环形缓冲区,保存过去每一步的最终快照。延迟采样和历史读取都取自这里。

环形缓冲区和额外缓冲区都要占用内存,所以管理器只在传感器需要时才分配。当传感器不施加延迟、不保留历史、也不做返回空间转换时,它的返回缓存就是中间缓存的零拷贝别名,完全不会分配环:

# SensorManager.build
needs_ring = cls_delay_depth > 1 or cls_max_history > 0 or pp_overridden
if needs_ring:
    ...  # allocate paired GT + measured return-space rings, distinct return cache
else:
    # Alias the return cache onto the intermediate slice: the per-step write is
    # already what read() should see.
    self._return_cache[sensor_cls] = self._intermediate_cache[intermediate_dtype][:, cls_slice]

逐步管线#

SensorManager.step 按顺序执行三个阶段:

# SensorManager.step
for ring in self._measured_timeline_ring.values():
    ring.rotate()                       # free a fresh write slot before compute

for context in self._shared_contexts.values():
    context.update()                    # refresh each shared resource at most once

for sensor_cls, sensors in self._sensors_by_type.items():
    sensor_cls._update_shared_cache(...)  # one batched compute pass per class
    ...                                   # then post-process, ring write, delay sample

先旋转环。 时间线环必须在计算之前推进,因为计算 hook 会写入槽位 0,而有状态滤波器要读取之前的槽位。返回空间环稍后在按类的循环内旋转,这样在投影这一步,槽位 0 仍然保存着上一步的最终输出(一个有意义的“上一个值”),而不是过期数据。

只刷新一次共享上下文。 管理器每步至多重建一次每个共享资源,且在任何传感器读取它之前完成,因此多个消费同一上下文的传感器类型只需付出一次代价。见下一节。

逐个类更新,然后收尾。 对每个类,管理器调用一次 _update_shared_cache,产生真值信号和测量工作缓冲区;然后通过 _post_process 把两个分支都投影到返回空间,把最终快照写入返回空间环,再从测量环延迟采样到返回缓存:

# SensorManager.step, per class
gt_projected = sensor_cls._post_process(metadata, ground_truth_slice.T, gt_return_ring, is_measured=False)
measured_projected = sensor_cls._post_process(metadata, intermediate, measured_return_ring, is_measured=True)

gt_return_ring.rotate()               # rotate after _post_process reads, before writing
measured_return_ring.set(measured_projected)
gt_return_ring.set(gt_projected)

# Ground truth has no readout delay; the measured branch samples a stale slot.
self._ground_truth_return_cache[sensor_cls].copy_(gt_return_ring.at(0, copy=False))
sensor_cls._apply_delay(metadata, measured_return_ring, self._return_cache[sensor_cls])

投影是立即执行的,每个分支每步一次,而不是在读取时惰性执行。立即投影让管理器拥有一个所有读取者共享的、真正的每类缓冲区,使投影次数与读取该传感器的消费者数量无关,并让有状态的投影(例如带宽滤波器)每步恰好执行一次。

上面提到的 hook(_update_shared_cache、_post_process、_apply_delay)属于传感器类,而不是管理器。SimpleSensor 把 _update_shared_cache 实现为一系列更细的 hook 的固定序列(原始信号、物理误差、变换、硬件误差),具体传感器按需重写。自定义传感器 记录了这些 hook 的触发顺序以及它们所触及的缓冲区。

共享上下文:跨传感器类型共享计算#

有些传感器需要一个在传感器类型之间完全相同的昂贵资源。光线投射器和基于光线投射的深度相机都要对同一份碰撞几何做投射;为每个传感器重建一次该加速结构会很浪费。共享上下文(shared context) 就是这个资源,构建一次、反复复用。

SharedSensorContext(genesis/engine/sensors/base_sensor.py)是基类。传感器类型把它消费的上下文声明为类型参数,所有声明同一上下文类的类型都会解析到管理器拥有的那一个实例:

# The raycast BVH set is shared by the raycaster and the depth camera.
class RaycasterSensor(SimpleSensor[RaycasterOptions, RaycastContext, ...]):
    ...

class DepthCameraSensor(RaycasterSensor, Sensor[DepthCameraOptions, RaycastContext, ...]):
    ...

这与传感器的元数据不同:元数据聚合的是某一种传感器类型的逐实例状态,使单个 kernel 能跑遍它的所有实例。元数据是一种批处理优化,随传感器数量增长;上下文是一种共享优化,在传感器数量上保持 O(1),因为它是由多个类型读取的单一资源。

上下文纯粹是一种优化,因此绝不能改变结果。一致性由管理器通过严格的生命周期来保证:

  • activate: 消费传感器在自己的 build 中调用它,此时场景几何已就绪。首次调用会构造资源;之后的调用是幂等的。没有传感器激活的上下文保持为空壳,不产生任何开销。

  • update: 管理器每步调用一次,在按类循环之前,这样每个消费者看到的是同一份刷新后的资源。未激活时它不做任何事。

  • reset / destroy: 管理器在 scene.reset() 和销毁时驱动它们。

查询未激活的上下文会抛错,而不是悄悄返回过期数据,因此传感器无法读取一个没有任何类型声明需要的资源。

真值与测量值#

每个传感器在管线中都有两条并行分支,并各自通过自己的读取方法暴露:

# Sensor.read / Sensor.read_ground_truth
def read(self, envs_idx=None) -> DataT:
    return self._get_formatted_data(self._manager.get_cloned_from_cache(self), envs_idx)

def read_ground_truth(self, envs_idx=None) -> DataT:
    return self._get_formatted_data(self._manager.get_cloned_from_cache(self, is_ground_truth=True), envs_idx)
  • read(): 测量值,已施加传感器误差并完成读出延迟采样。用于 sim-to-real 迁移训练的控制器应当消费它。

  • read_ground_truth(): 同一步的无噪声、无延迟值,形状完全相同。用于奖励计算、日志记录和调试。

两者都是对管理器在 step() 期间已经填充好的每类缓存的纯视图,都不会重新计算。真值分支保留原始仿真现象,从不经历读出延迟,因此管理器直接从当前环槽位填充它的缓存,而对测量分支做延迟采样。

历史与缓冲#

默认情况下,传感器只存储当前快照。设置 history_length=N,管理器就会保留最近 N 个最终快照,让 read() 可以把它们沿一个新轴堆叠返回:

contact = scene.add_sensor(
    gs.sensors.Contact(
        entity_idx=robot.idx,
        link_idx_local=robot.get_link("FL_foot").idx_local,
        history_length=4,  # keep the last 4 snapshots; index 0 is the current step
    )
)
# ... after scene.build() and stepping ...
window = contact.read()  # shape ([n_envs,] 4, 1)

历史读取始终来自每类返回空间环,而绝不是中间环,因为返回空间环保存着每一步经过全部处理后的最终快照。中间环处于噪声前空间,会给出错误的历史:

# SensorManager._gather_history
hist_idx = self._hist_idx_by_class[sensor_cls][:history_length]
ring = self._measured_return_timeline_ring[sensor_cls]  # or the GT ring
return ring.at(hist_idx).transpose(0, 1)

延迟和历史共用这个环。延迟读取单个过期槽位;历史读取最近槽位的一段连续窗口。环的大小按两者中更深的需求来定。

一次读取整个类#

对于批量消费者(记录每个传感器的日志器、训练用的观测向量),逐个读取传感器很浪费。read_sensors 在一次调用中按传感器类各返回一个张量,场景和实体上都暴露了它:

data = scene.read_sensors()        # every sensor in the scene, grouped by class
data = robot.read_sensors()        # only the sensors attached to this entity

结果把每个传感器类型标签(gs.sensors.types.<Name>)映射到形状为 ([n_envs,] [history,] class_cache_size) 的张量;只有配置了历史的传感器才有 history 轴。与返回视图的逐传感器 read() 不同,read_sensors 总是通过对环境轴做花式索引来分配新张量,因此调用者可以随意修改结果,而不会破坏内部存储。底层的 SensorManager.read_sensors 接受 entity_idx 过滤器和 is_ground_truth 标志,公共封装则让它们保持默认值。

塑造管线的选项#

两个选项类为管线提供输入。SensorOptions 承载每个传感器都暴露的时序参数;SimpleSensorOptions 增加 SimpleSensor 分支在读出阶段解释的误差参数。每个参数的含义,就是它在上面的管线中所起的作用:

选项

默认值

作用

delay

0.0

对测量返回空间环的读取偏移,单位为秒。延迟读取返回多少秒之前产生的快照,误差在那一刻固化。

jitter

0.0

每个环境的随机附加延迟,每步在 [0, jitter) 内均匀采样。不得超过 delay,且上限为一个 dt。

history_length

0

当 > 0 时,read() 返回最近 N 个最终快照,沿新轴堆叠;索引 0 是当前步。

noise

0.0

零均值高斯噪声的标准差,每步采样一次并固化到快照中。

bias

0.0

读出阶段添加的常量偏移。

random_walk

0.0

随机游走步长的标准差。漂移每步累积并固化到快照中,因此延迟读取看到的是快照捕获时传感器所具有的漂移。

resolution

0.0

量化步长。输出值会四舍五入到它的倍数。

noise、bias、random_walk 和 resolution 是通用误差参数。SimpleSensor 在读出阶段把它们作用在逐步工作缓冲区上,从不作用于时间线环,因此有状态滤波器的递推保持干净、不受读出噪声污染。直接继承 Sensor 的传感器可以以不同方式解释它们,或者忽略它们,相机传感器就是如此。

绕过管线的传感器#

并非每个传感器都使用环形管线。相机传感器直接继承 Sensor,惰性渲染,并设置 uses_ring_pipeline = False。由于延迟、抖动和历史都依赖每类返回空间环,选择退出的传感器无法满足这些选项,基类会在构造时直接拒绝它们,而不是悄悄忽略:

# Sensor.__init__
if not self.uses_ring_pipeline:
    if options.delay > 0.0:
        gs.raise_exception(f"{type(self).__name__} does not support `delay`; ...")

标准传感器(接触、接触力、IMU、关节力矩、测距、表面距离、温度以及触觉系列)都继承自 SimpleSensor,使用这里描述的完整管线。

另见#

  • 自定义传感器:添加自己的传感器类型时要重写哪些 hook,以及它们的 shape 和 dtype 契约。

  • 传感器总览:面向使用者的挂载与读取流程,以及内置传感器目录。