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

css fireworks 9가지 — 쏘아 올려 터지고 물 위에 비치는 불꽃놀이 장면

css fireworks 를 터뜨리려면 불꽃 하나가 아니라 알갱이 수백 개의 궤적부터 정해야 한다 — 01번 구면 분포와 07번 이차 분열이 그 궤적을 계산으로 짠다.

아홉 가지 불꽃놀이를 화면에서 바로 터뜨려 본다

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

css fireworks 9가지 — 중력·항력·꼬리 이력으로 터지는 불꽃

먼저 밝혀 둘 것이 있다. 아홉 장면은 CSS 키프레임이 아니라 <canvas> 2D 에 알갱이를 한 개씩 계산해 그린다. 불꽃 하나가 알갱이 수십 개에서 수백 개이고, 알갱이마다 속도와 꼬리 위치가 프레임마다 달라서 키프레임으로 일일이 적기에는 수가 너무 많았다. CSS 는 무대의 크기와 비율, 터치 동작 같은 바깥 틀만 맡는다.

아홉 장면은 같은 뼈대를 쓰고 장면 함수만 갈아 끼운다. FIXED_DT 16ms 간격으로 step 을 돌려 알갱이를 옮기고 그릴 때마다 draw 를 부르며, 난수는 장면마다 시드를 정한 lcg 한 가지뿐이다. Math.random 은 아홉 파일에 한 번도 없어서 같은 시드는 같은 불꽃 순서를 낸다. 장면이 만들어질 때 PREWARM_STEPS 틱을 먼저 돌려 두기 때문에 첫 화면부터 불꽃이 공중에 떠 있고, 라이브러리·CDN·WebGL 도 쓰지 않는다.

계산의 중심은 한 줄이다. 매 틱 v ← v·DRAG + 중력 을 하면 속도가 지수로 줄어서 알갱이는 어딘가에서 멈춘다. 01 의 BURST_SPEED 3.4 와 DRAG 0.975 로 중력을 빼고 따지면 3.4 / 0.025 = 136px 가 도달 거리이고, 모란·버들·꼬리·크래클은 이 한 줄에 초속도 분포나 꼬리 길이, 분열 규칙을 얹은 차이다.

html css fireworks 로 찾아 들어왔다면 쓰임으로 출발점을 고르면 된다. 축하 이벤트 랜딩은 01, 클릭한 높이에서 터져야 하는 보상 연출은 02, 야간 히어로 배경은 04, 촛불 같은 감성 섹션은 05, 호수나 해변 테마는 06, 행사 마감 화면은 08, 제품 발표 히어로는 09 부터 열어 보자.

css fireworks on click 을 기법별 코드로 본다

아래 조각은 장면마다 index.html 의 <script> 에서 이어진 줄을 그대로 옮긴 것이고, 한 조각은 25줄을 넘기지 않는다. 함수 이름과 상수 이름은 소스 그대로라서 zip 을 열면 같은 글자를 검색해 찾을 수 있다.

01알갱이가 구 표면에 고르게 퍼지고 공기저항으로 속도가 깎이는 기본 폭발

알갱이 방향은 피보나치 격자로 나눠 구 표면에 고르게 흩고, 매 틱 속도에 DRAG 를 곱한 뒤 세로에만 GRAVITY 를 더한다. 깊이 z 는 화면에 찍히지 않고 알갱이 크기와 밝기만 바꾸므로 평면에 그려도 공처럼 보인다. 이 장면은 면적 23.0%·강도 86.8 이고 23/23 프레임에서 움직임이 잡혔다.

알갱이가 구 표면에 고르게 퍼지고 공기저항으로 속도가 깎이는 기본 폭발
새 창에서 열기

