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

비눗방울 효과 9종 — 막 두께 색·터짐·두 방울 붙기 계산

비눗방울의 무지갯빛은 칠한 색이 아니라 막 두께가 만든 빛의 어긋남이다 — 01번은 두께에서 색을 계산하고, 04번은 찌른 자리부터 가장자리가 말려 터지며, 06번은 붙은 두 방울 사이에 휜 막이 생긴다.

비눗방울 아홉 개를 불고 찌르고 붙여 보는 놀이판

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

아홉 장면은 막 한 장이 생겨서 사라지기까지를 따라 늘어놓았다. 01·02 는 막 두께와 그 두께가 만드는 색, 03 은 다 분 방울이 공기 속을 떠가는 길, 04·05 는 찌르거나 툭 쳤을 때의 반응이다. 06·07 은 방울끼리 붙는 모양과 링에서 떨어져 나가는 모양, 08 은 두께 대신 보는 각도가 정하는 반사이고, 09 는 앞의 계산을 열네 개 방울에 한꺼번에 얹은 들판이다.

비눗방울의 무지갯빛은 왜 막이 얇아질수록 바뀌는가?

비눗방울 막에 닿은 빛은 일부가 바깥 면에서 바로 튕기고, 일부는 막 속으로 들어가 안쪽 면에서 튕긴 뒤 돌아 나온다. 돌아 나온 빛은 막을 한 번 왕복한 거리, 곧 굴절률 1.33 × 두께 × 2 만큼 늦게 나오고, 이 늦음이 파장의 몇 배쯤인지에 따라 두 빛이 서로 보태지거나 지워진다. 빨강·초록·파랑은 이 편에서 파장을 650·530·450nm 로 두기 때문에 같은 늦음에도 채널마다 남는 몫이 다르고, 그 차이가 색으로 보인다. 막 두께가 바뀌면 아무것도 칠하지 않아도 색이 바뀌는 까닭이 여기 있다.

앞면 반사만 뒤집힌다고 보면 얇은 막은 검다

reflectRGB 는 바깥 면에서 튕긴 빛만 위상이 반 바퀴 뒤집힌다고 보고, 채널마다 sin²(2π·1.33·d/λ) 를 그 채널의 몫으로 쓴다. 두께 d 가 0 에 가까우면 두 빛이 거의 반대로 겹쳐 세 값이 함께 0 쪽으로 떨어지고(30nm 에서 0.14·0.21·0.28), 그래서 흘러내려 아주 얇아진 꼭대기는 색 대신 어둡게 비친다. 02 가 30nm(BLACK_FILM_NM)를 곧 터질 막의 기준선으로 쓰는 것도 이 때문이다.

비스듬히 보면 두께가 같아도 색이 옮겨 간다

방울 테두리 쪽에서는 시선이 막을 비스듬히 지나므로, 막 안에서 꺾인 빛의 어긋남이 cos(꺾인 각)배로 줄어든다. insideCos 가 이 배율을 √(1 − (1 − cos²)/1.33²) 로 내고, 01 의 380nm 막은 이 배율 때문에 정면의 자홍에서 테두리로 갈수록 주황빛을 지나 초록, 다시 파랑으로 옮겨 간다. 두께를 고르게 둔 방울에서도 테두리에 가운데와 다른 색 고리가 생기는 건 이 한 줄의 몫이다.

0~1 로 나온 값은 반사율이 아니라 비율이다

세 값은 0~1 로 맞춘 비율이지, 빛이 몇 % 돌아오는지를 뜻하지 않는다. 실제 막이 돌려보내는 빛은 몇 % 수준인데, 08 을 뺀 여덟 장면은 이 비율에 235(09 는 200)를 곱한 밝기를 바탕색 위에 더하므로 화면의 방울은 실제보다 훨씬 밝고 진하다. 채널 하나를 파장 하나로 대신한 것도 근사여서, 파장 전 범위를 더해 생기는 실제 색보다 띠가 또렷하게 갈린다.

비눗방울 효과 9종 — 막 두께 색·흘러내림·바람·터짐·두 방울 붙기로 만드는 장면

캔버스 한 장과 방울만 한 계산판

장면마다 <canvas> 가 한 장씩이고 내부 좌표는 480×300 으로 고정돼 있다. 01·02·04·06·08·09 는 따로 둔 판에 화소 값을 직접 채운 다음 drawImage 로 무대에 올리는데, 판 크기는 01·02 가 250×250, 06 이 340×172, 09 가 무대 전체인 480×300 이다. putImageData 는 캔버스에 걸린 변환을 무시하기 때문에 판을 한 번 거치는 것이고, 판은 장면 좌표 크기 그대로 계산한 뒤 drawImage 가 기기 화소 배율(2 에서 끊음)만큼 늘여 그리므로 배율 높은 화면에서도 계산할 화소 수는 늘지 않는다. 03·05·07 은 화소를 채우지 않고 선과 그라디언트로만 그린다.

1nm 눈금 표가 sin 호출을 대신한다

reflectRGB 는 시작할 때 0~1600nm 를 1nm 간격으로 한 번씩만 불려 4,803칸짜리 filmLut 를 채우고, 화소를 칠할 때는 filmAt 이 그 표를 1nm 사이에서 선형 보간해 읽는다. 01 은 프레임마다 62,500화소를 새로 칠하지만 그 과정에서 Math.sin 을 부르지 않는다. 화면 화소를 읽어 오는 getImageData 는 아홉 장면 어디에도 없고, 계산한 값을 쓰기만 한다.

16ms 고정 스텝으로 흘리는 시간

시간은 requestAnimationFrame 이 넘겨 준 시각 차를 쌓아 두었다가 16ms 짜리 고정 스텝(FIXED_DT)으로 잘라 쓴다. 한 프레임에 쌓을 수 있는 시간은 100ms, 스텝은 최대 6번이며, 화소 계산은 스텝 수와 상관없이 프레임마다 한 번이다. 스타일시트에는 @keyframes 가 없고, 움직이는 건 버튼 배경색의 transition 하나뿐이다.

