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

자성유체 원리 9종 — 솟는 가시부터 자성유체 시계까지

자성유체 원리를 실물 대신 화면 속 계산으로 다시 세운 캔버스 시뮬레이션 9종이다. 자석이 다가와도 웅덩이는 평평하게 버티다가 세기가 문턱을 넘는 순간 가시가 한꺼번에 솟는다. 그 문턱은 어떻게 계산할까?

자석이 세져도 웅덩이는 한동안 버티다 한꺼번에 솟는다 — 자성유체 원리는 세기 문턱 하나에서 시작한다

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

아홉 장면이 공유하는 규칙은 짧다. 자석이 만든 세기를 점마다 재고, 그 세기가 문턱을 넘은 만큼만 액체를 움직인다. 01 은 세기의 방향을 선으로, 02~04 는 높이·광택·기울기로, 05·06 은 웅덩이와 방울의 이동으로, 07·08 은 시간에 따라 바뀌는 세기로 보여 주고, 09 는 그 다섯 갈래를 접시 하나에 겹친다.

한꺼번에 솟는 순간을 숫자로 풀면

02 에서 자석 극은 깊이 130px 에서 42px 로 0.9초에 걸쳐 다가온다. 극 하나의 세기는 거리 60px 에서 1 이 되게 잡았으므로 바로 위 세기가 0.45 가 되는 깊이는 식으로 60 ÷ √0.45 = 89.4px 이고, 프레임 단위로 재면 깊이 88.4px 인 0.63초에 처음 문턱을 넘는다. 그 전까지는 높이 목표가 칸마다 0 이라 웅덩이는 가장자리 턱만 남은 평평한 판이다. 넘은 뒤에는 가운데부터 가시가 서고, 자석이 가장 가까워지면 가시가 선 반지름이 √(60² ÷ 0.45 − 42²) = 79px 까지 넓어진다. 「한꺼번에」 는 문턱 아래에서 아무 변화가 없다가 넘는 순간 넓은 원이 빠르게 채워진다는 뜻에 가깝다.

실물 실험이 아니라 화면 계산으로 답하는 이유

이 편은 실물 자성유체 만들기나 다루는 방법을 설명하지 않는다. 화면 속 세기는 FIELD_REF_PX 60 거리에서 1 이 되도록 정한 상대값이라 실제 단위로 옮길 수 없고, 액체 색 FLUID [13,12,17] 도 거의 검정으로 고정한 그림 값이다. 대신 문턱·기울기·사슬처럼 눈으로 확인하고 싶은 성질을 수식 몇 줄로 세워, 숫자를 바꾸면 화면이 어떻게 달라지는지 바로 볼 수 있게 했다.

이웃한 캔버스 물리 편과 갈리는 지점

물리 시뮬레이션 만들기 편의 자석 파티클은 점 하나가 끌려가는 힘만 다루고, 비눗방울 효과 편은 막 두께로 색을 칠하는 계산이 중심이다. 이 편은 세기 장을 화면 전체에 깔고 그 위에 높이 격자를 세운다는 점이 다르다. 점과 막대로 천과 젤리를 흔드는 물리엔진 편이 압력으로 모양을 지킨다면, 05 의 웅덩이는 같은 압력에 자석이 당기는 기울기 힘을 더한다. 계산으로 그린 장면 효과의 기준은 물방울·블랙홀 캔버스 효과 편에 있다.

쌍극자 공식으로 장을 재고, 엇갈린 세 물결을 겹쳐 가시를 세우고, 높이 기울기로 광택을 얹는 세 단계

아홉 장면은 공통 함수 세 개를 장면마다 골라 엮는다. 단계마다 무엇이 정해지는지를 먼저 표로 두고, 아래에서 하나씩 연다.

단계 함수 정해지는 것 쓰는 장면
1 장 재기 fieldAt 극마다 더한 세기와 방향, 거리 하한 soften 01~05·07~09 (06 은 고른 장 방향 하나)
2 가시 세우기 hexWave 간격 spacing 의 육각 배열 무늬 0~1 02·03·07·08·09
3 광택 얹기 shadeOne·shadeGrid 기울기에서 구한 반사 하이라이트 색 02·03·07·08·09

극마다 더하는 세기와 18px 거리 하한

극 하나는 부호 있는 점 {x, y, q} 이고, 재는 점까지의 벡터에 세 번째 성분으로 soften 을 넣어 극 바로 위에서도 거리가 그 아래로 줄지 않게 한다. 01 은 이 값이 18px 라 극 하나만 볼 때 바로 위 세기가 (60 ÷ 18)² = 11.11 에서 멈춘다. 세 성분은 전역 field 배열에 남겨 두어 04 처럼 방향이 필요한 장면이 다시 계산하지 않고 읽는다.

    const FIELD_REF_PX = 60;
    const field = [0, 0, 0];
    function fieldAt(poles, x, y, soften) {
        let bx = 0;
        let by = 0;
        let bz = 0;
        const ref2 = FIELD_REF_PX * FIELD_REF_PX;
        for (const p of poles) {
            const dx = x - p.x;
            const dy = y - p.y;
            const r2 = dx * dx + dy * dy + soften * soften;
            const k = p.q * ref2 / (r2 * Math.sqrt(r2));
            bx += dx * k;
            by += dy * k;
            bz += soften * k;
        }
        field[0] = bx;
        field[1] = by;
        field[2] = bz;
        return Math.sqrt(bx * bx + by * by + bz * bz);
    }