01 · 알갱이가 구 표면에 고르게 퍼지고 공기저항으로 속도가 깎이는 기본 폭발 — 측정 누적면적 22.972% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BURST_NGRAVITYDRAGFUSE_MS
    function sphereDirs(n) {
        const out = [];
        for (let i = 0; i < n; i += 1) {
            const y = 1 - 2 * (i + 0.5) / n;
            const r = Math.sqrt(1 - y * y);
            const a = i * GOLDEN_ANGLE;
            out.push([r * Math.cos(a), y, r * Math.sin(a)]);
        }
        return out;
    }
    // 한 프레임 — 속도에 항력 계수를 곱하고 아래 방향으로 중력을 더한다
    function moveGrain(g) {
        g.px = g.x;
        g.py = g.y;
        g.vx *= DRAG;
        g.vy = g.vy * DRAG + GRAVITY;
        g.vz *= DRAG;
        g.x += g.vx;
        g.y += g.vy;
        g.z += g.vz;
    }

02클릭한 높이에서 정확히 정점을 찍고 터지는 포물선 발사체

누른 높이 h 로 v0 = √(2·LAUNCH_G·h) 를 거꾸로 풀어 초속도를 정하고, 상승 속도가 초속도의 4% 아래로 줄어든 틱에 터뜨려서 누른 높이가 곧 정점이 된다. 표적 y 를 60·100·140·190 으로 놓고 burstHeight() 를 노드에서 불러 보니 실제 터진 높이는 60.3·100.1·140.1·190.1 로 오차가 0.3px 안이었다. 렌더 쪽은 면적 58.2%, 강도 76.6 에 23/23 프레임이 움직인 것으로 잡혔다.

클릭한 높이에서 정확히 정점을 찍고 터지는 포물선 발사체
새 창에서 열기

02 · 클릭한 높이에서 정확히 정점을 찍고 터지는 포물선 발사체 — 측정 누적면적 58.175% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LAUNCH_GAPEX_FRACTRAIL_LENCLICK_PAD
    function launchSpeed(h) {
        return Math.sqrt(2 * LAUNCH_G * h);
    }
    function aimPoint(x, y) {
        return [clamp(x, CLICK_PAD, W - CLICK_PAD), clamp(y, CLICK_PAD, GROUND_Y - 60)];
    }
    function makeRocket(tx0, ty0, hue) {
        const p = aimPoint(tx0, ty0);
        const h = GROUND_Y - p[1];
        const v0 = launchSpeed(h);
        const x0 = lerp(W / 2, p[0], 0.35);
        const flight = v0 / LAUNCH_G;
        return { x: x0, y: GROUND_Y, vx: (p[0] - x0) / flight, vy: -v0, v0: v0, tx: p[0], ty: p[1], trail: [], hue: hue };
    }
    // 한 프레임 올리고 정점에 닿았는지 알려 준다 (상승 속도가 정점 문턱 아래로 줄면 참)
    function stepRocket(r) {
        r.x += r.vx;
        r.y += r.vy + LAUNCH_G * 0.5;
        r.vy += LAUNCH_G;
        r.trail.push(r.x, r.y);
        if (r.trail.length > TRAIL_LEN * 2) {
            r.trail.splice(0, 2);
        }
        return r.vy >= -APEX_FRAC * r.v0;
    }

03모란·버들·고리가 속도 분포 하나로 갈라지는 세 가지 셸

같은 코드에서 초속도 분포와 항력만 바꿔 모란·버들·고리를 만든다. 모란은 속도를 약 1.8 에서 5.1 까지 넓게 퍼뜨려 속이 찬 구가 되고, 버들은 최대 근처 속도만 써서 얇은 껍질에 꼬리 13점을 달며, 고리는 속도 3.5 하나에 원이 놓인 면을 RING_TILT 만큼 눕힌다. 면적 28.3%, 강도 93.3 으로 아홉 장면 중 강도가 가장 높고 23/23 프레임이 움직였다.

모란·버들·고리가 속도 분포 하나로 갈라지는 세 가지 셸
새 창에서 열기

