Террейн
Земля на основе карты высот: сетка отсчётов высоты, рисуемая чанками со splat-материалом — до
четырёх текстурных слоёв, смешиваемых по RGBA-карте управления на каждый отсчёт — плюс дыры,
скульптинг, покраска, дороги, под которые подстраивается земля, и коллайдер Jolt heightfield.
Один Terrain на узел (terrain.node); землю обычно задают блоком terrain: в
файле сцены и производят ассет-пайплайном (lecodes assets terrain-pack,
Unity-импортёр), а затем запрашивают и редактируют из кода.
Обзор
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`))Создание
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 применяются
скаляры слоёв из файла.
heights, holes, control — живые массивы. Редактировать их напрямую можно, но
ничего не доходит до движка до commit() (методы редактирования отслеживают грязный
прямоугольник за тебя).
Слои и splat-материал
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,
инвертированное) упаковывает карту в альфу за тебя.
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 рядом с альбедо слоёв.
t.layers // живой TerrainLayer[]; отредактируй, затем
t.applyLayers(): Promise<void> // перепривязать текстуры / множители
t.material // splat-Material (`Material.terrain()`), для `set()`Чтение земли
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, коллайдер — один раз.
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-отпечаток треугольников узлаДороги и фундаменты — земля облегает другой узел:
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 только засыпает.
Покраска карты управления:
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 } }): thispaintAuto — CPU-проход, с которого начинается процедурный уровень: slope в градусах (скала на
обрывах), height в метрах (снеговая линия), low ниже высоты (дно долин).
Отмена:
t.snapshot(): TerrainSnapshot // высоты / дыры / управление, скопированные
t.restore(snapshot): thisТени, коллизия, запечённый свет
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).
хост без нативного террейна рисует те же чанки дочерними мешами (предупреждение
говорит об этом; карты нормалей там читаются плоскими), а тот, что не может загрузить карту
управления, показывает все слои поровну (предупреждение один раз). Запросы, редактирование и
файл .terrain работают одинаково везде.
Файл .terrain и блок сцены
t.serialize(): ArrayBuffer // высоты (16 бит по их диапазону) / дыры / управление / скаляры слоёв
Terrain.parse(buf): { sizeX, sizeZ, cellSize, chunk, heights, holes, control, layers }В файле сцены узел является террейном по своему блоку terrain: — из
файла или свежей сетки, со слоями, штампами и дырами, применяемыми при загрузке, после того как
каждый узел файла существует:
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()