From 7886f4cfa06d22e72462e5ba0663b7c8ad756c36 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sun, 3 May 2026 13:22:41 +0200 Subject: [PATCH] Add unit tests for FoliageInstancedRenderer --- .../domain/foliage-instanced-renderer.test.ts | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 tests/domain/foliage-instanced-renderer.test.ts diff --git a/tests/domain/foliage-instanced-renderer.test.ts b/tests/domain/foliage-instanced-renderer.test.ts new file mode 100644 index 00000000..b5992097 --- /dev/null +++ b/tests/domain/foliage-instanced-renderer.test.ts @@ -0,0 +1,178 @@ +import { + BoxGeometry, + Group, + Mesh, + MeshBasicMaterial, + PerspectiveCamera, + 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[] +})); + +vi.mock("../../src/foliage/bundled-foliage-model-loader", () => ({ + loadBundledFoliageModelTemplate: async (bundledPath: string) => { + loaderState.loadCalls.push(bundledPath); + + const template = new Group(); + template.add( + new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial()) + ); + + return template; + } +})); + +import { FoliageInstancedRenderer } from "../../src/foliage/foliage-instanced-renderer"; + +const TEST_TERRAIN_ID = "terrain-renderer"; +const TEST_LAYER_ID = "foliage-layer-renderer"; + +function createCamera(position: { x: number; y: number; z: number }) { + const camera = new PerspectiveCamera(60, 1, 0.1, 500); + + camera.position.set(position.x, position.y, position.z); + camera.lookAt(8, 0, 8); + 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(sampleCount * sampleCount).fill(0), + foliageMasks: { + [TEST_LAYER_ID]: createTerrainFoliageMask({ + layerId: TEST_LAYER_ID, + resolutionX: sampleCount, + resolutionZ: sampleCount, + values: new Array(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, + shadows: "near" as const + } + }; +} + +function getInstancedMeshes(group: Group): Object3D[] { + const meshes: Object3D[] = []; + + group.traverse((object) => { + const maybeInstancedMesh = object as Object3D & { + isInstancedMesh?: boolean; + }; + + if (maybeInstancedMesh.isInstancedMesh === true) { + meshes.push(object); + } + }); + + return meshes; +} + +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((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 + ); + + 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 + ); + }); +});