03 · 모란·버들·고리가 속도 분포 하나로 갈라지는 세 가지 셸 — 측정 누적면적 28.288% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WILLOW_DRAGRING_TILTSHELL_TYPESTAIL_FADE
        for (let i = 0; i < GRAIN_N; i += 1) {
            let vx = 0;
            let vy = 0;
            let z = 0;
            if (kind === 'ring') {
                // 같은 속도로 원을 그리고, 원이 놓인 면을 RING_TILT 만큼 눕힌다
                const a = (i / GRAIN_N) * PI2;
                const sp = 3.5;
                vx = Math.cos(a) * sp;
                vy = Math.sin(a) * Math.cos(RING_TILT) * sp;
                z = Math.sin(a) * Math.sin(RING_TILT);
            }
            else {
                const a = rng() * PI2;
                // 모란은 0 에서 최대까지, 버들은 최대 근처에만 속도를 둔다
                const sp = kind === 'willow' ? 3.1 * (0.85 + 0.15 * rng()) : 5.1 * Math.sqrt(0.12 + 0.88 * rng());
                vx = Math.cos(a) * sp;
                vy = Math.sin(a) * sp;
            }
            grains.push({ x: x, y: y, vx: vx, vy: vy, z: z, hist: [] });
        }

04과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리

알갱이마다 최근 위치 16점을 저장해 선분으로 잇고, 뒤로 갈수록 알파가 FADE_ALPHA 의 거듭제곱으로 줄어 꼬리가 옅어진다. 합성 방식을 lighter 로 바꿔 두어서 꼬리와 머리의 빛무리가 겹친 곳은 색이 더해져 흰빛에 가깝게 번진다. 면적은 41.6%, 강도는 72.1 이고 스물세 번의 프레임 간격마다 모두 움직였다(23/23).

과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리
새 창에서 열기

04 · 과거 위치 이력을 잇고 겹칠수록 밝아지는 꼬리와 빛무리 — 측정 누적면적 41.561% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TRAIL_LENGLOW_BLURFADE_ALPHAHEAD_R
    function trailAlpha(back) {
        return Math.pow(FADE_ALPHA, back);
    }
                    const life = Math.pow(1 - s.age / s.life, 0.7);
                    const n = s.hist.length / 2;
                    for (let k = 1; k < n; k += 1) {
                        const a = trailAlpha(n - k) * life;
                        ctx.strokeStyle = rgba(PALETTE[s.hue], a * 0.85);
                        ctx.lineWidth = lerp(0.6, HEAD_R * 1.3, k / n);
                        ctx.beginPath();
                        ctx.moveTo(s.hist[(k - 1) * 2], s.hist[(k - 1) * 2 + 1]);
                        ctx.lineTo(s.hist[k * 2], s.hist[k * 2 + 1]);
                        ctx.stroke();
                    }

05흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨

불씨의 수명 비율을 다섯 단계 색 목록에 넣어 흰빛에서 연노랑·살구·장밋빛·잿빛으로 식게 하고, 밝기는 1차원 값 노이즈로 흔든다. 흔들림 폭에 곱하는 0.3 + 0.7·frac² 이 수명 끝에 가까울수록 커져서 꺼지기 직전에 가장 많이 깜빡인다. 수치는 면적 25.0%, 강도 64.2 이며 23/23 프레임이 움직였다.

흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨
새 창에서 열기

05 · 흰빛에서 주황, 붉은 잿빛으로 식으며 깜빡이다 꺼지는 불씨 — 측정 누적면적 24.988% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLICKER_HZCOOL_STOPSLIFE_MSNOISE_AMP
    function coolColor(t) {
        const c = clamp(t, 0, 1);
        for (let i = 1; i < COOL_STOPS.length; i += 1) {
            if (c <= COOL_STOPS[i][0]) {
                const a = COOL_STOPS[i - 1];
                const b = COOL_STOPS[i];
                return mixRGB(a[1], b[1], (c - a[0]) / (b[0] - a[0]));
            }
        }
        return COOL_STOPS[COOL_STOPS.length - 1][1];
    }
    // 불씨 하나의 밝기 — 1차원 노이즈로 흔들고, 식을수록 흔들림 폭이 커진다
    function flickerOf(id, seconds, frac, amp) {
        const n = noise1(id * 3.7 + seconds * FLICKER_HZ, 7);
        return 1 - amp * n * (0.3 + 0.7 * frac * frac);
    }

