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

구름 애니메이션 9종 — 구름 생성 원리부터 구름바다까지

구름 애니메이션은 캔버스에서 칸마다 물방울이 얼마나 짙은지 계산해 하늘을 그리는 장면이다. 뭉게구름 아랫면은 왜 칼로 자른 듯 평평할까? 03번은 올라가던 공기가 식어 응결 높이를 넘은 자리부터 퍼프를 남기고, 04번은 해 높이만큼 비껴 난 그림자를 언덕 위에 눕히며, 05번은

구름은 그림이 아니라 짙기다 — 구름 애니메이션은 칸마다 물방울이 얼마나 짙은지 계산하는 데서 시작한다

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

아홉 장면은 같은 노이즈 함수 몇 개를 서로 다른 곳에 꽂은 것이다. 01·02 는 구름 한 덩이의 윤곽과 빛, 03 은 구름이 생기는 높이, 04·05 는 땅과 바람이 구름에 남기는 흔적을 다룬다. 06·07 은 배경으로 깔 수 있는 평면과 띠, 08 은 흩어지는 구름이고 09 는 06·02·07 의 계산을 한 화면에 겹친다.

구름이 짙기 숫자 하나로 줄어드는 과정

하늘의 한 칸에는 구름이 있거나 없는 것이 아니라 물방울이 얼마나 짙은지의 숫자가 들어 있다. 그 숫자를 여러 겹 노이즈 합으로 만들고, 빛이 짙기를 지나며 얼마나 깎이는지를 계산하면 구름의 속은 어둡고 가장자리는 밝은 모양이 저절로 나온다. 움직이는 구름 gif 는 이미 그려진 프레임을 되풀이하지만, 이 편의 장면은 시각을 받아 매 프레임 짙기를 다시 계산하므로 해 높이나 바람 값을 바꾸면 다른 하늘이 나온다.

구름 애니메이션 소스는 캔버스 2D 하나로 끝난다

아홉 장면 모두 라이브러리·WebGL·CDN 과 사진 파일 없이 캔버스 2D 와 requestAnimationFrame 만 쓰고, 하늘·구름·땅은 전부 계산으로 칠한다. 화소 계산은 장면마다 작은 격자(02 는 판 하나 120×150 두 장, 03·08 은 160×100, 05·06·09 는 200×125, 01·04·07 은 240×150)에서 하고, putImageData 로 올린 그림을 480×300 으로 늘려 그린다. 난수는 시드를 고정한 생성기 하나뿐이라 Math.random 도 0/9 이며, 같은 코드는 같은 하늘을 그린다.

이웃한 편과 갈리는 지점

노이즈로 지형을 만드는 편은 캔버스 노이즈 지형, 같은 캔버스 장면의 기준선은 캔버스 특수효과 편이 다룬다. 하늘 색 띠 쪽은 노을 하늘 그라디언트 편, 같은 계산 방식으로 눈을 쌓는 장면은 눈 내리는 효과 편에 있다. 이 편은 구름 한 덩이가 어떻게 생기고 빛을 받고 흩어지는지를 아홉 가지로 쪼갠 것이 다르다.

구름 애니메이션 9종 — 퍼프를 녹여 붙인 만화 구름 윤곽·해를 등진 구름의 은빛 테두리·응결 높이에서 평평해지는 뭉게구름·해 높이만큼 비껴 지나는 언덕 위 구름 그림자·높이마다 다른 바람에 늘어나는 새털구름·비행기 창밖으로 펼쳐지는 구름바다·이음매 없이 흐르는 여러 겹 구름 띠·마른 공기와 섞여 가장자리부터 흩어지는 구름·해돋이 구름 위 히어로 장면

01 은 구름 한 덩이가 어떻게 이어 붙는지, 02 는 그 덩이가 빛을 어떻게 받는지부터 시작한다. 03 은 구름이 하늘 어디서 생기는지, 04·05 는 땅과 바람이 구름에 남기는 흔적을 보인다. 06·07 은 배경 장면이고 08 은 구름이 사라지는 쪽, 09 는 앞의 계산을 한 장면에 모은다.

아홉 장면을 렌더해서 얻은 면적·프레임·강도

