GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

캔버스 불 애니메이션 9종 — 시드 고정 파티클로 그리는 불꽃 효과

불 애니메이션은 사진이나 스프라이트를 재생하는 것이 아니라, 연료 입자가 태어나고 열을 잃고 위로 떠오르는 규칙을 프레임마다 계산해서 그리는 결과입니다. 라이브러리·WebGL 없이 캔버스 픽셀 계산만으로 촛불부터 불씨 확산까지 아홉 장면을 실측했습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

연료 셀 하나의 규칙이 아홉 불꽃으로 갈라지는 이유

순서는 화려한 정도가 아니라 불이 무엇을 등에 지고 있는가로 갈립니다. 01 촛불은 연료 입자 하나가 태어나 부력으로 오르고 색온도로 식는 가장 작은 단위이고, 02 모닥불은 그 단위 여러 개에 컬(curl) 노이즈 바람을 얹어 한 덩어리로 넘실대게 만듭니다. 03 횃불과 07 촛불·바람은 같은 부력 위에 외부 벡터(고정 바람 · 포인터 방향)를 태워 불꽃이 한쪽으로 눕는 것을 봅니다. 04 램프는 파티클 대신 반경별 감쇠 겹을 쌓아 후광을, 06 불꽃놀이와 08 유성은 파티클에 중력·냉각을 더해 궤적을 그립니다. 05 용암과 09 불씨 확산은 파티클을 버리고 격자 셀에 열과 연료를 저장해 이웃과 주고받는 확산 계산으로 넘어갑니다 — 이 캔버스 애니메이션 아홉 종을 관통하는 축은 그림이 아니라 연료가 줄어드는 숫자 하나입니다.

불꽃 효과는 왜 계산으로 그리나? — 연료 셀·부력·컬링 난류의 셈

연료가 줄어드는 숫자 하나가 불을 만든다

아홉 판 전부의 공통 규칙은 하나입니다 — 어떤 값(파티클의 k 또는 격자 셀의 heat)이 시간에 따라 줄어들고, 그 값이 색과 크기를 정합니다. 연료가 많으면 밝고 크게, 줄면 어둡고 작게 그리는 것만으로 "타오르다 사그라든다"는 인상이 계산에서 저절로 나옵니다. 스프라이트 시트를 재생하는 방식과 다른 점이 여기 있는데, 프레임 수를 늘려도 파일 용량이 늘지 않고 포인터 입력에 그 자리에서 반응할 수 있습니다.

부력은 위로 가는 속도가 아니라 가속이다

부력을 단순히 "y 속도를 음수로" 주면 불꽃이 로켓처럼 일정 속도로 솟아 뻣뻣해 보입니다. 01·04는 파티클이 태어날 때마다 vy 를 서로 다른 무작위 값으로 한 번 정해 두어 입자마다 오르는 속도가 갈리게 하고, 02는 한 걸음 더 나아가 vy 를 매 프레임 컬 필드 목표값 쪽으로 서서히 당기는 Math.min(1, dt * 3.2) 보간에 BUOY * 190 이라는 상수 가속까지 더해 컬 필드가 흔드는 방향과 위쪽 힘이 함께 섞이게 합니다. 무작위 초기 속도든 매 프레임 보간이든, 입자마다 다른 순간에 다른 세기로 흔들리게 만드는 것이 로켓처럼 뻣뻣해 보이지 않게 하는 핵심입니다.

컬링 난류는 회전이 있는데 흩어지지 않는 바람이다

02의 컬(curl) 노이즈는 포텐셜 필드 ψ 의 회전을 구해 만든 바람장이라, 발산이 0이라는 성질 때문에 입자가 한곳에 모이거나 완전히 흩어지지 않고 서로 감고 도는 소용돌이만 만듭니다. 07의 포인터 바람이 "한 점을 향한 직선 힘"이라면 02의 컬 바람은 "장 전체가 회전하는 힘"이라는 차이가, 모닥불이 한 다발로 넘실대고 촛불이 한쪽으로만 눕는 서로 다른 인상을 만드는 이유입니다.

격자 확산은 파티클 없이 연소를 옮긴다