120° 엇갈린 세 물결이 육각 배열이 되는 까닭

방향이 0°·120°·240° 인 cos 물결 셋을 더하면 합은 −1.5 에서 3 사이를 오가고, 세 마루가 겹치는 점만 3 에 가까워진다. 물결수를 4π ÷ (√3 × 간격) 으로 잡으면 그 겹침 점 사이가 정확히 간격이 되어 벌집처럼 놓인다. 합을 3 으로 나눈 값에서 0.3 아래는 0 으로 잘라 가시 사이를 평평하게 두고, 남은 윗부분을 0~1 로 늘려 꼭대기로 갈수록 곧게 좁아지는 원뿔 모양을 만든다.

높이 차에서 하이라이트까지 가는 길

면의 방향은 이웃 칸 높이 차로 구한 (−∂z/∂x, −∂z/∂y, 1) 을 길이 1 로 줄인 값이다. 빛 방향과 정면 위 눈 방향의 가운데 방향을 이 면 방향과 내적해 SHINE 제곱만큼 날카롭게 만들고, 분홍 [255,190,210] 과 하늘색 [172,212,255] 두 빛을 차례로 더한다. 면이 기운 만큼은 바탕 검정에 보랏빛 FLUID_EDGE [86,80,112] 를 절반까지 섞어, 하이라이트가 없는 비탈도 윤곽이 보인다. 격자 그림은 칸 하나를 화소 하나로 숨은 캔버스에 putImageData 로 적은 뒤 drawImage 로 판 크기까지 부드럽게 늘려 붙인다.

자성유체 원리 9종 — 쌍극자 공식으로 따라 그리는 자기력선·임계 세기를 넘으면 육각 배열로 솟는 가시·높이의 기울기로 얹는 검은 광택·자기력선 방향으로 기우는 가시·자석을 따라 끌려가며 둥글게 버티는 웅덩이·자기장 방향으로 줄 서는 방울 사슬·박자에 맞춰 솟았다 가라앉는 가시·숫자 모양 자석 배열로 시각을 세우는 자성유체 시계·유리 접시 위 자성유체 인테리어 장면

01~04 는 장 하나에서 선·가시·광택·기울기를 하나씩 떼어 보는 장면이고, 05·06 은 액체가 위치를 옮기는 장면, 07·08 은 세기를 시간표로 바꾸는 장면이다. 09 는 앞의 다섯 계산을 접시 위 한 장면에 모은다. 아홉 개 다 캔버스 2D 하나에 라이브러리·WebGL·이미지 파일 없이 그리고, 난수는 시드 고정 makeRng 라 열 때마다 같은 시연이 나온다.

480×300 화면을 2초 동안 찍어 잰 움직임

장면 누적면적 움직인 프레임 강도 평균
01 쌍극자 자기력선 43.6% 23/23 97.6
02 문턱을 넘어 솟는 육각 가시 9.8% 23/23 63.3
03 높이 기울기 광택 14.4% 23/23 38.3
04 장을 따라 기우는 가시 16.9% 23/23 95.5
05 끌려가며 버티는 웅덩이 30.5% 23/23 113.5
06 방울 사슬 63.1% 23/23 57.1
07 박자에 뛰는 가시 6.0% 23/23 64.4
08 자석 격자 시계 14.2% 23/23 29.6
09 유리 접시 장면 22.1% 23/23 79.4

방울 140개가 판 전체에서 모였다 흩어지는 06 이 63.1% 로 가장 넓다. 07 은 자석이 고정이라 가운데 가시 무리만 오르내려 6.0% 에 그친 것으로 보이고, 그래도 아홉 장면 다 23/23 프레임에서 화면이 바뀌었다.

01쌍극자 공식으로 따라 그리는 자기력선

과학 학습 사이트의 자기장 단원 도입 그림처럼, 막대 자석이 가로 44px·세로 30.8px 타원을 6초에 한 바퀴 도는 동안 N 극 둘레 14점에서 출발한 선을 4px 걸음마다 그 자리 장 방향으로 이어 프레임마다 17~18개를 다시 긋는다. 한 걸음은 반 걸음 앞의 방향을 다시 읽고 나아가는 두 단계 적분이고, S 극 9px 안에 닿거나 화면 밖 24px 를 넘으면 멈춘다. 선 위 점선은 세기에 비례해 흘러 극 옆에서 205px/s, 140px 떨어진 곳에서 2.36px/s 로 87배 차이가 난다.

쌍극자 공식으로 따라 그리는 자기력선
새 창에서 열기

