4K 모니터에서 Canvas 가 뚝뚝 끊겨서 — DPR 캡 + blur 배칭으로 되살린 이야기
저해상도에서는 잘 돌던 Canvas 가 4K 모니터에서만 프레임이 뚝뚝 떨어지던 문제. DPR 이 뭐고 blur cost 는 어떻게 계산되며, 어떤 전략으로 되돌릴 수 있는지 실제 수치와 함께 정리.
시작 — "왜 내 모니터에서만 느려요?"
Starship 과 Blackhole 같은 Canvas 기반 작품 몇 개가 쌓인 뒤였습니다. 대부분의 개발/테스트는 일반 FHD 노트북에서 했고, 체감상 충분히 부드러웠습니다.
그런데 4K 외부 모니터에 연결한 상태에서 테스트해보니 같은 작품이 확연히 끊기는 것이었습니다. 프로파일러를 열어보니 ctx.filter = 'blur(...)' 관련 연산이 프레임 예산의 절반 이상을 먹고 있었습니다. 이게 이번 글의 출발점입니다.
먼저 — DPR 이 뭔가
Device Pixel Ratio (DPR) 은 CSS 픽셀 1개에 실제 물리 픽셀이 몇 개 매핑되는지 나타내는 비율입니다.
- FHD 일반 모니터: DPR 1
- Retina / 고밀도 디스플레이: DPR 2
- 4K 노트북 내장 디스플레이: DPR 2~3
Canvas 를 HiDPI 대응하려면 흔히 이렇게 합니다.
const dpr = window.devicePixelRatio;
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
ctx.scale(dpr, dpr); // 이후 그리기 좌표는 CSS px 기준
이렇게 해야 고해상도 디스플레이에서 선명하게 보입니다. 여기까지는 표준 패턴.
문제 — 4K + 고DPR 조합에서 픽셀 수 폭발
4K 모니터 (3840×2160) 의 DPR 2 환경에서 풀스크린 캔버스를 띄우면:
cssWidth * dpr = 3840 * 2 = 7680
cssHeight * dpr = 2160 * 2 = 4320
→ 총 픽셀 수: 약 3,320만
비교: FHD DPR 1 에서는 1920 × 1080 ≈ 207만 픽셀. 16배 차이.
일반적인 draw 연산들은 그래도 버팁니다. 문제는 blur:
blur cost ∝ pixel_count × blur_radius²
blur 반경이 2배면 cost 는 4배. 픽셀 수까지 16배면 총 64배 부담. 한 프레임에 blur draw call 이 몇 번만 있어도 16ms 예산을 훌쩍 넘깁니다.
전략 1 — DPR 상한 캡
제일 간단하고 효과적인 방법. "어차피 4K 에서 DPR 2 로 그려도 사람 눈엔 DPR 1.5 와 거의 구분 안 됨" 이라는 현실을 받아들이고 상한을 설정합니다.
// utils/setupCanvasScale.ts
export function setupCanvasScale(
canvas: HTMLCanvasElement,
cssW: number,
cssH: number,
opts: { dprCap?: number } = {},
) {
const rawDpr = window.devicePixelRatio || 1;
const dprCap = opts.dprCap ?? 2;
const effectiveDpr = Math.min(rawDpr, dprCap);
canvas.width = Math.round(cssW * effectiveDpr);
canvas.height = Math.round(cssH * effectiveDpr);
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
return { effectiveDpr };
}
프로젝트 전체에서 이 유틸 하나만 쓰기로 하고, 성능 민감한 페이지(Starship, Blackhole)는 dprCap: 2 를 명시했습니다.
⚠️ 주의: 렌더러 내부에서
window.devicePixelRatio를 직접 참조하면 안 됩니다. 반드시setupCanvasScale의 반환값effectiveDpr을 사용해야 렌더링 해상도와 캔버스 버퍼 크기가 일치합니다.
전략 2 — blur radius 자체도 캡
DPR 을 캡해도 4K 자체 해상도는 높기 때문에 Math.min(minDim * 0.02, 20) 같은 식으로 blur radius 자체도 상한을 둡니다.
const minDim = Math.min(cssW, cssH);
const blurRadius = Math.min(Math.round(minDim * 0.02), 20);
ctx.filter = `blur(${blurRadius}px)`;
20px 넘어가면 GPU blur 가 급격히 느려지는 구간이 있습니다. 이 값은 장비마다 다르지만 20 을 경험적 상한으로 잡았습니다.
전략 3 — OffscreenCanvas 로 blur 배칭
진짜 큰 폭의 개선은 이쪽이었습니다. 여러 blur 대상이 있으면, 각각을 ctx.filter = 'blur(...)' 로 그리지 말고 OffscreenCanvas 에 blur 없이 먼저 그린 뒤, 그 결과를 한 번만 blur 로 drawImage 합니다.
// 모듈 레벨 캐시
let _offscreen: OffscreenCanvas | null = null;
function drawBlurredLayer(ctx: CanvasRenderingContext2D, items: Item[]) {
if (!_offscreen || _offscreen.width !== ctx.canvas.width) {
_offscreen = new OffscreenCanvas(ctx.canvas.width, ctx.canvas.height);
}
const off = _offscreen.getContext('2d')!;
off.clearRect(0, 0, _offscreen.width, _offscreen.height);
// 1. blur 없이 모든 아이템 그리기
for (const item of items) {
drawItem(off, item);
}
// 2. blur 한 번만 적용해서 메인으로
ctx.save();
ctx.filter = `blur(${blurRadius}px)`;
ctx.drawImage(_offscreen, 0, 0);
ctx.restore();
}
이 프로젝트의 Blackhole 페이지에서 측정한 결과:
| 방식 | 프레임당 blur op | 체감 FPS (4K) |
|---|---|---|
| 개별 blur | 438 | ~22fps |
| OffscreenCanvas 배칭 | 7 | ~58fps |
blur op 수가 62배 줄었고 FPS 는 2.5배 이상 올라갔습니다. 같은 시각 결과를 내면서요.
전략 4 — 정적 mesh / vertex 캐싱
성능 이슈를 파고드는 김에 한 가지 더 잡았습니다. 지형이나 별자리 같이 프레임마다 재계산할 필요가 없는 mesh 를 매번 jitter + hash 로 다시 만들고 있었던 것.
Cosmic Barrage 의 지형 삼각분할은 2000개 이상의 vertex 를 매 프레임 재생성하고 있었습니다. 이걸 stateRef 에 캐싱:
interface GameState {
terrainVerts?: Vertex[][]; // optional 캐시
// ...
}
function drawTerrain(ctx: CanvasRenderingContext2D, state: GameState) {
if (!state.terrainVerts) {
// 처음 한 번만 생성
state.terrainVerts = generateTerrain(...);
}
// 캐시된 vertex 재사용
renderVerts(ctx, state.terrainVerts);
}
// 스테이지 전환 시 무효화
function nextStage(state: GameState) {
state.terrainVerts = undefined;
}
매 프레임 2000+ 회의 Math.sin/Math.random 호출이 1회로 줄면서 GC 스파이크도 사라졌습니다.
⚠️ 함정: 캐시 리팩토링 시 내부 헬퍼 함수의 스코프에 주의.
if (!cached) { const hash = ...; ... }블록 안에 선언한hash는 캐시 히트 경로(else/이후 루프)에서ReferenceError가 납니다. 헬퍼 함수는 반드시 함수 최상단에 선언해야 두 경로 모두 참조 가능합니다. TypeScript 가 못 잡고 런타임 첫 캐시 히트에서만 터지니 브라우저 스모크 테스트 필수.
전략 5 — 글로우·경계 렌더링 최적화
사소해 보이지만 효과 컸던 패턴. 별·글로우를 그릴 때 흔히 "core 레이어 + glow 레이어" 를 겹쳐 그립니다. 그런데 두 레이어 사이에 하드 엣지가 보이는 문제가 생깁니다.
// ❌ 경계 하드 엣지 발생
drawGlow(ctx, x, y, 20);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
drawCore(ctx, x, y, 3);
한 번에 통합 gradient 로 처리하면 경계가 사라집니다.
// ✅ 하나의 radialGradient 로 core~glow 통합
const g = ctx.createRadialGradient(x, y, 0, x, y, 20);
g.addColorStop(0, 'rgba(255,255,255,1)'); // core
g.addColorStop(0.3, 'rgba(200,220,255,0.8)'); // transition
g.addColorStop(1, 'rgba(100,150,255,0)'); // fade out
ctx.fillStyle = g;
ctx.fillRect(x - 20, y - 20, 40, 40);
draw call 수도 절반이 되고 시각 품질도 더 좋아집니다.
체크리스트
Canvas 작업에서 성능이 신경 쓰이기 시작하면 이 순서로 점검하세요.
-
setupCanvasScale공통 유틸 사용 —window.devicePixelRatio직접 참조 금지 - 성능 민감 페이지는
dprCap: 2명시 - blur radius 는 상한 20px 이하
- 여러 blur 대상 → OffscreenCanvas 에 몰아 그린 뒤 1회 blur
- 매 프레임 재계산되는 mesh/vertex → state 에 캐시
- 별·글로우는 통합 radialGradient 로
회고
4K 에서 느려진다는 건 "고해상도니까 어쩔 수 없다" 가 아니라 blur 비용의 제곱 비례 때문 이었습니다. 이걸 알고 나니 DPR 캡과 blur 배칭이라는 두 가지 간단한 조치로 대부분 해결 가능했습니다.
성능 최적화는 원인 분석이 80%, 실제 코드 수정은 20% 라는 말을 새삼 실감했습니다. "왜 느린지" 를 계산식 수준에서 이해하면 실제 작업은 거의 기계적입니다.
비슷한 증상 (저해상도 OK, 고해상도에서만 끊김) 을 겪고 계시다면, DPR 캡부터 시도해보시길 권합니다. 30분 작업으로 FPS 가 2배 뛰는 경우가 흔합니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...