2초 동안 24장을 찍어 비교한 움직임

480×300 창에서 2초 동안 24장을 찍고 이웃한 두 장씩 비교했으니, 움직인 프레임은 23번의 비교 가운데 화면이 바뀐 횟수다.

장면 누적면적 강도 평균 움직인 프레임
01 두께로 계산한 무지갯빛 27.3% 32.6 23/23
02 흘러내림과 검은 막 29.1% 48.9 23/23
03 바람 타는 방울 무리 42.6% 88.4 23/23
04 찌른 자리 터짐 27.0% 88.6 23/23
05 출렁임 스프링 13.5% 125.0 23/23
06 쌍둥이 방울과 사이 막 23.9% 64.4 14/23
07 링에서 불기 20.7% 96.6 23/23
08 하늘·창문 반사 11.6% 41.7 23/23
09 들판의 방울 열네 개 33.7% 32.7 23/23

05 와 08 의 면적이 13.5%·11.6% 에 그치는 건 바뀌는 자리가 좁아서다. 05 는 둘레 선 한 줄만 출렁이고, 08 은 하늘이 11.4초에 한 바퀴라 2초 동안 약 63° 만 돌며 반사가 짙은 곳도 테두리 고리에 몰려 있다. 06 의 14/23 은 두 방울이 다 붙은 뒤 1.1초(HOLD_MS) 동안 멈춰 있는 구간이 2초 캡처의 뒤쪽에 걸리기 때문이고, 03 의 42.6% 는 방울 26개와 32px 간격 흐름선이 화면 전체에 깔려 있어서로 볼 수 있다.

이런 화면에 띄운다

여름 이벤트 랜딩이나 아이 대상 서비스의 첫 화면처럼 맑고 가벼운 인상이 먼저 필요한 곳이라면 09 를 히어로 배경으로 깔고 그 위에 제목을 얹는 구성이 출발점이다. 버튼이나 좋아요 아이콘에 비눗방울 애니메이션을 붙이고 싶다면 화면 전체를 쓰는 장면보다 04 의 찌른 자리 터짐이나 05 의 출렁임을 작게 떼어 쓰는 편이 계산도 가볍다. 업로드 진행 막대 자리에 07 의 링을 두면 진행이 끝나는 순간이 방울이 떠나는 모양으로 보이고, 02 는 남은 시간을 꼭대기 색으로 알리는 대기 화면에 맞는다.

01막 두께 하나로 무지갯빛을 계산해 칠하는 비눗방울

방울 안 250×250 판의 화소 62,500개마다 thicknessAt 이 꼭대기 0.68배·바닥 1.32배 기울기와 시간에 따라 흐르는 노이즈 얼룩으로 두께를 정하고, reflectRGB 가 그 두께에서 빨강 650·초록 530·파랑 450nm 세 파장의 몫을 구해 색을 만든다. 두께가 380nm 로 같아도 법선의 정면 성분 z 가 1 → 0.5 → 0.25 로 기울면 insideCos 가 어긋남을 줄여, 0.97·0.08·0.49 의 자홍이 0.29·0.97·0.64 의 초록을 거쳐 0.04·0.68·0.98 의 파랑으로 바뀐다. 브랜드 히어로 배경이나 제품 패키지 목업처럼 무늬보다 재질이 먼저 보여야 할 때 맞고, 찍는 점을 끌면 왼쪽 칸에 그 자리 두께와 세 값이 숫자로 뜬다.

막 두께 하나로 무지갯빛을 계산해 칠하는 비눗방울
새 창에서 열기

01 · 막 두께 하나로 무지갯빛을 계산해 칠하는 비눗방울 — 측정 누적면적 27.32% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FILM_NFILM_D_NMWAVE_RGB_NM
    // 막 앞면과 뒷면에서 튕긴 두 빛이 두께만큼 어긋나 파장마다 남는 몫 — sin²(2π·n·d/λ).
    // 앞면에서 튕긴 빛만 위상이 뒤집히므로 두께 0 이면 세 채널이 모두 0(검은 막). 값은 0~1 로 맞춘 비율이지 실제 반사율(몇 %)이 아니다
    function reflectRGB(dNm) {
        const out = [0, 0, 0];
        for (let c = 0; c < 3; c += 1) {
            const s = Math.sin(2 * Math.PI * FILM_N * dNm / WAVE_RGB_NM[c]);
            out[c] = s * s;
        }
        return out;
    }
    for (let d = 0; d <= LUT_D_MAX; d += 1) {
        const r = reflectRGB(d);
        filmLut[d * 3] = r[0];
        filmLut[d * 3 + 1] = r[1];
        filmLut[d * 3 + 2] = r[2];
    }
    // 비스듬히 보면 빛이 막 안에서 꺾여 지나므로 어긋남이 cos(꺾인 각)배로 줄어든다 — cosView 는 시선과 법선 사이 각의 cos
    function insideCos(cosView) {
        const s2 = (1 - cosView * cosView) / (FILM_N * FILM_N);
        return Math.sqrt(1 - s2);
    }

02막이 아래로 흘러내려 꼭대기는 검은 막으로 얇아지고 바닥 띠는 위로 차오르는 방울

꼭대기부터 바닥까지 띠 72개로 나눈 막에서 drainStep 이 경계마다 DRAIN_K × sin(경계 각) × 위 띠 두께 에 시간을 곱한 만큼을 아래 띠로 넘기고, 두께×넓이 합을 그대로 두므로 위가 얇아진 만큼 아래가 두꺼워진다. 처음부터 흘리면 꼭대기는 364nm 에서 1초 209·2초 120·3초 69nm 로 줄어 4.46초에 검은 막 30nm 에 닿고, 그사이 500nm 띠는 꼭대기에서 잰 각 38.8° 에서 51.3° 로 내려오며 900nm 띠는 156° 에서 141° 로 올라온다. 로딩 대기 화면이나 남은 시간 표시처럼 끝이 다가옴을 색으로 알리는 자리에 맞고, 시연은 3.9초 흘려 둔 방울에서 출발해 0.56초 뒤 꼭대기가 검어지고 그 0.7초 뒤 터진 다음 새 방울을 분다.