01 · 쌍극자 공식으로 따라 그리는 자기력선 — 측정 누적면적 43.612% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FIELD_SOFTEN_PXSTEP_PXFLOW_SPEED
        for (let s = 0; s < MAX_STEPS; s += 1) {
            dirAt(x, y, sign, d1);
            const mx = x + d1[0] * STEP_PX / 2;
            const my = y + d1[1] * STEP_PX / 2;
            const bm = dirAt(mx, my, sign, d2);
            x += d2[0] * STEP_PX;
            y += d2[1] * STEP_PX;
            // 흐름 시간 = 걸음 길이 ÷ 빠르기(FLOW_SPEED × 세기). 빠른 곳일수록 같은 시간에 점선이 멀리 간다
            t += STEP_PX / (FLOW_SPEED * Math.max(1e-3, bm));
            L.x.push(x);
            L.y.push(y);
            L.t.push(t);
            L.b.push(bm);
            if (Math.hypot(x - target.x, y - target.y) < SEED_RADIUS_PX) {
                reached = true;
                break;
            }
            if (x < -EDGE_PX || x > W + EDGE_PX || y < -EDGE_PX || y > H + EDGE_PX) {
                break;
            }
        }

02임계 세기를 넘으면 육각 배열로 솟는 가시

스피커 제품 소개 페이지의 첫 화면 배경으로, 자석 극이 다가오는 동안 웅덩이는 평평하다가 가운데 세기가 문턱 0.45 를 넘는 0.63초부터 가운데에서 가시가 서기 시작한다. 칸마다 높이 목표는 세기가 아니라 문턱을 넘은 만큼에 18 을 곱한 값이라, 가장 가까울 때 가운데 목표는 18 × (2.04 − 0.45) = 28.6px 이다. 높이는 단단함 80·감쇠 8 스프링으로 따라가 1.17초에 처음 목표의 ±3% 띠에 들고, 가운데 칸이 가장 높은 30.3px 에 닿는 1.25초에는 자석이 옆으로 미끄러지기 시작해 목표가 21.7px 로 내려가는 중이다.

임계 세기를 넘으면 육각 배열로 솟는 가시
새 창에서 열기

02 · 임계 세기를 넘으면 육각 배열로 솟는 가시 — 측정 누적면적 9.821% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CRITICAL_FIELDSPIKE_SPACING_PXSPIKE_STIFFNESS
    // 칸마다 세기를 재 높이 목표를 정한다 — 문턱 아래는 0, 위는 넘은 만큼
    function measure() {
        poles[0].x = magX;
        poles[0].y = magY;
        peak = fieldAt(poles, magX, magY, depth);
        for (let j = 0; j < grid.gh; j += 1) {
            for (let i = 0; i < grid.gw; i += 1) {
                const k = j * grid.gw + i;
                const b = fieldAt(poles, i * CELL_PX, j * CELL_PX, depth);
                grid.target[k] = grid.wet[k] > 0.5 ? SPIKE_GAIN_PX * Math.max(0, b - CRITICAL_FIELD) : 0;
            }
        }
        simStatus = peak < CRITICAL_FIELD
            ? fill(STATUS_FLAT, { b: peak.toFixed(2), c: CRITICAL_FIELD })
            : fill(STATUS_SPIKE, { b: peak.toFixed(2), c: CRITICAL_FIELD, r: Math.round(spikeRadius()) });
    }
    // 높이 = 돔 + 칸 스프링 높이 × 세 물결
    function compose() {
        const n = grid.gw * grid.gh;
        for (let k = 0; k < n; k += 1) {
            grid.z[k] = dome[k] + grid.amp[k] * hex[k];
        }
    }

03높이의 기울기로 얹는 검은 광택

다크 모드 제품 랜딩의 광택 배경으로, 두 자석이 세운 가시 판 위를 분홍·하늘색 빛 둘이 4초에 한 바퀴 돌며 가시 끝마다 하이라이트를 맺는다. 같은 장면을 둘로 나눠 왼쪽은 화면 화소마다 이웃 높이 네 번을 다시 재고, 오른쪽은 3px 격자 8,181칸에서 칸마다 한 번 재어 늘려 붙인다. 한 프레임 예산이 9,000 화소라 왼쪽은 배율 1 에서 8프레임, 배율 2 에서 32프레임에 한 장을 바꾸고 오른쪽은 매 프레임 바뀐다.

높이의 기울기로 얹는 검은 광택
새 창에서 열기

03 · 높이의 기울기로 얹는 검은 광택 — 측정 누적면적 14.402% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SHINECELL_PXFRAME_BUDGET_PX
    function pixelPass(budget) {
        const hv = halfVectors(passLights);
        const step = 1 / passScale;
        const total = passW * passH;
        let done = 0;
        while (done < budget && passAt < total) {
            const px = passAt % passW;
            const py = Math.floor(passAt / passW);
            const x = (px + 0.5) * step;
            const y = (py + 0.5) * step;
            const gx = (heightAt(passPoles, x + step, y) - heightAt(passPoles, x - step, y)) / (2 * step);
            const gy = (heightAt(passPoles, x, y + step) - heightAt(passPoles, x, y - step)) / (2 * step);
            shadeOne(gx, gy, wetAt(x, y), DISH, hv, SHINE, work, passAt * 4);
            passAt += 1;
            done += 1;
        }
        passFrames += 1;
        if (passAt >= total) {
            shown.set(work);
            lastPassFrames = passFrames;
            startPass();
        }
        return done;
    }

