diff --git a/docs/source/_static/images/tutorial/terrain/composite.jpg b/docs/source/_static/images/tutorial/terrain/composite.jpg new file mode 100644 index 00000000..1ecf83bc --- /dev/null +++ b/docs/source/_static/images/tutorial/terrain/composite.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:41a2c2385c931de40273e2eb06c3ee573a3cb831c9fa098ef9ad74b9a8d79e3c +size 68687 diff --git a/docs/source/_static/images/tutorial/terrain/grid.jpg b/docs/source/_static/images/tutorial/terrain/grid.jpg new file mode 100644 index 00000000..27cfc9f4 --- /dev/null +++ b/docs/source/_static/images/tutorial/terrain/grid.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7845d3511bac9289f1bb04860df6f653259cfeee11be6e460a0a488d2fa66ca5 +size 68051 diff --git a/docs/source/_static/images/tutorial/terrain/noise_rough.jpg b/docs/source/_static/images/tutorial/terrain/noise_rough.jpg new file mode 100644 index 00000000..6415ca2d --- /dev/null +++ b/docs/source/_static/images/tutorial/terrain/noise_rough.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a71fc822ad4445755efb8751bfa856337c15cb98876d229f93ff4c85c822148 +size 89118 diff --git a/docs/source/_static/images/tutorial/terrain/noise_smooth.jpg b/docs/source/_static/images/tutorial/terrain/noise_smooth.jpg new file mode 100644 index 00000000..27fecafa --- /dev/null +++ b/docs/source/_static/images/tutorial/terrain/noise_smooth.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:610631620bbb7d6a30a9121d810626ef93f0fa2a39d0d252579b4ed63528819c +size 51477 diff --git a/docs/source/_static/images/tutorial/terrain/noise_terraced.jpg b/docs/source/_static/images/tutorial/terrain/noise_terraced.jpg new file mode 100644 index 00000000..9e9884fb --- /dev/null +++ b/docs/source/_static/images/tutorial/terrain/noise_terraced.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c8402f61aaf5ca4218458a7f7221dd9173f8918a62c58b299599087f207d622 +size 79345 diff --git a/docs/source/_static/images/tutorial/terrain/obstacles_choice.jpg b/docs/source/_static/images/tutorial/terrain/obstacles_choice.jpg new file mode 100644 index 00000000..498b4014 --- /dev/null +++ b/docs/source/_static/images/tutorial/terrain/obstacles_choice.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:826be47fad7fe7e61052ac366811ffddbdbd73800170f1bcef9bf6c46df6e491 +size 52781 diff --git a/docs/source/_static/images/tutorial/terrain/slope_compare.jpg b/docs/source/_static/images/tutorial/terrain/slope_compare.jpg new file mode 100644 index 00000000..d188ac96 --- /dev/null +++ b/docs/source/_static/images/tutorial/terrain/slope_compare.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6b8d9fe3bdc4a1564e5ed3177082df80e9e5eda8f5217ef724acddf46edd9203 +size 64148 diff --git a/docs/source/_static/images/tutorial/terrain/stairs_ascending.jpg b/docs/source/_static/images/tutorial/terrain/stairs_ascending.jpg new file mode 100644 index 00000000..34aeb37b --- /dev/null +++ b/docs/source/_static/images/tutorial/terrain/stairs_ascending.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c07f413a6b41ef6a15fdf4d1ae3470abc82ab20e0d0a8b9c434ef6db25f8d6a1 +size 57219 diff --git a/docs/source/_static/images/tutorial/terrain/stairs_inverted_pyramid.jpg b/docs/source/_static/images/tutorial/terrain/stairs_inverted_pyramid.jpg new file mode 100644 index 00000000..da9385eb --- /dev/null +++ b/docs/source/_static/images/tutorial/terrain/stairs_inverted_pyramid.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8914a3a11dff7f4ffd54bc6648bf57455d0b3e90e410540f354fa81ea201d275 +size 54089 diff --git a/docs/source/_static/images/tutorial/terrain/stairs_pyramid.jpg b/docs/source/_static/images/tutorial/terrain/stairs_pyramid.jpg new file mode 100644 index 00000000..43d788f9 --- /dev/null +++ b/docs/source/_static/images/tutorial/terrain/stairs_pyramid.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ca14ed17e22a41c7780fe15a012061abe99c10c765de15af6acbf6ffa435a7c +size 51975 diff --git a/docs/source/en/user_guide/tutorial/building_envs/index.md b/docs/source/en/user_guide/tutorial/building_envs/index.md index 2aa49cd0..cb38035c 100644 --- a/docs/source/en/user_guide/tutorial/building_envs/index.md +++ b/docs/source/en/user_guide/tutorial/building_envs/index.md @@ -87,7 +87,9 @@ section of [Writing ManagerEnv Environments](manager_env.md). when you need declarative composition; 3. [SceneCfg: Setting Up the Physics Scene](scene.md): model files and simulation parameters in detail; -4. [SimBackend: Decoupling from the Simulator](sim_backend.md): the environment–simulator +4. [Procedural Terrain Generation](terrain.md): height-field terrain from declarative + generators; +5. [SimBackend: Decoupling from the Simulator](sim_backend.md): the environment–simulator boundary, backend selection and integration. ```{toctree} @@ -96,5 +98,6 @@ section of [Writing ManagerEnv Environments](manager_env.md). direct_env manager_env scene +terrain sim_backend ``` diff --git a/docs/source/en/user_guide/tutorial/building_envs/terrain.md b/docs/source/en/user_guide/tutorial/building_envs/terrain.md new file mode 100644 index 00000000..4814669d --- /dev/null +++ b/docs/source/en/user_guide/tutorial/building_envs/terrain.md @@ -0,0 +1,295 @@ +# Procedural Terrain Generation + +MotrixLab can declare procedural height-field terrain directly in the scene config: no +external heightmap files are needed — a **terrain generator** deterministically produces the +height data while the model is built. Rough ground, stairs, pyramid slopes, and discrete +obstacles each take a few lines of config, and a `seed` reproduces the exact terrain. + +## Minimal example + +Declare a procedural height-field asset (`ProceduralHFieldAssetCfg`) under +`SceneCfg.assets`, then mount it as the ground with `HFieldTerrainCfg` under `SceneCfg.objs`: + +```python +from motrix_env_core.base import EnvCfg +from motrix_env_core.config import configclass +from motrix_env_core.config.scene import ( + HFieldTerrainCfg, + MaterialCfg, + NoiseTerrainGeneratorCfg, + ProceduralHFieldAssetCfg, + SceneAssetsCfg, + SceneCfg, + SceneObjsCfg, +) + + +@configclass +class TerrainAssetsCfg(SceneAssetsCfg): + mat_ground: MaterialCfg = MaterialCfg() + terrain: ProceduralHFieldAssetCfg = ProceduralHFieldAssetCfg( + generator=NoiseTerrainGeneratorCfg(seed=0, height_scale=0.1), + size=(64.0, 64.0), # full world-space X/Y width in meters + shape=(320, 320), # height-field resolution (rows, columns) + ) + + +@configclass +class TerrainObjsCfg(SceneObjsCfg): + floor: HFieldTerrainCfg = HFieldTerrainCfg(hfield="terrain", material="mat_ground") + + +@configclass +class MyTaskEnvCfg(EnvCfg): + scene: SceneCfg = SceneCfg(assets=TerrainAssetsCfg(), objs=TerrainObjsCfg()) +``` + +Among the built-in environments, the rough-terrain tasks `go1-walk-rough`, `go2-walk-rough`, +and `anymalc-walk-rough` all generate terrain through the same +`NoiseTerrainGeneratorCfg(seed=0, height_scale=0.1, flip_y=True)`; preview it directly: + +```bash +python scripts/view.py env=go1-walk-rough +``` + +## The generator contract + +Every generator derives from `TerrainGeneratorCfg` and follows one contract: + +- `generate(size, shape)` returns heights **normalized to [0, 1]**; the physical height is + the normalized value times `height_scale` (meters). With `height_scale=0.1`, the whole + terrain spans at most 0.1 m of elevation. +- The array is MuJoCo row-major: the first dimension (rows, `shape[0]`) maps to the hfield + x axis and the second dimension (columns, `shape[1]`) to the y axis. +- `seed` drives all randomness; the same seed always yields the same terrain. +- Terrain is generated **once at model build time** and does not change during training; the + compiler validates the returned array's shape and that values are finite. +- Each generator's `validate()` rejects configurations that exceed `height_scale` (for + example a total stair climb taller than `height_scale`); the error message reports the + exact bound. + +## Built-in generators + +Each generator is a `@configclass` whose fields are the complete set of knobs. The +screenshots below are rendered from the showcase configs in `examples/terrain_generate.py`. + +### FlatTerrainGeneratorCfg — flat + +A constant normalized height `height` (0.0 by default); typically the base of a composite +terrain or a reset pad. + +### NoiseTerrainGeneratorCfg — noise + +Per-cell heights uniformly sampled from `[0, 1)`. When `downsampled_scale` (meters) is set, +noise is generated on a coarse grid sampled at that pitch and bilinearly interpolated to the +output resolution, producing smooth rolling hills; without it you get per-cell speckle. + +```{figure} /_static/images/tutorial/terrain/noise_rough.jpg +:alt: Screenshot of per-cell noise terrain with grainy bumps + +Per-cell noise (`height_scale=0.08`): grainy bumps. +``` + +```{figure} /_static/images/tutorial/terrain/noise_smooth.jpg +:alt: Screenshot of downsampled noise terrain with smooth rolling hills + +The same seed with `downsampled_scale=0.4`: bilinear interpolation turns it into smooth hills. +``` + +### QuantizedTerrainGeneratorCfg — terraces + +Quantizes the normalized output of the `source` generator into `levels` discrete height +levels (including both 0 and 1), forming terraces. The wrapped generator's own +`height_scale` is ignored; the physical scale comes from this config's `height_scale`. + +```{figure} /_static/images/tutorial/terrain/noise_terraced.jpg +:alt: Screenshot of quantized noise terrain with stepped terraces + +Noise quantized into `levels=5` terraces. +``` + +### StairsTerrainGeneratorCfg — stairs + +Regular stairs along one axis or radially from the center; the main fields: + +| Field | Meaning | +| ---------------- | --------------------------------------------------------------------------------------------- | +| `axis` | `"x"` / `"y"` linear along the axis; `"radial"` concentric square rings around the field center | +| `profile` | stair arrangement: `ascending`, `descending`, `pyramid`, `inverted_pyramid` | +| `step_count` | number of steps; when `step_width` is unset the steps evenly span the full axis | +| `step_height` | physical height of one step (m) | +| `step_width` | optional fixed tread width (m); when set, `step_count` acts as the maximum level cap | +| `platform_width` | central platform width (m) held at the profile's natural center level; 0 disables it | +| `base_level` | normalized base lift, giving pits headroom below the rim (hfield heights cannot be negative) | + +```{figure} /_static/images/tutorial/terrain/stairs_ascending.jpg +:alt: Screenshot of linear stairs rising along the x axis + +`axis="x", profile="ascending"`: eight steps rising from low to high along x. +``` + +```{figure} /_static/images/tutorial/terrain/stairs_pyramid.jpg +:alt: Screenshot of radial descending stairs from a central platform + +`axis="radial", profile="descending"`: a central platform descending outward step by step. +``` + +```{figure} /_static/images/tutorial/terrain/stairs_inverted_pyramid.jpg +:alt: Screenshot of radial inverted-pyramid stairs around a central pit + +`axis="radial", profile="inverted_pyramid"`: a central pit with rings rising outward. +``` + +### DiscreteObstaclesTerrainGeneratorCfg — discrete obstacles + +Randomly scatters rectangular bumps or pits over a flat base (normalized height 0.5). With +`height_mode="fixed"` every obstacle uses `height` (positive or negative); with +`height_mode="choice"` each obstacle draws from {+height, +height/2, −height/2, −height}, +mixing bumps and pits. Obstacle extents are sampled per axis uniformly in +`size_min`–`size_max` as fractions of the field extent. Validation requires +`|height| <= 0.5 * height_scale`. + +```{figure} /_static/images/tutorial/terrain/obstacles_choice.jpg +:alt: Screenshot of discrete-obstacles terrain with scattered bumps and pits + +`height_mode="choice"`: a mix of randomly scattered bumps and pits. +``` + +### PyramidSlopeTerrainGeneratorCfg — pyramidal slope + +Height rises linearly (`slope` is the rise per meter) with the Chebyshev distance to the +field boundary, peaking at the center of a square field; `inverted=True` flips it into a +central pit. The peak depends on the field size, known only at generation time — if it +exceeds `height_scale`, generation raises; increase `height_scale` or lower `slope`. + +```{figure} /_static/images/tutorial/terrain/slope_compare.jpg +:alt: Pyramidal slope comparison: central mound on the left, inverted central pit on the right + +Left: a central mound at `slope=0.1` with `platform_width=0.8`; right: `inverted=True` +flips it into a central pit. With gentle slopes (0.1 m of rise per meter) the relief is +hard to see from a top-down view — inspect slopes with a low-elevation camera. +``` + +## Composing terrains + +`CompositeTerrainGeneratorCfg` stitches several generators into one field: `base` fills the +whole field first, then each `TerrainRegionCfg` in `regions` pastes a rectangular patch on +top. Key points: + +- `center` and `size` are **fractions of the full field** (0–1), not meters. +- Regions are pasted in order; when they overlap, later regions overwrite earlier ones. +- Sub-generators are rescaled by `sub height_scale / composite height_scale` before pasting, + so **physical heights are conserved** — a stair's `step_height` or a slope's `slope` keeps + its physical size after composition. +- The composite `height_scale` must cover every sub-generator's physical peak; generation + raises otherwise. +- `blend` (0–0.5) is the transition margin at region edges as a fraction of the region + extent; edges flush with the field boundary do not blend. + +```{figure} /_static/images/tutorial/terrain/composite.jpg +:alt: Screenshot of composite terrain: terraced noise base with stairs, a pit, obstacles, and a slope + +Composite terrain: radial stairs, an inverted-pyramid pit, discrete obstacles, and a +pyramidal slope pasted over a terraced noise base. +``` + +`grid_terrain()` is a grid shortcut over composition — it lays generators out on a +rows × cols difficulty grid where every cell keeps its own generator, and `height_scale` +defaults to the largest sub-generator scale: + +```python +from motrix_env_core.config.scene import ( + FlatTerrainGeneratorCfg, + StairsTerrainGeneratorCfg, + grid_terrain, +) + +terrain = grid_terrain( + [ + [FlatTerrainGeneratorCfg(), StairsTerrainGeneratorCfg(step_count=3, step_height=0.05)], + [StairsTerrainGeneratorCfg(step_count=6, step_height=0.08), FlatTerrainGeneratorCfg()], + ], + blend=0.1, # transition margin on every cell boundary, as a fraction of the cell extent +) +``` + +```{figure} /_static/images/tutorial/terrain/grid.jpg +:alt: Screenshot of grid terrain: 2x3 stair cells arranged by difficulty + +`grid_terrain` arranges stairs of varying `step_count` and `step_height` into a difficulty grid. +``` + +## Using terrain in an environment + +The established pattern in the built-in quadruped and humanoid tasks keeps the environment +logic identical to the flat task and swaps only the scene: the flat config uses +`FlatTerrainCfg` as the floor, and the rough variant inherits it, overriding only `scene` — +`assets` becomes the terrain-asset group and `objs.floor` becomes +`HFieldTerrainCfg(hfield="terrain")`: + +```python +@registry.envcfg("my-robot-walk-rough") +@configclass +class MyRobotRoughEnvCfg(MyRobotFlatEnvCfg): + scene: MySceneCfg = MySceneCfg( + assets=TerrainAssetsCfg(), + objs=StandardSceneObjsCfg(floor=HFieldTerrainCfg(hfield="terrain", material="mat_ground")), + ) +``` + +On terrain, resets and rewards are usually measured relative to the **local ground height**. +At runtime, sample the ground height under arbitrary (x, y) points through the +backend-neutral SimBackend interface: + +```python +ground_height = env.sim.sample_terrain_height( + env.cfg.ground_geom_name, env_ids, base_pos[:, None, :2] +)[:, 0] +``` + +At reset, `go1-walk-rough` samples the terrain near the spawn point and lifts the base above +the highest sample so the robot never spawns inside the ground; its body-height reward also +stays relative to the local terrain. + +## Writing a custom generator + +Derive from `TerrainGeneratorCfg`, implement `generate(size, shape)` returning normalized +`[0, 1]` heights, and the generator plugs into every composition mechanism: + +```python +from motrix_env_core.config import configclass +from motrix_env_core.config.scene import TerrainGeneratorCfg + +import numpy as np + + +@configclass +class SinusoidalTerrainGeneratorCfg(TerrainGeneratorCfg): + """Sine waves along the x axis.""" + + waves: int = 4 + + def validate(self) -> None: + super().validate() + if self.waves < 1: + raise ValueError(f"waves must be at least 1, got {self.waves}") + + def generate(self, size: tuple[float, float], shape: tuple[int, int]) -> np.ndarray: + phase = np.linspace(0.0, self.waves * 2.0 * np.pi, shape[0])[:, None] + heights = 0.5 + 0.5 * np.sin(phase) # normalized to [0, 1] + return np.broadcast_to(heights, shape).astype(np.float32) +``` + +## Previewing and debugging + +`examples/terrain_generate.py` builds a showcase terrain for every built-in generator plus +composed layouts, prints height statistics, and saves normalized height maps as grayscale +PNGs; `--render` opens the composite terrain in the MotrixSim viewer: + +```bash +python examples/terrain_generate.py # every generator, output to terrain_previews/ +python examples/terrain_generate.py --resolution 256 # height-field resolution +python examples/terrain_generate.py --render # open the viewer for the composite terrain +``` + +The screenshots on this page were rendered offscreen from that script's showcase configs. diff --git a/docs/source/zh_CN/user_guide/tutorial/building_envs/index.md b/docs/source/zh_CN/user_guide/tutorial/building_envs/index.md index 076924c3..37690384 100644 --- a/docs/source/zh_CN/user_guide/tutorial/building_envs/index.md +++ b/docs/source/zh_CN/user_guide/tutorial/building_envs/index.md @@ -73,7 +73,8 @@ MotrixLab 提供两种环境工作流,区别在于任务逻辑写在哪里: 1. [编写 DirectEnv 环境](direct_env.md):从最小示例理解环境骨架; 2. [编写 ManagerEnv 环境](manager_env.md):需要声明式组合时切换到 Manager 工作流; 3. [SceneCfg:搭建物理场景](scene.md):场景文件与仿真参数的细节; -4. [SimBackend:与仿真器解耦](sim_backend.md):环境与仿真器的边界、backend 选择与接入。 +4. [程序化地形生成](terrain.md):用声明式生成器搭建高度场地形; +5. [SimBackend:与仿真器解耦](sim_backend.md):环境与仿真器的边界、backend 选择与接入。 ```{toctree} :hidden: @@ -81,5 +82,6 @@ MotrixLab 提供两种环境工作流,区别在于任务逻辑写在哪里: direct_env manager_env scene +terrain sim_backend ``` diff --git a/docs/source/zh_CN/user_guide/tutorial/building_envs/terrain.md b/docs/source/zh_CN/user_guide/tutorial/building_envs/terrain.md new file mode 100644 index 00000000..ffff7551 --- /dev/null +++ b/docs/source/zh_CN/user_guide/tutorial/building_envs/terrain.md @@ -0,0 +1,275 @@ +# 程序化地形生成 + +MotrixLab 支持在场景配置中直接声明程序化高度场地形:不依赖外部高度图文件, +在模型 build 阶段由**地形生成器**(terrain generator)确定性地产出高度数据。粗糙地面、台阶、 +金字塔坡、离散障碍物等地形只需要几行配置,通过 `seed` 即可精确复现。 + +## 最小示例 + +在 `SceneCfg.assets` 中声明一个程序化高度场资产(`ProceduralHFieldAssetCfg`), +在 `SceneCfg.objs` 中用 `HFieldTerrainCfg` 把它挂为地面: + +```python +from motrix_env_core.base import EnvCfg +from motrix_env_core.config import configclass +from motrix_env_core.config.scene import ( + HFieldTerrainCfg, + MaterialCfg, + NoiseTerrainGeneratorCfg, + ProceduralHFieldAssetCfg, + SceneAssetsCfg, + SceneCfg, + SceneObjsCfg, +) + + +@configclass +class TerrainAssetsCfg(SceneAssetsCfg): + mat_ground: MaterialCfg = MaterialCfg() + terrain: ProceduralHFieldAssetCfg = ProceduralHFieldAssetCfg( + generator=NoiseTerrainGeneratorCfg(seed=0, height_scale=0.1), + size=(64.0, 64.0), # 世界空间 X/Y 全宽,单位 m + shape=(320, 320), # 高度场分辨率(行、列) + ) + + +@configclass +class TerrainObjsCfg(SceneObjsCfg): + floor: HFieldTerrainCfg = HFieldTerrainCfg(hfield="terrain", material="mat_ground") + + +@configclass +class MyTaskEnvCfg(EnvCfg): + scene: SceneCfg = SceneCfg(assets=TerrainAssetsCfg(), objs=TerrainObjsCfg()) +``` + +内置环境中,`go1-walk-rough`、`go2-walk-rough`、`anymalc-walk-rough` 等粗糙地形任务 +都通过同一个 `NoiseTerrainGeneratorCfg(seed=0, height_scale=0.1, flip_y=True)` 生成地形, +可以直接预览效果: + +```bash +python scripts/view.py env=go1-walk-rough +``` + +## 生成器契约 + +所有生成器继承 `TerrainGeneratorCfg`,遵循同一条契约: + +- `generate(size, shape)` 返回 **归一化到 [0, 1]** 的高度数组;物理高度 = 归一化值 × + `height_scale`(米)。例如 `height_scale=0.1` 时,整张地形的高差最大为 0.1 m。 +- 数组为 MuJoCo 行主序:第一维(行,`shape[0]`)对应高度场 x 轴,第二维(列,`shape[1]`) + 对应 y 轴。 +- `seed` 驱动所有随机性,同一种子总是产出同一张地形。 +- 地形在**模型 build 时一次性生成**,训练过程中不再变化;编译器会校验返回数组的形状与数值有限性。 +- 各生成器的 `validate()` 会拒绝超出 `height_scale` 的配置(例如台阶总爬升超过 + `height_scale`),错误信息给出具体上限。 + +## 内置生成器 + +每个生成器都是 `@configclass`,字段即全部可调参数。以下截图均由 +`examples/terrain_generate.py` 的展示配置渲染。 + +### FlatTerrainGeneratorCfg —— 平面 + +常量归一化高度 `height`(默认 0.0),多用作组合地形的基底或重置平台。 + +### NoiseTerrainGeneratorCfg —— 噪声 + +逐单元均匀采样 `[0, 1)` 的随机高度。`downsampled_scale`(米)设置后,先在以该间距 +采样的粗网格上生成噪声,再双线性插值到目标分辨率,得到起伏平缓的丘陵噪声; +不设置则是逐单元的粗糙颗粒噪声。 + +```{figure} /_static/images/tutorial/terrain/noise_rough.jpg +:alt: 逐单元噪声地形截图:粗糙颗粒状起伏 + +逐单元噪声(`height_scale=0.08`),颗粒状起伏。 +``` + +```{figure} /_static/images/tutorial/terrain/noise_smooth.jpg +:alt: 下采样噪声地形截图:平缓丘陵起伏 + +`downsampled_scale=0.4` 的同一颗噪声,双线性插值后成为平缓丘陵。 +``` + +### QuantizedTerrainGeneratorCfg —— 台地化 + +把 `source` 生成器的归一化输出量化为 `levels` 个离散高度级(含 0 和 1),形成台地。 +被包裹生成器自己的 `height_scale` 被忽略,物理尺度由本配置的 `height_scale` 决定。 + +```{figure} /_static/images/tutorial/terrain/noise_terraced.jpg +:alt: 噪声量化台地地形截图:离散阶梯状平台 + +噪声量化为 `levels=5` 的台地。 +``` + +### StairsTerrainGeneratorCfg —— 台阶 + +沿一个轴或从中心径向铺规则台阶,主要字段: + +| 字段 | 含义 | +| ---------------- | ----------------------------------------------------------------------------------------- | +| `axis` | `"x"` / `"y"` 沿轴线性;`"radial"` 围绕场地中心铺同心方环 | +| `profile` | `ascending`、`descending`、`pyramid`、`inverted_pyramid` 四种台阶走向 | +| `step_count` | 台阶数;`step_width` 未设置时台阶均分整个轴向跨度 | +| `step_height` | 单级台阶的物理高度(m) | +| `step_width` | 可选的固定踏面宽度(m);设置后 `step_count` 退化为最大级数上限 | +| `platform_width` | 中心平台宽度(m),保持在轮廓的自然中心高度;0 关闭 | +| `base_level` | 归一化基础抬升,供坑洞留出向下空间(高度场高度不能为负) | + +```{figure} /_static/images/tutorial/terrain/stairs_ascending.jpg +:alt: 沿 x 轴上升的线性台阶地形截图 + +`axis="x", profile="ascending"`:沿 x 轴从低到高的 8 级台阶。 +``` + +```{figure} /_static/images/tutorial/terrain/stairs_pyramid.jpg +:alt: 径向下降台阶地形截图:中心高台向四周逐级下降 + +`axis="radial", profile="descending"`:中心高台向四周逐级下降。 +``` + +```{figure} /_static/images/tutorial/terrain/stairs_inverted_pyramid.jpg +:alt: 径向反转金字塔台阶地形截图:中心坑洞四周环形上升 + +`axis="radial", profile="inverted_pyramid"`:中心坑洞,四周环形上升。 +``` + +### DiscreteObstaclesTerrainGeneratorCfg —— 离散障碍物 + +在平坦基底(归一化高度 0.5)上随机散布矩形凸起或凹坑。`height_mode="fixed"` 时 +每个障碍物都是 `height`(可正可负);`height_mode="choice"` 时从 +{+height, +height/2, −height/2, −height} 中随机抽取,得到凸坑混合的障碍场。 +障碍物边长按 `size_min`–`size_max`(场地边长比例)逐轴均匀采样。注意校验要求 +`|height| <= 0.5 * height_scale`。 + +```{figure} /_static/images/tutorial/terrain/obstacles_choice.jpg +:alt: 离散障碍物地形截图:随机散布的凸起与凹坑 + +`height_mode="choice"`:随机散布的凸起与凹坑混合。 +``` + +### PyramidSlopeTerrainGeneratorCfg —— 金字塔坡 + +高度随到场地边界的切比雪夫距离线性上升(`slope` 为每米爬升),中心形成方形尖峰; +`inverted=True` 反转为中心凹坑。峰值取决于场地尺寸,生成时若超过 `height_scale` +会报错,需要增大 `height_scale` 或减小 `slope`。 + +```{figure} /_static/images/tutorial/terrain/slope_compare.jpg +:alt: 金字塔坡对比图:左侧为中心土丘,右侧为反转的中心凹坑 + +左:`slope=0.1`、`platform_width=0.8` 的中心土丘;右:`inverted=True` 时反转为中心凹坑。 +坡度平缓时(每米仅爬升 0.1 m),俯视视角下起伏不明显,建议用低角度相机检查坡面。 +``` + +## 组合地形 + +`CompositeTerrainGeneratorCfg` 把多个生成器拼进一张场地:先用 `base` 铺满全场, +再按 `regions` 中的 `TerrainRegionCfg` 逐个贴上矩形区域块。要点: + +- `center` 与 `size` 都是**全场比例**(0–1),不是米。 +- 区域按声明顺序粘贴,重叠时后声明的区域覆盖先声明的。 +- 子生成器按 `子 height_scale / 组合 height_scale` 重缩放后粘贴,**物理高度守恒**—— + 台阶的 `step_height`、坡的 `slope` 在拼接后保持原来的物理尺寸。 +- 组合 `height_scale` 必须覆盖每个子生成器的物理峰值,否则生成时报错。 +- `blend`(0–0.5)是区域边缘的过渡带宽度(占区域边长比例);与场地边界齐平的边不做过渡。 + +```{figure} /_static/images/tutorial/terrain/composite.jpg +:alt: 组合地形截图:台地化噪声基底上拼贴台阶、坑洞、障碍物与坡 + +组合地形:噪声台地基底上依次贴上径向台阶、反转金字塔坑、离散障碍物与金字塔坡。 +``` + +`grid_terrain()` 是组合地形的网格快捷方式——把生成器按行列摆成难度网格,每个格子 +保留自己的生成器,`height_scale` 默认取所有子生成器中的最大值: + +```python +from motrix_env_core.config.scene import ( + FlatTerrainGeneratorCfg, + StairsTerrainGeneratorCfg, + grid_terrain, +) + +terrain = grid_terrain( + [ + [FlatTerrainGeneratorCfg(), StairsTerrainGeneratorCfg(step_count=3, step_height=0.05)], + [StairsTerrainGeneratorCfg(step_count=6, step_height=0.08), FlatTerrainGeneratorCfg()], + ], + blend=0.1, # 所有格子边界过渡带占格子边长的比例 +) +``` + +```{figure} /_static/images/tutorial/terrain/grid.jpg +:alt: 网格地形截图:2x3 台阶格子按难度排列 + +`grid_terrain` 把不同 `step_count`、`step_height` 的台阶摆成难度网格。 +``` + +## 在环境中使用地形 + +与平地任务共用同一套环境逻辑,只替换场景,是内置四足/双足任务的固定做法: +平坦配置用 `FlatTerrainCfg` 做地面,粗糙变体继承平坦配置、只覆写 `scene`—— +`assets` 换成带地形资产的配置组,`objs.floor` 换成 `HFieldTerrainCfg(hfield="terrain")`: + +```python +@registry.envcfg("my-robot-walk-rough") +@configclass +class MyRobotRoughEnvCfg(MyRobotFlatEnvCfg): + scene: MySceneCfg = MySceneCfg( + assets=TerrainAssetsCfg(), + objs=StandardSceneObjsCfg(floor=HFieldTerrainCfg(hfield="terrain", material="mat_ground")), + ) +``` + +在地形上训练时,重置与奖励通常要以**地形相对高度**为基准。运行时通过 backend 中立的 +SimBackend 接口采样任意 (x, y) 处的地面高度: + +```python +ground_height = env.sim.sample_terrain_height( + env.cfg.ground_geom_name, env_ids, base_pos[:, None, :2] +)[:, 0] +``` + +`go1-walk-rough` 在重置时先采样出生点附近的地面高度,再把基座抬到最高点之上, +避免机器人出生在地里;body-height 奖励同样以局部地形高度为基准。 + +## 编写自定义生成器 + +继承 `TerrainGeneratorCfg` 并实现 `generate(size, shape)`,返回归一化 `[0, 1]` 高度数组 +即可接入所有组合机制: + +```python +from motrix_env_core.config import configclass +from motrix_env_core.config.scene import TerrainGeneratorCfg + +import numpy as np + + +@configclass +class SinusoidalTerrainGeneratorCfg(TerrainGeneratorCfg): + """Sine waves along the x axis.""" + + waves: int = 4 + + def validate(self) -> None: + super().validate() + if self.waves < 1: + raise ValueError(f"waves must be at least 1, got {self.waves}") + + def generate(self, size: tuple[float, float], shape: tuple[int, int]) -> np.ndarray: + phase = np.linspace(0.0, self.waves * 2.0 * np.pi, shape[0])[:, None] + heights = 0.5 + 0.5 * np.sin(phase) # 归一化到 [0, 1] + return np.broadcast_to(heights, shape).astype(np.float32) +``` + +## 预览与调试 + +`examples/terrain_generate.py` 为每种内置生成器与组合布局各生成一张展示地形, +打印高度统计,并把归一化高度图保存为灰度 PNG;`--render` 用 MotrixSim 渲染组合地形: + +```bash +python examples/terrain_generate.py # 全部生成器,输出到 terrain_previews/ +python examples/terrain_generate.py --resolution 256 # 指定高度场分辨率 +python examples/terrain_generate.py --render # 打开查看器查看组合地形 +``` + +本文截图即由该脚本的展示配置离屏渲染得到。 diff --git a/examples/terrain_generate.py b/examples/terrain_generate.py new file mode 100644 index 00000000..71e07bc6 --- /dev/null +++ b/examples/terrain_generate.py @@ -0,0 +1,291 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +"""Demonstrate the procedural terrain generators. + +Generates a showcase terrain for every primitive plus composed layouts, prints +height statistics, and saves grayscale PNG previews to ``--output``. With +``--render``, the composite terrain is additionally rendered offscreen through +the MotrixSim backend. + +Usage:: + + python examples/terrain_generate.py + python examples/terrain_generate.py --output /tmp/previews --resolution 256 + python examples/terrain_generate.py --render + +To use a generator in a scene, wrap it in a procedural height-field asset:: + + from motrix_env_core.config.scene import ProceduralHFieldAssetCfg + + ProceduralHFieldAssetCfg(generator=MyTerrainGeneratorCfg(...), size=(16.0, 16.0), shape=(256, 256)) +""" + +from __future__ import annotations + +import argparse +import struct +import zlib +from pathlib import Path + +import numpy as np + +from motrix_env_core.config import configclass +from motrix_env_core.config.scene import ( + CompositeTerrainGeneratorCfg, + DiscreteObstaclesTerrainGeneratorCfg, + FlatTerrainGeneratorCfg, + HFieldTerrainCfg, + LightCfg, + MaterialCfg, + NoiseTerrainGeneratorCfg, + ProceduralHFieldAssetCfg, + PyramidSlopeTerrainGeneratorCfg, + QuantizedTerrainGeneratorCfg, + SceneAssetsCfg, + SceneCfg, + SceneObjsCfg, + SceneVisualCfg, + SkyboxCfg, + StairsTerrainGeneratorCfg, + TerrainGeneratorCfg, + TerrainRegionCfg, + TextureCfg, + grid_terrain, +) + +TERRAIN_SIZE = 16.0 + + +def build_showcase() -> dict[str, TerrainGeneratorCfg]: + """One representative terrain per primitive, plus composed layouts.""" + return { + "flat": FlatTerrainGeneratorCfg(height=0.0, height_scale=0.3), + "noise_rough": NoiseTerrainGeneratorCfg(seed=1, height_scale=0.08), + "noise_smooth": NoiseTerrainGeneratorCfg(seed=1, height_scale=0.08, downsampled_scale=0.4), + "noise_terraced": QuantizedTerrainGeneratorCfg( + source=NoiseTerrainGeneratorCfg(seed=1, height_scale=0.08), levels=5, height_scale=0.08 + ), + "stairs_ascending": StairsTerrainGeneratorCfg( + axis="x", profile="ascending", step_count=8, step_height=0.1, height_scale=0.8 + ), + "stairs_pyramid": StairsTerrainGeneratorCfg( + axis="radial", + profile="descending", + step_height=0.14, + step_width=0.3, + platform_width=1.5, + height_scale=1.6, + ), + "stairs_inverted_pyramid": StairsTerrainGeneratorCfg( + axis="radial", + profile="inverted_pyramid", + step_height=0.14, + step_width=0.3, + platform_width=1.5, + height_scale=1.6, + ), + "obstacles_choice": DiscreteObstaclesTerrainGeneratorCfg( + seed=5, + count=12, + size_min=0.03, + size_max=0.06, + height=0.125, + height_scale=0.25, + height_mode="choice", + platform_width=1.0, + ), + "slope_mound": PyramidSlopeTerrainGeneratorCfg(slope=0.1, platform_width=0.8, height_scale=0.9), + "slope_pit": PyramidSlopeTerrainGeneratorCfg(slope=0.1, inverted=True, height_scale=0.9), + "composite": composite_terrain(), + "grid": grid_terrain( + [ + [ + StairsTerrainGeneratorCfg( + axis="radial", profile="descending", step_count=6, step_height=0.05, height_scale=0.3 + ), + StairsTerrainGeneratorCfg( + axis="radial", profile="descending", step_count=8, step_height=0.08, height_scale=0.6 + ), + StairsTerrainGeneratorCfg( + axis="radial", profile="descending", step_count=10, step_height=0.11, height_scale=1.0 + ), + ], + [ + StairsTerrainGeneratorCfg( + axis="radial", profile="descending", step_count=12, step_height=0.14, height_scale=1.6 + ), + StairsTerrainGeneratorCfg( + axis="radial", profile="inverted_pyramid", step_count=12, step_height=0.14, height_scale=1.6 + ), + StairsTerrainGeneratorCfg( + axis="radial", profile="inverted_pyramid", step_count=6, step_height=0.08, height_scale=0.5 + ), + ], + ] + ), + } + + +def composite_terrain() -> CompositeTerrainGeneratorCfg: + """A base rough field with stairs, a pit, obstacles, a slope, and a reset pad.""" + return CompositeTerrainGeneratorCfg( + height_scale=1.7, + base=QuantizedTerrainGeneratorCfg( + source=NoiseTerrainGeneratorCfg(seed=11, height_scale=0.08), levels=5, height_scale=0.08 + ), + regions=( + TerrainRegionCfg( + center=(0.28, 0.28), + size=(0.44, 0.44), + blend=0.05, + generator=StairsTerrainGeneratorCfg( + axis="radial", + profile="descending", + step_count=12, + step_height=0.14, + step_width=0.3, + platform_width=1.5, + height_scale=1.6, + ), + ), + TerrainRegionCfg( + center=(0.28, 0.75), + size=(0.4, 0.4), + blend=0.05, + generator=StairsTerrainGeneratorCfg( + axis="radial", + profile="inverted_pyramid", + step_count=12, + step_height=0.14, + step_width=0.3, + platform_width=1.5, + height_scale=1.6, + ), + ), + TerrainRegionCfg( + center=(0.75, 0.28), + size=(0.44, 0.44), + blend=0.05, + generator=DiscreteObstaclesTerrainGeneratorCfg( + seed=5, + count=12, + size_min=0.03, + size_max=0.06, + height=0.125, + height_scale=0.25, + height_mode="choice", + platform_width=1.0, + ), + ), + TerrainRegionCfg( + center=(0.78, 0.78), + size=(0.3, 0.3), + blend=0.08, + generator=PyramidSlopeTerrainGeneratorCfg(slope=0.15, platform_width=0.8, height_scale=0.9), + ), + ), + ) + + +def save_height_map(path: Path, heights: np.ndarray) -> None: + """Save a normalized height field as an 8-bit grayscale PNG (stdlib only).""" + lo, hi = float(heights.min()), float(heights.max()) + span = max(hi - lo, 1e-6) + gray = ((heights - lo) / span * 255.0).astype(np.uint8) + height, width = gray.shape + raw = b"".join(b"\x00" + gray[row].tobytes() for row in range(height)) + + def chunk(tag: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data)) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0) + idat = zlib.compress(raw) + png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"") + path.write_bytes(png) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, default=Path("terrain_previews"), help="Preview output directory") + parser.add_argument("--resolution", type=int, default=256, help="Height-field samples per axis") + parser.add_argument("--render", action="store_true", help="Open the MotrixSim viewer for the composite terrain") + args = parser.parse_args() + + args.output.mkdir(parents=True, exist_ok=True) + shape = (args.resolution, args.resolution) + for name, generator in build_showcase().items(): + generator.validate() + heights = generator.generate((TERRAIN_SIZE, TERRAIN_SIZE), shape) + physical = heights.astype(np.float64) * generator.height_scale + print( + f"{name:>24}: height {physical.min():+.3f} .. {physical.max():+.3f} m " + f"({np.unique(np.round(physical, 3)).size} levels)" + ) + save_height_map(args.output / f"{name}.png", heights) + print(f"previews saved to {args.output}/") + + if args.render: + view_terrain() + + +GROUND_TEXTURE = Path(__file__).parents[1] / "motrix_envs" / "src" / "motrix_envs" / "common" / "motphys-ground.png" + + +@configclass +class TerrainAssetsCfg(SceneAssetsCfg): + skybox: SkyboxCfg = SkyboxCfg() + tex_ground: TextureCfg = TextureCfg(file=GROUND_TEXTURE) + mat_ground: MaterialCfg = MaterialCfg(texture="tex_ground", texture_repeat=(0.4, 0.4)) + terrain: ProceduralHFieldAssetCfg = ProceduralHFieldAssetCfg( + generator=composite_terrain(), size=(TERRAIN_SIZE, TERRAIN_SIZE), shape=(256, 256) + ) + + +@configclass +class TerrainObjsCfg(SceneObjsCfg): + floor: HFieldTerrainCfg = HFieldTerrainCfg(hfield="terrain", material="mat_ground") + sun: LightCfg = LightCfg(color=(0.7, 0.7, 0.7), illuminance=10_000.0, cast_shadows=False) + + +def view_terrain() -> None: + """Open the interactive viewer window showing the composite terrain.""" + import time + + import motrixsim as mtx + from motrixsim.render import RenderApp, RenderClosedError, RenderSettings + + from motrix_env_motrixsim.compiler import build_scene_model + + model = build_scene_model( + SceneCfg( + assets=TerrainAssetsCfg(), + objs=TerrainObjsCfg(), + visual=SceneVisualCfg(ambient_light_color=(0.3, 0.3, 0.3), ambient_light_brightness=1_000.0), + ) + ) + data = mtx.SceneData(model, batch=[1]) + settings = RenderSettings.performance() + settings.enable_shadow = True + renderer = RenderApp() + try: + renderer.launch( + model, + batch=1, + render_offset=[[0.0, 0.0, 0.0]], + render_settings=settings, + ) + # NOTE: system-camera elevation is inverted: negative elevation looks DOWN at the scene. + renderer.system_camera.set_view(lookat=[0.0, 0.0, 0.3], distance=18.0, elevation=-50.0, azimuth=45.0) + renderer.system_camera.active = True + while not renderer.is_closed: + renderer.sync(data=data) + time.sleep(1.0 / 60.0) + except RenderClosedError: + pass + finally: + renderer.__exit__(None, None, None) + + +if __name__ == "__main__": + main() diff --git a/motrix_env_core/src/motrix_env_core/config/scene/__init__.py b/motrix_env_core/src/motrix_env_core/config/scene/__init__.py index c2757a30..0723e649 100644 --- a/motrix_env_core/src/motrix_env_core/config/scene/__init__.py +++ b/motrix_env_core/src/motrix_env_core/config/scene/__init__.py @@ -39,6 +39,16 @@ FrameSensorCfg, FrameSensorType, ) +from motrix_env_core.config.scene.terrain import ( + CompositeTerrainGeneratorCfg, + DiscreteObstaclesTerrainGeneratorCfg, + FlatTerrainGeneratorCfg, + PyramidSlopeTerrainGeneratorCfg, + QuantizedTerrainGeneratorCfg, + StairsTerrainGeneratorCfg, + TerrainRegionCfg, + grid_terrain, +) from motrix_env_core.config.scene.urdf import ( ActuatorCfg, JointCfg, @@ -53,9 +63,12 @@ "ActuatorCfg", "BodyCfg", "ContactReportField", + "CompositeTerrainGeneratorCfg", "ContactSensorCfg", "ContactSensorReduce", + "DiscreteObstaclesTerrainGeneratorCfg", "FlatTerrainCfg", + "FlatTerrainGeneratorCfg", "FrameObjectKind", "FrameRefKind", "FrameSensorCfg", @@ -72,6 +85,18 @@ "NoiseTerrainGeneratorCfg", "PositionActuatorCfg", "ProceduralHFieldAssetCfg", + "PyramidSlopeTerrainGeneratorCfg", + "QuantizedTerrainGeneratorCfg", + "StairsTerrainGeneratorCfg", + "TerrainRegionCfg", + "TerrainGeneratorCfg", + "TextureCfg", + "UrdfGeomCfg", + "UrdfFileCfg", + "Vec2", + "Vec3", + "Vec4", + "grid_terrain", "RobotCfg", "SceneAssetCfg", "SceneAssetsCfg", @@ -85,12 +110,5 @@ "SiteCfg", "SkyboxCfg", "SystemCameraCfg", - "TerrainGeneratorCfg", - "TextureCfg", - "UrdfGeomCfg", - "UrdfFileCfg", - "Vec2", - "Vec3", - "Vec4", "validate_scene_cfg", ] diff --git a/motrix_env_core/src/motrix_env_core/config/scene/asset.py b/motrix_env_core/src/motrix_env_core/config/scene/asset.py index 0ce01a35..97015eda 100644 --- a/motrix_env_core/src/motrix_env_core/config/scene/asset.py +++ b/motrix_env_core/src/motrix_env_core/config/scene/asset.py @@ -23,10 +23,10 @@ class TerrainGeneratorCfg(ABC): def validate(self) -> None: if self.seed < 0: raise ValueError(f"TerrainGeneratorCfg.seed must be non-negative, got {self.seed}") - if not np.isfinite(self.height_scale) or self.height_scale < 0.0: - raise ValueError( - f"TerrainGeneratorCfg.height_scale must be finite and non-negative, got {self.height_scale}" - ) + # Generators divide by ``height_scale`` when normalizing physical heights, and a + # zero span carries no information anyway, so it is rejected up front. + if not np.isfinite(self.height_scale) or self.height_scale <= 0.0: + raise ValueError(f"TerrainGeneratorCfg.height_scale must be finite and positive, got {self.height_scale}") @abstractmethod def generate(self, size: Vec2, shape: tuple[int, int]) -> np.ndarray: @@ -39,14 +39,50 @@ class NoiseTerrainGeneratorCfg(TerrainGeneratorCfg): # Reverse the generated row axis for parity with an equivalent image-backed height field. flip_y: bool = False + # Optional coarse sampling pitch in meters. When set, heights are sampled on a grid + # of this pitch and bilinearly interpolated to the output resolution, producing + # smooth rolling noise instead of per-cell speckle (Isaac Lab ``downsampled_scale``). + downsampled_scale: float | None = None + + def validate(self) -> None: + super().validate() + if self.downsampled_scale is not None and ( + not np.isfinite(self.downsampled_scale) or self.downsampled_scale <= 0.0 + ): + raise ValueError( + f"NoiseTerrainGeneratorCfg.downsampled_scale must be finite and positive when set, " + f"got {self.downsampled_scale!r}" + ) def generate(self, size: Vec2, shape: tuple[int, int]) -> np.ndarray: - del size rng = np.random.default_rng(self.seed) - heights = rng.uniform(0.0, 1.0, size=shape).astype(np.float32) + if self.downsampled_scale is not None and size[0] > self.downsampled_scale and size[1] > self.downsampled_scale: + coarse_rows = max(int(size[0] / self.downsampled_scale), 2) + coarse_cols = max(int(size[1] / self.downsampled_scale), 2) + coarse = rng.uniform(0.0, 1.0, size=(coarse_rows, coarse_cols)) + heights = _bilinear_resize(coarse, shape) + else: + heights = rng.uniform(0.0, 1.0, size=shape).astype(np.float32) return np.flipud(heights) if self.flip_y else heights +def _bilinear_resize(grid: np.ndarray, out_shape: tuple[int, int]) -> np.ndarray: + """Bilinearly interpolate a 2D grid to ``out_shape`` (numpy-only).""" + rows, cols = grid.shape + out_rows, out_cols = out_shape + ys = np.linspace(0.0, rows - 1, out_rows) + xs = np.linspace(0.0, cols - 1, out_cols) + y0 = np.floor(ys).astype(np.int64) + x0 = np.floor(xs).astype(np.int64) + y1 = np.minimum(y0 + 1, rows - 1) + x1 = np.minimum(x0 + 1, cols - 1) + fy = (ys - y0)[:, None] + fx = (xs - x0)[None, :] + top = grid[np.ix_(y0, x0)] * (1.0 - fx) + grid[np.ix_(y0, x1)] * fx + bottom = grid[np.ix_(y1, x0)] * (1.0 - fx) + grid[np.ix_(y1, x1)] * fx + return (top * (1.0 - fy) + bottom * fy).astype(np.float32) + + @configclass class TextureCfg(SceneAssetCfg): """A file-backed 2D texture asset.""" diff --git a/motrix_env_core/src/motrix_env_core/config/scene/terrain.py b/motrix_env_core/src/motrix_env_core/config/scene/terrain.py new file mode 100644 index 00000000..762b78c0 --- /dev/null +++ b/motrix_env_core/src/motrix_env_core/config/scene/terrain.py @@ -0,0 +1,518 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +"""Build-time procedural terrain generators: primitives, quantization, and composition. + +All generators follow the `TerrainGeneratorCfg` contract: `generate(size, shape)` +returns normalized `[0, 1]` heights as a MuJoCo-row-major 2D array whose first +dimension (rows, ``shape[0]``) maps to the hfield x axis and second dimension +(columns, ``shape[1]``) maps to the y axis. Physical heights are normalized +values scaled by ``height_scale`` in meters. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np + +from motrix_env_core.config import configclass +from motrix_env_core.config.scene._utils import Vec2 +from motrix_env_core.config.scene.asset import TerrainGeneratorCfg + +# Relative tolerance when comparing physical heights against height_scale bounds. +_HEIGHT_EPS = 1e-6 + +_STAIRS_AXES = ("x", "y", "radial") +_STAIRS_PROFILES = ("ascending", "descending", "pyramid", "inverted_pyramid") +_PYRAMID_PROFILES = ("pyramid", "inverted_pyramid") +_OBSTACLE_HEIGHT_MODES = ("fixed", "choice") + + +@configclass +class FlatTerrainGeneratorCfg(TerrainGeneratorCfg): + """A flat terrain at a constant normalized height (0.0 by default).""" + + # Constant normalized height in [0, 1]; useful as a composition base or reset area. + height: float = 0.0 + + def validate(self) -> None: + super().validate() + if not np.isfinite(self.height) or not 0.0 <= self.height <= 1.0: + raise ValueError(f"FlatTerrainGeneratorCfg.height must be finite and within [0, 1], got {self.height!r}") + + def generate(self, size: Vec2, shape: tuple[int, int]) -> np.ndarray: + del size + return np.full(shape, self.height, dtype=np.float32) + + +@configclass +class StairsTerrainGeneratorCfg(TerrainGeneratorCfg): + """Regular stairs along one field axis or radially from the center. + + Linear modes occupy the full field along the stair axis and are constant + along the other axis. ``axis="x"`` varies along ``shape[0]`` rows (the + hfield x axis); ``axis="y"`` varies along ``shape[1]`` columns (the hfield y + axis). ``axis="radial"`` lays the steps as concentric square rings around + the field center (Isaac Lab pyramid-stairs style): ``ascending`` rises + outward from a low center (a central pit), ``descending`` falls outward + from a central peak platform, and ``pyramid`` / ``inverted_pyramid`` place + the extreme ring at mid radius. + + Profiles (step level as a function of position along the stairs): + - ``ascending``: rises from 0 at the low-index edge to the top at the far edge. + - ``descending``: starts at the top and descends to 0 at the far edge. + - ``pyramid``: rises to a central peak and descends again (peak level is + ``step_count // 2`` steps above the edges). + - ``inverted_pyramid``: a central pit with the edges at the top (peak level + is ``step_count // 2`` steps above the pit floor). + """ + + # Stair direction: "x" -> shape[0] rows, "y" -> shape[1] columns, + # "radial" -> concentric rings around the field center. + axis: str = "x" + # Step arrangement profile. + profile: str = "ascending" + # Number of steps (each step spans 1/step_count of the field along the axis). + step_count: int = 8 + # Physical height of a single step in meters. + step_height: float = 0.05 + # Optional physical tread width in meters. When set, steps are laid out with this + # fixed tread width (Isaac Lab ``step_width`` style) and ``step_count`` acts as the + # maximum level cap; when None, ``step_count`` treads evenly span the field. + step_width: float | None = None + # Flat central zone width in meters (radial) or center band (linear), kept at the + # profile's natural center level (platform for descending/pyramid, pit floor for + # ascending/inverted). 0 disables the platform. + platform_width: float = 0.0 + # Normalized base offset added to every cell. Pits need headroom below their rim + # (hfield heights cannot be negative), so tiled layouts raise the base: e.g. + # base_level=0.5 makes a mound span [0.5, 1.0] and a pit span [0.0, 0.5], keeping + # tile boundaries at a seamless common level. + base_level: float = 0.0 + + def validate(self) -> None: + super().validate() + if self.axis not in _STAIRS_AXES: + raise ValueError(f"StairsTerrainGeneratorCfg.axis must be one of {_STAIRS_AXES}, got {self.axis!r}") + if self.profile not in _STAIRS_PROFILES: + raise ValueError( + f"StairsTerrainGeneratorCfg.profile must be one of {_STAIRS_PROFILES}, got {self.profile!r}" + ) + if self.step_count < 2: + raise ValueError(f"StairsTerrainGeneratorCfg.step_count must be at least 2, got {self.step_count}") + if not np.isfinite(self.step_height) or self.step_height <= 0.0: + raise ValueError( + f"StairsTerrainGeneratorCfg.step_height must be finite and positive, got {self.step_height!r}" + ) + max_level = (self.step_count - 1) // 2 if self.profile in _PYRAMID_PROFILES else self.step_count - 1 + if max_level * self.step_height > self.height_scale * (1.0 + _HEIGHT_EPS): + raise ValueError( + f"StairsTerrainGeneratorCfg requires max level ({max_level}) * step_height = " + f"{max_level * self.step_height:.4f} m to fit within height_scale = " + f"{self.height_scale:.4f} m" + ) + if self.step_width is not None and (not np.isfinite(self.step_width) or self.step_width <= 0.0): + raise ValueError( + f"StairsTerrainGeneratorCfg.step_width must be finite and positive when set, got {self.step_width!r}" + ) + if not np.isfinite(self.base_level) or not 0.0 <= self.base_level <= 1.0: + raise ValueError( + f"StairsTerrainGeneratorCfg.base_level must be finite and within [0, 1], got {self.base_level!r}" + ) + if self.base_level + max_level * self.step_height / self.height_scale > 1.0 + _HEIGHT_EPS: + raise ValueError( + f"StairsTerrainGeneratorCfg base_level + stair climb = " + f"{self.base_level + max_level * self.step_height / self.height_scale:.4f} " + f"must fit within [0, 1]" + ) + if not np.isfinite(self.platform_width) or self.platform_width < 0.0: + raise ValueError( + f"StairsTerrainGeneratorCfg.platform_width must be finite and non-negative, got {self.platform_width!r}" + ) + + def generate(self, size: Vec2, shape: tuple[int, int]) -> np.ndarray: + steps = self.step_count + step_span = self.step_height / self.height_scale + base_level_frac = self.base_level + extent = self._extent(size) + if self.axis == "radial": + rows = (np.arange(shape[0], dtype=np.float64) + 0.5) / shape[0] - 0.5 + cols = (np.arange(shape[1], dtype=np.float64) + 0.5) / shape[1] - 0.5 + # Chebyshev distance from the center, as a fraction of the field extent. + radius = np.maximum(np.abs(rows)[:, None], np.abs(cols)[None, :]) + index = self._index_from_distance(radius, extent, steps) + index = np.broadcast_to(index, shape) + else: + axis_length = shape[0] if self.axis == "x" else shape[1] + pos_frac = (np.arange(axis_length, dtype=np.float64) + 0.5) / axis_length + index = self._index_from_distance(pos_frac, extent, steps) + index = np.broadcast_to(index[:, None] if self.axis == "x" else index[None, :], shape) + if self.profile == "ascending": + level = index + elif self.profile == "descending": + level = steps - 1 - index + elif self.profile == "pyramid": + level = np.minimum(index, steps - 1 - index) + else: # inverted_pyramid + level = (steps - 1) // 2 - np.minimum(index, steps - 1 - index) + level = self._apply_platform(level, size, shape) + return (base_level_frac + level * step_span).astype(np.float32) + + def _extent(self, size: Vec2) -> float: + """Physical extent the profile spans: the stair axis for linear modes, the + shorter side for the radial mode (the largest inscribed square).""" + if self.axis == "x": + return size[0] + if self.axis == "y": + return size[1] + return min(size) + + def _index_from_distance(self, distance: np.ndarray, extent: float, steps: int) -> np.ndarray: + """Step index as a function of distance (fraction of extent) from the profile origin. + + Linear axes span ``steps`` treads over the full extent; the radial axis spans + ``2 * steps`` (radius is half the extent). With ``step_width`` set, treads have + a fixed physical width and cells inside the platform sit one level below the + first outer ring so the platform edge is a real step. + """ + platform_half_frac = (self.platform_width / 2.0) / extent + if self.step_width is None: + scale = 2.0 * steps if self.axis == "radial" else float(steps) + index = distance * scale + else: + w_frac = self.step_width / extent + inner = np.maximum(distance - platform_half_frac, 0.0) + bonus = (distance > platform_half_frac) if self.platform_width > 0.0 else 0 + index = np.floor(inner / w_frac) + bonus + return np.minimum(index.astype(np.int64), steps - 1) + + def _apply_platform(self, level: np.ndarray, size: Vec2, shape: tuple[int, int]) -> np.ndarray: + """Flatten the central zone to the profile's natural center level.""" + if self.platform_width <= 0.0: + return level + platform_half_frac = (self.platform_width / 2.0) / self._extent(size) + if self.axis == "radial": + rows = (np.arange(shape[0], dtype=np.float64) + 0.5) / shape[0] - 0.5 + cols = (np.arange(shape[1], dtype=np.float64) + 0.5) / shape[1] - 0.5 + radius = np.maximum(np.abs(rows)[:, None], np.abs(cols)[None, :]) + mask = radius <= platform_half_frac + else: + axis_length = shape[0] if self.axis == "x" else shape[1] + pos_frac = (np.arange(axis_length, dtype=np.float64) + 0.5) / axis_length + mask_1d = np.abs(pos_frac - 0.5) <= platform_half_frac + mask = mask_1d[:, None] if self.axis == "x" else mask_1d[None, :] + mask = np.broadcast_to(mask, shape) + return np.where(mask, level[tuple(np.array(shape) // 2)], level) + + +@configclass +class DiscreteObstaclesTerrainGeneratorCfg(TerrainGeneratorCfg): + """Randomly scattered rectangular bumps or pits on a flat base.""" + + # Number of obstacles scattered over the field. + count: int = 8 + # How obstacle heights are drawn: "fixed" uses ``height`` as-is for every + # obstacle; "choice" randomly picks from {+height, +height/2, -height/2, + # -height} per obstacle (Isaac Lab style mixed bumps and pits). + height_mode: str = "fixed" + # Signed obstacle height in meters: positive raises a bump, negative digs a pit. + height: float = 0.1 + # Obstacle extent sampled uniformly in [size_min, size_max] per axis, as a + # fraction of the full field extent. + size_min: float = 0.05 + size_max: float = 0.15 + # Flat central zone width in meters kept at base level; 0 disables it. + platform_width: float = 0.0 + + def validate(self) -> None: + super().validate() + if self.height_mode not in _OBSTACLE_HEIGHT_MODES: + raise ValueError( + f"DiscreteObstaclesTerrainGeneratorCfg.height_mode must be one of " + f"{_OBSTACLE_HEIGHT_MODES}, got {self.height_mode!r}" + ) + if self.count < 0: + raise ValueError(f"DiscreteObstaclesTerrainGeneratorCfg.count must be non-negative, got {self.count}") + if not 0.0 < self.size_min <= self.size_max <= 1.0: + raise ValueError( + "DiscreteObstaclesTerrainGeneratorCfg requires 0 < size_min <= size_max <= 1, " + f"got {(self.size_min, self.size_max)!r}" + ) + if not np.isfinite(self.height) or abs(self.height) > 0.5 * self.height_scale * (1.0 + _HEIGHT_EPS): + raise ValueError( + f"DiscreteObstaclesTerrainGeneratorCfg requires |height| <= 0.5 * height_scale = " + f"{0.5 * self.height_scale:.4f} m, got {self.height!r}" + ) + if not np.isfinite(self.platform_width) or self.platform_width < 0.0: + raise ValueError( + f"DiscreteObstaclesTerrainGeneratorCfg.platform_width must be finite and " + f"non-negative, got {self.platform_width!r}" + ) + + def generate(self, size: Vec2, shape: tuple[int, int]) -> np.ndarray: + rng = np.random.default_rng(self.seed) + heights = np.full(shape, 0.5, dtype=np.float64) + rows, cols = shape + magnitude = abs(self.height) / self.height_scale + sign = 1.0 if self.height >= 0 else -1.0 + for _ in range(self.count): + if self.height_mode == "choice": + delta = float(rng.choice([-magnitude, -magnitude / 2.0, magnitude / 2.0, magnitude])) + else: + delta = sign * magnitude + row_frac = rng.uniform(self.size_min, self.size_max) + col_frac = rng.uniform(self.size_min, self.size_max) + center_row = rng.uniform(row_frac / 2.0, 1.0 - row_frac / 2.0) + center_col = rng.uniform(col_frac / 2.0, 1.0 - col_frac / 2.0) + r0 = int(np.floor((center_row - row_frac / 2.0) * rows)) + r1 = int(np.ceil((center_row + row_frac / 2.0) * rows)) + c0 = int(np.floor((center_col - col_frac / 2.0) * cols)) + c1 = int(np.ceil((center_col + col_frac / 2.0) * cols)) + heights[r0 : max(r0 + 1, r1), c0 : max(c0 + 1, c1)] = 0.5 + delta + if self.platform_width > 0.0: + half_row = (self.platform_width / 2.0) / size[0] + half_col = (self.platform_width / 2.0) / size[1] + row_mask = np.abs((np.arange(rows, dtype=np.float64) + 0.5) / rows - 0.5) <= half_row + col_mask = np.abs((np.arange(cols, dtype=np.float64) + 0.5) / cols - 0.5) <= half_col + heights[np.ix_(row_mask, col_mask)] = 0.5 + return heights.astype(np.float32) + + +@configclass +class PyramidSlopeTerrainGeneratorCfg(TerrainGeneratorCfg): + """Smooth pyramidal slope rising linearly toward the field center. + + Elevation is ``slope`` (rise per meter) times the Chebyshev distance to the + field boundary, so the peak sits at the center of a square field. With + ``inverted`` the center dips instead (central pit). ``platform_width`` + flattens a central zone at the peak (or pit floor) level. Generation raises + when the physical peak exceeds ``height_scale`` since the peak depends on + the terrain size known only at generation time. + """ + + # Rise per meter of horizontal distance toward the center. + slope: float = 0.2 + # False: central mound; True: central pit. + inverted: bool = False + # Flat central zone width in meters; 0 runs the slope to the center point. + platform_width: float = 0.0 + + def validate(self) -> None: + super().validate() + if not np.isfinite(self.slope) or self.slope <= 0.0: + raise ValueError(f"PyramidSlopeTerrainGeneratorCfg.slope must be finite and positive, got {self.slope!r}") + if not np.isfinite(self.platform_width) or self.platform_width < 0.0: + raise ValueError( + f"PyramidSlopeTerrainGeneratorCfg.platform_width must be finite and non-negative, " + f"got {self.platform_width!r}" + ) + + def generate(self, size: Vec2, shape: tuple[int, int]) -> np.ndarray: + half = (size[0] / 2.0, size[1] / 2.0) + offset_x = (np.arange(shape[0], dtype=np.float64) + 0.5) / shape[0] * size[0] - half[0] + offset_y = (np.arange(shape[1], dtype=np.float64) + 0.5) / shape[1] * size[1] - half[1] + edge_distance = np.minimum((half[0] - np.abs(offset_x))[:, None], (half[1] - np.abs(offset_y))[None, :]) + heights = np.maximum(edge_distance - self.platform_width / 2.0, 0.0) * self.slope + peak = float(heights.max()) + if self.inverted: + heights = peak - heights + normalized = heights / self.height_scale + if normalized.max() > 1.0 + _HEIGHT_EPS: + raise ValueError( + f"PyramidSlopeTerrainGeneratorCfg peaks at {float(heights.max()):.4f} m which " + f"exceeds height_scale = {self.height_scale:.4f} m; increase height_scale or " + f"lower slope" + ) + return normalized.astype(np.float32) + + +@configclass +class QuantizedTerrainGeneratorCfg(TerrainGeneratorCfg): + """Quantize another generator's output into discrete height levels (terraces). + + The wrapped generator's own ``height_scale`` is ignored: the source output is + treated as normalized [0, 1] heights and the physical scale is this config's + ``height_scale``. + """ + + # Generator whose normalized output is quantized. + source: TerrainGeneratorCfg + # Number of discrete levels, including both 0 and 1. + levels: int = 8 + + def validate(self) -> None: + super().validate() + if not isinstance(self.source, TerrainGeneratorCfg): + raise TypeError( + "QuantizedTerrainGeneratorCfg.source must contain TerrainGeneratorCfg, " + f"got {type(self.source).__name__}" + ) + if self.levels < 2: + raise ValueError(f"QuantizedTerrainGeneratorCfg.levels must be at least 2, got {self.levels}") + self.source.validate() + + def generate(self, size: Vec2, shape: tuple[int, int]) -> np.ndarray: + heights = np.asarray(self.source.generate(size, shape), dtype=np.float32) + return (np.rint(heights * (self.levels - 1)) / (self.levels - 1)).astype(np.float32) + + +@configclass +class TerrainRegionCfg: + """A rectangular generator patch inside a composite terrain. + + ``center`` and ``size`` are fractions of the full field: center coordinates + in [0, 1] with (0, 0) at the low-index corner, sizes in (0, 1]. Index 0 + follows ``shape[0]`` rows (hfield x), index 1 follows ``shape[1]`` columns + (hfield y). + """ + + # Generator sampled over the region rectangle. + generator: TerrainGeneratorCfg + # Region center as a fraction of the full field. + center: Vec2 = (0.5, 0.5) + # Region extent as a fraction of the full field. + size: Vec2 = (1.0, 1.0) + # Blend margin as a fraction of the region extent per axis (0 = hard seam). + # Edges flush with the field boundary do not blend; gradients only appear on + # interior seams. + blend: float = 0.0 + + def validate(self) -> None: + if not isinstance(self.generator, TerrainGeneratorCfg): + raise TypeError( + f"TerrainRegionCfg.generator must contain TerrainGeneratorCfg, got {type(self.generator).__name__}" + ) + for name, vec in (("center", self.center), ("size", self.size)): + if len(vec) != 2 or any(not np.isfinite(value) for value in vec): + raise ValueError(f"TerrainRegionCfg.{name} must contain two finite values, got {vec!r}") + if any(not 0.0 <= value <= 1.0 for value in self.center): + raise ValueError(f"TerrainRegionCfg.center must lie within [0, 1], got {self.center!r}") + if any(not 0.0 < value <= 1.0 for value in self.size): + raise ValueError(f"TerrainRegionCfg.size must lie within (0, 1], got {self.size!r}") + if not np.isfinite(self.blend) or not 0.0 <= self.blend <= 0.5: + raise ValueError(f"TerrainRegionCfg.blend must be finite and within [0, 0.5], got {self.blend!r}") + self.generator.validate() + + +def _edge_ramp(count: int, flush_start: bool, flush_end: bool, width: float) -> np.ndarray: + """Linear 0→1 ramp from each non-flush region edge, 1 inside the core.""" + ramp = np.ones(count, dtype=np.float32) + if width > 0.0: + pos = np.arange(count, dtype=np.float32) + if not flush_start: + ramp = np.minimum(ramp, pos / width) + if not flush_end: + ramp = np.minimum(ramp, (count - 1 - pos) / width) + np.clip(ramp, 0.0, 1.0, out=ramp) + return ramp + + +def _check_peak(name: str, heights: np.ndarray, generator: TerrainGeneratorCfg, height_scale: float) -> None: + peak = float(np.max(heights)) * generator.height_scale + if peak > height_scale * (1.0 + _HEIGHT_EPS): + raise ValueError( + f"{name} peaks at {peak:.4f} m which exceeds its composite height_scale = " + f"{height_scale:.4f} m; increase the composite height_scale" + ) + + +@configclass +class CompositeTerrainGeneratorCfg(TerrainGeneratorCfg): + """Compose terrains: a full-field base with rectangular regions pasted on top. + + Regions are pasted in order; when regions overlap, later regions overwrite + earlier ones inside the blend core. Sub-generators are rescaled by + ``sub.height_scale / height_scale`` before pasting, so physical heights are + conserved (e.g. a stair ``step_height`` stays the same physical size). The + composite ``height_scale`` must cover every sub-generator's physical peak; + ``generate()`` raises otherwise since the compiler does not check [0, 1]. + """ + + # Generator filling the whole field first. + base: TerrainGeneratorCfg + # Regions pasted over the base in order. + regions: tuple[TerrainRegionCfg, ...] = () + + def validate(self) -> None: + super().validate() + if not isinstance(self.base, TerrainGeneratorCfg): + raise TypeError( + f"CompositeTerrainGeneratorCfg.base must contain TerrainGeneratorCfg, got {type(self.base).__name__}" + ) + self.base.validate() + for region in self.regions: + if not isinstance(region, TerrainRegionCfg): + raise TypeError( + f"CompositeTerrainGeneratorCfg.regions must contain TerrainRegionCfg, got {type(region).__name__}" + ) + region.validate() + + def generate(self, size: Vec2, shape: tuple[int, int]) -> np.ndarray: + heights = np.asarray(self.base.generate(size, shape), dtype=np.float32) + _check_peak("CompositeTerrainGeneratorCfg.base", heights, self.base, self.height_scale) + heights = heights * (self.base.height_scale / self.height_scale) + for index, region in enumerate(self.regions): + heights = self._paste_region(heights, region, index, size) + return heights + + def _paste_region(self, heights: np.ndarray, region: TerrainRegionCfg, index: int, size: Vec2) -> np.ndarray: + nrow, ncol = heights.shape + r0 = max(int(np.floor((region.center[0] - region.size[0] / 2.0) * nrow)), 0) + r1 = min(int(np.ceil((region.center[0] + region.size[0] / 2.0) * nrow)), nrow) + c0 = max(int(np.floor((region.center[1] - region.size[1] / 2.0) * ncol)), 0) + c1 = min(int(np.ceil((region.center[1] + region.size[1] / 2.0) * ncol)), ncol) + if r1 <= r0 or c1 <= c0: + return heights + region_shape = (r1 - r0, c1 - c0) + region_size = (region.size[0] * size[0], region.size[1] * size[1]) + sub = np.asarray(region.generator.generate(region_size, region_shape), dtype=np.float32) + _check_peak(f"CompositeTerrainGeneratorCfg.regions[{index}]", sub, region.generator, self.height_scale) + scaled = sub * (region.generator.height_scale / self.height_scale) + ramp_rows = _edge_ramp(region_shape[0], r0 == 0, r1 == nrow, region.blend * region_shape[0]) + ramp_cols = _edge_ramp(region_shape[1], c0 == 0, c1 == ncol, region.blend * region_shape[1]) + weight = ramp_rows[:, None] * ramp_cols[None, :] + patch = heights[r0:r1, c0:c1] + heights[r0:r1, c0:c1] = patch * (1.0 - weight) + scaled * weight + return heights + + +def grid_terrain( + cells: Sequence[Sequence[TerrainGeneratorCfg]], + *, + height_scale: float | None = None, + blend: float = 0.0, + base: TerrainGeneratorCfg | None = None, +) -> CompositeTerrainGeneratorCfg: + """Lay generators out on a rows x cols grid as a composite terrain. + + ``cells`` is a rectangular nested sequence (rows of columns). Each cell keeps + its own generator, so physical heights are conserved. ``height_scale`` + defaults to the largest sub-generator height_scale; ``blend`` is applied to + every cell boundary. Difficulty gradients are expressed by arranging the + cells; runtime difficulty switching is out of scope. + """ + rows = len(cells) + cols = len(cells[0]) if rows else 0 + if rows == 0 or cols == 0: + raise ValueError(f"grid_terrain requires a non-empty rectangular grid, got {rows}x{cols}") + if any(len(row) != cols for row in cells): + raise ValueError(f"grid_terrain requires every row to have {cols} cells") + scale = height_scale + if scale is None: + scale = max(generator.height_scale for row in cells for generator in row) + regions = tuple( + TerrainRegionCfg( + generator=generator, + center=((i + 0.5) / rows, (j + 0.5) / cols), + size=(1.0 / rows, 1.0 / cols), + blend=blend, + ) + for i, row in enumerate(cells) + for j, generator in enumerate(row) + ) + return CompositeTerrainGeneratorCfg( + base=base if base is not None else FlatTerrainGeneratorCfg(height_scale=scale), + regions=regions, + height_scale=scale, + ) diff --git a/motrix_envs/tests/test_scene_cfg.py b/motrix_envs/tests/test_scene_cfg.py index 6f3d910d..231c1a68 100644 --- a/motrix_envs/tests/test_scene_cfg.py +++ b/motrix_envs/tests/test_scene_cfg.py @@ -15,10 +15,13 @@ from motrix_env_core.config import configclass from motrix_env_core.config.scene import ( BodyCfg, + CompositeTerrainGeneratorCfg, ContactReportField, ContactSensorCfg, ContactSensorReduce, + DiscreteObstaclesTerrainGeneratorCfg, FlatTerrainCfg, + FlatTerrainGeneratorCfg, FrameObjectKind, FrameRefKind, FrameSensorCfg, @@ -32,6 +35,8 @@ ModelFileCfg, NoiseTerrainGeneratorCfg, ProceduralHFieldAssetCfg, + PyramidSlopeTerrainGeneratorCfg, + QuantizedTerrainGeneratorCfg, RobotCfg, SceneAssetCfg, SceneAssetsCfg, @@ -42,9 +47,12 @@ SceneSensorsCfg, SceneVisualCfg, SkyboxCfg, + StairsTerrainGeneratorCfg, SystemCameraCfg, TerrainGeneratorCfg, + TerrainRegionCfg, TextureCfg, + grid_terrain, validate_scene_cfg, ) from motrix_env_core.direct.env import DirectEnv, DirectEnvCfg @@ -496,7 +504,12 @@ class TerrainAssetsCfg(SceneAssetsCfg): ), ( ProceduralHFieldAssetCfg(generator=NoiseTerrainGeneratorCfg(height_scale=-0.1)), - "height_scale must be finite and non-negative", + "height_scale must be finite and positive", + ), + ( + # Generators divide by height_scale when normalizing, so zero is rejected. + ProceduralHFieldAssetCfg(generator=NoiseTerrainGeneratorCfg(height_scale=0.0)), + "height_scale must be finite and positive", ), ], ) @@ -534,6 +547,378 @@ class TerrainAssetsCfg(SceneAssetsCfg): build_scene_world(SceneCfg(assets=TerrainAssetsCfg())) +def test_stairs_terrain_runs_along_configured_axis(): + # axis semantics contract: "x" varies along shape[0] rows, "y" along shape[1] columns. + generator = StairsTerrainGeneratorCfg( + axis="x", + profile="ascending", + step_count=4, + step_height=0.05, + height_scale=0.15, + ) + + heights = generator.generate((8.0, 8.0), (8, 8)) + + assert np.all(heights == heights[:, :1]) + row_levels = heights[:, 0] + assert sorted(np.unique(row_levels)) == pytest.approx([0.0, 1.0 / 3, 2.0 / 3, 1.0]) + assert np.all(np.diff(row_levels) >= 0.0) + + transposed = StairsTerrainGeneratorCfg( + axis="y", + profile="ascending", + step_count=4, + step_height=0.05, + height_scale=0.15, + ).generate((8.0, 8.0), (8, 8)) + assert np.all(transposed == transposed[0:1, :]) + assert np.all(transposed[:, 0] == heights[0, :]) + + +def test_stairs_terrain_profiles(): + pyramid = StairsTerrainGeneratorCfg( + profile="pyramid", + step_count=8, + step_height=0.1, + height_scale=0.7, + ).generate((8.0, 8.0), (8, 8))[:, 0] + assert np.all(np.diff(pyramid[:4]) > 0.0) + assert np.all(np.diff(pyramid[4:]) < 0.0) + assert pyramid[0] == pytest.approx(0.0) + assert pyramid.max() == pyramid[3] + + pit = StairsTerrainGeneratorCfg( + profile="inverted_pyramid", + step_count=8, + step_height=0.1, + height_scale=0.7, + ).generate((8.0, 8.0), (8, 8))[:, 0] + # The central pit floor is the lowest point and the edges are the highest. + assert pit[3:5].min() == pit.min() + assert pit[0] == pit.max() + assert np.all(pit >= 0.0) + assert np.all(pit <= 1.0) + + +@pytest.mark.parametrize( + "generator", + [ + StairsTerrainGeneratorCfg(step_count=4, step_height=0.2, height_scale=0.5), + StairsTerrainGeneratorCfg(step_count=3), + DiscreteObstaclesTerrainGeneratorCfg(height=0.1, height_scale=0.1), + DiscreteObstaclesTerrainGeneratorCfg(size_min=0.3, size_max=0.2), + QuantizedTerrainGeneratorCfg(source=FlatTerrainGeneratorCfg(), levels=1), + TerrainRegionCfg(generator=FlatTerrainGeneratorCfg(), size=(1.5, 0.5)), + TerrainRegionCfg(generator=FlatTerrainGeneratorCfg(), blend=0.9), + ], +) +def test_terrain_generators_reject_invalid_configuration(generator): + with pytest.raises(ValueError): + generator.validate() + + +def test_stairs_terrain_lays_radial_rings(): + pit = StairsTerrainGeneratorCfg( + axis="radial", + profile="ascending", + step_count=8, + step_height=0.1, + height_scale=0.7, + ).generate((8.0, 8.0), (16, 16)) + + # Concentric rings: the center cell is the lowest, the edges are the highest. + assert pit[7, 7] == pit.min() + assert pit[0, 0] == pit.max() == pit[0, 15] == pit[15, 0] + # Symmetric under both axis flips. + assert np.all(pit == pit[::-1, :]) + assert np.all(pit == pit[:, ::-1]) + # Ring levels are multiples of the normalized step height. + assert sorted(np.unique(pit)) == pytest.approx([index * 0.1 / 0.7 for index in range(8)]) + + peak = StairsTerrainGeneratorCfg( + axis="radial", + profile="descending", + step_count=8, + step_height=0.1, + height_scale=0.7, + ).generate((8.0, 8.0), (16, 16)) + assert peak[7, 7] == peak.max() + assert np.all(peak + pit == pit.max()) + + +def test_quantized_terrain_snaps_heights_to_levels(): + generator = QuantizedTerrainGeneratorCfg( + source=NoiseTerrainGeneratorCfg(seed=11), + levels=5, + ) + + heights = generator.generate((8.0, 8.0), (16, 16)) + + allowed = {round(index / 4, 6) for index in range(5)} + snapped = {round(float(value), 6) for value in np.unique(heights)} + assert snapped <= allowed + assert len(snapped) > 1 + assert np.all(heights >= 0.0) + assert np.all(heights <= 1.0) + + +def test_discrete_obstacles_terrain_is_reproducible(): + generator = DiscreteObstaclesTerrainGeneratorCfg(seed=3, count=6, height=0.05, height_scale=0.2) + + heights = generator.generate((8.0, 8.0), (32, 32)) + replay = generator.generate((8.0, 8.0), (32, 32)) + + assert np.all(heights == replay) + # Flat base plus a single bump level at the signed obstacle height. + assert sorted(np.unique(heights)) == pytest.approx([0.5, 0.5 + 0.05 / 0.2]) + assert np.any(heights > 0.5) + + +def test_stairs_terrain_step_width_and_platform(): + generator = StairsTerrainGeneratorCfg( + axis="radial", + profile="descending", + step_count=8, + step_height=0.1, + step_width=0.5, + platform_width=2.0, + height_scale=1.0, + ) + + heights = generator.generate((8.0, 8.0), (64, 64)) + + # The central platform is flat at the top step level. + assert np.all(heights[28:36, 28:36] == heights[32, 32]) + assert np.isclose(heights[32, 32], 7 * 0.1 / 1.0) + # Rings descend outward with the configured tread width: mid-edge and corner + # cells sit strictly below the platform, and rings repeat every ~step_width. + assert heights[32, 4] < heights[32, 32] + assert heights[2, 2] < heights[32, 4] + assert np.all(heights >= 0.0) and np.all(heights <= 1.0) + + +def test_stairs_terrain_platform_rejects_negative_width(): + generator = StairsTerrainGeneratorCfg(step_count=4, step_height=0.05, height_scale=0.5, platform_width=-1.0) + + with pytest.raises(ValueError, match="platform_width"): + generator.validate() + + +def test_discrete_obstacles_terrain_choice_mode_and_platform(): + generator = DiscreteObstaclesTerrainGeneratorCfg( + seed=2, + count=20, + size_min=0.05, + size_max=0.1, + height=0.2, + height_scale=0.5, + height_mode="choice", + platform_width=2.0, + ) + + heights = generator.generate((8.0, 8.0), (80, 80)) + + # Choice mode draws from +/- full and +/- half of the obstacle height. + magnitude = 0.2 / 0.5 + unique = sorted(np.unique(np.round(heights, 4)).tolist()) + assert unique == pytest.approx( + sorted([0.5, 0.5 + magnitude, 0.5 + magnitude / 2, 0.5 - magnitude / 2, 0.5 - magnitude]) + ) + assert np.any(heights > 0.5 + magnitude / 4) + assert np.any(heights < 0.5 - magnitude / 4) + # The central platform stays clear at base level. + assert np.all(heights[38:42, 38:42] == 0.5) + + +def test_discrete_obstacles_terrain_rejects_unknown_height_mode(): + generator = DiscreteObstaclesTerrainGeneratorCfg(height_mode="random") + + with pytest.raises(ValueError, match="height_mode"): + generator.validate() + + +def test_pyramid_slope_terrain_rises_to_center(): + generator = PyramidSlopeTerrainGeneratorCfg(slope=0.25, height_scale=1.0) + + heights = generator.generate((8.0, 8.0), (64, 64)) + + # The peak sits at the center: slope * half extent = 0.25 * 4 m = 1 m. + # Cell centers sample slightly inside the boundary, so the max is just below 1. + assert heights.max() == pytest.approx(1.0, abs=0.02) + assert heights[32, 32] == heights.max() + assert heights[0, 0] == heights.min() + # Smooth monotonic rise from each edge midpoint to the center, then fall. + assert np.all(np.diff(heights[:32, 32]) >= -1e-6) + assert np.all(np.diff(heights[32:, 32]) <= 1e-6) + assert np.all(np.diff(heights[32, :32]) >= -1e-6) + assert np.all(np.diff(heights[32, 32:]) <= 1e-6) + + +def test_pyramid_slope_terrain_inverted_dips_at_center(): + generator = PyramidSlopeTerrainGeneratorCfg(slope=0.25, inverted=True, height_scale=1.0) + + heights = generator.generate((8.0, 8.0), (64, 64)) + + assert heights[32, 32] == heights.min() == pytest.approx(0.0) + # Corner cells sample 0.0625 m inside the boundary, so the rim tops out just below 1. + assert heights.max() == pytest.approx(1.0, abs=0.04) + + +def test_pyramid_slope_terrain_rejects_peak_above_height_scale(): + generator = PyramidSlopeTerrainGeneratorCfg(slope=0.5, height_scale=0.5) + + with pytest.raises(ValueError, match="height_scale"): + generator.generate((8.0, 8.0), (16, 16)) + + +def test_noise_terrain_downsampled_scale_smooths_speckle(): + def jaggedness(heights: np.ndarray) -> float: + return float(np.abs(np.diff(heights, axis=0)).mean() + np.abs(np.diff(heights, axis=1)).mean()) + + rough = NoiseTerrainGeneratorCfg(seed=3, height_scale=0.1) + smooth = NoiseTerrainGeneratorCfg(seed=3, height_scale=0.1, downsampled_scale=0.4) + + rough_heights = rough.generate((8.0, 8.0), (80, 80)) + smooth_heights = smooth.generate((8.0, 8.0), (80, 80)) + + assert jaggedness(smooth_heights) < jaggedness(rough_heights) + assert np.all(smooth_heights >= 0.0) and np.all(smooth_heights <= 1.0) + assert np.array_equal(smooth_heights, smooth.generate((8.0, 8.0), (80, 80))) + + +def test_noise_terrain_rejects_invalid_downsampled_scale(): + generator = NoiseTerrainGeneratorCfg(downsampled_scale=-0.1) + + with pytest.raises(ValueError, match="downsampled_scale"): + generator.validate() + + +def test_composite_terrain_conserves_physical_heights_and_stays_normalized(): + composite = CompositeTerrainGeneratorCfg( + base=FlatTerrainGeneratorCfg(height=0.25, height_scale=0.4), + height_scale=0.4, + regions=( + TerrainRegionCfg( + generator=StairsTerrainGeneratorCfg( + axis="x", + profile="ascending", + step_count=4, + step_height=0.05, + height_scale=0.15, + ), + center=(0.5, 0.75), + size=(1.0, 0.5), + blend=0.25, + ), + TerrainRegionCfg( + generator=FlatTerrainGeneratorCfg(height=1.0, height_scale=0.4), + center=(0.5, 0.75), + size=(0.25, 0.25), + ), + ), + ) + + heights = composite.generate((16.0, 16.0), (16, 16)) + + assert np.all(heights >= 0.0) + assert np.all(heights <= 1.0) + # Later regions overwrite earlier ones: the flat patch shows through at full + # height where it overlaps the stairs region. + assert heights[8, 11] == pytest.approx(1.0) + # Inside the stairs region (rows flush, right edge flush; the left edge is an + # interior seam) the physical step size is conserved after sub-generator rescaling. + core = np.unique(heights[6:10, 15]) + assert np.diff(core) * 0.4 == pytest.approx(0.05) + assert core[0] * 0.4 == pytest.approx(0.05) + # The blend margin mixes the flat base into the stairs across the interior seam + # (row 3 lies on stairs level 0, i.e. normalized height 0). + assert heights[3, 8] == pytest.approx(0.25) + assert heights[3, 9] == pytest.approx(0.5 * 0.25) + assert heights[3, 8] > heights[3, 9] > heights[3, 12] + + +def test_composite_terrain_rejects_sub_generator_above_height_scale(): + composite = CompositeTerrainGeneratorCfg( + base=FlatTerrainGeneratorCfg(), + height_scale=0.05, + regions=( + TerrainRegionCfg( + generator=NoiseTerrainGeneratorCfg(seed=1, height_scale=0.2), + center=(0.5, 0.5), + size=(0.5, 0.5), + ), + ), + ) + + with pytest.raises(ValueError, match="exceeds its composite height_scale"): + composite.generate((16.0, 16.0), (16, 16)) + + +def test_grid_terrain_lays_out_cells(): + composite = grid_terrain( + [ + [FlatTerrainGeneratorCfg(), StairsTerrainGeneratorCfg(step_count=3, step_height=0.05, height_scale=0.1)], + [StairsTerrainGeneratorCfg(step_count=3, step_height=0.05, height_scale=0.1), FlatTerrainGeneratorCfg()], + ], + blend=0.1, + ) + + assert isinstance(composite, CompositeTerrainGeneratorCfg) + assert len(composite.regions) == 4 + assert composite.height_scale == pytest.approx(0.1) + assert composite.regions[0].center == pytest.approx((0.25, 0.25)) + assert composite.regions[3].center == pytest.approx((0.75, 0.75)) + assert all(region.blend == pytest.approx(0.1) for region in composite.regions) + + heights = composite.generate((16.0, 16.0), (16, 16)) + assert np.all(heights >= 0.0) + assert np.all(heights <= 1.0) + + +def test_composite_terrain_supports_hydra_overrides(): + cfg = OmegaConf.structured( + ProceduralHFieldAssetCfg( + generator=grid_terrain( + [ + [ + FlatTerrainGeneratorCfg(), + StairsTerrainGeneratorCfg(step_count=8, step_height=0.01, height_scale=0.15), + ], + [ + StairsTerrainGeneratorCfg(step_count=8, step_height=0.01, height_scale=0.15), + FlatTerrainGeneratorCfg(), + ], + ] + ), + size=(4.0, 3.0), + shape=(8, 6), + ) + ) + + OmegaConf.update(cfg, "generator.height_scale", 0.2) + OmegaConf.update(cfg, "generator.regions.1.generator.step_height", 0.02) + OmegaConf.update(cfg, "generator.regions.1.blend", 0.2) + asset = OmegaConf.to_object(cfg) + + assert isinstance(asset.generator, CompositeTerrainGeneratorCfg) + assert asset.generator.height_scale == pytest.approx(0.2) + assert asset.generator.regions[1].generator.step_height == pytest.approx(0.02) + assert asset.generator.regions[1].blend == pytest.approx(0.2) + + @configclass + class TerrainAssetsCfg(SceneAssetsCfg): + terrain: ProceduralHFieldAssetCfg = asset + + world = build_scene_world(SceneCfg(assets=TerrainAssetsCfg())) + hfield = world.assets.hfields["terrain"] + assert hfield.nrow == 8 + assert hfield.ncol == 6 + assert hfield.height_scale == pytest.approx(0.2) + heights = hfield.source_type.value["hfield"].reshape(8, 6) + assert np.all(heights >= 0.0) + assert np.all(heights <= 1.0) + + @pytest.mark.parametrize( ("cfg", "expected_body", "expected_hfields", "solver_iterations", "solver_tolerance"), [