반응형 게임 캔버스
제대로 스케일되지 않는 캔버스는 화면 크기가 달라지면 흐릿해지거나 깨집니다. 이 문제는 셀 수 없을 만큼 디버깅해봤으니, 여러분의 수고를 덜어드릴게요.
여러 스케일링 모드를 시험해 보세요:
핵심 문제
캔버스에는 헷갈리기 쉬운 두 가지 크기가 있습니다. CSS 크기는 화면에 얼마나 크게 보이는지를 제어합니다. 내부 해상도는 실제로 몇 개의 픽셀을 그리는지를 제어합니다. 이 둘이 맞지 않으면 흐릿하거나 왜곡됩니다.
Retina 디스플레이에서는 CSS가 800x600이라고 해도 화면에는 실제로 1600x1200개의 물리 픽셀이 있습니다. 캔버스 내부 해상도가 800x600밖에 안 되면 브라우저가 이를 확대하고, 모든 게 뿌옇게 보입니다.
기본 반응형 설정
캔버스가 창을 가득 채우면서도 선명하게 보이도록 만드는 방법입니다:
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
function resize() {
const dpr = window.devicePixelRatio || 1
const width = window.innerWidth
const height = window.innerHeight
// 내부 해상도를 물리 픽셀에 맞춤
canvas.width = width * dpr
canvas.height = height * dpr
// CSS 크기를 논리 픽셀에 맞춤
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
// 논리 픽셀 단위로 그릴 수 있게 컨텍스트를 스케일
ctx.scale(dpr, dpr)
}
window.addEventListener('resize', resize)
resize()ctx.scale(dpr, dpr) 줄이 핵심입니다. 이후로는 (100, 100) 위치에 그리면 DPR과 상관없이 올바른 자리에 표시됩니다. 캔버스는 2x 디스플레이에서 내부적으로 200x200으로 렌더링하지만, 여러분의 코드는 그걸 알 필요가 없습니다.
한 가지 함정: ctx.scale은 누적됩니다. resize를 여러 번 호출하면 계속 스케일이 커집니다. 스케일하기 전에 ctx.setTransform(1, 0, 0, 1, 0, 0)으로 변환을 먼저 리셋하거나, 한 번만 스케일하세요.
레터박스로 고정 종횡비 유지하기
많은 게임이 특정 종횡비를 필요로 합니다. 16:9 게임이 21:9 울트라와이드 모니터를 채우려고 늘어나는 건 원치 않을 겁니다. 레터박스는 검은 막대를 추가해 비율을 유지합니다.
const GAME_WIDTH = 1920
const GAME_HEIGHT = 1080
const ASPECT = GAME_WIDTH / GAME_HEIGHT
function resizeWithLetterbox() {
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const windowAspect = windowWidth / windowHeight
let width, height
if (windowAspect > ASPECT) {
// 창이 게임보다 넓으면 양옆에 막대 추가
height = windowHeight
width = height * ASPECT
} else {
// 창이 게임보다 높으면 위아래에 막대 추가
width = windowWidth
height = width / ASPECT
}
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
canvas.style.position = 'absolute'
canvas.style.left = (windowWidth - width) / 2 + 'px'
canvas.style.top = (windowHeight - height) / 2 + 'px'
// 내부 해상도는 고정으로 유지
canvas.width = GAME_WIDTH
canvas.height = GAME_HEIGHT
}레터박스 막대가 보이도록 body 배경을 검정으로 설정하세요:
body {
margin: 0;
background: #000;
overflow: hidden;
}픽셀 아트를 위한 정수 스케일링
픽셀 아트는 정수 배율(1x, 2x, 3x)에서 또렷하고, 2.7x 같은 소수 배율에서는 흐릿해집니다. image-rendering: pixelated를 적용해도 2.5x 배율에서는 어떤 픽셀은 화면 픽셀 2개 너비이고 다른 픽셀은 3개 너비가 됩니다.
const GAME_WIDTH = 320
const GAME_HEIGHT = 180
function resizePixelPerfect() {
const maxWidth = window.innerWidth
const maxHeight = window.innerHeight
// 들어맞는 가장 큰 정수 배율 찾기
const scaleX = Math.floor(maxWidth / GAME_WIDTH)
const scaleY = Math.floor(maxHeight / GAME_HEIGHT)
const scale = Math.max(1, Math.min(scaleX, scaleY))
const width = GAME_WIDTH * scale
const height = GAME_HEIGHT * scale
canvas.width = GAME_WIDTH
canvas.height = GAME_HEIGHT
canvas.style.width = width + 'px'
canvas.style.height = height + 'px'
canvas.style.imageRendering = 'pixelated'
}대신 게임이 화면을 정확히 가득 채우지는 못합니다. 1920x1080 디스플레이에서 320x180 게임을 돌리면 5x 스케일(1600x900)이 되고 가장자리에 검은 막대가 생깁니다. 어떤 플레이어는 이걸 선호하고, 다른 플레이어는 약간 흐릿하더라도 게임이 화면을 가득 채우길 원합니다.
WebGL 캔버스 스케일링
WebGL에서는 캔버스 크기 외에 뷰포트도 업데이트해야 합니다:
function resizeGL(gl, canvas) {
const dpr = window.devicePixelRatio || 1
const width = canvas.clientWidth * dpr
const height = canvas.clientHeight * dpr
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width
canvas.height = height
gl.viewport(0, 0, width, height)
return true // 크기가 바뀜, 투영 행렬을 갱신해야 할 수 있음
}
return false
}resize 이벤트에서만이 아니라 렌더 루프 시작 부분에서 이 함수를 호출하세요. 일부 브라우저는 창을 모니터 사이로 드래그할 때 DPR을 바꾸는데, 그 경우를 잡아내고 싶을 겁니다.
resize 이벤트 디바운싱
사용자가 창 가장자리를 드래그하는 동안 resize 이벤트가 빠르게 발생합니다. resize 함수가 무겁다면 화면이 끊깁니다.
let resizeTimeout
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(resize, 100)
})100ms 지연은 사용자가 드래그를 멈춘 뒤에야 resize가 일어난다는 뜻입니다. 드래그 도중에도 더 매끄럽게 처리하려면 ResizeObserver를 쓰세요:
const observer = new ResizeObserver(entries => {
resize()
})
observer.observe(document.body)ResizeObserver는 더 드물게 발생하고 브라우저가 더 잘 최적화합니다.
전체 화면
Fullscreen API로 화면 전체를 차지할 수 있습니다:
async function toggleFullscreen() {
if (!document.fullscreenElement) {
await canvas.requestFullscreen()
} else {
await document.exitFullscreen()
}
}
document.addEventListener('fullscreenchange', resize)requestFullscreen은 클릭 같은 사용자 제스처에 대한 응답으로 호출해야 합니다. 그렇지 않으면 브라우저가 막습니다. 그리고 전체 화면에 들어가거나 나올 때는 사용 가능한 공간이 바뀌므로 resize를 잊지 마세요.
플랫폼 관련 주의점이 하나 있습니다. 2026년 중반 기준으로 Fullscreen API는 iPhone Safari에서 여전히 요소 단위 전체 화면을 지원하지 않습니다. requestFullscreen은 데스크톱 브라우저와 iPadOS에서는 작동하지만, iPhone에서는 캔버스 같은 임의의 요소에 대해 지원되지 않습니다(네이티브 비디오 전체 화면만 작동). if (canvas.requestFullscreen)로 기능을 감지하고 우아하게 대체하세요. 예를 들어 캔버스를 보이는 뷰포트에 맞게 크기를 조정하거나, 사용자에게 게임을 PWA로 홈 화면에 추가하라고 안내할 수 있습니다.
한데 모으기
흔한 경우들을 모두 처리하는 클래스입니다:
class ResponsiveCanvas {
constructor(canvas, options = {}) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.gameWidth = options.width || 1280
this.gameHeight = options.height || 720
this.pixelArt = options.pixelArt || false
this.letterbox = options.letterbox !== false
this.bind()
this.resize()
}
bind() {
window.addEventListener('resize', () => this.resize())
document.addEventListener('fullscreenchange', () => this.resize())
}
resize() {
const aspect = this.gameWidth / this.gameHeight
let width = window.innerWidth
let height = window.innerHeight
if (this.letterbox) {
if (width / height > aspect) {
width = height * aspect
} else {
height = width / aspect
}
}
if (this.pixelArt) {
const scale = Math.max(1, Math.floor(Math.min(
window.innerWidth / this.gameWidth,
window.innerHeight / this.gameHeight
)))
width = this.gameWidth * scale
height = this.gameHeight * scale
}
this.canvas.width = this.gameWidth
this.canvas.height = this.gameHeight
this.canvas.style.width = width + 'px'
this.canvas.style.height = height + 'px'
this.canvas.style.position = 'absolute'
this.canvas.style.left = (window.innerWidth - width) / 2 + 'px'
this.canvas.style.top = (window.innerHeight - height) / 2 + 'px'
if (this.pixelArt) {
this.canvas.style.imageRendering = 'pixelated'
}
}
get width() { return this.gameWidth }
get height() { return this.gameHeight }
}이렇게 사용합니다:
const responsive = new ResponsiveCanvas(document.getElementById('game'), {
width: 320,
height: 180,
pixelArt: true,
letterbox: true
})
// 게임 코드에서는 responsive.width와 responsive.height를 사용흔한 함정
사람들이 자주 걸려 넘어지는 몇 가지입니다:
resize 때 캔버스가 지워집니다. canvas.width나 canvas.height를 설정하면 캔버스가 지워집니다. 매 프레임 다시 그리는 게 아니라면 resize 후에 다시 그려야 합니다.
DPR이 바뀔 수 있습니다. 사용자가 브라우저 창을 Retina 디스플레이에서 비 Retina 디스플레이로 드래그하면 DPR이 바뀝니다. resize 이벤트에서만이 아니라 렌더 루프에서 확인하세요.
iOS Safari 뷰포트 특이점. 주소창이 보이거나 숨겨질 때 뷰포트 높이가 바뀝니다. 모바일에서 더 정확한 크기를 얻으려면 window.visualViewport를 사용하세요.
컨텍스트 상태가 리셋됩니다. 캔버스 크기를 설정하면 변환, 채우기 스타일, imageSmoothingEnabled를 포함한 컨텍스트 상태가 리셋됩니다. resize 후에 다시 적용하세요.
더 많은 자료
Canvas 2D 게임 루프는 update/render 루프의 기초를 다룹니다.
픽셀 아트 렌더링은 또렷한 픽셀 그래픽을 더 깊이 다룹니다.
모바일 친화적인 웹 게임은 터치 입력과 뷰포트 문제를 다룹니다.
WebGL 기초는 WebGL 컨텍스트의 뷰포트와 캔버스 설정을 다룹니다.
빠르게 로드되는 웹 게임 출시하기는 성능을 위한 캔버스 크기 최적화를 다룹니다.