장면 누적면적 움직인 프레임 강도 평균
01 퍼프 윤곽 23.1% 23/23 40.0
02 은빛 테두리 18.7% 23/23 18.1
03 뭉게구름 8.3% 23/23 25.1
04 구름 그림자 17.8% 23/23 38.0
05 새털구름 29.6% 23/23 19.3
06 구름바다 33.0% 23/23 24.3
07 구름 띠 43.2% 23/23 32.2
08 흩어지는 구름 14.2% 13/23 54.9
09 해돋이 히어로 49.2% 23/23 18.7

화면 대부분이 하늘 색으로 바뀌는 09 가 49.2% 로 가장 넓고, 구름 덩이 하나가 좁은 곳에서만 피어오르는 03 이 8.3% 로 가장 좁다. 강도 평균은 짙기가 걷혔다 차오르는 08 이 54.9 로 가장 높고 은빛 테두리 장면인 02 가 18.1 로 가장 낮다. 움직인 프레임은 08 만 13/23 이고 나머지 여덟 장면은 23/23 이다. 08 이 왜 13 인지는 이번 측정에서 따로 재지 않았다.

01퍼프를 녹여 붙인 만화 구름 윤곽

랜딩 첫 화면의 날씨 일러스트로 쓰면 원 11개가 하나의 윤곽으로 녹아 붙은 채 숨 쉬듯 출렁인다. smin 은 두 거리를 SMOOTH_K 18px 안에서 부드럽게 섞어, 8px 벌어진 두 원 한가운데 거리를 최솟값 4 에서 −0.5 로 안쪽에 메우고 틈 9px(SMOOTH_K / 2)까지 이어 붙인다. 퍼프 반지름은 PUFF_STIFFNESS 60 과 PUFF_DAMPING 5 의 스프링이라 한 번 차면 최대 +9.97px 부풀었다 −3.39px 넘치며 돌아오고, 감쇠비는 0.323 이다.

퍼프를 녹여 붙인 만화 구름 윤곽
새 창에서 열기

01 · 퍼프를 녹여 붙인 만화 구름 윤곽 — 측정 누적면적 23.124% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SMOOTH_KPUFF_STIFFNESSPUFF_DAMPING
    function smin(a, b, k) {
        const h = Math.max(k - Math.abs(a - b), 0) / k;
        return Math.min(a, b) - h * h * k / 4;
    }
    // 화면 점 (x, y) 에서 구름 윤곽까지의 거리(px, 안쪽 음수) — 퍼프 거리를 차례로 부드럽게 섞는다
    function cloudDistance(x, y, k) {
        let d = 1e9;
        for (let c = 0; c < 2; c += 1) {
            const cc = cloudCenter(c, sceneT);
            let dc = 1e9;
            for (let i = 0; i < puffs.length; i += 1) {
                const p = puffs[i];
                if (p.cloud !== c) {
                    continue;
                }
                const di = Math.hypot(x - cc[0] - p.ox, y - cc[1] - p.oy) - p.r;
                dc = k > 0 ? smin(dc, di, k) : Math.min(dc, di);
            }
            d = Math.min(d, dc);
        }
        return d;
    }

02해를 등진 구름의 은빛 테두리

서비스 소개 페이지의 해 질 녘 배경에 어울린다. lightSum 은 칸마다 해 쪽으로 6걸음 걸으며 짙기를 모으고 e^(−ABSORB × S) 로 빛을 깎아, 짙기 합 1 을 지나면 투과가 0.407 만 남는다. 시연 2초 화면에서 구름 속(짙기 0.85 위)의 평균 투과는 0.135 이고, 하늘 빛 하한이 있는 오른쪽 판은 얇은 가장자리가 밝기 0.879, 속이 0.742 로 가장자리가 더 밝다.

해를 등진 구름의 은빛 테두리
새 창에서 열기

02 · 해를 등진 구름의 은빛 테두리 — 측정 누적면적 18.733% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LIGHT_STEPSAMBIENT_LIGHTFORWARD_POWER
    // 칸에서 해 쪽으로 걸어 모은 짙기 합 S(자기 칸 반 걸음 포함)
    function lightSum(gx, gy, steps) {
        const x = (gx + 0.5) * CELL;
        const y = (gy + 0.5) * CELL;
        const dx = SUN_X - x;
        const dy = SUN_Y - y;
        const len = Math.hypot(dx, dy) || 1;
        const ux = dx / len * LIGHT_STEP_PX / CELL;
        const uy = dy / len * LIGHT_STEP_PX / CELL;
        let s = dens02.v[gy * GRID_W + gx] * 0.5;
        for (let k = 1; k <= steps; k += 1) {
            s += sampleField(dens02, gx + ux * k, gy + uy * k);
        }
        return s * LIGHT_STEP_FRAC;
    }

