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

3d 웹사이트 만들기 9종 — 마우스를 따라 도는 라이브러리 없는 모형

3d 웹사이트는 라이브러리 없이 점 좌표와 투영 공식 하나로도 만들어진다 — 02번은 매듭 곡선을 관으로 감싸 마우스 쪽으로 비틀고, 05번은 누르면 상자 덮개가 차례로 스프링처럼 열리며, 08번은 작은 큐브 모형이 흩어졌다 다시 모인다.

마우스를 대면 돌아보는 3D 모형 아홉 개를 이 화면에서 굴려 보기

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

3d 웹사이트 만들기 9종 — 다면체·매듭·점 구름·톱니·상자·나선·고리·복셀·변신 점

순서는 선만 있는 다면체(01)에서 시작해 면을 칠하는 관(02), 면 없이 점만 찍는 구(03)로 그리는 방식을 바꾸고, 두 물체가 맞물리는 톱니(04)와 경첩으로 열리는 상자(05)에서 부품끼리의 관계를 다룬다. 06·07 은 회전 속도 자체를 포인터가 정하는 모형이고, 08·09 는 모형을 이루는 조각이 흩어지거나 다른 모양으로 갈아타는 장면이다. 아홉 개 모두 <canvas> 2D 위에 다각형·원·선을 칠할 뿐이고, 화소를 읽어 오는 getImageData 는 한 번도 부르지 않는다.

이 데모가 직접 계산하는 것과 계산하지 않는 것

직접 하는 일은 다섯 가지로 정리된다. 점마다 회전 행렬을 곱하고, 깊이로 나눠 화면 좌표를 얻고, 먼 것부터 칠할 순서를 정렬하고, 법선이 화면을 등진 면을 거르고, 법선과 빛 방향의 내적으로 밝기를 매긴다. 화소마다 깊이를 비교하는 깊이 버퍼, 그림자 계산, 표면 무늬 입히기는 하지 않으므로, 이것을 범용 3D 엔진으로 여기면 곤란하다. 대신 모형 하나를 첫 화면에서 돌리는 데 필요한 만큼은 이 다섯 가지로 충분하다는 것을 아홉 모형이 보여 준다.

480×300 무대를 2초 동안 찍어 잰 움직임

캡처는 480×300 무대에서 2초에 걸쳐 스물네 장을 뜨고 앞뒤 두 장의 차이를 세는 방식이라, 프레임 칸의 분모가 23 이 된다.

모형 누적면적 움직인 프레임 강도 평균
01 관성 와이어프레임 21.1% 23/23 83.3
02 토러스 매듭 관 20.7% 23/23 62.2
03 점 구름 함몰 24.1% 23/23 85.4
04 맞물린 톱니 10.6% 23/23 93.6
05 상자 덮개 스프링 20.7% 23/23 62.7
06 이중 나선 꼬임 18.1% 23/23 81.3
07 세 겹 고리 던지기 23.7% 23/23 84.6
08 복셀 흩어짐 11.9% 23/23 58.2
09 점 변신 20.5% 23/23 84.7

아홉 개가 모두 23장 내내 움직였고 10% 아래로 떨어진 모형은 없다. 다만 누적면적은 가장 큰 03 도 24.1% 로, 캔버스 특수효과 편이 장면에 두는 목표 30% 에는 아홉 개 모두 못 미친다. 모형이 무대 가운데 일부만 차지하고 나머지는 빈 배경이라 바뀌는 화소가 모형 둘레 안에 머물기 때문이다. 04 와 08 이 특히 낮은데, 04 는 제자리에서 도는 톱니의 이 가장자리만 화소가 바뀌고 08 은 복셀이 흩어지는 순간 말고는 하트가 거의 제자리에 있다.

어떤 첫 화면에 어떤 모형을 올리나

브랜드를 상징하는 물건 하나를 띄우고 싶은 랜딩이라면 01 의 다면체가 가장 가볍고, 포트폴리오처럼 형태 자체를 보여 주고 싶은 곳에는 02 의 매듭이 맞다. 데이터나 AI 서비스의 히어로는 03 의 점 구름, 「이렇게 작동합니다」 섹션은 04 의 톱니가 어울린다. 상품 공개나 선물 이벤트 화면은 05 의 상자, 바이오·헬스케어 첫 화면은 06 의 나선이 주제와 바로 이어진다. 07 은 손으로 던져 보는 재미가 있어 대기 화면 장식에 좋고, 08 은 게임 소개나 이벤트 페이지, 09 는 서비스 단계를 하나씩 바꿔 보여 주는 배경 자리에 들어간다.

3d 웹사이트 레퍼런스로 옆에 두고 보기 좋도록, 아홉 모형은 무거운 장면 대신 무대 오른쪽에 물건 하나만 세워 포인터 쪽으로 돌린다. 모형 중심은 (300, 150) 이라 왼쪽 위 버튼과 왼쪽 아래 상태 줄을 비켜 간다.

평평한 화면 위의 점은 어떻게 앞뒤 깊이를 가진 모형처럼 보이는가?

모든 모형은 같은 좌표계를 쓴다. 화면 오른쪽이 +x, 아래가 +y, 화면 안쪽이 +z 이고, 그래서 화면을 보는 면은 법선의 z 가 음수다. 점을 돌리는 것은 rotX·rotY·rotZ 세 회전 행렬이고, 시점 행렬 viewMatrix(yaw, pitch) 는 세로축으로 먼저 돌린 다음 가로축으로 기울인다.

깊이로 나누면 먼 점이 작아지고 가운데로 모인다

