캔버스 하나로 별이 흐르는 배경 만들기
첫 화면 뒤에 깔린 별과 별똥별은 이미지가 아니라 캔버스에 매 프레임 그린 것이에요. 가볍게 돌아가도록 별을 만들고, 움직이고, 화면 크기에 맞춘 방법을 정리했어요.
첫 화면을 열면 검은 배경에 별이 천천히 흐르고 가끔 반짝여요. 마우스를 움직이면 커서 주변 별이 밝아지고 별가루가 떨어지고, 가끔 별똥별이 지나가요. 이 배경은 이미지나 영상이 아니라 canvas 하나에 자바스크립트로 그린 것이에요.
팀이 정한 분위기는 "우주에 떠 있는 듯한"이었어요. 배경이 너무 화려하면 위에 놓인 프로젝트가 안 보이고, 너무 단조로우면 그냥 검은 화면이에요. 그 사이를 맞추면서 정한 것들을 적어 봤어요.
별 하나가 가진 값
별은 위치와 몇 가지 숫자로만 이뤄져요.
type Star = {
x: number
y: number
depth: number
radius: number
alpha: number
phase: number
speed: number
warm: boolean
}
핵심은 depth예요. 0.2에서 1 사이의 값인데, 별이 얼마나 가까이 있는지를 나타내요. 크기, 밝기, 움직이는 속도가 모두 이 값 하나에서 나와요.
const depth = 0.2 + Math.random() * 0.8
return {
x: Math.random(),
y: Math.random(),
depth,
radius: 0.3 + depth * depth * 0.9,
alpha: 0.15 + depth * 0.55,
phase: Math.random() * Math.PI * 2,
speed: 0.0004 + Math.random() * 0.0014,
warm: Math.random() < 0.08,
}
가까운 별은 크고 밝고 빨리 움직이고, 먼 별은 작고 흐리고 느리게 움직여요. 반지름에 depth를 제곱해서 쓴 것은 큰 별을 드물게 만들기 위해서예요. 그냥 곱하면 큰 별이 너무 많아서 화면이 시끄러웠어요.
별 중 8%는 노란색이에요. 사이트의 강조색과 같은 색이라, 흰 별 사이에 가끔 섞이면 배경과 글자가 한 덩어리로 보여요.
위치 x, y는 픽셀이 아니라 0에서 1 사이의 비율로 저장해요. 그릴 때 화면 너비와 높이를 곱해요. 그래야 창 크기가 바뀌어도 별의 배치가 그대로 따라가요.
별의 수는 화면 넓이에 맞춰요
휴대폰과 넓은 모니터에 같은 수의 별을 뿌리면 한쪽은 빽빽하고 한쪽은 텅 비어 보여요. 그래서 화면 넓이에 비례해서 정해요.
const count = Math.min(420, Math.floor((width * height) / 3200))
3200제곱픽셀마다 별 하나예요. 대신 아무리 넓어도 420개를 넘기지 않아요. 별 하나를 그리는 비용은 작지만, 매 프레임 수백 개를 그리면 낡은 노트북에서는 팬이 돌기 시작해요.
반짝임과 흐름
매 프레임 별마다 두 가지를 계산해요.
const drift = reducedMotion ? 0 : time * 0.000004 * star.depth
const x = (((star.x + drift) % 1) + 1) % 1 * width + shiftX * star.depth
const twinkle = reducedMotion
? 0.7
: 0.55 + 0.45 * Math.sin(time * star.speed + star.phase)
drift는 별이 오른쪽으로 아주 천천히 흐르게 해요. 화면 밖으로 나간 별은 나머지 연산으로 왼쪽에서 다시 들어와요. % 1을 두 번 한 것은 음수가 나와도 0에서 1 사이로 돌려놓기 위해서예요.
twinkle은 사인 함수로 밝기를 오르내리게 해요. 별마다 phase와 speed가 달라서 같이 깜빡이지 않아요. 모든 별이 한꺼번에 깜빡이면 반짝임이 아니라 전구처럼 보여요.
마우스를 따라오는 빛
커서 근처의 별은 밝아지고 커져요. 커서 위치를 바로 쓰지 않고, 매 프레임 목표 위치로 12%씩 다가가게 했어요.
lightX += (targetX - lightX) * 0.12
lightY += (targetY - lightY) * 0.12
presence += ((hasPointer ? 1 : 0) - presence) * 0.06
빛이 커서를 살짝 늦게 따라와서 부드럽게 느껴져요. presence는 커서가 화면에 있는지를 0에서 1 사이로 나타내요. 커서가 창 밖으로 나가면 빛이 툭 꺼지지 않고 천천히 사라져요.
커서 위치에 따라 별 전체를 아주 조금 반대쪽으로 밀기도 해요. 가까운 별일수록 많이 움직여서, 화면에 깊이가 생겨요.
별가루와 별똥별
커서를 움직이면 지나간 자리에 작은 별가루가 떨어져요. 마우스 이벤트마다 만들면 너무 많아서, 지난번에 만든 자리에서 14픽셀 이상 움직였을 때만 하나씩 만들어요. 그리고 80개가 넘으면 오래된 것부터 지워요.
if (Math.hypot(targetX - lastDustX, targetY - lastDustY) < 14) return
별똥별은 한 번에 하나만 떠요. 하나가 사라지면 7초에서 16초 사이로 다음 시각을 정해요. 자주 떨어지면 시선을 빼앗고, 너무 드물면 아무도 못 봐요. 몇 번 바꿔 보다가 이 간격에서 "가끔 봤다"는 느낌이 들었어요.
화면이 선명하게 보이게
캔버스는 그대로 두면 레티나 같은 고해상도 화면에서 흐릿해요. CSS 크기와 실제 픽셀 수가 다르기 때문이에요. 그래서 기기의 픽셀 비율만큼 캔버스를 키우고 좌표계를 맞춰요.
const ratio = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = width * ratio
canvas.height = height * ratio
context.setTransform(ratio, 0, 0, ratio, 0, 0)
비율은 2에서 멈춰요. 3배 화면에서 3배로 그리면 그릴 픽셀이 2배보다 두 배 이상 많아지는데, 작은 별에서는 차이가 눈에 거의 보이지 않았어요.
모바일에서 별이 다시 뿌려지는 문제
처음에는 창 크기가 바뀔 때마다 별을 새로 만들었어요. 그런데 휴대폰에서 스크롤을 하면 주소창이 접히고 펴지면서 화면 높이가 바뀌어요. 그때마다 별이 전부 다른 자리로 뛰었어요.
그래서 너비가 바뀔 때만 별을 새로 만들고, 높이만 바뀌면 캔버스 크기만 맞추도록 바꿨어요.
if (width === starWidth) {
if (reducedMotion) draw(0)
return
}
starWidth = width
위치를 비율로 저장해 둔 덕분에 높이가 바뀌어도 별은 늘어나거나 줄어드는 느낌 없이 제자리에 있어요.
움직임을 원하지 않는 사람에게는
운영체제 설정에서 움직임 줄이기를 켠 사람에게는 애니메이션을 돌리지 않아요. 별은 한 번만 그리고, 반짝임과 흐름, 별가루와 별똥별은 모두 빼요. 마우스 이벤트도 등록하지 않아요.
if (!reducedMotion) {
window.addEventListener('pointermove', handleMove)
frame = requestAnimationFrame(loop)
}
배경은 장식일 뿐이에요. 장식 때문에 어지러운 사람이 있다면 꺼 주는 것이 맞아요.
정리
- 별마다 깊이 값 하나를 두고 크기, 밝기, 속도를 모두 거기서 만들면 자연스러운 원근감이 생겨요.
- 별의 수는 화면 넓이에 맞추고 상한을 둬요.
- 위치는 비율로 저장하고, 모바일에서는 너비가 바뀔 때만 별을 다시 만들어요.
- 고해상도 화면에서는 픽셀 비율만큼 키우되 2배에서 멈춰요.
- 움직임 줄이기를 켠 사람에게는 정지된 별만 보여줘요.