03응결 높이에서 평평해지는 뭉게구름

날씨 앱의 오후 소나기 예보 카드에 어울린다. 공기 덩이 70개가 buoyancy 의 부력으로 오르다 CONDENSE_H 112px 를 넘은 것만 stampPuff 로 짙기를 남겨, 밑면은 10초 내내 110px 줄에 붙어 있고 꼭대기 근처 폭은 2초의 105px 에서 10초의 261px 로 퍼진다. 응결 높이 위에서는 MOIST_LAPSE 0.04 만큼만 식으므로 덩이가 더 오르다가, 위로 속도가 SPREAD_SPEED 14 보다 느려지면 가운데에서 먼 쪽으로 밀려난다.

응결 높이에서 평평해지는 뭉게구름
새 창에서 열기

03 · 응결 높이에서 평평해지는 뭉게구름 — 측정 누적면적 8.347% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CONDENSE_HMOIST_LAPSESPREAD_ACCEL
    function buoyancy(p) { return (p.temp - envTemp(p.h)) * BUOYANCY; }
    function stepParcel(p, dt) {
        const a = buoyancy(p) - DRAG * p.vh;
        p.vh += a * dt;
        const dh = p.vh * dt;
        p.temp -= dh > 0 ? coolBy(p.h, dh) : (dh * DRY_LAPSE);
        p.h += dh;
        let ax = Math.sin(sceneT * 1.7 + p.seed) * 4 - DRAG * p.vx;
        if (p.h > CONDENSE_H && p.vh < SPREAD_SPEED) {
            ax += (p.x >= SOURCE_X ? 1 : -1) * SPREAD_ACCEL;
        }
        p.vx += ax * dt;
        p.x += p.vx * dt;
        p.age += dt;
        if (p.h < 0) {
            p.h = 0;
            p.vh = 0;
        }
        if (p.age > 9 || p.x < -20 || p.x > W + 20 || (p.h < 4 && p.vh <= 0 && p.age > 1)) {
            spawn(p, 0);
        }
    }

04해 높이만큼 비껴 지나는 언덕 위 구름 그림자

여행 소개 페이지의 들판 히어로에 어울린다. shadowSourceX 는 땅 점에서 해 쪽으로 구름층 높이까지 거슬러 올라간 자리를 x + (CLOUD_H − h) / tan(높이각) 로 구해, 해 높이 50° 에서 높이 34px 땅은 149.4px 오른쪽 구름의 그림자를, 126px 언덕은 72.2px 오른쪽 구름의 그림자를 받는다. sunFacing 은 비탈 법선과 해 방향의 내적으로 땅 밝기를 정해 해를 향한 비탈 평균 0.882, 등진 비탈 평균 0.515 로 갈린다.

해 높이만큼 비껴 지나는 언덕 위 구름 그림자
새 창에서 열기

04 · 해 높이만큼 비껴 지나는 언덕 위 구름 그림자 — 측정 누적면적 17.765% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SUN_ELEV_DEGCLOUD_HshadowSourceX
    // 땅 점 (x, h) 에서 해 쪽으로 구름층 높이까지 거슬러 올라간 자리 — x + (CLOUD_H − h) / tan(높이각)
    function shadowSourceX(x, h) { return x + (CLOUD_H - h) / Math.tan(toRad(SUN_ELEV_DEG)); }
    // 비탈 법선과 해 방향의 내적 — 1 이면 해를 정면으로, 0 이하면 해를 등진 비탈
    function sunFacing(x) {
        const s = terrainSlope(x);
        const v = sunVec();
        return (-s * v[0] + v[1]) / Math.hypot(s, 1);
    }

05높이마다 다른 바람에 늘어나는 새털구름

