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

글자 떨어지는 효과 9종 — 쌓이고 매달리고 물에 뜨는 글자

제목 글자를 바닥에 떨어뜨리면 보통 전부 똑같이 튕긴다. 여기서는 01번이 모서리로 닿은 글자를 돌려 쌓고, 03번은 부딪힌 속도만큼 젤리처럼 눌렸다 부풀며, 05번은 물에 잠긴 넓이만큼 떠오른다.

글자 아홉 묶음을 떨어뜨리고 매달고 물에 띄워 보는 무대

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

칸마다 가로 480·세로 300 좌표의 캔버스가 한 장씩 있고, 올라가는 글자는 「차곡차곡 쌓기」「매달린 간판」「말랑말랑」「안녕하세요」「여름 물놀이」「산산조각」「장바구니 담기」「가을 바람」「다시 한 줄로」 아홉 줄이다. 처음에는 저절로 도는 시연이 3,400ms 마다 장면을 처음 상태로 되돌리고, 캔버스를 누르거나 포커스를 준 뒤 키를 누르는 첫 입력에서 시연 표시가 떨어지며 손으로 다루는 상태가 된다. 칸 아래 단추 두 개는 장면을 처음으로 돌리는 단추와 일시정지 단추이고, 캔버스에 포커스가 있으면 Enter 와 Space 가 같은 일을 맡는다.

칸마다 손으로 건드리는 방법

장면 포인터 키보드
01 쌓임 누른 자리 글자를 튕긴다 ←·→ 흔들기, ↑ 띄우기
02 줄 끄는 동안 반지름 22px 원이 줄을 밀어낸다 ←·→ 흔들기
03 젤리 가까운 글자를 튕긴다 ↓ 내리꽂기, ↑ 띄우기
04 피하기 움직이기만 해도 포인터가 된다 방향키로 22px 씩 옮기는 키보드 포인터, Esc 치우기
05 물 누른 자리 수면을 누른다 ↓ 물속으로, ←·→ 밀기
06 조각 누른 글자를 내리꽂는다 ↓ 문턱보다 빠르게 내리꽂기
07 상자 가로 위치가 그대로 기울기가 된다 ←·→ 4° 씩 기울이기, Esc 자동으로
08 바람 누른 자리 글자를 띄운다 ←·→ 돌풍, ↑ 모두 띄우기
09 헤드라인 누르면 지금 무너뜨린다 ↓ 무너뜨리기, ↑ 되돌리기

글자 애니메이션 효과는 키프레임으로도 되는데, 왜 여기서는 속도와 힘을 매 틱 계산하는가?

키프레임은 미리 적어 둔 길을 시간에 맞춰 재생하는 방식이라, 글자 여섯 개가 서로 부딪혀 어느 각도로 멈출지는 미리 적을 방법이 없다. 01 의 무더기는 멈춘 각도가 0·89·89·1·137·323° 로 글자마다 달랐고, 포인터로 한 글자를 튕기면 그 뒤의 배치는 튕긴 순간의 위치와 속도가 정한다. 이런 결과는 지금 위치와 속도에서 다음 위치를 구하는 계산을 되풀이해야만 나온다. 그래서 아홉 장면의 스타일시트에는 @keyframes 가 하나도 없고, 움직임이라고는 단추 배경색 전환 하나뿐이다.

글자 애니메이션 만들기를 정해진 길 쪽에서 시작하고 싶다면 CSS 텍스트 애니메이션 효과 9종 이 키프레임 되튐을 다룬다. 이 편은 반대로 길을 정하지 않고 힘만 정한다.

한 프레임에 스텝을 6번까지만 따라잡는 시계

스크립트는 requestAnimationFrame 이 넘겨주는 시각 차이를 쌓아 두었다가 16ms 짜리 고정 스텝(FIXED_DT)으로 쪼개 돌린다. 한 프레임에 쌓을 수 있는 시간은 100ms 까지라 따라잡는 스텝도 최대 6번이다. 스텝 길이가 화면 주사율과 상관없이 같으므로 60Hz 화면과 120Hz 화면에서 같은 계산 순서를 밟을 것으로 보인다.

같은 시드가 같은 무더기를 만든다

배치는 수식이나 시드 고정 난수(makeRng 의 4721·4725·4726+i·4727·4729)로만 정하고 Math.random·Date.now 는 아홉 소스 어디에도 없다. 고정 스텝과 시드가 함께 묶여 있으니 손대지 않고 다시 떨어뜨리면 같은 자리에 같은 모양으로 쌓인다. 다시 떨어뜨린 직후 200ms(LEAD_MS)는 스텝이 아무것도 하지 않아, 글자가 놓인 자리에 잠깐 멈춰 있다가 움직이기 시작한다.

글자 떨어지는 효과 9종 — 쌓임·줄 매달림·젤리·피하기·물에 뜨기·조각나기·기울어진 상자·바람·히어로 헤드라인

단단한 상자 하나가 떨어져 쌓이는 01 이 바탕이고, 02 는 그 상자를 줄에 걸고, 03 은 상자 대신 말랑한 몸통을 준다. 04 는 떨어지지 않고 포인터를 피하는 쪽, 05 는 바닥 대신 물, 06 은 부딪힌 세기에 따라 갈라지는 쪽이다. 07 은 바닥 자체가 기울고, 08 은 중력보다 바람과 저항이 주인공이며, 09 는 01 의 상자 계산으로 무너뜨린 헤드라인을 읽는 순서대로 되돌린다.

2초 캡처 24장에서 센 화면 변화

480×300 크기로 2초 동안 찍은 24장을 이웃끼리 견준 변화 23번을 분모로 삼았다.

