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

마블링 효과 css 9가지 — 잉크 확산·빗 결·돌 결의 흐르는 배경

물 위의 잉크 한 방울은 표면장력이 붙잡아 매는 얇은 막이다 — 04번은 그 막을 한 방향으로 밀어 물결 띠로 굳히고, 05번은 어긋난 두 빗질로 결을 깃털로 갈라 놓으며, 08번은 층마다 밀도 값을 눌러 돌의 띠로 새긴다.

잉크를 떨어뜨리고 빗으로 긁고 돌을 자르는 아홉 장면

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

마블링 효과 css 9가지 — 잉크 확산·빗 결·돌 결로 그리는 흐르는 배경

순서는 물감이 겪는 일을 따라간다. 01~03 은 물 위에 놓인 잉크가 번지고 갈라지고 접히는 단계, 04·05 는 갈퀴와 빗으로 그 막을 긁는 단계, 06·07 은 방울과 점이 새로 떨어지는 순간, 08·09 는 층으로 굳은 돌의 단면과 고리로 퍼지는 먹이다. 아홉 장면은 모두 <canvas> 한 장에 그려지고, 스타일시트는 무대의 비율과 모서리, 버튼과 포커스 테두리만 정한다. 같은 계산이라도 상수 하나에 마블링 패턴의 인상이 크게 달라지는데, 그 숫자들은 아래 값 절에 따로 모았다.

2초 캡처 스물네 장으로 센 움직임

장면마다 2초에 걸쳐 스물네 장을 캡처하고 바로 앞 장과 달라진 픽셀을 셌다. 그래서 오른쪽 열은 비교 23번 가운데 화면이 바뀐 횟수다.

장면 누적면적 강도 평균 움직인 프레임
01 방울 피짐 71.4% 36.3 16/23
02 갈라지는 부분 결 30.3% 29.0 23/23
03 부채 주름 72.9% 26.3 23/23
04 갈퀴 물결 띠 71.1% 20.5 23/23
05 엇갈린 빗 깃털 73.8% 33.6 16/23
06 왕관 튐 14.2% 36.4 23/23
07 흩뿌린 점 68.5% 26.9 16/23
08 마노 층 60.4% 31.9 23/23
09 먹 고리 72.0% 56.9 23/23

누적면적이 30% 를 넘는 장면이 여덟이고 10% 아래로 내려간 장면은 없다. 가장 작은 06 의 14.2% 는 화면에서 바뀌는 것이 충격 링 선과 작은 알갱이뿐인 벡터 장면이라서 나온 크기다. 01·05·07 이 16/23 에 머문 것은 시연 일정 사이에 무늬가 멈춰 선 캡처가 끼어 있다는 뜻이고, 그 셋도 누적면적은 68% 를 넘는다.

이 무늬를 붙일 만한 자리

방울 피짐(01)과 부채 주름(03)은 화면 가운데에 시선이 모이는 무늬라 첫 화면이나 초대장 머리처럼 글자가 적은 자리에 맞는다. 갈퀴 물결(04)은 가로로 긴 띠라 푸터나 배너 아래 장식 띠로 잘라 쓰기 쉽고, 엇갈린 빗 깃털(05)은 결이 고르게 깔려 구독 신청 칸 뒤에 둘 만하다. 다만 그 위에 입력란을 얹을 때는 무늬 위에 바로 글자를 놓지 말고 불투명한 판을 한 겹 깔아야 글자 대비가 무늬 색에 흔들리지 않는다.

왕관 튐(06)은 누르는 순간에 반응하는 장면이라 클릭 피드백이나 히어로가 들어오는 한 박자에 어울리고, 흩뿌린 점(07)은 문구나 라벨 바탕처럼 작게 반복되는 면에 맞는다. 마노 층(08)은 한 장면만 멈춰 세워도 돌 단면처럼 읽혀서 명함이나 주얼리 배너에 정지 화면으로 써도 되고, 고리가 천천히 퍼지는 먹 흐름(09)은 브랜드 소개처럼 차분해야 하는 마블링 배경에 가깝다.

마블링 기법은 코드로 옮기면 무엇부터 계산하는가?

먼저 계산하는 것은 "이 픽셀에 지금 보이는 잉크는 원래 어디에 있던 것인가" 다. 01 의 inkAt 은 방울을 새것부터 거꾸로 되짚어 화면 점의 옛 자리를 찾고, 05 의 sourceY 는 두 번의 빗질을 y 에서 되돌려 처음 깔린 띠의 높이를 찾으며, 08 의 warp 은 좌표 자체를 노이즈로 비틀어 층을 찾을 주소를 바꾼다. 잉크 덩어리를 밀어 옮기는 대신 픽셀마다 출발점을 묻기 때문에, 이 세 장면은 무늬가 아무리 얽혀도 빈 구멍이나 두 번 칠한 자리 없이 화면 전체가 한 번에 채워진다.

작은 버퍼에 칠하고 크게 늘려 붙인다

01~05·07~09 여덟 장면은 화면보다 작은 필드(01 160×100, 02 96×60, 03·07·09 192×120, 04·05·08 240×150)에 픽셀 색을 계산해 createImageData 버퍼에 쓰고, putImageData 로 숨은 캔버스에 올린 다음 drawImage 로 480×300 무대에 늘려 붙인다. 늘릴 때 imageSmoothingQuality 를 'high' 로 두어 칸 경계를 부드럽게 한다. getImageData 는 아홉 파일 어디에도 없어서 화면을 다시 읽는 일 없이 쓰기만 하고, 06 하나는 이 버퍼를 쓰지 않고 타원과 선을 직접 그린다.