04자기력선 방향으로 기우는 가시

포트폴리오 첫 화면의 움직이는 배경으로, 옆에서 본 가시 76개(세 줄 31·25·20개) 밑을 막대 자석이 4.4초에 한 번 좌우로 오가면 가시가 제자리 장의 축을 따라 눕는다. 기울기 목표는 축 각에 0.9 를 곱해 ±60.2° 에서 자르고, 높이 목표는 세로 성분이 문턱 0.35 를 넘은 만큼이라 장이 눕는 두 극 가운데 위(세로 0·가로 2.33)에는 가시가 서지 않는다. 각도 스프링(60·9)이 늦게 따라가 한 바퀴 동안 가장 크게 누운 가시는 상한을 지나친 70.7° 다.

자기력선 방향으로 기우는 가시
새 창에서 열기

04 · 자기력선 방향으로 기우는 가시 — 측정 누적면적 16.903% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LEAN_GAINLEAN_MAXLEAN_STIFFNESS
    // 장의 축 각 — 세로 위(0)에서 시계 방향 양수. 가시는 화살 방향이 아니라 선의 축을 따르므로 ±π/2 밖이면 반 바퀴 돌려 접는다
    function fieldAxisAngle() {
        let a = Math.atan2(field[0], -field[1]);
        if (a > Math.PI / 2) {
            a -= Math.PI;
        }
        if (a < -Math.PI / 2) {
            a += Math.PI;
        }
        return a;
    }
    function aim() {
        for (const s of spikes) {
            const r = ROWS[s.row];
            fieldAt(poles, s.x, r.y, Math.hypot(LEAN_SOFTEN_PX, r.depth));
            s.lean.to = clamp(fieldAxisAngle() * LEAN_GAIN, -LEAN_MAX, LEAN_MAX);
            // 높이는 표면을 곧게 뚫는 세로 성분 |by| 로 — 두 극 사이처럼 장이 눕는 곳에서는 가시가 서지 않는다
            const normal = Math.abs(field[1]);
            s.h.to = Math.min(SPIKE_MAX_PX, SPIKE_GAIN_PX * Math.max(0, normal - CRITICAL_FIELD)) * r.scale;
        }
    }

05자석을 따라 끌려가며 둥글게 버티는 웅덩이

체험형 전시 소개 페이지의 끌어당김 연출로, 반지름 46px 웅덩이 둘레의 점 40개를 자석이 3초에 한 번 옆으로 누운 8자를 그리며 끌고 간다. 점마다 세기 제곱의 기울기로 당기는 힘, 이웃 두 점 가운데로 돌아가는 힘, 처음 넓이를 지키는 압력, 점성 감쇠를 더해 넓이는 0.93~1.04배 안에 머문다. 자석이 앞서 가면 웅덩이는 물방울 모양으로 늘어나 뒤따르고, 자석 뒤로 남는 꼬리는 0.75초에 가장 긴 62.8px 다.

자석을 따라 끌려가며 둥글게 버티는 웅덩이
새 창에서 열기

05 · 자석을 따라 끌려가며 둥글게 버티는 웅덩이 — 측정 누적면적 30.456% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SURFACE_TENSIONAREA_PRESSUREPUDDLE_DT_MAX
    function ringAccel(r, poles, p) {
        const n = r.n;
        const area = ringArea(r);
        const squeeze = p.pressure * (r.area0 - area) / Math.max(1, Math.abs(r.area0));
        for (let i = 0; i < n; i += 1) {
            const a = (i + n - 1) % n;
            const b = (i + 1) % n;
            const mx = (r.x[a] + r.x[b]) / 2;
            const my = (r.y[a] + r.y[b]) / 2;
            // 바깥 방향 — 앞뒤 점을 잇는 방향 (tx, ty) 를 (ty, −tx) 로 돌린 쪽. 넓이가 양수가 되는 차례로 놓인 고리에서는 이쪽이 바깥
            const tx = r.x[b] - r.x[a];
            const ty = r.y[b] - r.y[a];
            const tl = Math.hypot(tx, ty) || 1;
            const g = strength2Grad(poles, r.x[i], r.y[i], p.soften);
            ringAx[i] = p.drag * g[0] + p.tension * TENSION_RATE * (mx - r.x[i]) + squeeze * (ty / tl) - p.viscosity * r.vx[i];
            ringAy[i] = p.drag * g[1] + p.tension * TENSION_RATE * (my - r.y[i]) + squeeze * (-tx / tl) - p.viscosity * r.vy[i];
        }
    }

06자기장 방향으로 줄 서는 방울 사슬