장면 누적면적 움직인 프레임 강도 평균
01 모서리 회전 쌓임 24.1% 21/23 101.0
02 줄에 매달린 간판 16.6% 23/23 121.3
03 젤리 눌림 23.0% 23/23 104.3
04 포인터 피하기 52.1% 23/23 67.3
05 물에 뜨기 27.1% 22/23 88.5
06 조각나기 21.9% 15/23 117.2
07 기울어진 상자 26.9% 23/23 94.0
08 바람에 날림 22.6% 23/23 90.4
09 무너졌다 정렬 26.9% 20/23 105.1

누적면적이 가장 넓은 04 는 가상 포인터가 8자 곡선으로 무대를 가로지르며 반경 원과 다섯 글자를 함께 끌고 다니기 때문으로 보인다. 06 이 15장에 그친 것은 문턱 아래로 떨어진 두 글자와 흩어진 조각이 바닥에 멈춘 뒤 화면이 거의 바뀌지 않기 때문으로 보인다. 누적면적이 10% 아래로 내려간 장면은 없다.

이런 화면에 붙인다

신제품을 처음 보여 주는 랜딩 첫 화면은 01 처럼 제목이 쏟아져 쌓이거나 09 처럼 무너졌다가 한 줄로 서는 쪽이 어울리고, 행사 안내 배너에는 02 의 매달린 간판이 맞다. 어린이 서비스 앱의 시작 화면은 03, 여름 행사 페이지 제목은 05, 가을 할인 배너는 08 이 계절을 그대로 말해 준다.

포인터를 따라 반응하는 텍스트 인터랙션이 필요한 포트폴리오 인사말에는 04 를 둔다. 게임 결과 화면의 점수 글자는 06 처럼 세게 떨어진 것만 깨지게 하면 높은 점수 연출로 쓰기 좋고, 07 의 기울어지는 상자는 장바구니에 물건이 담겼다는 알림 그림으로 짧게 띄우기에 알맞다.

글자를 상자 하나로 보는 충돌 판정과, 모서리로 닿았을 때 회전이 생기는 충격량

measureText 로 재는 글자 상자

아홉 장면 모두 layoutWord 가 원문을 Array.from 으로 낱글자로 나누고 measureText 로 글자마다 폭을 잰다. 줄 전체가 정해 둔 폭보다 길면 글자 크기를 같은 비율로 줄이고, 상자 폭은 잰 폭(아주 좁은 글자는 글자 크기의 0.34배), 높이는 글자 크기의 0.9배, 글자 사이는 크기의 0.1배, 띄어쓰기는 상자 없이 자리만 차지한다. 01 에서 잰 값은 한국어 「차곡차곡 쌓기」 58px 에 상자 여섯 개가 모두 폭 58, 영어 「STACK IT UP」 55.1px 에 I 19.2px 부터 A 41.3px 까지, 일본어 「つみかさね」 는 줄이지 않은 74px 에 폭 74 였다.

글자는 fillText 로 같은 글자를 0.5배 어두운 색으로 (1.4, 2.8)px 비켜 한 번 찍고 그 위에 제 색을 덮어 두께를 낸다. 글꼴은 800 굵기의 시스템 글꼴 목록이고 웹 글꼴을 불러오지 않는다.

넓이로 정하는 질량과 회전 관성

01·09 의 글자 상자는 넓이를 900 으로 나눈 값을 질량으로, m(w² + h²)/12 를 회전 관성으로 갖는다. 폭이 넓은 글자일수록 무겁고 잘 돌지 않으며, 높이가 같은 영문 I 상자는 폭이 A 상자의 0.46배라 질량도 그만큼 가볍다.

모서리 접점이 충격량을 회전으로 바꾸는 식

접점은 상자 꼭짓점이 바닥·벽 밖으로 나가거나 다른 상자 안에 들어간 자리에서 생기고, 덜 파고든 축이 밀어낼 방향(법선)이 된다. effMass 는 1/질량에 (접점 − 무게중심) × 법선의 제곱을 회전 관성으로 나눈 값을 더하는데, 접점이 무게중심 바로 밑이면 이 외적이 0 이라 충격량이 전부 위로 미는 데 쓰이고 모서리처럼 옆으로 비켜 있으면 같은 충격량의 일부가 각속도로 간다. 닿기 직전 법선 속도가 −1.2px/틱 보다 빠르면 그 속도의 0.32배로 되튀기를 목표로 삼고, 느리면 0 을 목표로 삼아 쌓인 글자가 잔잔하게 앉는다.

아홉 장면이 한 틱에 무엇을 계산하고 무엇이 움직임을 잦아들게 하는지는 이렇게 나뉜다.

장면 한 틱의 계산 잦아들게 하는 것
01 중력 적분을 한 틱 2번, 꼭짓점 접점, 충격량 6번 되튐 0.32, 마찰 0.55
02 이전 위치와의 차로 이어 가는 적분, 마디 길이 되맞춤 8번 공기 감쇠 0.012
03 모양 맞춤 회전각, 모양 스프링 0.45, 압력 0.8 점 감쇠 0.02, 바닥에서 세로 × −0.1
04 제자리 스프링 0.05, 거리 역수 반발 속도 × 0.88
05 기둥 스프링과 이웃 넘기기 2번, 표본 20점의 잠김 비율 물결 감쇠 0.02, 물속 저항
06 중력 0.5, 첫 충돌 속도와 문턱 9 비교 조각이 바닥에서 × −0.3, 가로 × 0.7
07 상자 각도로 돌린 중력, 밀어내기 4번 틱마다 한 번 × 0.96
08 값 노이즈 바람, 속도 제곱 저항, 기울기 활공 저항 0.02, 각속도 감쇠 0.06
09 01 의 상자 계산 뒤 0.9초부터 복귀 스프링 감쇠 2√0.03

01모서리로 닿으면 돌면서 쌓이는 글자

