From dd7bc1641a605639dce637df426cf6059a1d396b Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sat, 2 May 2026 04:56:25 +0200 Subject: [PATCH] feat(test): Add unit tests for foliage render batch creation and instance matrix calculation --- tests/domain/foliage-render-batches.test.ts | 143 ++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 tests/domain/foliage-render-batches.test.ts diff --git a/tests/domain/foliage-render-batches.test.ts b/tests/domain/foliage-render-batches.test.ts new file mode 100644 index 00000000..5623e182 --- /dev/null +++ b/tests/domain/foliage-render-batches.test.ts @@ -0,0 +1,143 @@ +import { Matrix4, Quaternion, Vector3 } from "three"; +import { describe, expect, it } from "vitest"; + +import { + createFoliageInstanceMatrix, + createFoliageRenderBatches +} from "../../src/foliage/foliage-render-batches"; +import { BUNDLED_FOLIAGE_PROTOTYPES } from "../../src/foliage/bundled-foliage-manifest"; +import { + FOLIAGE_PROTOTYPE_LOD_LEVELS, + createFoliagePrototype, + type FoliagePrototype +} from "../../src/foliage/foliage"; +import type { + DerivedFoliageInstance, + FoliageScatterResult +} from "../../src/foliage/foliage-scatter"; + +function createInstance( + overrides: Partial = {} +): DerivedFoliageInstance { + return { + terrainId: overrides.terrainId ?? "terrain-a", + layerId: overrides.layerId ?? "foliage-layer-a", + prototypeId: overrides.prototypeId ?? BUNDLED_FOLIAGE_PROTOTYPES[0]!.id, + position: overrides.position ?? { x: 0, y: 0, z: 0 }, + normal: overrides.normal ?? { x: 0, y: 1, z: 0 }, + yawRadians: overrides.yawRadians ?? 0, + scale: overrides.scale ?? 1, + colorTint: overrides.colorTint ?? { r: 1, g: 1, b: 1 }, + windPhase: overrides.windPhase ?? 0, + windStrength: overrides.windStrength ?? 0, + lodBias: overrides.lodBias ?? 0, + alignToNormal: overrides.alignToNormal ?? 1, + cullDistance: overrides.cullDistance ?? 100 + }; +} + +function createScatter( + instances: readonly DerivedFoliageInstance[] +): FoliageScatterResult { + return { + chunks: [ + { + id: "chunk-a", + terrainId: "terrain-a", + layerId: "foliage-layer-a", + chunkX: 0, + chunkZ: 0, + bounds: { + min: { x: 0, y: 0, z: 0 }, + max: { x: 16, y: 0, z: 16 } + }, + instances: [...instances] + } + ], + instanceCount: instances.length + }; +} + +function createProjectAssetPrototype(): FoliagePrototype { + return createFoliagePrototype({ + id: "foliage-project-render-ignored", + label: "Project Render Ignored", + lods: FOLIAGE_PROTOTYPE_LOD_LEVELS.map((level) => ({ + level, + source: "projectAsset", + modelAssetId: `asset-project-foliage-${level}`, + maxDistance: 30 + level * 30, + castShadow: level === 0 + })) + }); +} + +describe("foliage render batch helpers", () => { + it("groups scatter instances by terrain, layer, prototype, and bundled LOD0", () => { + const prototype = BUNDLED_FOLIAGE_PROTOTYPES[0]!; + const otherPrototype = BUNDLED_FOLIAGE_PROTOTYPES[1]!; + const batches = createFoliageRenderBatches( + createScatter([ + createInstance({ prototypeId: prototype.id }), + createInstance({ prototypeId: prototype.id }), + createInstance({ prototypeId: otherPrototype.id }) + ]), + { + [prototype.id]: prototype, + [otherPrototype.id]: otherPrototype + } + ); + + expect(batches).toHaveLength(2); + expect(batches[0]?.instances).toHaveLength(2); + expect(batches[1]?.instances).toHaveLength(1); + expect(batches[0]?.lodLevel).toBe(0); + expect(batches[0]?.bundledPath).toMatch(/_LOD0\.glb$/u); + }); + + it("ignores prototypes that do not have a bundled LOD0 render source", () => { + const projectPrototype = createProjectAssetPrototype(); + const batches = createFoliageRenderBatches( + createScatter([createInstance({ prototypeId: projectPrototype.id })]), + { + [projectPrototype.id]: projectPrototype + } + ); + + expect(batches).toEqual([]); + }); + + it("builds instance matrices from position, scale, source matrix, and normal alignment", () => { + const sourceMatrix = new Matrix4().makeTranslation(0, 1, 0); + const matrix = createFoliageInstanceMatrix( + createInstance({ + position: { x: 1, y: 2, z: 3 }, + scale: 2, + normal: { x: 0, y: 1, z: 0 }, + alignToNormal: 1 + }), + sourceMatrix + ); + const position = new Vector3().setFromMatrixPosition(matrix); + + expect(position.x).toBeCloseTo(1); + expect(position.y).toBeCloseTo(4); + expect(position.z).toBeCloseTo(3); + }); + + it("tilts local up toward the scatter normal when alignToNormal is enabled", () => { + const normal = new Vector3(1, 1, 0).normalize(); + const matrix = createFoliageInstanceMatrix( + createInstance({ + normal: { x: normal.x, y: normal.y, z: normal.z }, + alignToNormal: 1 + }) + ); + const rotation = new Quaternion().setFromRotationMatrix(matrix); + const transformedUp = new Vector3(0, 1, 0).applyQuaternion(rotation); + + expect(transformedUp.x).toBeCloseTo(normal.x, 6); + expect(transformedUp.y).toBeCloseTo(normal.y, 6); + expect(transformedUp.z).toBeCloseTo(normal.z, 6); + }); +});