돌린 점을 화면에 옮기는 일은 아래 project 한 함수가 맡는다. 배율 s = focal ÷ (focal + z) 이라 z 가 클수록 s 가 작아지고, 화면 좌표가 모형 중심 쪽으로 끌려 들어간다. 01 의 기본값 FOCAL_PX 600 에서 외접구 반지름 110px 인 다면체의 가장 가까운 꼭짓점은 600/490 = 1.224배, 가장 먼 꼭짓점은 600/710 = 0.845배가 되어 앞뒤 크기 차가 1.45배 난다.

    // 원근 나눗셈 — 화면 배율 s = focal / (focal + z). 깊이 z 가 클수록(안쪽) 작아지고 가운데로 모인다
    function project(p, focal) {
        const s = focal / (focal + p[2]);
        return { x: CX + p[0] * s, y: CY + p[1] * s, s: s, z: p[2] };
    }
    // 빛 — 왼쪽 위 앞에서 비춘다
    const LIGHT = vnorm([-0.45, -0.62, -0.64]);
    function lambert(n) { return Math.max(0, dot(n, LIGHT)); }

크기만으로는 모자라서 순서와 밝기를 더한다

배율만 바꾸면 겹친 부분에서 뒤가 앞을 덮을 수 있다. 그래서 09 를 뺀 여덟 모형은 그리기 전에 깊이로 정렬해 먼 것부터 칠하고(04 는 옆면과 바퀴 순서로 두 번), 01·02·04·05·08 은 법선이 화면을 등진 면을 아예 건너뛴다. 07 은 고리 안쪽 면을 거르지 않고 어둡게 칠한다. 칠하는 면의 밝기는 왼쪽 위 앞에서 오는 빛 LIGHT 와 법선의 내적을 0 아래로 자른 lambert 값으로 정해, 같은 색이라도 빛을 마주한 면은 밝고 비스듬한 면은 어둡다.

포인터 위치가 회전각이 되고, 모형이 그 각을 부드럽게 뒤따라가는 길

포인터 좌표는 getBoundingClientRect 로 캔버스 안의 비율을 구해 480×300 장면 좌표로 바꾼다. 01·02·04·05·08·09 에서는 그 점이 모형 중심에서 가로로 얼마나 벗어났는지(−1~1)에 YAW_RANGE 1.1 rad 를, 세로로 벗어난 정도에 PITCH_RANGE 0.8 rad 를 곱한 것이 목표 각이고, 04 는 벗어남에 0.45 를, 05·08 은 0.4 를 먼저 곱해 덜 돌린다. 03 은 포인터를 함몰 자리로, 06 은 꼬임 속도로, 07 은 끌기로 쓰므로 이 목표 각을 포인터에서 얻지 않는다. 06·07 을 뺀 일곱 모형은 목표 각으로 바로 뛰지 않고 viewFollow 가 16ms 스텝마다 남은 차이에 비율 k 를 곱한 만큼만 다가간다.

    function viewFollow(v, k) {
        v.yaw += (v.yawTo - v.yaw) * k;
        v.pitch += (v.pitchTo - v.pitch) * k;
    }
    function viewSnap(v) { v.yaw = v.yawTo; v.pitch = v.pitchTo; }
    function viewHome(v) { v.yawTo = v.yaw0; v.pitchTo = v.pitch0; }
    function viewGap(v) { return Math.hypot(v.yawTo - v.yaw, v.pitchTo - v.pitch); }
    // 포인터 → 목표 각. 모형 중심에서 가로로 벗어난 만큼 yaw, 세로로 벗어난 만큼 pitch (오른쪽을 가리키면 앞면이 오른쪽으로 돈다)
    const YAW_RANGE = 1.1;
    const PITCH_RANGE = 0.8;
    function aimAt(v, px, py) {
        v.yawTo = v.yaw0 - clamp((px - CX) / (W / 2), -1, 1) * YAW_RANGE;
        v.pitchTo = v.pitch0 + clamp((py - CY) / (H / 2), -1, 1) * PITCH_RANGE;
    }

프레임이 아니라 16ms 고정 스텝으로 센다

requestAnimationFrame 이 넘겨주는 시각 차를 쌓아 두고(한 프레임에 100ms 까지만), 쌓인 시간에서 16ms 를 꺼낼 수 있을 때마다 step() 을 한 번씩, 한 프레임에 최대 여섯 번 돌린 뒤 draw() 를 부른다. 그래서 따라가는 빠르기나 마찰이 화면 주사율에 따라 달라지지 않는다. 방향키도 같은 목표 각을 움직이는데, 01~05·08·09 는 한 번 누를 때마다 KEY_STEP 0.3 rad 씩 돌리고, 06 은 방향키를 꼬임 속도와 기울기에, 07 은 ←/→ 를 던지기에 쓴다.

01마우스 쪽으로 관성을 두고 돌며 깊이만큼 흐려지는 와이어프레임 다면체

꼭짓점 12개를 (0, ±1, ±φ) 세 벌로 놓으면 원래 좌표에서 거리가 2 인 쌍 30개가 모서리, 서로 다 이웃한 세 점 20묶음이 면이 되고, 모형은 포인터가 정한 목표 각을 16ms 마다 남은 차의 8% 씩 좁혀 차가 10% 로 줄기까지 28스텝, 448ms 가 걸린다. 모서리는 먼 것부터 그리면서 깊이에 따라 굵기와 투명도를 깎아, 깊이 값이 끝까지 간 뒤쪽에서는 굵기와 투명도가 둘 다 25% 만 남는다. 랜딩 첫 화면의 브랜드 오브젝트로 두기 좋고, 렌더에서는 누적면적 21.1%·강도 83.3·23/23 프레임이 나왔다.

마우스 쪽으로 관성을 두고 돌며 깊이만큼 흐려지는 와이어프레임 다면체
새 창에서 열기

