2026-05-03 13:22:41 +02:00
|
|
|
import {
|
|
|
|
|
Group,
|
2026-05-21 06:08:40 +02:00
|
|
|
InstancedMesh,
|
|
|
|
|
MeshStandardMaterial,
|
2026-05-03 13:22:41 +02:00
|
|
|
PerspectiveCamera,
|
2026-05-21 06:08:40 +02:00
|
|
|
type Material,
|
2026-05-03 13:22:41 +02:00
|
|
|
type Object3D
|
|
|
|
|
} from "three";
|
|
|
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
createTerrain,
|
|
|
|
|
createTerrainFoliageMask
|
|
|
|
|
} from "../../src/document/terrains";
|
|
|
|
|
import { BUNDLED_FOLIAGE_PROTOTYPES } from "../../src/foliage/bundled-foliage-manifest";
|
|
|
|
|
import { createFoliageLayer } from "../../src/foliage/foliage";
|
|
|
|
|
|
|
|
|
|
const loaderState = vi.hoisted(() => ({
|
|
|
|
|
loadCalls: [] as string[]
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-03 13:22:50 +02:00
|
|
|
vi.mock("../../src/foliage/bundled-foliage-model-loader", async () => {
|
|
|
|
|
const three = await vi.importActual<typeof import("three")>("three");
|
2026-05-03 13:22:41 +02:00
|
|
|
|
2026-05-03 13:22:50 +02:00
|
|
|
return {
|
|
|
|
|
loadBundledFoliageModelTemplate: async (bundledPath: string) => {
|
|
|
|
|
loaderState.loadCalls.push(bundledPath);
|
2026-05-03 13:22:41 +02:00
|
|
|
|
2026-05-03 13:22:50 +02:00
|
|
|
const template = new three.Group();
|
|
|
|
|
template.add(
|
|
|
|
|
new three.Mesh(
|
|
|
|
|
new three.BoxGeometry(1, 1, 1),
|
2026-05-21 06:08:40 +02:00
|
|
|
new three.MeshStandardMaterial()
|
2026-05-03 13:22:50 +02:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return template;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-05-03 13:22:41 +02:00
|
|
|
|
|
|
|
|
import { FoliageInstancedRenderer } from "../../src/foliage/foliage-instanced-renderer";
|
|
|
|
|
|
|
|
|
|
const TEST_TERRAIN_ID = "terrain-renderer";
|
|
|
|
|
const TEST_LAYER_ID = "foliage-layer-renderer";
|
|
|
|
|
|
2026-05-03 13:27:47 +02:00
|
|
|
function createCamera(
|
|
|
|
|
position: { x: number; y: number; z: number },
|
|
|
|
|
target: { x: number; y: number; z: number } = { x: 8, y: 0, z: 8 }
|
|
|
|
|
) {
|
2026-05-03 13:22:41 +02:00
|
|
|
const camera = new PerspectiveCamera(60, 1, 0.1, 500);
|
|
|
|
|
|
|
|
|
|
camera.position.set(position.x, position.y, position.z);
|
2026-05-03 13:27:47 +02:00
|
|
|
camera.lookAt(target.x, target.y, target.z);
|
2026-05-03 13:22:41 +02:00
|
|
|
camera.updateProjectionMatrix();
|
|
|
|
|
camera.updateMatrixWorld(true);
|
|
|
|
|
|
|
|
|
|
return camera;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createRendererInput() {
|
|
|
|
|
const prototype = BUNDLED_FOLIAGE_PROTOTYPES[0]!;
|
|
|
|
|
const sampleCount = 17;
|
|
|
|
|
const terrain = createTerrain({
|
|
|
|
|
id: TEST_TERRAIN_ID,
|
|
|
|
|
position: { x: 0, y: 0, z: 0 },
|
|
|
|
|
sampleCountX: sampleCount,
|
|
|
|
|
sampleCountZ: sampleCount,
|
|
|
|
|
cellSize: 1,
|
|
|
|
|
heights: new Array<number>(sampleCount * sampleCount).fill(0),
|
|
|
|
|
foliageMasks: {
|
|
|
|
|
[TEST_LAYER_ID]: createTerrainFoliageMask({
|
|
|
|
|
layerId: TEST_LAYER_ID,
|
|
|
|
|
resolutionX: sampleCount,
|
|
|
|
|
resolutionZ: sampleCount,
|
|
|
|
|
values: new Array<number>(sampleCount * sampleCount).fill(1)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const layer = createFoliageLayer({
|
|
|
|
|
id: TEST_LAYER_ID,
|
|
|
|
|
name: "Renderer foliage layer",
|
|
|
|
|
prototypeIds: [prototype.id],
|
|
|
|
|
density: 3,
|
|
|
|
|
seed: 11
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
terrains: {
|
|
|
|
|
[terrain.id]: terrain
|
|
|
|
|
},
|
|
|
|
|
foliageLayers: {
|
|
|
|
|
[layer.id]: layer
|
|
|
|
|
},
|
|
|
|
|
bundledFoliagePrototypes: {
|
|
|
|
|
[prototype.id]: prototype
|
|
|
|
|
},
|
|
|
|
|
quality: {
|
|
|
|
|
enabled: true,
|
|
|
|
|
densityMultiplier: 1,
|
|
|
|
|
maxDistanceMultiplier: 1,
|
2026-05-21 06:08:40 +02:00
|
|
|
shadows: "near" as const,
|
|
|
|
|
windEnabled: true,
|
|
|
|
|
windStrength: 1,
|
|
|
|
|
windSpeed: 1,
|
|
|
|
|
windDirectionDegrees: 35
|
2026-05-03 13:22:41 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 06:08:40 +02:00
|
|
|
function getInstancedMeshes(group: Group): InstancedMesh[] {
|
|
|
|
|
const meshes: InstancedMesh[] = [];
|
2026-05-03 13:22:41 +02:00
|
|
|
|
|
|
|
|
group.traverse((object) => {
|
|
|
|
|
const maybeInstancedMesh = object as Object3D & {
|
|
|
|
|
isInstancedMesh?: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (maybeInstancedMesh.isInstancedMesh === true) {
|
2026-05-21 06:08:40 +02:00
|
|
|
meshes.push(object as InstancedMesh);
|
2026-05-03 13:22:41 +02:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return meshes;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 06:08:40 +02:00
|
|
|
function getSingleMaterial(material: Material | Material[]): Material {
|
|
|
|
|
return Array.isArray(material) ? material[0]! : material;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 13:22:41 +02:00
|
|
|
function getVisibleBatchKeys(group: Group): string[] {
|
|
|
|
|
const keys: string[] = [];
|
|
|
|
|
|
|
|
|
|
group.traverse((object) => {
|
|
|
|
|
if (object.type !== "Group" || object.visible !== true) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const batchKey = object.userData.foliageBatchKey;
|
|
|
|
|
|
|
|
|
|
if (typeof batchKey === "string") {
|
|
|
|
|
keys.push(batchKey);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return keys.sort();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe("FoliageInstancedRenderer", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
loaderState.loadCalls.length = 0;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("updates camera-dependent foliage LOD by visibility without rebuilding resources", async () => {
|
|
|
|
|
let rebuildCount = 0;
|
|
|
|
|
const rebuildWaiters: Array<() => void> = [];
|
|
|
|
|
const renderer = new FoliageInstancedRenderer({
|
|
|
|
|
onRebuilt: () => {
|
|
|
|
|
rebuildCount += 1;
|
|
|
|
|
rebuildWaiters.shift()?.();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const waitForRebuild = () =>
|
|
|
|
|
new Promise<void>((resolve) => {
|
|
|
|
|
rebuildWaiters.push(resolve);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const initialRebuild = waitForRebuild();
|
|
|
|
|
renderer.sync(createRendererInput());
|
|
|
|
|
renderer.updateView(createCamera({ x: 8, y: 8, z: 8 }));
|
|
|
|
|
await initialRebuild;
|
|
|
|
|
|
|
|
|
|
const activeBatchRoot = renderer.group.children[0];
|
|
|
|
|
const instancedMeshes = getInstancedMeshes(renderer.group);
|
|
|
|
|
const initialVisibleBatchKeys = getVisibleBatchKeys(renderer.group);
|
|
|
|
|
const initialLoadCallCount = loaderState.loadCalls.length;
|
|
|
|
|
const initialRebuildCount = rebuildCount;
|
|
|
|
|
|
|
|
|
|
expect(activeBatchRoot).toBeDefined();
|
|
|
|
|
expect(instancedMeshes.length).toBeGreaterThan(0);
|
|
|
|
|
expect(initialVisibleBatchKeys.some((key) => key.includes("|0|"))).toBe(
|
|
|
|
|
true
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-03 13:27:47 +02:00
|
|
|
renderer.updateView(
|
|
|
|
|
createCamera(
|
|
|
|
|
{ x: 8, y: 8, z: 8 },
|
|
|
|
|
{
|
|
|
|
|
x: 10,
|
|
|
|
|
y: 0,
|
|
|
|
|
z: 8
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
|
|
|
|
|
expect(renderer.group.children[0]).toBe(activeBatchRoot);
|
|
|
|
|
expect(getInstancedMeshes(renderer.group)).toEqual(instancedMeshes);
|
|
|
|
|
expect(loaderState.loadCalls).toHaveLength(initialLoadCallCount);
|
|
|
|
|
expect(rebuildCount).toBe(initialRebuildCount);
|
|
|
|
|
|
2026-05-03 13:22:41 +02:00
|
|
|
renderer.updateView(createCamera({ x: 68, y: 8, z: 8 }));
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
|
|
|
|
|
expect(renderer.group.children[0]).toBe(activeBatchRoot);
|
|
|
|
|
expect(getInstancedMeshes(renderer.group)).toEqual(instancedMeshes);
|
|
|
|
|
expect(loaderState.loadCalls).toHaveLength(initialLoadCallCount);
|
|
|
|
|
expect(rebuildCount).toBe(initialRebuildCount);
|
|
|
|
|
expect(getVisibleBatchKeys(renderer.group)).not.toEqual(
|
|
|
|
|
initialVisibleBatchKeys
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-05-21 06:08:40 +02:00
|
|
|
|
|
|
|
|
it("patches standard foliage materials with GPU wind attributes and uniforms", async () => {
|
|
|
|
|
let rebuildCount = 0;
|
|
|
|
|
const rebuildWaiters: Array<() => void> = [];
|
|
|
|
|
const renderer = new FoliageInstancedRenderer({
|
|
|
|
|
onRebuilt: () => {
|
|
|
|
|
rebuildCount += 1;
|
|
|
|
|
rebuildWaiters.shift()?.();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const waitForRebuild = () =>
|
|
|
|
|
new Promise<void>((resolve) => {
|
|
|
|
|
rebuildWaiters.push(resolve);
|
|
|
|
|
});
|
|
|
|
|
const input = createRendererInput();
|
|
|
|
|
const initialRebuild = waitForRebuild();
|
|
|
|
|
|
|
|
|
|
renderer.sync(input);
|
|
|
|
|
renderer.updateView(createCamera({ x: 8, y: 8, z: 8 }));
|
|
|
|
|
await initialRebuild;
|
|
|
|
|
|
|
|
|
|
const instancedMesh = getInstancedMeshes(renderer.group)[0]!;
|
|
|
|
|
const material = getSingleMaterial(instancedMesh.material);
|
|
|
|
|
|
|
|
|
|
expect(material).toBeInstanceOf(MeshStandardMaterial);
|
|
|
|
|
expect(instancedMesh.geometry.getAttribute("foliageWind")?.itemSize).toBe(
|
|
|
|
|
2
|
|
|
|
|
);
|
|
|
|
|
expect(material.customProgramCacheKey()).toContain("foliage-wind-v1");
|
|
|
|
|
|
|
|
|
|
const shader = {
|
|
|
|
|
uniforms: {},
|
|
|
|
|
vertexShader:
|
|
|
|
|
"#include <common>\nvoid main() {\n#include <project_vertex>\n}",
|
|
|
|
|
fragmentShader: ""
|
|
|
|
|
} as any;
|
|
|
|
|
|
|
|
|
|
material.onBeforeCompile(shader, {} as never);
|
|
|
|
|
|
|
|
|
|
expect(shader.vertexShader).toContain("attribute vec2 foliageWind");
|
|
|
|
|
expect(shader.vertexShader).toContain("foliageWindStrength");
|
|
|
|
|
expect(shader.uniforms.foliageWindStrength.value).toBe(1);
|
|
|
|
|
expect(shader.uniforms.foliageWindSpeed.value).toBe(1);
|
|
|
|
|
|
|
|
|
|
const initialLoadCallCount = loaderState.loadCalls.length;
|
|
|
|
|
const initialRebuildCount = rebuildCount;
|
|
|
|
|
renderer.sync({
|
|
|
|
|
...input,
|
|
|
|
|
quality: {
|
|
|
|
|
...input.quality,
|
|
|
|
|
windStrength: 2.25,
|
|
|
|
|
windSpeed: 3,
|
|
|
|
|
windDirectionDegrees: 90
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
|
|
|
|
|
expect(getInstancedMeshes(renderer.group)[0]).toBe(instancedMesh);
|
|
|
|
|
expect(loaderState.loadCalls).toHaveLength(initialLoadCallCount);
|
|
|
|
|
expect(rebuildCount).toBe(initialRebuildCount);
|
|
|
|
|
expect(shader.uniforms.foliageWindStrength.value).toBe(2.25);
|
|
|
|
|
expect(shader.uniforms.foliageWindSpeed.value).toBe(3);
|
|
|
|
|
expect(shader.uniforms.foliageWindDirection.value.x).toBeCloseTo(0, 6);
|
|
|
|
|
expect(shader.uniforms.foliageWindDirection.value.y).toBeCloseTo(1, 6);
|
|
|
|
|
|
|
|
|
|
renderer.updateWind(0.5);
|
|
|
|
|
expect(shader.uniforms.foliageWindTime.value).toBeCloseTo(0.5);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("leaves foliage materials and geometry unpatched when wind is disabled", async () => {
|
|
|
|
|
const rebuildWaiters: Array<() => void> = [];
|
|
|
|
|
const renderer = new FoliageInstancedRenderer({
|
|
|
|
|
onRebuilt: () => {
|
|
|
|
|
rebuildWaiters.shift()?.();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const waitForRebuild = () =>
|
|
|
|
|
new Promise<void>((resolve) => {
|
|
|
|
|
rebuildWaiters.push(resolve);
|
|
|
|
|
});
|
|
|
|
|
const input = createRendererInput();
|
|
|
|
|
const initialRebuild = waitForRebuild();
|
|
|
|
|
|
|
|
|
|
renderer.sync({
|
|
|
|
|
...input,
|
|
|
|
|
quality: {
|
|
|
|
|
...input.quality,
|
|
|
|
|
windEnabled: false
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
renderer.updateView(createCamera({ x: 8, y: 8, z: 8 }));
|
|
|
|
|
await initialRebuild;
|
|
|
|
|
|
|
|
|
|
const instancedMesh = getInstancedMeshes(renderer.group)[0]!;
|
|
|
|
|
const material = getSingleMaterial(instancedMesh.material);
|
|
|
|
|
|
|
|
|
|
expect(instancedMesh.geometry.getAttribute("foliageWind")).toBeUndefined();
|
|
|
|
|
expect(material.customProgramCacheKey()).not.toContain("foliage-wind-v1");
|
|
|
|
|
});
|
2026-05-21 06:09:28 +02:00
|
|
|
|
|
|
|
|
it("writes zero wind influence for prototypes with no authored wind strength", async () => {
|
|
|
|
|
const rebuildWaiters: Array<() => void> = [];
|
|
|
|
|
const renderer = new FoliageInstancedRenderer({
|
|
|
|
|
onRebuilt: () => {
|
|
|
|
|
rebuildWaiters.shift()?.();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const waitForRebuild = () =>
|
|
|
|
|
new Promise<void>((resolve) => {
|
|
|
|
|
rebuildWaiters.push(resolve);
|
|
|
|
|
});
|
|
|
|
|
const input = createRendererInput();
|
|
|
|
|
const prototype = BUNDLED_FOLIAGE_PROTOTYPES[0]!;
|
|
|
|
|
input.bundledFoliagePrototypes[prototype.id] = {
|
|
|
|
|
...prototype,
|
|
|
|
|
windStrength: 0
|
|
|
|
|
};
|
|
|
|
|
const initialRebuild = waitForRebuild();
|
|
|
|
|
|
|
|
|
|
renderer.sync(input);
|
|
|
|
|
renderer.updateView(createCamera({ x: 8, y: 8, z: 8 }));
|
|
|
|
|
await initialRebuild;
|
|
|
|
|
|
|
|
|
|
const instancedMesh = getInstancedMeshes(renderer.group)[0]!;
|
|
|
|
|
const windAttribute = instancedMesh.geometry.getAttribute("foliageWind");
|
|
|
|
|
const values = Array.from(windAttribute.array as Float32Array);
|
|
|
|
|
|
|
|
|
|
expect(values.length).toBeGreaterThan(0);
|
|
|
|
|
expect(values.filter((_value, index) => index % 2 === 1)).toEqual(
|
|
|
|
|
new Array(values.length / 2).fill(0)
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-05-03 13:22:41 +02:00
|
|
|
});
|