Террейн

Земля на основе карты высот: сетка отсчётов высоты, рисуемая чанками со splat-материалом — до четырёх текстурных слоёв, смешиваемых по RGBA-карте управления на каждый отсчёт — плюс дыры, скульптинг, покраска, дороги, под которые подстраивается земля, и коллайдер Jolt heightfield. Один Terrain на узел (terrain.node); землю обычно задают блоком terrain: в файле сцены и производят ассет-пайплайном (lecodes assets terrain-pack, Unity-импортёр), а затем запрашивают и редактируют из кода.

Обзор

TypeScript
const scene = new Scene()
scene.add(Light.sun())
const t = Terrain.create({
  size: 257, cellSize: 2,                                      // квадрат 512 м, отсчёты через 2 м
  heights: (x, z) => 20 * Math.sin(x / 60) * Math.cos(z / 80),   // метры, ЛОКАЛЬНЫЕ x/z
  layers: [
    { albedo: asset('./assets/terrain/grass.ktx2'), tiling: 6, normalScale: 1 },
    { albedo: asset('./assets/terrain/rock.ktx2'), tiling: 4, normalScale: 1, triplanar: true },
  ],
  normals: [asset('./assets/terrain/normals01.ktx2')],
})
t.paintAuto({ base: 0, slope: { layer: 1, from: 30, to: 45 } })  // скала на обрывах
t.commit()
t.node.aspect(Shape, { heightfield: true }).aspect(Physics, { motion: 'static' })
scene.add(t.node)
scene.open()
t.ready.then(() => console.log(`ground at the centre: ${t.heightAt(256, 256).toFixed(2)} m`))

Создание

TypeScript
Terrain.create(opts?: TerrainOptions): Terrain                 // синхронно; `ready` = текстуры загружены
Terrain.load(url, opts?): Promise<Terrain>                     // файл `.terrain` (высоты / дыры / управление / слои)
Terrain.of(node): Terrain | null                               // Terrain, который несёт узел (узел террейна файла сцены)

type TerrainOptions = {
  size?: number                 // отсчётов на сторону, по умолчанию 129 (257 / 513 / 1025 — обычные размеры)
  sizeX?, sizeZ?: number
  cellSize?: number             // метров между отсчётами, по умолчанию 1
  chunk?: number                // ячеек на сторону чанка, ≤ 255, по умолчанию 64
  heights?: Float32Array | ((x: number, z: number) => number)   // метры; строки z по порядку (row-major), или f(локальные x, z)
  holes?: Uint8Array            // байт на отсчёт, 1 = дыра
  control?: Uint8Array          // RGBA-веса слоёв на отсчёт (нормализуются в шейдере); по умолчанию: везде слой 0
  layers?: TerrainLayer[]       // ≤ 4
  normals?: [pack01?, pack23?]  // два ПАКА карт нормалей (ниже)
  tint?: ColorInput             // множитель на альбедо каждого слоя
  material?: Material           // собственный шейдер террейна с теми же юниформами (по умолчанию Material.terrain())
  name?: string
}

Начало координат террейна — его отсчёт (0, 0); локальные x/z идут вдоль сетки в метрах (extent = [(sizeX − 1) · cellSize, (sizeZ − 1) · cellSize]), высоты по +Y. Размещай его, перемещая и поворачивая terrain.node (масштаб держи 1) — у каждого запроса ниже есть локальная и мировая форма. t.ready резолвится, когда загружена каждая текстура слоя (до тех пор террейн рисуется белым; о текстуре, которая не загрузилась, выдаётся предупреждение, и она пропускается). Terrain.load принимает те же опции минус сетку (size, heights, holes, control приходят из файла); текстур в файле нет, так что передай layers / normals — без layers применяются скаляры слоёв из файла.

Note

heights, holes, control — живые массивы. Редактировать их напрямую можно, но ничего не доходит до движка до commit() (методы редактирования отслеживают грязный прямоугольник за тебя).

Слои и splat-материал

TypeScript
type TerrainLayer = {
  albedo?: string | Texture     // sRGB; не задано = белый
  tiling?: number               // метров на повтор текстуры, по умолчанию 8
  roughness?: number            // МНОЖИТЕЛЬ на альфу альбедо (его карту шероховатости), по умолчанию 1
  normalScale?: number          // 0 = слот слоя в паке нормалей не читается (по умолчанию); 1 с картой нормалей
  triplanar?: boolean           // сэмплировать в мировом пространстве (скала обрыва: без растяжения на склонах); его карта нормалей пропускается
}