막이 아래로 흘러내려 꼭대기는 검은 막으로 얇아지고 바닥 띠는 위로 차오르는 방울
새 창에서 열기

02 · 막이 아래로 흘러내려 꼭대기는 검은 막으로 얇아지고 바닥 띠는 위로 차오르는 방울 — 측정 누적면적 29.149% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DRAIN_KBLACK_FILM_NMSWIRL_AMP
    // 띠 i 와 i+1 사이 경계로 내려가는 양 — 위 띠의 두께로 정한다. 두 띠의 (두께×넓이) 합은 그대로라 막의 양이 보존된다
    function drainStep(dt) {
        const h = dt / 4;
        for (let s = 0; s < 4; s += 1) {
            for (let i = 0; i < BAND_N - 1; i += 1) {
                const e = Math.sin((i + 1) / BAND_N * Math.PI);
                const q = DRAIN_K * e * thick[i] * h;
                const moved = Math.min(q, thick[i] * bandArea[i] * 0.5);
                thick[i] -= moved / bandArea[i];
                thick[i + 1] += moved / bandArea[i + 1];
            }
            if (thick[BAND_N - 1] > D_DRIP) {
                thick[BAND_N - 1] = D_DRIP;
            }
        }
        age += dt;
    }

03부력과 공기 저항, 노이즈 바람 속에서 떠다니는 방울 무리

방울 26개가 매 스텝 driftStep 에서 아래로 끄는 9px/s²(NET_SINK)와 바람과의 속도 차에 1.6/s(DRAG_K)를 곱한 저항을 받는데, 바람은 windAt 이 2D 노이즈를 흐름 함수로 삼아 그 기울기를 90도 돌린 소용돌이 장에 오른쪽 바탕 바람 16px/s 를 더한 값이다. 가라앉는 끝속도는 9 ÷ 1.6 = 5.63px/s 로 아주 느리고 속도 차가 반으로 줄기까지 ln2 ÷ 1.6 = 0.433초라, 방울은 제 무게보다 바람을 따라가고 90px 안 이웃끼리 속도 방향의 코사인 평균이 0.95 로 거의 같은 쪽을 향한다. 랜딩 배경이나 이벤트 페이지 장식에서 방울이 무리 지어 흘러야 할 때 쓰고, 화면을 누르면 반지름 120px 안의 방울이 가운데 140px/s 돌풍에 바깥으로 밀려난다.

부력과 공기 저항, 노이즈 바람 속에서 떠다니는 방울 무리
새 창에서 열기

03 · 부력과 공기 저항, 노이즈 바람 속에서 떠다니는 방울 무리 — 측정 누적면적 42.649% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

NET_SINKDRAG_KWIND_SCALE
    function windAt(x, y, tSec, scale) {
        const z = tSec * WIND_DRIFT;
        const gx = x * scale;
        const gy = y * scale;
        const e = 0.01;
        // 흐름 함수 ψ 의 기울기를 격자 단위로 — (∂ψ/∂y, −∂ψ/∂x) 가 소용돌이 바람
        const dy = (noise2(gx + z, gy + e) - noise2(gx + z, gy - e)) / (2 * e);
        const dx = (noise2(gx + z + e, gy) - noise2(gx + z - e, gy)) / (2 * e);
        return [WIND_BASE_X + dy * WIND_SPEED, -dx * WIND_SPEED];
    }
    // 무게에서 부력을 뺀 아주 약한 가라앉음(px/s²) — 아래(+y)로 끈다
    const NET_SINK = 9;
    // 공기 저항(1/s) — 방울 속도와 바람 속도의 차에 비례해 그 차를 줄인다
    const DRAG_K = 1.6;
    // 한 스텝 적분 — 가속 = 가라앉음 + 저항×(바람 − 속도). 속도를 먼저 바꾸고 그 속도로 위치를 옮긴다
    function driftStep(b, dt, tSec, scale) {
        const w = windAt(b.x, b.y, tSec, scale);
        b.vx += DRAG_K * (w[0] - b.vx) * dt;
        b.vy += (NET_SINK + DRAG_K * (w[1] - b.vy)) * dt;
        b.x += b.vx * dt;
        b.y += b.vy * dt;
    }

04찌른 자리부터 구멍 가장자리가 말려 나가며 물방울이 튀는 터짐

누른 자리에 구멍을 열면 stepPop 이 둘레 120방향마다 가장자리를 rimSpeed 로 밀어내는데, 그 속도가 RIM_V_K × 560 × √(400 ÷ 그 자리 두께) 라 막이 얇은 꼭대기(264nm)에서 빠르고 두꺼운 바닥(622nm)에서 느려 방울 안에서 447~716px/s 로 갈린다. 가장자리가 물방울 28개의 출발 거리를 지나면 물방울은 그 자리 속도의 0.35배로 바깥을 향하고 60px/s 만큼 위로 튀었다가 900px/s²(SPRAY_G)로 떨어지며, 가운데를 찌르면 다 터지기까지 247ms 가 걸린다. 클릭 피드백이나 좋아요 반응처럼 누른 곳에서부터 비눗방울 터지는 순간이 퍼져야 하는 자리에 맞고, 시연이 차례로 찌르는 네 자리는 334~395ms 에 다 터진다.

찌른 자리부터 구멍 가장자리가 말려 나가며 물방울이 튀는 터짐
새 창에서 열기