명상 앱의 시작 화면 하늘에 어울린다. windAt 은 높이 비 hr 에 SHEAR 를 곱해 바닥 30px/초에서 꼭대기 48px/초(1.6배)까지 바람을 세게 하고, wispPhase 는 읽는 x 를 그 바람으로 밀면서 가로만 6배 늘여 읽는다. 16초마다 되감는 두 판을 반 주기 어긋나게 섞어 미는 거리가 끝없이 커지지 않게 했고, 위상 한가운데(8초)의 결은 25.6° 기울어 있다.

높이마다 다른 바람에 늘어나는 새털구름
새 창에서 열기

05 · 높이마다 다른 바람에 늘어나는 새털구름 — 측정 누적면적 29.586% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WIND_PXPHASE_SwindAt
    // 높이 비 hr 에서의 바람(px/초)
    function windAt(hr) { return WIND_PX * (1 + SHEAR * hr); }
    // 한 위상 판의 짙기 — 읽는 x 를 그 높이 바람으로 tp 초만큼 거슬러 민 뒤 가로만 STRETCH 배 늘여 읽는다
    function wispPhase(x, y, tp, salt) {
        const hr = 1 - y / H;
        const u = (x - tp * windAt(hr)) / (NOISE_PX * STRETCH);
        const v = y / NOISE_PX + salt;
        return layeredNoise(u, v, 4, 55);
    }
    // 칸 (x, y) 의 새털구름 짙기 — 두 위상 판을 삼각 무게로 섞고, 높이 비 0.28~0.52 에서 차츰 나타나 그 위 높은 하늘에만 남긴다
    function wispDensity(x, y, t) {
        const f = t / PHASE_S - Math.floor(t / PHASE_S);
        const g = (f + 0.5) - Math.floor(f + 0.5);
        const wa = 1 - Math.abs(2 * f - 1);
        const n = wispPhase(x, y, f * PHASE_S, 0) * wa + wispPhase(x, y, g * PHASE_S, 17.3) * (1 - wa);
        const hr = 1 - y / H;
        return band(0.52, 0.66, n) * band(0.28, 0.52, hr);
    }

06비행기 창밖으로 펼쳐지는 구름바다

항공권 검색 첫 화면의 배경에 어울린다. planeDistance 는 지평선 아래 화소의 기울기로 평면까지 거리를 CAM_HEIGHT 40 / 기울기로 구해, 화면 y 300 은 44, y 120 은 4000 만큼 떨어진 자리를 읽는다. 거리가 멀수록 노이즈 겹 수를 4에서 2로 줄이고 안개 몫을 1 − e^(−z × FOG) 로 섞어 y 120 근처는 0.988 까지 하늘색으로 녹는다.

비행기 창밖으로 펼쳐지는 구름바다
새 창에서 열기

06 · 비행기 창밖으로 펼쳐지는 구름바다 — 측정 누적면적 33.032% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CAM_HEIGHTFOCAL_PXplaneDistance
    function buildSea(t) {
        const travel = t * FLY_SPEED;
        for (let gy = 0; gy < GRID_H; gy += 1) {
            const y = (gy + 0.5) * CELL;
            const z = planeDistance(y);
            for (let gx = 0; gx < GRID_W; gx += 1) {
                const i = gy * GRID_W + gx;
                seaZ[i] = z;
                if (z < 0) {
                    continue;
                }
                const X = ((gx + 0.5) * CELL - W / 2) / FOCAL_PX * z;
                const oct = octavesFor(z);
                const n = seaHeight(X, z + travel, oct);
                seaN[i] = n;
                seaLit[i] = clamp(0.5 + (n - seaHeight(X, z + travel + LIGHT_PROBE, oct)) * 6, 0, 1);
            }
        }
    }

07이음매 없이 흐르는 여러 겹 구름 띠

웹사이트 헤더의 흐르는 배경에 어울린다. 네 겹의 윗선을 bandProfile 의 주기 노이즈(PERIOD 8칸)로 만들어 8칸을 흐르면 처음 값으로 돌아오고, 깊이 1 겹은 89.6px/초, 깊이 4 겹은 12.8px/초 로 느려지며 하늘색 몫은 0 에서 0.72 로 늘어난다. 한 바퀴는 가까운 겹이 5초, 먼 겹이 20초다.

이음매 없이 흐르는 여러 겹 구름 띠
새 창에서 열기

