- 까닭: 화면 상자 조회가 엔티티 칸(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
139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
|
|
import { getHighlightedEntityIds, setHighlightedEntityIds } from '../state';
|
|
import { drawEntities } from './draw-functions';
|
|
import { getSceneVersion } from './scene-version';
|
|
import { queryEntitiesInBox } from './spatial-index';
|
|
|
|
/**
|
|
* Static scene cache: all entities are rendered once into an offscreen canvas.
|
|
* While panning, the cached bitmap is blitted at a pixel offset instead of
|
|
* re-stroking every entity each frame. The cache is rebuilt when the scene
|
|
* version bumps (entities/layers/selection changed), when zoom or canvas size
|
|
* changes, or after the pan offset has settled for PAN_SETTLE_MS.
|
|
*
|
|
* Highlight is intentionally NOT baked into the cache (it changes on every
|
|
* mouse move) — draw.ts re-draws highlighted entities on top each frame.
|
|
*/
|
|
const PAN_SETTLE_MS = 120;
|
|
|
|
interface RenderedParams {
|
|
version: number;
|
|
scale: number;
|
|
offsetX: number;
|
|
offsetY: number;
|
|
sizeX: number;
|
|
sizeY: number;
|
|
}
|
|
|
|
let offscreenCanvas: HTMLCanvasElement | null = null;
|
|
let rendered: RenderedParams | null = null;
|
|
let prevOffsetX = Number.NaN;
|
|
let prevOffsetY = Number.NaN;
|
|
let lastOffsetChangeAt = 0;
|
|
|
|
export const scenePerf = {
|
|
sceneRebuilds: 0,
|
|
lastRebuildMs: 0,
|
|
avgFrameMs: 0,
|
|
};
|
|
(window as unknown as Record<string, unknown>).__aisloCadPerf = scenePerf;
|
|
|
|
export function invalidateSceneCache(): void {
|
|
rendered = null;
|
|
}
|
|
|
|
/**
|
|
* Viewport culling: only entities whose bbox intersects the current view
|
|
* (expanded by one viewport on each side, so short pans stay covered by the
|
|
* blit before the settle-rebuild) are rendered into the scene cache.
|
|
*/
|
|
function sceneEntitiesForViewport(drawController: ScreenCanvasDrawController) {
|
|
const size = drawController.getCanvasSize();
|
|
const scale = drawController.getScreenScale();
|
|
const offset = drawController.getScreenOffset();
|
|
const viewWidth = size.x / scale;
|
|
const viewHeight = size.y / scale;
|
|
return queryEntitiesInBox(
|
|
offset.x - viewWidth,
|
|
offset.y - viewHeight,
|
|
offset.x + 2 * viewWidth,
|
|
offset.y + 2 * viewHeight
|
|
);
|
|
}
|
|
|
|
function rebuildScene(drawController: ScreenCanvasDrawController): void {
|
|
const size = drawController.getCanvasSize();
|
|
if (!offscreenCanvas) {
|
|
offscreenCanvas = document.createElement('canvas');
|
|
}
|
|
if (offscreenCanvas.width !== size.x || offscreenCanvas.height !== size.y) {
|
|
offscreenCanvas.width = Math.max(1, size.x);
|
|
offscreenCanvas.height = Math.max(1, size.y);
|
|
}
|
|
const offscreenContext = offscreenCanvas.getContext('2d');
|
|
if (!offscreenContext) return;
|
|
|
|
const startedAt = performance.now();
|
|
// Exclude the (rapidly changing) hover highlight from the baked bitmap.
|
|
const savedHighlight = getHighlightedEntityIds();
|
|
if (savedHighlight.length) setHighlightedEntityIds([]);
|
|
drawController.withContext(offscreenContext, () => {
|
|
// 투명 바탕 — 배경 · 모눈은 화면 clear() 가 그림(모눈이 비트맵에 박히면 매 프레임 다시 그려야 함)
|
|
offscreenContext.clearRect(0, 0, size.x, size.y);
|
|
// Style-run batching + sub-pixel decimation: one stroke per style run
|
|
drawController.beginBatch();
|
|
drawEntities(drawController, sceneEntitiesForViewport(drawController));
|
|
drawController.endBatch();
|
|
});
|
|
if (savedHighlight.length) setHighlightedEntityIds(savedHighlight);
|
|
|
|
scenePerf.lastRebuildMs = performance.now() - startedAt;
|
|
scenePerf.sceneRebuilds++;
|
|
|
|
const offset = drawController.getScreenOffset();
|
|
rendered = {
|
|
version: getSceneVersion(),
|
|
scale: drawController.getScreenScale(),
|
|
offsetX: offset.x,
|
|
offsetY: offset.y,
|
|
sizeX: size.x,
|
|
sizeY: size.y,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Draw the static scene: rebuild the cache when needed, otherwise blit the
|
|
* cached bitmap (shifted by the pan delta). Called once per frame by draw().
|
|
*/
|
|
export function drawScene(drawController: ScreenCanvasDrawController, now: number): void {
|
|
const size = drawController.getCanvasSize();
|
|
const scale = drawController.getScreenScale();
|
|
const offset = drawController.getScreenOffset();
|
|
|
|
if (offset.x !== prevOffsetX || offset.y !== prevOffsetY) {
|
|
lastOffsetChangeAt = now;
|
|
prevOffsetX = offset.x;
|
|
prevOffsetY = offset.y;
|
|
}
|
|
|
|
const paramsChanged =
|
|
!rendered ||
|
|
rendered.version !== getSceneVersion() ||
|
|
rendered.scale !== scale ||
|
|
rendered.sizeX !== size.x ||
|
|
rendered.sizeY !== size.y;
|
|
const offsetChanged =
|
|
!!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y);
|
|
|
|
if (paramsChanged || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) {
|
|
rebuildScene(drawController);
|
|
}
|
|
|
|
if (!offscreenCanvas || !rendered) return;
|
|
// screenX = (worldX - offsetX) * scale, canvasY is y-flipped afterwards:
|
|
// content shifts left when offset.x grows, down when offset.y grows.
|
|
const dx = (rendered.offsetX - offset.x) * scale;
|
|
const dy = (offset.y - rendered.offsetY) * scale;
|
|
drawController.blitImage(offscreenCanvas, dx, dy);
|
|
}
|