Files
Aislo/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts
T
eomsangdonandClaude Opus 5.5 66e84b56bf fix(cad): 줌아웃 느려짐 — 공간 색인 칸 범위 자름 · 모눈을 장면 비트맵에서 뺌(227-1)
- 까닭: 화면 상자 조회가 엔티티 칸(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
2026-10-10 16:33:00 +09:00

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);
}