Материал смешивает четыре слоя по RGBA-весам карты управления (один тексель на отсчёт высоты). Металличности нет: земля — диэлектрик.

Шероховатость попиксельная и живёт в альфе альбедо. Шероховатость слоя в текселе — roughness × albedo.a. Непрозрачное альбедо читается с альфой 1, так что остаётся один множитель; альбедо, чья альфа несёт карту шероховатости, заставляет мокрое пятно или протоптанную тропу блестеть внутри одного слоя. Это единственный свободный канал, который оставляет бюджет сэмплеров (девять сэмплеров на feature level 1), поэтому отдельной текстуры шероховатости нет — lecodes assets terrain-pack --roughness (или Unity-изображение *_AlbedoSmoothness, инвертированное) упаковывает карту в альфу за тебя.

Note

roughness 0 — это зеркало. Когда слой выглядит как мокрое стекло на солнце, сначала проверь множитель: он умножает то, что лежит в альфе, а инструмент авторинга, имеющий в виду «smoothness 1», записал противоположное тому, что означает это поле.

Карты нормалей поставляются упакованными по две на текстуру — normals[0] держит XY слоя 0 в RG и слоя 1 в BA, normals[1] так же для слоёв 2 и 3; Z восстанавливается. Незаданный пак декодируется в плоскую нормаль, а слой с normalScale 0 его никогда не читает. lecodes assets terrain-pack пишет normals01.ktx2 / normals23.ktx2 рядом с альбедо слоёв.

TypeScript
t.layers                        // живой TerrainLayer[]; отредактируй, затем
t.applyLayers(): Promise<void>  // перепривязать текстуры / множители
t.material                      // splat-Material (`Material.terrain()`), для `set()`

Чтение земли

TypeScript
t.heightAt(x, z): number                       // ЛОКАЛЬНЫЕ метры, по нарисованным треугольникам; NaN вне сетки / в дыре
t.worldHeightAt(x, z): number                  // МИРОВЫЕ x/z → мировой y; NaN так же
t.normalAt(x, z): Vec3                         // нормаль нарисованного треугольника (локальная)
t.sampleNormal(ix, iz): Vec3                   // сглаженная нормаль в отсчёте (индексы)
t.weightsAt(x, z): [r, g, b, a]                // четыре веса слоёв, нормализованные (сумма 1)
t.layerAt(x, z): number                        // индекс доминирующего слоя
t.raycast(origin, dir, maxDistance?): TerrainHit | null        // локально
t.raycastWorld(origin, dir, maxDistance?): TerrainHit | null   // в мире;  { point, normal, distance }
t.toLocal(p) / t.toWorld(p): Vec3
t.extent: [width, depth]                       // метры
t.heightRange: [min, max]

Запросы выполняются в JS по тому же разбиению на треугольники, которое каждый хост рисует и сталкивает, так что игра читает землю одинаково везде. Дыры учитываются: луч через дыру промахивается, а heightAt там — NaN; ничего не рисуется и не сталкивается. weightsAt читает карту управления билинейно ([1, 0, 0, 0] там, где она пуста, край вне сетки): что за земля под точкой — для следов на песке и их отсутствия на брусчатке или звука шага на каждый слой.

Редактирование

Каждый метод редактирования помечает грязный прямоугольник; commit() проталкивает пакет — меши чанков, текстуру управления и, когда узел несёт Shape heightfield, коллайдер — один раз.

TypeScript
t.setHeights(x0, z0, w, h, src): this          // блок отсчётов (индексы отсчётов)
t.shape(region, (h, w, x, z) => number, falloff?): this   // h = текущая высота, w = вес 0…1 внутри области
t.flatten(region, height, { falloff? }): this
t.raise(region, delta, { falloff? }): this
t.sculpt({ x, z, radius, mode?, amount?, hardness?, target? }): this   // один мазок кисти: raise | lower | smooth | flatten
t.hole(region, on = true): this                // там нет треугольников, коллизии, навмеша
t.commit(): this

type TerrainRegion =                           // ЛОКАЛЬНЫЕ метры
  | { circle: { x, z, r } }
  | { rect: { x, z, w, h } }
  | { polygon: [x, z][] }
  | { node: Node }                             // XZ-отпечаток треугольников узла

Дороги и фундаменты — земля облегает другой узел:

TypeScript
t.conform(target: Node | Geometry | Float32Array, { offset?, falloff?, mode? }): this
t.conformPath(points: Vec3[], { width, uvScale?, offset?, falloff?, mode? }): this
Terrain.ribbon(points: Vec3[], { width, uvScale? }): Geometry     // сам меш дороги, для отрисовки