07 · 이음매 없이 흐르는 여러 겹 구름 띠 — 측정 누적면적 43.224% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LAYER_COUNTSKY_MIXperiodicLayered
    // 깊이 depth 겹의 흐른 거리(격자 칸) — PERIOD 로 나머지 처리해 숫자가 끝없이 커지지 않게
    function layerOffset(depth, t) {
        const s = t * layerSpeed(depth);
        return s - Math.floor(s / PERIOD) * PERIOD;
    }
    // 겹의 윗선 높이(0~1) — 주기 여러 겹 노이즈 합
    function bandProfile(u, depth) { return periodicLayered(u, depth * 3.1, PERIOD, 4, 77); }

08마른 공기와 섞여 가장자리부터 흩어지는 구름

블로그 글 머리의 계절 전환 장면에 어울린다. stepGrid 는 짙기를 이웃 평균으로 번지게 하고 빈 이웃이 많은 바깥 칸일수록 증발을 크게 깎아, 마른 1초(0.5~1.5초) 동안 바깥 칸은 평균 0.334, 안쪽 칸은 0.156 줄어 바깥이 2.1배 빨리 마른다. 습도가 4.6~5.4초에 0.12 에서 0.92 로 오르면 가운데 무게가 큰 칸부터 차올라 5.6초에 무게 0.9 넘는 칸의 평균 짙기가 0.591, 0.2~0.5 칸은 0.093 이다.

마른 공기와 섞여 가장자리부터 흩어지는 구름
새 창에서 열기

08 · 마른 공기와 섞여 가장자리부터 흩어지는 구름 — 측정 누적면적 14.21% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

EVAP_RATEEMPTY_LEVELSPIKE_CAP_S
                const avg = (l + r + u + dn) / 4;
                let empty = 0;
                for (let oy = -1; oy <= 1; oy += 1) {
                    for (let ox = -1; ox <= 1; ox += 1) {
                        if (ox === 0 && oy === 0) {
                            continue;
                        }
                        const x = gx + ox;
                        const y = gy + oy;
                        const nb = (x < 0 || y < 0 || x >= GRID_W || y >= GRID_H) ? 0 : v[y * GRID_W + x];
                        if (nb < EMPTY_LEVEL) {
                            empty += 1;
                        }
                    }
                }
                let nv = v[i] + DIFFUSE * dt * (avg - v[i]);
                nv -= EVAP_RATE * (1 - hum) * (EDGE_BASE + empty / 8) * dryMix(gx, gy) * dt;
                nv += FILL_RATE * hum * core08[i] * (1 - v[i]) * dt;
                next08[i] = (nv < CUT_LEVEL && nv < v[i]) ? 0 : clamp(nv, 0, 2);
            }
        }
        v.set(next08);
    }

09해돋이 구름 위 히어로 장면

포트폴리오 첫 화면의 히어로에 어울린다. 06 의 평면 위에서 sunBlock 이 해 쪽 5걸음(SUN_STEPS) 동안 빛줄기보다 솟은 구름 높이를 모으고 sunTransmit 이 e^(−ABSORB_09 × S / 22) 로 투과를 구한다. 해가 0° 일 때 꼭대기 위 20% 는 투과 0.841, 골짜기 아래 20% 는 0.339 로 꼭대기부터 빛이 닿고, 12° 에서는 0.996 과 0.967 로 거의 같아진다.

해돋이 구름 위 히어로 장면
새 창에서 열기

09 · 해돋이 구름 위 히어로 장면 — 측정 누적면적 49.187% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SUN_STEPSAMBIENT_09sunTransmit
    // 꼭대기 점 (X, Z, h) 에서 해 쪽(+Z, 위로 tan 높이각)으로 걸어 모은 가림 S — 빛줄기보다 높이 솟은 구름 몫만 더한다
    function sunBlock(X, Z, h, elevDeg) {
        const rise = Math.tan(toRad(elevDeg)) * SUN_STEP;
        let s = 0;
        for (let k = 1; k <= SUN_STEPS; k += 1) {
            const ray = h + rise * k;
            const top = topHeight(X, Z + SUN_STEP * k, 2);
            if (top > ray) {
                s += top - ray;
            }
        }
        return s;
    }
    function sunTransmit(X, Z, h, elevDeg) { return Math.exp(-ABSORB_09 * sunBlock(X, Z, h, elevDeg) / TOP_HEIGHT); }

