2026-05-02 04:53:19 +02:00
|
|
|
import {
|
|
|
|
|
Color,
|
|
|
|
|
Group,
|
|
|
|
|
InstancedMesh,
|
|
|
|
|
Matrix4,
|
|
|
|
|
Mesh,
|
|
|
|
|
type BufferGeometry,
|
|
|
|
|
type Material
|
|
|
|
|
} from "three";
|
|
|
|
|
|
|
|
|
|
import type { Terrain } from "../document/terrains";
|
|
|
|
|
import { applyRendererRenderCategoryFromMaterial } from "../rendering/render-layers";
|
|
|
|
|
import { loadBundledFoliageModelTemplate } from "./bundled-foliage-model-loader";
|
|
|
|
|
import {
|
|
|
|
|
createFoliageInstanceMatrix,
|
|
|
|
|
createFoliageRenderBatches,
|
|
|
|
|
type FoliageRenderBatch
|
|
|
|
|
} from "./foliage-render-batches";
|
|
|
|
|
import type {
|
|
|
|
|
FoliageLayerRegistry,
|
|
|
|
|
FoliagePrototypeRegistry
|
|
|
|
|
} from "./foliage";
|
|
|
|
|
import {
|
|
|
|
|
createFoliageScatterPrototypeRegistry,
|
|
|
|
|
generateFoliageScatterForScene,
|
|
|
|
|
type FoliageScatterPrototypeSource
|
|
|
|
|
} from "./foliage-scatter";
|
|
|
|
|
|
|
|
|
|
export interface FoliageInstancedRendererSyncInput {
|
|
|
|
|
terrains: Record<string, Terrain> | readonly Terrain[];
|
|
|
|
|
foliageLayers: FoliageLayerRegistry;
|
|
|
|
|
foliagePrototypes?: FoliagePrototypeRegistry;
|
|
|
|
|
bundledFoliagePrototypes?: FoliageScatterPrototypeSource;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FoliageInstancedRendererOptions {
|
|
|
|
|
onRebuilt?: () => void;
|
|
|
|
|
onDiagnostic?: (message: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FoliageTemplateSourceMesh {
|
|
|
|
|
geometry: BufferGeometry;
|
|
|
|
|
material: Material | Material[];
|
|
|
|
|
localMatrix: Matrix4;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cloneMaterial(material: Material): Material {
|
|
|
|
|
return material.clone();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cloneMaterialSet(material: Material | Material[]): Material | Material[] {
|
|
|
|
|
return Array.isArray(material)
|
|
|
|
|
? material.map((entry) => cloneMaterial(entry))
|
|
|
|
|
: cloneMaterial(material);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function disposeMaterial(material: Material | Material[]) {
|
|
|
|
|
const materials = Array.isArray(material) ? material : [material];
|
|
|
|
|
|
|
|
|
|
for (const entry of materials) {
|
|
|
|
|
entry.dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function disposeInstancedMesh(mesh: InstancedMesh) {
|
|
|
|
|
mesh.geometry.dispose();
|
|
|
|
|
disposeMaterial(mesh.material);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function disposeFoliageGroup(group: Group) {
|
|
|
|
|
const instancedMeshes: InstancedMesh[] = [];
|
|
|
|
|
|
|
|
|
|
group.traverse((object) => {
|
|
|
|
|
const maybeInstancedMesh = object as InstancedMesh & {
|
|
|
|
|
isInstancedMesh?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (maybeInstancedMesh.isInstancedMesh === true) {
|
|
|
|
|
instancedMeshes.push(maybeInstancedMesh);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const mesh of instancedMeshes) {
|
|
|
|
|
disposeInstancedMesh(mesh);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
group.clear();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeTerrainRegistry(
|
|
|
|
|
terrains: Record<string, Terrain> | readonly Terrain[]
|
|
|
|
|
): Record<string, Terrain> {
|
|
|
|
|
if (Array.isArray(terrains)) {
|
2026-05-02 04:57:18 +02:00
|
|
|
const terrainList = terrains as readonly Terrain[];
|
|
|
|
|
|
|
|
|
|
return Object.fromEntries(
|
|
|
|
|
terrainList.map((terrain) => [terrain.id, terrain])
|
|
|
|
|
);
|
2026-05-02 04:53:19 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 04:57:18 +02:00
|
|
|
return terrains as Record<string, Terrain>;
|
2026-05-02 04:53:19 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectTemplateSourceMeshes(template: Group): FoliageTemplateSourceMesh[] {
|
|
|
|
|
const sourceMeshes: FoliageTemplateSourceMesh[] = [];
|
|
|
|
|
|
|
|
|
|
template.updateMatrixWorld(true);
|
|
|
|
|
template.traverse((object) => {
|
|
|
|
|
const maybeMesh = object as Mesh<BufferGeometry, Material | Material[]> & {
|
|
|
|
|
isMesh?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (maybeMesh.isMesh !== true) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sourceMeshes.push({
|
|
|
|
|
geometry: maybeMesh.geometry,
|
|
|
|
|
material: maybeMesh.material,
|
|
|
|
|
localMatrix: maybeMesh.matrixWorld.clone()
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return sourceMeshes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createInstancedMeshForSource(
|
|
|
|
|
batch: FoliageRenderBatch,
|
|
|
|
|
sourceMesh: FoliageTemplateSourceMesh
|
|
|
|
|
): InstancedMesh {
|
|
|
|
|
const mesh = new InstancedMesh(
|
|
|
|
|
sourceMesh.geometry.clone(),
|
|
|
|
|
cloneMaterialSet(sourceMesh.material),
|
|
|
|
|
batch.instances.length
|
|
|
|
|
);
|
|
|
|
|
const color = new Color();
|
|
|
|
|
|
|
|
|
|
mesh.name = `Foliage:${batch.prototypeId}:${batch.lodLevel}`;
|
|
|
|
|
mesh.userData.nonPickable = true;
|
|
|
|
|
mesh.userData.foliageBatchKey = batch.key;
|
|
|
|
|
mesh.userData.foliagePrototypeId = batch.prototypeId;
|
|
|
|
|
mesh.userData.foliageLayerId = batch.layerId;
|
|
|
|
|
mesh.userData.foliageTerrainId = batch.terrainId;
|
|
|
|
|
mesh.castShadow = batch.castShadow;
|
|
|
|
|
mesh.receiveShadow = true;
|
|
|
|
|
mesh.frustumCulled = false;
|
|
|
|
|
mesh.raycast = () => undefined;
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < batch.instances.length; index += 1) {
|
|
|
|
|
const instance = batch.instances[index]!;
|
|
|
|
|
mesh.setMatrixAt(
|
|
|
|
|
index,
|
|
|
|
|
createFoliageInstanceMatrix(instance, sourceMesh.localMatrix)
|
|
|
|
|
);
|
|
|
|
|
color.setRGB(
|
|
|
|
|
instance.colorTint.r,
|
|
|
|
|
instance.colorTint.g,
|
|
|
|
|
instance.colorTint.b
|
|
|
|
|
);
|
|
|
|
|
mesh.setColorAt(index, color);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mesh.instanceMatrix.needsUpdate = true;
|
|
|
|
|
|
|
|
|
|
if (mesh.instanceColor !== null) {
|
|
|
|
|
mesh.instanceColor.needsUpdate = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return mesh;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class FoliageInstancedRenderer {
|
|
|
|
|
readonly group = new Group();
|
|
|
|
|
|
|
|
|
|
private requestId = 0;
|
|
|
|
|
private activeBatchGroup: Group | null = null;
|
|
|
|
|
private readonly onRebuilt?: () => void;
|
|
|
|
|
private readonly onDiagnostic?: (message: string) => void;
|
|
|
|
|
|
|
|
|
|
constructor(options: FoliageInstancedRendererOptions = {}) {
|
|
|
|
|
this.onRebuilt = options.onRebuilt;
|
|
|
|
|
this.onDiagnostic = options.onDiagnostic;
|
|
|
|
|
this.group.name = "foliageInstancedRenderer";
|
|
|
|
|
this.group.userData.nonPickable = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sync(input: FoliageInstancedRendererSyncInput) {
|
|
|
|
|
const requestId = ++this.requestId;
|
|
|
|
|
const terrains = normalizeTerrainRegistry(input.terrains);
|
|
|
|
|
const prototypeRegistry = createFoliageScatterPrototypeRegistry({
|
|
|
|
|
foliagePrototypes: input.foliagePrototypes,
|
|
|
|
|
bundledFoliagePrototypes: input.bundledFoliagePrototypes
|
|
|
|
|
});
|
|
|
|
|
const scatter = generateFoliageScatterForScene({
|
|
|
|
|
terrains,
|
|
|
|
|
foliageLayers: input.foliageLayers,
|
|
|
|
|
foliagePrototypes: input.foliagePrototypes,
|
|
|
|
|
bundledFoliagePrototypes: input.bundledFoliagePrototypes
|
|
|
|
|
});
|
|
|
|
|
const batches = createFoliageRenderBatches(scatter, prototypeRegistry);
|
|
|
|
|
|
|
|
|
|
this.clearActiveBatches();
|
|
|
|
|
|
|
|
|
|
if (batches.length === 0) {
|
|
|
|
|
this.onRebuilt?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 04:55:16 +02:00
|
|
|
void this.rebuildBatchesAsync(requestId, batches);
|
2026-05-02 04:53:19 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dispose() {
|
|
|
|
|
this.requestId += 1;
|
|
|
|
|
this.clearActiveBatches();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private clearActiveBatches() {
|
|
|
|
|
if (this.activeBatchGroup === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.group.remove(this.activeBatchGroup);
|
|
|
|
|
disposeFoliageGroup(this.activeBatchGroup);
|
|
|
|
|
this.activeBatchGroup = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private emitDiagnostic(message: string) {
|
|
|
|
|
if (this.onDiagnostic !== undefined) {
|
|
|
|
|
this.onDiagnostic(message);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.warn(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async rebuildBatchesAsync(
|
|
|
|
|
requestId: number,
|
2026-05-02 04:55:16 +02:00
|
|
|
batches: readonly FoliageRenderBatch[]
|
2026-05-02 04:53:19 +02:00
|
|
|
) {
|
|
|
|
|
const nextBatchGroup = new Group();
|
|
|
|
|
nextBatchGroup.name = "foliageInstancedBatches";
|
|
|
|
|
nextBatchGroup.userData.nonPickable = true;
|
|
|
|
|
|
|
|
|
|
for (const batch of batches) {
|
|
|
|
|
let template: Group;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
template = await loadBundledFoliageModelTemplate(batch.bundledPath);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const message =
|
|
|
|
|
error instanceof Error
|
|
|
|
|
? error.message
|
|
|
|
|
: `Bundled foliage model failed to load from ${batch.bundledPath}.`;
|
|
|
|
|
this.emitDiagnostic(message);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (requestId !== this.requestId) {
|
|
|
|
|
disposeFoliageGroup(nextBatchGroup);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sourceMeshes = collectTemplateSourceMeshes(template);
|
|
|
|
|
|
|
|
|
|
if (sourceMeshes.length === 0) {
|
|
|
|
|
this.emitDiagnostic(
|
|
|
|
|
`Bundled foliage model ${batch.bundledPath} contains no renderable meshes.`
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const batchGroup = new Group();
|
|
|
|
|
batchGroup.name = `FoliageBatch:${batch.prototypeId}`;
|
|
|
|
|
batchGroup.userData.nonPickable = true;
|
|
|
|
|
batchGroup.userData.foliageBatchKey = batch.key;
|
|
|
|
|
batchGroup.userData.foliagePrototypeId = batch.prototypeId;
|
|
|
|
|
batchGroup.userData.foliageLayerId = batch.layerId;
|
|
|
|
|
batchGroup.userData.foliageTerrainId = batch.terrainId;
|
|
|
|
|
|
|
|
|
|
for (const sourceMesh of sourceMeshes) {
|
|
|
|
|
batchGroup.add(createInstancedMeshForSource(batch, sourceMesh));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applyRendererRenderCategoryFromMaterial(batchGroup);
|
|
|
|
|
nextBatchGroup.add(batchGroup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (requestId !== this.requestId) {
|
|
|
|
|
disposeFoliageGroup(nextBatchGroup);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.clearActiveBatches();
|
|
|
|
|
|
|
|
|
|
if (nextBatchGroup.children.length === 0) {
|
|
|
|
|
disposeFoliageGroup(nextBatchGroup);
|
|
|
|
|
this.onRebuilt?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.activeBatchGroup = nextBatchGroup;
|
|
|
|
|
this.group.add(nextBatchGroup);
|
|
|
|
|
this.onRebuilt?.();
|
|
|
|
|
}
|
|
|
|
|
}
|