05·09는 파티클을 아예 쓰지 않습니다. 화면을 저해상도 셀로 나누고 이웃 셀과 값을 주고받는 확산 계산(nh = c + K_DIFF * (avg - c), nheat[ni] += give)만으로 불이 번지거나 굳는 과정을 흉내 냅니다. 파티클 방식은 개별 불씨의 움직임을, 격자 방식은 연소가 지도 위를 먹어 들어가는 면적의 변화를 보여줘서 같은 "불 애니메이션 그리기"라도 보여주려는 것이 다릅니다.

항목별 코드

01촛불 하나 — 심지 위에 세운 가장 작은 불

명상·야간 무드 페이지의 배경 촛불로 쓰는, 심지 끝에서 파티클이 태어나 부력을 받아 올라가며 노랑에서 주황·빨강으로 식는 촛불입니다 — 실측 누적면적 38.292%(화면의 38.292%가 프레임 사이 바뀝니다) · 강도 평균 35.8 · 23/23 프레임이 움직였습니다.

촛불 하나 — 심지 위에 세운 가장 작은 불
새 창에서 열기

01 · 촛불 하나 — 심지 위에 세운 가장 작은 불 — 측정 누적면적 38.292% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillRectglobalCompositeOperationrequestAnimationFrame

연료 입자 하나가 촛불이 되는 규칙

// ── 연료 입자 — 심지 끝에서 태어나 부력으로 오르며 식는다 ──
var parts = [];
function spawn(p) {
  p.x = (rand() - 0.5) * 3;            // 심지 폭 안 (px)
  p.y = 0;                             // 심지 끝에서 출발
  p.vx = (rand() - 0.5) * 7;
  p.vy = -20 - rand() * 28;            // 부력 — 위로 (px/s)
  p.k = 1;                             // 연료(열). 매초 K_BURN 닳는다
  p.ph = rand() * TAU;                 // 개별 흔들림 위상
}
for (var i = 0; i < N_PART; i++) {
  var p = {}; spawn(p); p.y = -rand() * 42; p.k = rand();
  parts.push(p);
}

파티클마다 가진 값은 위치·속도·연료 k 하나뿐이고, N_PART 240개가 매초 K_BURN(2.3) 만큼 k 를 잃다가 0이 되면 심지에서 다시 태어나는 순환이 촛불의 전부입니다. 반경별 빛 겹은 LAYERS 13장을 FALLOFF 1.05로 감쇠시켜 쌓고 SMK 14개의 연기가 따로 떠오르며, 포인터를 옮기면 x 는 바람·y 는 화력이 되어 power가 부력과 후광 세기를 함께 밀어 올립니다.

02모닥불 — 컬링 노이즈가 흔드는 불기둥

캠핑·아웃도어 랜딩의 히어로 배경으로 쓰는, 포텐셜 필드의 회전(컬)을 샘플링해 장작 여러 개가 한 덩어리로 넘실대는 모닥불입니다 — 실측 누적면적 37.507% · 강도 평균 45.5 · 23/23 프레임이 움직였습니다.

모닥불 — 컬링 노이즈가 흔드는 불기둥
새 창에서 열기

02 · 모닥불 — 컬링 노이즈가 흔드는 불기둥 — 측정 누적면적 37.507% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillRectglobalCompositeOperationrequestAnimationFrame

포텐셜 필드의 컬을 접선 바람으로 쓰는 법

// ── 연료 입자 — 컬 바람을 받아 접선으로 흘러간다 ──
for (var j = 0; j < N_PART; j++) {
  var q = parts[j];
  q.k -= K_BURN * dt;
  if (q.k <= 0) { spawn(q); continue; }
  var baseX = logX(q.col, cx, W);
  var wx = baseX + q.x;       // 세계 좌표 — 컬 필드는 여기를 샘플링
  var wy = ground + q.y;
  var cw = curl(wx, wy, tSec);
  q.vx += (cw[0] - q.vx) * Math.min(1, dt * 3.2);
  q.vy += (cw[1] - q.vy) * Math.min(1, dt * 3.2) - BUOY * 190 * dt;
  q.x += q.vx * dt; q.y += q.vy * dt;
}