「차곡차곡 쌓기」 여섯 글자가 가운데로 모인 채 위아래 62px 남짓한 간격으로 겹쳐 떨어지고, 모서리로 닿은 글자는 돌며 넘어지고 평평하게 닿은 글자는 그대로 앉아 멈춘 각도가 0·89·89·1·137·323° 로 흩어진다. 접점마다 6번 되풀이하는 solveContact 는 누적 충격량을 0 아래로 내려가지 못하게 잘라 상자가 서로 끌어당기지 않게 하고, 미끄러지는 쪽 충격량은 누르는 충격량의 0.55배 안에 묶는다. 첫 바닥 닿음은 768ms, 가장 빠른 회전은 16.7°/틱, 여섯 글자가 끝까지 멈춘 시각은 2,688ms 였고 무더기 높이는 글자 높이 52.2px 의 1.48배인 77.1px 다.

모서리로 닿으면 돌면서 쌓이는 글자
새 창에서 열기

01 · 모서리로 닿으면 돌면서 쌓이는 글자 — 측정 누적면적 24.095% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GRAVITYRESTITUTIONFRICTION
    function effMass(c, dx, dy) {
        const ra = (c.px - c.a.x) * dy - (c.py - c.a.y) * dx;
        let k = c.a.im + ra * ra * c.a.ii;
        if (c.b) {
            const rb = (c.px - c.b.x) * dy - (c.py - c.b.y) * dx;
            k += c.b.im + rb * rb * c.b.ii;
        }
        return k;
    }
    function solveContact(c, fric) {
        const rel = relVel(c);
        const vn = rel[0] * c.nx + rel[1] * c.ny;
        const jn0 = c.jn;
        c.jn = Math.max(jn0 + (c.target - vn) / effMass(c, c.nx, c.ny), 0);
        const dj = c.jn - jn0;
        pushPair(c, c.nx * dj, c.ny * dj);
        // 마찰 — 미끄러지는 방향 충격량은 누르는 충격량 x fric 을 넘지 못한다
        const tx = -c.ny;
        const ty = c.nx;
        const rel2 = relVel(c);
        const vt = rel2[0] * tx + rel2[1] * ty;
        const lim = fric * c.jn;
        const jt0 = c.jt;
        c.jt = clamp(jt0 - vt / effMass(c, tx, ty), -lim, lim);
        const djt = c.jt - jt0;
        pushPair(c, tx * djt, ty * djt);
    }

02줄에 매달려 흔들리는 글자

「매달린 간판」 다섯 글자가 길이 46px 부터 10px 씩 긴 줄 다섯 개 끝에 걸려, 오른쪽으로 30° 들린 자세에서 놓이면 줄마다 다른 주기로 물결처럼 어긋나 흔들린다. 마디 점은 지금 위치와 이전 위치의 차에 0.988 을 곱해 속도로 이어 받고, fixSegment 를 8번 돌려 두 점을 질량 역수 비율(걸이 0·줄 1·끝 0.3)로 나눠 마디 길이를 되돌린다. 줄 늘어남은 최대 2.57%, 마지막 글자 기울기는 최대 33.6° 였고 흔들림이 0.05px/틱 아래로 내려가 머물기까지 7,984ms 가 걸렸다.

줄에 매달려 흔들리는 글자
새 창에서 열기

02 · 줄에 매달려 흔들리는 글자 — 측정 누적면적 16.641% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ROPE_SEGMENTSCONSTRAINT_ITERATIONSAIR_DAMPING
    function fixSegment(r, k) {
        const dx = r.px[k + 1] - r.px[k];
        const dy = r.py[k + 1] - r.py[k];
        const d = Math.max(Math.hypot(dx, dy), 0.0001);
        const wa = invMass(k);
        const wb = invMass(k + 1);
        const diff = (d - r.rest) / d / (wa + wb);
        r.px[k] += dx * diff * wa;
        r.py[k] += dy * diff * wa;
        r.px[k + 1] -= dx * diff * wb;
        r.py[k + 1] -= dy * diff * wb;
    }

03부딪힌 속도만큼 젤리처럼 눌렸다 부푸는 글자

「말랑말랑」 네 글자는 화소 둘레를 28방향 광선으로 훑어 뽑은 외곽 점 28개가 몸통이 되고, 바닥에 빨리 부딪힐수록 더 납작하게 눌렸다가 부푼다. pose 가 제자리 모양이 지금 점들에 가장 잘 겹치는 회전각을 구하면, 점마다 그 각의 0.85배만 돌린 제자리 쪽으로 0.45 만큼 당겨 바로 서려는 힘을 만들고 줄어든 넓이 비율 × 0.8 × 2 만큼 바깥으로 민다. 첫 충돌 7.42·9.35·11.41·13.09px/틱 에서 가장 낮아진 높이는 처음의 0.706·0.623·0.599·0.594배였고, 0.58배 아래로는 바닥 쪽을 기준으로 늘려 맞춘다.

부딪힌 속도만큼 젤리처럼 눌렸다 부푸는 글자
새 창에서 열기