06수면에 거꾸로 비치고 잔물결에 일그러지는 불꽃의 반사

하늘만 따로 그려 원본으로 두고, 물의 j 번째 줄에 수평선에서 j 줄 위의 하늘을 뒤집어 붙여 반사를 만든다. 줄마다 rowShift 가 사인 파동으로 가로 밀림을 주고 깊을수록 알파가 옅어져서 물결에 일그러진 모습이 된다. 이 장면은 면적 22.8%·강도 85.8·23/23 프레임 움직임으로 면적이 아홉 중 가장 작다.

수면에 거꾸로 비치고 잔물결에 일그러지는 불꽃의 반사
새 창에서 열기

06 · 수면에 거꾸로 비치고 잔물결에 일그러지는 불꽃의 반사 — 측정 누적면적 22.779% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MIRROR_ALPHAWAVE_AMPWAVE_KHORIZON_Y
    function rowShift(j, tick) {
        const depth = 0.5 + j / (H - HORIZON_Y);
        return WAVE_AMP * depth * Math.sin(WAVE_K * j + tick * WAVE_SPEED) + WAVE_AMP * 0.35 * (noise1(j * 0.2 + tick * 0.05, 3) - 0.5);
    }
                const rows = H - HORIZON_Y;
                const wobble = reduce ? 0 : t;
                for (let j = 0; j < rows; j += 1) {
                    // 수평선에서 j 줄 아래의 물은 j 줄 위의 하늘을 뒤집어 보여 준다
                    const sy = (HORIZON_Y - 1 - j) * dpr;
                    ctx.globalAlpha = MIRROR_ALPHA * (1 - j / (rows * 1.5));
                    ctx.drawImage(cv, 0, sy, W * dpr, dpr, rowShift(j, wobble), HORIZON_Y + j, W, 1);
                }

07알갱이가 수명 끝에서 다시 잘게 터지는 두 겹 크래클

수명이 끝난 부모 알갱이가 SPLIT_P 확률로 자식 다섯 개를 만들고, 자식은 더 짧게 살다 사라지며 깊이 제한 때문에 더 터지지 않는다. 폭발이 두 겹에서 멈추는 이 장치가 있어야 알갱이 수가 눈덩이처럼 불지 않는다. 면적 65.2% 로 아홉 장면 중 화면을 가장 넓게 쓰고 강도 75.2, 프레임 23/23 이다.

알갱이가 수명 끝에서 다시 잘게 터지는 두 겹 크래클
새 창에서 열기

07 · 알갱이가 수명 끝에서 다시 잘게 터지는 두 겹 크래클 — 측정 누적면적 65.167% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPLIT_DEPTHCHILD_NCHILD_SPEEDSPLIT_P
    function splitChildren(p, rng) {
        const kids = [];
        if (p.depth >= SPLIT_DEPTH || rng() >= SPLIT_P) {
            return kids;
        }
        const base = rng() * PI2;
        for (let i = 0; i < CHILD_N; i += 1) {
            const a = base + (i / CHILD_N) * PI2;
            const sp = CHILD_SPEED * (0.7 + rng() * 0.6);
            kids.push({ x: p.x, y: p.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 20 + rng() * 10, depth: p.depth + 1, hue: CREAM });
        }
        return kids;
    }

08터질 때마다 하늘이 물들고 건물 실루엣이 거리별로 밝아지는 연속 발사

폭발이 일어난 자리를 광원으로 적어 두고, 건물의 띠마다 모든 광원이 주는 밝기를 더해 색을 정하는데, 광원 하나의 밝기는 (1−나이/60)²/(1+(d/90)²) 이라 막 터졌을 때 가장 세고 90px 떨어진 띠에서는 절반으로 줄어든다. 네 발을 이어 쏘고 쉬는 식이라 앞선 발의 빛이 남은 채 다음 발이 겹친다. 면적 45.1%, 강도 44.2 로 아홉 중 가장 낮고 23/23 프레임이 움직였다.

터질 때마다 하늘이 물들고 건물 실루엣이 거리별로 밝아지는 연속 발사
새 창에서 열기

