Source code for mjlab.terrains.terrain_generator

from __future__ import annotations

import abc
import time
from dataclasses import dataclass, field
from typing import Literal

import mujoco
import numpy as np

from mjlab.terrains.utils import make_border
from mjlab.utils.color import RGBA

_DARK_GRAY = (0.2, 0.2, 0.2, 1.0)


@dataclass
class TerrainGeometry:
  geom: mujoco.MjsGeom | None = None
  hfield: mujoco.MjsHField | None = None
  color: tuple[float, float, float, float] | None = None


@dataclass
class TerrainOutput:
  origin: np.ndarray
  geometries: list[TerrainGeometry]


[docs] @dataclass class SubTerrainCfg(abc.ABC): proportion: float = 1.0 size: tuple[float, float] = (10.0, 10.0)
[docs] @abc.abstractmethod def function( self, difficulty: float, spec: mujoco.MjSpec, rng: np.random.Generator ) -> TerrainOutput: """Generate terrain geometry. Returns: TerrainOutput containing spawn origin and list of geometries. """ raise NotImplementedError
[docs] @dataclass(kw_only=True) class TerrainGeneratorCfg: seed: int | None = None curriculum: bool = False size: tuple[float, float] border_width: float = 0.0 border_height: float = 1.0 num_rows: int = 1 num_cols: int = 1 color_scheme: Literal["height", "random", "none"] = "height" sub_terrains: dict[str, SubTerrainCfg] = field(default_factory=dict) difficulty_range: tuple[float, float] = (0.0, 1.0) add_lights: bool = False
[docs] class TerrainGenerator: """Generates procedural terrain grids with configurable difficulty. Creates a grid of terrain patches where each patch can be a different terrain type. Supports two modes: random (patches get random difficulty) or curriculum (difficulty increases along rows for progressive training). Terrain types are weighted by proportion and their geometry is generated based on a difficulty value in the configured range. The grid is centered at the world origin. A border can be added around the entire grid along with optional overhead lighting. """
[docs] def __init__(self, cfg: TerrainGeneratorCfg, device: str = "cpu") -> None: if len(cfg.sub_terrains) == 0: raise ValueError("At least one sub_terrain must be specified.") self.cfg = cfg self.device = device for sub_cfg in self.cfg.sub_terrains.values(): sub_cfg.size = self.cfg.size if self.cfg.seed is not None: seed = self.cfg.seed else: seed = np.random.randint(0, 10000) self.np_rng = np.random.default_rng(seed) self.terrain_origins = np.zeros((self.cfg.num_rows, self.cfg.num_cols, 3))
[docs] def compile(self, spec: mujoco.MjSpec) -> None: body = spec.worldbody.add_body(name="terrain") if self.cfg.curriculum: tic = time.perf_counter() self._generate_curriculum_terrains(spec) toc = time.perf_counter() print(f"Curriculum terrain generation took {toc - tic:.4f} seconds.") else: tic = time.perf_counter() self._generate_random_terrains(spec) toc = time.perf_counter() print(f"Terrain generation took {toc - tic:.4f} seconds.") self._add_terrain_border(spec) self._add_grid_lights(spec) counter = 0 for geom in body.geoms: geom.name = f"terrain_{counter}" counter += 1
def _generate_random_terrains(self, spec: mujoco.MjSpec) -> None: # Normalize the proportions of the sub-terrains. proportions = np.array( [sub_cfg.proportion for sub_cfg in self.cfg.sub_terrains.values()] ) proportions /= np.sum(proportions) sub_terrains_cfgs = list(self.cfg.sub_terrains.values()) # Randomly sample and place sub-terrains in the grid. for index in range(self.cfg.num_rows * self.cfg.num_cols): sub_row, sub_col = np.unravel_index(index, (self.cfg.num_rows, self.cfg.num_cols)) sub_row = int(sub_row) sub_col = int(sub_col) # Randomly select a sub-terrain type and difficulty. sub_index = self.np_rng.choice(len(proportions), p=proportions) difficulty = self.np_rng.uniform(*self.cfg.difficulty_range) # Calculate the world position for this sub-terrain. world_position = self._get_sub_terrain_position(sub_row, sub_col) # Create the terrain mesh and get the spawn origin in world coordinates. spawn_origin = self._create_terrain_geom( spec, world_position, difficulty, sub_terrains_cfgs[sub_index], ) # Store the spawn origin for this terrain. self.terrain_origins[sub_row, sub_col] = spawn_origin def _generate_curriculum_terrains(self, spec: mujoco.MjSpec) -> None: # Normalize the proportions of the sub-terrains. proportions = np.array( [sub_cfg.proportion for sub_cfg in self.cfg.sub_terrains.values()] ) proportions /= np.sum(proportions) sub_indices = [] for index in range(self.cfg.num_cols): sub_index = np.min( np.where(index / self.cfg.num_cols + 0.001 < np.cumsum(proportions))[0] ) sub_indices.append(sub_index) sub_indices = np.array(sub_indices, dtype=np.int32) sub_terrains_cfgs = list(self.cfg.sub_terrains.values()) for sub_col in range(self.cfg.num_cols): for sub_row in range(self.cfg.num_rows): lower, upper = self.cfg.difficulty_range difficulty = (sub_row + self.np_rng.uniform()) / self.cfg.num_rows difficulty = lower + (upper - lower) * difficulty world_position = self._get_sub_terrain_position(sub_row, sub_col) spawn_origin = self._create_terrain_geom( spec, world_position, difficulty, sub_terrains_cfgs[sub_indices[sub_col]] ) self.terrain_origins[sub_row, sub_col] = spawn_origin def _get_sub_terrain_position(self, row: int, col: int) -> np.ndarray: """Get the world position for a sub-terrain at the given grid indices. This returns the position of the sub-terrain's corner (not center). The entire grid is centered at the world origin. """ # Calculate position relative to grid corner. rel_x = row * self.cfg.size[0] rel_y = col * self.cfg.size[1] # Offset to center the entire grid at world origin. grid_offset_x = -self.cfg.num_rows * self.cfg.size[0] * 0.5 grid_offset_y = -self.cfg.num_cols * self.cfg.size[1] * 0.5 return np.array([grid_offset_x + rel_x, grid_offset_y + rel_y, 0.0]) def _create_terrain_geom( self, spec: mujoco.MjSpec, world_position: np.ndarray, difficulty: float, cfg: SubTerrainCfg, ) -> np.ndarray: """Create a terrain geometry at the specified world position. Args: spec: MuJoCo spec to add geometry to. world_position: World position of the terrain's corner. difficulty: Difficulty parameter for terrain generation. cfg: Sub-terrain configuration. Returns: The spawn origin in world coordinates. """ output = cfg.function(difficulty, spec, self.np_rng) for terrain_geom in output.geometries: if terrain_geom.geom is not None: terrain_geom.geom.pos = np.array(terrain_geom.geom.pos) + world_position if terrain_geom.geom.material is not None: if self.cfg.color_scheme == "height" and terrain_geom.color: terrain_geom.geom.rgba[:] = terrain_geom.color elif self.cfg.color_scheme == "random": terrain_geom.geom.rgba[:3] = self.np_rng.uniform(0.3, 0.8, 3) terrain_geom.geom.rgba[3] = 1.0 elif self.cfg.color_scheme == "none": terrain_geom.geom.rgba[:] = (0.5, 0.5, 0.5, 1.0) return output.origin + world_position def _add_terrain_border(self, spec: mujoco.MjSpec) -> None: body = spec.body("terrain") border_size = ( self.cfg.num_rows * self.cfg.size[0] + 2 * self.cfg.border_width, self.cfg.num_cols * self.cfg.size[1] + 2 * self.cfg.border_width, ) inner_size = ( self.cfg.num_rows * self.cfg.size[0], self.cfg.num_cols * self.cfg.size[1], ) # Border should be centered at origin since the terrain grid is centered. border_center = (0, 0, -self.cfg.border_height / 2) boxes = make_border( body, border_size, inner_size, height=abs(self.cfg.border_height), position=border_center, ) for box in boxes: if self.cfg.color_scheme == "random": box.rgba = RGBA.random(self.np_rng, alpha=1.0) else: box.rgba = _DARK_GRAY def _add_grid_lights(self, spec: mujoco.MjSpec) -> None: if not self.cfg.add_lights: return total_width = self.cfg.size[0] * self.cfg.num_rows total_height = self.cfg.size[1] * self.cfg.num_cols light_height = max(total_width, total_height) * 0.6 spec.body("terrain").add_light( pos=(0, 0, light_height), type=mujoco.mjtLightType.mjLIGHT_DIRECTIONAL, dir=(0, 0, -1), )