03 · 부딪힌 속도만큼 젤리처럼 눌렸다 부푸는 글자 — 측정 누적면적 22.985% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPRING_STIFFNESSPRESSURESQUASH_LIMIT
    function pose(j) {
        let cx = 0;
        let cy = 0;
        for (let k = 0; k < HULL_POINTS; k += 1) {
            cx += j.px[k];
            cy += j.py[k];
        }
        cx /= HULL_POINTS;
        cy /= HULL_POINTS;
        let num = 0;
        let den = 0;
        for (let k = 0; k < HULL_POINTS; k += 1) {
            const dx = j.px[k] - cx;
            const dy = j.py[k] - cy;
            num += j.qx[k] * dy - j.qy[k] * dx;
            den += j.qx[k] * dx + j.qy[k] * dy;
        }
        j.cx = cx;
        j.cy = cy;
        j.theta = Math.atan2(num, den);
    function stepJelly(j) {
        pose(j);
        // 제자리 모양은 지금 기운 각도보다 조금 덜 기운 자세로 놓는다 — 그 차가 글자를 바로 세우는 힘이 된다
        const c = Math.cos(j.theta * (1 - UPRIGHT_PULL));
        const s = Math.sin(j.theta * (1 - UPRIGHT_PULL));
        const area = polyArea(j.px, j.py);
        const deficit = (j.area0 - area) / j.area0;
        for (let k = 0; k < HULL_POINTS; k += 1) {
            // 모양 스프링 — 지금 자세로 옮긴 제자리 쪽으로
            const gx = j.cx + j.qx[k] * c - j.qy[k] * s;
            const gy = j.cy + j.qx[k] * s + j.qy[k] * c;
            j.vx[k] += (gx - j.px[k]) * SPRING_STIFFNESS;
            j.vy[k] += (gy - j.py[k]) * SPRING_STIFFNESS + JELLY_GRAVITY;
        // 납작함 상한 — 높이가 하한 아래면 바닥 쪽(maxY)을 기준으로 위로 늘려 하한에 맞춘다
        const minH = (1 - SQUASH_LIMIT) * j.ext[1];
        const hgt = maxY - minY;
        if (hgt < minH && hgt > 0.0001) {
            const k2 = minH / hgt;
            for (let k = 0; k < HULL_POINTS; k += 1) {
                j.py[k] = maxY - (maxY - j.py[k]) * k2;
                if (j.vy[k] > 0) {
                    j.vy[k] *= 0.5;
                }
            }
        }

04포인터를 피했다가 스프링으로 제자리에 돌아오는 글자

「안녕하세요」 다섯 글자가 포인터 반경 90px 안에서 밀려났다가 0.05 세기의 스프링으로 제 자리에 돌아오고, 제자리에서 멀어질수록 그림자가 멀어지고 글자가 최대 1.12배까지 커진다. 미는 힘은 44 × (1/거리 − 1/90) 이라 반경 끝에서 0 이 되고, 거리를 12px 아래로는 보지 않아 포인터가 글자 한가운데를 지나도 힘이 한없이 커지지 않는다. 8자로 도는 가상 포인터에 가운데 글자는 63.1px, 양 끝 글자는 8.9px 밀렸고 포인터를 치우자 624ms 에 0.5px 안으로 돌아와 그대로 머물렀다.

포인터를 피했다가 스프링으로 제자리에 돌아오는 글자
새 창에서 열기

04 · 포인터를 피했다가 스프링으로 제자리에 돌아오는 글자 — 측정 누적면적 52.056% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REPEL_RADIUSRETURN_SPRINGDAMPING
    function step() {
        for (let i = 0; i < movers.length; i += 1) {
            const m = movers[i];
            let ax = -(m.x - m.g.hx) * RETURN_SPRING;
            let ay = -(m.y - m.g.hy) * RETURN_SPRING;
            if (pointer.on) {
                const dx = m.x - pointer.x;
                const dy = m.y - pointer.y;
                const d = Math.max(Math.hypot(dx, dy), MIN_DIST);
                if (d < REPEL_RADIUS) {
                    const f = REPEL_FORCE * (1 / d - 1 / REPEL_RADIUS);
                    ax += (dx / d) * f;
                    ay += (dy / d) * f;
                }
            }
            m.vx = (m.vx + ax) * (1 - DAMPING);
            m.vy = (m.vy + ay) * (1 - DAMPING);
            m.x += m.vx;
            m.y += m.vy;
            // 기울기 — 옆으로 움직이는 속도를 따라 기울었다가 멈추면 바로 선다
            m.a = lerp(m.a, clamp(m.vx * 0.07, -0.5, 0.5), 0.2);
        }
    }

05물에 떨어져 잠긴 넓이만큼 떠오르는 글자

「여름 물놀이」 다섯 글자가 8px 간격 기둥 61개로 나눈 수면에 떨어져 물결을 일으키고, 글자 상자 안 표본 20점 가운데 수면 아래에 든 점의 비율만큼 부력을 받아 떠오른다. 기둥은 높이에 비례해 되돌아가는 힘을 받고 이웃과의 높이 차에 0.24 를 곱해 양옆에 넘기기를 한 틱에 두 번 해, 한 기둥의 출렁임이 한 틱에 두 칸까지 번진다. 글자는 736~928ms 사이에 13.16~17.34px/틱 으로 물에 들었고, 수면은 가장 깊은 곳이 23.9px 눌렸으며 한때 물방울이 78개 떠 있었다.

물에 떨어져 잠긴 넓이만큼 떠오르는 글자
새 창에서 열기

05 · 물에 떨어져 잠긴 넓이만큼 떠오르는 글자 — 측정 누적면적 27.119% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WATER_DENSITYWAVE_SPREADSPLASH_FORCE
    function stepWater() {
        for (let k = 0; k < COLS; k += 1) {
            colV[k] += -WAVE_K * colH[k] - WAVE_DAMP * colV[k];
            colH[k] += colV[k];
        }
        // 이웃 확산 — 두 번 돌려 물결이 한 틱에 두 기둥까지 번진다
        for (let pass = 0; pass < 2; pass += 1) {
            const lD = [];
            const rD = [];
            for (let k = 0; k < COLS; k += 1) {
                lD.push(k > 0 ? WAVE_SPREAD * (colH[k] - colH[k - 1]) : 0);
                rD.push(k < COLS - 1 ? WAVE_SPREAD * (colH[k] - colH[k + 1]) : 0);
            }
            for (let k = 0; k < COLS; k += 1) {
                if (k > 0) {
                    colV[k - 1] += lD[k];
                    colH[k - 1] += lD[k];
                }
                if (k < COLS - 1) {
                    colV[k + 1] += rD[k];
                    colH[k + 1] += rD[k];
                }
            }
        }
    }

06세게 부딪히면 조각나 흩어지는 글자

「산산조각」 네 글자를 높은 곳과 바닥 60px 위에서 번갈아 떨어뜨려, 첫 충돌이 18.5·19.5px/틱 인 첫 「산」 과 「조」 는 조각나고 8.0px/틱 인 나머지 둘은 문턱 9 아래라 튀기만 한다. 조각은 초기화 때 글자를 화면 밖 캔버스에 그려 화소마다 가장 가까운 씨앗 점 12개 중 하나의 번호를 붙여 나눈 면이고, 깨질 때 조각 가운데가 글자 가운데에서 비켜난 방향으로 충돌 속도에 비례해 날아간다. 씨앗은 12개지만 초기화 때 나눠 둔 조각은 첫 「산」 이 10개, 「각」 이 11개였고 가장 빠른 조각 회전은 상한 0.32 안의 0.299rad/틱 이었다.

세게 부딪히면 조각나 흩어지는 글자
새 창에서 열기

06 · 세게 부딪히면 조각나 흩어지는 글자 — 측정 누적면적 21.919% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SHARD_COUNTIMPACT_SPEEDSHARD_SPIN
        const src = octx.getImageData(0, 0, side, side);
        const rng = makeRng(seed);
        const seeds = [];
        for (let k = 0; k < SHARD_COUNT; k += 1) {
            seeds.push([side / 2 + (rng() - 0.5) * g.w * SHARD_SCALE, side / 2 + (rng() - 0.5) * g.h * SHARD_SCALE]);
        }
        const label = new Int16Array(side * side).fill(-1);
        const box = [];
        for (let k = 0; k < SHARD_COUNT; k += 1) {
            box.push([side, side, -1, -1]);
        }
        for (let y = 0; y < side; y += 1) {
            for (let x = 0; x < side; x += 1) {
                const p = y * side + x;
                if (src.data[p * 4 + 3] < 8) {
                    continue;
                }
                let best = 0;
                let bd = 1e12;
                for (let k = 0; k < SHARD_COUNT; k += 1) {
                    const dx = x - seeds[k][0];
                    const dy = y - seeds[k][1];
                    const d = dx * dx + dy * dy;
                    if (d < bd) {
                        bd = d;
                        best = k;
                    }
                }
                label[p] = best;

07기울어지는 상자 안에서 한쪽으로 몰리는 글자

「장바구니 담기」 여섯 칸이 최대 28° 로 좌우를 오가는 상자 안에서 낮은 쪽으로 미끄러져 몰렸다가, 반대로 기울면 다시 건너간다. 상자를 돌리는 대신 상자 안에서 본 중력을 (0.42 sin θ, 0.42 cos θ) 로 돌리고, 겹친 두 칸은 덜 겹친 축으로 깊이의 0.5배를 반씩 나눠 4번 되풀이해 밀어내며, 마찰은 되풀이가 다 끝난 뒤 틱마다 한 번만 곱한다. 칸들의 가로 평균은 기울기 25.9° 에서 69.4px, −25.9° 에서 −56.7px 까지 갔고 첫 벽 닿음은 960ms, 끝에 남은 겹침은 최대 0.28px 였다.

기울어지는 상자 안에서 한쪽으로 몰리는 글자
새 창에서 열기

07 · 기울어지는 상자 안에서 한쪽으로 몰리는 글자 — 측정 누적면적 26.91% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TILT_ANGLE_MAXCOLLISION_PUSHSLIDE_FRICTION
    function step() {
        tilt = tiltNow();
        const th = tilt * DEG;
        // 상자 안에서 본 중력 — 세계의 아래 방향을 상자 각도만큼 거꾸로 돌린다
        const gx = TILE_GRAVITY * Math.sin(th);
        const gy = TILE_GRAVITY * Math.cos(th);
        for (let i = 0; i < tiles.length; i += 1) {
            const t = tiles[i];
            t.floor = false;
            t.wall = false;
            t.vx += gx;
            t.vy += gy;
            t.x += t.vx;
            t.y += t.vy;
        }
        for (let it = 0; it < PUSH_ITERATIONS; it += 1) {
            for (let i = 0; i < tiles.length; i += 1) {
                for (let j = i + 1; j < tiles.length; j += 1) {
                    pushApart(tiles[i], tiles[j]);
                }
            }
            for (let i = 0; i < tiles.length; i += 1) {
                keepInBox(tiles[i]);
            }
        }
        // 마찰 — 밀어내기를 몇 번 되풀이했든 틱마다 한 번만. 바닥·아래 칸에 얹혔으면 옆 속도를, 벽·옆 칸에 붙었으면 세로 속도를 덜어 낸다
        for (let i = 0; i < tiles.length; i += 1) {
            const t = tiles[i];
            if (t.floor) {
                t.vx *= 1 - SLIDE_FRICTION;
            }
            if (t.wall) {
                t.vy *= 1 - SLIDE_FRICTION;
            }
        }
    }

08바람에 날려 나뭇잎처럼 흔들리며 내려앉는 글자

「가을 바람」 네 글자가 높이마다 세기가 다른 바람에 밀리며 나뭇잎처럼 좌우로 기울었다 미끄러지기를 되풀이하다 바닥에 내려앉는다. 바람은 높이 × 0.011 과 시간 × 0.0006 을 값 노이즈에 넣어 얻고, 저항은 속력 × 속도 × 0.02 라 빠를수록 세지며, 기울기는 옆 속도의 0.5배를 목표 각으로 삼되 각속도에 관성이 있어 늦게 따라가다 지나쳤다 돌아온다. 가장 빠른 낙하 3.16px/틱 은 바람이 없을 때의 끝속도 √(0.2/0.02) 와 같았고 기울기는 최대 69.2°, 첫 글자의 옆 방향은 10번 뒤집혔다.

바람에 날려 나뭇잎처럼 흔들리며 내려앉는 글자
새 창에서 열기

08 · 바람에 날려 나뭇잎처럼 흔들리며 내려앉는 글자 — 측정 누적면적 22.611% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WIND_STRENGTHDRAG_COEFFFLUTTER_RATE
    // 값 노이즈 — 이웃 두 격자 값을 부드러운 곡선으로 잇는다
    function valueNoise(u) {
        const i = Math.floor(u);
        return lerp(latticeValue(i), latticeValue(i + 1), smooth(u - i));
    }
    function windAt(y, t) {
        return (valueNoise(y * WIND_SCALE_Y + t * WIND_SPEED) * 2 - 1 + WIND_BIAS) * WIND_STRENGTH;
    }
        const speed = Math.hypot(l.vx, l.vy);
        const ax = windAt(l.y, age) - DRAG_COEFF * speed * l.vx - Math.sin(l.a) * GLIDE * LEAF_GRAVITY;
        const ay = LEAF_GRAVITY - DRAG_COEFF * speed * l.vy;
        l.vx += ax;
        l.vy += ay;
        // 기울기 — 옆 속도에 비례한 목표 각도를 늦게 따라간다(각속도에 관성이 있어 지나쳤다 돌아온다)
        l.w += (l.vx * FLUTTER_RATE - l.a) * TILT_FOLLOW - l.w * TILT_DAMPING;
        l.a += l.w;
        l.x += l.vx;
        l.y += l.vy;

09무너져 쌓인 뒤 다시 한 줄로 정렬되는 히어로 헤드라인

「다시 한 줄로」 헤드라인이 208ms 에 무너져 01 과 같은 상자 계산으로 바닥에 흩어졌다가, 0.9초 뒤부터 글자 번호 × 45ms 씩 늦게 읽는 순서대로 제자리에 돌아온다. 복귀는 감쇠를 2√k 로 맞춘 스프링(k 0.03)이라 제자리를 위로 지나친 거리가 0px 이었고, 각도는 가장 가까운 한 바퀴 자리로 맞춰 여러 바퀴 돈 글자도 그만큼 되감지 않는다. 무너졌을 때 줄 오차는 최대 110.6px, 첫 글자 복귀는 1,120ms 에 시작했고 다섯 글자 평균 오차가 0.5px 안에 들어 머문 시각은 1,984ms 였다.

무너져 쌓인 뒤 다시 한 줄로 정렬되는 히어로 헤드라인
새 창에서 열기

09 · 무너져 쌓인 뒤 다시 한 줄로 정렬되는 히어로 헤드라인 — 측정 누적면적 26.865% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ASSEMBLE_DELAYSETTLE_SPRINGLOOP_SECONDS
    function collapse() {
        const rng = makeRng(4729);
        fallAt = age;
        for (let i = 0; i < bodies.length; i += 1) {
            const b = bodies[i];
            b.free = true;
            b.vx = (rng() - 0.5) * 3;
            b.vy = -2 - rng() * 3;
            b.w = (rng() - 0.5) * 0.24;
        }
    }
    // 임계 감쇠 스프링 한 칸 — 가속도 = -세기 x 벗어난 거리 - 2 x 제곱근(세기) x 속도
    function springTo(v, x, target) {
        return v - SETTLE_SPRING * (x - target) - 2 * Math.sqrt(SETTLE_SPRING) * v;
    }

글자끼리 파고들 때·탭을 오래 비웠다 돌아올 때·글자 폭이 제각각일 때 — 쌓인 글자 무더기가 깨지는 경우

아래 숫자는 헤드리스 Chrome 에서 소스의 함수를 직접 불러 얻었고, 돌려 보지 않은 경우는 「~로 보인다」 로 적었다.

꼭짓점만 살피는 판정과 십자로 엇갈린 두 상자

01 의 접점 찾기는 상자의 꼭짓점이 다른 상자 안에 들어갔는지만 본다. 두 상자가 십자 모양으로 겹쳐 서로의 꼭짓점이 상대 안에 하나도 들지 않으면 접점을 찾지 못한다. 배치는 글자 가로 자리를 가운데로 0.28배 모으고 기울기를 ±0.4rad 안에 두어, 글자들이 꼭짓점부터 서로의 위에 얹히게 떨어뜨린다.

한 틱을 두 번으로 나누는 이유

중력을 1.1 로 올리면 떨어지는 글자의 세로 속도가 한 틱에 20px 를 넘는다. 이 속도로 한 번에 옮기면 상자 높이 절반 가까이를 한 틱에 파고들어, 밀어내는 쪽이 반대 방향이 되어 글자가 튕겨 나갈 것으로 보인다. SUBSTEPS 2 가 한 틱을 둘로 쪼개 한 번에 파고드는 깊이를 줄인다.

중력을 올리면 오래가는 잔떨림

중력 0.45 에서 2,688ms 에 멈추던 무더기는 1.1 에서 9,328ms 까지 멈추지 않았다. 바닥에 닿는 시각은 768ms 에서 576ms 로 빨라졌지만, 위에 얹힌 글자가 더 큰 무게로 아래 글자를 누르며 작은 되튐을 오래 주고받은 결과로 보인다. 무더기 높이는 77.1px 에서 73.7px 로 조금 낮아졌다.

탭을 오래 비웠다 돌아왔을 때

다른 탭을 보다 돌아오면 그사이 쌓인 시간이 수십 초라도 한 프레임에 100ms, 곧 6스텝만 따라잡는다. 그래서 글자가 바닥으로 순간이동하지 않고 떠나기 전 자세에서 이어서 떨어진다.

폭이 제각각인 영문 상자

한국어와 일본어 무대는 상자 폭이 모두 같은데 영어 「STACK IT UP」 은 I 19.2px 와 A 41.3px 가 섞인다. 좁은 상자는 가볍고 바닥 면이 짧아 무더기 위에서 넘어지기 쉬운 것으로 보이고, 크기의 0.34배를 최소 폭으로 둔 것은 이보다 더 좁은 글자가 막대처럼 얇아지지 않게 하려는 장치로 보인다.

마디만 늘린 줄은 고무줄이 된다

02 의 마디를 6 에서 14 로 늘리고 되맞춤을 8번 그대로 두면 줄이 2.57% 가 아니라 15.77% 까지 늘어난다. 되맞춤 한 번이 이웃 두 점만 고치므로, 마디가 많아지면 맨 위 걸이의 고정이 끝 점까지 전해지기 전에 틱이 끝나는 것으로 보인다. 공기 감쇠를 0 으로 두면 이 적분은 에너지를 잃지 않아 흔들림이 잦아들지 않을 것으로 보인다.

외곽 점의 평균을 원점에 두지 않은 젤리

03 은 외곽 점 28개의 평균을 원점으로 옮긴 뒤 제자리 모양으로 쓴다. 평균이 0 이 아니면 모양 스프링이 몸통 전체를 한쪽으로 끄는 상시 힘이 되어, 만드는 동안에는 떨어지던 젤리가 더 빨라지지 않고 옆으로 흘러갔다. UPRIGHT_PULL 을 0 으로 두면 바닥에 닿는 면이 비대칭인 글자가 굴러 옆으로 거의 눕는 것도 렌더에서 확인했다.

스프링이 무르면 속도 차가 사라진다

모양 스프링을 0.45 에서 0.16 으로 낮추면 6.04px/틱 으로 부딪혀도 높이가 0.603배, 13.2px/틱 에서도 0.584배로 두 경우 모두 0.58 하한에 붙어 버린다. 눌림이 속도를 말해 주려면 하한에 닿기 전에 스프링이 버텨야 한다.

획이 없는 자리에 떨어진 씨앗

06 의 씨앗 12개는 글자 상자 안에 무작위로 뿌리므로, 획이 지나지 않는 빈 자리에 떨어진 씨앗은 화소를 하나도 받지 못해 조각이 되지 않는다. 조각끼리는 부딪히지 않고 겹쳐 쌓이며, 나누기는 초기화 때 한 번이라 글자 크기를 바꾸면 조각을 다시 만들어야 한다.

되풀이 안에 넣은 마찰

07 을 만들 때 처음에는 마찰을 밀어내기 되풀이 안에 두었다가 한 틱에 4번 곱해지는 바람에, 상자를 크게 기울여도 칸이 거의 미끄러지지 않았다. 마찰을 되풀이 밖으로 옮겨 틱마다 한 번만 곱하자 칸이 기울기를 따라 건너가기 시작했다. 상자가 빨리 돌아도 칸에는 원심력이 없고 중력 방향만 바뀐다는 점도 이 근사의 한계다.

낮춘 각속도 감쇠와 뒤집히는 낙엽

08 의 각속도 감쇠를 0.06 에서 0.03 으로 낮추면 글자가 거꾸로 뒤집힐 만큼 돌아, 지금 판 실측으로 최대 기울기가 146.3° 였다. 저항이 속도 제곱이라 DRAG_COEFF 를 반으로 줄이면 끝속도는 √2 배로 빨라질 것으로 보인다.

표본 20점이 만드는 0.05 계단

05 의 물 밀도 1.7 이면 떠 있는 글자의 이론상 잠김은 1/1.7 = 0.588 이다. 하지만 잠김을 표본 20점으로 세므로 비율이 0.05 단위로만 바뀌어, 오래 둔 뒤 다섯 글자는 0.50·0.75·0.75·0.55·0.50 을 보였고 계단 사이에서 미세하게 오르내리는 것으로 보인다. 900틱(14,400ms) 안에서 다섯 글자가 모두 물속에 들고 세로 속도가 0.15 아래인 순간은 마지막 틱에야 처음 나왔을 만큼 물결과 함께 오래 출렁인다.

겹쳐 지나가는 복귀 글자

09 에서 복귀를 시작한 글자는 충돌에서 빠지므로 아직 바닥에 있는 글자를 뚫고 지나간다. 각도를 가장 가까운 한 바퀴로 맞추는 줄이 없으면 무너지며 여러 바퀴 돈 글자가 그만큼 되감기고, 감쇠를 2√k 보다 작게 두면 제자리를 지나쳐 출렁일 것으로 보인다.

글자만 바꿔 내 제목으로 돌려 볼 수 있게 아홉 장면의 vanilla 파일과 같은 계산을 옮긴 리액트 판을 한 압축 파일에 담았고, 여는 암호는 f3yv8pu7 이다.

중력·되튐·스프링 세기를 바꾸면 글자 무더기는 어떻게 달라지는가

이름 값 느낌
중력 키우기 01 GRAVITY 0.45 → 1.1 첫 글자가 768ms 대신 576ms 에 바닥에 닿아 더 세게 부딪히고, 가장 빠른 회전이 16.7° 에서 21.6°/틱 으로 커진다. 쌓인 글자의 잔떨림이 길어져 무더기가 멈추기까지 2,688ms 가 아니라 9,328ms 가 걸린다
줄 마디 늘리기 02 ROPE_SEGMENTS 6 → 14 되맞춤이 8번 그대로라 줄 늘어남이 2.57% 에서 15.77% 로 커져 줄이 처진다. 마지막 글자의 가장 큰 기울기도 33.6° 에서 39.1° 로 커진다
피하는 반경 넓히기 04 REPEL_RADIUS 90 → 160 양 끝 글자도 8.9px 대신 12.5px 비켜나고, 가운데 글자 40px 위에 포인터를 세워 두면 이웃 글자의 밀림이 0.7px 에서 4.6px 로 커진다. 포인터를 치운 뒤 한 번 빠져나갔다 들어와 끝까지 자리에 머무는 시각은 624ms 에서 736ms 로 늦어진다

되튐 쪽은 01 의 RESTITUTION 0.32 가 닿기 직전 속도에 곱하는 비율이라, 올리면 무더기가 쌓이기 전에 더 오래 통통거릴 것으로 보인다. 다만 −1.2px/틱 보다 느린 접촉은 되튐 목표를 0 으로 두므로, 쌓인 뒤의 느린 접촉에는 되튐이 붙지 않는다. 스프링 세기는 03 과 09 가 서로 다른 뜻으로 쓴다. 03 의 0.45 는 모양을 지키는 세기라 낮출수록 눌림이 하한에 붙고, 09 의 0.03 은 제자리로 끄는 세기다. 소스가 감쇠를 2 * Math.sqrt(SETTLE_SPRING) 로 함께 계산하므로, 올리면 감쇠도 따라 커지면서 줄 정렬이 빨라질 것으로 보인다.

떨어지는 글자도 읽혀야 한다 — 화면 낭독기용 원문과 움직임 줄이기 접근성

원문은 칸마다 <p class="pc__sr" data-role="word"> 한 줄에만 있다. 이 문단은 clip-path: inset(50%) 로 1px 만 남겨 화면에서 숨기고 화면 낭독기만 읽으며, 스크립트가 같은 글자를 읽어 캔버스에 낱글자로 그리므로 흩어진 낱글자는 접근성 트리에 들어가지 않는다. 캔버스의 aria-label 은 장면과 조작만 설명하고 원문을 되풀이하지 않아, 「차곡차곡 쌓기」 가 두 번 읽히지 않는다.

캔버스는 tabindex="0" 이라 키보드로 포커스가 들어가고, 포커스 테두리는 캔버스와 단추 모두 focus-visible 로 보인다. 상태 줄은 aria-live="off" 로 시작해 시연 문구를 읽어 대지 않다가, 첫 입력에서 polite 로 바뀌고 문구가 달라질 때만 글자를 고친다. 휴대폰에서는 touch-action: pan-y 라 캔버스 위에서도 세로 스크롤이 막히지 않는다.

prefers-reduced-motion 에서 reduce 를 고른 화면은 rAF 루프를 돌리지 않고, settle() 이 360스텝(5,760ms 분량)을 그리지 않은 채 한 번에 계산해 내려앉은 결과만 그린다. 누르거나 키를 누른 뒤에도 after() 가 같은 계산을 다시 해 그 입력이 낳은 마지막 배치를 바로 보여 주고, 타이머(setTimeout)는 아홉 소스 어디에도 없다. 아홉 장면 모두 matchMedia 에 change 리스너를 달아 두어, 페이지를 연 채 설정을 바꿔도 다음 순간부터 그 방식으로 그린다.

시간에 묶인 연출은 따로 멈춘다. 04 의 가상 포인터는 시연일 때만 돌아 reduce 에서는 나오지 않고, 07 의 자동 기울기는 16.8° 에 멈춰 선 채로 남으며, 09 가 3.4초마다 스스로 다시 무너지는 반복도 reduce 에서는 돌지 않는다. 02·03·05·07 의 상태 줄은 이때 잦아든 결과를 알리는 문구를 보인다.

물리로 움직이는 글자를 헤드라인에 얹기 전에 자주 묻는 것

무대 글자를 내 문구로 바꾸려면 어디를 고치나?

칸마다 숨겨 둔 <p data-role="word"> 의 글자만 바꾸면 된다. 스크립트가 시작할 때 이 글자를 읽어 layoutWord 가 다시 재므로, 문구가 길면 글자 크기가 같은 비율로 줄어 무대 폭 안에 들어온다. 화면 낭독기도 바꾼 문구를 그대로 읽는다.

글자 애니메이션 효과에 물리 엔진 라이브러리를 꼭 붙여야 하나?

이 편의 아홉 장면은 라이브러리·물리 엔진·WebGL·CDN 을 하나도 쓰지 않고, 한 폴더의 index.html 하나에 계산이 다 들어 있다. 대신 01 은 꼭짓점 접점만 보고, 03 은 글자끼리 부딪히지 않으며, 07 은 칸이 돌지 않는 근사다. 글자 수십 개가 서로 맞물려 굴러야 하는 장면이라면 그때 엔진을 고르는 편이 낫고, 도형 위주의 물리 장면은 물리 시뮬레이션 만들기 9종 에서 이어 볼 수 있다.

휴대폰에서 무겁지 않나?

캔버스 화소는 기기 화소 비율을 2배까지만 따라가고, 한 틱의 계산은 글자 몇 개에 접점·외곽 점·표본 몇십 개 수준이다. 계산이 많은 쪽은 06 이 시작할 때 글자 화소마다 씨앗 12개와의 거리를 재는 한 번의 나누기와 03 의 외곽 광선 훑기인데, 둘 다 초기화 때 한 번뿐이다. 기기별 프레임 처리 시간은 재지 않았으니 낡은 휴대폰이 대상이라면 직접 띄워 보는 쪽이 확실하다. 글자를 화소 입자로 뜯어 흩는 쪽이 궁금하면 글자를 캔버스 픽셀로 뜯어 다시 짓는 9종 을 함께 보면 된다.

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

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