08 · 터질 때마다 하늘이 물들고 건물 실루엣이 거리별로 밝아지는 연속 발사 — 측정 누적면적 45.078% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

VOLLEY_NSKY_GLOWLIGHT_FALLOFFGAP_MS
    function lightAt(f, px, py) {
        const d = Math.hypot(f.x - px, f.y - py);
        const k = 1 - f.age / FLASH_LIFE;
        return (k * k) / (1 + (d / LIGHT_FALLOFF) * (d / LIGHT_FALLOFF));
    }
                    for (let band = 0; band < BAND_N; band += 1) {
                        const cy = H - bd.h + (band + 0.5) * bandH;
                        let r = BUILDING_BASE[0];
                        let gg = BUILDING_BASE[1];
                        let bb = BUILDING_BASE[2];
                        for (let i = 0; i < flashes.length; i += 1) {
                            const l = lightAt(flashes[i], bd.x + bd.w / 2, cy) * 1.9 * gain;
                            r += flashes[i].hue[0] * l * 0.55;
                            gg += flashes[i].hue[1] * l * 0.55;
                            bb += flashes[i].hue[2] * l * 0.55;
                        }
                        ctx.fillStyle = 'rgb(' + Math.round(clamp(r, 0, 255)) + ',' + Math.round(clamp(gg, 0, 255)) + ',' + Math.round(clamp(bb, 0, 255)) + ')';
                        ctx.fillRect(bd.x, H - bd.h + band * bandH, bd.w, bandH + 0.5);
                    }

09포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면

폭발이 남긴 연기 입자는 노이즈 방향으로 천천히 흐르고, 포인터가 움직인 속도만큼 반경 70px 안의 입자에 바람을 더하는데, 세기는 속도×0.1×(1−d/70)² 이라 가까울수록 세다. 사람이 만지지 않으면 가짜 포인터가 화면 가운데에서 좌우 150px 를 오가며 쓸고 지나가서 연기가 갈라지는 모양을 먼저 보여 준다. 면적 28.3%, 강도 46.5 이고 23/23 프레임이 움직였다.

포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면
새 창에서 열기

09 · 포인터 바람에 연기 구름이 밀려나며 불꽃이 쏘아지는 히어로 장면 — 측정 누적면적 28.317% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WIND_RSMOKE_NNOISE_SCALEDRAFT
    function windPush(dist, speed) {
        if (dist >= WIND_R) {
            return 0;
        }
        const k = 1 - dist / WIND_R;
        return speed * WIND_GAIN * k * k;
    }

값이 불꽃의 인상을 바꾸는 곳 — 알갱이 수·항력·파동 크기

값 하나로 인상이 크게 달라지는 곳은 셋이고, 정점 오차는 값을 바꾸지 않아도 확인할 만한 숫자다. 아래 숫자는 브라우저 렌더가 아니라 소스의 함수를 노드의 가짜 DOM 에서 직접 불러 센 값이라서, 바로 아래 렌더 측정 표와는 따로 읽어야 한다.

장면 누적면적 강도 평균 움직인 프레임
01 구면 폭발 23.0% 86.8 23/23
02 정점 발사 58.2% 76.6 23/23
03 모란·버들·고리 28.3% 93.3 23/23
04 가산 꼬리 41.6% 72.1 23/23
05 불씨 25.0% 64.2 23/23
06 물 반사 22.8% 85.8 23/23
07 크래클 65.2% 75.2 23/23
08 도시 실루엣 45.1% 44.2 23/23
09 연기와 바람 28.3% 46.5 23/23

BURST_N 을 90 에서 240 으로 올리면 칸 점유가 90 에서 227 로 는다

01 에서 80틱이 지난 한 프레임을 6px 칸으로 나누어 세면 서로 다른 칸을 차지한 알갱이가 90개에서 227개가 되고, 가장 가까운 이웃과의 평균 거리는 14.3px 에서 7.4px 로 줄어든다. 30틱 뒤에는 88개에서 210개, 8.8px 에서 4.6px 이다. 점으로 보이던 구가 얇은 막처럼 차오른다.

