Рельеф и мир
FR-17…FR-20, VR-3, VR-4, VR-0, NFR-1, NFR-2. Исследование источников — research/terrain_sources.md.
Что где
| Файл | Что делает |
|---|---|
scripts/terrain/terrain.gd (Terrain, группа "terrain") | нода рельефа: загрузка локации, контракт height_at / normal_at / get_start_sites / sun_exposure_at, surface_at / forest_at / get_forest_mask / thermal_source_strength_at / get_landing_sites |
scripts/terrain/surface_layer.gd (SurfaceLayer) | карта поверхности (классы земного покрова) на сетке, class_at, текстура R8; маска «деталь 10 м» (forest_at, mask_r) |
scripts/terrain/surface_classifier.gd (SurfaceClassifier) | запасная процедурная карта (нет сети) по высоте/уклону/экспозиции/шуму |
scripts/terrain/cog_reader.gd (CogReader), worldcover_loader.gd (WorldCoverLoader) | разбор COG GeoTIFF и рантайм-загрузка WorldCover HTTP range-запросами, кеш user://terrain_cache/worldcover |
scripts/terrain/tree_placer.gd (TreePlacer), terrain_tree_models.gd (TerrainTreeModels), tree_model.gdshader | деревья-модели 5 пород × 3 LOD (MultiMesh), расстановка в рабочем потоке, качание от ветра |
scripts/terrain/forest_impostors.gd + .gdshader (ForestImpostors) | средний план леса 0,35–3,5 км: билборды из атласа импостеров |
scripts/terrain/rock_scatter.gd + .gdshader (RockScatter) | 3D-камни вокруг камеры (≤ 300 м): глыбы курумника и выходы породы по той же логике, что пятна породы в шейдере, камни на скалах, редкие в траве; не на лесе/воде/полях/просеках/стартах; configs/world.json → rocks, подключение RockScatter.attach(terrain, cam) |
scripts/terrain/grass_field.gd + grass.gdshader (GrassField) | травинки вокруг камеры — см. docs/guide/vegetation.md |
scripts/terrain/terrain_wind.gd + terrain_wind.gdshaderinc (TerrainWind) | ветер и термики атмосферы → шейдеры рельефа, травы, деревьев (VR-17) |
scripts/terrain/terrain_relief.gd (TerrainRelief) | поля рельефа из DEM любой локации: влажность (сток D8 + вогнутость), сглаженная экспозиция, AO по горизонтам, горизонт к солнцу (тень рельефа) |
scripts/terrain/height_layer.gd (HeightLayer) | сетка высот, билинейная выборка (RefCounted, тестируется headless) |
scripts/terrain/geo.gd (TerrainGeo) | lat/lon ↔ мир, направление солнца, вектор курса |
scripts/terrain/terrain_renderer.gd (TerrainRenderer) | чанки + LOD, материалы, текстуры поверхностей |
scripts/terrain/terrain.gdshader + terrain_common.gdshaderinc | высоты в вершинном шейдере, раскраска (трава, лес, поля, скалы, снег, реки) |
scripts/terrain/terrain_trees.gd + trees.gdshader (TerrainTrees) | запасные процедурные кроны (если нет моделей) |
scripts/terrain/terrarium_loader.gd (TerrariumLoader) | рантайм-загрузка рельефа по lat/lon (FR-17), кеш user://terrain_cache |
scripts/terrain/map_picker.gd + map_hillshade.gdshader (MapPicker) | карта выбора точки: отмывка из Terrarium, щелчок / ввод координат → point_picked(lat, lon) |
scripts/world/sky_environment.gd (SkyEnvironment), haze.gdshader, scenes/world/environment.tscn | небо, солнце с мягкими тенями, голубая дымка, мутный слой под инверсией (VR-3), glow |
scenes/terrain/terrain_preview.tscn, map_picker_preview.tscn | отдельный запуск модуля |
tools/terrain/fetch_dem.py, rivers.py, fetch_landcover.py, cog.py | подготовка данных встроенной локации (высоты, реки, карта поверхности) |
configs/locations/<id>.json | локация: центр, слои DEM, LOD, старты, посадки, реки, карта поверхности, переопределения вида и пород деревьев |
configs/world.json | солнце, небо, дымка, эффекты, вид рельефа, деревья, текстуры, рантайм-загрузка, карта |
Как устроено
Данные. Локация — несколько вложенных квадратных слоёв высот (детальный 40 км / 25 м из Copernicus GLO-30,
фон 160 км / 100 м из Terrarium). Файл слоя — float32 LE, brotli (<слой>.f32.br), строки с севера на юг.
Узлы слоёв выровнены, грубый слой в зоне детального содержит его же высоты → на стыке нет ступеньки.
Загрузка «Алтая» — 0.2–0.3 с (NFR-2: ≤ 10 с): распаковка + to_float32_array() + текстура FORMAT_RF.
height_at — билинейная интерполяция самого детального слоя, покрывающего точку; за краем — край грубого.
≈1.6 мкс на вызов (10 000 вызовов — 16 мс). В узлах совпадает с данными до 1 см (тест).
normal_at — центральные разности с шагом сетки. sun_exposure_at = clamp(dot(normal, sun_dir), 0, 1),
солнце — configs/world.json → sun (азимут/высота). Ровная площадка при солнце 52° → 0.79.
Старты — из start_sites локации (lat/lon/курс), высота = земля + start_position_agl_m.
Меш. Слой режется на чанки (render.<слой>.chunk_cells); у всех чанков слоя общие плоские сетки LOD
(шаг 1, 2, 4… клетки), высоту даёт вершинный шейдер через texelFetch → построение мгновенное.
LOD выбирается по расстоянию от камеры до AABB чанка (lod_distances_m) раз в lod_update_interval_s.
Щели между LOD закрывает «юбка». Коллизии нет — полёт использует height_at.
Карта поверхности (VR-4, VR-0). Классы: 1 лес, 2 луг/трава, 3 пашня/поля, 4 кустарник, 5 скалы/голый грунт,
6 вода, 7 застройка, 8 снег; 0 — нет данных. Источник — ESA WorldCover 2021 (10 м, CC-BY 4.0), fetch_landcover.py
читает только нужные тайлы COG (HTTP range, без GDAL), класс узла — мода 3×3 подвыборок; перевод кодов WorldCover —
world.json → surface.worldcover.classes. Файлы <слой>_surface.png (8 бит, значение = класс, та же сетка, что у высот,
не импортируются Godot: importer="keep") + surface.json. Данные: ~0,5 МБ на локацию.
Маска «деталь 10 м» (T02/T03) — <слой>_detail10.png детального слоя (40 км → 4001×4001, PNG серый+альфа = RG8,
1,0–1,7 МБ; вся локация ≤ 15 МБ): R — доля леса в клетке 10 м по 3×3 подвыборкам WorldCover (без моды, T02), G —
доля воды в клетке 10 м (T03, VR-9). fetch_landcover.py <id> --only=detail10 (≈ 1 мин, world.json → surface.detail10)
готовит R и обнуляет G; tools/terrain/osm_water.py <id> дописывает G поверх — реки/ручьи/каналы из data/osm/<id>.json → water.rivers (ширина по типу: river 25 м, canal 6 м, stream 4 м — тег width пока не пакует fetch_osm.py), сглаженный
край ~1 клетка (как rivers.py), и озёра (water.lakes, полигоны, антиалиасинг края супервыборкой 4×4). Ручьи (4 м) уже
клетки — покрытие ≤ 0,4, ниже порога 0,5 классификации (в шейдере это берег/затемнение, не сплошная вода); реки и каналы
(≥ 6 м) выше порога. PNG читается в рабочем потоке, пока распаковываются высоты (загрузка Онгудая 0,44–0,49 с).
Рантайм-локации (FR-17) — без маски (класс «вода» WorldCover как раньше).
forest_at(x, z) — доля леса 0..1 как кромка в шейдере (маска билинейно + порог 0,5 ± forest_edge_soft, без шума):
0,1→0,9 за ≤ 4 м; без маски — по классу. get_forest_mask() → [Image RG8, origin (угол пикселя (0,0)), 10]
для деревьев/импостеров (передаётся им set_forest_mask, если метод есть). Поляны у стартов вырезаны и в маске.
surface_at(x, z) = вода там, где G ≥ 0,5 (реки/озёра OSM — точнее контуром, чем карта классов 25 м; над водой
термики слабые, VR-4). В шейдере: вода = max(класс «вода» WorldCover, G маски билинейно); вдали (шаг пикселя больше
клетки маски) — максимум по кресту вокруг точки (forest_mask_g_aa), чтобы узкие реки/ручьи не мигали при движении
камеры; вдоль кромки воды — тонкая полоса мокрого грунта (bank, по значению G в диапазоне 0,02–0,4, без него — суше).
Вода от ветра (VR-16, water_wind, ключи terrain_look.water_*): штиль — зеркало (water_roughness), к
water_wind_full_ms — матовая рябь (шероховатость и нормаль ряби поперёк ветра; мельче пикселя — в шероховатость);
в пятнах порывов (то же поле wind_far_gusts, что у травы с высоты) рябь сильнее — «кошачьи лапы» темнее за счёт
меньшего отражения неба, против солнца искрятся (цвет не трогается); ветровая тень — 4 выборки G маски против ветра
(до water_lee_m): суша с наветра гасит ветер — гладкая полоса у наветренного берега.
Из этой одной карты берутся цвет земли (шейдер), деревья (только на «лесе») и сила источников термиков.
surface_at(x, z) — класс ближайшего узла; лес — по маске 10 м (R ≥ 0,5; «не лес» в лесной клетке 25 м — самый частый
не-лесной класс соседей, иначе луг); луг/поле/кустарник круче terrain_look.rock_slope_deg → скалы (как в шейдере).
VR-21: height_at — DSM Copernicus с кронами: на опушках Онгудая ступенька ≈ 10–12 м (тест
test_height_includes_crowns), посадка в лес — удар о кроны, а не о землю.
Пустыри у стартов вписаны прямо в карту: лес в круге → луг (класс GRASS — трава растёт как на лугу; маска леса 10 м —
ноль до радиуса + диагональ клетки, так что и билинейная forest_at, и деревья внутри круга — ноль). Кустарник, кусты,
камни и трава остаются — старт с травой, без деревьев. У встроенных стартов по-прежнему лес и кустарник → луг в
locations/<id>.json → start_clearing_radius_m (70 м), дальше до game.json → start_search.clearing_radius_m — только
лес. Старт с карты выбирается после загрузки рельефа — Terrain.add_start_clearing(x, z, r) (синхронно, К1 v2) правит
карту на месте: текстура классов и маски у рельефа обновляется в той же ImageTexture (её же читают трава, дальняя
трава, импостеры, процедурные кроны), деревья-модели и импостеры получают новую маску, surface_revision растёт —
кусты сбрасывают кеш тайлов; всё перестраивается со следующего кадра, как при движении камеры (≈ 0,3 мс на рельефе с
карты, ≈ 10 мс на Онгудае с маской 4001²). get_start_clearings() — все пустыри Vector3(x, z, r): одиночные деревья
ShrubScatter (на лугу они бы выросли) внутри не ставятся, кусты — ставятся. Почему радиус такой — docs/guide/game.md →
«Разбег».
thermal_source_strength_at(x, z) = clamp(class_strength[класс] · exposure_gain · освещённость^exposure_power
· (1 + edge_boost · близость к границе), 0, 1) — world.json → surface.thermal: поле/скалы/застройка сильные, лес 0,45,
вода 0,05; границы поле–лес, луг–лес, пашня–луг — триггеры (полное усиление ближе 50 м, до 150 м спадает).
Семантика та же, что у sun_fn атмосферы (0..1, ниже atmosphere.thermal.sun_min термик не рождается): ровное поле ≈ 0,87,
поле у опушки 1, лес ≈ 0,38, вода 0.
Рантайм (load_location_latlon): WorldCoverLoader берёт обзорный уровень COG с пикселем ≤ шага карты
(шаг = шаг высот × surface.runtime.cell_factor), качает 1024² тайлы параллельно (≈ 30–150 КБ каждый), выборка — в потоке.
Нет сети/данных → SurfaceClassifier (высота, уклон, экспозиция, шум, маска рек) — та же форма карты, путь дальше общий.
Раскраска (FR-19) по карте поверхности: доли классов в точке — билинейно по 4 узлам карты, обрезка по уровню 0,5 (граница — плавная кривая, не «лесенка»),
сдвиг порога шумом (surface_edge_noise, surface_edge_sharpness). Лес на детальном слое — по маске 10 м: порог 0,5
(forest_edge_soft) + узкий шум (forest_edge_noise ~3 м, извилистость forest_edge_warp_m), переход ≤ 1,5 клетки;
остальные классы делят долю «не лес» в прежней пропорции. Когда клетка 10 м мельче пикселя (след > 25 м) — прежняя
карта 25 м и без выборок из маски (дешевле, чем было). Ступенька DSM на опушке: из уклона луга и классов (скалы,
осыпи) вычитается canopy_dsm_height_m (10 м) × градиент маски — без тени и «скал» на лугу у кромки; лес освещается
по DSM (кроны). Внутри класса — рисунок: пашня — прямоугольные участки
разного цвета, луг — сухая трава на южных склонах и пятнами, лес — хвойные/лиственные пятнами (больше хвойных на северных),
застройка — кварталы 18 м, скалы — по уклону (кроме леса), вода — класс «вода» с блеском. Вблизи — шум детализации, рельеф крон,
CC0-текстуры травы и скал (их рисунок, цвет остаётся процедурным — без шва вдали). Вокруг стартов — поляны.
Деревья (TerrainTreeModels): 5 пород (assets/models/trees/tree_<порода>.glb, пути — world.json → trees.models)
× 8 вариантов (trees.variants, ноды V{n}_LOD{k}; вариант — хеш клетки) × 3 LOD (до 60 м — LOD0, до 180 м — LOD1,
до radius_m — крест-импостор LOD2), по MultiMesh на породу × вариант × LOD (пустые скрыты). LOD0 и LOD1 варианта — из
одного каркаса (ствол, мутовки, пучки; LOD1 — пучки ветки слиты в один крупный, без ветвей), импостор запечён с LOD0.
Смена LOD мягкая: в полосе lod_fade_m (+ запас rebuild_step_m на сдвиг камеры до пересчёта) дерево стоит в обоих LOD,
tree_model.gdshader растворяет один и проявляет другой дизерингом по экранному шуму (без прозрачности).
TreePlacer раз в rebuild_step_m пересчитывает расстановку в рабочем потоке: клетка spacing_m, дерево только на классе
«лес», порода — по высотному поясу и экспозиции (trees.species: берёза и лиственница ниже, кедр и ель выше, ель на северных,
сосна на южных сухих склонах; локация переопределяет веса — в Онгудае лиственница ×4, на Биягоде сосна и берёза).
Всё — хешем клетки, дерево не «прыгает». Выше max_agl_m деревья не рисуются. Утоплены на sink_fraction (DSM с кронами).
Нет моделей → процедурные кроны TerrainTrees (как раньше).
Мир. SkyEnvironment: небо (sky.gdshader), солнце с 4 каскадами теней (3 км) и полутенью (angular_distance_deg),
голубая дымка чистого воздуха (туман Godot, fog.density 2,5e-5: дальние хребты синеют), glow, лёгкая коррекция цвета.
Дымка и инверсия (VR-3) — haze.gdshader, полноэкранный квад поверх сцены (Forward+ и Compatibility): по глубине кадра
восстанавливается точка, оптическая толщина слоя считается аналитически вдоль луча (плотность постоянна до верха слоя,
линейный переход top_transition_m). Под верхом мутно (видимость haze.visibility_km = 60 км, вместе с голубой дымкой ≈ 50 км),
выше — чисто и сине; сверху граница мягкая (переход top_transition_m 400 м). Верх = set_inversion_height_msl(h) + top_margin_m
(интегратор передаёт Atmosphere.get_cloudbase_msl()), до вызова — default_top_msl_m.
Камерам: SkyEnvironment.setup_camera(cam) ставит near/far из world.json → rendering (far 110 км).
Солнце и ослепление. Небо — scripts/world/sky.gdshader (градиент как у ProceduralSky + диск солнца в HDR с короной,
sky.sun_disk; яркость × time.light.disk_energy — вечером диск тёплый и тусклый; в кубическую карту освещения диск не входит).
SunGlare (scripts/world/sun_glare.gd + .gdshader, CanvasLayer поверх 3D и облаков, под интерфейсом) — ослепление, как у глаза
(world.json → sun_glare): солнце у направления взгляда — засвет вокруг него и потемнение кадра; углы — от настоящего направления
пикселя, при любом поле зрения одинаковы. Закрыто ли солнце: рельеф и облака — лучи из глаза по диску (центр + кольцо,
CameraRig.ground_fn, Atmosphere.cloud_density_at), сглажено ~0,3 с; крыло и точный край облака — пробы яркости пикселей диска
в шейдере, каждая — максимум в крестике ±3 px, поэтому тонкие тросы засвет не гасят, а парус и трубы — гасят. Lens flare нет —
это глаз, а не камера. Каска (configs/helmet.json, настройка «Каска», --helmet=none|open|visor|visor_dark) — только в кабине:
кромка козырька (доли кадра — стоит у края при любом FOV, закрывает солнце), визор — тон стекла, искажение у краёв, против
солнца — засвет стекла, полосы следов протирки, царапины (блестят поперёк направления на солнце) и пылинки.
Цвет лугов по локации и сезону. terrain_look.dryness 0..1 (0 — сочный луг, 0,5 — подсохшая трава, 1 — солома),
dry_south_k (южные склоны суше), dry_noise (пятна), grass_saturation 0,8. Функция meadow_color() в
terrain_common.gdshaderinc — общая для рельефа и травинок; палитра наружу — Terrain.get_grass_palette().
Лето: Манжерок 0,1, Онгудай 0,15 (+0,35 на южных), Биягода 0,25, Аушкуль 0,4; осенью — поднять до 0,5–0,7.
Фактура открытых мест с высоты: луг — куртины 20–60 м гуще/реже, кочки 2–6 м, редкие тропинки; поле — борозды 3 м
и колеи техники каждые 18 м по направлению участка, спелость пятнами, межи ~3 м. Все детали гаснут, когда
становятся мельче пикселя (по fwidth) — без мерцания и видимого тайлинга.
Лес с высоты — три плана. (1) Полог в шейдере рельефа на классе «лес»: кроны-ячейки Вороного canopy_cell_m 6 м
(радиус, оттенок, хвойные/лиственные по conifer_share), освещение кроны по солнцу (sun_dir от Terrain), тень от
соседней кроны, тёмные просветы; куртины ×3 шага (видны до нескольких км); пятна древостоев 50–200 м
(canopy_stand_var); опушка темнее на canopy_edge_depth_m только внутри леса (луг у кромки не темнеет).
Когда крона мельче пикселя — плавно к среднему тону полога canopy_far_shade (приглушённый canopy_far_sat) с пятнами
древостоев и массивов 300–800 м (canopy_far_var, canopy_far_big) — подобрано по фото (a) стенда: с 2 км лес не тёмный
ковёр (S5: лес/луг 0,93 → 0,98, RMS в лесу 12,0 → 15,3 при фото 1,16 / 16,1). Переход к среднему тону — плавно по
следу пикселя (0,8–2,5 шага крон), без кольца на границе детального полога. (2) Средний план ForestImpostors: билборды из
trees_impostor_atlas.png на сетке 12 м от 0,7·trees.radius_m до 3,5 км, порода — те же веса, что у моделей,
просеки учитываются. (3) Ближний — 3D-модели (редеют с 0,7·radius к radius, уступая импостерам). У опушки деревья
утоплены меньше (edge_sink_fraction) — стволы видны.
Скалы. Крутые (> rock_slope_deg 38°) луга/кустарник/грунт: два тона породы (серо-бурый и охристый) крупными
пятнами, горизонтальные слои strata_m на обрывах, лишайник на верхних гранях, трипланарная текстура + карта нормалей
(ambientCG Rock030). Осыпи — склоны 14–38° ниже обрыва (обрыв ищется в scree_probe_m выше по склону),
светлая щебёнка (ambientCG Gravel022). Снег в кулуарах — выше gully_snow_line_m (на северных склонах ниже на
gully_north_m) только в ложбинах (вогнутость рельефа на масштабе gully_scale_m), а не шапкой.
Ветер на земле (VR-17). Terrain.set_wind_sources(atmo.mean_wind_at, atmo.thermals_near, atmo.air_velocity_at)
(air_fn необязателен) — TerrainWind раз в 0,2 с берёт средний ветер на 10 м над землёй под камерой и до
8 ближайших термиков и передаёт шейдерам: по траве и ниве бегут пятна порывов (светлеют — видна изнанка
листьев) и рябь вдоль ветра; над термиком у земли — изломанная закрученная волна, сходящаяся к оси, и
усиленное колыхание; деревья качаются (макушка до sway_top_m). Смещение рисунка порывов копится на CPU
каждый кадр (TerrainWind.advance, offset += wind · dt → wind_offset), а не через TIME · wind в
шейдере — смена направления ветра не двигает рисунок задним числом (нет скачка). Порывистость атмосферы
(|air_velocity_at − mean_wind_at| у земли, сглажена экспонентой gust_tau_s) усиливает амплитуду пятен
(gust_full_ms, gust_gain); без air_fn порывистость 0 — поведение как до T05.
Видно с 50–300 м, дальше wind_fade_m гаснет. Параметры — world.json → wind_visual. Термик берётся по ключу
ground_axis (если атмосфера его отдаёт), иначе source — см. отчёт. Травинки (GrassField): пучки на сетке 0,3 м
в радиусе 30 м, по классу (луг, нива выше, кустарник реже), приминаются у ног пилота (Terrain.set_pilot(node)),
на посадочных площадках скошены. Просеки: Terrain.set_clearings(image, origin, cell_m) (маска
WorldClearings.build_for(id)) — деревья-модели и импостеры не стоят на дорогах, под ЛЭП, у зданий и на посадках.
Поля рельефа (влажность, AO, тень рельефа). TerrainRelief.compute для каждого слоя при setup (т. е. и
для load_location_latlon), в рабочих потоках: сетка — узлы слоя через шаг (surface.relief.grid_max 801/401 →
50 м / 400 м), Онгудай 1,3–1,7 с + фон 0,3–0,4 с. Влажность 0..1 = base + flow_weight · сток (накопление D8
по сортировке высот, лог площади водосбора flow_area_m2) + concavity_weight · вогнутость (лапласиан на 100 и 300 м),
сглажена blur_m. Экспозиция — северность, сглаженная на 150 м. AO — 8 направлений до 1,2 км (через узел,
ao_stride), угол горизонта над касательной плоскостью: плоский склон и гребень — 1, ложбина темнее. Горизонт к
солнцу — угол в сторону азимута солнца до 8 км; высота солнца учитывается в шейдере бесплатно, смена азимута на
shadow_recompute_deg → пересчёт в фоне (~0,5 с, Terrain.set_sun(to_sun): подключить sky.clock.sun_changed).
Шейдер: текстура RGBA8 (влажность, AO, северность) + R8 горизонт; луг в ложбинах сочнее/зеленее/темнее
(terrain_look.wet_dry_k, wet_green, wet_dark), на южных склонах суше (aspect_dry_k), лес в сырых ложбинах
и на северных склонах гуще (forest_wet_dark); AO — в цвет (relief_ao_albedo) и рассеянный свет
(relief_ao_ambient); тень рельефа гасит только прямую долю (relief_shadow_*) и проявляется дальше
relief_shadow_near_m (ближе — тени Godot). Термики: сырые ложбины слабее (surface.thermal.wet_k).
Terrain.moisture_at / relief_ao_at / relief_horizon_at. GPU рельефа — в пределах шума замера (±0,1 мс).
Использование
var terrain: Terrain = $Terrain # location_id = "altai" грузится в _ready
atmosphere.set_ground(terrain.height_at, terrain.thermal_source_strength_at) # VR-4: источники из карты
sky.set_inversion_height_msl(atmosphere.get_cloudbase_msl()) # VR-3: верх дымки
var site: Dictionary = terrain.get_start_sites()[0] # {id, name, position, heading_deg, lat, lon}
glider.reset_on_ground(site.position, site.heading_deg)
# произвольная точка (FR-17): асинхронно, затем сигнал loaded
terrain.load_location_latlon(43.25, 42.45, 40.0)
await terrain.loaded
# карта выбора места
var picker := MapPicker.new()
picker.point_picked.connect(func(lat, lon): terrain.load_location_latlon(lat, lon))Рантайм-загрузка с карты: ход, фризы, ошибки
load_location_latlon не останавливает главный поток надолго (окно отвечает, экран загрузки живой):
- тайлы Terrarium и WorldCover —
HTTPRequestсuse_threads(TLS и чтение — не в главном потоке); - мозаика тайлов → высоты →
HeightLayer(TerrariumLoader.assemble), распаковка тайлов COG и выборка классов WorldCover, процедурная карта поверхности (SurfaceClassifier) — вWorkerThreadPool; - сборка сцены (
setup_async): меш, деревья, трава — в разных кадрах; пока идёт —Terrainне обрабатывается (process_mode), чтобы дети не видели полусборку.setup()(встроенные локации, тесты) — как раньше, синхронно.
Ход — terrain.progress (LoadProgress, scripts/core/load_progress.gd): этап (stage(key, текст)) и доля
0..1 по весам configs/ui.json → loading.stage_weights, внутри скачивания — по тайлам. Экран загрузки
(LoadingScreen) слушает progress.changed.
Высокие широты: пиксель web-mercator мельчает как cos(широта), поэтому уровень тайлов слоя понижается,
пока шаг не станет ≥ runtime_terrain.layers[].min_spacing_m (TerrariumLoader.layer_zoom): на 72° (Гренландия)
сетка не в 4 раза больше, чем на Алтае. Дальше max_abs_lat тайлов нет.
Ошибки → load_failed(текст для пилота), игра возвращается в меню:
- нет связи / таймаут → «Нет связи с сервером рельефа…»; тайла нет (403/404) → «Для этого места нет данных рельефа.»;
- море (
Terrain.is_sea): высота точки нижеsea_depth_mили ≤ 0 (над океаном тайлы дают 0) без карты покрова / с водой в ней → «Здесь море — выберите точку на суше.»; - доля хода не менялась
stall_timeout_s→ отмена (cancel_load) и «Сервер рельефа не отвечает…».
Замер: tools/loading/load_probe.tscn -- --latlon=<lat>,<lon> [--cache=<пустая папка>] [--url=<шаблон>] —
этапы с длительностью, самый длинный интервал между кадрами, итог (полёт / меню с сообщением).
Как добавить локацию
- Скопировать
configs/locations/altai.jsonвconfigs/locations/<id>.json, поменятьname,center_lat/lon,data_dir(res://data/terrain/<id>), при желании размеры слоёв (сторона/шаг должны давать целое числоchunk_cells). uv run --with numpy --with pillow --with tifffile --with imagecodecs python tools/terrain/fetch_dem.py <id> --preview=/tmp— скачает Copernicus/Terrarium (кеш~/.cache/deltaplan_terrain), сохранит слои, маски рек иmeta.json(≈ 30 с; отмывки для проверки — в/tmp).uv run --with numpy --with pillow python tools/terrain/fetch_landcover.py <id>— карта поверхности WorldCover (разделsurfaceлокации: уровень COG и подвыборки по слоям; ≈ 1–3 мин, кеш~/.cache/deltaplan_terrain/cog).- Вписать
start_sites(lat, lon,heading_deg— курс разбега вниз по склону, 1–3 старта) иlanding_sites. Проверка:tests/terrain/test_locations.gd(добавить id вLOCATIONS) — старт на склоне 8–35°, вниз по курсу, не в лесу; посадка пологая, не лес и не вода. Бюджет данных — ≤ 15 МБ на локацию. godot --path . res://scenes/terrain/terrain_preview.tscn -- --location=<id> --site=<id старта>— посмотреть.- Внести данные в
ASSETS.md.
Локации
| id | Что | Старты |
|---|---|---|
ongudai | Алтай, долина Урсула у Онгудая, хребет с перевалом Каянча (полёты с 1980-х) | южный склон у Онгудайского ретранслятора, 1870 м, 151° |
askarovo | Башкирия, хребет Биягода у Идяш-Кускарово (дельтадром, ЧР-2025) | запад у верхнего лагеря 294°, восток 94°, южная вершина 285° |
aushkul | Башкирия, озеро Аушкуль: гора Ауштау («пупырь») и хребет у Старобайрамгулово | Ауштау восток 119°, Ауштау юг 170°, хребет запад 273° |
altai | Манжерок, Малая Синюха (дополнительная) | 3 старта |
Источники и допущения — _sources_doc в конфиге локации и questions.md → Рельеф 2. |
Превью и замеры
godot --path . res://scenes/terrain/terrain_preview.tscn -- [--site=<id>] [--agl=м] [--yaw=°] [--pitch=°]
[--shot=file.png] [--bench] [--no-trees] [--no-shadows] [--latlon=lat,lon] [--location=id]
[--inversion=м] [--no-haze]WASD/QE/Shift — полёт камеры, правая кнопка — обзор. --bench печатает FPS и GPU-время.
RTX 4070 SUPER, 1080p, Forward+, Онгудай: у земли 2.85 мс GPU со всем (рельеф с пологом/скалами/ветром 1.25,
тени 0.6, деревья-модели + импостеры 0.5, травинки 0.5); на 800 м — 2.8 мс. Для встроенной графики: grass.enabled
false, trees.impostors.outer_m 2000, trees.radius_m 300, trees.cast_shadows false.
Для встроенной графики (NFR-1): если не хватает — trees.radius_m 300, trees.cast_shadows false,
sun.shadow_max_distance_m 1500; в Compatibility-рендере всё работает (проверено на llvmpipe).
Заменяемые ассеты
Модели деревьев — world.json → trees.models (нет файла → процедурные кроны). Карта поверхности — PNG, можно
заменить любой другой классификацией (OSM landuse, свой рисунок) с теми же классами.
Текстуры поверхностей — пути в configs/world.json → terrain_textures (пусто — процедурно; нет файла — предупреждение
и процедурно). Реки (маски rivers.py) нужны только запасной карте; при карте WorldCover вода — её класс «вода».
Стенд кадров (T01)
Стенд T01 (tools/terrain/shots.sh <выход>): 7 кадров 1920×1080 (S1–S5 Онгудай, A1 Аскарово, U1 Аушкуль) + bench.json (GPU «полностью»/«рельеф», среднее и 95-й перцентиль) за ~45–55 с. tools/terrain/compare_ref.py сравнивает кадры с 3 реальными фото Wikimedia Commons (data/terrain/reference/sources.json) по яркости/контрасту прямоугольников «лес»/«луг», полос близко/средне/далеко и профилю опушки (S3), собирает metrics.json и монтажи «кадр|фото».
Кромку леса в метрах по земле (S3) стенд не считает: итоги T02 — проекцией пикселей кадра на рельеф (DSM) и знаковым
расстоянием до кромки маски 10 м: переход 10→90 % по линии профиля 32 → 13 м, в среднем по кадру (до 1,5 км) 25 → 15 м
(без деревьев-моделей 7,5 м); луг в 0–30 м от кромки темнее луга в 100 м: 12,7 % → 7,9 % (без моделей 3,6 %; остаток —
тени и кроны моделей у опушки). GPU «рельеф» (A/B, чередуя): стенд 2,26 → 1,96 мс, S1–S5 — на 0,24–0,38 мс меньше
(лес вдали — без выборок маски, пятна древостоев только на лесе).
Дымка (T04)
Дымка и инверсия (VR-3, FR-20) — haze.gdshader, полноэкранный квад поверх сцены (Forward+ и Compatibility). По глубине кадра восстанавливается точка, оптическая толщина считается аналитически вдоль луча. Два вещества, у каждого своё ослабление по каналам R, G, B. Чистый воздух (haze.clear_visibility_km 140 км, clear_rgb 0,7/1/1,5) действует по всей длине луча до рельефа; в небе — только у горизонта (путь ≤ clear_sky_height_m/sin угла), цвет — в тон неба у горизонта по солнцу. Мутный слой (visibility_km 100 км, почти серый extinction_rgb) лежит под верхом: плотность постоянна, в top_transition_m (400 м) линейно спадает до нуля. Свет дымки ∝ (1 − e^−τ)^inscatter_power (1,75): вблизи дымка голубая и слабая, контраст на 2–10 км сохраняется; к горизонту светлеет до clear_air_color. Туман Godot выключен (fog.density 0), раньше он ослаблял даль второй раз. Вместе видимость 58 км, пропускание на 20 км 26 %. Верх = set_inversion_height_msl(h) + top_margin_m, до вызова — default_top_msl_m; сверху граница мягкая (переход top_transition_m 400 м). Итоги T04 по стенду (S5): «далеко/близко» 1,15 → 0,89 (фото 0,72); «средне/близко» 0,54 → 0,59 (фото 0,91 — мешают тёмные теневые склоны среднего плана); хребет на 20 км синее ближнего на 39 %.