종이 결은 한 번만 굽고 시간은 16ms 로 자른다

바탕의 종이 결(paperGrain, 3옥타브 값 노이즈)은 시간이 흘러도 변하지 않아 initField 에서 grainF 배열에 한 번 구워 두고 픽셀마다 꺼내 쓴다. 첫 판은 이걸 프레임마다 다시 계산하다가 캡처에서 움직인 프레임이 비어 지금 구조로 바꿨다. 시간은 requestAnimationFrame 이 준 시각을 쌓아 16ms(FIXED_DT) 단위로 끊어 쓰는데, 한 번에 쌓는 양은 100ms, 도는 스텝은 6번이 상한이다. 캔버스 내부 해상도는 480×300 에 화면 배율을 곱하되 Math.min(window.devicePixelRatio || 1, 2) 로 2배에서 멈춘다.

난수 대신 정수 해시라서 무늬가 매번 같다

Math.random 은 한 번도 부르지 않는다. Math.imul 로 섞는 정수 해시 hash1·hash2 와 그 위의 값 노이즈 vnoise2, 옥타브를 겹치는 fbm2 가 무늬의 불규칙함을 모두 맡고, 호출마다 시드 숫자를 따로 넘긴다(02 의 이득 지도 211, 07 의 점 자리 811, 08 의 비틂 101·313, 09 의 반경 흔들림 401). 그래서 새로고침해도 같은 자리에 같은 결이 다시 선다.

아래 항목의 코드는 장면별 index.html 스크립트에서 중간 줄을 빼지 않고 한 구간을 그대로 떠 온 것이다. 함수 이름이 소스와 같아서, 받은 파일에서 그 이름을 찾으면 앞뒤 맥락까지 바로 이어진다.

01떨어진 잉크가 고리를 만들며 바깥으로 번지는 방울 피짐

반경 R 인 방울이 떨어지면 바깥 점은 중심에서 √(1 + R²/d²) 배 멀어지는데, inkAt 은 그 역인 √(1 − R²/d²) 배를 새 방울부터 차례로 곱해 화면 점이 방울들 이전에 있던 자리를 되짚는다. 이 변위는 넓이를 보존해서, BLOOM_RADIUS 96 방울 두 개를 같은 자리에 놓고 중심에서 108.2px 인 점을 되짚으면 첫 방울 안 반경 비율 0.521(50/96)이 나온다 — 50px 에 있던 잉크가 정확히 108.2px 까지 밀려났다는 뜻이다. 방울 안은 R/RING_N = 19.2px 폭의 띠 다섯 겹으로 칠하고 EDGE_PUSH 0.35 자리에 되밀린 잉크 한 줄을 겹쳐 이중 띠를 만들며, 렌더에서는 누적면적 71.4%·강도 36.3·16/23 프레임이 나왔다.

떨어진 잉크가 고리를 만들며 바깥으로 번지는 방울 피짐
새 창에서 열기

01 · 떨어진 잉크가 고리를 만들며 바깥으로 번지는 방울 피짐 — 측정 누적면적 71.384% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BLOOM_RADIUSRING_NEDGE_PUSH
    function inkAt(x, y, now) {
        let qx = x, qy = y;
        hitI = -1;
        for (let i = blooms.length - 1; i >= 0; i -= 1) {
            const b = blooms[i];
            const R = radiusOf(b, now);
            if (R <= 0) {
                continue;
            }
            const dx = qx - b.x, dy = qy - b.y;
            const d2 = dx * dx + dy * dy;
            if (d2 < R * R) {
                hitI = i;
                hitQ = Math.sqrt(d2) / R;
                return;
            }
            const s = Math.sqrt(1 - (R * R) / d2);
            qx = b.x + dx * s;
            qy = b.y + dy * s;
        }
    }

02밀린 잉크 면이 좁은 틈으로 갈라져 가지치는 부분 결

96×60 격자의 셀마다 압력을 밀도 × (1.15 − 이득) 으로 두고, 이득을 fbm2 3옥타브(SPLIT_DEPTH 3)로 깔아 큰 골짜기 안에 작은 골짜기를 판 뒤, 오른쪽·아래 이웃끼리 압력 차 × DENSITY_DIFF_K 0.16 만큼 잉크를 넘기되 한 번에 셀 밀도의 24% 에서 자른다. 교환만으로는 잉크가 흐린 얼룩으로 고르게 퍼지기 때문에, tradeDens 끝의 반응항 d + EDGE_KEEP·d(1−d)(d−½)·4 가 0 과 1 사이의 값을 양 끝으로 되밀어 골짜기가 비는 자리를 날카로운 틈으로 세운다. 소스 함수를 노드에서 돌리면 틈(밀도 0.1 미만 셀)은 10.6% 에서 160스텝(2.56초) 뒤 22.7% 까지 넓어지고, 렌더 측정은 누적면적 30.3%·강도 29.0·23/23 이다.

밀린 잉크 면이 좁은 틈으로 갈라져 가지치는 부분 결
새 창에서 열기

