Real Terrain Assets
srb.assets.scenery.RealTerrain is the entry point for using real Lunar /
Martian terrain — actual Digital Elevation Models (DEMs) from NASA/USGS/ESA
surveys, baked into simulation-ready meshes — in place of procedurally
generated surfaces. Use it when you need geographically accurate, photorealistic
scenes. This guide covers the runtime classes; for adding new DEM sources and
the offline DEM → mesh → USD bake pipeline, see
Adding Terrain Sites.
Basic usage
from srb.assets.scenery import RealTerrain
from srb.assets.scenery.real_terrain_sites import LunarSiteApollo17, MartianSiteJezero
# Generic constructor — body + site name from manifest.
terrain = RealTerrain(body="moon", site="apollo17")
# Or use the per-site shortcut class (auto-generated from manifest).
terrain = LunarSiteApollo17()
# Or an arbitrary lat/lon region — no manifest entry at all (v2 stacks;
# see the terrain-stacks guide for identity/normalization/fallback rules).
terrain = RealTerrain(body="moon", lat_deg=-89.66, lon_deg=0.0, size_m=2000.0)
# Returns an Isaac Lab AssetBaseCfg you can attach to a scene.
env_cfg.scene.terrain = terrain.asset_cfg
Companion assets
Each RealTerrain exposes optional sky / lighting / rocks companions, all
configured through the manifest with per-instance overrides:
# Use everything the manifest specifies for this site (recommended).
terrain = LunarSiteApollo17()
terrain.attach_companions(env_cfg)
# → env_cfg.scene now has skydome, lighting, rocks_00, ... attached.
# Override individual companions.
terrain = LunarSiteApollo17(
sky="apollo17_pan", # explicit catalog name
lighting="auto", # use manifest default (default value)
rocks=None, # disable rocks
)
The env path now attaches companions automatically (OI-1, terrain v2
Phase 4): when env.scenery resolves to a RealTerrain with
attach_companions_enabled=True (the default), BaseEnvCfg._add_scenery
calls attach_companions itself — arbitrated against the env.ephemeris
sun/skydome drives, with an all-or-nothing warn-once degrade when companion
products cannot be built (the shipped sites’ companion catalog entries are
placeholders until operator action O3, so today every curated site degrades
and renders as before). Set attach_companions_enabled=False to opt a task
out. The precedence table and the rendered-output caveat live in
Terrain Stacks → Companions and the ephemeris sun.
Ephemeris-driven lighting
A site whose manifest sets lighting: ephemeris (plus coords + a curated
epoch) bakes its Sun / Earthshine lights from real SPICE ephemeris at that
epoch rather than a static preset, via
srb/terrain/lights/ephemeris_bridge.py. The bridge runs one query(), folds
the terrain’s persisted georef.north_convergence_deg into the sun azimuth
(az_grid = az_true + γ), and writes an epoch-keyed, content-hashed
lights.usd (.../srb_lights/ephemeris/<body>_<site>_<epoch>_<hash8>/) so a
changed epoch or grid-north correction yields a fresh file. With the pinned NAIF
kernels absent it warns once and falls back to the body-default static preset
(air-gapped-safe); srb ephemeris download enables the real path. See
Ephemeris → Terrain lighting for the
manifest fields, the offline fallback, and the srb asset list provenance
nuance (listed azimuth is raw true-north; the bake folds +γ).
SimForge augmentation
For per-env variation (rocks scattered with a different seed per env), pass
augment="simforge":
terrain = RealTerrain(
body="moon", site="apollo17", augment="simforge",
)
# Baked terrain is the static backbone; SimForge generates rocks on top.
# Augment params come from `augment_preset="lunar_default"` (default).
augment="simforge" requires simforge_foundry to be importable; otherwise
raises SimforgeMissingError.
User-supplied DEMs
Skip the manifest entirely and bake a local DEM:
terrain = RealTerrain(
body="moon",
dem_path="/data/my_lunar_dem.tif",
site_name="my_polar_crater", # cache key
attribution="Acme Surveying, 2026",
patch_size_m=300,
use_ortho=False,
)
attribution is REQUIRED for user-supplied DEMs — license compliance.
site, dem_path, and the region triplet (lat_deg/lon_deg/size_m)
are mutually exclusive selection modes — exactly one must be given, and
setting two (including site + dem_path together, which used to slip
through) is rejected at the spec boundary. Note the per-site shortcut
classes bake site in as a class default, so
LunarSiteApollo17(dem_path=...) is a both-set error unless you also
explicitly clear site=None — for a local DEM, plain
RealTerrain(body=..., dem_path=...) is the clearer spelling.
CLI workflow
# Discover available sites + presets.
srb asset list terrain
srb asset list terrain --body moon
srb asset list rocks
srb asset list hdri
srb asset list lights
srb asset info terrain apollo17
# Pre-bake explicitly (otherwise it bakes lazily on first use).
srb asset download terrain apollo17
srb asset download terrain --all --body moon
# Arbitrary region (v2 stacks; no manifest site — mutually exclusive with a
# positional target/--all and the site-only flags):
srb asset download terrain --lat -89.66 --lon 0.0 --size-m 2000 \
--body moon --deshade
srb asset bake terrain --dem /data/my.tif --body moon --site-name my_local \
--attribution "Me, 2026"
# Manage the cache.
srb asset cache-stats
srb asset clean terrain apollo17 --baked
srb asset clean terrain --all
srb asset validate-manifest
# CI: warm everything at lod=low.
srb asset prefetch-fixtures
Real-terrain v2: source catalog and stack resolution
A per-body source catalog (srb/terrain/catalog/<body>.yaml, schema in
srb/terrain/catalog_schema.py) and a resolver
(srb.terrain.catalog.resolve.resolve_stacks) exist alongside the v1
manifest described above. Given a location, the resolver turns the catalog
into a CompositionPlan: a prioritized DEM stack, plus sunlit-color,
PSR-color, mask, and quality stacks, each layer’s estimated fetch bytes, and
which layers would need large-download consent. The v2 resolver and bake path
are live. The srb asset stack command is only a read-only preview: it never
touches the network or moves a byte.
srb asset stack --body moon --lat -89.46 --lon 137.3 --size 2000
srb asset stack --body moon --lat -89.46 --lon 137.3 --size 2000 --json
The per-body source catalogs now carry real, curated data — but coverage
is narrow. srb/terrain/catalog/moon.yaml carries 19 real, sha256-pinned
layers; mars.yaml and earth.yaml carry 2 each (one HiRISE DTM+ortho pair
for Jezero; one Copernicus polar tile and one 3DEP non-polar tile). For
access: cog_remote remains a catalog compatibility label. Production first
downloads the complete source through SRB’s redirect-validating, size-bounded
provider, verifies the pinned SHA-256, then reads local raster windows. Direct
GDAL /vsicurl network access is refused because it cannot enforce SRB’s
per-hop destination-host policy. First use therefore needs full-source storage
and large-download consent where applicable; content-addressed reuse avoids a
second download. Each contributing layer records bytes_verified: true in its
meta.json fusion provenance. Curated
layer data (a real, downloaded-once sha256 per file, a reviewed footprint,
an assigned priority_class) is added by a human, offline (operator action
O3; see
Adding a v2 source layer) —
this is an ongoing, incremental process, not a one-shot completed catalog.
A request whose footprint falls outside every curated layer still resolves
to an empty plan, and srb asset stack prints the same v1-fallback line
RealTerrain itself falls back to:
no v2 source coverage for <body> at (<lat>, <lon>) — falling back to the v1 single-source path.
See Terrain Stacks → Catalog status per body
for the exact curated layer list.
Wired for both request forms. A srb/terrain/manifest.yaml site whose
sources declares a dem_stack (and, optionally, a color_stack) under a
version: 2 manifest bakes end to end through the exact same
RealTerrain(body=..., site=...) / srb asset download terrain <site> /
srb asset bake calls a v1 site uses — bake_or_load recognizes it
automatically (sources.dem is None on a v2 site) and runs it through the
fusion engine described below. See
Adding a v2 site with a declared stack
for the full walkthrough. An arbitrary region —
RealTerrain(body="moon", lat_deg=..., lon_deg=..., size_m=...) with no
manifest site/patch at all, or the matching
srb asset download terrain --lat --lon --size-m CLI form — resolves its
stacks by footprint intersection and bakes through the same path, with a
deterministic slug identity, degrade-to-procedural in envs, and an
illumination-correction (deshade) knob. That request path has its own
operator guide:
Terrain Stacks — identity/normalization
rules, the body-aware (polar canonical / non-polar local-stereographic)
resolution CRS, deshade precedence and cache-keying, and the
companion/ephemeris arbitration table all live there.
Consent — read this before trusting the preview. srb asset stack’s
“consent required” line is a descriptive preview
(CompositionPlan.consented, an OR of --allow-large and
SRB_TERRAIN_LARGE_OK) — it is not what actually protects a fetch. The real,
non-bypassable gate (check_layer_consent / layer_max_bytes in
srb/terrain/sources/acquisition.py, an AND of the catalog layer’s own
allow_large_download flag and SRB_TERRAIN_LARGE_OK) lives on the fetch
path and re-derives consent independently, per layer — it never reads the
preview’s consented field. Seeing consent required: none in
srb asset stack’s output is not authorization to fetch anything; nothing in
Phase 2 fetches automatically either way (no code path calls input() or
prompts — consent is env-var/flag only, never interactive).
Real-terrain v2: the fusion engine
A v2 site’s dem_stack names one role: base layer (gap-free, covers the
whole patch) plus zero or more role: inset layers (finer, partial
coverage); color_stack is the same shape for colour. srb/terrain/fusion/
composes them into one DEM and one colour raster:
- Every contributing layer is warped onto the patch’s own grid
(
fusion.grid.warp_to_grid). - Each DEM inset is co-registered against the base (published catalog shift, or an estimated one when the catalog records none) and demoted — dropped, with a warning — if the QC residual is too large.
- A feathered priority blend composites the DEM stack: the finest/
highest-priority inset wins its own interior outright, feathering to the
next layer only within a narrow band (
blend_len_m, clamped to 50–200 m) of its own true footprint boundary — never a global weighted average, and never the layer’s declared footprint, only where it actually has data. - Colour is composited the same way (first-finite-wins over the sorted stack) and radiometrically normalized against the stack’s anchor layer.
What a fused bake adds to the artifact, alongside the same
terrain.usd / textures a v1 bake produces:
provenance.tif— a single-banduint8GeoTIFF the same size as the fused DEM:0means no layer contributed there,1..Nis the 1-based index of the layer that won that pixel (matchingmeta["fusion"]’s own per-layerindex).meta.json["fusion"]— a new top-level block (v1 bakes never carry it;meta["georef"]is unrelated and unchanged in shape either way):fusion_version,grid(crs,center_xy_m,size_m,gsd_m,pixels),dem_layers/color_layers(each entry:layer_id,role,kind,index,area_fraction,native_gsd_m,upsampled,radiometry,alignment,license,attribution),contributors(loaded mask inputs that affected colour selection but own no raster index),provenance_tif/uncertainty_tif(filenames ornull), andwarnings.meta.json["bake_identity"]— the versioned cache identity for a fused generation: cache key, canonical DEM and colour/auxiliary stack hashes, fusion version, target GSD, and effective deshade state. A fused generation without this record is not ready.ATTRIBUTION.txt— onelayer_id/license/attributionblock per contributing layer (base, every surviving inset, and loaded mask contributor), in blend order — the v1 form (a singleattribution + "\n"string) only ever applies to a v1 (single-source) bake.
Budgets — v2 only; v1 is unchanged forever. A v1 bake keeps its
stride-based LOD mesh and full-resolution, uncapped textures exactly as
before. A v2 (fused) bake additionally quadric-decimates the mesh toward a
per-LOD face budget and caps texture/AO resolution
(srb/terrain/fusion/budgets.py):
| LOD | Max mesh faces (grid) | Max texture side (px) |
|---|---|---|
high | 4,000,000 | 8192 |
medium | 1,000,000 | 4096 |
low | 250,000 | 2048 |
Ambient occlusion is computed at ≤2048 px regardless of LOD, then upsampled. The face budget applies to the decimated grid; the (never decimated) skirt and solid-shell geometry appended for collision adds a small, perimeter- scaling number of faces on top — the shipped mesh can land a little over the nominal number for that reason, never because the budget failed to bite.
This path is proven against real data for region requests; no declared-
stack manifest site uses it yet. The fusion engine, the v2 request
plumbing, and the budgets are all real, tested, and reachable both offline
(a hand-built manifest + fixture catalog exercises the whole path; see
tests/terrain/integration/test_fusion_end_to_end.py) and, since the
SPICE/Terrain remaining-work plan, against real network products: four real
region bakes (moon polar, mars Jezero, earth polar, earth non-polar)
complete end to end through this exact fusion path,
area_fraction=1.0 on every base layer (see
Terrain Stacks → Catalog status per body
and docs/research/real_terrain_v2/). What remains not done: manifest.yaml
is still version: 1 — no site declares a dem_stack/color_stack against
the real catalog (see
Adding a v2 site with a declared stack —
its connecting_ridge example is still illustrative, using layer ids that
don’t exist in the shipped catalog), so a declared-stack v2 site remains
untried; only the arbitrary-region request form has been exercised against
real data.
Caching and reproducibility
Bakes are content-addressed by (body, site, patch, lod, seed, materials_version, dem_sha256, ...). The same inputs always produce the
same output bytes; changing any input (e.g., bumping materials_version)
invalidates the bake.
Cache root: assets/srb_assets/scenery/terrain/<body>/<site>/<patch_key>/.
Override via SRB_TERRAIN_CACHE_ROOT=/custom/path.
Disabling auto-fetch
By default, missing bakes are produced on first use. To require an explicit pre-bake step (e.g., air-gapped CI):
export SRB_TERRAIN_AUTO_FETCH=0
RealTerrain(...) will then raise TerrainNotBakedError with the exact
srb asset download command to run.
Troubleshooting
| Symptom | Likely cause | Remediation |
|---|---|---|
ManifestSiteUnknownError | typo in site= | srb asset list terrain |
LicenseMissingError | user DEM without attribution | pass attribution="..." |
ChecksumMismatchError | upstream DEM changed | srb asset validate-manifest --check-shas |
SimforgeMissingError | augment="simforge" w/o package | pip install simforge_foundry |
TerrainNotBakedError | auto_fetch=False + cache miss | run the srb asset download shown |