offset (по умолчанию 0.05 м) держит билинейную землю строго под изогнутой дорогой между отсчётами; falloff (по умолчанию 4 м; 0 = жёсткий край) плавно возвращает окружающую землю; mode both срезает и засыпает, lower только срезает, raise только засыпает.

Покраска карты управления:

TypeScript
t.paint(x0, z0, w, h, rgba): this              // блок RGBA-весов (индексы отсчётов)
t.paintLayer(region, layer, strength = 1, falloff = 2): this
t.paintAuto({ base?, slope?: { layer, from, to }, height?: { layer, from, to }, low?: { layer, from, to } }): this

paintAuto — CPU-проход, с которого начинается процедурный уровень: slope в градусах (скала на обрывах), height в метрах (снеговая линия), low ниже высоты (дно долин).

Отмена:

TypeScript
t.snapshot(): TerrainSnapshot                  // высоты / дыры / управление, скопированные
t.restore(snapshot): this

Тени, коллизия, запечённый свет

TypeScript
t.castShadows = true                           // по умолчанию true
t.receiveShadows = true                        // по умолчанию true
t.node.aspect(Shape, { heightfield: true }).aspect(Physics, { motion: 'static' })   // Jolt HeightField (см. physics.md)

Коллайдер обновляется для грязного прямоугольника при каждом commit() (перестраивается целиком, когда высоты выходят за диапазон, под который он был построен). Под лайтмапом террейн файла сцены — естественный приёмник: статичный узел terrain: строится с запечённым splat-материалом (Material.terrainLightmap()) и берёт один прямоугольник атласа на свой UV1; растения берут из него свой запечённый свет (foliage.md).

Host-gated

хост без нативного террейна рисует те же чанки дочерними мешами (предупреждение говорит об этом; карты нормалей там читаются плоскими), а тот, что не может загрузить карту управления, показывает все слои поровну (предупреждение один раз). Запросы, редактирование и файл .terrain работают одинаково везде.

Файл .terrain и блок сцены

TypeScript
t.serialize(): ArrayBuffer                     // высоты (16 бит по их диапазону) / дыры / управление / скаляры слоёв
Terrain.parse(buf): { sizeX, sizeZ, cellSize, chunk, heights, holes, control, layers }

В файле сцены узел является террейном по своему блоку terrain: — из файла или свежей сетки, со слоями, штампами и дырами, применяемыми при загрузке, после того как каждый узел файла существует:

TypeScript
ground: {
  terrain: {
    data: asset('./assets/map/terrain/map.terrain'),
    layers: [{ albedo: asset('./assets/map/terrain/soil.ktx2'), tiling: 2, roughness: 1, normalScale: 2 }, /* … */],
    normals: [asset('./assets/map/terrain/normals01.ktx2'), asset('./assets/map/terrain/normals23.ktx2')],
    stamps: [{ node: 'road', offset: 0.05, falloff: 6 }],     // Terrain.conform по узлу этого файла
    holes: [{ circle: { x: 120, z: 80, r: 4 } }],
  },
  aspects: [use(Shape, { heightfield: true }), use(Physics, { motion: 'static' })],
}

Ассет-пайплайн

  • lecodes assets terrain-pack <layer|dir> … — до четырёх слоёв (альбедо, его нормаль, угаданная по имени или через --normals, опциональная карта --roughness или альфа Unity *_AlbedoSmoothness) → <name>.ktx2 на слой с шероховатостью в альфе + normals01/23.ktx2, и печатает фрагмент layers.
  • lecodes assets scene <map.unity> --project … — Unity-террейн приходит как .terrain + упакованные слои (его splat-слои сведены к четырём по покрытию). HDRP-слой держит свою smoothness в альфе mask map с m_Smoothness как множителем на неё; импортёр упаковывает 1 − smoothness в альфу альбедо и ставит множитель 1. Слой без mask map берёт 1 − m_Smoothness как свой множитель.

Подводные камни

  • roughness слоя — множитель, а не значение: roughness: 0 ➜ roughness: 1 с картой в альфе альбедо (или задуманная константа, когда альбедо непрозрачно).
  • Правки без commit() меняют только массивы ➜ t.raise(…).flatten(…).commit().
  • heights идёт по строкам, строки — z: heights[z * sizeX + x].

Смотрите также

  • scene-files.md — блок terrain: среди других источников узла
  • physics.md — Shape { heightfield }, рейкасты по земле
  • lightmap.md — террейн как приёмник запекания
  • foliage.md — растения берут запечённый свет земли под собой
  • material.md — Material.terrain() / terrainLightmap()