값노이즈 격자로 만든 포텐셜 필드 ψ 의 회전을 해석적으로 계산해 curl(x, y, t) 로 얻고 각 입자를 그 방향에 서서히 수렴시키면(Math.min(1, dt * 3.2) 관성), 발산 없는 벡터장이라 입자가 모이지도 흩어지지도 않고 서로 감고 도는 것이 불기둥이 "넘실댄다"고 느껴지는 이유입니다. N_COL 3개의 장작이 N_EACH 150개씩 총 450개 입자를 나눠 가지고 BUOY 계수 0.35가 위로 밀어 올립니다.

03횃불 — 바람 벡터를 등에 진 불꽃

게임·이벤트 페이지의 성화 횃불로 쓰는, 일정한 바람 벡터에 느린 요동을 더해 불꽃 전체가 한 방향으로 쓸리는 횃불입니다 — 실측 누적면적 34.055% · 강도 평균 34.3 · 23/23 프레임이 움직였습니다.

횃불 — 바람 벡터를 등에 진 불꽃
새 창에서 열기

03 · 횃불 — 바람 벡터를 등에 진 불꽃 — 측정 누적면적 34.055% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillRectglobalCompositeOperationrequestAnimationFrame

고정 벡터 위에 얹는 느린 숨쉬기

var strength = 0.5 + px01;          // 포인터 x — 바람 세기 배율(0.5~1.5)
var dirTilt = (py01 - 0.5) * 0.7;   // 포인터 y — 바람 방향 미세 회전
var baseAng = Math.atan2(WIND_VY, WIND_VX) + dirTilt;
var wobAng = baseAng + WOBBLE * Math.sin(tSec * 0.35);
var windMag = Math.hypot(WIND_VX, WIND_VY) * strength;
var windVX = Math.cos(wobAng) * windMag;
var windVY = Math.sin(wobAng) * windMag;

WIND_VX 46 · WIND_VY −6 이 이 판의 기본 바람이고, 그 위에 WOBBLE * Math.sin(tSec * 0.35) 로 저주파 각도 흔들림 하나만 더해야 불꽃이 뻣뻣하게 죽지 않고 성화가 "숨 쉬며 눕는" 것처럼 보입니다. LAYERS 15장의 앙비언트 글로우가 FALLOFF 0.9로 감쇠하며 밤의 벌판을 밝힙니다.

04램프 불꽃 — 유리 등 뒤로 번지는 후광

고즈넉한 상점·호텔 소개 화면에 쓰는, 반경별로 감쇠하는 빛 겹을 더해 유리 램프 안에서 흔들리는 불빛이 주변을 데우는 장면입니다 — 실측 누적면적 43.983% · 강도 평균 46.8 · 23/23 프레임이 움직였습니다.

램프 불꽃 — 유리 등 뒤로 번지는 후광
새 창에서 열기

04 · 램프 불꽃 — 유리 등 뒤로 번지는 후광 — 측정 누적면적 43.983% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillRectcreateRadialGradientglobalCompositeOperation

createRadialGradient 로 쌓는 온도 겹

// 안쪽 겹은 불꽃온도(gold), 바깥 겹은 실내 온도(rose)로 색이 식는다
var mixk = L / Math.max(1, LAYERS - 1);
var lr = C_GOLD[0] + (C_ROSE[0] - C_GOLD[0]) * mixk;
var lg = C_GOLD[1] + (C_ROSE[1] - C_GOLD[1]) * mixk;
var lb = C_GOLD[2] + (C_ROSE[2] - C_GOLD[2]) * mixk;
var rg = ctx.createRadialGradient(hx, hy, R * 0.04, hx, hy, R);
rg.addColorStop(0, 'rgba(' + (lr|0) + ',' + (lg|0) + ',' + (lb|0) + ',' + a.toFixed(3) + ')');
rg.addColorStop(0.72, 'rgba(' + (lr|0) + ',' + (lg|0) + ',' + (lb|0) + ',' + (a * 0.6).toFixed(3) + ')');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.fillRect(hx - R, hy - R, R * 2, R * 2);

LAYERS 11장을 createRadialGradient 로 겹마다 다시 만들어 lighter 합성으로 쌓으면 중심부의 뜨거운 금빛과 바깥 실내 온도의 로즈 톤이 자연스럽게 섞입니다. K_BURN 1.35로 소모율을 낮게 잡아 파티클 각각이 오래 살아 후광이 자잘하게 끊기지 않고, STILL_T 1.85초 시점이 후광이 가장 넓게 퍼져 읽히는 순간입니다.