구름 그림자 그리기는 구름 자리에 무엇을 더해야 땅 위에 맞게 떨어질까?

구름 그림자는 구름 바로 아래에 두면 어긋난다. 04 는 땅 점 (x, h) 의 그림자가 x + (CLOUD_H − h) / tan(높이각) 자리의 구름에서 온다고 계산하므로, 구름 x 에 해 높이로 정해지는 거리를 더해야 그림자가 맞는 자리에 떨어진다.

높은 언덕일수록 구름에 가까운 그림자

같은 해 높이 50° 에서 높이 34px 땅은 구름과 149.4px 떨어진 그림자를 받고, 80px 는 110.8px, 126px 는 72.2px 로 줄어든다. 언덕 꼭대기가 구름층에 가까울수록 해 쪽으로 거슬러 오를 높이가 짧기 때문이다.

해를 땅에서 재는 각으로 둘 것

SUN_ELEV_DEG 는 땅에서 잰 높이각이다. 각이 작을수록 tan 이 작아져 나눗셈 결과인 비껴 나는 거리가 길어지므로, 머리 위에서 잰 각으로 넣으면 거리 계산이 어긋난다.

배경 위에 얹을 때

구름 배경 그리기에서는 04 처럼 계산 격자를 화면보다 작게 잡고 늘려 그리는 편이 가볍다. 여행 소개 페이지의 들판 히어로라면 언덕 높이 h(x) 를 페이지의 지형 그림에 맞추고, 상태 줄은 왼쪽 위, 일시정지는 오른쪽 위에 둔 자리를 그대로 쓰면 된다.

노이즈로 짙기를 만들고, 해 쪽으로 걸어 빛을 깎고, 바람으로 밀어 옮긴다 — 노이즈 한 장이 구름이 되는 순서

구름이 되는 길은 네 단계다. 짙기를 만들고, 해 쪽으로 걸어 빛을 깎고, 바람으로 밀고, 작은 격자에서 계산해 늘려 그린다.

단계 하는 일 쓰는 이름 쓰는 장면
1 짙기 만들기 여러 겹 노이즈를 겹마다 빈도 2배·세기 절반으로 합해 0~1 로 맞춤 layeredNoise 02~09
2 빛 깎기 해 쪽으로 걸으며 짙기를 합산해 지수로 깎음 lightSum·sunBlock 02·09
3 바람으로 밀기 읽는 좌표를 흐른 거리만큼 밀어 구름이 흐름 windAt·layerOffset 05·07
4 늘려 그리기 작은 격자 화소를 만들어 480×300 에 확대 makePix·putPx·blit 01~09

여러 겹 노이즈 합이 구름 결인 이유

한 겹 노이즈는 매끈한 언덕만 만든다. 겹마다 빈도를 2배, 세기를 절반으로 쌓고 세기 합으로 나누면 큰 덩이 위에 잔 결이 얹히면서도 값은 0~1 에 머문다.

빛을 합산해 지수로 깎는 이유

투과를 e^(−ABSORB × S) 로 두면 짙기 0.5 를 두 번 지난 빛과 1 을 한 번 지난 빛이 같다. 걸음 수를 정해 짙기를 합산하기만 하면 되므로, 칸마다 해 쪽으로 6걸음 걷는 것으로 구름 속은 어둡고 얇은 가장자리는 밝은 모양이 나온다.

작은 격자에서 계산해 늘려 그리는 이유

화소 계산은 칸 수에 비례한다. 장면마다 120×150에서 240×150 사이의 작은 격자에서 계산하고 drawImage 로 늘려 그리면, 구름의 부드러운 경계는 확대가 대신 만들어 준다.

구름이 깨지는 순간 — 이음매에서 툭 끊기는 반복·해를 등진 쪽이 까맣게 죽는 빛·숨은 탭 뒤 한꺼번에 튀어 오른 구름

이음매에서 끊기는 반복

07 의 위쪽 시험 띠 둘은 같은 u 를 서로 다른 노이즈에 넣은 것이다. 흐른 거리를 PERIOD 로 나머지 처리해도 주기 없는 노이즈는 u 가 8 에서 0 으로 돌아가는 자리에서 다른 격자값을 읽어, u 7.999 와 0 의 값 차(64개 높이 줄)가 평균 0.175, 최대 0.553 이 된다. 주기 노이즈는 평균·최대 모두 0 이고, 높이 17px 시험 띠에서 주기 없음은 이음매에서 5px 툭 끊기는 반면 주기 노이즈는 0px 로 끊김이 없다.

