Рельеф и мир

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=<шаблон>] — этапы с длительностью, самый длинный интервал между кадрами, итог (полёт / меню с сообщением).

Как добавить локацию

  1. Скопировать configs/locations/altai.json в configs/locations/<id>.json, поменять name, center_lat/lon, data_dir (res://data/terrain/<id>), при желании размеры слоёв (сторона/шаг должны давать целое число chunk_cells).
  2. 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).
  3. uv run --with numpy --with pillow python tools/terrain/fetch_landcover.py <id> — карта поверхности WorldCover (раздел surface локации: уровень COG и подвыборки по слоям; ≈ 1–3 мин, кеш ~/.cache/deltaplan_terrain/cog).
  4. Вписать start_sites (lat, lon, heading_deg — курс разбега вниз по склону, 1–3 старта) и landing_sites. Проверка: tests/terrain/test_locations.gd (добавить id в LOCATIONS) — старт на склоне 8–35°, вниз по курсу, не в лесу; посадка пологая, не лес и не вода. Бюджет данных — ≤ 15 МБ на локацию.
  5. godot --path . res://scenes/terrain/terrain_preview.tscn -- --location=<id> --site=<id старта> — посмотреть.
  6. Внести данные в 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 %.