05용암 흐름 — 격자 셀에 쌓이는 열

게임 배경·화산 콘셉트 소개 화면에 쓰는, 화면을 셀로 나눠 이웃끼리 열을 주고받고 임곗값 넘는 셀만 용암색으로 그리는 흐름입니다 — 실측 누적면적 39.884% · 강도 평균 17.0 · 23/23 프레임이 움직였습니다.

용암 흐름 — 격자 셀에 쌓이는 열
새 창에서 열기

05 · 용암 흐름 — 격자 셀에 쌓이는 열 — 측정 누적면적 39.884% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ImageDataputImageDatarequestAnimationFrame

이웃 평균과 방사 냉각의 확산 규칙

// ②③ 확산 + 부유 편향
for (var yy = 1; yy < GH - 1; yy++) {
  for (var xx = 1; xx < GW - 1; xx++) {
    var i2 = yy * GW + xx;
    var c = heat[i2];
    var nU = heat[i2 - GW], nD = heat[i2 + GW], nL = heat[i2 - 1], nR = heat[i2 + 1];
    var avg = (nU * 0.9 + nD * 1.1 + nL + nR) / 4;   // 아래 셀 가중 — 위로 자라는 흐름
    var nh = c + K_DIFF * (avg - c);
    nh = nh - nh * COOL * (0.6 + nh);                // 방사 냉각 — 열이 셀수록 더 식는다
    if (nh < 0) nh = 0;
    if (nh > 1.4) nh = 1.4;

이웃 4칸의 평균을 낼 때 아래쪽에 0.9·1.1로 비대칭 가중을 줘서 열이 확산만 하지 않고 위로 자라는 흐름처럼 보이게 하고, K_DIFF(확산 계수) 0.34는 낮추면 화구 주변만 밝고 격자 전체가 순환에 들지 않아 재튜닝한 값입니다. 밝기가 T_MELT 0.62를 넘으면 녹은 용암색, T_GLOW 0.86을 넘으면 하얗게 달아오르며, VENT_N 5개의 화구가 하단 폭 전체에 고르게 퍼집니다.

06불꽃놀이 폭발 — 중력과 잔불의 산수

축하·연말 랜딩의 하이라이트로 쓰는, 한 점에서 튀어나간 파편이 중력으로 포물선을 그리며 색온도대로 식어 사라지는 폭발입니다 — 실측 누적면적 39.459% · 강도 평균 122.2 · 23/23 프레임이 움직였습니다.

불꽃놀이 폭발 — 중력과 잔불의 산수
새 창에서 열기

06 · 불꽃놀이 폭발 — 중력과 잔불의 산수 — 측정 누적면적 39.459% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillRectglobalCompositeOperationrequestAnimationFrame

색온도 사다리를 내려가는 파편

// k > 0.92 : 흰 섬광(터진 직후)
// 0.92..0.64 : gold → amber (활활)
// 0.64..0.34 : amber → ember (식어가는 주황)
// 0.34..0.12 : ember → ink+연기 (잔불)
function fragColor(k, tone, tSec, ph) {
  var r, g, b;
  if (k > 0.92) {
    var m0 = (k - 0.92) / 0.2;
    r = C_GOLD[0] + (C_WHITE[0] - C_GOLD[0]) * m0;
    g = C_GOLD[1] + (C_WHITE[1] - C_GOLD[1]) * m0;
    b = C_GOLD[2] + (C_WHITE[2] - C_GOLD[2]) * m0;
  }

GRAV 34가 파편을 아래로 당기고 DRAG 0.985가 매 프레임 속도를 깎아 궤적을 둥글게 처지게 하며, 연료 소모율 BURN 은 값이 크면 잔불이 프레임 사이에서 끊겨 점선처럼 보이던 것을 0.62에서 0.34로 낮춰 재튜닝했습니다. PER_BURST 170개가 한 번 터질 때의 파편 수이고 MAXP(1,700)는 여러 번 터뜨려도 버틸 풀 크기입니다.

07촛불과 바람 — 포인터를 향해 눕는 불꽃

인터랙티브 카드·특별한 순간의 장식으로 쓰는, 포인터에서 불꽃까지의 방향을 바람 벡터로 바꿔 손을 다가갈 때마다 눕는 촛불입니다 — 실측 누적면적 51.817% · 강도 평균 26.7 · 23/23 프레임이 움직였습니다.

촛불과 바람 — 포인터를 향해 눕는 불꽃
새 창에서 열기

07 · 촛불과 바람 — 포인터를 향해 눕는 불꽃 — 측정 누적면적 51.817% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermovefillRectglobalCompositeOperation

거리 역제곱으로 줄어드는 바람 세기

function pointerWind() {
  if (px < 0 || py < 0) return [0, 0];
  var wickX = W * 0.5, wickY = H * 0.62;
  var dx = wickX - px, dy = wickY - py;
  var dist = Math.sqrt(dx * dx + dy * dy);
  if (dist < 1) return [0, -WIND_MAX];    // 바로 위 — 위로 치솟는다
  var norm = 1 / dist;
  var s = Math.min(WIND_MAX, WIND_MAX * Math.pow(60 / dist, FALLOFF));
  return [dx * norm * s, dy * norm * s];
}

이 항목만 pointermove 실측 좌표를 그대로 받아 심지까지의 방향 벡터를 만들고, 거리가 멀어질수록 Math.pow(60 / dist, FALLOFF)(FALLOFF 2.0)로 세기를 급하게 줄입니다. WIND_MAX 3.2가 심지에 포인터가 붙었을 때의 최대 가속이고, IDLE_MS 2,400ms 동안 조용하면 시드 고정 미풍(breeze 함수의 이중 사인파)으로 되돌아갑니다.

08유성 — 잔상이 남는 불의 궤적

야경·날씨 콘셉트 페이지의 배경으로 쓰는, 머리 뒤에 파티클 꼬리를 붙이고 지나간 자리를 천천히 식혀 대기를 가로지르는 유성입니다 — 실측 누적면적 47.778% · 강도 평균 29.7 · 21/23 프레임이 움직였습니다.

유성 — 잔상이 남는 불의 궤적
새 창에서 열기

08 · 유성 — 잔상이 남는 불의 궤적 — 측정 누적면적 47.778% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

fillRectglobalCompositeOperationrequestAnimationFrame

진행 방향의 반대로 흩뿌리는 꼬리

var dvx = (head.x - head.px), dvy = (head.y - head.py);
var dlen = Math.sqrt(dvx * dvx + dvy * dvy) || 1;
var dirx = dvx / dlen, diry = dvy / dlen;        // 진행 방향 단위벡터
for (var n = 0; n < TAIL_N; n++) {
  var back = (n / TAIL_N) * 13 * SPEED;
  var jx = (rand() - 0.5) * 8;
  var jy = (rand() - 0.5) * 8;
  emit(
    head.x - dirx * back + jx,
    head.y - diry * back + jy,
    -dirx * (0.2 + rand() * 0.5) + (rand() - 0.5) * 0.3,
    -diry * (0.2 + rand() * 0.5) + (rand() - 0.5) * 0.3 - 0.05,
    0.75 + rand() * 0.25,
    2.4 + rand() * 3.8
  );
}

머리의 진행 방향 단위벡터를 구해 그 반대 방향으로 TAIL_N 42개를 매 프레임 흩뿌리고, 냉각률 COOL 을 0.05에서 0.017로 낮춰 잔상을 더 오래 남기는 만큼 POOL(1,800개) 풀도 함께 키웠습니다. 이 항목은 21/23 프레임만 움직인 값이 실측됐는데, 궤적 진입·소멸 구간 두 프레임은 화면 경계 밖이라 픽셀 변화가 잡히지 않은 것입니다.

09불씨 확산 — 연료 셀이 타고 번지는 격자

게임 상태 화면·시뮬레이션 소개에 쓰는, 각 셀에 연료를 저장해 두고 붙은 셀이 이웃에게 열을 넘기며 연료가 닳는 규칙으로 지도 위를 먹어 들어가는 불꽃 표현입니다 — 실측 누적면적 52.501% · 강도 평균 92.6 · 23/23 프레임이 움직였습니다.

불씨 확산 — 연료 셀이 타고 번지는 격자
새 창에서 열기

09 · 불씨 확산 — 연료 셀이 타고 번지는 격자 — 측정 누적면적 52.501% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ImageDataputImageDatarequestAnimationFrame

8칸 이웃에 열을 나눠 주는 연소

var IGNITE = 0.34;
function sim() {
  var i, x, y, dx, dy, nx, ny, ni;
  for (i = 0; i < heat.length; i++) {
    var f = fuel[i];
    if (heat[i] > IGNITE) {
      if (f > 0) {
        var burn = Math.min(f, BURN_RATE * (0.4 + f));   // 연료가 많으면 열도 세다
        fuel[i] = f - burn;
        var give = SPREAD * (0.3 + f * 0.7);
        for (dy = -1; dy <= 1; dy++) {
          for (dx = -1; dx <= 1; dx++) {
            if (!dx && !dy) continue;
            nx = (i % COLS) + dx; ny = ((i / COLS) | 0) + dy;
            if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
            ni = ny * COLS + nx;
            nheat[ni] += give / (dx && dy ? 1.41 : 1);   // 대각선은 1.41배 멀어 덜 번진다

COLS 120칸 격자에서 열이 IGNITE 임곗값 0.34를 넘은 칸만 연료 f를 BURN_RATE(0.0032) 만큼 소모하며 8개 이웃(대각선 포함)에 SPREAD 0.115 비율로 열을 나눠 줍니다. 대각선 이웃은 물리적으로 √2배 멀기 때문에 전달량을 1.41로 나눠 등방적으로 번지게 하는 것이 이 캔버스 구현에서 가장 눈에 띄는 불꽃 표현의 계산 축입니다.

불꽃 이펙트를 실제로 쓰는 화면

성공 메시지나 이벤트 페이지의 축하 연출에는 06 불꽃놀이 폭발처럼 중력과 잔불로 흩어지는 장면이 맞습니다 — 결제 완료나 목표 달성 순간에 한 번만 터뜨리면 됩니다. 다크 모드 토글이나 조명 아이콘 자리에는 04 램프 불꽃처럼 후광만 번지는 작은 장면이 어울립니다.

히어로 배경에 잔잔한 움직임을 더하고 싶다면 08 유성처럼 잔상을 남기며 지나가는 궤적이나, 01 촛불 하나처럼 가장 작은 단위로 시작하는 장면을 낮은 강도로 깔아 둡니다.

부력 값을 과하게 올리면 불꽃이 여기서 깨지는 함정

연료 소모율을 올리면 궤적이 끊긴다

06 불꽃놀이는 처음 BURN 값을 0.62로 뒀더니 파편이 두세 프레임 만에 k 가 0.34 밑으로 떨어져 궤적이 점선처럼 끊겨 보였습니다. 렌더링 자체엔 문제가 없었고, 색온도 사다리를 너무 빨리 통과하는 소모율이 문제였습니다 — 0.34로 낮추고서야 잔불 구간이 프레임 사이를 채울 만큼 오래 남았습니다. 캔버스 렌더링에서 "빨리 사라진다"는 인상은 대개 알파가 아니라 이런 소모 계수 하나가 만듭니다. 08의 냉각률도 같은 함정이라 짧은 잔상 쪽 값(0.05)이 아니라 COOL 을 0.017로 낮게 잡아야 잔상이 프레임 사이를 채울 만큼 오래 남았고, 그만큼 풀 크기 POOL 도 같이 키워야 했습니다.

확산 계수가 낮으면 격자 전체가 순환에 들지 않는다

05 용암은 K_DIFF 를 0.24로 뒀을 때 화구 주변 몇 칸만 밝고 나머지 격자는 영영 임곗값을 못 넘었습니다. 이웃 4칸 평균으로 옮기는 열의 양이 방사 냉각(COOL)을 못 이기면 확산이 냉각에 잡아먹혀 화면 절반이 죽은 그림으로 남습니다. 0.34까지 올리고서야 격자 전체가 데워졌다 식었다 순환에 들었습니다. 아홉 판의 zip 안 소스와 React 판을 열어 이 값을 직접 흔들어 보려면 암호 zip 을 열어야 하고, 여는 암호는 cb4svrhm 입니다.

격자 폭을 좁히면 대각선 확산이 어색해진다

09는 대각선 이웃에 준 열을 1.41(√2)로 나누지 않으면 불이 정사각형 모양으로 번져 격자 티가 심하게 났습니다. COLS 를 늘려 칸을 좁혀도 이 나눗셈이 없으면 같은 문제가 재현되는데, 저해상도 격자에서 등방적으로 보이려면 칸 수보다 이 가중치가 먼저입니다.

입자 수 하나로 갈리는 세 변형과 접근성 처리

값 하나를 바꾼 세 변형

이름 바꾼 값 느낌
파티클 수 01 촛불 파티클 240 → 480 불꽃이 짙어지는 대가로 프레임이 무거워진다
부력 02 모닥불 상승 계수 BUOY 0.35 → 0.7 불기둥이 두 배로 솟지만 옅어진다
색 $fire-amber → $fire-rose 주황 불꽃이 로즈 톤 촛불로 바뀐다

셋 다 소스 맨 위 변형 상수 한 줄만 고치는 축입니다. 이런 변형은 파일을 통째로 다시 쓰지 않고 값 하나만 조절해 다른 무드의 불로 바꿀 수 있다는 것을 보여 주는 자리이기도 합니다.

움직임을 줄인 사용자에게 남는 정지 프레임

prefers-reduced-motion: reduce 를 켠 사용자에게는 [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) 루프를 아예 시작하지 않고, 그 판이 가장 잘 읽히는 한 시각(STILL_T)만 그립니다 — 01은 1.6초(연기까지 차오른 순간), 04는 1.85초(후광이 가장 넓게 퍼진 순간), 05는 격자 판이라 STILL_TICK 210스텝을 미리 돌려 흐름이 안정된 한 장을 보여 주고, 09는 still() 함수가 불씨 두 개를 심은 뒤 40스텝을 미리 돌려 번짐이 자리 잡은 한 장을 보여 줍니다. 계산은 애니메이션을 켰을 때와 똑같이 하고 화면만 한 번 그리는 것이라 품질은 그대로입니다.

키보드·포인터로 만지는 세 판

02·05·07·09는 role="application" 과 tabindex="0" 을 가진 실험판이라 탭으로 초점을 옮길 수 있습니다 — 02·05·07은 포인터(터치 포함)로 직접 만져 02는 화력, 05는 새 화구, 07은 바람 방향을 바꾸고, 09는 포인터 대신 Enter·Space 키를 누를 때마다 격자 위 무작위 자리에 새 불씨를 심습니다. reduced-motion 사용자도 손이 닿는 즉시 한 프레임을 다시 계산해 그리므로 정지 화면이라도 반응 자체는 살아 있습니다.

불꽃 이펙트를 붙이기 전에 자주 나오는 질문

css 불꽃 애니메이션과 캔버스 불 애니메이션은 뭐가 다른가요?

CSS 애니메이션은 정해 둔 키프레임 사이를 브라우저가 보간하는 방식이라 흔들림의 규칙이 몇 개의 값으로 제한됩니다. 여기 아홉 판은 매 프레임 연료·부력·확산을 다시 계산하는 캔버스 애니메이션이라 시드가 같아도 매 순간 다른 조합이 나오고, 포인터·격자 인터랙션처럼 CSS 키프레임으로는 만들기 어려운 반응도 들어갑니다.

불 애니메이션 그리기를 처음 해봐도 이 코드를 그대로 붙여 쓸 수 있나요?

붙여 쓸 수 있습니다. 아홉 판 모두 라이브러리·빌드·서버 없이 캔버스와 순수 자바스크립트만으로 짜여 있어, 암호 zip 에서 바닐라 판을 꺼내 <script> 로 붙이면 그대로 움직입니다. 색은 각 판 scss 맨 위의 파스텔 토큰 변수($fire-amber 등)만 바꾸면 되고, React 판도 같은 zip 에 들어 있습니다.

08 유성만 왜 23/23이 아니라 21/23 프레임이 움직였나요?

궤적이 화면 경계를 드나드는 진입·소멸 두 프레임에서는 머리와 꼬리가 캔버스 밖에 있어 픽셀 변화량이 실측 도구가 잡는 임곗값 아래로 떨어졌기 때문입니다. 궤적 자체의 계산은 23프레임 내내 돌아가고 있고, 화면에 찍히는 픽셀만 두 프레임 적게 잡힌 것이라 눈으로 볼 때는 끊김이 느껴지지 않습니다.

더 읽을거리

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.