1618 lines
42 KiB
TypeScript
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";
|
|
}
|