아래 seamJump 는 화면이 부르지 않는 시험·측정용 함수다.

    // 실패판 — 같은 자리를 주기 처리 없는 여러 겹 노이즈 합으로 읽는다(u 를 PERIOD 로 나머지 처리한 값을 그대로 넣는다)
    function bandProfileNaive(u, depth) {
        const m = u - Math.floor(u / PERIOD) * PERIOD;
        return layeredNoise(m, depth * 3.1, 4, 77);
    }
    // 이음매 값 차 — u 가 PERIOD 바로 앞(PERIOD − 0.001)과 0 에서 읽은 값의 차
    function seamJump(read, depth) {
        return Math.abs(read(PERIOD - 0.001, depth) - read(0, depth));
    }

해를 등진 쪽이 까맣게 죽는 빛

02 의 왼쪽 판은 투과만으로 칠한 판이라 해를 등진 구름 속에서 빛이 지수로 줄어 바닥을 친다. 오른쪽 판은 paintPanel02 에 AMBIENT_LIGHT 0.6 을 넘겨 하늘 빛 하한을 더하며, 같은 짙기에서 구름 칸 최저 밝기가 0.029 에서 0.629 로 오르고 밝기 0.15 아래 칸이 80.4% 에서 0% 로 사라진다. 09 도 같은 수리를 AMBIENT_09 로 하며, 해 못 받은 골짜기 밝기 몫은 하한 0 이면 0.2, 있으면 0.88 이다.

                const dv = dens02.v[i];
                if (dv > 0) {
                    const L = trans02[i] * (BASE_LIGHT + forwardPhase(x, y));
                    const cl = [
                        SUN_TINT[0] * L * 1.15 + AMBIENT_TINT[0] * ambient,
                        SUN_TINT[1] * L * 1.15 + AMBIENT_TINT[1] * ambient,
                        SUN_TINT[2] * L * 1.15 + AMBIENT_TINT[2] * ambient,
                    ];
                    c = mix(c, cl, 1 - Math.exp(-dv * OPACITY));
                }

숨은 탭 뒤에 한꺼번에 튀어 오른 구름

08 은 시연 2초에 1.2초짜리 프레임이 들어오는 상황을 일부러 만든다. 수리판은 SPIKE_CAP_S 0.05초로 잘라 확산 몫 0.04 의 한 걸음만 가고, 실패판은 1.2초를 한 걸음에 써 확산 몫 0.96 이 되어 짙기가 0.2 넘게 바뀐 칸이 격자 전체 기준 0% 대 6.82% 가 된다. 그 뒤 실패판은 빈 칸이 늘어 증발이 빨라져 4초 22.4 대 17.4%, 5초 18.2 대 12.1% 로 벌어졌다가 습해진 6초 이후 26.7 대 26.6% 로 다시 붙는다.

    function update(dt) {
        const hum = humidityAt(sceneT);
        mixT08 = sceneT;
        if (lastT08 < SPIKE_AT && sceneT >= SPIKE_AT) {
            stepGrid(fix08, Math.min(HIDDEN_FRAME_S, SPIKE_CAP_S), hum);
            stepGrid(fail08, HIDDEN_FRAME_S, hum);
        }
        lastT08 = sceneT;
        stepGrid(fix08, dt, hum);
        stepGrid(fail08, dt, hum);
    }

실제 탭이 숨으면 프레임 루프가 멈추고, 돌아올 때 lastTs 를 −1 로 비워 둔 채 DT_MAX 0.05초로 한 번 더 잘라 쓴다. SPIKE_CAP_S 와 DT_MAX 는 값이 0.05초로 같을 뿐 별개의 상수다. 압축 파일은 암호 n97f273v 로 열고 07·02·08 폴더의 index.html 을 일시정지 버튼으로 세워 두면 깨진 판과 고친 판이 갈라지는 자리를 눈으로 확인할 수 있다.

흡수를 높이면 아랫면은 얼마나 어두워질까 — 흡수율·해 높이·높이별 바람 차이를 바꿔 본 차이

