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

낙엽 떨어지는 효과 9종 — 흔들림·바람·단풍 물듦·쌓이는 더미

낙엽 떨어지는 효과는 가지를 떠난 잎이 바람에 흔들리며 내려와 땅에 쌓이는 가을 장면을 웹 페이지 위에 움직이게 그리는 애니메이션이다. 잎을 곧장 아래로 떨어뜨리면 돌멩이처럼 보인다.

잎 한 장이 가지를 떠나 땅에 눕기까지 — 바람·빛·색·더미를 따로 계산한 장면

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

아홉 장면은 사진이나 이미지 파일 없이 캔버스 2D 위에 잎 윤곽 72점과 잎맥을 계산으로 그리고, 시드를 고정해 열 때마다 같은 배치와 같은 바람으로 혼자 시연한다. 손을 대면 덧붙는 경로가 열린다. 01 은 포인터 둘레 80px 에 160px/s 옆바람을 불고, 03 은 포인터 둘레 70px 에 220px/s 돌풍을 일으키며, 07 은 빗자루가 정해 둔 길 대신 포인터를 따라간다. 캔버스 안 좌표는 480×300 으로 고정이고, 실제 화소는 화면 배율을 곱하되 2배를 넘지 않는다.

가을 이벤트 배너와 시즌 랜딩 첫 화면

가을 캠페인 랜딩의 첫 화면이라면 09 가 맞는다. 앞 층 잎은 크고 선명하게, 먼 층 잎은 작고 흐리게 내려오므로 제목 글자를 얹을 자리 뒤로 깊이가 생긴다. 폭이 좁은 계절 이벤트 배너에는 앞뒤로 뒤집히며 밝기가 바뀌는 02 의 여덟 장이 어울리고, 가을 낙엽 배경을 은은하게 깔고 싶은 시즌 랜딩에는 잎 세 장만 지그재그로 내려오는 01 이 덜 시끄럽다.

페이지 아래 장식부터 바람 화면까지

페이지 하단의 계절 장식에는 06 의 더미가 어울린다. 잎이 쌓일수록 둔덕이 자라다가 비탈이 가팔라지면 옆으로 흘러내려서, 바닥 한 줄에 낙엽 떨어지는 풍경이 담긴다. 날씨 앱의 바람 화면에는 돌풍 띠가 지나가는 03, 청소·정리 서비스 소개에는 빗자루가 잎을 쓸어 흩는 07, 가을 인사 카드에는 꼭지가 하나씩 끊기는 05, 이벤트 시작 연출에는 회오리가 잎을 들어 올리는 08, 계절이 바뀌는 상품 섹션에는 잎 한 장이 물드는 04 를 둔다.

낙엽 떨어지는 모습은 왜 곧게 떨어지면 돌멩이처럼 어색한가?

잎은 얇고 넓어서, 떨어지는 동안 몸이 기운 각도에 따라 공기가 잎을 옆으로 떠민다. 곧게 떨어뜨리는 애니메이션에는 이 옆 힘이 없으니 속도만 다를 뿐 무거운 물체가 떨어지는 길과 같아진다. CSS 입자 배경 편 의 벚꽃 잎은 키프레임으로 정해 둔 길을 따라가고 떨어지는 글자 편 은 바람에 나뭇잎처럼 흔들리는 글자를 다뤘는데, 이 편의 01 은 기울기에서 힘을 계산해 그 길이 저절로 나오게 한다.

기울기가 만드는 옆 미끄러짐

받음각 α 는 잎 면과 공기 흐름이 이루는 각이다. 양력은 |sin α·cos α| 에 비례해 45° 에서 가장 크고, 잎이 납작하게 누웠거나 칼날처럼 섰을 때 0 이 된다. 기운 잎은 옆으로 미끄러지며 속도가 붙고, 속도가 붙을수록 u² 를 곱한 양력도 커진다. 01 의 세 장에서 양력 최대는 997·994·934 이고, 받음각은 최대 88~90° 까지 돈다.

기울기를 되돌리는 회전력이 만드는 흔들림

미끄러지기만 하면 잎은 한쪽으로 날아가 버린다. 01 은 기울기의 sin 에 RIGHTING_K 14 를 곱한 회전력으로 잎을 수평 쪽으로 되돌리고, 각속도는 SPIN_DAMP 1.5 로 줄여 간다. 되돌아오는 사이 sin α 의 부호가 바뀌면 양력이 미는 쪽도 반대가 되어, 가로 속도가 4~5번 방향을 바꾸는 지그재그가 나온다. 양력 계수를 0.6 으로 낮춘 판에서는 옆 폭이 ±1.6~5.5px 로 줄어 거의 곧게 떨어지는데, 곧은 낙하가 왜 어색한지를 거꾸로 보여 주는 셈이다.

