3D 기하 모션 레퍼런스 · Pure CSS
외부 라이브러리 없이 CSS transform-style:preserve-3d 하나로 만드는 회전 입체 도형. 정육면체부터 팔면체·기둥까지 5종을 라이브 컨트롤로 돌려보고, 그대로 복사해 쓸 수 있는 좌표·각도 유도 과정을 함께 싣습니다. 중간에 제네시스 스포츠카드 덱의 홀로포일·틸트 질감을 3D로 재현하고, 마지막에는 프리미엄 세단 전륜 더블위시본의 4절 링크 기구학을 실제로 풀어 그리는 인터랙티브 다이어그램을 싣습니다.
원근이 없으면 회전해도 납작하게 뭉갠 사각형으로 보입니다.
자식이 부모 평면에 눌려서 겹칩니다. 입체가 무너지는 대표 증상.
perspective 600px + preserve-3d. 같은 마크업인데 입체가 섭니다.
/* ① 무대 — 원근 거리. 값이 작을수록 왜곡이 강하다 (600~1200px 권장) */
.stage{ perspective: 900px; }
/* ② 회전체 — 자식을 3D 공간에 유지. 이 한 줄이 빠지면 전부 납작해진다 */
.obj{
transform-style: preserve-3d;
animation: spinY 14s linear infinite;
}
@keyframes spinY{ to{ transform: rotateX(-14deg) rotateY(360deg) } }
/* ③ 면 — 방향(rotate) 잡고 중심에서 밖으로(translateZ) 민다. 순서가 중요 */
.f{
position: absolute; left:50%; top:50%;
transform: var(--t); /* 면마다 --t 만 다르게 */
}
rotateY(90deg) translateZ(60px)와 translateZ(60px) rotateY(90deg)는 결과가 다릅니다. transform은 왼쪽부터 차례로 좌표계를 바꾸므로, 반드시 회전 → 이동 순서로 써야 "방향을 잡고 그 방향으로 민다"가 됩니다./* 한 변 120px → 중심에서 각 면까지 = 반변 60px = translateZ 값 */
.cube .f{ width:120px; height:120px; margin:-60px 0 0 -60px; }
앞 --t: rotateY(0deg) translateZ(60px)
오른 --t: rotateY(90deg) translateZ(60px)
뒤 --t: rotateY(180deg) translateZ(60px)
왼 --t: rotateY(270deg) translateZ(60px)
위 --t: rotateX(90deg) translateZ(60px)
아래 --t: rotateX(-90deg) translateZ(60px)
/* margin 은 left/top:50% 로 잡은 기준점을 면의 중심으로 되돌리는 보정 */
/* 밑변 120(반변 a=60), 높이 h=90 으로 정하면
빗면 slant = √(h² + a²) = √(8100+3600) = 108.17px
기울기 A = asin(a / slant) = asin(0.5547) = 33.69°
검산: slant × cos(A) = 108.17 × 0.8321 = 90.00 = h ✓ */
.pyr .side{
width:120px; height:108.17px; margin:-63.17px 0 0 -60px;
transform-origin: 50% 100%; /* 밑변을 축으로 눕힌다 */
clip-path: polygon(50% 0, 100% 100%, 0 100%); /* 삼각형 */
}
옆면 ×4 --t: rotateY(0|90|180|270deg) translateZ(60px) rotateX(33.69deg)
밑면 --t: translateY(45px) rotateX(90deg)
/* 팔면체는 이 사각뿔을 그대로 두 번 쓴다 — 아래쪽 묶음에 rotateX(180deg) */
| 도형 | 면 구성 | 핵심 수치 | 난이도 |
|---|---|---|---|
| 정육면체 | 정사각형 6 | translateZ = 반변 (120px → 60px) | ★☆☆ |
| 사각뿔 | 삼각형 4 + 밑면 1 | 빗면 108.17 · 기울기 33.69° | ★★☆ |
| 팔면체 | 삼각형 8 | 사각뿔 2개 · 아래쪽 rotateX(180deg) | ★★☆ |
| 삼각기둥 | 사각형 3 + 삼각형 2 | 정삼각형 내접반지름 34.64 · 120°씩 | ★★☆ |
| 육각기둥 | 사각형 6 + 육각형 2 | 내접반지름 51.96 · 60°씩 | ★★☆ |
| 정이십면체 | 삼각형 20 | CSS로는 비권장 — Canvas 투영이 낫다 | ★★★ |
rotateY(k·360/n) translateZ(내접반지름) 반복입니다. 내접반지름 = 변길이 × 1/(2·tan(π/n)). 육각형(변 60)이면 51.96, 팔각형(변 60)이면 72.43.도형이 같아도 어느 축으로 어떤 속도 곡선을 타느냐에 따라 성격이 완전히 달라집니다. 로고·로딩·배경 중 어디에 쓸지에 따라 골라 쓰세요.
속도가 일정. 배경 오브젝트·무한 로딩에 적합. 시선을 끌지 않습니다.
가속·감속이 붙어 살아있는 느낌. 히어로 로고·브랜드 심볼용.
끝에서 살짝 튕깁니다. 게임·카드 뒤집기 등 캐주얼한 맥락에만.
/* 축 — 무엇을 보여주고 싶은가 */
rotateY(360deg) /* 좌우 회전. 기본값. 면을 차례로 보여준다 */
rotateX(360deg) /* 앞뒤로 구르기. 상하 면이 강조된다 */
rotateZ(360deg) /* 화면 안에서 뱅글. 사실상 2D — 입체감 없음 */
rotateX(360deg) rotateY(720deg) /* 배수를 다르게 = 불규칙 텀블링 */
/* 이징 — 어떤 성격으로 보이고 싶은가 */
linear /* 배경·로딩. 존재감을 낮춘다 */
cubic-bezier(.65,0,.35,1) /* 히어로 로고. 호흡감 */
cubic-bezier(.68,-.6,.32,1.6) /* 카드 뒤집기. 끝에서 오버슈트 */
/* ⚠ 무한 회전에 오버슛 이징을 쓰면 매 바퀴 덜컥거린다.
무한 루프는 linear, 1회성 트리거는 곡선 이징이 원칙. */
제네시스 팀 카드 덱에서 쓴 홀로포일·네온 엣지는 정지 이미지였습니다. 여기에 3D 회전을 붙이면 실제 트레이딩 카드를 손에 쥔 감각이 됩니다. 왼쪽은 자동 회전(backface-visibility로 앞뒷면 전환), 오른쪽은 마우스를 올려보세요.
Y축 360° 회전 + 포일 그라디언트 스윕. 뒷면은 rotateY(180deg)로 겹쳐 두고 backface-visibility:hidden으로 서로를 가립니다.
커서 위치를 rotateX/rotateY로 매핑하고 같은 좌표로 radial-gradient 광택을 따라 붙입니다. 실제 카드의 각도별 반사를 흉내내는 방식.
/* CSS — 각도와 광택 위치를 전부 커스텀 속성으로 뺀다 */
.tilt{
transform: rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg));
transition: transform .12s ease-out; /* 짧게. 길면 끈적인다 */
}
.tilt .sheen{
background: radial-gradient(circle at var(--mx,50%) var(--my,50%), #7dd3fc 0%, transparent 55%);
mix-blend-mode: color-dodge; /* 광택의 핵심 */
}
// JS — 커서를 -1~1 로 정규화해 최대 기울기(MAX)만큼 곱한다
const MAX = 12;
box.addEventListener('pointermove', e => {
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width;
const py = (e.clientY - r.top) / r.height;
card.style.setProperty('--ry', (px - .5) * 2 * MAX + 'deg');
card.style.setProperty('--rx', -(py - .5) * 2 * MAX + 'deg'); // 부호 반대
card.style.setProperty('--mx', px * 100 + '%');
card.style.setProperty('--my', py * 100 + '%');
});
box.addEventListener('pointerleave', () => { /* 0deg 로 복귀 */ });
일정 회전이 아니라 90°씩 끊어 구르는 키프레임. 스피너보다 대기 시간이 짧게 느껴집니다.
패널 8장을 rotateY(k·45deg) translateZ(R)로 원형 배치. 도형 만들기와 완전히 같은 공식입니다.
/* n장을 원형 배치할 때 필요한 반지름 R
R ≥ (패널너비 / 2) / tan(180° / n)
예) 패널 110px, 8장 → (55) / tan(22.5°) = 55 / 0.4142 = 132.8px
→ 여유를 줘서 150px 사용 */
.caro{ transform-style: preserve-3d; animation: spinY 18s linear infinite; }
.caro .p{
transform: var(--t);
backface-visibility: hidden; /* ★ 없으면 뒷면 숫자가 거울상으로 비친다 */
}
01 --t: rotateY(0deg) translateZ(150px)
02 --t: rotateY(45deg) translateZ(150px)
… 45° 씩 증가 × 8장
/* 뒷면을 감추는 대신 "뒤에서도 글자를 바로 세우고" 싶다면,
패널 안쪽에 역회전 래퍼를 하나 더 두고 rotateY(180deg) 를 상쇄시킨다 */
지금까지가 보이는 기하였다면, 이것은 움직이는 기하입니다. 더블위시본은 길이가 다른 상·하 컨트롤암이 만드는 4절 링크(차체–UCA–너클–LCA)이고, 휠이 위아래로 움직일 때 캠버·트랙·롤센터가 전부 이 링크의 기구학으로 결정됩니다. 아래 다이어그램은 그림이 아니라 실제로 4절 링크를 풀어서 그립니다 — 슬라이더를 움직이면 원-원 교점을 다시 계산합니다.
캠버·롤센터는 정면도만으로 풀리지만, 조향축(킹핀축)은 정면도의 KPI와 측면도의 캐스터가 합쳐진 3차원 축입니다. 평면도 두 장으로는 안 보이니 돌려서 봅니다.
위 다이어그램의 하드포인트에서 계산된 값과 프리미엄 세단의 통상 범위 비교. 캐스터·스크럽 반경은 정면도 모델 밖이라 대표값입니다.
| 파라미터 | 이 모델 | 통상 |
|---|---|---|
| 정적 캠버 | −0.50° | −0.3 ~ −1.2° |
| 캠버 게인 | −0.41°/25mm | −0.3 ~ −0.7 |
| KPI | 11.1° | 8 ~ 15° |
| 롤센터 높이 | 50.0mm | 30 ~ 80mm |
| FVSA | 3591mm | 2000 ~ 4500mm |
| LCA / UCA | 425 / 250mm | UCA가 짧아야 함 |
| 캐스터* | 6.5° | 3 ~ 8° |
| 스크럽 반경* | +8mm | −5 ~ +15mm |
/* 하드포인트 (정면도, mm). 원점 = 지면 × 차량중심선, +x 외측, +y 상향 */
Li = [330, 165] // 로어암 내측 피벗 (차체)
Lo = [755, 157] // 로어암 외측 볼조인트
Ui = [420, 573] // 어퍼암 내측 피벗 (차체)
Uo = [668, 600] // 어퍼암 외측 볼조인트
WC = [800, 340] // 휠센터, 타이어 반경 R = 340
/* 1) LCA 각도 θ 를 입력으로 두면 하부 볼조인트는 원 위에 있다 */
Lo(θ) = Li + Ll·[cos θ, sin θ]
/* 2) 상부 볼조인트 = 두 원의 교점
· Ui 중심 반지름 Lu (어퍼암 길이)
· Lo 중심 반지름 DK (너클 길이 — 두 볼조인트 간 거리는 불변) */
d = |Lo − Ui|
a = (Lu² − DK² + d²) / (2d)
h = √(Lu² − a²) // h² < 0 이면 링크가 성립하지 않는 각도
P = Ui + a·(Lo−Ui)/d
Uo = P ± h·perp((Lo−Ui)/d) // 두 해 중 직전 자세에 가까운 쪽을 택한다
/* 3) 너클이 회전한 만큼이 곧 캠버 변화 (강체) */
tilt = atan2(Uo.x − Lo.x, Uo.y − Lo.y)
camber = camber₀ + (tilt − tilt₀)
/* 4) 순간중심 IC = 두 암 연장선의 교점,
롤센터 RC = 접지점 CP 와 IC 를 이은 선이 차량중심선(x=0)과 만나는 높이 */
IC = line(Li,Lo) ∩ line(Ui,Uo)
RC = CP.y + (0 − CP.x)·(IC.y − CP.y)/(IC.x − CP.x)
/* 5) 휠 트래블 → θ 는 역산이 필요하다. θ 를 훑어 (θ, 휠센터높이) 표를
만들어 두고 선형보간으로 뒤집는 편이 뉴턴법보다 안정적이다. */
면도 그림자도 없이 선만으로 차를 세웁니다. 세 대를 같은 축척(9.5mm/unit)과 같은 지면선에 올려 비례 차이가 그대로 드러나게 했고, 세 차종을 관통하는 시그니처 두 줄(Two Lines)을 같은 굵기로 강조했습니다. 선은 stroke-dashoffset으로 앞에서 뒤로 그려집니다.
낮은 지붕(y=61)과 뒤로 길게 흐르는 C필러가 만드는 패스트백. 트렁크 리드가 짧아 옆에서 보면 쿠페에 가깝습니다.
G80보다 250mm 높고 60mm 짧습니다. 지붕이 뒤까지 평평하게 이어지고 D필러가 거의 수직으로 떨어져 적재 공간을 만듭니다.
가장 박스에 가까운 비례. 전기차 전용 플랫폼이라 후드가 짧고 휠베이스가 길어 도어 구간이 넓습니다. 가운데 도어 컷이 B필러 없는 코치도어로 마주 열리는 구간.
≈로 표기했고, 공개된 콘셉트 방향(모노리식 볼륨·코치도어)만 반영했습니다. 정밀 비교가 필요하면 실제 도면 좌표로 path를 교체하세요 — 구조는 그대로 씁니다.
/* CSS — 경로 길이만큼 점선을 만들고 그만큼 밀어두면 "안 그려진" 상태가 된다 */
[data-draw]{
fill: none;
stroke-dasharray: var(--len); /* 점선 1칸 = 경로 전체 길이 */
stroke-dashoffset: var(--len); /* 그만큼 밀어 완전히 숨긴다 */
}
.go [data-draw]{ animation: silDraw 1.5s cubic-bezier(.3,.8,.4,1) forwards;
animation-delay: var(--d); }
@keyframes silDraw{ to{ stroke-dashoffset: 0 } } /* 0 이 되면 다 그려진다 */
// JS — 길이는 브라우저가 재준다. 하드코딩하면 경로 수정 때마다 깨진다
svg.querySelectorAll('[data-draw]').forEach((el,i) => {
el.style.setProperty('--len', el.getTotalLength());
el.style.setProperty('--d', (i * 0.07) + 's'); // 스태거
});
// 화면에 들어올 때 재생 — 스크롤 위치와 무관하게 처음부터 보이게 한다
new IntersectionObserver((es,ob) => es.forEach(e => {
if(e.isIntersecting){ e.target.classList.add('go'); ob.unobserve(e.target); }
}), {threshold:.25}).observe(svg);
/* ⚠ getTotalLength() 는 <path>·<circle>·<line> 등 기하 요소에만 있다.
<rect> 는 지원이 갈리니 path 로 바꿔 두는 편이 안전하다. */
제네시스 팀 카드 덱 CARD 4의 육각 레이더는 평면입니다. 6개 축을 적도 정점으로 두고 위아래에 꼭짓점을 세우면 그대로 육각 쌍뿔(hexagonal bipyramid)이 되고, 스탯 값이 곧 정점의 반지름이 됩니다 — 숫자가 형상이 되는 셈입니다. 값을 바꾸면 젬의 실루엣이 즉시 달라집니다.
균등한 값은 대칭 젬, 한쪽으로 쏠린 값은 찌그러진 젬이 됩니다. 레이더 차트에서는 눈에 덜 띄던 편중이 입체에서는 실루엣으로 드러납니다.
/* 1) 6축을 적도에 배치. 각도는 60°씩, 반지름은 스탯 값에 비례 */
for(let k=0; k<6; k++){
const a = k*Math.PI/3, r = vals[k]/100;
verts.push([Math.cos(a)*r, 0, Math.sin(a)*r]); // y=0 평면 = 레이더 원본
}
const H = avg(vals)/100 * 1.15; // 평균값이 젬의 높이
verts.push([0, H, 0], [0, -H, 0]); // 위·아래 꼭짓점 → 면 12개
/* 2) 회전 + 원근 투영. z 가 클수록(가까울수록) 크게 그린다 */
function project(p, ry, rx){
const x = p[0]*cos(ry) + p[2]*sin(ry);
const z = -p[0]*sin(ry) + p[2]*cos(ry);
const y2 = p[1]*cos(rx) - z*sin(rx);
const z2 = p[1]*sin(rx) + z*cos(rx);
const s = D/(D - z2); // D = 시점 거리
return {x: cx + x*s*S, y: cy - y2*s*S, z: z2};
}
/* 3) 반투명 면은 반드시 깊이 정렬 — 안 하면 뒷면이 앞면을 덮는다 */
faces
.map(f => ({f, z: avgZ(f)}))
.sort((a,b) => a.z - b.z) // 먼 것부터 그린다 (painter's algorithm)
.forEach(drawFace);
/* ⚠ Canvas 는 z-버퍼가 없다. 정렬을 빼먹으면 도형이 뒤집혀 보인다.
면이 서로 관통하는 형상이라면 정렬만으로는 부족하니 그때는 WebGL 로 간다. */
| 항목 | 왜 | 어떻게 |
|---|---|---|
| reduced-motion | 전정 장애 사용자에게 회전은 어지럼증을 유발 | @media (prefers-reduced-motion:reduce)에서 animation:none + 보기 좋은 정지 각도 고정 (이 페이지 적용됨) |
| GPU 합성 | transform/opacity만 60fps로 합성됨. width·top 애니는 매 프레임 레이아웃 재계산 | 회전은 transform만 쓴다. 이미 지킴 |
| will-change | 레이어를 미리 승격해 첫 프레임 버벅임 제거 — 단 남발하면 메모리 폭증 | 화면에 상시 보이는 1~2개에만. 목록 전체엔 걸지 않는다 |
| backface-visibility | 기본값이 visible이라 뒷면 글자가 거울상으로 비침 | 카드 앞뒷면에는 hidden. 반투명 다면체는 visible이 정답(속이 비쳐야 입체로 읽힘) |
| perspective 값 | 너무 작으면(<400px) 어안렌즈처럼 왜곡 | 도형 크기의 5~8배를 기준으로. 120px 큐브면 600~1000px |
| z-fighting | 면이 정확히 겹치면 깜빡임 | 같은 평면에 두 면을 두지 말고 translateZ를 0.1px 어긋내기 |
| 모바일 발열 | 반투명 면이 많으면 오버드로 부담 | 작은 화면에서는 면 수를 줄이거나 animation-duration을 늘린다 |