auto-git:
[add] src/foliage/foliage-scatter.ts
This commit is contained in:
651
src/foliage/foliage-scatter.ts
Normal file
651
src/foliage/foliage-scatter.ts
Normal file
@@ -0,0 +1,651 @@
|
||||
import type { Vec3 } from "../core/vector";
|
||||
import type { Terrain } from "../document/terrains";
|
||||
import {
|
||||
getTerrainBounds,
|
||||
getTerrainFootprintDepth,
|
||||
getTerrainFootprintWidth,
|
||||
sampleTerrainFoliageMaskAtWorldPosition
|
||||
} from "../document/terrains";
|
||||
import { sampleTerrainHeightAtWorldPosition } from "../geometry/terrain-brush";
|
||||
|
||||
import { BUNDLED_FOLIAGE_PROTOTYPE_REGISTRY } from "./bundled-foliage-manifest";
|
||||
import type {
|
||||
FoliageLayer,
|
||||
FoliageLayerRegistry,
|
||||
FoliagePrototype,
|
||||
FoliagePrototypeRegistry
|
||||
} from "./foliage";
|
||||
|
||||
export interface DerivedFoliageInstance {
|
||||
terrainId: string;
|
||||
layerId: string;
|
||||
prototypeId: string;
|
||||
position: Vec3;
|
||||
normal: Vec3;
|
||||
yawRadians: number;
|
||||
scale: number;
|
||||
colorTint: Vec3;
|
||||
windPhase: number;
|
||||
windStrength: number;
|
||||
lodBias: number;
|
||||
alignToNormal: number;
|
||||
cullDistance: number;
|
||||
}
|
||||
|
||||
export interface DerivedFoliageScatterChunk {
|
||||
id: string;
|
||||
terrainId: string;
|
||||
layerId: string;
|
||||
chunkX: number;
|
||||
chunkZ: number;
|
||||
bounds: {
|
||||
min: Vec3;
|
||||
max: Vec3;
|
||||
};
|
||||
instances: DerivedFoliageInstance[];
|
||||
}
|
||||
|
||||
export interface DerivedFoliageScatter {
|
||||
chunks: DerivedFoliageScatterChunk[];
|
||||
instanceCount: number;
|
||||
}
|
||||
|
||||
export interface GenerateFoliageScatterOptions {
|
||||
chunkSizeMeters?: number;
|
||||
maxInstancesPerChunk?: number;
|
||||
}
|
||||
|
||||
export interface GenerateFoliageScatterForTerrainOptions
|
||||
extends GenerateFoliageScatterOptions {
|
||||
terrain: Terrain;
|
||||
foliageLayers: FoliageLayerRegistry;
|
||||
foliagePrototypes?: FoliagePrototypeRegistry;
|
||||
bundledFoliagePrototypes?:
|
||||
| FoliagePrototypeRegistry
|
||||
| readonly FoliagePrototype[];
|
||||
}
|
||||
|
||||
export interface GenerateFoliageScatterForSceneOptions
|
||||
extends GenerateFoliageScatterOptions {
|
||||
terrains: Record<string, Terrain>;
|
||||
foliageLayers: FoliageLayerRegistry;
|
||||
foliagePrototypes?: FoliagePrototypeRegistry;
|
||||
bundledFoliagePrototypes?:
|
||||
| FoliagePrototypeRegistry
|
||||
| readonly FoliagePrototype[];
|
||||
}
|
||||
|
||||
interface WeightedFoliagePrototype {
|
||||
prototype: FoliagePrototype;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
interface TerrainChunkFootprint {
|
||||
chunkX: number;
|
||||
chunkZ: number;
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minZ: number;
|
||||
maxZ: number;
|
||||
area: number;
|
||||
}
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
export const DEFAULT_FOLIAGE_SCATTER_CHUNK_SIZE_METERS = 16;
|
||||
export const DEFAULT_MAX_FOLIAGE_SCATTER_INSTANCES_PER_CHUNK = 512;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return clamp(value, 0, 1);
|
||||
}
|
||||
|
||||
function lerp(start: number, end: number, alpha: number): number {
|
||||
return start + (end - start) * alpha;
|
||||
}
|
||||
|
||||
function normalizeVector(vector: Vec3): Vec3 {
|
||||
const length = Math.hypot(vector.x, vector.y, vector.z);
|
||||
|
||||
if (length <= 0.000001) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 1,
|
||||
z: 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: vector.x / length,
|
||||
y: vector.y / length,
|
||||
z: vector.z / length
|
||||
};
|
||||
}
|
||||
|
||||
function hashStringToUint32(value: string): number {
|
||||
let hash = 2166136261;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function mixUint32(left: number, right: number): number {
|
||||
let value = (left ^ right) >>> 0;
|
||||
value = Math.imul(value ^ (value >>> 16), 2246822507);
|
||||
value = Math.imul(value ^ (value >>> 13), 3266489909);
|
||||
return (value ^ (value >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
function createSeededRandom(seed: string): () => number {
|
||||
let state = hashStringToUint32(seed);
|
||||
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let value = state;
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function hashGridNoise(seed: number, x: number, z: number): number {
|
||||
const mixedX = mixUint32(seed, x >>> 0);
|
||||
const mixedZ = mixUint32(mixedX, z >>> 0);
|
||||
return mixedZ / 4294967295;
|
||||
}
|
||||
|
||||
function smoothstep(value: number): number {
|
||||
return value * value * (3 - 2 * value);
|
||||
}
|
||||
|
||||
function sampleValueNoise2d(
|
||||
worldX: number,
|
||||
worldZ: number,
|
||||
scale: number,
|
||||
seed: number
|
||||
): number {
|
||||
if (!Number.isFinite(scale) || scale <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const noiseX = worldX / scale;
|
||||
const noiseZ = worldZ / scale;
|
||||
const x0 = Math.floor(noiseX);
|
||||
const z0 = Math.floor(noiseZ);
|
||||
const x1 = x0 + 1;
|
||||
const z1 = z0 + 1;
|
||||
const tx = smoothstep(noiseX - x0);
|
||||
const tz = smoothstep(noiseZ - z0);
|
||||
const top = lerp(
|
||||
hashGridNoise(seed, x0, z0),
|
||||
hashGridNoise(seed, x1, z0),
|
||||
tx
|
||||
);
|
||||
const bottom = lerp(
|
||||
hashGridNoise(seed, x0, z1),
|
||||
hashGridNoise(seed, x1, z1),
|
||||
tx
|
||||
);
|
||||
|
||||
return lerp(top, bottom, tz);
|
||||
}
|
||||
|
||||
function createPrototypeRegistry(
|
||||
projectPrototypes: FoliagePrototypeRegistry = {},
|
||||
bundledPrototypes:
|
||||
| FoliagePrototypeRegistry
|
||||
| readonly FoliagePrototype[] = BUNDLED_FOLIAGE_PROTOTYPE_REGISTRY
|
||||
): FoliagePrototypeRegistry {
|
||||
const bundledRegistry = Array.isArray(bundledPrototypes)
|
||||
? Object.fromEntries(
|
||||
bundledPrototypes.map((prototype) => [prototype.id, prototype])
|
||||
)
|
||||
: bundledPrototypes;
|
||||
|
||||
return {
|
||||
...bundledRegistry,
|
||||
...projectPrototypes
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLayerWeightedPrototypes(
|
||||
layer: FoliageLayer,
|
||||
prototypes: FoliagePrototypeRegistry
|
||||
): WeightedFoliagePrototype[] {
|
||||
const seenPrototypeIds = new Set<string>();
|
||||
const weightedPrototypes: WeightedFoliagePrototype[] = [];
|
||||
|
||||
for (const prototypeId of layer.prototypeIds) {
|
||||
if (seenPrototypeIds.has(prototypeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenPrototypeIds.add(prototypeId);
|
||||
const prototype = prototypes[prototypeId];
|
||||
const weight = prototype?.densityWeight ?? 0;
|
||||
|
||||
if (
|
||||
prototype === undefined ||
|
||||
!Number.isFinite(weight) ||
|
||||
weight <= 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
weightedPrototypes.push({
|
||||
prototype,
|
||||
weight
|
||||
});
|
||||
}
|
||||
|
||||
return weightedPrototypes;
|
||||
}
|
||||
|
||||
function chooseWeightedPrototype(
|
||||
weightedPrototypes: readonly WeightedFoliagePrototype[],
|
||||
random: () => number
|
||||
): FoliagePrototype | null {
|
||||
const totalWeight = weightedPrototypes.reduce(
|
||||
(sum, entry) => sum + entry.weight,
|
||||
0
|
||||
);
|
||||
|
||||
if (totalWeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cursor = random() * totalWeight;
|
||||
|
||||
for (const entry of weightedPrototypes) {
|
||||
cursor -= entry.weight;
|
||||
|
||||
if (cursor <= 0) {
|
||||
return entry.prototype;
|
||||
}
|
||||
}
|
||||
|
||||
return weightedPrototypes[weightedPrototypes.length - 1]?.prototype ?? null;
|
||||
}
|
||||
|
||||
function getChunkFootprints(
|
||||
terrain: Terrain,
|
||||
chunkSizeMeters: number
|
||||
): TerrainChunkFootprint[] {
|
||||
const width = getTerrainFootprintWidth(terrain);
|
||||
const depth = getTerrainFootprintDepth(terrain);
|
||||
const chunkCountX = Math.max(1, Math.ceil(width / chunkSizeMeters));
|
||||
const chunkCountZ = Math.max(1, Math.ceil(depth / chunkSizeMeters));
|
||||
const chunks: TerrainChunkFootprint[] = [];
|
||||
|
||||
for (let chunkZ = 0; chunkZ < chunkCountZ; chunkZ += 1) {
|
||||
const minZ = terrain.position.z + chunkZ * chunkSizeMeters;
|
||||
const maxZ = Math.min(
|
||||
terrain.position.z + depth,
|
||||
minZ + chunkSizeMeters
|
||||
);
|
||||
|
||||
for (let chunkX = 0; chunkX < chunkCountX; chunkX += 1) {
|
||||
const minX = terrain.position.x + chunkX * chunkSizeMeters;
|
||||
const maxX = Math.min(
|
||||
terrain.position.x + width,
|
||||
minX + chunkSizeMeters
|
||||
);
|
||||
const area = Math.max(0, maxX - minX) * Math.max(0, maxZ - minZ);
|
||||
|
||||
if (area <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks.push({
|
||||
chunkX,
|
||||
chunkZ,
|
||||
minX,
|
||||
maxX,
|
||||
minZ,
|
||||
maxZ,
|
||||
area
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function sampleTerrainNormalAtWorldPosition(
|
||||
terrain: Terrain,
|
||||
worldX: number,
|
||||
worldZ: number
|
||||
): Vec3 {
|
||||
const step = Math.max(terrain.cellSize, 0.0001);
|
||||
const left =
|
||||
sampleTerrainHeightAtWorldPosition(terrain, worldX - step, worldZ, true) ??
|
||||
0;
|
||||
const right =
|
||||
sampleTerrainHeightAtWorldPosition(terrain, worldX + step, worldZ, true) ??
|
||||
0;
|
||||
const down =
|
||||
sampleTerrainHeightAtWorldPosition(terrain, worldX, worldZ - step, true) ??
|
||||
0;
|
||||
const up =
|
||||
sampleTerrainHeightAtWorldPosition(terrain, worldX, worldZ + step, true) ??
|
||||
0;
|
||||
|
||||
return normalizeVector({
|
||||
x: left - right,
|
||||
y: 2 * step,
|
||||
z: down - up
|
||||
});
|
||||
}
|
||||
|
||||
function getSlopeDegreesFromNormal(normal: Vec3): number {
|
||||
return (
|
||||
(Math.acos(clamp(normal.y, -1, 1)) * 180) /
|
||||
Math.PI
|
||||
);
|
||||
}
|
||||
|
||||
function getLayerMaskInfluence(
|
||||
terrain: Terrain,
|
||||
layer: FoliageLayer,
|
||||
worldX: number,
|
||||
worldZ: number
|
||||
): number {
|
||||
const maskValue =
|
||||
sampleTerrainFoliageMaskAtWorldPosition(
|
||||
terrain,
|
||||
layer.id,
|
||||
worldX,
|
||||
worldZ
|
||||
) ?? 0;
|
||||
|
||||
if (maskValue <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const noiseSeed = hashStringToUint32(`${layer.id}:${layer.seed}:noise`);
|
||||
const noiseValue = sampleValueNoise2d(
|
||||
worldX,
|
||||
worldZ,
|
||||
layer.noiseScale,
|
||||
noiseSeed
|
||||
);
|
||||
|
||||
if (noiseValue < layer.noiseThreshold) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const noiseInfluence = lerp(1, noiseValue, clamp01(layer.noiseStrength));
|
||||
return clamp01(maskValue * noiseInfluence);
|
||||
}
|
||||
|
||||
function createChunkBounds(
|
||||
terrain: Terrain,
|
||||
chunk: TerrainChunkFootprint
|
||||
): { min: Vec3; max: Vec3 } {
|
||||
const terrainBounds = getTerrainBounds(terrain);
|
||||
|
||||
return {
|
||||
min: {
|
||||
x: chunk.minX,
|
||||
y: terrainBounds.min.y,
|
||||
z: chunk.minZ
|
||||
},
|
||||
max: {
|
||||
x: chunk.maxX,
|
||||
y: terrainBounds.max.y,
|
||||
z: chunk.maxZ
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createFoliageInstance(options: {
|
||||
terrain: Terrain;
|
||||
layer: FoliageLayer;
|
||||
prototype: FoliagePrototype;
|
||||
worldX: number;
|
||||
worldZ: number;
|
||||
random: () => number;
|
||||
}): DerivedFoliageInstance | null {
|
||||
const height = sampleTerrainHeightAtWorldPosition(
|
||||
options.terrain,
|
||||
options.worldX,
|
||||
options.worldZ
|
||||
);
|
||||
|
||||
if (height === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normal = sampleTerrainNormalAtWorldPosition(
|
||||
options.terrain,
|
||||
options.worldX,
|
||||
options.worldZ
|
||||
);
|
||||
const slopeDegrees = getSlopeDegreesFromNormal(normal);
|
||||
|
||||
if (
|
||||
slopeDegrees < options.layer.minSlopeDegrees ||
|
||||
slopeDegrees > options.layer.maxSlopeDegrees
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const layerScale = lerp(
|
||||
Math.min(options.layer.minScale, options.layer.maxScale),
|
||||
Math.max(options.layer.minScale, options.layer.maxScale),
|
||||
options.random()
|
||||
);
|
||||
const prototypeScale = lerp(
|
||||
Math.min(options.prototype.minScale, options.prototype.maxScale),
|
||||
Math.max(options.prototype.minScale, options.prototype.maxScale),
|
||||
options.random()
|
||||
);
|
||||
const colorVariation = clamp01(
|
||||
Math.max(options.layer.colorVariation, options.prototype.colorVariation)
|
||||
);
|
||||
const createTintChannel = () =>
|
||||
clamp(1 + (options.random() * 2 - 1) * colorVariation, 0, 2);
|
||||
|
||||
return {
|
||||
terrainId: options.terrain.id,
|
||||
layerId: options.layer.id,
|
||||
prototypeId: options.prototype.id,
|
||||
position: {
|
||||
x: options.worldX,
|
||||
y: options.terrain.position.y + height,
|
||||
z: options.worldZ
|
||||
},
|
||||
normal,
|
||||
yawRadians: options.prototype.randomYaw ? options.random() * TAU : 0,
|
||||
scale: layerScale * prototypeScale,
|
||||
colorTint: {
|
||||
x: createTintChannel(),
|
||||
y: createTintChannel(),
|
||||
z: createTintChannel()
|
||||
},
|
||||
windPhase:
|
||||
options.random() * TAU * clamp01(options.prototype.windPhaseRandomness),
|
||||
windStrength: options.prototype.windStrength,
|
||||
lodBias: (options.random() * 2 - 1) * 0.5,
|
||||
alignToNormal: clamp01(options.layer.alignToNormal),
|
||||
cullDistance: options.prototype.defaultCullDistance
|
||||
};
|
||||
}
|
||||
|
||||
function generateFoliageScatterChunk(options: {
|
||||
terrain: Terrain;
|
||||
layer: FoliageLayer;
|
||||
chunk: TerrainChunkFootprint;
|
||||
weightedPrototypes: readonly WeightedFoliagePrototype[];
|
||||
maxInstancesPerChunk: number;
|
||||
}): DerivedFoliageScatterChunk | null {
|
||||
const candidateCount = Math.min(
|
||||
options.maxInstancesPerChunk,
|
||||
Math.ceil(options.chunk.area * Math.max(0, options.layer.density))
|
||||
);
|
||||
|
||||
if (candidateCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const random = createSeededRandom(
|
||||
[
|
||||
"foliage-scatter-v1",
|
||||
options.terrain.id,
|
||||
options.layer.id,
|
||||
String(options.layer.seed),
|
||||
String(options.chunk.chunkX),
|
||||
String(options.chunk.chunkZ)
|
||||
].join(":")
|
||||
);
|
||||
const instances: DerivedFoliageInstance[] = [];
|
||||
|
||||
for (
|
||||
let candidateIndex = 0;
|
||||
candidateIndex < candidateCount &&
|
||||
instances.length < options.maxInstancesPerChunk;
|
||||
candidateIndex += 1
|
||||
) {
|
||||
const worldX = lerp(options.chunk.minX, options.chunk.maxX, random());
|
||||
const worldZ = lerp(options.chunk.minZ, options.chunk.maxZ, random());
|
||||
const maskInfluence = getLayerMaskInfluence(
|
||||
options.terrain,
|
||||
options.layer,
|
||||
worldX,
|
||||
worldZ
|
||||
);
|
||||
|
||||
if (maskInfluence <= 0 || random() > maskInfluence) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prototype = chooseWeightedPrototype(
|
||||
options.weightedPrototypes,
|
||||
random
|
||||
);
|
||||
|
||||
if (prototype === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const instance = createFoliageInstance({
|
||||
terrain: options.terrain,
|
||||
layer: options.layer,
|
||||
prototype,
|
||||
worldX,
|
||||
worldZ,
|
||||
random
|
||||
});
|
||||
|
||||
if (instance !== null) {
|
||||
instances.push(instance);
|
||||
}
|
||||
}
|
||||
|
||||
if (instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${options.terrain.id}:${options.layer.id}:${options.chunk.chunkX}:${options.chunk.chunkZ}`,
|
||||
terrainId: options.terrain.id,
|
||||
layerId: options.layer.id,
|
||||
chunkX: options.chunk.chunkX,
|
||||
chunkZ: options.chunk.chunkZ,
|
||||
bounds: createChunkBounds(options.terrain, options.chunk),
|
||||
instances
|
||||
};
|
||||
}
|
||||
|
||||
export function generateFoliageScatterForTerrain(
|
||||
options: GenerateFoliageScatterForTerrainOptions
|
||||
): DerivedFoliageScatter {
|
||||
const chunkSizeMeters = Math.max(
|
||||
0.1,
|
||||
options.chunkSizeMeters ?? DEFAULT_FOLIAGE_SCATTER_CHUNK_SIZE_METERS
|
||||
);
|
||||
const maxInstancesPerChunk = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
options.maxInstancesPerChunk ??
|
||||
DEFAULT_MAX_FOLIAGE_SCATTER_INSTANCES_PER_CHUNK
|
||||
)
|
||||
);
|
||||
const prototypes = createPrototypeRegistry(
|
||||
options.foliagePrototypes,
|
||||
options.bundledFoliagePrototypes
|
||||
);
|
||||
const chunks: DerivedFoliageScatterChunk[] = [];
|
||||
|
||||
for (const layer of Object.values(options.foliageLayers).sort((left, right) =>
|
||||
left.id.localeCompare(right.id)
|
||||
)) {
|
||||
if (!layer.enabled || options.terrain.foliageMasks[layer.id] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const weightedPrototypes = resolveLayerWeightedPrototypes(layer, prototypes);
|
||||
|
||||
if (weightedPrototypes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const chunk of getChunkFootprints(options.terrain, chunkSizeMeters)) {
|
||||
const scatterChunk = generateFoliageScatterChunk({
|
||||
terrain: options.terrain,
|
||||
layer,
|
||||
chunk,
|
||||
weightedPrototypes,
|
||||
maxInstancesPerChunk
|
||||
});
|
||||
|
||||
if (scatterChunk !== null) {
|
||||
chunks.push(scatterChunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chunks,
|
||||
instanceCount: chunks.reduce(
|
||||
(sum, chunk) => sum + chunk.instances.length,
|
||||
0
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function generateFoliageScatterForScene(
|
||||
options: GenerateFoliageScatterForSceneOptions
|
||||
): DerivedFoliageScatter {
|
||||
const chunks: DerivedFoliageScatterChunk[] = [];
|
||||
|
||||
for (const terrain of Object.values(options.terrains).sort((left, right) =>
|
||||
left.id.localeCompare(right.id)
|
||||
)) {
|
||||
const terrainScatter = generateFoliageScatterForTerrain({
|
||||
terrain,
|
||||
foliageLayers: options.foliageLayers,
|
||||
foliagePrototypes: options.foliagePrototypes,
|
||||
bundledFoliagePrototypes: options.bundledFoliagePrototypes,
|
||||
chunkSizeMeters: options.chunkSizeMeters,
|
||||
maxInstancesPerChunk: options.maxInstancesPerChunk
|
||||
});
|
||||
|
||||
chunks.push(...terrainScatter.chunks);
|
||||
}
|
||||
|
||||
return {
|
||||
chunks,
|
||||
instanceCount: chunks.reduce(
|
||||
(sum, chunk) => sum + chunk.instances.length,
|
||||
0
|
||||
)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user