안녕하세요. 자바파커입니다.
불꽃놀이를 Canvas로 그리는 예제는 많습니다. 대부분 몇십 줄이면 동작합니다. 그런데 막상 만들어 보면 불꽃처럼 보이지 않습니다. 점이 사방으로 흩어지는 애니메이션일 뿐 폭죽으로 안 읽힙니다.
46초짜리 불꽃축제 영상을 코드로만 만들면서 그 간극을 좁혀 봤습니다. 카메라도, 소재 영상도, 배경음악도 쓰지 않았습니다. 결론부터 말하면 이렇습니다.
현실감은 불꽃 파티클이 아니라 그 주변에서 나옵니다.
이 글에서 다루는 것들을 실제로 적용한 46초 영상입니다. 카메라도 소재 영상도 배경음악도 쓰지 않고 화면의 모든 픽셀과 소리를 코드로 만들었습니다.
왜 도넛처럼 보이는가
가장 먼저 부딪히는 문제입니다. 파티클을 균등하게 뿌렸는데 가운데가 빈 고리가 됩니다.
// 흔한 코드 — 그리고 고리가 되는 이유
const angle = (i / n) * Math.PI * 2
const vx = Math.cos(angle) * speed
const vy = Math.sin(angle) * speed각도를 한 바퀴 돌리면 파티클은 원둘레 위에만 놓입니다. 하지만 실제 화포는 평면이 아니라 사방으로 터집니다. 그 구를 옆에서 보면 원반이고, 가장자리가 밝은 형태가 됩니다.
방향을 구면에서 뽑아야 합니다.
// 구면 균등 분포 — z를 균등하게 뽑는 것이 핵심이다
const dz = r1 * 2 - 1 // cos(theta) 균등
const phi = r2 * Math.PI * 2
const s = Math.sqrt(1 - dz * dz)
const dx = s * Math.cos(phi)
const dy = s * Math.sin(phi) // 화면에 그릴 두 축
// dz 는 화면에 안 그리지만 깊이 정보로 쓴다theta를 균등하게 뽑으면 극 쪽에 몰립니다. cos(theta)를 균등하게 뽑아야 구 표면에 고르게 퍼집니다.
dz는 버리지 말고 깊이로 씁니다. 앞으로 오는 별을 크고 밝게 그리면 평면이 아니라 입체로 읽힙니다.
const depth = 0.68 + 0.42 * ((dz + 1) / 2)
alpha *= depth
radius *= depth그래도 도넛이라면
구면으로 바꿔도 여전히 가운데가 비어 보일 수 있습니다. 실제로 그랬습니다.
파티클을 늘려도 소용없습니다. 실제 불꽃 사진이 꽉 차 보이는 이유는 별의 개수가 아니라 중심에서 바깥으로 뻗는 긴 광적(光跡)이기 때문입니다. 카메라 노출 시간 동안 별이 지나간 궤적이 선으로 남습니다.
꼬리를 0.05초에서 0.3초로 늘리자 한 번에 해결됐습니다.
// 지금 위치와 0.3초 전 위치를 잇는다
const back = positionAt(dt - 0.3)
ctx.beginPath()
ctx.moveTo(back.x, back.y)
ctx.lineTo(now.x, now.y)
ctx.stroke()감속을 넣으면 반경이 1/3 이 된다
중력만 넣고 등속으로 날리면 별 모양으로 뻗기만 합니다. 실제 별은 공기 저항으로 빠르게 감속했다가 그 자리에서 흘러내립니다.
속도가 v' = -kv로 감쇠하면 이동 거리는 이렇게 됩니다.
const reach = (1 - Math.exp(-k * dt)) / k // 최종값은 1/k 로 수렴
const x = cx + dx * v * reach여기서 함정이 하나 있습니다. v에 원하는 반경을 그대로 넣으면 화면에 실제로 그려지는 반경은 v/k 가 됩니다. k = 1.45라면 의도의 69%, 감속을 더 세게 주면 3분의 1까지 줄어듭니다.
r이 "도달 반경"이라는 뜻이 되게 하려면 환산해야 합니다.
const v = r * k // 이러면 최종 도달 거리가 정확히 r 이 된다중력도 같은 감쇠를 받습니다. 이게 버들형(willow) 불꽃의 늘어지는 곡선을 만듭니다.
const fall = (G * (dt - (1 - Math.exp(-k * dt)) / k)) / k
const y = cy + dy * v * reach + fall진짜처럼 보이게 하는 다섯 가지
불꽃 자체를 아무리 다듬어도 넘지 못하는 벽이 있습니다. 다음 다섯 개가 그 벽을 넘게 해 줬습니다. 영향이 큰 순서대로 적습니다.
1. 터지기 전 1.4초
화면 밖에서 갑자기 터지면 스티커를 붙인 것처럼 보입니다. 아래에서 올라오는 궤적이 있어야 "쏘아 올렸다"가 됩니다.
감속하면서 올라가고, 뒤에 짧은 불티를 남기고, 정점에서 터집니다.
const e = 1 - Math.pow(1 - u, 2.1) // 위로 갈수록 느려진다
const y = lerp(groundY, burstY, e)2. 빛보다 늦게 오는 폭음
가장 알아보기 쉬운 신호입니다. 번쩍하고 잠시 뒤에 소리가 오는 순간 거리감이 생깁니다.
소리는 초당 343m를 갑니다. 고도와 좌우 거리로 지연을 계산해 효과음을 미룹니다.
function boomDelay(x, y) {
const dx = (x - CENTER) / CENTER
const alt = clamp((1000 - y) / 700, 0, 1)
return 0.62 + alt * 0.48 + Math.abs(dx) * 0.16 // 0.6 ~ 1.2초
}3. 물에 비친 하늘
강이나 바다가 있으면 반사가 절반을 담당합니다. 하늘을 별도 캔버스에 그린 뒤 가로 띠 단위로 뒤집어 깝니다.
const STEP = 4,
COMPRESS = 1.95
for (let d = 0; d < riverH; d += STEP) {
const sy = horizon - d * COMPRESS // 위쪽을 끌어온다
const wob = Math.sin(d * 0.05 + t * 1.9) * amp
ctx.globalAlpha = 0.5 - (d / riverH) * 0.3
ctx.drawImage(skyCanvas, 0, sy, W, STEP, wob, horizon + d, W, STEP + 1)
}압축률을 1보다 크게 줘야 원근이 맞습니다. 그리고 흔들림은 깊이에 비례시켜야 합니다. 전 구간을 같은 진폭으로 흔들면 수평선 근처 건물이 녹아내리는 것처럼 보입니다.
4. 개화 후광
터지는 순간 하늘만 밝아지면 안 됩니다. 도시 옥상과 수면도 같이 밝아져야 같은 공간에 있는 걸로 읽힙니다.
주의할 점은 화면 전체를 밝은 색으로 덮으면 안 된다는 것입니다.
// 이렇게 하면 안개가 낀 것처럼 뿌옇게 뜬다
ctx.globalAlpha = 0.17
ctx.fillStyle = burstColor
ctx.fillRect(0, 0, W, H)화면 전체는 0.05 이하로만 들고, 대신 터진 자리 주변에 반경 그래디언트를 깝니다. 밤공기가 빛을 머금는 부분입니다.
5. 남는 연기
터진 자리에 옅은 연기를 남기고 바람에 흘려보냅니다. 다음 발이 그 연기를 통과하면 낱개의 폭죽이 아니라 이어지는 축제가 됩니다.
개화 순간이 흰 원반으로 뭉친다
별 200개가 한 점에서 출발하므로, 터진 직후 0.2초 동안은 전부 겹칩니다. 가산 합성(lighter)이라 흰색으로 포화되어 하드 엣지 원반이 됩니다.
물리적으로는 맞지만 보기에는 어색합니다. 그 구간만 밝기를 눌러 줍니다.
alpha *= lerp(0.3, 1, clamp(dt / 0.28)) // 부풀어 오르는 빛이 된다성능 — shadowBlur를 쓰면 안 된다
글로우를 shadowBlur로 주는 코드가 많습니다. 오브젝트가 몇 개일 때는 괜찮지만 파티클마다 걸면 프레임 시간이 폭증합니다. CPU 래스터라이즈라 GPU 가속을 못 받습니다.
글로우가 구워진 스프라이트를 오프스크린 캔버스에 한 번만 만들어 두고 drawImage로 찍으면 됩니다.
const cache = new Map()
function glowSprite(color, r = 24) {
const key = `${color}@${r}`
if (cache.has(key)) return cache.get(key)
const cv = document.createElement("canvas")
cv.width = cv.height = r * 2
const c = cv.getContext("2d")
const q = c.createRadialGradient(r, r, 0, r, r, r)
q.addColorStop(0.0, "rgba(255,255,255,0.95)") // 심지는 흰색이라야 발광으로 읽힌다
q.addColorStop(0.18, hexToRgba(color, 0.95))
q.addColorStop(1.0, hexToRgba(color, 0))
c.fillStyle = q
c.fillRect(0, 0, r * 2, r * 2)
cache.set(key, cv)
return cv
}색은 캐시 키가 되므로 연속적으로 변하는 색을 쓰면 캐시가 무한히 늘어납니다. 불꽃은 꺼질 때 제 색에서 주황으로 변하는데, 이걸 6단계로 양자화하면 조합이 수십 개로 유지됩니다.
이 방식으로 파티클 1,000개 이상을 그려도 프레임 시간이 거의 늘지 않았습니다.
결정론 — Math.random을 쓰지 않는다
영상으로 뽑으려면 프레임을 순서대로 그리지 않습니다. seek(t) 방식으로 임의의 시점을 방문하고, 실패하면 다시 그립니다. 난수를 쓰면 같은 t를 두 번 그렸을 때 결과가 달라져 화면이 튑니다.
모든 값을 (seed, index) 해시에서 뽑습니다.
function hash01(seed, i, salt = 0) {
let h = (seed | 0) * 374761393 + (i | 0) * 668265263 + (salt | 0) * 2246822519
h = Math.imul(h ^ (h >>> 13), 1274126177)
h ^= h >>> 16
return (h >>> 0) / 4294967296
}상태를 들고 있지 않으므로 호출 순서와 무관합니다. 별의 방향·속도·수명, 건물과 창문, 별자리, 연기까지 전부 여기서 나옵니다.
효과음도 마찬가지입니다. 잔가락(crackle)을 난수로 흩뿌리면 렌더할 때마다 소리가 달라집니다. 황금비를 이용한 결정론적 배치를 쓰면 불규칙하게 들리면서도 재현됩니다.
const u = (i * 0.6180339887) % 1
const at = t + Math.pow(i / n, 0.85) * dur + u * 0.045다만 픽셀 단위로 완전히 같지는 않다
방문 순서를 바꿔 가며 같은 프레임을 두 번 뽑아 비교했습니다.
| 프레임 | 두 번 렌더 결과 |
|---|---|
| 파티클 없는 구간 | 완전 동일 (PSNR ∞) |
| 파티클 구간 | PSNR 77.6dB · 최대 차이 12/255 |
파티클 위치가 어긋난 게 아닙니다. 위치가 흔들리면 PSNR이 30dB대로 떨어집니다. lighter로 겹쳐 쌓인 밝은 픽셀에서 브라우저 래스터라이저가 반올림을 다르게 하는 것이고, H.264 양자화가 이보다 크게 뭉갭니다.
비트 단위 일치를 전제로 하는 검사(루프 이음매 PSNR 비교 등)에는 파티클을 쓰면 안 됩니다.
소리도 코드로 만든다
에셋 파일 없이 Web Audio API만 씁니다.
| 소리 | 만드는 법 |
|---|---|
| 발사 | 노이즈를 밴드패스에 통과시켜 260Hz → 1500Hz로 쓸어 올린다 |
| 개화 | 저역 사인(60→30Hz)과 로우패스 노이즈를 겹친다 |
| 잔가락 | 짧은 하이패스 노이즈 팝을 흩뿌린다 |
| 강변 소음 | 로우패스 노이즈를 아주 낮게 깐다 |
배경음악은 넣지 않았습니다. 소리가 전부 화면의 동작에 붙어 있으면 음악이 없어도 비어 있다는 느낌이 들지 않습니다.
그리고 효과음 큐는 화포 표에서 자동으로 파생시킵니다.
for (const shell of SHELLS) {
sfx.push({ t: shell.launchAt, fx: "launch" })
sfx.push({ t: shell.burstAt + boomDelay(shell.x, shell.y), fx: "boom" })
}화면과 소리를 각각 손으로 적으면 반드시 어긋납니다. 한쪽 타이밍을 조정하고 다른 쪽을 깜빡하는 일이 실제로 생깁니다.
정리
Canvas로 불꽃놀이를 그릴 때 막히는 지점과 해법입니다.
| 증상 | 원인 | 해법 |
|---|---|---|
| 가운데가 빈 고리 | 방향을 2D 원에서 뽑음 | 구면에서 뽑아 투영 |
| 여전히 비어 보임 | 꼬리가 짧음 | 광적을 0.3초 수준으로 |
| 생각보다 작음 | 감속으로 v/k만 이동 |
v = r · k로 환산 |
| 스티커처럼 보임 | 상승 궤적 없음 | 터지기 전 1.4초 |
| 거리감이 없음 | 소리가 즉시 남 | 343m/s로 지연 계산 |
| 하늘이 뿌옇게 뜸 | 화면 전체를 밝게 채움 | 터진 자리 주변 후광 |
| 프레임이 느림 | 파티클마다 shadowBlur |
글로우 스프라이트 캐시 |
| 프레임이 튐 | Math.random() |
(seed, index) 해시 |
핵심은 하나입니다. 불꽃을 더 예쁘게 만드는 것보다, 불꽃이 놓인 공간을 만드는 쪽이 효과가 큽니다. 상승 궤적, 늦게 오는 소리, 물에 비친 빛, 같이 밝아지는 도시, 남은 연기 — 이 다섯 개가 없으면 아무리 화려한 파티클도 화면에 붙은 그림으로 보입니다.
자주 묻는 것
Q. WebGL을 써야 하지 않나요? A. 파티클이 수만 개 단위로 가면 WebGL이 맞습니다. 다만 글로우 스프라이트를 캐시하면 Canvas 2D로도 1,000개 이상을 무리 없이 그릴 수 있습니다. 이 영상은 동시에 최대 1,200개 안팎이었고 프레임당 0.29초로 렌더됐습니다.
Q. 실시간 애니메이션에도 결정론이 필요한가요?
A. 화면에 띄우기만 한다면 필요 없습니다. Math.random()을 써도 됩니다. 영상 파일로 뽑거나, 특정 시점으로 되감아야 하거나, 테스트로 프레임을 비교해야 할 때만 필요합니다.
Q. 파티클 개수는 몇 개가 적당한가요? A. 화포 하나에 100~250개면 충분합니다. 개수보다 꼬리 길이와 크기 분포가 인상을 훨씬 크게 바꿉니다. 개수를 두 배로 늘리는 것보다 꼬리를 늘리는 쪽이 효과가 큽니다.