UNPKG

@spearwolf/twopoint5d

Version:

Create 2.5D realtime graphics and pixelart with WebGL and three.js

188 lines 10.9 kB
import { Matrix4, OrthographicCamera, PerspectiveCamera, Vector3 } from 'three/webgpu'; import { beforeEach, describe, expect, test } from 'vitest'; import { CameraBasedVisibility } from './CameraBasedVisibility.js'; import { Map2DTileCoordsUtil } from './Map2DTileCoordsUtil.js'; function makeTopDownCamera() { const camera = new PerspectiveCamera(90, 1, 0.1, 500); camera.position.set(0, 100, 0); camera.lookAt(0, 0, 0); camera.updateMatrixWorld(); camera.updateProjectionMatrix(); return camera; } function makeOrthoCameraLookingHorizontally() { const camera = new OrthographicCamera(-100, 100, 100, -100, 0.1, 500); camera.position.set(0, 50, 0); camera.lookAt(1, 50, 0); camera.updateMatrixWorld(); camera.updateProjectionMatrix(); return camera; } function ids(tiles) { return (tiles ?? []).map((t) => t.id).sort(); } describe('CameraBasedVisibility', () => { describe('computeVisibleTiles()', () => { let visibility; let tileCoords; let matrixWorld; beforeEach(() => { tileCoords = new Map2DTileCoordsUtil(100, 100); matrixWorld = new Matrix4(); }); test('returns undefined when no camera is assigned', () => { visibility = new CameraBasedVisibility(); expect(visibility.computeVisibleTiles([], [0, 0], tileCoords, matrixWorld)).toBeUndefined(); }); test('returns a visible-tiles result with non-empty tiles when the camera looks at the plane', () => { visibility = new CameraBasedVisibility(makeTopDownCamera()); const result = visibility.computeVisibleTiles([], [0, 0], tileCoords, matrixWorld); expect(result).toBeDefined(); expect(result.tiles.length).toBeGreaterThan(0); expect(result.createTiles).toBeDefined(); expect(result.createTiles.length).toEqual(result.tiles.length); expect(result.reuseTiles ?? []).toHaveLength(0); expect(result.removeTiles ?? []).toHaveLength(0); }); test('contains the tile at the camera ground point as a visible tile', () => { visibility = new CameraBasedVisibility(makeTopDownCamera()); const result = visibility.computeVisibleTiles([], [0, 0], tileCoords, matrixWorld); const tileIds = ids(result.tiles); expect(tileIds).toContain('y0x0'); }); test('returns undefined on a fresh instance when the camera direction is parallel to the plane and there are no previousTiles', () => { visibility = new CameraBasedVisibility(makeOrthoCameraLookingHorizontally()); const result = visibility.computeVisibleTiles([], [0, 0], tileCoords, new Matrix4()); expect(result).toBeUndefined(); }); test('returns tiles=[] and removeTiles=previousTiles when the camera direction is parallel to the plane and previousTiles is populated', () => { const seeder = new CameraBasedVisibility(makeTopDownCamera()); const seed = seeder.computeVisibleTiles([], [0, 0], new Map2DTileCoordsUtil(100, 100), new Matrix4()); const previous = seed.tiles; expect(previous.length).toBeGreaterThan(0); visibility = new CameraBasedVisibility(makeOrthoCameraLookingHorizontally()); const result = visibility.computeVisibleTiles(previous, [0, 0], tileCoords, new Matrix4()); expect(result).toBeDefined(); expect(result.tiles).toEqual([]); expect(result.removeTiles).toBe(previous); }); test('caches the previous result when dependencies have not changed', () => { visibility = new CameraBasedVisibility(makeTopDownCamera()); const first = visibility.computeVisibleTiles([], [0, 0], tileCoords, matrixWorld); const tilesRef = first.tiles; const second = visibility.computeVisibleTiles(first.tiles, [0, 0], tileCoords, matrixWorld); expect(second).toBe(first); expect(second.tiles).toBe(tilesRef); expect(second.createTiles).toBeUndefined(); expect(second.removeTiles).toBeUndefined(); expect(second.reuseTiles).toBe(tilesRef); }); test('classifies tiles into create / reuse / remove across frames with different center points', () => { visibility = new CameraBasedVisibility(makeTopDownCamera()); const first = visibility.computeVisibleTiles([], [0, 0], tileCoords, matrixWorld); const firstIds = new Set(first.tiles.map((t) => t.id)); const second = visibility.computeVisibleTiles(first.tiles, [400, 0], tileCoords, matrixWorld); const secondIds = new Set(second.tiles.map((t) => t.id)); const reuseIds = new Set(second.reuseTiles.map((t) => t.id)); const createIds = new Set(second.createTiles.map((t) => t.id)); const removeIds = new Set(second.removeTiles.map((t) => t.id)); expect(reuseIds.size + createIds.size).toEqual(secondIds.size); for (const id of secondIds) { expect(reuseIds.has(id) || createIds.has(id)).toBe(true); } for (const id of reuseIds) { expect(firstIds.has(id)).toBe(true); } for (const id of removeIds) { expect(firstIds.has(id)).toBe(true); expect(secondIds.has(id)).toBe(false); } expect(reuseIds.size + removeIds.size).toEqual(firstIds.size); expect(removeIds.size).toBeGreaterThan(0); expect(createIds.size).toBeGreaterThan(0); }); test('lists visibles sorted by distance to the camera (ascending)', () => { visibility = new CameraBasedVisibility(makeTopDownCamera()); visibility.computeVisibleTiles([], [0, 0], tileCoords, matrixWorld); const distances = visibility.visibles.map((v) => v.distanceToCamera); const sorted = [...distances].sort((a, b) => a - b); expect(distances).toEqual(sorted); }); test('every visible TileBox carries a frustum/tile box and a Map2DTileCoords (helpers contract)', () => { visibility = new CameraBasedVisibility(makeTopDownCamera()); const result = visibility.computeVisibleTiles([], [0, 0], tileCoords, matrixWorld); expect(visibility.visibles.length).toEqual(result.tiles.length); for (const v of visibility.visibles) { expect(v.frustumBox, `frustumBox set on ${v.id}`).toBeDefined(); expect(v.box, `box set on ${v.id}`).toBeDefined(); expect(v.centerWorld, `centerWorld set on ${v.id}`).toBeInstanceOf(Vector3); expect(v.map2dTile, `map2dTile set on ${v.id}`).toBeDefined(); expect(typeof v.distanceToCamera).toBe('number'); } const primaries = visibility.visibles.filter((v) => v.primary === true); expect(primaries.length).toBeGreaterThan(0); }); test('builds tile.view from the underlying tile coords (origin-aligned)', () => { visibility = new CameraBasedVisibility(makeTopDownCamera()); const result = visibility.computeVisibleTiles([], [0, 0], tileCoords, matrixWorld); const tile00 = result.tiles.find((t) => t.id === 'y0x0'); expect(tile00).toBeDefined(); expect(tile00.view.left).toBe(0); expect(tile00.view.top).toBe(0); expect(tile00.view.width).toBe(100); expect(tile00.view.height).toBe(100); }); test('returns offset and translate vectors that reflect the map/center configuration', () => { visibility = new CameraBasedVisibility(makeTopDownCamera()); const offsetCoords = new Map2DTileCoordsUtil(100, 100, -50, -25); const result = visibility.computeVisibleTiles([], [10, 20], offsetCoords, matrixWorld); expect(result.offset).toBeDefined(); expect(result.offset.x).toBeCloseTo(-50 - 10); expect(result.offset.y).toBeCloseTo(-25 - 20); expect(result.translate).toBeDefined(); expect(result.translate.x).toBeCloseTo(0); expect(result.translate.z).toBeCloseTo(0); }); test('respects matrixWorld translation in the returned translate vector', () => { visibility = new CameraBasedVisibility(makeTopDownCamera()); const translatedWorld = new Matrix4().makeTranslation(7, 0, 11); const result = visibility.computeVisibleTiles([], [0, 0], tileCoords, translatedWorld); expect(result.translate.x).toBeCloseTo(7); expect(result.translate.z).toBeCloseTo(11); }); test('low-GC: subsequent non-cached calls reuse the same TileBox objects when the same tiles stay visible', () => { visibility = new CameraBasedVisibility(makeTopDownCamera()); const first = visibility.computeVisibleTiles([], [0, 0], tileCoords, matrixWorld); const warmIds = first.tiles.map((t) => t.id).sort(); const warmTileBoxes = new Map(visibility.visibles.map((v) => [v.id, v])); const warmFrustumBoxes = new Map(); const warmCenterWorlds = new Map(); for (const v of visibility.visibles) { warmFrustumBoxes.set(v.id, v.frustumBox); warmCenterWorlds.set(v.id, v.centerWorld); } visibility.computeVisibleTiles(first.tiles, [0, 0], tileCoords, new Matrix4().makeTranslation(0, 0, 0.0001)); const refreshed = visibility.computeVisibleTiles(first.tiles, [0, 0], tileCoords, new Matrix4()); const refreshedIds = refreshed.tiles.map((t) => t.id).sort(); expect(refreshedIds).toEqual(warmIds); for (const v of visibility.visibles) { expect(warmTileBoxes.get(v.id), `tile box for ${v.id} is the pooled instance`).toBe(v); expect(warmFrustumBoxes.get(v.id), `frustumBox for ${v.id} is reused`).toBe(v.frustumBox); expect(warmCenterWorlds.get(v.id), `centerWorld for ${v.id} is reused`).toBe(v.centerWorld); } }); }); describe('frustumBoxScale', () => { test('defaults to 1.1', () => { expect(new CameraBasedVisibility().frustumBoxScale).toBeCloseTo(1.1); }); }); describe('IMap2DVisibilitor interface', () => { test('is implemented (computeVisibleTiles function exposed)', () => { const visibility = new CameraBasedVisibility(); const fn = visibility.computeVisibleTiles.bind(visibility); expect(typeof fn).toBe('function'); }); }); }); //# sourceMappingURL=CameraBasedVisibility.spec.js.map