WILLOW_DRAG 를 0.96 에서 0.99 로 올리면 평균 도달 거리가 2.26배가 된다

03 의 버들이 124틱 동안 출발점에서 날아간 평균 거리는 132.5px 에서 299.5px 로 늘고, 화면 높이가 300px 이니 가지가 바닥 근처까지 드리운다. 항력 계수가 1 에 가까워질수록 속도가 거의 줄지 않기 때문이다. 값을 0.005 만 올려도 길이 차이가 눈에 띄므로 이 상수는 조금씩 움직여야 한다.

WAVE_AMP 를 3 에서 10 으로 키우면 줄 밀림 최댓값이 4.8px 에서 16.1px 가 된다

06 에서 120틱 동안 물의 110개 줄이 가로로 밀린 최댓값이 4.8px 에서 16.1px 로 늘었다. 알갱이 하나의 폭이 약 5px 라서 일그러짐이 폭의 1배 남짓에서 3배 넘게 번지고, 반사가 불꽃 모양으로 알아보기 어려워진다. 틱 10 에서 맨 위 줄은 1.2px 에서 4.0px, 맨 아래 줄은 -4.3px 에서 -14.5px 로 움직였다.

표적 높이 60 에서 190 까지 정점 오차는 0.3px 안이다

02 의 초속도는 표적 y 60·100·140·190 에서 각각 7.23·6.54·5.75·4.60 이고, 실제 터진 높이는 60.3·100.1·140.1·190.1 이다. APEX_FRAC 을 0.04 보다 크게 잡으면 정점에 닿기 전에 터져서 오차가 APEX_FRAC²·h 만큼 커진다. 클릭 위치에서 터지는 느낌은 이 상수가 지킨다.

어디서 깨지는가 — 알갱이가 너무 많을 때·탭이 멈출 때·움직임 줄이기 설정

아홉 장면을 직접 만지다 보면 먼저 눈에 띄는 곳이 알갱이 수, 항력, 입력 처리다. 아래는 소스의 상수와 노드 호출로 확인한 것이고, 느려지는 정도를 브라우저에서 재 보지는 않았다.

알갱이가 많아지면 칸을 나눠 쓰고 그리기 호출만 늘어난다

01 에서 BURST_N 이 240 이면 80틱 뒤에도 13개는 다른 알갱이와 같은 칸에 있다. 같은 장면은 MAX_SHELLS 5 겹이 동시에 살아 있을 수 있어서, 알갱이마다 선 하나와 원 하나를 그린다고 치면 프레임당 240 × 5 × 2 = 2,400번의 그리기 호출이 필요하다. DRAG 를 1 이상으로 올리면 속도가 줄지 않아 화면을 벗어난 알갱이도 GRAIN_LIFE 까지 계속 갱신된다.

버들의 항력이 1 에 가까우면 화면 밖에서도 계산이 계속된다

03 에서 WILLOW_DRAG 가 0.99 이면 평균 도달 거리가 299px 이고 가장 빠른 알갱이는 480×300 화면을 벗어난다. 소스는 수명 124틱이 끝날 때까지 알갱이를 지우지 않으므로 화면 밖 알갱이의 꼬리 계산도 그만큼 이어진다.

불씨는 상한이 잘라 내고 노이즈 진폭은 1 에서 깜빡임이 아니게 된다

05 는 틱마다 불씨 4개를 만들고 MAX_EMBERS 420 을 넘으면 가장 오래된 불씨부터 잘라 낸다. LIFE_MS 를 늘리면 이 상한에 먼저 닿는다. NOISE_AMP 가 1 이면 밝기가 0 까지 흔들려 깜빡이는 것이 아니라 사라졌다 나타나는 것처럼 보인다.

반사는 프레임마다 물의 줄 수만큼 drawImage 를 부른다

06 은 H − HORIZON_Y 인 110줄에 줄 단위 drawImage 를 한 번씩 부르고, getImageData 는 아홉 장면 어디에도 없다. 하늘 원본은 devicePixelRatio 가 바뀌면 다시 만든다. 수평선 아래로 떨어진 알갱이는 하늘 원본에서 그려지지 않아 반사가 생기지 않는다.