기울기는 양력으로, 바람은 노이즈로, 색은 잎맥 거리로 — 잎 한 장을 움직이는 계산

아홉 장면이 나눠 쓰는 계산 묶음은 넷이다. 01·05·09 는 흔들림, 05·09 는 꼭지 스프링, 03·05·09 는 바람, 06·09 는 더미 묶음을 쓰고, 02·04·07·08 은 묶음 없이 제 계산만 쓴다. 흔들림·바람 두 묶음과 04 의 제 계산을 숫자로 풀면 이렇다.

양력 sin 2α 와 저항 sin²α

저항은 AIR_K 0.075 × u² × (0.1 + sin²α) 라 잎이 공기를 정면으로 받을수록 커지고, 양력은 같은 u² 에 LEAF_LIFT_GAIN 1.6 과 |sin α·cos α| 를 곱한다. |sin α·cos α| 는 ½·|sin 2α| 와 같으니, 양력은 저항이 가장 큰 90° 가 아니라 그 절반인 45° 에서 정점을 찍는다. 저항은 공기 속도 반대쪽으로, 양력은 속도에 수직인 쪽으로 건다. 01 의 저항 최대는 482·480·456 으로 양력 최대의 절반쯤이다.

자리마다 다른 바람과 지나가는 띠

03 의 바람은 정수 격자에 값을 두고 3차 곡선으로 이어 붙인 부드러운 노이즈다. 척도 WIND_SCALE 0.007 이라 계산으로는 1/0.007 ≈ 143px 를 가야 격자 한 칸을 넘고, WIND_DRIFT 0.4 로 시간이 흐르며 바람 무늬가 옆으로 밀린다. 가로 바람은 기본 34 에 노이즈 × 46 을 더하고, 돌풍 띠 가운데는 시각 × GUST_SPEED 를 판 폭 + 띠 폭 여섯 배 + 간격 420 으로 나눈 나머지를 따라 왼쪽에서 오른쪽으로 되풀이해 지나간다. 노이즈 바람에 떠다니는 비눗방울 편 의 표류와 갈리는 지점이 이 띠로, 화면을 가로지르는 전선 안에 든 잎만 세게 민다.

잎맥까지의 거리로 정하는 물드는 순서

04 는 잎 안 픽셀마다 가장 가까운 잎맥 선분까지의 거리를 재고 최댓값으로 나눠 0~1 로 만든 다음, 0.66 × 거리 + 0.34 × 얼룩 노이즈를 그 픽셀이 물드는 순서로 삼는다. 순서 값은 0.01~0.881 에 퍼져 있고, 시간이 갈수록 내려오는 문턱을 먼저 넘는 가장자리부터 색이 바뀐다. 잎맥을 자라게 그리는 제너러티브 아트 편 과 달리 여기서 잎맥은 미리 정해 두고, 그 잎맥까지의 거리만 쓴다.

낙엽 떨어지는 효과 9종 — 양력 흔들림·세 축 뒤집힘·돌풍 띠·단풍 물듦·꼭지 끊김·흘러내리는 더미·빗자루 바람·회오리·가을 나무

데모마다 2초를 돌리며 24장을 캡처하고, 앞뒤 장의 차이 23개로 움직인 넓이와 세기를 셌다. 140장이 판 전체를 흐르는 03 이 71.5% 로 가장 넓고, 꼭지 여섯 개가 흔들리다 잎을 떨구는 05 가 8.5% 로 가장 좁다. 06 은 22/23, 나머지 여덟은 23/23 이다.

장면마다 잰 넓이·프레임·강도

항목 바뀐 넓이(누적면적) 움직인 프레임 강도 평균
01 양력 흔들림 13.5% 23/23 91.5
02 세 축 뒤집힘 29.3% 23/23 70.0
03 돌풍 띠 71.5% 23/23 83.6
04 단풍 물듦 22.3% 23/23 29.1
05 꼭지 끊김 8.5% 23/23 67.5
06 흘러내리는 더미 12.9% 22/23 84.6
07 빗자루 바람 31.2% 23/23 54.2
08 회오리 20.8% 23/23 51.2
09 가을 나무 34.9% 23/23 69.1

08 의 회오리는 오로라 배경 편 의 포인터 소용돌이 빛 가루처럼 속도장을 쓰지만, 가운데 통째 회전과 바깥 감쇠에 위로 끄는 바람을 더해 잎을 3차원 나선으로 띄운다는 점이 다르다.

01기운 각도로 옆으로 미끄러지며 흔들리는 잎

