import { createOpaqueId } from "../core/ids"; import type { Vec3 } from "../core/vector"; export interface TerrainLayer { materialId: string | null; } export interface TerrainFoliageMask { layerId: string; resolutionX: number; resolutionZ: number; values: number[]; } export interface TerrainFoliageBlockerMask { resolutionX: number; resolutionZ: number; values: number[]; } export type TerrainFoliageMaskRegistry = Record; export interface Terrain { id: string; kind: "terrain"; name?: string; visible: boolean; enabled: boolean; collisionEnabled: boolean; position: Vec3; sampleCountX: number; sampleCountZ: number; cellSize: number; heights: number[]; layers: TerrainLayer[]; paintWeights: number[]; foliageMasks: TerrainFoliageMaskRegistry; foliageBlockerMask: TerrainFoliageBlockerMask; } export interface TerrainHeightPatchEntry { index: number; before: number; after: number; } export interface TerrainSampleBounds { minSampleX: number; maxSampleX: number; minSampleZ: number; maxSampleZ: number; } interface TerrainBoundsCacheEntry { heights: number[]; position: Vec3; sampleCountX: number; sampleCountZ: number; cellSize: number; minHeight: number; maxHeight: number; bounds: { min: Vec3; max: Vec3; }; } interface TerrainRenderDirtyHistoryEntry { revision: number; bounds: TerrainSampleBounds; } interface TerrainRenderDirtyState { revision: number; entries: TerrainRenderDirtyHistoryEntry[]; } export const DEFAULT_TERRAIN_VISIBLE = true; export const DEFAULT_TERRAIN_ENABLED = true; export const DEFAULT_TERRAIN_COLLISION_ENABLED = true; export const MIN_TERRAIN_SAMPLE_COUNT = 2; export const DEFAULT_TERRAIN_SAMPLE_COUNT_X = 9; export const DEFAULT_TERRAIN_SAMPLE_COUNT_Z = 9; export const DEFAULT_TERRAIN_CELL_SIZE = 1; export const DEFAULT_TERRAIN_HEIGHT = 0; export const TERRAIN_LAYER_COUNT = 4; export const MIN_TERRAIN_LAYER_COUNT = 1; export const MAX_TERRAIN_LAYER_COUNT = 8; export const TERRAIN_SHADER_LAYER_COUNT = 8; export const DEFAULT_TERRAIN_LAYER_MATERIAL_IDS = [ "patchy_grass_ground_250x250", "patchy_weedy_dirt_ground_300x300", "ground_sand_300x300", "concrete_wall_cladding_250x250" ] as const; function cloneVec3(vector: Vec3): Vec3 { return { x: vector.x, y: vector.y, z: vector.z }; } function cloneTerrainBounds(bounds: { min: Vec3; max: Vec3 }): { min: Vec3; max: Vec3; } { return { min: cloneVec3(bounds.min), max: cloneVec3(bounds.max) }; } function areVec3Equal(left: Vec3, right: Vec3): boolean { return left.x === right.x && left.y === right.y && left.z === right.z; } function assertFiniteVec3(vector: Vec3, label: string) { if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { throw new Error(`${label} must be finite on every axis.`); } } export function normalizeTerrainName( name: string | null | undefined ): string | undefined { if (name === undefined || name === null) { return undefined; } const trimmedName = name.trim(); return trimmedName.length === 0 ? undefined : trimmedName; } export function normalizeTerrainSampleCount( value: number, label: string ): number { if (!Number.isInteger(value) || value < MIN_TERRAIN_SAMPLE_COUNT) { throw new Error( `${label} must be an integer greater than or equal to ${MIN_TERRAIN_SAMPLE_COUNT}.` ); } return value; } export function normalizeTerrainCellSize(value: number): number { if (!Number.isFinite(value) || value <= 0) { throw new Error("Terrain cell size must be a positive finite number."); } return value; } function normalizeTerrainLayerMaterialId( value: string | null | undefined, label: string ): string | null { if (value === null || value === undefined) { return null; } if (typeof value !== "string") { throw new Error(`${label} must be a string or null.`); } const normalizedValue = value.trim(); return normalizedValue.length === 0 ? null : normalizedValue; } function normalizeTerrainCollisionEnabled(value: boolean): boolean { if (typeof value !== "boolean") { throw new Error("Terrain collisionEnabled must be a boolean."); } return value; } function normalizeTerrainFoliageLayerId(value: string, label: string): string { if (typeof value !== "string") { throw new Error(`${label} must be a string.`); } const trimmedValue = value.trim(); if (trimmedValue.length === 0) { throw new Error(`${label} must be a non-empty string.`); } return trimmedValue; } export function getTerrainLayerLabel(layerIndex: number): string { if (!Number.isInteger(layerIndex) || layerIndex < 0) { throw new Error(`Terrain layer index ${layerIndex} is out of range.`); } return layerIndex === 0 ? "Base Layer" : `Layer ${layerIndex + 1}`; } export function createDefaultTerrainLayers(): TerrainLayer[] { return Array.from({ length: TERRAIN_LAYER_COUNT }, (_, layerIndex) => ({ materialId: DEFAULT_TERRAIN_LAYER_MATERIAL_IDS[layerIndex] ?? null })); } export function cloneTerrainLayers( layers: readonly TerrainLayer[] ): TerrainLayer[] { return layers.map((layer, layerIndex) => ({ materialId: normalizeTerrainLayerMaterialId( layer.materialId, `Terrain layer ${layerIndex}` ) })); } function normalizeTerrainLayers( layers: readonly TerrainLayer[] | undefined ): TerrainLayer[] { if (layers === undefined) { return createDefaultTerrainLayers(); } if ( !Number.isInteger(layers.length) || layers.length < MIN_TERRAIN_LAYER_COUNT || layers.length > MAX_TERRAIN_LAYER_COUNT ) { throw new Error( `Terrain layers must contain between ${MIN_TERRAIN_LAYER_COUNT} and ${MAX_TERRAIN_LAYER_COUNT} layer slots.` ); } return layers.map((layer, layerIndex) => { if (typeof layer !== "object" || layer === null) { throw new Error(`Terrain layer ${layerIndex} must be an object.`); } return { materialId: normalizeTerrainLayerMaterialId( layer.materialId, `Terrain layer ${layerIndex}.materialId` ) }; }); } export function normalizeTerrainLayerCount(layerCount: number): number { if ( !Number.isInteger(layerCount) || layerCount < MIN_TERRAIN_LAYER_COUNT || layerCount > MAX_TERRAIN_LAYER_COUNT ) { throw new Error( `Terrain layer count must be an integer between ${MIN_TERRAIN_LAYER_COUNT} and ${MAX_TERRAIN_LAYER_COUNT}.` ); } return layerCount; } export function getTerrainStoredPaintWeightCount(layerCount: number): number { return Math.max(0, normalizeTerrainLayerCount(layerCount) - 1); } export function createFlatTerrainHeights( sampleCountX: number, sampleCountZ: number, height = DEFAULT_TERRAIN_HEIGHT ): number[] { const normalizedSampleCountX = normalizeTerrainSampleCount( sampleCountX, "Terrain sampleCountX" ); const normalizedSampleCountZ = normalizeTerrainSampleCount( sampleCountZ, "Terrain sampleCountZ" ); if (!Number.isFinite(height)) { throw new Error("Terrain height samples must be finite."); } return new Array(normalizedSampleCountX * normalizedSampleCountZ).fill(height); } export function createFlatTerrainPaintWeights( sampleCountX: number, sampleCountZ: number, layerCount = TERRAIN_LAYER_COUNT ): number[] { const normalizedSampleCountX = normalizeTerrainSampleCount( sampleCountX, "Terrain sampleCountX" ); const normalizedSampleCountZ = normalizeTerrainSampleCount( sampleCountZ, "Terrain sampleCountZ" ); const normalizedLayerCount = normalizeTerrainLayerCount(layerCount); return new Array( normalizedSampleCountX * normalizedSampleCountZ * getTerrainStoredPaintWeightCount(normalizedLayerCount) ).fill(0); } export function createFlatTerrainFoliageMaskValues( resolutionX: number, resolutionZ: number, value = 0 ): number[] { const normalizedResolutionX = normalizeTerrainSampleCount( resolutionX, "Terrain foliage mask resolutionX" ); const normalizedResolutionZ = normalizeTerrainSampleCount( resolutionZ, "Terrain foliage mask resolutionZ" ); if (!Number.isFinite(value)) { throw new Error("Terrain foliage mask values must remain finite."); } return new Array(normalizedResolutionX * normalizedResolutionZ).fill( clamp(value, 0, 1) ); } export function createFlatTerrainFoliageBlockerMaskValues( resolutionX: number, resolutionZ: number, value = 0 ): number[] { return createFlatTerrainFoliageMaskValues(resolutionX, resolutionZ, value); } export function getTerrainSampleIndex( terrain: Pick, sampleX: number, sampleZ: number ): number { if ( !Number.isInteger(sampleX) || sampleX < 0 || sampleX >= terrain.sampleCountX ) { throw new Error(`Terrain sampleX ${sampleX} is out of range.`); } if ( !Number.isInteger(sampleZ) || sampleZ < 0 || sampleZ >= terrain.sampleCountZ ) { throw new Error(`Terrain sampleZ ${sampleZ} is out of range.`); } return sampleZ * terrain.sampleCountX + sampleX; } export function getTerrainPaintWeightSampleOffset( terrain: Pick, sampleX: number, sampleZ: number ): number { return ( getTerrainSampleIndex(terrain, sampleX, sampleZ) * getTerrainStoredPaintWeightCount(terrain.layers.length) ); } export function getTerrainHeightAtSample( terrain: Terrain, sampleX: number, sampleZ: number ): number { return terrain.heights[getTerrainSampleIndex(terrain, sampleX, sampleZ)] ?? 0; } function normalizeTerrainPaintWeights( sampleCountX: number, sampleCountZ: number, layerCount: number, paintWeights: readonly number[] | undefined ): number[] { const storedWeightCount = getTerrainStoredPaintWeightCount(layerCount); const expectedLength = sampleCountX * sampleCountZ * storedWeightCount; const normalizedPaintWeights = paintWeights === undefined ? createFlatTerrainPaintWeights(sampleCountX, sampleCountZ, layerCount) : [...paintWeights]; if (normalizedPaintWeights.length !== expectedLength) { throw new Error( `Terrain paint weights must contain exactly ${expectedLength} values.` ); } for ( let sampleIndex = 0; sampleIndex < sampleCountX * sampleCountZ; sampleIndex += 1 ) { const offset = sampleIndex * storedWeightCount; let weightSum = 0; for (let layerOffset = 0; layerOffset < storedWeightCount; layerOffset += 1) { const value = normalizedPaintWeights[offset + layerOffset]; if (!Number.isFinite(value)) { throw new Error("Terrain paint weights must remain finite."); } const clampedValue = Math.min(1, Math.max(0, value)); normalizedPaintWeights[offset + layerOffset] = clampedValue; weightSum += clampedValue; } if (weightSum <= 1) { continue; } const scale = 1 / weightSum; for (let layerOffset = 0; layerOffset < storedWeightCount; layerOffset += 1) { normalizedPaintWeights[offset + layerOffset] *= scale; } } return normalizedPaintWeights; } export function createTerrainFoliageMask(options: { layerId: string; resolutionX: number; resolutionZ: number; values?: readonly number[]; }): TerrainFoliageMask { const layerId = normalizeTerrainFoliageLayerId( options.layerId, "Terrain foliage mask layerId" ); const resolutionX = normalizeTerrainSampleCount( options.resolutionX, "Terrain foliage mask resolutionX" ); const resolutionZ = normalizeTerrainSampleCount( options.resolutionZ, "Terrain foliage mask resolutionZ" ); const expectedValueCount = resolutionX * resolutionZ; const values = options.values === undefined ? createFlatTerrainFoliageMaskValues(resolutionX, resolutionZ) : [...options.values]; if (values.length !== expectedValueCount) { throw new Error( `Terrain foliage mask values must contain exactly ${expectedValueCount} samples.` ); } for (let index = 0; index < values.length; index += 1) { const value = values[index]; if (!Number.isFinite(value)) { throw new Error("Terrain foliage mask values must remain finite."); } values[index] = clamp(value, 0, 1); } return { layerId, resolutionX, resolutionZ, values }; } export function createEmptyTerrainFoliageMask( terrain: Pick, layerId: string ): TerrainFoliageMask { return createTerrainFoliageMask({ layerId, resolutionX: terrain.sampleCountX, resolutionZ: terrain.sampleCountZ }); } export function cloneTerrainFoliageMask( mask: TerrainFoliageMask ): TerrainFoliageMask { return createTerrainFoliageMask(mask); } export function createTerrainFoliageBlockerMask(options: { resolutionX: number; resolutionZ: number; values?: readonly number[]; }): TerrainFoliageBlockerMask { const resolutionX = normalizeTerrainSampleCount( options.resolutionX, "Terrain foliage blocker mask resolutionX" ); const resolutionZ = normalizeTerrainSampleCount( options.resolutionZ, "Terrain foliage blocker mask resolutionZ" ); const expectedValueCount = resolutionX * resolutionZ; const values = options.values === undefined ? createFlatTerrainFoliageBlockerMaskValues(resolutionX, resolutionZ) : [...options.values]; if (values.length !== expectedValueCount) { throw new Error( `Terrain foliage blocker mask values must contain exactly ${expectedValueCount} samples.` ); } for (let index = 0; index < values.length; index += 1) { const value = values[index]; if (!Number.isFinite(value)) { throw new Error( "Terrain foliage blocker mask values must remain finite." ); } values[index] = clamp(value, 0, 1); } return { resolutionX, resolutionZ, values }; } export function createEmptyTerrainFoliageBlockerMask( terrain: Pick ): TerrainFoliageBlockerMask { return createTerrainFoliageBlockerMask({ resolutionX: terrain.sampleCountX, resolutionZ: terrain.sampleCountZ }); } export function cloneTerrainFoliageBlockerMask( mask: TerrainFoliageBlockerMask ): TerrainFoliageBlockerMask { return createTerrainFoliageBlockerMask(mask); } function normalizeTerrainFoliageMasks( sampleCountX: number, sampleCountZ: number, foliageMasks: TerrainFoliageMaskRegistry | undefined ): TerrainFoliageMaskRegistry { if (foliageMasks === undefined) { return {}; } const normalizedMasks: TerrainFoliageMaskRegistry = {}; for (const [layerId, mask] of Object.entries(foliageMasks)) { const normalizedMask = createTerrainFoliageMask(mask); if (normalizedMask.layerId !== layerId) { throw new Error( `Terrain foliage mask ${layerId} must match its layerId.` ); } if ( normalizedMask.resolutionX !== sampleCountX || normalizedMask.resolutionZ !== sampleCountZ ) { throw new Error( "Terrain foliage mask resolution must match the terrain sample grid." ); } normalizedMasks[layerId] = normalizedMask; } return normalizedMasks; } function normalizeTerrainFoliageBlockerMask( sampleCountX: number, sampleCountZ: number, foliageBlockerMask: TerrainFoliageBlockerMask | undefined ): TerrainFoliageBlockerMask { const normalizedMask = foliageBlockerMask === undefined ? createTerrainFoliageBlockerMask({ resolutionX: sampleCountX, resolutionZ: sampleCountZ }) : createTerrainFoliageBlockerMask(foliageBlockerMask); if ( normalizedMask.resolutionX !== sampleCountX || normalizedMask.resolutionZ !== sampleCountZ ) { throw new Error( "Terrain foliage blocker mask resolution must match the terrain sample grid." ); } return normalizedMask; } export function cloneTerrainFoliageMasks( foliageMasks: TerrainFoliageMaskRegistry ): TerrainFoliageMaskRegistry { return Object.fromEntries( Object.entries(foliageMasks).map(([layerId, mask]) => [ layerId, cloneTerrainFoliageMask(mask) ]) ); } export function getTerrainSampleLayerWeights( terrain: Pick, sampleX: number, sampleZ: number ): number[] { const offset = getTerrainPaintWeightSampleOffset(terrain, sampleX, sampleZ); const layerCount = terrain.layers.length; const weights = new Array(layerCount).fill(0); let explicitWeightSum = 0; for (let layerIndex = 1; layerIndex < layerCount; layerIndex += 1) { const weight = terrain.paintWeights[offset + layerIndex - 1] ?? 0; weights[layerIndex] = weight; explicitWeightSum += weight; } weights[0] = Math.max(0, 1 - explicitWeightSum); const weightSum = weights.reduce((sum, weight) => sum + weight, 0); if (weightSum <= 0) { weights[0] = 1; return weights; } return weights.map((weight) => weight / weightSum); } export function getTerrainWorldSamplePosition( terrain: Terrain, sampleX: number, sampleZ: number ): Vec3 { return { x: terrain.position.x + sampleX * terrain.cellSize, y: terrain.position.y + getTerrainHeightAtSample(terrain, sampleX, sampleZ), z: terrain.position.z + sampleZ * terrain.cellSize }; } export function getTerrainFootprintWidth( terrain: Pick ): number { return (terrain.sampleCountX - 1) * terrain.cellSize; } export function getTerrainFootprintDepth( terrain: Pick ): number { return (terrain.sampleCountZ - 1) * terrain.cellSize; } const terrainBoundsCache = new WeakMap(); const terrainRenderDirtyState = new WeakMap(); const MAX_TERRAIN_RENDER_DIRTY_HISTORY = 64; function createTerrainBoundsCacheEntry( terrain: Terrain, minHeight: number, maxHeight: number ): TerrainBoundsCacheEntry { const width = getTerrainFootprintWidth(terrain); const depth = getTerrainFootprintDepth(terrain); const bounds = { min: { x: terrain.position.x, y: terrain.position.y + minHeight, z: terrain.position.z }, max: { x: terrain.position.x + width, y: terrain.position.y + maxHeight, z: terrain.position.z + depth } }; return { heights: terrain.heights, position: cloneVec3(terrain.position), sampleCountX: terrain.sampleCountX, sampleCountZ: terrain.sampleCountZ, cellSize: terrain.cellSize, minHeight, maxHeight, bounds }; } function isTerrainBoundsCacheEntryCurrent( terrain: Terrain, entry: TerrainBoundsCacheEntry ): boolean { return ( entry.heights === terrain.heights && entry.sampleCountX === terrain.sampleCountX && entry.sampleCountZ === terrain.sampleCountZ && entry.cellSize === terrain.cellSize && areVec3Equal(entry.position, terrain.position) ); } export function invalidateTerrainBoundsCache(terrain: Terrain) { terrainBoundsCache.delete(terrain); } export function updateTerrainBoundsCacheAfterHeightPatch( terrain: Terrain, patch: readonly TerrainHeightPatchEntry[] ) { if (patch.length === 0) { return; } const cachedEntry = terrainBoundsCache.get(terrain); if ( cachedEntry === undefined || !isTerrainBoundsCacheEntryCurrent(terrain, cachedEntry) ) { return; } let minHeight = cachedEntry.minHeight; let maxHeight = cachedEntry.maxHeight; let requiresRescan = false; for (const entry of patch) { if ( !Number.isInteger(entry.index) || entry.index < 0 || entry.index >= terrain.heights.length || !Number.isFinite(entry.before) || !Number.isFinite(entry.after) ) { requiresRescan = true; break; } if (entry.before === minHeight && entry.after > entry.before) { requiresRescan = true; break; } if (entry.before === maxHeight && entry.after < entry.before) { requiresRescan = true; break; } minHeight = Math.min(minHeight, entry.after); maxHeight = Math.max(maxHeight, entry.after); } if (requiresRescan) { invalidateTerrainBoundsCache(terrain); return; } terrainBoundsCache.set( terrain, createTerrainBoundsCacheEntry(terrain, minHeight, maxHeight) ); } function cloneTerrainSampleBounds(bounds: TerrainSampleBounds): TerrainSampleBounds { return { minSampleX: bounds.minSampleX, maxSampleX: bounds.maxSampleX, minSampleZ: bounds.minSampleZ, maxSampleZ: bounds.maxSampleZ }; } function mergeTerrainSampleBounds( currentBounds: TerrainSampleBounds | null, nextBounds: TerrainSampleBounds ): TerrainSampleBounds { if (currentBounds === null) { return cloneTerrainSampleBounds(nextBounds); } return { minSampleX: Math.min(currentBounds.minSampleX, nextBounds.minSampleX), maxSampleX: Math.max(currentBounds.maxSampleX, nextBounds.maxSampleX), minSampleZ: Math.min(currentBounds.minSampleZ, nextBounds.minSampleZ), maxSampleZ: Math.max(currentBounds.maxSampleZ, nextBounds.maxSampleZ) }; } export function getFullTerrainSampleBounds( terrain: Pick ): TerrainSampleBounds { return { minSampleX: 0, maxSampleX: terrain.sampleCountX - 1, minSampleZ: 0, maxSampleZ: terrain.sampleCountZ - 1 }; } export function markTerrainRenderSamplesDirty( terrain: Terrain, bounds: TerrainSampleBounds | null ) { if (bounds === null) { return; } const currentState = terrainRenderDirtyState.get(terrain) ?? { revision: 0, entries: [] }; const nextRevision = currentState.revision + 1; const nextEntries = [ ...currentState.entries, { revision: nextRevision, bounds: cloneTerrainSampleBounds(bounds) } ]; if (nextEntries.length > MAX_TERRAIN_RENDER_DIRTY_HISTORY) { nextEntries.splice(0, nextEntries.length - MAX_TERRAIN_RENDER_DIRTY_HISTORY); } terrainRenderDirtyState.set(terrain, { revision: nextRevision, entries: nextEntries }); } export function getTerrainRenderDirtyRevision(terrain: Terrain): number { return terrainRenderDirtyState.get(terrain)?.revision ?? 0; } export function getTerrainRenderDirtyBoundsSince( terrain: Terrain, revision: number ): { revision: number; dirtyBounds: TerrainSampleBounds | null; } { const state = terrainRenderDirtyState.get(terrain); if (state === undefined || revision >= state.revision) { return { revision: state?.revision ?? 0, dirtyBounds: null }; } const firstEntry = state.entries[0]; if (firstEntry === undefined || revision < firstEntry.revision - 1) { return { revision: state.revision, dirtyBounds: getFullTerrainSampleBounds(terrain) }; } let dirtyBounds: TerrainSampleBounds | null = null; for (const entry of state.entries) { if (entry.revision <= revision) { continue; } dirtyBounds = mergeTerrainSampleBounds(dirtyBounds, entry.bounds); } return { revision: state.revision, dirtyBounds }; } export function getTerrainBounds(terrain: Terrain): { min: Vec3; max: Vec3 } { const cachedEntry = terrainBoundsCache.get(terrain); if ( cachedEntry !== undefined && isTerrainBoundsCacheEntryCurrent(terrain, cachedEntry) ) { return cloneTerrainBounds(cachedEntry.bounds); } let minHeight = Number.POSITIVE_INFINITY; let maxHeight = Number.NEGATIVE_INFINITY; for (const height of terrain.heights) { minHeight = Math.min(minHeight, height); maxHeight = Math.max(maxHeight, height); } if (!Number.isFinite(minHeight) || !Number.isFinite(maxHeight)) { minHeight = 0; maxHeight = 0; } const nextEntry = createTerrainBoundsCacheEntry( terrain, minHeight, maxHeight ); terrainBoundsCache.set(terrain, nextEntry); return cloneTerrainBounds(nextEntry.bounds); } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function lerp(start: number, end: number, alpha: number): number { return start + (end - start) * alpha; } function sampleTerrainHeightAtGridCoordinate( terrain: Terrain, sampleX: number, sampleZ: number ): number { const clampedSampleX = clamp(sampleX, 0, terrain.sampleCountX - 1); const clampedSampleZ = clamp(sampleZ, 0, terrain.sampleCountZ - 1); const minSampleX = Math.floor(clampedSampleX); const maxSampleX = Math.min(terrain.sampleCountX - 1, minSampleX + 1); const minSampleZ = Math.floor(clampedSampleZ); const maxSampleZ = Math.min(terrain.sampleCountZ - 1, minSampleZ + 1); const blendX = clampedSampleX - minSampleX; const blendZ = clampedSampleZ - minSampleZ; const height00 = getTerrainHeightAtSample(terrain, minSampleX, minSampleZ); const height10 = getTerrainHeightAtSample(terrain, maxSampleX, minSampleZ); const height01 = getTerrainHeightAtSample(terrain, minSampleX, maxSampleZ); const height11 = getTerrainHeightAtSample(terrain, maxSampleX, maxSampleZ); return lerp( lerp(height00, height10, blendX), lerp(height01, height11, blendX), blendZ ); } export function sampleTerrainHeightAtLocalPosition( terrain: Terrain, localX: number, localZ: number, clampToBounds = false ): number | null { const sampleSpaceX = localX / terrain.cellSize; const sampleSpaceZ = localZ / terrain.cellSize; const maxSampleX = terrain.sampleCountX - 1; const maxSampleZ = terrain.sampleCountZ - 1; if (!clampToBounds) { if ( sampleSpaceX < 0 || sampleSpaceX > maxSampleX || sampleSpaceZ < 0 || sampleSpaceZ > maxSampleZ ) { return null; } } return sampleTerrainHeightAtGridCoordinate( terrain, sampleSpaceX, sampleSpaceZ ); } export function sampleTerrainHeightAtWorldPosition( terrain: Terrain, worldX: number, worldZ: number, clampToBounds = false ): number | null { return sampleTerrainHeightAtLocalPosition( terrain, worldX - terrain.position.x, worldZ - terrain.position.z, clampToBounds ); } function getStoredTerrainPaintWeightAtSample( terrain: Terrain, sampleX: number, sampleZ: number, layerOffset: number ): number { const offset = getTerrainPaintWeightSampleOffset(terrain, sampleX, sampleZ); return terrain.paintWeights[offset + layerOffset] ?? 0; } export function getTerrainFoliageMaskSampleIndex( mask: Pick< TerrainFoliageMask | TerrainFoliageBlockerMask, "resolutionX" | "resolutionZ" >, sampleX: number, sampleZ: number ): number { if ( !Number.isInteger(sampleX) || sampleX < 0 || sampleX >= mask.resolutionX ) { throw new Error(`Terrain foliage mask sampleX ${sampleX} is out of range.`); } if ( !Number.isInteger(sampleZ) || sampleZ < 0 || sampleZ >= mask.resolutionZ ) { throw new Error(`Terrain foliage mask sampleZ ${sampleZ} is out of range.`); } return sampleZ * mask.resolutionX + sampleX; } export function getTerrainFoliageMaskValueAtSample( mask: TerrainFoliageMask, sampleX: number, sampleZ: number ): number { return ( mask.values[getTerrainFoliageMaskSampleIndex(mask, sampleX, sampleZ)] ?? 0 ); } export function getTerrainFoliageBlockerMaskValueAtSample( mask: TerrainFoliageBlockerMask, sampleX: number, sampleZ: number ): number { return ( mask.values[getTerrainFoliageMaskSampleIndex(mask, sampleX, sampleZ)] ?? 0 ); } export function getTerrainFoliageMask( terrain: Pick, layerId: string ): TerrainFoliageMask | null { return terrain.foliageMasks[layerId] ?? null; } export function getOrCreateTerrainFoliageMask( terrain: Terrain, layerId: string ): TerrainFoliageMask { const normalizedLayerId = normalizeTerrainFoliageLayerId( layerId, "Terrain foliage mask layerId" ); const currentMask = terrain.foliageMasks[normalizedLayerId]; if (currentMask !== undefined) { return currentMask; } const nextMask = createEmptyTerrainFoliageMask(terrain, normalizedLayerId); terrain.foliageMasks[normalizedLayerId] = nextMask; return nextMask; } export function isTerrainFoliageMaskEmpty( mask: TerrainFoliageMask ): boolean { return mask.values.every((value) => value === 0); } export function isTerrainFoliageBlockerMaskEmpty( mask: TerrainFoliageBlockerMask ): boolean { return mask.values.every((value) => value === 0); } function sampleTerrainPaintWeightAtGridCoordinate( terrain: Terrain, sampleX: number, sampleZ: number, layerOffset: number ): number { const clampedSampleX = clamp(sampleX, 0, terrain.sampleCountX - 1); const clampedSampleZ = clamp(sampleZ, 0, terrain.sampleCountZ - 1); const minSampleX = Math.floor(clampedSampleX); const maxSampleX = Math.min(terrain.sampleCountX - 1, minSampleX + 1); const minSampleZ = Math.floor(clampedSampleZ); const maxSampleZ = Math.min(terrain.sampleCountZ - 1, minSampleZ + 1); const blendX = clampedSampleX - minSampleX; const blendZ = clampedSampleZ - minSampleZ; const weight00 = getStoredTerrainPaintWeightAtSample( terrain, minSampleX, minSampleZ, layerOffset ); const weight10 = getStoredTerrainPaintWeightAtSample( terrain, maxSampleX, minSampleZ, layerOffset ); const weight01 = getStoredTerrainPaintWeightAtSample( terrain, minSampleX, maxSampleZ, layerOffset ); const weight11 = getStoredTerrainPaintWeightAtSample( terrain, maxSampleX, maxSampleZ, layerOffset ); return lerp( lerp(weight00, weight10, blendX), lerp(weight01, weight11, blendX), blendZ ); } function sampleTerrainFoliageMaskAtGridCoordinate( mask: TerrainFoliageMask | TerrainFoliageBlockerMask, sampleX: number, sampleZ: number ): number { const clampedSampleX = clamp(sampleX, 0, mask.resolutionX - 1); const clampedSampleZ = clamp(sampleZ, 0, mask.resolutionZ - 1); const minSampleX = Math.floor(clampedSampleX); const maxSampleX = Math.min(mask.resolutionX - 1, minSampleX + 1); const minSampleZ = Math.floor(clampedSampleZ); const maxSampleZ = Math.min(mask.resolutionZ - 1, minSampleZ + 1); const blendX = clampedSampleX - minSampleX; const blendZ = clampedSampleZ - minSampleZ; const readMaskValue = (x: number, z: number) => mask.values[getTerrainFoliageMaskSampleIndex(mask, x, z)] ?? 0; const value00 = readMaskValue(minSampleX, minSampleZ); const value10 = readMaskValue(maxSampleX, minSampleZ); const value01 = readMaskValue(minSampleX, maxSampleZ); const value11 = readMaskValue(maxSampleX, maxSampleZ); return lerp( lerp(value00, value10, blendX), lerp(value01, value11, blendX), blendZ ); } function sampleTerrainFoliageBlockerMaskAtGridCoordinate( mask: TerrainFoliageBlockerMask, sampleX: number, sampleZ: number ): number { return sampleTerrainFoliageMaskAtGridCoordinate(mask, sampleX, sampleZ); } export function sampleTerrainFoliageMaskAtLocalPosition( terrain: Terrain, layerId: string, localX: number, localZ: number, clampToBounds = false ): number | null { const sampleSpaceX = localX / terrain.cellSize; const sampleSpaceZ = localZ / terrain.cellSize; const maxSampleX = terrain.sampleCountX - 1; const maxSampleZ = terrain.sampleCountZ - 1; if (!clampToBounds) { if ( sampleSpaceX < 0 || sampleSpaceX > maxSampleX || sampleSpaceZ < 0 || sampleSpaceZ > maxSampleZ ) { return null; } } const mask = getTerrainFoliageMask(terrain, layerId); if (mask === null) { return 0; } return sampleTerrainFoliageMaskAtGridCoordinate( mask, sampleSpaceX, sampleSpaceZ ); } export function sampleTerrainFoliageMaskAtWorldPosition( terrain: Terrain, layerId: string, worldX: number, worldZ: number, clampToBounds = false ): number | null { return sampleTerrainFoliageMaskAtLocalPosition( terrain, layerId, worldX - terrain.position.x, worldZ - terrain.position.z, clampToBounds ); } export function sampleTerrainFoliageBlockerMaskAtLocalPosition( terrain: Terrain, localX: number, localZ: number, clampToBounds = false ): number | null { const sampleSpaceX = localX / terrain.cellSize; const sampleSpaceZ = localZ / terrain.cellSize; const maxSampleX = terrain.sampleCountX - 1; const maxSampleZ = terrain.sampleCountZ - 1; if (!clampToBounds) { if ( sampleSpaceX < 0 || sampleSpaceX > maxSampleX || sampleSpaceZ < 0 || sampleSpaceZ > maxSampleZ ) { return null; } } return sampleTerrainFoliageBlockerMaskAtGridCoordinate( terrain.foliageBlockerMask, sampleSpaceX, sampleSpaceZ ); } export function sampleTerrainFoliageBlockerMaskAtWorldPosition( terrain: Terrain, worldX: number, worldZ: number, clampToBounds = false ): number | null { return sampleTerrainFoliageBlockerMaskAtLocalPosition( terrain, worldX - terrain.position.x, worldZ - terrain.position.z, clampToBounds ); } function createTerrainPositionFromCenter( center: Vec3, sampleCountX: number, sampleCountZ: number, cellSize: number ): Vec3 { return { x: center.x - ((sampleCountX - 1) * cellSize) * 0.5, y: center.y, z: center.z - ((sampleCountZ - 1) * cellSize) * 0.5 }; } function getTerrainFootprintCenter(terrain: Terrain): Vec3 { return { x: terrain.position.x + getTerrainFootprintWidth(terrain) * 0.5, y: terrain.position.y, z: terrain.position.z + getTerrainFootprintDepth(terrain) * 0.5 }; } function createResampledTerrainHeights( terrain: Terrain, sampleCountX: number, sampleCountZ: number ): number[] { const heights = new Array(sampleCountX * sampleCountZ); for (let sampleZ = 0; sampleZ < sampleCountZ; sampleZ += 1) { const normalizedSampleZ = sampleCountZ === 1 ? 0 : sampleZ / (sampleCountZ - 1); const sourceSampleZ = normalizedSampleZ * (terrain.sampleCountZ - 1); for (let sampleX = 0; sampleX < sampleCountX; sampleX += 1) { const normalizedSampleX = sampleCountX === 1 ? 0 : sampleX / (sampleCountX - 1); const sourceSampleX = normalizedSampleX * (terrain.sampleCountX - 1); heights[sampleZ * sampleCountX + sampleX] = sampleTerrainHeightAtGridCoordinate( terrain, sourceSampleX, sourceSampleZ ); } } return heights; } function createResampledTerrainPaintWeights( terrain: Terrain, sampleCountX: number, sampleCountZ: number ): number[] { const storedWeightCount = getTerrainStoredPaintWeightCount( terrain.layers.length ); const paintWeights = new Array( sampleCountX * sampleCountZ * storedWeightCount ); for (let sampleZ = 0; sampleZ < sampleCountZ; sampleZ += 1) { const normalizedSampleZ = sampleCountZ === 1 ? 0 : sampleZ / (sampleCountZ - 1); const sourceSampleZ = normalizedSampleZ * (terrain.sampleCountZ - 1); for (let sampleX = 0; sampleX < sampleCountX; sampleX += 1) { const normalizedSampleX = sampleCountX === 1 ? 0 : sampleX / (sampleCountX - 1); const sourceSampleX = normalizedSampleX * (terrain.sampleCountX - 1); const offset = (sampleZ * sampleCountX + sampleX) * storedWeightCount; for (let layerOffset = 0; layerOffset < storedWeightCount; layerOffset += 1) { paintWeights[offset + layerOffset] = sampleTerrainPaintWeightAtGridCoordinate( terrain, sourceSampleX, sourceSampleZ, layerOffset ); } } } return paintWeights; } function createResampledTerrainFoliageMasks( terrain: Terrain, sampleCountX: number, sampleCountZ: number ): TerrainFoliageMaskRegistry { return Object.fromEntries( Object.entries(terrain.foliageMasks).map(([layerId, mask]) => { const values = new Array(sampleCountX * sampleCountZ); for (let sampleZ = 0; sampleZ < sampleCountZ; sampleZ += 1) { const normalizedSampleZ = sampleCountZ === 1 ? 0 : sampleZ / (sampleCountZ - 1); const sourceSampleZ = normalizedSampleZ * (mask.resolutionZ - 1); for (let sampleX = 0; sampleX < sampleCountX; sampleX += 1) { const normalizedSampleX = sampleCountX === 1 ? 0 : sampleX / (sampleCountX - 1); const sourceSampleX = normalizedSampleX * (mask.resolutionX - 1); values[sampleZ * sampleCountX + sampleX] = sampleTerrainFoliageMaskAtGridCoordinate( mask, sourceSampleX, sourceSampleZ ); } } return [ layerId, createTerrainFoliageMask({ layerId, resolutionX: sampleCountX, resolutionZ: sampleCountZ, values }) ]; }) ); } function createResampledTerrainFoliageBlockerMask( terrain: Terrain, sampleCountX: number, sampleCountZ: number ): TerrainFoliageBlockerMask { const values = new Array(sampleCountX * sampleCountZ); for (let sampleZ = 0; sampleZ < sampleCountZ; sampleZ += 1) { const normalizedSampleZ = sampleCountZ === 1 ? 0 : sampleZ / (sampleCountZ - 1); const sourceSampleZ = normalizedSampleZ * (terrain.foliageBlockerMask.resolutionZ - 1); for (let sampleX = 0; sampleX < sampleCountX; sampleX += 1) { const normalizedSampleX = sampleCountX === 1 ? 0 : sampleX / (sampleCountX - 1); const sourceSampleX = normalizedSampleX * (terrain.foliageBlockerMask.resolutionX - 1); values[sampleZ * sampleCountX + sampleX] = sampleTerrainFoliageBlockerMaskAtGridCoordinate( terrain.foliageBlockerMask, sourceSampleX, sourceSampleZ ); } } return createTerrainFoliageBlockerMask({ resolutionX: sampleCountX, resolutionZ: sampleCountZ, values }); } export function resizeTerrainGrid( terrain: Terrain, options: Pick & { preserveCenter?: boolean; } ): Terrain { const sampleCountX = normalizeTerrainSampleCount( options.sampleCountX, "Terrain sampleCountX" ); const sampleCountZ = normalizeTerrainSampleCount( options.sampleCountZ, "Terrain sampleCountZ" ); const cellSize = normalizeTerrainCellSize(options.cellSize); const preserveCenter = options.preserveCenter ?? true; const nextPosition = preserveCenter ? createTerrainPositionFromCenter( getTerrainFootprintCenter(terrain), sampleCountX, sampleCountZ, cellSize ) : cloneVec3(terrain.position); return createTerrain({ ...terrain, position: nextPosition, sampleCountX, sampleCountZ, cellSize, heights: createResampledTerrainHeights(terrain, sampleCountX, sampleCountZ), paintWeights: createResampledTerrainPaintWeights( terrain, sampleCountX, sampleCountZ ), foliageMasks: createResampledTerrainFoliageMasks( terrain, sampleCountX, sampleCountZ ), foliageBlockerMask: createResampledTerrainFoliageBlockerMask( terrain, sampleCountX, sampleCountZ ) }); } function createDefaultTerrainPosition( sampleCountX: number, sampleCountZ: number, cellSize: number ): Vec3 { return { x: -((sampleCountX - 1) * cellSize) * 0.5, y: 0, z: -((sampleCountZ - 1) * cellSize) * 0.5 }; } export function createTerrain( overrides: Partial< Pick< Terrain, | "id" | "name" | "visible" | "enabled" | "collisionEnabled" | "position" | "sampleCountX" | "sampleCountZ" | "cellSize" | "heights" | "layers" | "paintWeights" | "foliageMasks" | "foliageBlockerMask" > > = {} ): Terrain { const sampleCountX = normalizeTerrainSampleCount( overrides.sampleCountX ?? DEFAULT_TERRAIN_SAMPLE_COUNT_X, "Terrain sampleCountX" ); const sampleCountZ = normalizeTerrainSampleCount( overrides.sampleCountZ ?? DEFAULT_TERRAIN_SAMPLE_COUNT_Z, "Terrain sampleCountZ" ); const cellSize = normalizeTerrainCellSize( overrides.cellSize ?? DEFAULT_TERRAIN_CELL_SIZE ); const position = cloneVec3( overrides.position ?? createDefaultTerrainPosition(sampleCountX, sampleCountZ, cellSize) ); const heights = overrides.heights !== undefined ? [...overrides.heights] : createFlatTerrainHeights(sampleCountX, sampleCountZ); const layers = normalizeTerrainLayers(overrides.layers); const paintWeights = normalizeTerrainPaintWeights( sampleCountX, sampleCountZ, layers.length, overrides.paintWeights ); const foliageMasks = normalizeTerrainFoliageMasks( sampleCountX, sampleCountZ, overrides.foliageMasks ); const foliageBlockerMask = normalizeTerrainFoliageBlockerMask( sampleCountX, sampleCountZ, overrides.foliageBlockerMask ); const visible = overrides.visible ?? DEFAULT_TERRAIN_VISIBLE; const enabled = overrides.enabled ?? DEFAULT_TERRAIN_ENABLED; const collisionEnabled = normalizeTerrainCollisionEnabled( overrides.collisionEnabled ?? DEFAULT_TERRAIN_COLLISION_ENABLED ); assertFiniteVec3(position, "Terrain position"); if (typeof visible !== "boolean") { throw new Error("Terrain visible must be a boolean."); } if (typeof enabled !== "boolean") { throw new Error("Terrain enabled must be a boolean."); } if (heights.length !== sampleCountX * sampleCountZ) { throw new Error( `Terrain heights must contain exactly ${sampleCountX * sampleCountZ} samples.` ); } if (heights.some((height) => !Number.isFinite(height))) { throw new Error("Terrain heights must remain finite."); } return { id: overrides.id ?? createOpaqueId("terrain"), kind: "terrain", name: normalizeTerrainName(overrides.name), visible, enabled, collisionEnabled, position, sampleCountX, sampleCountZ, cellSize, heights, layers, paintWeights, foliageMasks, foliageBlockerMask }; } export function cloneTerrain(terrain: Terrain): Terrain { return createTerrain(terrain); } export function areTerrainsEqual(left: Terrain, right: Terrain): boolean { return ( left.id === right.id && left.kind === right.kind && left.name === right.name && left.visible === right.visible && left.enabled === right.enabled && left.collisionEnabled === right.collisionEnabled && areVec3Equal(left.position, right.position) && left.sampleCountX === right.sampleCountX && left.sampleCountZ === right.sampleCountZ && left.cellSize === right.cellSize && left.heights.length === right.heights.length && left.heights.every((height, index) => height === right.heights[index]) && left.layers.length === right.layers.length && left.layers.every( (layer, index) => layer.materialId === right.layers[index]?.materialId ) && left.paintWeights.length === right.paintWeights.length && left.paintWeights.every( (weight, index) => weight === right.paintWeights[index] ) && Object.keys(left.foliageMasks).length === Object.keys(right.foliageMasks).length && Object.entries(left.foliageMasks).every(([layerId, leftMask]) => { const rightMask = right.foliageMasks[layerId]; return ( rightMask !== undefined && leftMask.layerId === rightMask.layerId && leftMask.resolutionX === rightMask.resolutionX && leftMask.resolutionZ === rightMask.resolutionZ && leftMask.values.length === rightMask.values.length && leftMask.values.every((value, index) => value === rightMask.values[index]) ); }) && left.foliageBlockerMask.resolutionX === right.foliageBlockerMask.resolutionX && left.foliageBlockerMask.resolutionZ === right.foliageBlockerMask.resolutionZ && left.foliageBlockerMask.values.length === right.foliageBlockerMask.values.length && left.foliageBlockerMask.values.every( (value, index) => value === right.foliageBlockerMask.values[index] ) ); } export function compareTerrains(left: Terrain, right: Terrain): number { const leftName = left.name ?? ""; const rightName = right.name ?? ""; if (leftName !== rightName) { return leftName.localeCompare(rightName); } return left.id.localeCompare(right.id); } export function getTerrains(terrains: Record): Terrain[] { return Object.values(terrains).sort(compareTerrains); } export function getTerrainKindLabel(): string { return "Terrain"; }