Skip to content

Commit e058d5f

Browse files
authored
Merge pull request #78 from makepath/issue-74
Add distance-based terrain LOD system
2 parents 851401d + fe10f9e commit e058d5f

10 files changed

Lines changed: 1101 additions & 7 deletions

File tree

docs/api-reference.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,57 @@ Print and return memory breakdown of the current scene.
350350

351351
---
352352

353+
## LOD Utilities
354+
355+
Level-of-detail helpers for terrain tiles and instanced geometry.
356+
357+
```python
358+
from rtxpy import compute_lod_level, compute_lod_distances, simplify_mesh, build_lod_chain
359+
```
360+
361+
#### `compute_lod_level(distance, lod_distances)`
362+
363+
Map a distance value to a discrete LOD level.
364+
365+
| Parameter | Type | Description |
366+
|-----------|------|-------------|
367+
| `distance` | float | Distance from camera to object/tile center |
368+
| `lod_distances` | list[float] | Ascending thresholds for LOD transitions |
369+
370+
**Returns:** `int` — LOD level (0 = highest detail)
371+
372+
#### `compute_lod_distances(tile_diagonal, factor=3.0, max_lod=3)`
373+
374+
Generate distance thresholds from tile geometry.
375+
376+
| Parameter | Type | Default | Description |
377+
|-----------|------|---------|-------------|
378+
| `tile_diagonal` | float | | Tile diagonal in world units |
379+
| `factor` | float | `3.0` | Base multiplier for first threshold |
380+
| `max_lod` | int | `3` | Number of LOD transitions |
381+
382+
**Returns:** `list[float]` — distance thresholds
383+
384+
#### `simplify_mesh(vertices, indices, ratio)`
385+
386+
Simplify a triangle mesh via quadric decimation (requires `trimesh`).
387+
388+
| Parameter | Type | Description |
389+
|-----------|------|-------------|
390+
| `vertices` | ndarray | Flat float32 vertex buffer `(N*3,)` |
391+
| `indices` | ndarray | Flat int32 index buffer `(M*3,)` |
392+
| `ratio` | float | Fraction of triangles to keep (0.0-1.0) |
393+
394+
**Returns:** `(vertices, indices)` — simplified mesh buffers
395+
396+
#### `build_lod_chain(vertices, indices, ratios=(1.0, 0.5, 0.25, 0.1))`
397+
398+
Build a list of progressively simplified meshes.
399+
400+
**Returns:** `list[(vertices, indices)]` — one pair per LOD level
401+
402+
---
403+
353404
## RTX Class
354405

355406
Low-level OptiX wrapper. Use this directly for custom ray tracing without the xarray accessor.

docs/user-guide.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ dem.rtx.explore(wind_data=wind) # Shift+W to toggle
461461
| **[** / **]** | Decrease / increase observer height |
462462
| **R** | Decrease terrain resolution (coarser) |
463463
| **Shift+R** | Increase terrain resolution (finer) |
464+
| **Shift+A** | Toggle distance-based terrain LOD |
464465
| **Z** | Decrease vertical exaggeration |
465466
| **Shift+Z** | Increase vertical exaggeration |
466467
| **B** | Toggle mesh type (TIN / voxel) |
@@ -537,6 +538,7 @@ write_stl('terrain.stl', verts, indices)
537538

538539
## Performance Tips
539540

541+
- **Enable terrain LOD**: Press `Shift+A` in the viewer to activate distance-based LOD. Nearby tiles render at full detail while distant tiles are automatically subsampled, cutting triangle count without visible quality loss
540542
- **Subsample large DEMs**: `dem[::2, ::2]` or `explore(subsample=4)` — 4x subsample is 16x less geometry
541543
- **Lower render_scale**: `explore(render_scale=0.25)` renders at quarter resolution for faster interaction
542544
- **Cache meshes**: Use `mesh_cache` parameter in `place_buildings()`, `place_roads()`, etc. to skip GeoJSON parsing on reload