물리 수업 자료의 자기장 시연으로, 방울 140개가 장이 켜진 2.6초 동안 사슬로 이어지고 꺼진 1.4초 동안 흩어진다. 두 방울을 잇는 선과 장이 이루는 각 θ 로 (3cos²θ − 1)/ρ⁴ 에 비례하는 힘을 주어 장 방향으로 놓인 짝은 끌고 옆으로 놓인 짝은 밀며, sin 2θ 성분은 잇는 선을 장 쪽으로 돌린다. 켜진 뒤 0.2초 만에 사슬 52개가 생기고, 2.4초에 이은 짝 99쌍의 선은 장 방향과 평균 14.9° 어긋난다.

자기장 방향으로 줄 서는 방울 사슬
새 창에서 열기

06 · 자기장 방향으로 줄 서는 방울 사슬 — 측정 누적면적 63.08% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CHAIN_GAINCUTOFF_PXLINK_RATIO
                                const ux = dx / r;
                                const uy = dy / r;
                                let px = 0;
                                let py = 0;
                                if (fieldOn > 0) {
                                    const cos = ux * fx + uy * fy;
                                    const sin = fx * uy - fy * ux;
                                    const rho = Math.max(r, dia * 0.9) / dia;
                                    const k = CHAIN_GAIN * fieldOn / (rho * rho * rho * rho);
                                    const radial = -k * (3 * cos * cos - 1);
                                    const turn = -k * 2 * sin * cos;
                                    px += radial * ux + turn * -uy;
                                    py += radial * uy + turn * ux;
                                }
                                if (fieldOn < 1) {
                                    const s = SCATTER_GAIN * (1 - fieldOn) * (1 - r / CUTOFF_PX);
                                    px += s * ux;
                                    py += s * uy;
                                }
                                a.fx += px;
                                a.fy += py;
                                b.fx -= px;
                                b.fy -= py;

07박자에 맞춰 솟았다 가라앉는 가시

음악 플레이어의 재생 중 표시로, 시드 7 로 뽑은 10박(간격 0.32·0.48·0.64초 가운데 하나)마다 빠르게 오르고 천천히 줄어드는 곡선을 만들어 자기장 세기 배율을 1.00~3.37배로 흔든다. 세기 1 박은 0.066초에 꼭대기에 닿고 0.312초에 반으로 줄며, 박이 없을 때도 가운데 세기 0.9 가 문턱의 2배라 가시가 조금 서 있다. 칸 스프링(140·9)이 센 박에서 목표를 최대 15.7px 넘쳐 2.55초에 가장 높은 56.4px 가시를 세우고, 소리 파일과 마이크는 쓰지 않는다.

박자에 맞춰 솟았다 가라앉는 가시
새 창에서 열기

07 · 박자에 맞춰 솟았다 가라앉는 가시 — 측정 누적면적 5.995% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ATTACK_SDECAY_SENVELOPE_GAIN
    function makeBeats() {
        const rng = makeRng(BEAT_SEED);
        beats = [];
        for (let t = FIRST_BEAT_S; t < LOOP_S - 0.3; t += BEAT_GAPS[Math.floor(rng() * BEAT_GAPS.length)]) {
            beats.push({ t: t, s: 0.45 + rng() * 0.55 });
        }
    }
    function envelopeAt(t) {
        let e = 0;
        for (const b of beats) {
            const tau = t - b.t;
            if (tau < 0) {
                continue;
            }
            e += b.s * (1 - Math.exp(-tau / ATTACK_S)) * Math.exp(-tau / DECAY_S);
        }
        return e;
    }

08숫자 모양 자석 배열로 시각을 세우는 자성유체 시계

라운지 대기 화면의 시계 위젯으로, 12px 간격 40×25 자석 격자에서 5×7 점 글자 넷과 콜론 자리 자석만 켜고 시각이 바뀔 때만 장을 다시 합산한다. 가상 시계는 시드상 00:41 에서 시작해 1.1초마다 7분씩 넘어가고, 액체는 더 센 이웃 칸으로 끌리면서 바뀐 직후 0.25초 시간 상수로 잦아드는 번짐을 타고 새 숫자 자리로 옮겨 간다. 칸에서 뺀 양을 이웃에 그대로 더하므로 전체 양 64 는 한 바퀴 최대 오차 6.6×10⁻⁶ 으로 보존되고, 바뀐 뒤 1초면 98~99% 가 새 숫자 자리에 모인다.

숫자 모양 자석 배열로 시각을 세우는 자성유체 시계
새 창에서 열기

08 · 숫자 모양 자석 배열로 시각을 세우는 자성유체 시계 — 측정 누적면적 14.197% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