부모 1000개 중 571개가 갈라지면 터질 때마다 크래클 양이 다르다

07 에서 부모 1000개를 가정해 splitTally(1000, 5) 를 불러 보니 571개(57.1%)가 갈라져 자식이 2,855개(571 × 5)가 되었고 손자는 0이었다. 실제 한 겹은 부모가 84개라서 갈라지는 수가 46개 안팎에서 흔들리고, 그래서 터질 때마다 크래클의 양이 조금씩 다르다. SPLIT_DEPTH 를 2 로 올리면 자식도 갈라져서 알갱이 수가 갈라질 때마다 SPLIT_P × CHILD_N = 2.75배씩 늘어난다.

네 발이 쏘아지는 동안 첫 발의 빛은 아직 살아 있다

08 은 한 번에 VOLLEY_N 4발을 GAP_MS 260ms(16틱) 간격으로 쏜다. 네 발은 첫 발부터 넷째 발까지 48틱에 걸쳐 나가고, 한 발의 빛은 FLASH_LIFE 60틱 동안 남아서 넷째 발이 터질 때도 첫 발의 빛이 12틱 더 살아 있다. 그래서 네 빛이 한꺼번에 겹치고 그중 방금 터진 넷째 발이 가장 세다. 광원은 8개까지만 두고 넘으면 가장 오래된 것부터 지우며, 불꽃 알갱이도 280개를 넘으면 오래된 것부터 잘라 낸다.

포인터 속도는 주사율을 타고 캔버스 밖으로 나가면 끊긴다

09 의 inp.vx 는 포인터 이벤트마다 갱신되어 주사율이 높은 모니터에서는 같은 손놀림도 속도가 커지므로 소스가 ±12 로 잘라 둔다. 아홉 소스 모두 setPointerCapture 가 0 번이라서 포인터가 캔버스 밖으로 나가면 pointermove 가 끊기고 바람도 멈춘다. 터치에서 끄는 동안 화면이 스크롤되지 않게 하는 touch-action: none 은 09 만 걸고, 나머지 여덟 장면은 manipulation 이다.

탭이 멈췄다 돌아오면 따라잡는 시간에 상한이 있다

아홉 소스는 한 번의 프레임에서 흘려보내는 시간을 Math.min(ts - lastTs, 160) 으로 막는다. 탭을 오래 숨겼다 돌아와도 한 번에 최대 10틱만 진행해서 불꽃이 한꺼번에 폭주하지 않는다. 대신 숨겨 둔 동안의 시간은 따라잡지 않고 버려진다.

02 는 가장자리를 누르면 누른 곳과 터진 곳이 달라진다

02 의 aimPoint 는 가로를 CLICK_PAD 28 안쪽으로, 세로를 GROUND_Y − 60 위쪽으로 당겨 놓는다. 그래서 바닥 근처를 누르면 y 218 에서 터지고 모서리를 누르면 28px 안쪽에서 터진다. 이 값 밖으로 보내려면 aimPoint 를 고쳐야 한다.

상수를 하나씩 바꿔 불꽃이 어디서 무너지는지 직접 보려면 zip 을 열어야 하는데, 비밀번호는 r9k5d5jr 이다. 파일에는 장면 아홉 개의 index.html·style.scss 와 리액트 판이 같이 담겨 있다.

변형 세 가지와 접근성

이름 값 느낌
알갱이 수 바꾸기 01 BURST_N 90 → 240 80틱 뒤 서로 다른 6px 칸을 차지한 알갱이가 90개에서 227개가 되고 이웃 평균 거리는 14.3px 에서 7.4px 로 줄어 점의 구가 얇은 막처럼 찬다.
버들 항력 바꾸기 03 WILLOW_DRAG 0.96 → 0.99 124틱 동안 날아가는 평균 거리가 133px 에서 299px 로 2.3배 늘어 화면 높이만큼 가지가 드리운다.
물결 크기 바꾸기 06 WAVE_AMP 3 → 10 줄이 밀리는 최댓값이 4.8px 에서 16.1px 로 늘어 알갱이 폭의 3배 넘게 번지며 반사가 흩어진다.