02 · 밀린 잉크 면이 좁은 틈으로 갈라져 가지치는 부분 결 — 측정 누적면적 30.344% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CELL_NDENSITY_DIFF_KSPLIT_DEPTH
    // 교환 한 번 — 오른쪽·아래 이웃 쌍만 돌아 한 쌍을 두 번 세지 않는다. 골짜기가 비며 틈이 되고,
    // 반응항 d(1−d)(d−½) 이 0 과 1 사이 값을 양 끝으로 되밀어 틈의 가장자리가 흐려지지 않는다
    function tradeDens(dx, dy) {
        const out = new Float32Array(dens);
        for (let y = 0; y < GY; y += 1) {
            for (let x = 0; x < GX; x += 1) {
                const i = y * GX + x;
                if (x + 1 < GX) {
                    trade(out, i, i + 1, dx);
                }
                if (y + 1 < GY) {
                    trade(out, i, i + GX, dy);
                }
            }
        }
        for (let i = 0; i < out.length; i += 1) {
            const d = out[i];
            out[i] = clamp(d + EDGE_KEEP * d * (1 - d) * (d - 0.5) * 4, 0, 1);
        }
        dens = out;
        trades += 1;
    }

03한 점에서 뻗는 주름이 부채처럼 접히는 부케 결

부채 중심(필드 폭의 0.44, 높이의 0.94)에서 잰 각에 pleatWidth 가 RADIANT_K × PLEAT_FREQ × sin(각) 만큼 폭을 더해 접힘 좌표 (각 × 15 + 폭 × 0.5)/π 를 만들므로, 한 바퀴에 띠 30칸이 서고 화면에 보이는 위쪽 반원에는 약 15칸이 놓인다. 그 좌표의 소수부가 0.44 를 넘는 뒤 절반은 0.12 폭에 걸쳐 어두워지고, FOLD_CONTRAST 0.34 가 밝기를 밝은 면 0.66 과 접힌 면 0.474 두 계단으로 갈라 천을 접어 둔 듯한 면을 만든다. 시연의 첫 회전은 각속도 0.0021rad/ms 에서 스텝마다 0.965 배로 줄어 약 55° 돌고 멈추며, 렌더는 누적면적 72.9%·강도 26.3·23/23 이었다.

한 점에서 뻗는 주름이 부채처럼 접히는 부케 결
새 창에서 열기

03 · 한 점에서 뻗는 주름이 부채처럼 접히는 부케 결 — 측정 누적면적 72.876% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RADIANT_KPLEAT_FREQFOLD_CONTRAST
                    let ang = Math.atan2(dx, -dy) + fanA;
                    const w = pleatWidth(r, ang);
                    // 접힘 좌표 — 주름 폭만큼 각을 눌러 천이 접히는 듯한 밀도를 만든다
                    const fold = (ang * PLEAT_FREQ + w * 0.5) / Math.PI;
                    const fr = fold - Math.floor(fold);
                    // 접힌 면(뒤 절반)은 어둡게 — 경계는 0.12 폭으로 부드럽게 넘어가 계단이 톱니로 깨지지 않는다
                    const shade = 1 - 0.58 * smooth((fr - 0.44) / 0.12) * (1 - smooth((fr - 0.92) / 0.08));
                    const tint = TINTS[Math.abs(Math.floor(fold)) % TINTS.length];
                    const edge = smooth(1 - Math.abs(fr - 0.5) * 2);
                    let k = smooth(clamp(1 - r / (FH * 0.98), 0, 1)) * 0.8;
                    // 결마다 밝기 계단 — shade 와 FOLD_CONTRAST 로 접힌 면 사이 계단
                    const stepB = shade * (1 - FOLD_CONTRAST) + (1 - shade) * FOLD_CONTRAST;
                    k = k * stepB + edge * 0.06;

04한 방향으로 밀린 물결 띠가 비스듬히 굳는 빗질 결

bandAt 은 띠 번호를 (y + 기울기 + 물결 + 잔물결) ÷ BAND_PX 13 으로 매기는데, 기울기 CHEVRON_SKEW 0.34 는 띠를 18.8° 기울이고 물결은 줄마다 sin(y × 0.028) × 1.15 만큼 위상이 어긋난 진폭 13(WAVE_AMP 26 의 절반)의 사인파다. 스텝마다 위상 drift 에 방향 × PHASE_DRIFT 0.022rad 가 쌓여 초당 1.375rad, 물결 마루로 치면 가로 125px/s 로 흘러가고, 방향을 바꾸지 않는 한 같은 결이 한쪽으로 계속 밀려 비스듬한 띠로 굳는다. 시연은 200ms 에 오른쪽, 1900ms 에 왼쪽으로 빗질을 바꾸고, 렌더 수치는 누적면적 71.1%·강도 20.5·23/23 이다.

한 방향으로 밀린 물결 띠가 비스듬히 굳는 빗질 결
새 창에서 열기

04 · 한 방향으로 밀린 물결 띠가 비스듬히 굳는 빗질 결 — 측정 누적면적 71.056% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WAVE_AMPPHASE_DRIFTCHEVRON_SKEW
    function bandAt(x, y) {
        // 어긋난 위상 사인파 합성 — 줄마다 위상이 달라 물결, 가로 기울기가 셰브런을 만든다
        const tilt = CHEVRON_SKEW * (x - W / 2);
        const wave = WAVE_AMP * 0.5 * Math.sin(x * 0.011 + Math.sin(y * 0.028) * 1.15 + drift);
        const fine = WAVE_AMP * 0.22 * Math.sin(x * 0.0031 + y * 0.052 - drift * 0.6);
        return (y + tilt + wave + fine) / BAND_PX;
    }