POLE_QPULL_RATESTEP_MIN
                for (let q = 0; q < 4; q += 1) {
                    const j = nb[q];
                    if (j < 0) {
                        continue;
                    }
                    const bi = coarseField[i];
                    const bj = coarseField[j];
                    let f = liquid[i] * PULL_RATE * h * Math.max(0, bj - bi) / (bj + bi + PULL_BIAS);
                    // 번짐은 짝마다 한 번만(i < j 쪽에서) 양쪽 차의 반을 옮긴다
                    if (j > i) {
                        f += spread * h * (liquid[i] - liquid[j]) / 2;
                    }
                    outs[q] = f;
                    outSum += Math.max(0, f);
                }
                const cap = outSum > liquid[i] * 0.5 ? liquid[i] * 0.5 / outSum : 1;
                for (let q = 0; q < 4; q += 1) {
                    const j = nb[q];
                    if (j < 0) {
                        continue;
                    }
                    const f = outs[q] > 0 ? outs[q] * cap : outs[q];
                    flowTmp[i] -= f;
                    flowTmp[j] += f;
                }

09유리 접시 위 자성유체 인테리어 장면

인테리어 소품 쇼핑몰의 상품 첫 화면으로, 반지름 128px 유리 접시 아래를 막대 자석이 6초에 한 바퀴 돌면 05 의 점 고리 웅덩이가 끌려가고 그 위에 02 의 가시·04 의 기울기·03 의 광택·01 의 자기력선이 겹친다. 기울기는 물결 값이 높은 곳일수록 6px × 기울기만큼 비켜 읽어 가시 몸통이 한쪽으로 휘어 보인다. 가시가 선 칸은 36~60% 사이를 오가고, 자석과 웅덩이 무게중심 거리는 1초 2.7px 에서 5.5초 19.1px 로 벌어진다.

유리 접시 위 자성유체 인테리어 장면
새 창에서 열기

09 · 유리 접시 위 자성유체 인테리어 장면 — 측정 누적면적 22.147% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAG_ORBIT_RLEAN_SHIFT_PXDOME_PX
    // 높이 = 웅덩이 돔 + 가시 높이 × 기울여 비켜 읽은 물결 값
    function compose() {
        for (let j = 0; j < grid.gh; j += 1) {
            for (let i = 0; i < grid.gw; i += 1) {
                const k = j * grid.gw + i;
                let p = 0;
                if (grid.amp[k] > 0.05) {
                    const x = grid.ox + i * CELL_PX - DISH_X;
                    const y = grid.oy + j * CELL_PX - DISH_Y;
                    const p1 = hexBase[k];
                    p = hexWave(x - leanX[k] * LEAN_SHIFT_PX * p1, y - leanY[k] * LEAN_SHIFT_PX * p1, SPIKE_SPACING_PX);
                }
                grid.z[k] = DOME_PX * wetRaw[k] + grid.amp[k] * p;
            }
        }
    }

자성유체 활용 장면을 화면에 옮기면 가시는 어느 쪽으로 기울까?

가시는 자석 쪽이 아니라 그 자리를 지나는 자기력선의 축 쪽으로 기운다. 04 의 fieldAxisAngle 은 장 벡터를 화살로 보지 않고 선으로 보아, 세로에서 ±90° 를 넘으면 반 바퀴를 접어 되돌린다. 그래서 N 극 위와 S 극 위의 가시는 장의 부호가 반대여도 같은 쪽으로 서고, 두 극 사이처럼 장이 거의 누운 곳은 기울기 대신 높이가 0 이 된다.

기울기가 뒤집히는 한순간

자석이 지나가며 축이 수평을 넘는 순간에는 목표가 +60° 에서 −60° 로 바로 뒤집힌다. 노드에서 한 바퀴를 재 보면 목표와 실제 기울기의 가장 큰 차가 129.7° 였고, 각도 스프링은 그 차를 −π~π 로 감아 짧은 쪽으로 돌아간다. 가시 숲이 물결처럼 차례로 눕고 일어서는 것은 이 늦은 스프링 덕으로 보인다.

이런 화면에 붙인다

인테리어 소품 쇼핑몰의 상품 첫 화면에는 09 를 그대로 걸고, 상품 사진 대신 접시 위 액체가 천천히 끌려가는 모습으로 분위기를 잡을 수 있다. 스피커 제품 소개처럼 소리와 엮고 싶은 페이지라면 02 의 문턱 장면이나 07 의 박자 장면이 맞고, 07 은 소리 파일 없이 시드 박자로 뛰니 재생 버튼 옆 작은 표시로도 쓸 수 있다.

과학 학습 자료라면 쓰임을 말로 늘어놓기보다 01 의 선과 06 의 사슬을 나란히 띄우는 쪽이 이해가 빠르다. 라운지나 매장 대기 화면은 08 시계가 맞는데, 지금은 가상 시각을 쓰므로 실제 시각을 보이려면 시각 넘김 함수에 바깥 시각을 넣는 한 단계를 더해야 한다.

자성유체 장면이 깨지는 곳 — 자석 바로 위에서 끝없이 치솟는 장·픽셀마다 다시 재는 기울기·숨은 탭에서 돌아온 긴 한 프레임

거리 하한을 지우면 극 옆이 14,401 로 치솟는다