01 · 마우스 쪽으로 관성을 두고 돌며 깊이만큼 흐려지는 와이어프레임 다면체 — 측정 누적면적 21.065% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ROT_LERPFOCAL_PXDEPTH_FADE
    // 꼭짓점 12개 — (0, ±1, ±φ) 를 x·y·z 자리로 돌려 세 벌 만든다
    function icosaRaw() {
        const out = [];
        for (let i = 0; i < 4; i += 1) {
            const a = i & 1 ? 1 : -1;
            const b = i & 2 ? PHI : -PHI;
            out.push([0, a, b]);
            out.push([a, b, 0]);
            out.push([b, 0, a]);
        }
        return out;
    }
    const RAW = icosaRaw();
    const VERTS = RAW.map(function (p) { return vscale(vnorm(p), R); });
    // 모서리 30개 — 원래 좌표에서 거리가 2 인 꼭짓점 쌍
    const EDGES = [];
    for (let i = 0; i < RAW.length; i += 1) {
        for (let j = i + 1; j < RAW.length; j += 1) {
            const d = vsub(RAW[i], RAW[j]);
            if (Math.abs(dot(d, d) - 4) < 1e-6) {
                EDGES.push([i, j]);
            }
        }
    }

02매듭 곡선을 관으로 감싸 마우스를 따라 비트는 토러스 매듭

(2, 3) 매듭 곡선 위 132곳마다 접선, 곡률 쪽 법선, 둘에 수직인 세 번째 축으로 틀을 세우고 둘레 10점을 반지름 13px 로 둘러 면 1,320장짜리 관을 짓는다. 법선이 화면을 등진 면을 거르고 남은 면만 깊이 평균순으로 칠하므로 기본 시점에서는 683장만 그려지고, 누르거나 「다른 매듭」 을 고르면 (3, 5)·(2, 5) 로 관을 다시 만든다. 포트폴리오 히어로처럼 형태가 곧 볼거리인 자리에 맞고, 누적면적 20.7%·강도 62.2·23/23 프레임으로 측정됐다.

매듭 곡선을 관으로 감싸 마우스를 따라 비트는 토러스 매듭
새 창에서 열기

02 · 매듭 곡선을 관으로 감싸 마우스를 따라 비트는 토러스 매듭 — 측정 누적면적 20.738% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