05반대 방향 두 빗질이 어긋나 만나 깃털로 갈라지는 결

tineShift 는 가장 가까운 이빨 줄까지의 가로 거리 d 에서 끌린 양을 amount × λ/(d + λ) 로 주고, λ 가 14 × 0.55 ÷ FEATHER_SPLIT_K 1.3 = 5.92px 라서 이빨 줄 위 64px 끌림이 반 칸(7px) 옆에서는 29.3px 로 줄어든다. sourceY 는 아래로 끈 첫 빗과 이빨을 COMB_TOOTH_GAP × STAGGER_PHASE = 7px 옮겨 위로 끈 둘째 빗을 y 에서 차례로 되돌려 처음 깔린 16px 가로 띠의 색을 가져오므로, 두 끌림이 엇갈리는 자리마다 띠가 깃털 모양으로 갈라진다. 480px 폭에 이빨 34.3개가 지나가고 첫 빗 64px 는 1138ms, 둘째 빗 51.2px 는 910ms 걸리며, 렌더는 누적면적 73.8%·강도 33.6·16/23 프레임이었다.

반대 방향 두 빗질이 어긋나 만나 깃털로 갈라지는 결
새 창에서 열기

05 · 반대 방향 두 빗질이 어긋나 만나 깃털로 갈라지는 결 — 측정 누적면적 73.832% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

COMB_TOOTH_GAPSTAGGER_PHASEFEATHER_SPLIT_K
    // 빗 이빨 한 줄이 잉크를 끄는 양 — 가장 가까운 이빨 줄까지 가로 거리 d 에서 amount·λ/(d+λ)
    function tineShift(x, offset, amount) {
        const g = COMB_TOOTH_GAP;
        let u = (x - offset) / g;
        u -= Math.round(u);
        const d = Math.abs(u) * g;
        const lam = (g * 0.55) / FEATHER_SPLIT_K;
        return amount * lam / (d + lam);
    }
    // 화면 점이 빗질 전 어느 높이에 있던 잉크인가 — 두 빗 모두 세로로만 끌므로 y 만 되돌리면 된다
    function sourceY(x, y) {
        return y - tineShift(x, 0, pull1) + tineShift(x, COMB_TOOTH_GAP * STAGGER_PHASE, pull2);
    }

06닿는 순간 잉크가 링 위로 솟았다 되떨어지는 왕관 튐

06 은 픽셀 버퍼 없이 타원과 선을 그리는 벡터 장면이고, 방울이 닿으면 CROWN_N 28개 알갱이가 충격 링과 함께 스텝마다 RING_PULSE 1.1px 씩 바깥으로 실려 가면서 높이 쪽으로는 처음 속도 TANGENT_EJECT × 0.6~1.1 로 솟았다가 스텝마다 GRAVITY_FALL 0.36 씩 깎인다. 높이가 0 아래로 내려가는 순간 stepBead 가 그 자리를 낙점으로 굳히는데, 노드로 재 보면 가장 느린 알갱이는 320ms 동안 17.4px 솟아 반경 28px 에, 가장 빠른 알갱이는 592ms 동안 61.2px 솟아 46.7px 에 내려앉는다. 수면의 원은 세로를 TILT 0.46 배로 눌러 비스듬한 시점의 타원으로 그리고 화면 위쪽 알갱이부터 칠해 앞뒤를 맞추며, 렌더는 누적면적 14.2%·강도 36.4·23/23 이었다.

닿는 순간 잉크가 링 위로 솟았다 되떨어지는 왕관 튐
새 창에서 열기

06 · 닿는 순간 잉크가 링 위로 솟았다 되떨어지는 왕관 튐 — 측정 누적면적 14.168% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RING_PULSETANGENT_EJECTGRAVITY_FALL
    // 알갱이 한 스텝 — 링과 함께 바깥으로 실려 가며(RING_PULSE) 위로는 중력에 감속한다. 높이가 0 아래로 내려가면 낙점
    function stepBead(b) {
        if (b.land > 0) {
            b.land = Math.min(1, b.land + 0.025);
            return;
        }
        b.r += RING_PULSE;
        b.vh -= GRAVITY_FALL;
        b.h += b.vh;
        if (b.h <= 0 && b.vh < 0) {
            b.h = 0;
            b.land = 0.01;
        }
    }

07흩뿌린 점이 번지고 늘어나 얼룩지는 점 무늬 결

seedDots 는 5px 격자 안쪽 814자리마다 해시 값 하나를 SCATTER_P 0.55 와 견줘 작을 때만 점을 놓아 435개(53.4%)를 세우고, 위치와 크기(1.3~2.8)와 색도 자리 해시로 정해 다시 불러도 같은 점이 같은 자리에 난다. splatDots 는 점마다 자기 반경 상자만 돌며 (1 − r²)² × 0.55 무게와 그 무게만큼의 색을 함께 쌓아, 겹친 자리가 이웃 점 색의 가중 평균으로 섞이면서 화면 기준 12.5~26.9px 크기로 번진다. 이어 커널의 가로 반경과 x 위치를 최대 STRETCH_ALONG_COMB 1.9 배까지 늘려 점을 빗 방향으로 끌고, 렌더에서는 누적면적 68.5%·강도 26.9·16/23 프레임이 잡혔다.

흩뿌린 점이 번지고 늘어나 얼룩지는 점 무늬 결
새 창에서 열기