examples/terrain_lod_demo.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Terrain LOD demo — distance-based level of detail for large terrains.
2+
3+
Generates a synthetic 2048x2048 terrain and launches the interactive
4+
viewer. Press Shift+A to toggle terrain LOD, which splits the terrain
5+
into tiles and assigns each tile a resolution based on camera distance.
6+
7+
Controls:
8+
Shift+A Toggle terrain LOD on/off
9+
R / Shift+R Manual resolution down / up (applies globally)
10+
Z / Shift+Z Vertical exaggeration
11+
WASD / arrows Move camera
12+
13+
The LOD system is most useful with large terrains where full-resolution
14+
rendering everywhere would exceed GPU memory or hurt frame rate. Nearby
15+
tiles render at full detail while distant tiles are progressively
16+
subsampled (2x, 4x, 8x).
17+
18+
Usage:
19+
python terrain_lod_demo.py
20+
python terrain_lod_demo.py --size 4096
21+
"""
22+
23+
import argparse
24+
25+
import numpy as np
26+
import xarray as xr
27+
28+
import rtxpy # noqa: F401 — registers .rtx accessor
29+
30+
31+
def make_terrain(size, seed=42):
32+
"""Generate a synthetic island terrain using multi-octave Perlin noise.
33+
34+
Falls back to simple sine-based terrain if xarray-spatial is not
35+
installed.
36+
"""
37+
try:
38+
import cupy as cp
39+
from xrspatial import generate_terrain
40+
41+
template = xr.DataArray(
42+
cp.zeros((size, size), dtype=cp.float32), dims=['y', 'x'],
43+
)
44+
terrain = generate_terrain(
45+
template,
46+
x_range=(-5000, 5000),
47+
y_range=(-5000, 5000),
48+
seed=seed,
49+
zfactor=3000,
50+
full_extent=(-5000, -5000, 5000, 5000),
51+
noise_mode='ridged',
52+
warp_strength=0.3,
53+
octaves=5,
54+
)
55+
# Make it an island
56+
sea_level = float(cp.nanmedian(terrain.data)) * 0.8
57+
terrain.data[:] = cp.maximum(terrain.data - sea_level, 0)
58+
return terrain
59+
except ImportError:
60+
pass
61+
62+
# Fallback: sin/cos-based terrain (no xarray-spatial needed)
63+
rng = np.random.default_rng(seed)
64+
y = np.linspace(0, 4 * np.pi, size, dtype=np.float32)
65+
x = np.linspace(0, 4 * np.pi, size, dtype=np.float32)
66+
yy, xx = np.meshgrid(y, x, indexing='ij')
67+
z = (np.sin(xx) * np.cos(yy * 0.7) * 500
68+
+ np.sin(xx * 2.3 + 1) * np.cos(yy * 1.7 + 0.5) * 200
69+
+ rng.normal(0, 10, (size, size)).astype(np.float32))
70+
z = np.maximum(z, 0)
71+
return xr.DataArray(z, dims=['y', 'x'])
72+
73+
74+
if __name__ == "__main__":
75+
parser = argparse.ArgumentParser(
76+
description="Terrain LOD demo — press Shift+A in viewer to toggle",
77+
)
78+
parser.add_argument("--size", type=int, default=2048,
79+
help="Grid size in pixels (default: 2048)")
80+
parser.add_argument("--seed", type=int, default=42,
81+
help="Random seed (default: 42)")
82+
args = parser.parse_args()
83+
84+
print(f"Generating {args.size}x{args.size} terrain...")
85+
terrain = make_terrain(args.size, seed=args.seed)
86+
87+
elev = terrain.data
88+
if hasattr(elev, 'get'):
89+
elev = elev.get()
90+
print(f"Elevation range: {np.nanmin(elev):.0f} - {np.nanmax(elev):.0f} m")
91+
print(f"\nPress Shift+A to toggle terrain LOD")
92+
print(f"Press H for help overlay\n")
93+
94+
terrain.rtx.explore(
95+
width=1920,
96+
height=1080,
97+
render_scale=0.5,
98+
subsample=1,
99+
title="Terrain LOD Demo",
100+
)

rtxpy/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323
load_meshes_from_zarr,
2424
chunks_for_pixel_window,
2525
)
26+
from .lod import (
27+
compute_lod_level,
28+
compute_lod_distances,
29+
simplify_mesh,
30+
build_lod_chain,
31+
)
2632
from .analysis import viewshed, hillshade, render, flyover, view
2733
from .engine import explore
2834

0 commit comments

Comments
 (0)