잎 세 장이 저마다 다른 기울기로 출발해, 받음각에서 나온 양력이 옆으로 밀고 복원 회전력이 기울기를 되돌리는 사이 지그재그로 내려온다. 아래 flutterStep 은 잎 속도에서 바람을 뺀 공기 속도를 잎 면 방향과 법선으로 나눠 저항과 양력을 따로 구하고, 첫 잎에는 두 힘을 화살표로 겹쳐 그린다. 소스 함수를 그대로 돌려 보면 세 장은 처음 자리 왼쪽으로 26.6~28.9px, 오른쪽으로 22.6~34.2px 를 오가며 3,872~4,912ms 에 바닥에 닿는다.

기운 각도로 옆으로 미끄러지며 흔들리는 잎
새 창에서 열기

01 · 기운 각도로 옆으로 미끄러지며 흔들리는 잎 — 측정 누적면적 13.519% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LEAF_LIFT_GAINRIGHTING_KTWIST_GAIN
    function flutterStep(f, windX, windY, dt, liftGain) {
        const rx = f.vx - windX;
        const ry = f.vy - windY;
        const u2 = rx * rx + ry * ry;
        const u = Math.sqrt(u2);
        let ax = 0;
        let ay = GRAVITY;
        let torque = -RIGHTING_K * Math.sin(f.tilt);
        f.lift = 0;
        f.drag = 0;
        if (u > 1e-6) {
            const tx = Math.cos(f.tilt);
            const ty = Math.sin(f.tilt);
            const nx = -ty;
            const ny = tx;
            const cosA = (rx * tx + ry * ty) / u;
            const sinA = (rx * nx + ry * ny) / u;
            f.aoa = Math.atan2(Math.abs(sinA), Math.abs(cosA));
            const drag = AIR_K * u2 * (DRAG_BASE + sinA * sinA);
            const lift = liftGain * AIR_K * u2 * Math.abs(sinA * cosA);

02세 축으로 뒤집히며 앞뒷면 밝기가 바뀌는 잎

잎 여덟 장이 각자 세 축으로 돌며 원근 배율 FOCAL_LENGTH / (FOCAL_LENGTH + z) 로 작아졌다 커지고, 뒤집혀 법선 z 가 양수가 되면 잎 색과 크림을 반반 섞은 뒷면 색으로 바뀐다. 아래 조각은 윤곽 72점을 화면으로 옮긴 뒤, 보이는 면의 법선과 왼쪽 위 앞에서 오는 빛 방향 LIGHT_DIR 의 내적으로 밝기 0.32 + 0.68 × 그 값을 정한다. 6초를 돌려 보면 잎마다 앞뒤가 6~8번 바뀌고 원근 배율은 0.757~1.232 사이를 오간다.

세 축으로 뒤집히며 앞뒷면 밝기가 바뀌는 잎
새 창에서 열기

02 · 세 축으로 뒤집히며 앞뒷면 밝기가 바뀌는 잎 — 측정 누적면적 29.32% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FOCAL_LENGTHLIGHT_DIRTUMBLE_RATES
        function toScreen(u, v) {
            const q = apply(m, u * half, v * half, 0);
            const z = cz + q[2];
            const k = FOCAL_LENGTH / (FOCAL_LENGTH + z);
            return [W / 2 + (cx - W / 2 + q[0]) * k, H / 2 + (p.y - H / 2 + q[1]) * k];
        }
        for (let i = 0; i < outline.length; i += 1) {
            pts.push(toScreen(outline[i][0], outline[i][1]));
        }
        const veins = VEINS[p.kind].map(function (s) { return [toScreen(s[0][0], s[0][1]), toScreen(s[1][0], s[1][1])]; });
        depth = cz;
        const n = apply(m, 0, 0, -1);
        // 법선 z 가 음수면 앞면이 눈을 향한다. 보이는 면의 법선으로 빛과 내적한다
        const front = n[2] < 0;
        const s = front ? 1 : -1;
        const lit = Math.max(0, s * (n[0] * LIGHT_DIR[0] + n[1] * LIGHT_DIR[1] + n[2] * LIGHT_DIR[2]));
        const base = front ? p.color : backColor(p.color);
        const bright = AMBIENT_LIGHT + (1 - AMBIENT_LIGHT) * lit;
        const fill = mixRGB(mixRGB(base, INK, 0.36 * (1 - bright)), CREAM, 0.34 * lit * lit);
        return { pts: pts, veins: veins, depth: depth, lit: lit, front: front, fill: fill, leaf: p };

03돌풍 띠가 지나가며 잎 무리를 휩쓰는 바람

잎 140장이 부드러운 노이즈로 정한 자리별 바람을 따라 흐르다가, 복숭아색 돌풍 띠가 지나가는 동안 그 안의 잎만 세게 밀려 한쪽으로 쓸려 간다. gustWeight 는 띠 가운데에서 1, GUST_WIDTH 64px 떨어지면 1/e 인 종 모양 폭이고, windAt 은 여기에 GUST_GAIN 300 을 곱해 가로 바람에 더하면서 세로로는 살짝 들어 올린다. 판 가운데 x 200 에 시험 잎을 두면 띠 안에 1,232ms 머물며 가로 속도가 275px/s 까지 올라 2.8초에 329px 를 가는데, 돌풍이 없으면 61.7px 에 그친다.

돌풍 띠가 지나가며 잎 무리를 휩쓰는 바람
새 창에서 열기

03 · 돌풍 띠가 지나가며 잎 무리를 휩쓰는 바람 — 측정 누적면적 71.459% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GUST_SPEEDGUST_WIDTHWIND_COUPLING
    // 종 모양 폭 — 띠 가운데에서 1, GUST_WIDTH 만큼 떨어지면 1/e
    function gustWeight(x, center) {
        const k = (x - center) / GUST_WIDTH;
        return Math.exp(-k * k);
    }
    // 자리 (x, y)·시각 t(초)의 바람(px/s) — out[0] 가로, out[1] 세로(아래가 +)
    function windAt(x, y, t, center, out) {
        const n1 = windNoise(x * WIND_SCALE - t * WIND_DRIFT, y * WIND_SCALE);
        const n2 = windNoise(x * WIND_SCALE + 31.7, y * WIND_SCALE - t * WIND_DRIFT * 0.7);
        const g = gustWeight(x, center);
        out[0] = WIND_BASE + WIND_SWIRL * n1 + GUST_GAIN * g;
        // 돌풍 안은 살짝 위로 들어 올린다
        out[1] = WIND_SWIRL * 0.6 * n2 - GUST_GAIN * 0.18 * g;
    }

04잎맥에서 먼 가장자리부터 물드는 단풍

다섯 갈래 잎 한 장이 4,200ms 에 걸쳐 잎맥에서 먼 가장자리부터 초록·버터·살구·단풍 순으로 물들고, 노이즈 얼룩이 그 경계를 들쭉날쭉하게 만든다. 아래 paintField 는 매 프레임 문턱 th = 1 − p·(1 + RAMP_WIDTH) 하나만 옮기고, 미리 구해 둔 픽셀별 순서 값과 견줘 램프 색을 고른다. 잎 안 35,352 픽셀 가운데 첫 픽셀이 다 붉어지는 때는 1,796ms, 마지막은 4,371ms 이고, 잎맥 가까운 1,338 픽셀은 순서 평균이 0.18 이라 늦게 물든다.

잎맥에서 먼 가장자리부터 물드는 단풍
새 창에서 열기

04 · 잎맥에서 먼 가장자리부터 물드는 단풍 — 측정 누적면적 22.259% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RAMP_WIDTHBLOTCH_MIXFIELD_SCALE
    function paintField(p) {
        if (!fieldCv || !fieldImg) {
            return;
        }
        const d = fieldImg.data;
        const th = 1 - p * (1 + RAMP_WIDTH);
        for (let i = 0; i < insideIdx.length; i += 1) {
            const s = clamp((turnOrder[i] - th) / RAMP_WIDTH, 0, 1);
            const c = mixRGB(rampColor(s), INK, 0.34 * veinNear[i]);
            const o = insideIdx[i] * 4;
            d[o] = c[0];
            d[o + 1] = c[1];
            d[o + 2] = c[2];
            d[o + 3] = 255;
        }
        rampCompares += insideIdx.length;
        lastCompares = insideIdx.length;
        const g = fieldCv.getContext('2d');
        if (g) {
            g.putImageData(fieldImg, 0, 0);
        }
    }

05흔들림이 쌓이면 꼭지가 끊겨 떨어지는 잎

가지에 매달린 잎 여섯 장이 돌풍 띠가 왼쪽에서 오른쪽으로 지나갈 때 차례로 휘고, 꼭지 둘레 고리는 피로를 한계에 견준 비율만큼 차오르다 80% 를 넘으면 단풍색이 된다. stemStep 은 강성 STEM_K 70·감쇠 STEM_DAMP 2.2 의 각 스프링을 적분하며 휜 각도의 제곱을 피로로 쌓고, 꼭지마다 다른 한계 STEM_LIMITS 를 넘는 순간 끊긴 잎은 그때의 속도를 받아 01 의 flutterStep 으로 떨어진다. 꼭지는 x 순서로 1,120·7,280·1,728·2,128·2,304·8,240ms 에 끊겨, 첫 돌풍에 넷이 떨어지고 다음 돌풍에 둘이 떨어진다.

흔들림이 쌓이면 꼭지가 끊겨 떨어지는 잎
새 창에서 열기

05 · 흔들림이 쌓이면 꼭지가 끊겨 떨어지는 잎 — 측정 누적면적 8.544% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEM_KFATIGUE_RATESTEM_LIMITS
    function stemStep(s, windX, dt) {
        if (s.snapped) {
            return false;
        }
        const bend = s.ang - s.rest;
        const torque = -Math.sin(s.ang) * windX * STEM_WIND;
        s.angVel += (-STEM_K * bend - STEM_DAMP * s.angVel + torque) * dt;
        s.ang += s.angVel * dt;
        s.fatigue += bend * bend * FATIGUE_RATE * dt;
        if (s.fatigue >= s.limit) {
            s.snapped = true;
            return true;
        }
        return false;
    }

06비탈이 가팔라지면 흘러내리는 낙엽 더미

바닥을 12px 칸 40개로 나눠 이미 쌓인 160장 위에 x 236 둘레로 초당 80장씩 440장을 쏟고, 꼭대기 양옆에 한계 기울기 비탈을 점선으로 그린다. 아래 조각은 heapRelax 의 몸통으로, 칸마다 양옆 가운데 (높이 차 × LEAF_THICK 2.6) ÷ 칸 폭이 기울기 한계를 넘는 더 가파른 쪽으로 맨 위 잎 하나를 옮기고, 옮긴 잎은 260ms 동안 옛 자리에서 새 자리로 미끄러져 보인다. 다 쏟고 나면 600장이 꼭대기 124.8px·폭 300px 둔덕으로 서고, 그동안 흘러내림은 1,571번 일어난다.

비탈이 가팔라지면 흘러내리는 낙엽 더미
새 창에서 열기

06 · 비탈이 가팔라지면 흘러내리는 낙엽 더미 — 측정 누적면적 12.862% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REPOSE_SLOPECELL_WPOUR_RATE
                const h = hp.cols[c].length;
                let best = -1;
                let bestDrop = 0;
                for (let k = 0; k < 2; k += 1) {
                    const nb = c + ((k + pass) % 2 === 0 ? -1 : 1);
                    if (nb < 0 || nb >= n) {
                        continue;
                    }
                    const drop = (h - hp.cols[nb].length) * LEAF_THICK / hp.cellW;
                    if (drop > slope && drop > bestDrop) {
                        best = nb;
                        bestDrop = drop;
                    }
                }
                if (best < 0) {
                    continue;
                }
                const leaf = hp.cols[c].pop();
                leaf.fromX = heapSlotX(hp, c, leaf);
                leaf.fromY = heapSlotY(hp, h - 1);
                leaf.slideAge = 0;
                hp.cols[best].push(leaf);
                moved += 1;
                changed = true;

07빗자루 바람이 지나가며 낙엽을 쓸어 흩는 마당

위에서 내려다본 마당에 잎 150장이 깔리고, 빗자루가 두 번 굽은 530px 길을 170px/s 로 지나가며 둘레 66px 안의 잎을 밀어 흩는다. 무게 w = (1 − d/R)² 는 반지름 절반에서 0.25 로 줄고, 충격량은 바깥 방향 0.55 와 진행 방향 0.45 를 섞으며, 진행 방향과 바깥 방향의 외적이 옆구리를 맞은 잎일수록 세게 돌린다. 2초 동안 맞은 잎은 59장, 길 끝까지는 102장이고, 맞은 잎은 평균 34.6px 밀려 가며 회전 속도는 최대 9.2rad/s 까지 오른다.

빗자루 바람이 지나가며 낙엽을 쓸어 흩는 마당
새 창에서 열기

07 · 빗자루 바람이 지나가며 낙엽을 쓸어 흩는 마당 — 측정 누적면적 31.231% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BROOM_RADIUSPUSH_GAINSPIN_GAIN
        for (let i = 0; i < yard.length; i += 1) {
            const lf = yard[i];
            const dx = lf.x - broomX;
            const dy = lf.y - broomY;
            const d = Math.hypot(dx, dy);
            if (d < BROOM_RADIUS && mv > 0.01) {
                const w = (1 - d / BROOM_RADIUS) * (1 - d / BROOM_RADIUS);
                const ax = d > 0.01 ? dx / d : headX;
                const ay = d > 0.01 ? dy / d : headY;
                // 미는 방향 = 바깥 방향과 빗자루 진행 방향을 섞은 것
                const jx = (0.55 * ax + 0.45 * headX) * w * PUSH_GAIN * dt;
                const jy = (0.55 * ay + 0.45 * headY) * w * PUSH_GAIN * dt;
                lf.vx += jx;
                lf.vy += jy;
                // 진행 방향과 바깥 방향의 외적 — 옆구리를 맞은 잎일수록 세게 돈다
                lf.spin += (headX * ay - headY * ax) * w * PUSH_GAIN * SPIN_GAIN * dt;
                lf.hop = Math.min(1, lf.hop + w * 4 * dt);
                if (!lf.hit) {
                    lf.hit = true;
                    hits += 1;
                }
            }

08회오리에 말려 나선으로 솟았다 흩어지는 잎

잎 120장이 회오리 가운데로 빨려 들며 나선을 그리고 솟았다가, 세기가 잦아드는 1,600ms 동안 바깥으로 흩어져 내려앉는다. tangential 은 반지름 30 안에서는 통째로 도는 4.2·r, 바깥에서는 4.2·30²/r 이라 경계에서 126px/s 로 가장 빠르고, 상승 기류는 UPDRAFT 420 에 exp(−r/70) 과 (1 − 높이/LOFT_TOP) 을 곱해 높이 230 에 가까울수록 잦아든다. 2초에 떠 있는 잎은 63장, 3,696ms 에는 120장 전부가 떠 있고, 평균 반지름은 96.8px 에서 다 내려앉은 뒤 122.6px 로 넓어진다.

회오리에 말려 나선으로 솟았다 흩어지는 잎
새 창에서 열기

08 · 회오리에 말려 나선으로 솟았다 흩어지는 잎 — 측정 누적면적 20.78% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CORE_RADIUSUPDRAFTINFLOW
    function tangential(r) { return r < CORE_RADIUS ? SPIN_RATE * r : SPIN_RATE * CORE_RADIUS * CORE_RADIUS / r; }
    function step(dt) {
        t08 += dt * 1000;
        strength = swirlStrength(t08);
        const s = strength;
        for (let i = 0; i < swirlers.length; i += 1) {
            const p = swirlers[i];
            const onGround = p.h <= 0 && p.vh <= 0;
            // 바닥에 누운 잎은 바람이 충분히 셀 때만 움직인다
            const grip = onGround ? clamp((s - 0.15) / 0.3, 0, 1) : 1;
            const vt = tangential(p.r) * s * grip;
            p.th += vt / Math.max(p.r, 4) * dt;
            const lift = UPDRAFT * s * Math.exp(-p.r / LIFT_REACH) * clamp(1 - p.h / LOFT_TOP, 0, 1);
            p.vh += (lift - SWIRL_SINK) * dt * 2;
            p.vh -= p.vh * 1.4 * dt;
            p.h += p.vh * dt;
            if (p.h < 0) {
                p.h = 0;
                p.vh = 0;
            }

09깊이마다 크기와 흐림이 다른 가을 나무 장면

앞·중간·먼 세 층에 잎을 나눠 크기 1·0.62·0.38배, 흐림 0·0.5·1.6, 빠르기 1·0.72·0.45배로 그리고, 나무에서 끊긴 잎이 바람을 타고 내려와 바닥 더미에 쌓이면 그 자리에 새 잎이 다시 달린다. 아래 growTree 는 시드 47990 으로 고정한 재귀 두 갈래 분기라 깊이 6·갈래 각 0.44rad·길이 비 0.74 로 가지 63개와 끝 32개를 만들고, 나무는 오프스크린에 한 번만 그려 750프레임 뒤에도 treeDraws 가 1 이다. 2초 안에 6장, 12초 동안 14장이 끊기고 더미는 84장이 된다.

깊이마다 크기와 흐림이 다른 가을 나무 장면
새 창에서 열기

09 · 깊이마다 크기와 흐림이 다른 가을 나무 장면 — 측정 누적면적 34.949% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LAYER_SCALELAYER_BLURBRANCH_DEPTH
    function growTree() {
        segs = [];
        tips = [];
        const rng = makeRng(47990);
        function branch(x, y, ang, len, depth, w) {
            const x1 = x + Math.cos(ang) * len;
            const y1 = y + Math.sin(ang) * len;
            segs.push({ x0: x, y0: y, x1: x1, y1: y1, w: w });
            if (depth >= BRANCH_DEPTH) {
                tips.push([x1, y1]);
                return;
            }
            const j = (rng() - 0.5) * 0.18;
            branch(x1, y1, ang - BRANCH_SPREAD + j, len * BRANCH_SHRINK, depth + 1, w * 0.68);
            branch(x1, y1, ang + BRANCH_SPREAD + j, len * BRANCH_SHRINK * 0.94, depth + 1, w * 0.68);
        }
        branch(TREE_X, TREE_GROUND, -Math.PI / 2, TRUNK_LEN, 1, 12);
    }

양력을 약하게, 돌풍을 빠르게, 더미 비탈을 낮게 — 숫자 하나로 달라지는 가을 바람

세 줄 모두 소스의 상수 한 줄만 바꾸고 같은 함수를 다시 돌려 잰 값이다. 눈여겨볼 것은 가운데 줄로, 돌풍을 거의 두 배 빠르게 하면 잎은 오히려 덜 밀린다. 띠에 실려 가는 시간이 1,232ms 에서 368ms 로 줄기 때문이다.

이름 바꾼 값 느낌
양력을 약하게 01 LEAF_LIFT_GAIN 1.6 → 0.6 양력 최대가 934~997 에서 54~160 으로 줄고, 옆 폭이 왼쪽 26.6~28.9px·오른쪽 22.6~34.2px 에서 ±1.6~5.5px 로 좁아져 거의 곧게 떨어진다. 내려앉는 시각은 3,872~4,912ms 에서 3,776~4,560ms 로 크게 다르지 않다
돌풍을 빠르게 03 GUST_SPEED 220 → 420 판 가운데 시험 잎의 최고 가로 속도가 275 에서 177px/s 로, 2.8초 이동이 329 에서 195.7px 로 준다. 띠가 다시 오는 간격은 5,836ms 에서 3,057ms 로 짧아지고, 한때 띠 안에 든 잎은 최대 104장에서 61장이 된다
더미 비탈을 낮게 06 REPOSE_SLOPE 0.9 → 0.4 비탈이 42° 에서 21.8° 로 눕자 600장 꼭대기가 124.8px 에서 62.4px 로 절반이 되고, 폭 300px 이던 더미가 판 끝 480px 까지 퍼진다. 흘러내림은 1,571번에서 3,416번으로 두 배 넘게 는다

낙엽이 깨지기 쉬운 때 — 잎이 수백 장으로 늘었을 때·탭을 다녀온 뒤 한꺼번에 튀는 잎·고해상도 화면의 단풍 물들이기

잎이 수백 장으로 늘었을 때

잎마다 윤곽 72점을 경로로 잇고 잎맥까지 긋는 일을 매번 되풀이하면, 2초(16ms 걸음 125번) 동안 03 하나에서만 17,500번이다. 02·04 를 뺀 일곱 장면은 잎 모양 3 × 색 5 = 스프라이트 15장을 처음 한 번만 오프스크린에 그려 두고, 그다음부터는 자리·크기·회전·세로 납작함만 바꿔 drawImage 로 찍는다. 2초 동안 잰 찍기 횟수는 03 17,500·06 28,190·07 18,750·08 15,000·09 17,375번인데 새로 그린 스프라이트는 다섯 장면 모두 0 이다(09 는 흐림 단계까지 더해 35장). 잎 수도 상한에 묶었다. 03 의 140장은 판 밖으로 나가면 반대쪽 끝으로 돌아와 2초에 42번 다시 쓰이고, 09 는 내려앉은 잎 자리에 새 잎을 단다. 이 절에서 다루는 잎 수 상한·스프라이트·고정 크기 거리장을 그대로 담은 소스 아홉 벌과 리액트 판은 zip 하나에 들어 있고, 압축은 fxr3gktp 을 넣어야 풀린다.

탭을 다녀온 뒤 한꺼번에 튀는 잎

탭을 숨겼다가 돌아오면 첫 프레임과 앞 프레임의 시각 차가 수 초로 벌어질 수 있다. 아래 frame 은 그 차를 DT_MAX 100ms 까지만 쌓고, 16ms 걸음을 한 프레임에 최대 6번만 돈다.

    function frame(ts) {
        if (paused || !alive) {
            rafId = 0;
            return;
        }
        if (lastTs < 0) {
            lastTs = ts;
        }
        // dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
        acc += Math.min(ts - lastTs, DT_MAX);
        lastTs = ts;
        let n = 0;
        while (acc >= FIXED_DT && n < MAX_STEPS) {
            clock += FIXED_DT;
            if (clock >= loopLength()) {
                clock = 0;
                build();
            }
            step(STEP_S);
            acc -= FIXED_DT;
            n += 1;
        }
        render();
        rafId = window.requestAnimationFrame(frame);
    }

16ms 간격 40프레임 뒤에 5초를 비운 한 프레임을 넣어 보면, 상한이 있을 때 그 프레임은 96ms 만 돌아 01 첫 잎이 7px 움직인다. 상한을 없애면 5,008ms 를 한 번에 돌아 잎이 220.4px 를 순간이동하고, 세 장 다 바닥에 닿아 있다. 06 은 더 심해서 상한이 있으면 그 프레임에 8장을 쏟고 착지는 0장이지만, 없으면 401장을 쏟아 331장이 착지하고 흘러내림 1,069번이 한 프레임에 몰린다. 그래서 visibilitychange 로 숨으면 루프를 세우고, 돌아오면 startLoop 가 시계 기준을 다시 잡는다.

고해상도 화면의 단풍 물들이기

04 의 거리장을 캔버스 화소 수로 잡으면 480×300 에 배율 2 를 곱한 576,000 픽셀이 되어, 계산으로는 300×244 고정 판(73,200 픽셀)의 576,000/73,200 ≈ 7.9배다. 이 판은 거리장 크기를 화면 배율과 떼어 두고, 아래 이중 반복으로 잎 안 35,352 픽셀 × 잎맥 5 = 176,760번의 거리 계산을 buildField 에서 한 번만 한다(fieldBuilds 는 다시 만들어도 1).

        for (let y = 0; y < FIELD_H; y += 1) {
            for (let x = 0; x < FIELD_W; x += 1) {
                if (!insidePoly(x + 0.5, y + 0.5, outline)) {
                    continue;
                }
                let best = 1e9;
                for (let s = 0; s < veins.length; s += 1) {
                    distanceChecks += 1;
                    const d = segDist(x + 0.5, y + 0.5, veins[s][0], veins[s][1]);
                    if (d < best) {
                        best = d;
                    }
                }
                idx.push(y * FIELD_W + x);
                dist.push(best);
                if (best > maxD) {
                    maxD = best;
                }
            }
        }

매 프레임은 앞의 paintField 처럼 문턱과 35,352번 견주기만 하니, 거리를 프레임마다 다시 재는 176,760번보다 횟수로 5분의 1 이다.

움직임 줄이기에서는 이미 내려앉은 잎으로 — 일시정지 버튼과 장면 설명을 갖춘 접근성

장면을 말로 적은 설명

아홉 캔버스 모두 role="img" 와 지금 장면을 말로 적은 aria-label 을 달아, 화면 읽기 프로그램이 그림 대신 문장을 읽는다. 문장은 문구.json 에 세 언어로 들어 있다.

키보드로 누르는 일시정지

일시정지는 aria-pressed 를 단 네이티브 <button> 이라 Tab 으로 가서 Enter 나 Space 로 누르면 되고, 멈춘 동안은 바탕이 진한 잉크색·글자가 크림색으로 뒤집힌다. 포커스가 오면 :focus-visible 의 2px 윤곽이 보인다.

움직임 줄이기에서 남는 판

prefers-reduced-motion 이 reduce 면 아홉 장면은 멈춘 채로 시작해 루프 없이 마지막 판 한 장을 그린다. 01 은 세 장이 자취를 남긴 채 내려앉은 판, 02 는 바닥에 눕힌 여덟 장, 03 은 바닥 띠에 눕힌 140장, 04 는 다 물든 잎, 05 는 여섯 장이 끊겨 바닥에 놓인 판, 06 은 흘러내림이 끝난 더미, 07 은 빗자루가 길 끝까지 지난 마당, 08 은 잦아든 뒤 바닥에 닿은 잎들, 09 는 6초를 돌린 더미에 날던 잎까지 땅에 눕힌 판이다. 재생을 누르면 처음부터 움직이고, 페이지를 연 채 설정을 바꾸면 그 변화도 다시 읽는다.

가을 페이지 배경이나 계절 배너에 낙엽을 깔 때 자주 묻는 질문

낙엽 떨어지는 gif 를 넣는 대신 이 코드를 쓰면 무엇이 달라지나요?

이 판은 시드를 고정해 열 때마다 같은 장면을 다시 계산하고, 포인터가 오면 01·03 은 바람을 보태고 07 은 빗자루가 따라온다. 움직임 줄이기 설정을 읽어 멈춘 판을 보여 주고 일시정지 버튼도 있어서, 정해진 장면을 되풀이하는 움짤보다 화면 쪽 사정에 맞추기가 쉬운 것으로 보인다.

잎을 더 많이 깔아도 화면이 버티나요?

02·04 를 뺀 장면에서 잎 한 장을 그리는 비용은 스프라이트 한 번 찍기라, 잰 다섯 장면은 2초에 15,000~28,190번을 찍어도 새로 그린 스프라이트가 0 이다. 다만 찍는 횟수는 잎 수에 비례해 늘어나므로, 03 처럼 수를 정해 두고 판 밖으로 나간 잎을 반대쪽에서 다시 쓰는 방식이 안전해 보인다.

잎 색을 사이트 색에 맞추려면 어디를 고치나요?

잎 다섯 색은 LEAF_COLORS 의 RGB 배열 다섯 개이고, 02 의 뒷면 색은 backColor 가 그 색과 크림을 반반 섞어 따로 만든다. 스프라이트는 모양·색·흐림을 묶은 키로 처음 쓸 때 한 번 그려지니 색을 바꿔도 비용은 같다. 04 의 물드는 색만은 초록에서 단풍으로 가는 네 단계 TURN_RAMP 를 따로 고쳐야 한다.

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

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