07 · 흩뿌린 점이 번지고 늘어나 얼룩지는 점 무늬 결 — 측정 누적면적 68.47% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SCATTER_PBLEND_RADIUSSTRETCH_ALONG_COMB
    function seedDots() {
        dots = [];
        let i = 0;
        for (let gy = 1; gy < FH / 5 - 1; gy += 1) {
            for (let gx = 1; gx < FW / 5 - 1; gx += 1) {
                const h = hash2(gx, gy, 811);
                // 격자 안쪽 칸에 시드 해시 확률로 점을 놓는다 — 자리마다 늘 같다
                if (h < SCATTER_P) {
                    dots.push({
                        x: gx * 5 + (hash2(gx, gy, 977) - 0.5) * 3.6,
                        y: gy * 5 + (hash2(gx, gy, 341) - 0.5) * 3.6,
                        s: 1.3 + hash2(gx, gy, 553) * 1.5,
                        tint: Math.floor(hash2(gx, gy, 761) * TINTS.length) % TINTS.length,
                        k: 0.55 + hash2(gx, gy, 197) * 0.5,
                    });
                    i += 1;
                }
            }
        }
        scatterT = 0;
        stretchT = 0;
    }

08층마다 밀도가 달라 띠 폭이 달라지는 마노 돌 결

warp 은 화면 좌표를 4옥타브 fbm2 두 개로 비틀어 넘기는 도메인 왜곡이라, 그림을 흔드는 대신 층을 찾을 주소가 최대 21.9px 휘고 그 비틂은 스텝마다 DOMAIN_SHIFT 0.0048 씩 흘러간다. layerAt 은 층 좌표 (x + 0.22y) × 0.055 에 저주파 노이즈를 더한 값의 소수부가 1 − AGATE_LAYER_DENSITY 를 넘는 부분만 짙게 칠하므로, 3px 간격 표본에서 짙은 층의 몫이 32.0% 로 밀도 값 0.35 를 거의 그대로 따라간다. 짙은 층 꼭대기(0.82 근처)에 얇은 광택을 얹어 돌 단면처럼 읽히게 했고, 렌더 수치는 누적면적 60.4%·강도 31.9·23/23 이다.

층마다 밀도가 달라 띠 폭이 달라지는 마노 돌 결
새 창에서 열기

08 · 층마다 밀도가 달라 띠 폭이 달라지는 마노 돌 결 — 측정 누적면적 60.386% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

AGATE_LAYER_DENSITYWARP_AMPDOMAIN_SHIFT
    function warp(x, y) {
        // 도메인 왜곡 — 좌표 자체를 노이즈로 비틀어 층 경계를 굽힌다
        const a = (fbm2(x * 0.012 + warpT, y * 0.012, 4, 101) - 0.5) * 2 * WARP_AMP;
        const b = (fbm2(x * 0.012 + 519.3, y * 0.012 - warpT * 0.7, 4, 313) - 0.5) * 2 * WARP_AMP * 0.8;
        return [x + a, y + b];
    }
    function layerAt(wx, wy) {
        // 층 좌표 — 마노 단면의 띠 축. 밀도가 짙은 층은 얇고 촘촘하다
        const s = (wx + wy * 0.22) * 0.055 + fbm2(wx * 0.006, wy * 0.006, 3, 61) * 1.5;
        const fract = s - Math.floor(s);
        // 층 밀도로 띠 폭 조절 — AGATE_LAYER_DENSITY 가 띠 내 짙은 비중을 정한다
        const dense = smooth((fract - (1 - AGATE_LAYER_DENSITY)) / AGATE_LAYER_DENSITY);
        return dense;
    }

09중심마다 퍼지는 동심원이 흔들리며 겹치는 먹 흐름 결

ringField 는 중심마다 스텝당 RING_SPEED 0.9px(초당 56.25px)로 자라는 고리를 두고, 각도마다 흐르는 노이즈로 반경을 최대 ±4.16px(RADIUS_NOISE 0.16 × RING_GAP 26)까지 흔들어 먹선이 떨리게 한다. 반환값은 Σc + INTERFERE_K × ((Σc)² − Σc²)/2 인데, 한 중심의 고리끼리는 26px 간격으로 떨어져 곱 항이 거의 0 이라 두 중심의 고리가 만나는 마디에서만 단순 합보다 짙은 먹이 선다. 시연은 200·480·760ms 에 첫 중심, 1000·1300ms 에 둘째 중심에 먹을 떨어뜨리고 이전 고리를 지우지 않으며, 렌더 측정은 누적면적 72.0%·강도 56.9·23/23 으로 아홉 장면 가운데 강도가 가장 높다.

중심마다 퍼지는 동심원이 흔들리며 겹치는 먹 흐름 결
새 창에서 열기

09 · 중심마다 퍼지는 동심원이 흔들리며 겹치는 먹 흐름 결 — 측정 누적면적 72.013% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