KNOT_PKNOT_QTUBE_R
    function knotAt(p, q, phi) {
        const r = 2 + Math.cos(q * phi);
        return [r * Math.cos(p * phi) * KNOT_SCALE, r * Math.sin(p * phi) * KNOT_SCALE, -Math.sin(q * phi) * KNOT_SCALE];
    }
    // 관 만들기 — 점마다 접선 T, 곡률 쪽 법선 N, 둘 다에 수직인 B 로 틀을 세우고 둘레 점을 고리로 두른다
    function buildTube(p, q) {
        const verts = [];
        const ringDir = [];
        const h = TAU / SEG_U / 4;
        for (let i = 0; i < SEG_U; i += 1) {
            const phi = (i / SEG_U) * TAU;
            const c = knotAt(p, q, phi);
            const a = knotAt(p, q, phi - h);
            const b = knotAt(p, q, phi + h);
            const tng = vnorm(vsub(b, a));
            const accel = vadd(vsub(b, c), vsub(a, c));
            const nrm = vnorm(vsub(accel, vscale(tng, dot(accel, tng))));
            const bin = cross(tng, nrm);
            for (let j = 0; j < SEG_V; j += 1) {
                const v = (j / SEG_V) * TAU;
                const dir = vadd(vscale(nrm, Math.cos(v)), vscale(bin, Math.sin(v)));
                ringDir.push(dir);
                verts.push(vadd(c, vscale(dir, TUBE_R)));
            }
        }
        const m = matMul(viewMatrix(view.yaw, view.pitch), rotZ(spin));
        const rv = tube.verts.map(function (p) { return apply(m, p); });
        const pv = rv.map(function (p) { return project(p, FOCAL); });
        const faceN = [];
        const faceZ = [];
        const order = [];
        for (let i = 0; i < SEG_U; i += 1) {
            const i2 = (i + 1) % SEG_U;
            for (let j = 0; j < SEG_V; j += 1) {
                const j2 = (j + 1) % SEG_V;
                const f = i * SEG_V + j;
                const n = apply(m, tube.normals[f]);
                faceN.push(n);
                faceZ.push((rv[i * SEG_V + j][2] + rv[i * SEG_V + j2][2] + rv[i2 * SEG_V + j][2] + rv[i2 * SEG_V + j2][2]) / 4);
                // 화면을 등진 면(법선 z > 0)은 건너뛴다 — 관 안쪽 면이 앞으로 비쳐 나오지 않는다
                if (n[2] < 0.05) {
                    order.push(f);
                }
            }
        }

03포인터가 닿은 자리가 움푹 들어갔다 되돌아오는 점 구름 구

점 720개를 높이는 고르게 나누고 점마다 황금각 137.508° 씩 돌려 구 표면에 펴 두고, 화면 쪽 점 가운데 투영 위치가 포인터에서 66px 안에 든 것만 반지름을 최대 34% 까지 줄인다. 되돌아오는 힘은 감쇠비 0.38 스프링이라 가장 깊이 들어간 점이 제자리를 26.1% 넘어 바깥으로 부풀었다가 880ms 에 자리를 잡는다. 데이터나 AI 서비스 히어로에 어울리며, 렌더 값은 누적면적 24.1%·강도 85.4·23/23 프레임이다.

포인터가 닿은 자리가 움푹 들어갔다 되돌아오는 점 구름 구
새 창에서 열기

03 · 포인터가 닿은 자리가 움푹 들어갔다 되돌아오는 점 구름 구 — 측정 누적면적 24.134% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

POINT_COUNTDENT_RADIUSDENT_SPRING
    // 황금각 나선 — 높이를 고르게 나누고 점마다 황금각만큼 돌려 놓는다
    function goldenSphere(n) {
        const out = [];
        for (let i = 0; i < n; i += 1) {
            const y = 1 - (2 * (i + 0.5)) / n;
            const r = Math.sqrt(1 - y * y);
            const th = i * GOLDEN_ANGLE;
            out.push([r * Math.cos(th), y, r * Math.sin(th)]);
        }
        return out;
    }
    // 포인터 근접 → 목표 반지름. 화면 쪽(z < 0) 점만, 가까울수록 깊게
    function dentTarget(p, front, ax, ay) {
        if (ax < 0 || !front) {
            return 1;
        }
        const d = Math.hypot(p.x - ax, p.y - ay);
        if (d >= DENT_RADIUS) {
            return 1;
        }
        return 1 - DENT_DEPTH * smooth(1 - d / DENT_RADIUS);

04톱니 수 비율대로 맞물려 돌고 마우스로 시점이 바뀌는 입체 톱니바퀴

톱니 18개 큰 바퀴와 10개 작은 바퀴의 윤곽을 앞뒤로 9px 씩 밀어 두께 18px 의 입체로 만들고, 작은 바퀴 각을 큰 바퀴 각 × 18/10 의 반대 부호로 두어 큰 바퀴가 톱니 하나(20°) 돌 때 작은 바퀴는 −36°, 곧 자기 톱니 하나만큼 돈다. 작은 바퀴는 반 톱니 18° 를 더한 위상에서 출발해 큰 바퀴를 마주 보는 자리가 홈이 되고, 피치 원 위 선속도는 두 바퀴 모두 56.7px/s 로 같다. 서비스 작동 원리를 보여 주는 섹션에 맞고, 측정은 누적면적 10.6%·강도 93.6·23/23 프레임이다.

톱니 수 비율대로 맞물려 돌고 마우스로 시점이 바뀌는 입체 톱니바퀴
새 창에서 열기

04 · 톱니 수 비율대로 맞물려 돌고 마우스로 시점이 바뀌는 입체 톱니바퀴 — 측정 누적면적 10.555% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TEETH_ATEETH_BTOOTH_DEPTH
    // 톱니 윤곽 — 이 하나에 네 점: 뿌리 왼쪽·끝 왼쪽·끝 오른쪽·뿌리 오른쪽 (끝이 뿌리보다 좁은 사다리꼴)
    function gearOutline(teeth, r, phase) {
        const out = [];
        const per = TAU / teeth;
        const r0 = r - TOOTH_DEPTH / 2;
        const r1 = r + TOOTH_DEPTH / 2;
        for (let k = 0; k < teeth; k += 1) {
            const a = phase + k * per;
            const spots = [[-0.28, r0], [-0.15, r1], [0.15, r1], [0.28, r0]];
            for (let s = 0; s < spots.length; s += 1) {
                const th = a + spots[s][0] * per;
                out.push([Math.cos(th) * spots[s][1], Math.sin(th) * spots[s][1]]);
            }
        }
        return out;
    }
    const GEARS = [
        { teeth: TEETH_A, r: RA, cx: SHIFT, outline: gearOutline(TEETH_A, RA, 0), col: PAL[0] },
        { teeth: TEETH_B, r: RB, cx: SHIFT + RA + RB, outline: gearOutline(TEETH_B, RB, Math.PI + HALF_TOOTH), col: PAL[4] },
    ];
    // 작은 바퀴 각 — 큰 바퀴 각에 톱니 수 비를 곱하고 부호를 뒤집는다
    function angleB(a) { return -a * TEETH_A / TEETH_B; }
    function report() { say(running ? TXT_RUN : TXT_STOP); }
    function step() {
        viewFollow(view, VIEW_LERP);
        if (running) {
            angA += dir * SPIN_A * FIXED_DT / 1000;
        }
    }

05누르면 덮개 네 장이 차례로 스프링처럼 열리는 3D 상자 모형

윗면을 꼭짓점이 가운데로 모이는 삼각형 넷으로 나눈 덮개를 각자 경첩 축 둘레로 들어 올리고, 열림 각은 덮개마다 110ms 씩 늦게 출발하도록 예약한 감쇠비 0.527 스프링으로 적분한다. 한 장은 목표 210° 를 지나 368ms 에 236.7°(12.7% 넘침)까지 갔다가 돌아오고, 출발 예약이 16ms 스텝 단위로 처리돼 실제 출발이 16·112·224·336ms 가 되므로 네 장이 ±2° 안에 머물기 시작하는 시각도 880·976·1,088·1,200ms 로 96·112·112ms 간격을 두고 차례가 난다. 상품 공개나 선물 이벤트 화면에 쓰기 좋으며, 렌더 수치는 누적면적 20.7%·강도 62.7·23/23 프레임이다.

누르면 덮개 네 장이 차례로 스프링처럼 열리는 3D 상자 모형
새 창에서 열기

05 · 누르면 덮개 네 장이 차례로 스프링처럼 열리는 3D 상자 모형 — 측정 누적면적 20.651% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LID_OPEN_DEGLID_SPRINGFLAP_STAGGER
    // 덮개 끝점 — 경첩에서 안쪽으로 S 만큼 뻗은 꼭짓점을 경첩 축 둘레로 θ 만큼 들어 올린다
    function flapTip(f, th) {
        return vadd(f.hinge, vadd(vscale(f.inward, S * Math.cos(th)), [0, -S * Math.sin(th), 0]));
    }
    function setOpen(v) {
        open = v;
        for (let i = 0; i < flaps.length; i += 1) {
            // 닫을 때는 마지막에 연 덮개부터
            const order = open ? i : flaps.length - 1 - i;
            flaps[i].start = lidClock + order * FLAP_STAGGER;
        }
        if (lidBtn) {
            lidBtn.setAttribute('aria-pressed', open ? 'true' : 'false');
        }
    }
    function step() {
        viewFollow(view, VIEW_LERP);
        lidClock += FIXED_DT;
        gemSpin += 1.2 * FIXED_DT / 1000;
        for (let i = 0; i < flaps.length; i += 1) {
            const f = flaps[i];
            if (f.start >= 0 && lidClock >= f.start) {
                f.spring.to = open ? LID_OPEN_DEG * DEG : 0;
            }
            springStep(f.spring, LID_SPRING, LID_DAMP, FIXED_DT / 1000);
            // 닫히다 넘친 덮개는 상자 안으로 들어가지 않고 윗면에서 살짝 튕긴다
            if (f.spring.x < 0) {
                f.spring.x = 0;
                f.spring.v = -f.spring.v * 0.3;
            }
        }
    }

06마우스 가로 위치에 따라 꼬임 속도가 바뀌는 이중 나선 구슬 모형

두 가닥의 구슬 34쌍을 같은 높이에서 180° 어긋나게 2.5바퀴 감고 세 칸마다 가로대를 걸어, 가로대는 모두 12개다. 꼬임 속도는 포인터의 가로 위치에서 정해 왼쪽 끝 −2.0, 가운데 0.6, 오른쪽 끝 3.2 rad/s 사이를 오가고, 세로 위치는 앞뒤 기울기가 되며, 구슬·등뼈·가로대를 한 목록에 넣어 깊이순으로 그린다. 바이오나 헬스케어 서비스의 첫 화면에 맞고, 누적면적 18.1%·강도 81.3·23/23 프레임으로 측정됐다.

마우스 가로 위치에 따라 꼬임 속도가 바뀌는 이중 나선 구슬 모형
새 창에서 열기

06 · 마우스 가로 위치에 따라 꼬임 속도가 바뀌는 이중 나선 구슬 모형 — 측정 누적면적 18.058% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HELIX_TURNSRUNG_EVERYTWIST_GAIN
    // 포인터 → 꼬임 속도·기울기 목표
    function aimTwist(x, y) {
        twistTo = BASE_TWIST + TWIST_GAIN * clamp((x - CX) / (W / 2), -1, 1);
        tiltTo = clamp((y - CY) / (H / 2), -1, 1) * TILT_MAX;
    }
    function step() {
        twist += (twistTo - twist) * TWIST_LERP;
        tilt += (tiltTo - tilt) * TWIST_LERP;
        if (!paused) {
            phase += twist * FIXED_DT / 1000;
        }
    }
    function settle() { twist = twistTo; tilt = tiltTo; }
        // 세로축 둘레 꼬임은 phase 가, 앞뒤 기울기는 가로축 회전이, 살짝 옆으로 누운 자세는 깊이축 회전이 맡는다
        const m = matMul(rotZ(-0.32), rotX(tilt));
        const bs = beads();
        const rv = bs.map(function (b) { return apply(m, b.p); });
        const pv = rv.map(function (p) { return project(p, FOCAL); });
        const cols = [PAL[1], PAL[2]];
        const items = [];
        let rungs = 0;
        for (let i = 0; i < bs.length; i += 1) {
            items.push({ z: rv[i][2], kind: 0, a: pv[i], b: pv[i], ca: cols[bs[i].strand], cb: cols[bs[i].strand] });
            // 같은 가닥 다음 구슬과 잇는 등뼈
            if (i + 2 < bs.length) {
                items.push({ z: (rv[i][2] + rv[i + 2][2]) / 2 + 0.5, kind: 1, a: pv[i], b: pv[i + 2], ca: cols[bs[i].strand], cb: cols[bs[i].strand] });
            }
        }
        for (let i = 0; i < BEADS; i += RUNG_EVERY) {
            const a = 2 * i, b = 2 * i + 1;
            items.push({ z: (rv[a][2] + rv[b][2]) / 2 + 1, kind: 2, a: pv[a], b: pv[b], ca: cols[0], cb: cols[1] });
            rungs += 1;
        }
        // 깊이순 — 먼 구슬·가로대부터
        items.sort(function (p, q) { return q.z - p.z; });

07던지듯 끌면 관성으로 돌다 서서히 멈추는 세 겹 고리

끌기를 놓는 순간의 손 빠르기를 각속도로 바꾸고, 놓은 뒤에는 16ms 마다 0.96 을 곱해 줄이면서 바깥 고리는 세로축, 가운데는 가로축, 안쪽은 깊이축으로 같은 각을 나눠 돌린다. 스텝당 0.1 rad 로 던지면 속도가 5% 로 줄기까지 74스텝(1.18초) 동안 130.8° 를 더 돌고 멈출 때까지는 136.8° 를 도는데, 등비급수 닫힌 식 w·F/(1−F) 가 주는 2.4 rad 와 실측 2.388 rad 가 맞아떨어진다. 만지작거리며 노는 첫 화면이나 기다리는 화면 장식에 어울리고, 렌더에서 누적면적 23.7%·강도 84.6·23/23 프레임이 나왔다.

던지듯 끌면 관성으로 돌다 서서히 멈추는 세 겹 고리
새 창에서 열기

07 · 던지듯 끌면 관성으로 돌다 서서히 멈추는 세 겹 고리 — 측정 누적면적 23.661% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLICK_GAINSPIN_FRICTIONRING_COUNT
    // 끌기 한 스텝의 움직임 → 부호 있는 빠르기. 가로가 크면 가로 방향, 세로가 크면 세로 방향을 따른다
    function signedSpeed(dx, dy) { return Math.abs(dx) >= Math.abs(dy) ? dx : dy; }
    // 손을 뗀 뒤 한 스텝 — 각속도에 마찰 계수를 곱한다
    function coast(v) {
        const n = v * SPIN_FRICTION;
        return Math.abs(n) < STOP_W ? 0 : n;
    }
    function step() {
        if (dragging) {
            // 끄는 동안은 손 빠르기를 바로 각속도로 — 반쯤만 섞어 손 떨림을 거른다
            const target = clamp(signedSpeed(px - lastX, py - lastY) * FLICK_GAIN, -MAX_SPIN, MAX_SPIN);
            w = lerp(w, target, 0.5);
            lastX = px;
            lastY = py;
        }
        else {
            w = coast(w);
        }
        angle += w;
        turned += Math.abs(w);
    }
    // 고리 i 의 자세 — 바깥 고리 안에 가운데, 그 안에 안쪽이 매달린 짐벌. 같은 각을 각자 다른 축에 쓴다
    function axisRot(axis, a) { return axis === 'y' ? rotY(a) : (axis === 'x' ? rotX(a) : rotZ(a)); }
    function ringMatrix(i) {
        let m = axisRot(RING_AXIS[0], angle);
        for (let k = 1; k <= i; k += 1) {
            m = matMul(m, axisRot(RING_AXIS[k], angle));
        }
        return m;
    }

08마우스를 대면 흩어졌다 다시 모이는 작은 큐브 모형

9×8 문자표의 # 46칸을 두 겹 쌓아 복셀 92개로 하트를 만들고, 투영 위치가 포인터 64px 안에 새로 들어온 복셀에만 바깥 방향 속도를 준 뒤 감쇠비 0.406 스프링으로 제자리에 끌어당긴다. 한 번 밀린 복셀은 192ms 에 가장 먼 38px 까지 나갔다가 1,520ms 부터 1px 안에 머물고, 큐브마다 화면을 보는 면만 서로 다른 밝기로 칠한다. 이벤트 페이지나 게임 소개 히어로에 맞으며, 측정은 누적면적 11.9%·강도 58.2·23/23 프레임이다.

마우스를 대면 흩어졌다 다시 모이는 작은 큐브 모형
새 창에서 열기

08 · 마우스를 대면 흩어졌다 다시 모이는 작은 큐브 모형 — 측정 누적면적 11.915% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

VOXEL_SIZESCATTER_FORCEREFORM_K
    // 포인터 반경에 새로 든 복셀 — 화면에서 포인터 반대쪽으로 밀고, 시드 값만큼 앞뒤로도 튀긴다
    function scatter(m) {
        for (let i = 0; i < voxels.length; i += 1) {
            const v = voxels[i];
            const q = project(apply(m, v.pos), FOCAL);
            const d = Math.hypot(q.x - px, q.y - py);
            const inside = px >= 0 && d < SCATTER_RADIUS;
            if (inside && !v.inside) {
                const dx = d > 1e-3 ? (q.x - px) / d : 0;
                const dy = d > 1e-3 ? (q.y - py) / d : -1;
                // 화면 방향을 모형 좌표로 되돌린다 — 회전 행렬의 전치
                const dir = applyT(m, vnorm([dx, dy, -0.6 + 0.4 * v.kick]));
                const k = SCATTER_FORCE * (1 - 0.5 * d / SCATTER_RADIUS);
                v.vel = vadd(v.vel, vscale(dir, k));
            }
            v.inside = inside;
        }
    }
    function step() {
        viewFollow(view, VIEW_LERP);
        wobble += 0.9 * FIXED_DT / 1000;
        const m = matrix();
        scatter(m);
        const dt = FIXED_DT / 1000;
        for (let i = 0; i < voxels.length; i += 1) {
            const v = voxels[i];
            // 스프링 귀환 — 가속 = −k(위치 − 제자리) − d·속도
            const a = vsub(vscale(vsub(v.pos, v.home), -REFORM_K), vscale(v.vel, REFORM_DAMP));
            v.vel = vadd(v.vel, vscale(a, dt));
            v.pos = vadd(v.pos, vscale(v.vel, dt));
        }
    }

09점 구름이 정육면체·구·고리 모양으로 차례로 바뀌며 도는 장면

정육면체·구·고리 표면에 520점씩 시드로 뽑아 높이순으로 줄 세운 뒤 같은 순번의 점끼리 짝지어 900ms 동안 위치와 색을 보간한다. 그 사이에 얹는 흔들림의 폭은 14·sin(π·진행률) 이라 출발과 도착에서 0, 시간 25% 지점에서 2.73px, 50% 지점에서 14px 가 되고, 포인터는 모형 전체의 시점을 돌린다. 서비스 단계를 하나씩 바꿔 보여 주는 배경에 쓰기 좋고, 렌더 값은 누적면적 20.5%·강도 84.7·23/23 프레임이다.

점 구름이 정육면체·구·고리 모양으로 차례로 바뀌며 도는 장면
새 창에서 열기

09 · 점 구름이 정육면체·구·고리 모양으로 차례로 바뀌며 도는 장면 — 측정 누적면적 20.519% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MORPH_MSSHAPE_ORDERNOISE_AMP
    function sample(name, seed) {
        const rnd = makeRng(seed);
        const out = [];
        for (let i = 0; i < N; i += 1) {
            if (name === 'cube') {
                const f = Math.floor(rnd() * 6);
                const u = rnd() * 2 - 1, v = rnd() * 2 - 1, s = f % 2 ? 1 : -1;
                const a = SIZE * 0.78;
                out.push(f < 2 ? [u * a, v * a, s * a] : (f < 4 ? [s * a, u * a, v * a] : [u * a, s * a, v * a]));
            }
            else if (name === 'sphere') {
                const y = 1 - (2 * (i + 0.5)) / N;
                const r = Math.sqrt(1 - y * y);
                const th = i * Math.PI * (3 - Math.sqrt(5));
                out.push([r * Math.cos(th) * SIZE, y * SIZE, r * Math.sin(th) * SIZE]);
            }
            else {
                const u = rnd() * TAU, v = rnd() * TAU;
                const big = SIZE * 0.8, small = SIZE * 0.3;
                out.push([(big + small * Math.cos(v)) * Math.cos(u), small * Math.sin(v), (big + small * Math.cos(v)) * Math.sin(u)]);
            }
        }
        out.sort(function (a, b) { return a[1] - b[1]; });
        return out;
    }
    // 점마다 정수 해시 → −1 ~ 1. 같은 점·같은 마디면 늘 같은 값
    function hash(i, k) {
        let h = Math.imul(i, 374761393) ^ Math.imul(k, 668265263);
        h = Math.imul(h ^ (h >>> 13), 1274126177);
        return (((h ^ (h >>> 16)) >>> 0) / 4294967296) * 2 - 1;
    }
    // 값 노이즈 — 마디 값 둘을 smoothstep 으로 잇는다
    function noise(i, ms) {
        const x = ms / NOISE_STEP;
        const k = Math.floor(x);
        return lerp(hash(i, k), hash(i, k + 1), smooth(x - k));
    }

점을 수천 개로 늘리고 카메라를 바짝 대고 뒷면이 앞으로 돌 때 — 입체 착시가 깨지는 지점

아래는 소스 함수를 직접 불러 확인한 값과 소스의 식에서 다시 계산한 값이다. 기종별 프레임 소요 시간은 재지 않았다.

점 수를 늘리면 정렬과 투영이 그만큼 늘어난다

03 은 그릴 때마다 720점을 모두 투영하고 깊이로 정렬하며, 09 는 520점을 정렬 없이 투명도와 크기만으로 앞뒤를 드러낸다. 점을 수천 개로 늘리면 투영 계산과 정렬 비교가 함께 불어나므로, 많이 쓰고 싶다면 09 처럼 정렬을 빼는 쪽이 먼저 떠오르는 선택지다. 다만 정렬을 빼면 겹친 점의 앞뒤가 섞여 보인다.

초점 거리를 모형 반지름 가까이 줄이면 배율이 터진다

배율의 분모가 focal + z 이고 가장 가까운 꼭짓점의 z 는 −R 이라, FOCAL_PX 를 반지름 110 쪽으로 줄이면 분모가 0 에 다가가 가까운 쪽 배율이 걷잡을 수 없이 커진다. 300 만 되어도 가까운 꼭짓점이 1.579배로, 600 일 때의 1.224배보다 확연히 튀어나와 보인다.

비율을 프레임마다 곱하면 주사율 따라 빠르기가 달라진다

01 의 따라가기나 07 의 마찰을 rAF 프레임마다 곱하면 프레임이 두 배 잦은 화면에서 두 배 빨리 따라가고, 던진 고리가 멈추는 거리도 달라진다. 이 소스는 16ms 고정 스텝에서만 곱해서 그런 차이가 생기지 않는다.

깊이 평균으로 정렬하면 서로 파고드는 순간에 앞뒤가 틀린다

정렬 단위는 면이나 물체 하나의 평균 깊이라 화소마다 깊이를 비교하지 않는다. 04 는 바퀴 하나를 한 덩어리로 정렬하므로 두 바퀴 축이 크게 기울어 앞뒤가 엇갈리는 시점에서는 맞물린 자리의 앞뒤가 틀릴 수 있고, 08 은 흩어지는 복셀끼리 서로 관통하는 순간 한 프레임 앞뒤가 뒤바뀔 수 있다. 02 에서 정렬을 빼면 매듭이 제 몸을 넘어가는 곳에서 뒤 가닥이 앞 가닥을 덮는다.

뒷면을 거르기만 하면 열린 상자 안이 빈다

05 가 바깥 벽만 만들고 뒷면을 거르면 덮개가 열렸을 때 상자 안쪽이 비어 뒤 배경이 보인다. 그래서 0.6px 안으로 들인 안쪽 벽 넷과 바닥을 법선을 안쪽으로 해서 따로 두고, 기본 시점 열린 상태에서는 26면 가운데 13면을 그린다. 06 은 반대로 거르지 않는 대신 가로대 깊이에 +1, 등뼈에 +0.5 를 더해 같은 깊이의 구슬과 정렬이 흔들려 깜박이지 않게 한다.

움직인 위치로 거리를 다시 재면 점이 떤다

03 이 함몰된 뒤의 위치로 포인터와의 거리를 재면 들어간 점이 포인터에서 멀어져 튀어나오고, 다시 가까워져 들어가기를 되풀이한다. 그래서 거리는 함몰 전 반지름 R 로 투영한 위치에서 잰다. 08 도 반경 안에 있는 동안 매 스텝 속도를 주면 포인터를 대고만 있어도 복셀이 끝없이 빨라지므로 반경에 새로 든 순간에만 밀고, 화면 방향은 applyT 로 회전을 되돌려 모형 좌표에 더한다.

짝을 잘못 지으면 맞물림도 변신도 엉킨다

04 에서 반 톱니 위상을 빼면 이와 이가 정면으로 겹쳐 서로 파고든다. 09 에서 두 모양의 점을 아무 순서로 짝지으면 점이 화면을 가로질러 엉켜 한 덩어리 구름이 되기 때문에 높이순으로 줄 세운 뒤 짝짓는다. 02 의 (p, q) 도 짝이 맞아야 해서, 2 와 4 처럼 서로소가 아니면 곡선이 한 줄로 닫히지 않고 고리 여러 개로 갈라진다.

아홉 모형의 바닐라 index.html·style.scss 와 같은 계산을 tsx 로 옮긴 리액트 판은 한 압축 파일에 들어 있고, 풀 때 묻는 비밀번호에는 ndnyxvuv 을 넣으면 된다. 3d 웹사이트 제작에 바로 가져다 쓸 때는 모형 중심 CX·CY 와 상수 몇 줄만 내 화면에 맞추면 된다.

따라가는 빠르기·초점 거리·마찰을 바꿔 보는 변형과 마우스 없이도 돌릴 수 있게 하는 접근성

이름 값 느낌
따라가는 빠르기 올리기 01 ROT_LERP 0.08 → 0.2 남은 각도 차가 처음의 10% 로 줄기까지 28스텝(448ms)이던 것이 11스텝(176ms)으로 짧아진다. 모형이 포인터에 거의 붙어 돌고, 그만큼 손 떨림도 덜 걸러져 화면에 묻어난다.
초점 거리 줄이기 01 FOCAL_PX 600 → 300 반지름 110px 기준 가까운·먼 꼭짓점 배율이 1.224·0.845배에서 1.579·0.732배로 바뀌어 앞뒤 차가 1.45배에서 2.16배가 된다. 원근이 과장되어 가까운 모서리가 화면 밖으로 밀려 나오는 것처럼 보인다.
마찰 줄이기 07 SPIN_FRICTION 0.96 → 0.99 스텝당 0.1 rad 로 던졌을 때 5% 까지 74스텝(1.18초)·130.8° 이던 것이 299스텝(4.78초)·539° 로 늘고, 멈출 때까지 도는 각은 136.8° 에서 564° 가 된다. 한 번 던진 고리가 한 바퀴 반을 넘게 이어 돈다.

키보드와 화면 읽기 프로그램으로 다루는 법

캔버스 9개에는 모두 tabindex="0" 과 한국어 aria-label 이 있어 포커스가 들어가고, <button> 도 모형마다 두 개씩 실제 버튼으로 둔다. 방향키는 01~05·08·09 에서 시점을 돌리고, 06 에서는 ←/→ 가 꼬임 속도를 ±0.8, ↑/↓ 가 기울기를 ±0.2 바꾸며, 07 에서는 ←/→ 가 각속도를 ±0.12 더하는 던지기, ↑/↓ 가 시점이다. 01 의 「깊이 흐림 끄기」, 04 의 「거꾸로 돌리기」, 05 의 「덮개 여닫기」, 06 의 「꼬임 멈추기」 는 aria-pressed 로 켜짐과 꺼짐을 알린다. 상태 줄은 자동 시연 동안 aria-live="off" 로 조용하다가 사람이 처음 포인터나 키, 버튼을 건드리면 polite 로 바뀌어 그 뒤의 변화만 읽는다.

움직임 줄이기 설정을 켰을 때

prefers-reduced-motion 은 아홉 모형 모두 matchMedia 로 읽고, 거기에 change 리스너를 하나씩 달아 페이지를 연 채로 설정을 바꿔도 곧바로 따라간다. 켜지는 순간 onMotionPref 가 돌던 프레임 루프를 멈추고 settle() 로 끝 상태를 만든 뒤 한 번 그리며, 꺼지면 루프를 다시 시작한다. 줄이기 상태에서도 입력은 받아서 누를 때마다 after() 가 끝 상태로 계산해 바로 다시 그리므로, 시점을 돌리거나 덮개를 여닫은 결과가 움직임 없이 화면에 남는다.

저절로 돌던 02 의 매듭 자전, 03 의 구 자전, 04 의 톱니, 06 의 꼬임, 09 의 자전은 이 상태에서 멈춰 있다. 그 대신 04 의 Enter 는 반 톱니 한 칸, 06 의 ←/→ 는 위상 ±0.4, 08 의 흩뿌리기는 시점 yaw +0.5 로 바뀌어 입력이 화면에 보이게 하고, 07 은 끄는 만큼 바로 돌고 놓으면 남은 회전을 등비급수 합으로 한 번에 더한다.

3d 웹사이트 예시처럼 보이려면 라이브러리를 꼭 깔아야 하는가?

모형 하나를 포인터 쪽으로 돌리는 정도라면 깔지 않아도 된다. 이 편의 아홉 모형은 라이브러리·CDN·WebGL 없이 CanvasRenderingContext2D 의 경로 채우기·원·선과 requestAnimationFrame, 포인터 이벤트만 쓰고, 회전과 투영은 3×3 행렬 곱과 나눗셈 한 번이다. 이 편의 viewMatrix 도 세로축 회전을 먼저, 가로축 기울기를 나중에 곱하는데, 행렬 곱의 순서는 MDN 의 웹을 위한 행렬 수학 문서에서 더 살펴볼 수 있다.

필요가 달라지는 지점은 앞 절에 적은 한계와 겹친다. 면이 수천 장이고 서로 파고드는 장면, 화소마다 깊이를 비교해야 맞는 장면, 표면에 사진을 입혀야 하는 장면은 이 다섯 가지 계산의 범위를 넘는다. 모형이 첫 화면의 물건 하나이고 면 수가 이 편 정도라면 소스 한 파일을 붙이는 쪽이 받는 파일도 적고 고칠 곳도 눈에 보인다.

CSS transform 만으로 카드와 큐브를 돌리는 방법은 마우스를 따라오는 CSS 3D 회전 장면 9종 에, 캔버스 투영으로 관람차나 지형 같은 세계 장면 전체를 그리는 쪽은 마우스를 따라 도는 입체 씬 에, 회전 행렬로 윗면을 읽어 내는 쪽은 주사위 굴리기 9가지 에 있다. 깊이 층을 겹친 배경은 깊이 층 3D 장면, 화소마다 거리를 재 그리는 3D 는 레이 마칭 9종, 이 편이 품질 기준으로 삼은 계산 장면은 캔버스 특수효과 9종 이다.

3D 모형을 내 첫 화면에 올릴 때 사람들이 자주 묻는 것

자바스크립트 3d 모형을 내 로고 모양으로 바꾸려면 어디를 고치나?

가장 손이 덜 가는 곳은 08 이다. 문자표 HEART 의 # 자리를 내 로고 윤곽으로 바꾸면 그 칸마다 복셀이 쌓이고, 흩어짐과 귀환은 그대로 따라온다. 선으로 된 로고라면 01 의 icosaRaw 가 돌려주는 꼭짓점 목록과 모서리 쌍 EDGES 를 내 좌표로 바꾸고, 점으로 채운 로고라면 09 의 sample 에 모양 하나를 더한다.

휴대폰 터치로도 모형이 도는가?

돈다. 아홉 모형 모두 마우스와 터치를 함께 받는 pointermove·pointerdown 으로 입력을 읽고, style.css 의 touch-action: pan-y 가 세로 끌기는 페이지 스크롤에 남겨 둔다. 07 은 끌기 도중 손가락이 캔버스를 벗어나도 놓치지 않도록 setPointerCapture 로 포인터를 붙잡는다.

포인터를 치우면 모형은 어떻게 되나?

01·02·04·05·08·09 는 pointerleave 를 받으면 viewHome 으로 목표 각을 기본 시점에 되돌리고, 08 은 흩는 위치도 함께 지운다. 03 은 포인터 좌표만 지워 함몰이 스프링으로 부풀었다 가라앉을 뿐 시점은 그대로 두고, 06 은 goHome() 으로 꼬임 속도를 처음 값 0.6 rad/s 로, 기울기를 처음 값 0.25 로 되돌린다. 07 은 손을 뗀 뒤의 관성이 곧 이 모형의 동작이라 떠나기를 따로 받지 않고, 던진 만큼 돌다가 마찰로 멈춘다.

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

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