- 까닭: 화면 상자 조회가 엔티티 칸(64x64) 밖 빈 칸까지 돎 — 0.01배에서 한 화면 19.3 초 - 칸 범위를 0~64 로 잘라 0.01배 19,348 → 16 ms · 0.1배 175 → 15 ms - 모눈은 화면 clear() 만 그림 — 모눈 켜도 정지 화면에서 장면 다시 그리지 않음 - 측정 · 회귀 시험 zoom-out-perf.test.ts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
145 lines
4.2 KiB
TypeScript
145 lines
4.2 KiB
TypeScript
import type { Entity } from '../entities/Entity';
|
|
import { getEntities } from '../state';
|
|
import { getSceneVersion } from './scene-version';
|
|
|
|
/**
|
|
* Uniform-grid spatial index over top-level entity bounding boxes.
|
|
* Rebuilt lazily whenever the scene version changes (entity edits bump it).
|
|
* Queries return entities in original array order so z-order is preserved.
|
|
*/
|
|
const GRID_CELLS_PER_AXIS = 64;
|
|
|
|
interface IndexedEntity {
|
|
entity: Entity;
|
|
minX: number;
|
|
minY: number;
|
|
maxX: number;
|
|
maxY: number;
|
|
}
|
|
|
|
let indexVersion = -1;
|
|
let indexedEntities: IndexedEntity[] = [];
|
|
let unindexedEntities: Entity[] = []; // bbox unavailable — always included in results
|
|
let cells: Map<number, number[]> = new Map();
|
|
let cellSize = 1;
|
|
let gridMinX = 0;
|
|
let gridMinY = 0;
|
|
let gridCols = 1;
|
|
|
|
/** 칸 번호 범위 — 엔티티가 놓인 칸(0~GRID_CELLS_PER_AXIS) 밖은 비었으니 잘라 냄(줌아웃 때 빈 칸 수억 개를 돌지 않게). */
|
|
function cellRange(min: number, max: number, gridMin: number): [number, number] {
|
|
return [
|
|
Math.max(0, Math.floor((min - gridMin) / cellSize)),
|
|
Math.min(GRID_CELLS_PER_AXIS, Math.floor((max - gridMin) / cellSize)),
|
|
];
|
|
}
|
|
|
|
function ensureIndex(): void {
|
|
const version = getSceneVersion();
|
|
if (version === indexVersion) return;
|
|
|
|
indexedEntities = [];
|
|
unindexedEntities = [];
|
|
cells = new Map();
|
|
|
|
const entities = getEntities();
|
|
let minX = Number.POSITIVE_INFINITY;
|
|
let minY = Number.POSITIVE_INFINITY;
|
|
let maxX = Number.NEGATIVE_INFINITY;
|
|
let maxY = Number.NEGATIVE_INFINITY;
|
|
|
|
for (const entity of entities) {
|
|
let box: { xmin: number; ymin: number; xmax: number; ymax: number } | null = null;
|
|
try {
|
|
box = entity.getBoundingBox();
|
|
} catch {
|
|
box = null;
|
|
}
|
|
if (
|
|
!box ||
|
|
!Number.isFinite(box.xmin) ||
|
|
!Number.isFinite(box.ymin) ||
|
|
!Number.isFinite(box.xmax) ||
|
|
!Number.isFinite(box.ymax)
|
|
) {
|
|
unindexedEntities.push(entity);
|
|
continue;
|
|
}
|
|
indexedEntities.push({
|
|
entity,
|
|
minX: box.xmin,
|
|
minY: box.ymin,
|
|
maxX: box.xmax,
|
|
maxY: box.ymax,
|
|
});
|
|
if (box.xmin < minX) minX = box.xmin;
|
|
if (box.ymin < minY) minY = box.ymin;
|
|
if (box.xmax > maxX) maxX = box.xmax;
|
|
if (box.ymax > maxY) maxY = box.ymax;
|
|
}
|
|
|
|
if (indexedEntities.length) {
|
|
const extent = Math.max(maxX - minX, maxY - minY, 1e-9);
|
|
cellSize = extent / GRID_CELLS_PER_AXIS;
|
|
gridMinX = minX;
|
|
gridMinY = minY;
|
|
gridCols = GRID_CELLS_PER_AXIS + 2;
|
|
|
|
indexedEntities.forEach((item, index) => {
|
|
const [cx0, cx1] = cellRange(item.minX, item.maxX, gridMinX);
|
|
const [cy0, cy1] = cellRange(item.minY, item.maxY, gridMinY);
|
|
for (let cy = cy0; cy <= cy1; cy++) {
|
|
for (let cx = cx0; cx <= cx1; cx++) {
|
|
const key = cy * gridCols + cx;
|
|
const bucket = cells.get(key);
|
|
if (bucket) bucket.push(index);
|
|
else cells.set(key, [index]);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
indexVersion = version;
|
|
}
|
|
|
|
/** 뷰포트/사각 영역과 bbox가 겹치는 엔티티 (원본 배열 순서 유지). */
|
|
export function queryEntitiesInBox(
|
|
minX: number,
|
|
minY: number,
|
|
maxX: number,
|
|
maxY: number
|
|
): Entity[] {
|
|
ensureIndex();
|
|
if (!indexedEntities.length) return [...unindexedEntities];
|
|
|
|
const seen = new Set<number>();
|
|
const [cx0, cx1] = cellRange(minX, maxX, gridMinX);
|
|
const [cy0, cy1] = cellRange(minY, maxY, gridMinY);
|
|
for (let cy = cy0; cy <= cy1; cy++) {
|
|
for (let cx = cx0; cx <= cx1; cx++) {
|
|
const bucket = cells.get(cy * gridCols + cx);
|
|
if (!bucket) continue;
|
|
for (const index of bucket) seen.add(index);
|
|
}
|
|
}
|
|
|
|
const result: Entity[] = [];
|
|
let unindexedCursor = 0;
|
|
for (let index = 0; index < indexedEntities.length; index++) {
|
|
if (!seen.has(index)) continue;
|
|
const item = indexedEntities[index];
|
|
if (item.maxX < minX || item.minX > maxX || item.maxY < minY || item.minY > maxY) continue;
|
|
result.push(item.entity);
|
|
}
|
|
// bbox 불명 엔티티는 항상 포함 (뒤에 붙여도 소수라 시각 영향 없음)
|
|
for (; unindexedCursor < unindexedEntities.length; unindexedCursor++) {
|
|
result.push(unindexedEntities[unindexedCursor]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** 점 주변 반경 후보 엔티티 (스냅·호버용). */
|
|
export function queryEntitiesNearPoint(x: number, y: number, radius: number): Entity[] {
|
|
return queryEntitiesInBox(x - radius, y - radius, x + radius, y + radius);
|
|
}
|