04 · 찌른 자리부터 구멍 가장자리가 말려 나가며 물방울이 튀는 터짐 — 측정 누적면적 27.03% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RIM_V_KDROPLET_NSPRAY_G
    // 막 두께(nm) — 위가 얇고 아래가 두껍고, 노이즈로 얼룩진다. nx·ny 는 방울 중심 기준 −1~1
    function thicknessAt(nx, ny) {
        return Math.max(420 * (1 + 0.45 * ny) * (1 + 0.18 * noise2(nx * 1.8 + filmT * 0.2, ny * 1.8 + 4.1)), 20);
    }
    function rimSpeed(x, y) {
        const d = thicknessAt((x - BCX) / BR, (y - BCY) / BR);
        return RIM_V_K * RIM_V0 * Math.sqrt(D_RIM_REF / d);
    }
    function stepPop(dt) {
        let open = 0;
        for (let k = 0; k < RIM_N; k += 1) {
            if (rimR[k] >= rimMax[k]) {
                open += 1;
                continue;
            }
            const a = k / RIM_N * Math.PI * 2;
            const v = rimSpeed(hx + Math.cos(a) * rimR[k], hy + Math.sin(a) * rimR[k]);
            rimR[k] = Math.min(rimR[k] + v * dt, rimMax[k]);
        }
        for (const d of drops) {
            if (d.live || d.done || rimR[d.k] < d.at) {
                continue;
            }
            const a = d.k / RIM_N * Math.PI * 2;
            const v = rimSpeed(hx + Math.cos(a) * d.at, hy + Math.sin(a) * d.at);
            d.x = hx + Math.cos(a) * d.at;
            d.y = hy + Math.sin(a) * d.at;
            d.vx = Math.cos(a) * v * 0.35;
            d.vy = Math.sin(a) * v * 0.35 - 60;
            d.live = true;
            spawned += 1;
        }

05둘레가 여러 겹의 출렁임 스프링으로 흔들리다 가라앉는 방울

둘레 반지름을 2·3·4번째 출렁임의 합으로 두고 springStep 이 출렁임마다 −ω²·진폭 − 2γ·빠르기로 가속을 매기는데, ω 는 omega 식으로 n 이 클수록 커져 8.50·16.46·25.50rad/s 가 되고 감쇠 γ 는 n² 에 비례해 0.55·1.24·2.20 으로 커진다. 한 번 툭 친 뒤 진폭 0.004 를 마지막으로 넘은 시각이 4번 928ms, 3번 2,016ms, 2번 5,760ms 라 높은 출렁임부터 잦아들고 느린 타원 흔들림이 남으며, 늘어난 둘레는 두께가 (R0/r)² 배로 얇아져 선 색이 달라진다. 버튼 호버 반응이나 마스코트 인터랙션에 맞고, 전체 진폭이 처음 0.004 아래로 드는 건 1,856ms 지만 타원이 다시 넘나들어 끝까지 가라앉아 머무는 건 5,776ms 부터다.

둘레가 여러 겹의 출렁임 스프링으로 흔들리다 가라앉는 방울
새 창에서 열기

05 · 둘레가 여러 겹의 출렁임 스프링으로 흔들리다 가라앉는 방울 — 측정 누적면적 13.506% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MODE_MAXMODE_DAMPPOKE_IMPULSE
    function omega(n) { return OMEGA_2 * Math.sqrt((n - 1) * n * (n + 2) / 8); }
    function gamma(n) { return MODE_DAMP * n * n / 4; }
    // 감쇠 스프링 한 스텝 — 가속 = −ω²·진폭 − 2γ·빠르기. 빠르기를 먼저 바꾸고 그 빠르기로 진폭을 옮긴다
    function springStep(dt) {
        for (let n = MODE_MIN; n <= MODE_MAX; n += 1) {
            const w = omega(n);
            const g = gamma(n);
            velC[n] += (-w * w * ampC[n] - 2 * g * velC[n]) * dt;
            velS[n] += (-w * w * ampS[n] - 2 * g * velS[n]) * dt;
            ampC[n] = clamp(ampC[n] + velC[n] * dt, -AMP_CAP, AMP_CAP);
            ampS[n] = clamp(ampS[n] + velS[n] * dt, -AMP_CAP, AMP_CAP);
        }
    }

06두 방울이 붙어 사이에 휜 막 하나를 나눠 갖는 쌍둥이 방울

두 방울이 닿으면 geometry 가 두 원의 교점을 구하고 사이 막의 휘어짐을 p × (1/rs − 1/rl) 로 정하는데, 다 붙은 반지름 52·84px 이면 사이 막은 반지름 rs·rl/(rl − rs) = 136.5px 의 원호가 되고 그 중심이 작은 방울 쪽에 있어 막은 큰 방울 쪽으로 불룩하다. 중심 거리는 두 반지름이 60도로 벌어진 삼각형의 셋째 변 √(rs² + rl² − rs·rl) 로 미리 놓고 junctionAngles 가 위 교점에서 세 막의 접선 사이 각을 재는데, 다 붙은 뒤에는 시연의 네 크기 모두 120.00° 지만 붙는 도중(p = 0.5)에는 82°·142°·136° 로 아직 기울어 있다. 비교 카드나 두 요소를 잇는 장면에 맞고, 같은 크기(84·84px)가 되면 휘어짐이 0 이라 사이 막이 곧은 선으로 선다.

두 방울이 붙어 사이에 휜 막 하나를 나눠 갖는 쌍둥이 방울
새 창에서 열기

06 · 두 방울이 붙어 사이에 휜 막 하나를 나눠 갖는 쌍둥이 방울 — 측정 누적면적 23.855% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

R_SMALLR_LARGEJOIN_MS
        const x1 = MIDX + (rs - rl - s) / 2;
        const g = { x1: x1, x2: x1 + s, s: s, touching: s < touch - 0.01, hj: 0, xc: 0, flat: true, rw: 0, xw: 0, p: p };
        if (g.touching) {
            // 두 원의 교점 — 작은 원 중심에서 공통 현까지 거리 a, 현의 반 길이 hj
            const a = (s * s + rs * rs - rl * rl) / (2 * s);
            g.hj = Math.sqrt(Math.max(rs * rs - a * a, 0));
            g.xc = x1 + a;
            // 사이 막의 휘어짐 = p × (1/작은 반지름 − 1/큰 반지름). 다 붙으면(p=1) 두 방울 압력 차와 맞는 값
            const kappa = p * (1 / rs - 1 / rl);
            if (kappa > 1e-6) {
                g.flat = false;
                g.rw = 1 / kappa;
                // 사이 막 원의 중심은 작은 방울 쪽 — 그래서 막은 큰 방울 쪽으로 불룩하다
                g.xw = g.xc - Math.sqrt(Math.max(g.rw * g.rw - g.hj * g.hj, 0));
            }
        }
        return g;
    }
    function junctionAngles(g) {
        const jx = g.xc;
        const jy = MIDY - g.hj;
        const t1 = tangentAway(g.x1, jx, jy, g.x2, rl);
        const t2 = tangentAway(g.x2, jx, jy, g.x1, rs);
        let t3 = [0, 1];
        if (!g.flat) {
            const len = Math.hypot(jx - g.xw, jy - MIDY);
            t3 = [-(jy - MIDY) / len, (jx - g.xw) / len];
            if (t3[1] < 0) {
                t3 = [-t3[0], -t3[1]];
            }
        }
        const ang = function (a, b) {
            return Math.acos(clamp(a[0] * b[0] + a[1] * b[1], -1, 1)) * 180 / Math.PI;
        };
        return [ang(t1, t2), ang(t2, t3), ang(t3, t1)];
    }