RING_SPEEDRADIUS_NOISEINTERFERE_K
    function ringField(x, y, now) {
        // 시간 확산 동심원 — 스텝마다 반경이 자라고, 이웃 고리 간격으로 파동 무늬를 이룬다
        let v = 0;
        let v2 = 0;
        for (let i = 0; i < rings.length; i += 1) {
            const rg = rings[i];
            const dx = x - rg.x, dy = y - rg.y;
            const r = Math.sqrt(dx * dx + dy * dy);
            if (r > rg.r + 2) {
                continue;
            }
            // 반경 노이즈 — 각도마다 흐르는 노이즈로 결이 흔들린다
            const ang = Math.atan2(dy, dx);
            const sway = RADIUS_NOISE * RING_GAP * (fbm2(Math.cos(ang) * 2.4 + 7.1, Math.sin(ang) * 2.4 + now * 0.00021, 3, 401) - 0.5) * 2;
            const d = r - (rg.r + sway);
            const w = RING_GAP * 1.3;
            if (Math.abs(d) < w) {
                const c = Math.exp(-(d * d) / (w * w * 0.24)) * (1 - rg.r / RING_MAX_R);
                v += c;
                v2 += c * c;
            }
        }
        // 이중심 간섭 — 고리끼리 겹치는 자리에 곱 항 ((Σc)² − Σc²)/2 를 INTERFERE_K 만큼 더한다.
        // 한 중심의 고리끼리는 간격(RING_GAP)만큼 떨어져 거의 안 겹치므로 이 항은 두 중심의 고리가 만나는 마디에서 선다
        return v + INTERFERE_K * (v * v - v2) * 0.5;

확산 반경·빗 이빨 간격·층 밀도 — 숫자 하나가 무늬의 결을 바꾸는 자리

여기 적은 값은 렌더로 잰 것이 아니다. inkAt·tineShift·layerAt·tradeDens 를 노드에서 그대로 불러 상수만 바꿔 가며 계산했으므로 위 측정 표와 섞어 읽으면 안 된다.

BLOOM_RADIUS 를 96 에서 200 으로 키우면 방울 하나가 화면을 거의 덮는다

방울 하나가 덮는 넓이가 화면의 20.1% 에서 87.3% 로 커진다. 고리 수 RING_N 5 는 그대로라 띠 한 칸이 19.2px 에서 40px 로 굵어지고, 중심에서 50px 떨어져 있던 잉크는 108.2px 이 아니라 206.2px 자리까지 밀려난다. 새 방울 하나가 떨어질 때마다 이전 무늬 대부분이 화면 밖으로 나가므로 겹겹이 쌓인 결 대신 큰 고리 한 벌만 남는다.

COMB_TOOTH_GAP 을 14 에서 34 로 벌리면 깃털이 2.4배 넓어진다

480px 폭을 지나가는 이빨이 34.3개에서 14.1개로 줄고, 끌림이 잦아드는 폭 λ 는 5.92px 에서 14.38px 로 커진다. 그런데 반 칸 지점의 끌림은 두 값 모두 29.3px 로 같다. λ 가 이빨 간격에 비례해 함께 늘어나서 깃털의 생김새 비율은 지키고 크기만 바뀌기 때문이다.

AGATE_LAYER_DENSITY 를 0.35 에서 0.72 로 올리면 가는 띠가 굵은 돌 결로 뭉친다

짙은 층에 드는 표본의 몫이 32.0% 에서 65.9% 로, 평균 밀도가 0.174 에서 0.359 로 늘어난다. WARP_AMP 26 은 그대로라 경계가 휘는 최대 폭 21.9px 는 바뀌지 않는다. 띠의 굵기와 휘는 정도가 서로 다른 상수에 묶여 있어서, 돌 결의 무게감과 물결감을 따로 고를 수 있다.

SPLIT_DEPTH 를 3 에서 1 로 내리면 틈이 조금 더 빨리 벌어진다

옥타브가 한 겹이면 이득 지도에 큰 골짜기만 남고 그 안의 작은 골짜기가 사라진다. 같은 160스텝 뒤 틈 비율은 22.7% 대신 24.5% 가 되어, 가지가 잘게 갈라지는 대신 굵은 틈 몇 줄이 먼저 벌어진다.

팔레트 색이 가까울 때·무늬가 화면보다 클 때·움직임 줄이기 설정에서 깨지는 곳

03 의 접힘 대비를 0.5 로 올리면 밝기 계단은 사라져도 접힌 면 그늘은 남는다

밝은 면 밝기는 1 − FOLD_CONTRAST, 접힌 면은 0.42 × (1 − 대비) + 0.58 × 대비라서, 대비가 0.5 가 되는 순간 두 면의 밝기 계단이 똑같이 0.5 가 된다. 이름만 보면 대비를 키울수록 주름이 또렷해질 것 같지만 0.5 를 지나면 오히려 접힌 면의 밝기 계단 값이 더 높아지기 시작하고, 정확히 0.5 에서는 계단이 사라진 채 접힌 면에 먹을 섞는 그늘 한 겹만 남는다.

07 의 번짐 반경을 키우면 섞인 색이 탁해진다

BLEND_RADIUS 가 커지면 한 자리에 겹치는 이웃 점이 늘고, 가중 평균으로 섞인 색은 다섯 파스텔 색의 가운데, 곧 회색 쪽으로 끌려간다. SCATTER_P 를 1 로 올려도 비슷하게 망가지는데, 814자리가 모두 차서 점 무늬가 아니라 고른 바탕이 된다.

01 의 큰 방울은 이전 무늬를 한꺼번에 밀어낸다

방울이 DROP_MAX 7개를 넘으면 가장 오래된 방울을 버린다. 이미 화면 밖으로 밀려났으리라는 가정인데, 화면 가운데 큰 방울이 오래 남아 있는 상황에서는 그 방울이 어느 순간 통째로 사라져 보인다. 픽셀마다 방울 수만큼 되짚기 때문에 상한을 올리면 그만큼 계산도 늘어난다.

07 의 늘림은 오른쪽 점부터 화면 밖으로 민다

늘림은 x 좌표 자체에 배율을 곱하는 방식이라 왼쪽 끝 점은 거의 제자리에 있고 오른쪽 점일수록 멀리 밀린다. 1.9배까지 늘리면 오른쪽 절반의 점은 무대 밖으로 나가 버리므로, 넓은 화면 전체에 깔 때는 늘림 기준점을 가운데로 옮기는 편이 낫다.

08 의 휨이 층 간격보다 크면 띠가 섬처럼 끊긴다

층과 층 사이는 약 18px 인데 WARP_AMP 를 키워 휨이 이 간격을 넘으면 같은 층이 한 줄 위에서 두 번 지나가며 접힌다. 그러면 이어져야 할 띠가 섬처럼 끊겨 돌보다는 얼룩처럼 보인다.

06 의 왕관은 바깥으로 벌어지지 않는다

알갱이는 링 벽을 따라 수직으로만 솟았다 떨어지는 근사라서, 실제 왕관처럼 가장자리가 바깥으로 벌어지며 갈라지는 모양은 나오지 않는다. 벌어짐까지 원하면 알갱이마다 바깥 방향 속도를 하나 더 두고, 솟아 있는 동안 그 값을 알갱이가 링 중심에서 떨어진 거리 b.r 에 더해 주면 된다. GRAVITY_FALL 을 너무 작게 하면 비행이 RING_LIFE 1500ms 보다 길어져 링이 사라진 뒤에도 알갱이가 떠 있고, 가장자리에 떨어뜨리면 알갱이가 무대 밖으로 나가기 때문에 포인터 좌표를 가로 40~W−40, 세로 60~H−40 안으로 잘라 둔다.

02 는 반응항이 빠지면 0.75초 만에 멈춘다

첫 판은 EDGE_KEEP 이 0 이었고, 잉크가 흐린 얼룩으로 고르게 퍼진 채 0.75초 만에 수렴해 그 뒤로는 프레임이 하나도 바뀌지 않았다. 반대로 DENSITY_DIFF_K 를 키워 교환량이 셀 밀도 24% 상한에 자주 걸리면 흐름이 상한에서 잘려 결이 격자 모양으로 굳는다.

05 는 두 빗이 같은 줄을 지나면 서로 지운다

STAGGER_PHASE 가 0 이면 둘째 빗이 첫 빗과 같은 줄을 반대로 끌어, 두 끌림이 같을 때 무늬가 처음 띠로 완전히 돌아간다. 필드 240×150 에서 이빨 간격 14px 은 필드 7칸이라, 간격을 6px 아래로 좁히면 이빨 하나가 필드 3칸도 안 되어 깃털 대신 깨진 줄이 남는다.

09 의 간섭 계수를 키우면 마디가 덩어리로 뭉친다

INTERFERE_K 를 올리면 두 중심의 고리가 만나는 마디가 먹색 상한 0.92 에 붙어 점 대신 덩어리가 된다. RING_MAX_R 320 을 넘은 고리는 보이지 않아도 최근 24개 안에 있는 동안 계속 계산된다.

작은 필드를 늘린 자리에는 계단이 보인다

01 은 160×100 을 3배로, 02 는 96×60 을 5배로 늘려 붙여서 띠와 셀 경계가 가까이 보면 계단으로 보인다. 필드를 키우면 계단은 줄지만 계산은 가로세로 곱으로 늘고, 그중 08 은 픽셀마다 4옥타브 노이즈 두 번과 3옥타브 노이즈 두 번을 240×150, 곧 3만 6천 픽셀에 돌려 아홉 장면 가운데 가장 무겁다.

움직임 줄이기 설정에서는 06 의 왕관이 솟는 순간이 보이지 않는다

움직임 줄이기가 켜져 있으면 06 은 finishSplash 로 같은 stepBead 를 알갱이가 다 내려앉을 때까지 한 번에 돌린 결과만 그린다. 그래서 점이 앉은 자리는 애니메이션과 똑같지만 왕관이 솟는 장면 자체는 나오지 않고, 09 도 퍼지는 과정 없이 다 퍼진 고리를 다시 계산해 보여 준다.

상수 하나씩 고쳐 가며 무늬가 무너지는 경계를 직접 찾아보려면 아래 zip 을 내려받아 95rs6qwq 을 넣고 풀면 된다. 순수 HTML 판(vanilla/)과 리액트 판(react/)이 같은 상수 이름을 쓰므로 어느 쪽에서 찾아도 글의 이름이 그대로 나온다.

잉크·빗·돌 결의 값을 바꾸는 세 가지와 접근성

이름 바꾼 값 느낌
잉크 확산 반경 바꾸기 01 BLOOM_RADIUS 96 → 200 방울 하나가 화면의 87.3% 를 덮고 띠 한 칸이 40px 로 굵어져, 겹친 결 대신 큰 고리 한 벌이 남는다.
빗 이빨 간격 바꾸기 05 COMB_TOOTH_GAP 14 → 34 이빨이 14.1개로 줄고 깃털이 2.4배 넓어지지만 반 칸 끌림 29.3px 는 같아 생김새 비율은 유지된다.
돌 결 층 밀도 바꾸기 08 AGATE_LAYER_DENSITY 0.35 → 0.72 짙은 층 몫이 65.9% 로 늘어 굵은 띠로 뭉치고, 휘는 폭 21.9px 는 그대로다.

마우스가 없어도 아홉 장면을 모두 움직일 수 있다. 캔버스마다 tabindex="0" 과 항목 이름을 담은 한국어 aria-label 이 있고, 키는 장면마다 다르다 — 01 은 방향키로 원 모양 커서를 옮기고 Enter·Space 로 방울을 떨어뜨리며, 02 는 방향키로 잉크를 밀고, 03 은 좌우로 부채를 돌리고, 04 는 좌우로 빗질 방향을 정한다. 05 는 ↓ 로 첫 빗, ↑ 로 둘째 빗을 끌고, 06 은 방향키 커서와 Enter·Space 방울, 07 은 Enter·Space 로 다시 뿌리기, 08 은 좌우나 Enter 로 다른 단면, 09 는 Enter·Space 로 먹물을 떨어뜨린다. preventDefault 는 그 장면이 쓰는 키에서만 불러 Tab 이동은 막히지 않고, 장면마다 <button> 이 하나씩 있으며 캔버스와 버튼 모두 :focus-visible 테두리가 보인다. 터치에서는 touch-action: pan-y 라 무대 위에서 세로로 쓸어도 페이지가 스크롤된다.

상태 줄은 시연이 도는 동안 aria-live="off" 로 조용히 있다가, 사람이 처음 포인터나 키나 버튼을 건드리는 순간 wake() 가 시연을 끝내면서 polite 로 바뀐다. 자동 시연이 화면 읽기 프로그램을 계속 떠들게 하지 않으려는 순서다.

prefers-reduced-motion 은 아홉 장면 모두 스크립트의 matchMedia 와 스타일시트 양쪽에서 확인한다. 이 설정이 켜지면 rAF 루프를 아예 걸지 않고 완성된 상태로 맞춘 장면을 한 장만 그려 둔다. 입력이 들어와도 중간 과정 없이 목표로 건너뛴다 — 01 은 다 퍼진 방울을 더하고, 02 는 교환 24번을 한꺼번에, 03 은 0.28rad 회전을, 04 는 위상 0.9rad 를 바로 적용하며, 05 는 끌림 목표값으로, 06 은 알갱이가 다 앉은 상태로, 07 은 산포와 늘림 0.55 로, 08 은 단면 교체로, 09 는 다 퍼진 고리로 곧장 넘어간 뒤 draw() 를 한 번 부른다.

마블링 배경을 CSS 규칙만으로 어디까지 흘릴 수 있는가?

이 편에서 CSS 가 하는 일은 무대 틀이다. .hs 가 width: min(92vw, calc(160vh - 12.8vw)) 와 aspect-ratio: 480 / 300 으로 크기를 정하고, 16px 모서리와 overflow: hidden, 그림자를 준다. 아홉 장면의 스타일시트에 @keyframes 는 하나도 없고 움직임은 전부 스크립트에서 나온다. 그라디언트를 겹치면 줄무늬나 동심원처럼 정해진 무늬는 그릴 수 있지만, 01 처럼 새 방울이 이전의 모든 방울을 밀어내거나 02 처럼 이웃 칸끼리 잉크를 주고받는 일은 다음 장면이 직전 장면에 달려 있어 규칙만으로는 적어 둘 수 없다.

정해진 무늬를 시드로 다시 그리는 쪽이 필요하면 CSS 배경 패턴 9종, 그라디언트 층만으로 하늘색을 바꾸는 예는 css sunset gradient 9가지 가 가깝다. 픽셀 계산을 더 깊이 보려면 물방울 굴절을 다룬 html5 canvas 애니메이션 9종 과 격자 위 잉크 흐름을 푼 유체 시뮬레이션 9종 으로 이어 가면 된다. 버퍼를 화면에 올리는 방식은 putImageData 와 requestAnimationFrame 문서를 기준으로 짰다.

마블링 아트 화면을 내 페이지에 옮기기 전에 자주 묻는 질문

마블링 패턴의 색을 내 사이트 색으로 바꾸려면 어디를 고치나?

아홉 파일 모두 스크립트 앞쪽 27~34행에 바탕 PAPER(252, 246, 234), 먹 INK(58, 48, 76), 잉크 다섯 색을 담은 TINTS 가 같은 자리에 있다. 이 배열만 바꾸면 계산은 그대로 두고 색만 갈아 끼울 수 있다. 무늬 위에 글자를 얹을 계획이라면 가장 진한 색과 가장 옅은 색 양쪽에서 글자 대비를 확인해야 한다.

새로고침할 때마다 다른 무늬가 나오게 할 수 있나?

지금은 해시 호출에 넘기는 시드 숫자가 고정이라 매번 같은 무늬가 나온다. 07 이라면 hash2(gx, gy, 811) 의 811 을, 08 이라면 warp 안 fbm2 의 101·313 을 다른 수로 바꾸면 결이 달라진다. 파일마다 SEED 463 상수도 선언돼 있지만 지금의 계산 함수들은 그 상수가 아니라 호출마다 적힌 숫자를 쓴다.

페이지 전체 배경으로 늘려 깔아도 무겁지 않나?

비용은 화면 크기가 아니라 필드 픽셀 수를 따라간다. 무대가 커져도 필드는 그대로 두고 늘려 붙이기만 하므로 계산량은 같고, 대신 늘린 배율만큼 계단이 굵어진다. 가장 무거운 08 은 필드를 줄이거나, 멈춘 한 장면으로 써도 돌 단면으로 충분히 읽히니 움직임이 꼭 필요한 자리인지 먼저 따져 보면 좋다.

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

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