세 변형의 숫자도 위 값 절과 같이 소스를 노드에서 직접 불러 센 값이다.

키보드만으로도 아홉 장면을 모두 쏠 수 있다. 캔버스에 tabindex="0" 이 있어 Tab 으로 초점이 오고, aria-label 에는 항목 이름이 들어 있다. Enter·Space 는 눌렀을 때의 동작을 대신하고 방향키는 가상 포인터를 KEY_STEP 14 씩 옮기는데, 스크롤과 Tab 이동을 막지 않도록 preventDefault 는 이 여섯 키에만 건다. 화면 읽기 프로그램용 상태 문구는 aria-live="polite" 인 <p> 하나이고 장면이 정한 문자열이 바뀔 때만 갱신된다.

prefers-reduced-motion 설정을 켠 사람에게는 아홉 장면 모두 requestAnimationFrame 루프를 돌리지 않고 정지한 첫 장면만 보여 준다. 일시정지 버튼 자리는 다음 장면 이 차지해서, 한 번 누를 때마다 0.9초 분량만큼만 불꽃이 나아간다. 번쩍임은 따로 약하게 깐다. 01 의 중심 번쩍임은 알파 0.8 에서 0.35 로, 04 의 빛무리는 0.85 에서 0.6 으로 내리고, 05 는 NOISE_AMP 에 0.4 를 곱하며, 06 은 물결 시계를 세우고, 08 은 gain 을 0.5 로 둔다. 멈춘 장면에도 불꽃이 남아 있어서 빈 화면을 보지는 않는다.

css fireworks overlay 만으로 충분한가?

css fireworks overlay 라는 말에서 페이지 위에 덮는 효과를 떠올리기 쉽지만, 이 편의 아홉 장면은 무대 안에서만 돌려 봤고 페이지 전체에 덮어 본 적은 없다. 덮는다면 캔버스가 아래 요소의 클릭을 가로채지 않는지가 첫 확인거리이고, 입력을 받는 02·09 는 특히 그렇다.

바깥 틀이 하는 일은 무대 크기가 전부다. .sc 는 width: min(92vw, calc(160vh - 12.8vw)) 에 aspect-ratio: 480 / 300 이고, 좌표는 늘 480×300 논리 좌표라서 창 크기가 바뀌어도 장면 계산은 같다. 알갱이 계산이 필요 없는 한 번짜리 효과라면 종이 조각이 떨어지는 컨페티 효과 9종 이 더 가볍고, 불의 움직임을 계산으로 보고 싶으면 캔버스 불 애니메이션 9종 과 이어서 보면 되며, 별이 날아오르는 배경은 css starfield 9가지 에 있다. 계산의 근거는 requestAnimationFrame 과 globalCompositeOperation 문서에서 확인했다.

css fireworks codepen 코드를 가져다 쓰기 전에 자주 묻는 질문

html css fireworks 로 키프레임만 쓰는 판은 없나?

이 편에는 없다. 아홉 장면이 모두 캔버스이고, CSS 는 무대 크기·비율·touch-action 과 버튼 모양만 맡는다. 알갱이 수만큼의 키프레임을 손으로 적어 두는 판은 만들어 보지 않았다.

알갱이를 더 늘리면 어디서 먼저 무거워지나?

소스를 읽어 어림하면 알갱이마다 선 하나와 원 하나를 그리는 01 이 알갱이 수에 곧바로 비례하고, 06 은 알갱이 수와 상관없이 프레임마다 110번의 drawImage 가 고정으로 든다. 늘려서 프레임 시간을 재 보지는 않았다.

화면 크기가 달라지면 불꽃 크기도 변하나?

변한다. 장면 좌표는 480×300 에 고정이고 무대가 뷰포트에 비례해 늘고 줄어서, 폰에서는 알갱이가 그만큼 작게 보인다. 내부 해상도는 devicePixelRatio 를 2 까지 반영해 fit() 이 다시 맞춘다.

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

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