07링에 맺힌 막을 바람으로 부풀리다 목이 조여 떨어져 나가는 방울

불 때마다 막 안 넓이를 초당 7,200px²(BLOW_RATE)씩 늘리고 solveC 가 domeArea 를 50번 반씩 좁혀 그 넓이를 내는 원을 거꾸로 찾는데, 막 길이가 링 둘레 251.3px 를 넘는 넓이 8,295px² 부터 simulate 가 목 반지름을 줄이기 시작한다. 평평한 막에서 계속 불면 1.156초에 목이 조이기 시작하고 목이 링 반지름 40px 의 0.3배 아래로 좁아지는 1.560초에 방울이 떨어지며, 막 양을 보존하는 근사(D_FLAT × 2R ÷ 길이)라 떠난 방울의 막은 192nm 까지 얇아져 있다. 업로드나 전송 진행 표시처럼 차오르다 한 번에 떠나는 순간이 필요한 곳에 맞고, 시연은 7,600px² 까지 부푼 막에서 시작해 0.504초 만에 첫 방울을 떠나보낸다.

링에 맺힌 막을 바람으로 부풀리다 목이 조여 떨어져 나가는 방울
새 창에서 열기

07 · 링에 맺힌 막을 바람으로 부풀리다 목이 조여 떨어져 나가는 방울 — 측정 누적면적 20.721% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BLOW_RATERING_RPINCH_RATIO
    // 링 두 끝을 지나고 중심이 링에서 c 만큼 오른쪽인 원 — 링 오른쪽에 든 넓이
    function domeArea(c) {
        const rho = Math.hypot(RING_R, c);
        return rho * rho * Math.acos(-c / rho) + c * RING_R;
    }
    // 넓이에서 c 를 거꾸로 — 넓이는 c 에 따라 늘기만 하므로 반씩 좁혀 찾는다
    function solveC(a) {
        let lo = -4000;
        let hi = 800;
        for (let k = 0; k < 50; k += 1) {
            const mid = (lo + hi) / 2;
            if (domeArea(mid) < a) {
                lo = mid;
            }
            else {
                hi = mid;
            }
        }
        return (lo + hi) / 2;
    }
    function simulate(dt, blow) {
        tSec += dt;
        if (blow) {
            area += BLOW_RATE * dt;
        }
        const s = shape();
        if (stage === 0 && s.len >= CIRC) {
            stage = 1;
        }
        if (stage === 1) {
            neck -= NECK_RATE * RING_R * ((s.len - CIRC) / CIRC + NECK_BASE) * dt;
            if (neck / RING_R < PINCH_RATIO) {
                release(shape());
            }
        }

08하늘과 창문을 비추고 가장자리로 갈수록 반사가 짙어지는 방울

paintBubble 이 화소마다 구면 법선으로 반사 방향 2z·n − (0,0,1) 을 구해 하늘 그라디언트와 창문 사각형에서 색을 읽고, 뒤쪽 막 안쪽 면에서는 x·y 를 뒤집은 방향으로 한 번 더 읽어 거꾸로 선 두 번째 창문을 더한다. 섞는 비율 reflectance 는 EDGE_F0 + (1 − EDGE_F0)(1 − cosθ)⁵ 라 가운데는 4%, 반지름 0.9 에서 9.5%, 0.97 에서 27.9% 로 테두리 얇은 고리에서만 하늘이 짙어지고, 뒤 무대는 (1 − F)² 배로 거의 그대로 비친다. 유리질 아이콘이나 프리미엄 카드 광택처럼 가운데는 투명하고 테두리만 빛나야 하는 재질에 맞고, 끌면 하늘이 가로로 돌고, 돌리기가 켜져 있는 동안에는 11.4초에 한 바퀴씩 저절로 돈다.

하늘과 창문을 비추고 가장자리로 갈수록 반사가 짙어지는 방울
새 창에서 열기

08 · 하늘과 창문을 비추고 가장자리로 갈수록 반사가 짙어지는 방울 — 측정 누적면적 11.57% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

EDGE_F0SKY_TILTWINDOW_GLINT
                const z = Math.sqrt(1 - r2);
                // 들어온 시선 (0,0,−1) 을 법선에 비춰 돌린 방향 = 2z·n − (0,0,1)
                sampleEnv(2 * z * nx, 2 * z * ny, 2 * z * z - 1);
                const f0 = env[0];
                const f1 = env[1];
                const f2 = env[2];
                // 뒤쪽 막 안쪽 면에서 한 번 더 — 법선이 안을 향해 반사 방향의 x·y 가 뒤집힌다(거꾸로 선 두 번째 창문)
                sampleEnv(-2 * z * nx, -2 * z * ny, 2 * z * z - 1);
                const f = reflectance(z);
                const t = (1 - f) * (1 - f);
                const bo = (clamp(Math.floor(y), 0, H - 1) * W + clamp(Math.floor(x), 0, W - 1)) * 4;
                px[o] = bg[bo] * t + f0 * f + env[0] * f * (1 - f);
                px[o + 1] = bg[bo + 1] * t + f1 * f + env[1] * f * (1 - f);
                px[o + 2] = bg[bo + 2] * t + f2 * f + env[2] * f * (1 - f);
                px[o + 3] = 255 * clamp((1 - Math.sqrt(r2)) * BR, 0, 1);

09들판 위로 바람 따라 흐르는 비눗방울 열네 개가 저마다 색과 반사를 계산하는 장면

하늘·구름·언덕·풀밭·꽃점 70개를 처음 한 번 480×300 화소로 구워 두고 매 프레임 그 배열을 복사한 위에 방울 14개(BUBBLE_N)만 paintBubble 로 다시 계산하는데, 막 두께는 나이가 들수록 위쪽부터 얇아지는 식 하나로 02 의 흘러내림을 줄여 넣었다. 방울마다 수명이 6.5초의 0.7~1.3배인 4.55~8.45초이고 처음 장면은 0.35·0.77·1.19초처럼 0.42초 간격으로 차례가 흩어져 있어, 다한 방울은 위쪽 테두리 한 점에서 420px/s 로 구멍이 번져 50~160ms 만에 터지고 왼쪽 바깥에서 새 방울이 들어온다. 서비스 히어로나 여름 이벤트 랜딩의 첫 화면에 맞고, 빈 곳을 누르면 그 방향으로 해(SUN_DEG 기본 38°)가 옮겨 가 하이라이트 자리가 바뀐다.

들판 위로 바람 따라 흐르는 비눗방울 열네 개가 저마다 색과 반사를 계산하는 장면
새 창에서 열기

09 · 들판 위로 바람 따라 흐르는 비눗방울 열네 개가 저마다 색과 반사를 계산하는 장면 — 측정 누적면적 33.719% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BUBBLE_NSUN_DEGLIFE_S
                const z = Math.sqrt(1 - r2);
                // 막 두께 — 아래가 두껍고, 나이가 들수록 위쪽부터 얇아진다(흘러내림), 노이즈로 얼룩
                const d = Math.max(b.d0 * (1 + 0.3 * ny) * Math.exp(-1.1 * drain * (1 - ny) * 0.5) + 50 * noise2(nx * 2 + b.ph + tSec * 0.3, ny * 2), 0);
                const dEff = d * insideCos(z);
                // 가장자리 반사 — 반사 방향이 위를 보면 하늘, 아래를 보면 풀빛
                const m = 1 - z;
                const f = 0.04 + 0.96 * m * m * m * m * m;
                const rx = 2 * z * nx;
                const ry = 2 * z * ny;
                const rz = 2 * z * z - 1;
                const up = -ry;
                const envC = up > 0 ? mix(SKY_LOW, SKY_TOP, Math.min(up, 1)) : GRASS;
                const spec = Math.pow(Math.max(rx * lx + ry * ly + rz * lz, 0), 140);
                const amp = 0.3 + 0.6 * Math.pow(m, 0.6);
                const edge = clamp((1 - Math.sqrt(r2)) * rr, 0, 1) * fade;
                for (let c = 0; c < 3; c += 1) {
                    const v = px[o + c] * (1 - f * 0.8) + envC[c] * f + filmAt(dEff, c) * 200 * amp + spec * 255;
                    px[o + c] = px[o + c] + (v - px[o + c]) * edge;
                }

막 앞뒤에서 두 번 튕긴 빛 가운데 어떤 색이 살아남는가

정면에서 본 두께별 세 값을 reflectRGB 로 직접 불러 재면 아래와 같다. 막이 흘러 얇아지면 방울의 그 자리 색은 표를 위에서 아래로 내려간다.

두께 빨강 650nm 초록 530nm 파랑 450nm 보이는 쪽 이 편에서 나오는 자리
500nm 0.02 1.00 0.02 초록 01 방울 바닥 근처
380nm 0.97 0.08 0.49 자홍 01 기준 두께
260nm 0.04 0.67 0.99 파랑 04 꼭대기(264nm)
120nm 1.00 0.90 0.63 옅은 노랑빛 흰색 01 두께 변형, 02 의 2초 무렵 꼭대기
30nm 0.14 0.21 0.28 어두운 회청색 02 검은 막 기준
0nm 0 0 0 검정 터지기 직전

02 의 위쪽 띠와 바닥 띠가 엇갈려 움직이는 이유

경계로 넘기는 양이 위 띠 두께와 경계 둘레(sin θ)에 함께 비례하므로, 꼭대기는 내보내는 만큼 위에서 받지 못해 얇아지고 그 얇아짐이 아래로 번지면서 500nm 를 만족하는 자리가 38.8° 에서 51.3° 로 내려온다. 바닥 띠는 받기만 하고 넘침 한도 1500nm(D_DRIP)에 묶여 있어서, 두꺼운 영역이 위로 넓어지며 900nm 자리가 156° 에서 141° 로 올라온다. 넘쳐 버린 양은 전체 막의 0.25%(30,713 → 30,638)라, 방울이 떨어뜨리는 물보다 위아래로 옮겨 가는 물이 훨씬 많다.

04 의 가장자리가 얇은 곳에서 서두르는 계산

속도가 √(400 ÷ 두께) 에 비례하므로 두께가 4분의 1 이 되면 가장자리는 두 배 빨라진다. 04 의 막은 꼭대기 264nm·가운데 455nm·바닥 622nm 로 위가 얇아서, 아래쪽을 찌르면 구멍이 위로 먼저 달려 나가고 바닥 쪽 가장자리는 뒤처진다. 가운데에서 잰 속도는 525px/s 이고, 방울 안 전체로는 447~716px/s 사이다.

08 은 두께 대신 각도가 반사를 정한다

08 에는 reflectRGB 가 없고, 반사의 세기를 시선과 법선의 각도 하나로 정한다. 반사율이 10% 를 넘는 건 반지름 0.905 바깥, 50% 를 넘는 건 0.991 바깥이라 하늘빛은 테두리의 얇은 고리에만 진하게 모인다. 창문은 흰빛에 7배(WINDOW_GLINT)를 곱해 두어서, 반사율 4% 인 가운데에서도 0.04 × 255 × 7 ≈ 71 만큼 밝게 얹혀 보인다.

방울 수십 개의 화소 계산·너무 얇은 막·멈춘 탭 — 비눗방울이 깨지는 세 곳

이 장면들은 크게 세 곳에서 깨지고, 그 밖에 근사와 입력이 버티지 못하는 경계가 세 군데 더 있다.

방울이 수십 개면 화소 일이 넓이만큼 붙는다

01 은 반지름 124px 방울 하나를 위해 250×250, 62,500화소를 프레임마다 칠한다. 09 가 방울 14개를 반지름 11.5~32px 로 작게 두는 까닭이 여기 있는데, 열네 개를 다 합쳐도 방울 화소는 약 25,000 으로, 01 이 프레임마다 칠하는 판 62,500화소의 절반이 안 된다. 같은 크기 방울을 수십 개 띄우려면 BUBBLE_N 을 늘리기 전에 반지름부터 줄이고, 04 처럼 화소마다 atan2·hypot 을 부르는 터짐은 여러 개를 한꺼번에 돌리지 않는 편이 프레임을 지킨다.

흐름을 두께의 세제곱으로 바꾸면 꼭대기가 검어지지 않는다

02 의 흐름 양은 위 띠 두께에 비례하도록 둔 근사이고, 중력이 비탈을 따라 끄는 몫도 뺐다. 실제 막은 두께의 세제곱에 가깝게 흐르는데, 이 소스에서 세제곱으로 바꿔 돌리면 4.7초가 지나도 꼭대기가 약 260nm 에 머물러 몇 초 안에 검은 막을 보여 주는 시연이 되지 않았다. 비탈 성분을 넣으면 비탈이 거의 없는 꼭대기가 거의 흐르지 않는 것도 같은 문제를 낳는다. 그러니 02 의 4.46초는 과정이 화면에서 보이게 고른 빠르기이지 실제 방울이 얇아지는 시간이 아니다. 바닥 넘침 한도를 빼면 바닥 띠의 넓이가 0 에 가까워 두께가 30만 nm 까지 치솟았고, 한 번에 띠 양의 절반까지만 옮기는 묶음은 넓이가 작은 꼭대기 띠가 음수로 내려가는 것을 막는다.

막이 너무 얇아지면 가장자리 속도가 튄다

04 의 rimSpeed 는 두께로 나누는 식이라, 두께가 0 에 다가가면 속도가 한없이 커진다. 그래서 thicknessAt 이 두께를 20nm 아래로 내려가지 않게 묶는데, 그 바닥에서도 속도는 560 × √20 ≈ 2,504px/s 로 가운데 525px/s 의 다섯 배 가까이 된다. 실제 터짐은 수 ms 에 끝나지만 여기서는 RIM_V0 560px/s 로 늦춰 수백 ms 에 걸쳐 보이게 했고, 이 값을 열 배로 올리면 가운데 찌르기가 247ms 의 10분의 1인 약 25ms(계산값), 16ms 스텝 두 번 안에 끝나 말려 나가는 모양을 볼 틈이 거의 없다.

탭이 멈췄다 돌아오면 100ms 만큼만 따라잡는다

프레임 하나에 쌓는 시간을 100ms 에서 끊기 때문에, 탭을 가렸다 돌아와도 밀린 물리가 한꺼번에 몰려 계산되지 않는다. 대신 09 의 수명 시계도 그만큼만 흘러, 가려 둔 동안 터졌어야 할 방울이 돌아온 화면에 그대로 떠 있다. 방울을 실제 시각 기준으로 정리해야 하는 화면이라면 이 상한과 별개로 경과 시간을 따로 셈해야 한다.

120도는 두 방울일 때의 식이다

06 은 세 막이 120도로 만나는 중심 거리를 식으로 놓았을 뿐, 막이 당기는 힘을 풀어 그 거리를 찾지 않는다. 그래서 셋 이상 붙은 거품 덩어리에는 그대로 옮길 수 없고, 작은 쪽이 큰 쪽보다 커지면 휘어짐 식의 부호가 뒤집혀 입력을 30~84px, 큰 방울 반지름 이하로 묶어 두었다. 압력 차도 따로 계산하지 않고 휘어짐 식이 그 관계를 대신한다.

손가락 세로 끌기는 페이지가 가져간다

아홉 캔버스 모두 touch-action: pan-y 라서 터치 화면의 세로 쓸기는 페이지 스크롤로 넘어간다. 08 의 하늘 기울기는 터치 세로 끌기로는 바뀌지 않으니, 그 축은 ↑·↓ 키가 맡는다.

막 두께나 말림 속도를 직접 바꿔 볼 파일 묶음은 xdxs3ayg 을 넣어야 풀리고, 안에는 장면마다 index.html 과 style.scss, 같은 계산을 옮긴 리액트 판이 함께 들어 있다.

막 두께·말림 속도·바람 크기를 옮겨 보는 변형, 그리고 떠다니는 방울의 접근성

이름 값 느낌
막 두께 바꾸기 01 FILM_D_NM 380 → 120 정면 세 값이 0.97·0.08·0.49(자홍)에서 1.00·0.90·0.63(옅은 노랑빛 흰색)으로 바뀌고, 꼭대기는 0.68배인 약 82nm 까지 얇아져 검은 막 쪽으로 다가간다.
가장자리 말림 속도 바꾸기 04 RIM_V_K 1.0 → 2.5 가운데 속도가 525 에서 1,313px/s 로 오르고, 가운데 찌르기는 247 에서 100ms, 시연 네 자리는 334~395 에서 135~159ms 로 짧아진다. 물방울 28개는 그대로다.
바람 촘촘함 바꾸기 03 WIND_SCALE 0.004 → 0.012 소용돌이 하나가 250px 에서 약 83px 로 줄고 평균 빠르기는 34.8 에서 34.5px/s 로 거의 같지만, 90px 안 이웃의 방향 일치는 0.95 에서 0.07 로 떨어져 무리가 흩어진다.

세 번째 변형은 03 의 「바람 촘촘하게」 버튼(FINE_X 3배)이 이미 품고 있어서, 소스를 고치기 전에 Enter 나 버튼으로 먼저 비교해 볼 수 있다.

키보드로 불고 찌르기

캔버스마다 tabindex="0" 과 무엇을 하는 장면인지 적은 한국어 aria-label 이 붙어 있고, 마우스 없이 누를 수 있는 진짜 <button> 도 장면 하나에 한 쌍씩 놓였다. Home 은 아홉 장면 모두 처음 상태로 되돌리고, 01 은 -·+ 로 막을 40nm 씩, 02 는 → 로 흘러내림을 0.6초씩 앞당긴다. 04 는 화살표로 겨냥하고 Enter 로 찌르며, 06 은 ↑·↓ 로 작은 방울 크기를, 07 은 Space 로 불기를, 08 은 ←·→ 로 하늘을, 09 는 ←·→ 로 해를 10° 씩 옮긴다. 켜고 끄는 버튼은 02·03·07·08·09 에 있고 aria-pressed 로 상태를 알린다.

시연 동안 조용한 상태 줄

장면 아래 상태 줄은 자동 시연 중에는 aria-live="off" 로 있다가, 사람이 처음 포인터나 키, 버튼을 쓰는 순간 polite 로 바뀌고 시연도 멈춘다. 그래서 방울이 터질 때마다 화면 읽기 프로그램이 말을 쏟아 내지 않는다.

움직임 줄이기에서 남는 한 장

prefers-reduced-motion 이 켜지면 루프를 멈추고 한 장을 남기며, 아홉 장면 모두 change 이벤트를 듣고 있어 페이지를 연 채 설정을 바꿔도 곧바로 따른다. 01 은 시간이 멈춘 막, 02 는 꼭대기가 검은 막에 닿은 순간, 06 은 붙은 두 방울로 서 있다. 입력은 이때도 받아서 03 은 0.5초를, 07 은 0.4초를 한 번에 적분한 결과를, 04 는 터진 뒤 물방울이 0.3초 떨어진 자리를, 05 는 친 자리가 들어간 모양을 바로 그리고, 09 는 터뜨린 방울을 곧장 새 방울로 바꾼다.

비눗방울 터지는 효과는 이미지 파일 없이 코드만으로 되는가?

된다. 아홉 장면에는 이미지 파일·라이브러리·CDN·WebGL 이 하나도 없고, 화소를 채우는 여섯 장면은 createImageData 로 만든 빈 판에 계산한 값을 넣어 putImageData 로 올린다. 08·09 처럼 방울 뒤로 비칠 무대가 필요하면 그 무대도 처음 한 번 화소로 계산해 굽고 그 배열을 읽으며, 움직임은 requestAnimationFrame 루프 하나가 돌린다. 난수를 쓰는 01·02·03·04·06·09 는 시드 465 로 고정한 makeRng 를 부르고 05·07·08 은 난수를 쓰지 않아서, 페이지를 열 때마다 같은 배치에서 시작한다.

물방울이 뒤 화면을 굴절시켜 비추는 계산은 캔버스 특수효과 9종 — 물방울·블랙홀 에서, 터진 불꽃이 물 위에 비치는 장면은 불꽃놀이 장면 9가지 에서 이어 볼 수 있다. 스프링과 감쇠로 반응하는 다른 화면은 물리 시뮬레이션 만들기 9종 이 가깝다.

버블 애니메이션을 내 페이지에 띄우기 전에 자주 묻는 질문

비눗방울 애니메이션의 색을 우리 브랜드 색으로 바꿀 수 있나요?

막의 무지갯빛은 팔레트가 아니라 두께 계산값이라 무대와 버튼의 파스텔 토큰을 바꿔도 따라 바뀌지 않는다. 색의 중심을 옮기려면 01 의 FILM_D_NM 을 바꾸는 쪽이 가장 단순해서, 위 표대로 500nm 면 초록, 380nm 면 자홍, 260nm 면 파랑이 중심이 된다. 한 가지 색으로 고정하고 싶다면 이 계산 대신 단색을 칠하는 편이 맞다.

방울 개수를 늘리면 무엇부터 무거워지나요?

화소를 채우는 장면이라면 방울 화소의 합이 곧 프레임마다 할 일이다. 09 는 BUBBLE_N 14 에서 그 합이 약 25,000 이라, 반지름을 그대로 두고 개수를 두 배로 하면 할 일도 거의 두 배가 된다. 03 은 둘레를 12조각 선으로 그리는 무리여서 FLOCK_N 26 을 늘려도 화소 계산은 생기지 않는다.

리액트 판도 같은 장면을 그리나요?

같은 TypeScript 원본에서 vanilla index.html 과 리액트 컴포넌트를 함께 만들었기 때문에 계산 함수와 상수 값이 같다. 이 글의 코드 조각과 수치는 vanilla index.html 을 기준으로 했고, 리액트 판에서 FILM_D_NM 같은 상수 이름을 찾으면 같은 값이 나온다.

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

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