01 아래쪽 단면 그래프는 두 극을 지나는 곧은 선 ±72px 를 1px 마다 재어 거리 하한 18 판과 하한 0 판을 겹쳐 그린다. 하한 18 판의 최대가 10.96 인데 하한 0 판은 14,401 로 1,314배라 그래프 위로 뚫고 나간다. 표본이 화소 가운데라 극에서 가장 가까운 점이 0.5px 이고, 거리 제곱이 0.25 까지 내려가 60² ÷ 0.25 = 14,400 이 나온 것이다. 이 값을 02 식에 넣으면 그 한 칸 목표만 18 × 14,400 쯤이 되어 화면 위로 솟는다.

    // 단면 두 벌 — 거리 하한을 둔 세기와 하한 0 의 세기
    function measureSection() {
        sectionSoft = [];
        sectionHard = [];
        const ux = Math.cos(magA);
        const uy = Math.sin(magA);
        for (let k = -SECTION_HALF_PX; k < SECTION_HALF_PX; k += 1) {
            const u = k + 0.5;
            const x = magX + ux * u;
            const y = magY + uy * u;
            sectionSoft.push(fieldAt(poles, x, y, FIELD_SOFTEN_PX));
            sectionHard.push(fieldAt(poles, x, y, 0));
        }
    }

화소마다 기울기를 다시 재면 32프레임에 한 장

03 의 왼쪽 판은 화소 하나마다 이웃 높이를 네 번 다시 재므로 배율 1 에서 72,000화소·높이 재기 288,000번, 배율 2 에서 288,000화소·1,152,000번이 한 장이다. 프레임 예산 9,000 화소로 나누면 8프레임과 32프레임에 한 장이 바뀌고, 그동안 화면 왼쪽 위에는 「N프레임째 같은 그림」 이 뜬다. 오른쪽 3px 격자는 배율과 상관없이 8,181칸이라 매 프레임 새로 칠한다. 화면 배율이 높은 기기일수록 화소 방식은 더 느려지니, 높이처럼 부드럽게 바뀌는 값은 격자에서 재고 늘려 붙이는 쪽이 맞다.

숨은 탭에서 돌아온 0.8초 한 프레임

05 는 1.15초에 0.8초를 한 프레임에 밀어 넣어 탭이 숨었다 돌아온 상황을 흉내 낸다. 받은 시간을 그대로 한 걸음에 쓴 왼쪽 웅덩이는 한 점이 3,116px 튀고 넓이가 −566배로 뒤집혀 꼬인 다각형이 되고, 0.05초에서 자르고 1/240초 잔걸음으로 나눈 오른쪽은 가장 크게 옮겨진 점이 12.7px·넓이 0.96배에 그친다. 장력 하나만 볼 때 그대로 한 걸음이 견디는 한계가 0.101초라 0.8초는 그 8배에 가깝다.

    // 한 걸음 — 넘겨받은 dt 를 그대로 한 번에(속도 먼저, 위치 다음). 자르지도 나누지도 않는다
    function stepRingRaw(r, poles, p, dt) {
        ringAccel(r, poles, p);
        for (let i = 0; i < r.n; i += 1) {
            r.vx[i] += ringAx[i] * dt;
            r.vy[i] += ringAy[i] * dt;
            r.x[i] += r.vx[i] * dt;
            r.y[i] += r.vy[i] * dt;
        }
    }
    // 나눠 걷기 — dt 를 PUDDLE_DT_MAX 에서 자르고, 그것을 PUDDLE_SUBSTEP 이하 잔걸음으로 쪼개 위 걸음을 되풀이
    const PUDDLE_DT_MAX = 0.05;
    const PUDDLE_SUBSTEP = 1 / 240;
    function stepRingSafe(r, poles, p, dt) {
        let left = Math.min(dt, PUDDLE_DT_MAX);
        while (left > 1e-9) {
            const h = Math.min(PUDDLE_SUBSTEP, left);
            stepRingRaw(r, poles, p, h);
            left -= h;
        }
    }

아홉 장면은 visibilitychange 로 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 숨은 시간을 첫 프레임 길이로 넘기지 않는다. 내려받은 zip 의 비밀번호는 zm79qtgy 이고, 05 폴더의 index.html 하나만 브라우저로 열어도 1.15초에 왼쪽 웅덩이가 꼬여 터지는 순간을 일시정지 버튼으로 붙잡아 볼 수 있다. 터진 모양은 그 자리에 멈춰 두므로, 오른쪽 웅덩이와 나란히 견주기 쉽다.

문턱을 낮추면 가시는 얼마나 일찍 솟을까 — 임계 세기·표면 장력·장 완화 거리를 바꿔 본 결과

이름 바꾼 값 느낌
가시가 더 일찍 솟게 02 CRITICAL_FIELD 0.45 → 0.3 시작 0.63초(깊이 88.4px) → 0.48초(109.3px), 가시가 선 반지름 79px → 101.2px, 가운데 목표 28.6px → 31.3px
웅덩이 둘레를 더 단단하게 05 SURFACE_TENSION 0.22 → 0.5 넓이 0.93~1.04 → 0.82~1.03배, 둥근 정도 0.66 → 0.68, 꼬리 62.8px → 65.4px, 그대로 한 걸음이 견디는 한계 0.101초 → 0.067초
극 바로 위를 더 날카롭게 01 FIELD_SOFTEN_PX 18 → 6 극 위 세기 11.1 → 100(9배), 극 옆 흐름 205 → 726px/s, 선 개수 17~18 → 18