이름 바꾼 값 느낌
빛을 더 많이 먹게 02 ABSORB 0.9 → 1.6 짙기 합 1 을 지난 빛이 41% 에서 20% 로 줄어 구름 속 투과가 평균 0.14 에서 0.04 로 떨어지고, 주변광 없는 판의 거의 까만 칸이 구름의 80% 에서 91% 로 늘며 얇은 가장자리 밝기도 0.88 에서 0.79 로 내려가 은빛 테두리가 가늘어진다
해를 낮게 04 SUN_ELEV_DEG 50 → 20 높이각의 탄젠트가 1.19 에서 0.36 으로 줄어 그림자가 구름에서 비껴 나는 거리가 72~149px 에서 236~489px 로 세 배 넘게 멀어지고, 해를 향한 비탈과 등진 비탈의 밝기 몫이 0.88·0.52 에서 0.53·0.12 로 벌어지며 해를 못 받는 땅이 0.2% 에서 15% 로 늘어난다
높이별 바람 차이를 크게 05 SHEAR 0.6 → 1.4 꼭대기 바람이 바닥의 1.6배에서 2.4배(초당 48px 에서 72px)가 되어 위상 한가운데(8초) 결 기울기가 25.6°에서 48.2°로 눕고, 높이 비 0.3 과 꼭대기 결의 어긋남이 100.8px 에서 235.2px 로 벌어진다

값을 바꾸기 전에 볼 것

02 의 ABSORB 는 지수 안에 들어가므로 1.6 으로 올리면 구름 속 투과가 곱셈으로 급히 떨어진다. 04 의 SUN_ELEV_DEG 는 땅에서 잰 각이라 20 으로 낮추면 tan 이 작아져 나눗셈 결과가 커진다. 05 의 SHEAR 는 높이 비에 곱해지므로 1.4 에서 꼭대기 바람이 바닥의 2.4배가 된다.

움직임 줄이기에서는 멈춘 하늘 한 장 — 지금 하늘을 읽어 주는 문장과 일시정지의 접근성

상태 문장과 일시정지

아홉 장면의 캔버스는 role="img" 와 장면 설명 aria-label 을 갖고, 상태 줄은 「구름 양 40% · 해 높이 50° · 바람 오른쪽으로」 꼴로 지금 하늘을 문장으로 알린다. 일시정지 버튼은 Space·Enter 로 누르면 aria-pressed 가 바뀌고 루프가 멈추며, 포커스는 3px 외곽선으로 보인다. 시연 중 상태 줄은 aria-live off 이고 첫 입력 뒤에 polite 로 바뀐다.

움직임 줄이기 설정

prefers-reduced-motion: reduce 이면 아홉 장면 모두 루프를 세우고 대표 순간 한 장만 그린다. 그 순간은 01 1.2초·02 2초·03 8초·04 3초·05 8초·06 2초·07 3초·08 4초·09 8초 이고, 흐름·부풀기·흩어짐은 없다. 설정을 풀면 다시 돌아가고, 규격은 MDN 의 prefers-reduced-motion 에 있다.

구름 애니메이션 FAQ — css 만으로 만들기·휴대폰 부하·배경으로 깔기

구름 생성 원리를 몰라도 css 만으로 같은 구름을 만들 수 있나요

그라디언트를 겹친 정지 구름은 css 만으로 되지만, 이 편의 장면은 칸마다 짙기와 빛을 계산하므로 캔버스와 js 가 맞습니다. 원리를 몰라도 ABSORB·SUN_ELEV_DEG·SHEAR 같은 상수만 바꿔 다른 하늘을 얻을 수 있습니다.

휴대폰에서도 돌려 볼 수 있나요

캔버스 내부 좌표는 480×300 으로 고정이고 devicePixelRatio 는 2 까지만 쓰며, 화소 계산은 120×150~240×150 의 작은 격자에서 한다. 그래도 무겁다면 격자 칸 수를 줄이는 것부터 해 보시면 됩니다.

페이지 배경으로 깔아도 되나요

06·07·09 는 헤더와 히어로 배경으로 쓰기 좋고, 탭이 숨으면 루프가 멈춥니다. 글자를 읽는 구간에는 구름 위에 반투명 판을 한 장 두어 글자 대비를 지키는 쪽이 좋습니다.

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

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