Files
webeditor3d/src/document/terrains.ts

1618 lines
42 KiB
TypeScript

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<string, TerrainFoliageMask>;
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 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 || layerIndex >= TERRAIN_LAYER_COUNT) {
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 (layers.length !== TERRAIN_LAYER_COUNT) {
throw new Error(
`Terrain layers must contain exactly ${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 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
): number[] {
const normalizedSampleCountX = normalizeTerrainSampleCount(
sampleCountX,
"Terrain sampleCountX"
);
const normalizedSampleCountZ = normalizeTerrainSampleCount(
sampleCountZ,
"Terrain sampleCountZ"
);
return new Array(
normalizedSampleCountX *
normalizedSampleCountZ *
(TERRAIN_LAYER_COUNT - 1)
).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<Terrain, "sampleCountX" | "sampleCountZ">,
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<Terrain, "sampleCountX" | "sampleCountZ">,
sampleX: number,
sampleZ: number
): number {
return getTerrainSampleIndex(terrain, sampleX, sampleZ) * (TERRAIN_LAYER_COUNT - 1);
}
export function getTerrainHeightAtSample(
terrain: Terrain,
sampleX: number,
sampleZ: number
): number {
return terrain.heights[getTerrainSampleIndex(terrain, sampleX, sampleZ)] ?? 0;
}
function normalizeTerrainPaintWeights(
sampleCountX: number,
sampleCountZ: number,
paintWeights: readonly number[] | undefined
): number[] {
const expectedLength =
sampleCountX * sampleCountZ * (TERRAIN_LAYER_COUNT - 1);
const normalizedPaintWeights =
paintWeights === undefined
? createFlatTerrainPaintWeights(sampleCountX, sampleCountZ)
: [...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 * (TERRAIN_LAYER_COUNT - 1);
let weightSum = 0;
for (
let layerOffset = 0;
layerOffset < TERRAIN_LAYER_COUNT - 1;
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 < TERRAIN_LAYER_COUNT - 1;
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<Terrain, "sampleCountX" | "sampleCountZ">,
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<Terrain, "sampleCountX" | "sampleCountZ">
): 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<Terrain, "sampleCountX" | "sampleCountZ" | "paintWeights">,
sampleX: number,
sampleZ: number
): [number, number, number, number] {
const offset = getTerrainPaintWeightSampleOffset(terrain, sampleX, sampleZ);
const layer1 = terrain.paintWeights[offset] ?? 0;
const layer2 = terrain.paintWeights[offset + 1] ?? 0;
const layer3 = terrain.paintWeights[offset + 2] ?? 0;
const baseLayer = Math.max(0, 1 - (layer1 + layer2 + layer3));
const weightSum = baseLayer + layer1 + layer2 + layer3;
if (weightSum <= 0) {
return [1, 0, 0, 0];
}
return [
baseLayer / weightSum,
layer1 / weightSum,
layer2 / weightSum,
layer3 / 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<Terrain, "sampleCountX" | "cellSize">
): number {
return (terrain.sampleCountX - 1) * terrain.cellSize;
}
export function getTerrainFootprintDepth(
terrain: Pick<Terrain, "sampleCountZ" | "cellSize">
): number {
return (terrain.sampleCountZ - 1) * terrain.cellSize;
}
const terrainBoundsCache = new WeakMap<Terrain, TerrainBoundsCacheEntry>();
const terrainRenderDirtyState = new WeakMap<Terrain, TerrainRenderDirtyState>();
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<Terrain, "sampleCountX" | "sampleCountZ">
): 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
);
}
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<Terrain, "foliageMasks">,
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<number>(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 paintWeights = new Array<number>(
sampleCountX * sampleCountZ * (TERRAIN_LAYER_COUNT - 1)
);
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) * (TERRAIN_LAYER_COUNT - 1);
for (
let layerOffset = 0;
layerOffset < TERRAIN_LAYER_COUNT - 1;
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<number>(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<number>(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<Terrain, "sampleCountX" | "sampleCountZ" | "cellSize"> & {
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,
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<string, Terrain>): Terrain[] {
return Object.values(terrains).sort(compareTerrains);
}
export function getTerrainKindLabel(): string {
return "Terrain";
}