값을 바꾸기 전에 볼 숫자

문턱을 0.3 으로 낮추면 같은 자석 길에서 0.15초 일찍 서고, 가운데 목표는 SPIKE_GAIN_PX × 0.15 = 2.7px 높아진다. 표면 장력을 0.5 로 올려도 꼬리는 오히려 2.6px 길어졌는데, 이 힘은 이웃 점 가운데로 당겨 둘레를 매끄럽게 할 뿐 끌려 늘어나는 길이를 직접 막지는 않는 것으로 보인다. 대신 단단함이 2 × 0.5 × 900 = 900 으로 커져 그대로 한 걸음이 견디는 한계가 2 ÷ √900 = 0.067초로 줄어드니, 이 값을 올릴 때는 잔걸음 1/240초를 그대로 두는 편이 안전하다.

거리 하한을 6px 로 줄이면 선 개수는 그대로지만 극 위 세기가 9배가 되어 극 옆 점선이 3.5배 빨라진다. 02 처럼 높이를 세기에 비례시키는 장면에 그 값을 옮기면 극 바로 위 칸만 튀어 오를 수 있으니, 앞 절의 첫 함정이 바로 그 경우다.

가시를 못 봐도 세기가 전해지게 — 자기장 세기를 알리는 문장과 멈춤 버튼, 움직임 줄이기 설정에서의 접근성

상태 줄이 읽어 주는 숫자

캔버스마다 role="img" 와 장면을 한 문장으로 설명하는 aria-label, tabindex="0" 을 달았다. 캔버스에 초점이 있으면 화살표 키가 조절점을 8px(Shift 를 누르면 24px) 옮기고 Enter·Space 는 가운데로 되돌리며, 08 만은 화살표·Enter·누르기가 시각을 한 번 넘긴다. 상태 줄은 aria-live="off" 로 시작해 첫 입력 뒤 polite 로 바뀌고, 02 라면 「세기 2.04 · 문턱 0.45 넘음 — 가시가 선 반지름 79px」 처럼 글자가 바뀔 때만 한 문장을 읽어 준다.

움직임 줄이기와 일시정지

prefers-reduced-motion: reduce 에서는 루프를 돌리지 않고 정해 둔 시각의 모습에 스프링을 목표로 바로 앉힌 한 장만 그려 시간이 지나도 화면이 같다. 01 의 점선 흐름은 0 에서 멈추고 05·06·08 은 그 시각까지를 한 번에 계산한 멈춘 모습을 보인다. 이 상태에서 화살표를 누르면 바뀐 자리로 한 장을 다시 그리고 상태 줄도 갱신된다. 일시정지 버튼은 아홉 장면에 다 있고 누르면 aria-pressed="true" 로 바뀐다. 알림 문장의 동작은 MDN aria-live에, 설정 쿼리는 MDN prefers-reduced-motion에 정리되어 있다.

자성유체 원리 FAQ — 진짜 액체와 다른 점·휴대폰에서 버거울 때·검은 액체의 대비

자성 유체 실험 영상에서 보던 진짜 액체와 무엇이 다른가요

02·07·09 의 가시는 액체가 어떻게 흐르는지를 풀지 않는다. 세기 장에서 높이 목표를 정하고 칸마다 스프링으로 따라가게 한 뒤, 정해 둔 간격의 세 물결 무늬를 곱해 가시 모양을 만든다. 그래서 가시 간격은 세기에 따라 바뀌지 않고 02 에서는 SPIKE_SPACING_PX 28px 로 고정이며, 세기 숫자도 실제 단위가 아닌 화면 상대값이다.

휴대폰에서 버거우면 어디를 줄이나요

먼저 격자 칸 크기다. 02 는 CELL_PX 2 로 36,391칸을 매 프레임 재는데, 칸을 키우면 칸 수가 그 제곱에 반비례해 줄어든다. 캔버스 화소 배율은 fitCanvas 가 2 로 묶어 두었고, 06 은 44px 칸 공간 격자로 짝 검사를 최대 1,127번(모든 짝 9,730번)으로 줄였다. 장 합산을 미리 표로 두는 방법은 07(세기 1 표에 배율만 곱함)과 08(시각이 바뀔 때만 합산)이 쓰고 있다.

검은 액체 위에서 하이라이트와 글자가 잘 보이나요

액체 색 FLUID 는 거의 검정 [13,12,17] 으로 두고 파스텔 변환에서 뺐다. 이 색이 어두워야 분홍·하늘색 하이라이트가 가시 끝에서 맺혀 보이기 때문이다. 상태 줄과 버튼은 $subject-ink 바탕 위 $subject-cream 글자로 캔버스 밖에 둔다. 캔버스에 그림을 적는 방식은 MDN putImageData에, 탭 숨김 이벤트는 MDN visibilitychange에 있다.

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

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