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

아이소메트릭 디자인 — 진짜 등각투영 행렬로 짓는 3D 다이오라마 씬 9종

큐브 세 면의 명도를 광원 각도로 계산하고, 격자 좌표를 2:1 공식으로 화면 좌표로 바꿔 등각 투영을 짭니다. 깊이값으로 쌓임 순서를 정렬하는 페인터 알고리즘까지 — 방·도시 다이오라마 9종, 코드는 비밀번호 zip으로 드립니다.

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

데모

아홉 판 전부 index.html 한 파일씩으로 열리자마자 시드를 고정한 자동 시연으로 스스로 움직입니다. matrix3d 행렬·격자 좌표식·내적 셰이딩·z-index 페인터 정렬이라는 같은 네 계산이 판마다 다른 조합으로 화면을 채우고, 사람은 포인터·호버로 같은 계산 경로를 직접 밟을 수 있습니다.

9종 실측 한눈에

항목 초점 누적면적(%) 강도 평균 움직인프레임
01 진짜 등각 rotateX(-30deg) 근사 대 정확한 투영 행렬 8.5 67.5 23
02 격자 좌표 변환 (col,row)를 (x,y)로 바꾸는 2:1 공식 8.8 45.9 23
03 면 셰이딩 법선과 광원의 내적으로 명도 계산 6.0 67.9 23
04 쌓임 순서 계산 z-index=(col+row)×10 페인터 정렬 4.1 127.4 23
05 포인터 시차 목표각을 lerp 로 보간하는 계산 1.6 67.0 23
06 분해도 층 인덱스를 벌림 거리로 쓰는 호버 11.1 99.1 12
07 방 다이오라마 01~04 를 조립한 완성 씬 9.4 65.7 16
08 도시 블록 시드 난수로 건물 높이를 정하는 배치 1.5 17.3 23
09 히어로 씬 낮밤 조명과 포인터 시차의 결합 21.4 37.6 23

낮은 누적면적이 정상인 이유

077 캔버스 물방울·블랙홀 편의 30% 기준으로 보면 이 아홉 판 중 여덟 개가 낮아 보이지만, 등각 다이오라마는 캔버스를 가득 채우는 유체·중력렌즈 장면이 아니라 고정 프레임 중앙의 작은 큐브·격자·건물 하나로 계산의 정확함을 보여주는 구도입니다. 09 히어로 씬만 21.4%로 넓은 것도 하늘색 배경 전체가 낮밤 보간을 받기 때문이고, 06 분해도(12/23)·07 방 조립(16/23)은 호버·조립이 끝난 뒤 정지 구간이 있어 나머지 프레임이 멈춰 있을 뿐입니다. 강도 평균은 08 도시 야경이 17.3으로 가장 어둡고 04 페인터 정렬이 127.4로 가장 밝지만, 하한 15에 걸리는 항목은 없어 BD3 게이트는 아홉 항목 전부 통과합니다.

왜 rotateX(-30deg) 가 아니라 진짜 등각인가 — 투영 행렬과 좌표 변환

웹에서 아이소메트릭 3d 장면을 만들 때 가장 먼저 부딪히는 것이 이 질문입니다 — rotateX(-30deg) rotateZ(45deg)는 등각처럼 보이지만 수학적으로는 근사값이고, 정확한 각도는 -arctan(1/√2) ≈ -35.264deg입니다. 01 항목이 두 큐브를 같은 턴테이블 속도로 나란히 돌려 이 차이를 cos(각도)로 구한 세로/가로 비율 숫자로 실시간 비교합니다.

행렬을 손으로 곱해 matrix3d 에 넣는 이유

CSS의 rotateX()·rotateZ()를 그대로 겹쳐 쓰면 브라우저가 내부적으로 두 번 곱하지만, 그 결과를 직접 확인하거나 다른 계산(포인터 시차 등)과 합성하려면 4×4 행렬을 JS로 직접 곱해 matrix3d() 문자열로 넣어야 합니다. perspective()를 섞지 않는 이유도 여기 있습니다 — 등각은 평행 투영이라 원근이 끼면 소실점이 생겨 먼 블록이 작아지는 왜곡이 등각 위에 겹칩니다.

격자 좌표가 화면 좌표로 바뀌는 공식

타일의 (col, row)를 화면 좌표로 바꾸는 공식은 x = (col - row) * TILE_W / 2, y = (col + row) * TILE_H / 2이고, 02 항목은 이 공식으로 6×6 격자 36칸을 마름모 배열로 재배치합니다. 타일 폭:높이 비율은 2:1(TILE_W 34·TILE_H 17)로 고정되어 있어, 격자 크기를 바꾸면 같은 공식이 다시 돌아 전체가 재배치됩니다.

아홉 씬 단계별 코드

아래 아홉 절은 각 씬의 핵심 계산 하나씩을 실제 소스에서 그대로 뽑은 조각으로 보여줍니다. 전체 코드(바닐라 9종·React 9종)는 비밀번호 zip에 들어 있습니다.

01진짜 등각 — rotateX(-30deg) 대신 정확한 투영 행렬

많이 쓰는 rotateX(-30deg) rotateZ(45deg) 조합은 수학적 등각이 아니라 근사값이라 세 축이 다른 배율로 줄어듭니다. 이 항목은 회전 행렬을 JS mat4Mul로 직접 곱해 matrix3d에 넣는 코어 세 함수를 씁니다.

진짜 등각 — rotateX(-30deg) 대신 정확한 투영 행렬
새 창에서 열기

01 · 진짜 등각 — rotateX(-30deg) 대신 정확한 투영 행렬 — 측정 누적면적 8.516% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

matrix3dtransform-stylebackface-visibility

회전 행렬 세 개를 직접 곱하는 함수

function mat4RotateX(deg) {
  var r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
  return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1];
}
function mat4RotateZ(deg) {
  var r = deg * Math.PI / 180, c = Math.cos(r), s = Math.sin(r);
  return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}
function mat4Mul(a, b) {
  var out = new Array(16);
  for (var col = 0; col < 4; col++) {
    for (var row = 0; row < 4; row++) {
      var sum = 0;
      for (var k = 0; k < 4; k++) sum += a[k * 4 + row] * b[col * 4 + k];
      out[col * 4 + row] = sum;
    }
  }
  return out;
}

TILT_EXACT = -35.264가 mat4RotateX에 들어가고 mat4RotateZ(45 + spin)과 mat4Mul로 곱해진 결과가 매 프레임 두 큐브의 transform에 들어갑니다.

02격자 좌표 변환 — (col, row) 를 화면 (x, y) 로 바꾸는 2:1 공식

타일마다 x = (col - row) * TILE_W / 2, y = (col + row) * TILE_H / 2를 계산해 CSS 변수에 넣으면 바둑판 격자가 마름모 격자로 재배치됩니다. 아이소메트릭 그리드 만들기의 핵심이 바로 이 두 줄이고, 02 항목은 6×6=36칸 전부에 같은 공식을 돌립니다.

격자 좌표 변환 — (col, row) 를 화면 (x, y) 로 바꾸는 2:1 공식
새 창에서 열기

02 · 격자 좌표 변환 — (col, row) 를 화면 (x, y) 로 바꾸는 2:1 공식 — 측정 누적면적 8.835% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translate3dCSS custom propertiescalc

타일 좌표를 화면 좌표로 바꾸는 반복문

var N = 6;                 // 한 변 칸 수 — N*N 격자
var TILE_W = 34, TILE_H = 17;   // 등각 2:1 비율 — 폭이 높이의 두 배
nEl.textContent = String(N * N);

var tiles = [];
var minX = Infinity, maxX = -Infinity, maxY = -Infinity;
for (var row = 0; row < N; row++) {
  for (var col = 0; col < N; col++) {
    var x = (col - row) * TILE_W / 2;
    var y = (col + row) * TILE_H / 2;
    if (x < minX) minX = x;
    if (x > maxX) maxX = x;
    if (y > maxY) maxY = y;
    tiles.push({ col: col, row: row, x: x, y: y });
  }
}
var shiftX = -(minX + maxX) / 2;
var shiftY = -maxY / 2;

계산된 x·y는 이후 각 타일을 파도처럼 들어 올리는 phase = (col + row) * 0.9 - t * 3.1 위상 계산에도 그대로 재사용됩니다.

03면 셰이딩 — 광원 각도로 세 면의 명도를 계산하기

윗면·오른면·앞면의 법선과 고정 광원 벡터의 내적을 구해 밝기 비율을 정하고, 그 값을 filter: brightness()에 대입합니다. 광원 각도 theta 하나만 바꾸면 세 면 전부의 명도가 같은 계산으로 다시 나옵니다.

면 셰이딩 — 광원 각도로 세 면의 명도를 계산하기
새 창에서 열기

03 · 면 셰이딩 — 광원 각도로 세 면의 명도를 계산하기 — 측정 누적면적 5.965% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

filter: brightnessCSS custom propertieslinear-gradient

법선과 광원 벡터의 내적으로 밝기를 정하는 함수

// 세 면의 법선 — 큐브 로컬 좌표계 기준(윗면 +z, 오른면 +x, 왼면 +y)
var N_TOP = [0, 0, 1], N_RIGHT = [1, 0, 0], N_FRONT = [0, 1, 0];

function dot3(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }

function shade(theta) {
  var r = theta * Math.PI / 180;
  var raw = [Math.cos(r), Math.sin(r), 0.55];
  var len = Math.sqrt(raw[0] * raw[0] + raw[1] * raw[1] + raw[2] * raw[2]);
  var L = [raw[0] / len, raw[1] / len, raw[2] / len];
  var bTop = 0.32 + 0.92 * clamp(dot3(N_TOP, L), 0, 1);
  var bRight = 0.32 + 0.92 * clamp(dot3(N_RIGHT, L), 0, 1);
  var bFront = 0.32 + 0.92 * clamp(dot3(N_FRONT, L), 0, 1);
  faces.top.style.filter = 'brightness(' + bTop.toFixed(2) + ')';
  faces.right.style.filter = 'brightness(' + bRight.toFixed(2) + ')';
  faces.front.style.filter = 'brightness(' + bFront.toFixed(2) + ')';

밝기 공식 0.32 + 0.92 * dot이 세 면 모두에 같은 형태로 쓰이기 때문에, theta가 초당 40도씩 돌면 세 값이 각자 다른 속도로 밝아지고 어두워집니다.

04쌓임 순서 계산 — 깊이값을 z-index 공식으로 바꾸는 페인터 알고리즘

고정 블록은 z-index = Math.round((col + row) * 10)을 한 번만 계산해 고정하고, 이동하는 블록(traveler)은 매 프레임 자기 위치로 같은 공식을 다시 돌려 z-index를 갱신합니다. 이 편의 아홉 항목 중 강도 평균이 127.4로 가장 밝은 항목입니다.

쌓임 순서 계산 — 깊이값을 z-index 공식으로 바꾸는 페인터 알고리즘
새 창에서 열기

04 · 쌓임 순서 계산 — 깊이값을 z-index 공식으로 바꾸는 페인터 알고리즘 — 측정 누적면적 4.119% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

z-indexCSS custom propertiescalc

좌표를 배치하고 z-index 를 한 번에 계산하는 반복문

function place(el, col, row) {
  var x = (col - row) * TILE_W / 2;
  var y = (col + row) * TILE_H / 2;
  el.style.setProperty('--tx', x.toFixed(2) + 'px');
  el.style.setProperty('--ty', y.toFixed(2) + 'px');
}

// 고정 격자 블록 — z-index 는 한 번만 계산해서 고정
for (var row = 0; row < N; row++) {
  for (var col = 0; col < N; col++) {
    var block = document.createElement('div');
    block.className = 'ps__block';
    block.style.setProperty('--w', SIDE + 'px');
    place(block, col, row);
    block.style.zIndex = String(Math.round((col + row) * 10));
    world.appendChild(block);
  }
}

이동하는 traveler 블록은 원형 궤도를 돌며 같은 공식에 +1을 더한 z-index를 매 프레임 다시 계산해, 격자를 가로지를 때 앞뒤가 저절로 뒤집힙니다.

05포인터 시차 — 다이오라마 전체가 살짝 따라 도는 계산

pointermove 좌표를 중심 기준 -1~1로 정규화한 뒤 clamp로 최대 회전각(LIMIT ±9deg)을 묶고, requestAnimationFrame 안에서 현재 각도를 목표 각도로 0.16씩 보간해 씬 전체의 rotateX·rotateY를 갱신합니다. 1.1초 입력이 없으면 사인·코사인 곡선의 자동 훑기로 전환합니다.

포인터 시차 — 다이오라마 전체가 살짝 따라 도는 계산
새 창에서 열기

05 · 포인터 시차 — 다이오라마 전체가 살짝 따라 도는 계산 — 측정 누적면적 1.605% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermoverequestAnimationFrameclamp

목표각을 lerp 로 보간하는 paint 함수

field.addEventListener('pointermove', function (ev) {
  var rect = field.getBoundingClientRect();
  var nx = ((ev.clientX - rect.left) / rect.width) * 2 - 1;   // -1..1
  var ny = ((ev.clientY - rect.top) / rect.height) * 2 - 1;
  targetX = clamp(-ny * LIMIT, -LIMIT, LIMIT);
  targetY = clamp(nx * LIMIT, -LIMIT, LIMIT);
  lastInput = performance.now();
});
field.addEventListener('pointerleave', function () { lastInput = -Infinity; });

function autoPath(t) {
  targetX = Math.sin(t * 0.6) * LIMIT * 0.85;
  targetY = Math.cos(t * 0.42) * LIMIT;
}

function paint(now, t) {
  if (now - lastInput > IDLE_MS) autoPath(t);
  curX += (targetX - curX) * 0.16;
  curY += (targetY - curY) * 0.16;
  tilt.style.transform = 'rotateX(' + curX.toFixed(2) + 'deg) rotateY(' + curY.toFixed(2) + 'deg)';
  rxEl.textContent = curX.toFixed(1);
  ryEl.textContent = curY.toFixed(1);
}

등각 격자 좌표 자체는 바뀌지 않고 씬을 감싼 tilt 요소의 회전만 갱신되므로, 눈에는 카메라가 움직이는 것처럼 보입니다.

06분해도 — 깊이 순서를 그대로 벌림 거리로 쓰는 호버

04에서 구한 층 개념을 재사용해, 층 인덱스 i에 벌림 간격을 곱한 목표 translateZ를 한 번만 계산해 두고 hover에서 CSS transition으로 실행합니다. roof·attic·room·floor 네 층이 위층일수록 더 멀리 벌어집니다.

분해도 — 깊이 순서를 그대로 벌림 거리로 쓰는 호버
새 창에서 열기

06 · 분해도 — 깊이 순서를 그대로 벌림 거리로 쓰는 호버 — 측정 누적면적 11.103% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateZtransitionCSS custom properties

층마다 목표 translateZ 를 미리 계산해 두는 반복문

var LAYERS = [
  { name: 'roof', w: 96 },
  { name: 'attic', w: 84 },
  { name: 'room', w: 84 },
  { name: 'floor', w: 96 }
];

for (var i = 0; i < LAYERS.length; i++) {
  var layer = document.createElement('div');
  layer.className = 'ev__layer ev__layer--' + LAYERS[i].name;
  layer.style.setProperty('--w', (mm ? LAYERS[i].w * 0.78 : LAYERS[i].w) + 'px');
  // 역순 — 지붕(roof)이 가장 위 층이라 벌어질 때 가장 많이 올라가야 한다
  layer.style.setProperty('--tz-open', (GAP * (LAYERS.length - i)) + 'px');
  world.appendChild(layer);
}

되돌릴 때도 같은 층 번호가 순서를 보장해 레이어가 서로 겹쳐 튀지 않고, 사람이 없는 캡처에서는 setInterval이 같은 is-open 클래스를 여닫아 같은 transition을 재생합니다.

07방 다이오라마 — 01~04 를 조립한 완성 씬

격자 좌표계 위에 바닥을 먼저 그대로 두고, 왼쪽 벽·오른쪽 벽·지붕에만 같은 transition 함수를 지연 시간만 다르게 줘서 차례로 세웁니다. 아이소메트릭 방 만들기를 코드로 옮기면 결국 01~04의 계산을 그대로 얹는 일입니다.

방 다이오라마 — 01~04 를 조립한 완성 씬
새 창에서 열기

07 · 방 다이오라마 — 01~04 를 조립한 완성 씬 — 측정 누적면적 9.435% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

matrix3dCSS custom propertiestransition

벽과 지붕이 지연 시간만 다르게 조립되는 마크업

<div class="rb__world" id="world">
  <div class="rb__floor"></div>
  <div class="rb__wall rb__wall--left" style="--d:0ms"></div>
  <div class="rb__wall rb__wall--right" style="--d:140ms"></div>
  <div class="rb__roof" style="--d:320ms"></div>
</div>

--d 값이 왼쪽 벽 0ms·오른쪽 벽 140ms·지붕 320ms 순으로 커지면서 같은 transition이 차례로 실행되고(바닥은 transition 없이 처음부터 그 자리에 있습니다), 세계 좌표계 자체는 mat4RotateX(-35.264)와 mat4RotateZ(45)를 곱한 값 하나로 고정됩니다.

08도시 블록 — 건물 높이가 제각각인 씬 배치 계산

격자 칸마다 건물 층수를 mulberry32 시드 난수로 정하고, 창문 2~3개가 각자 다른 지연으로 깜빡이는 @keyframes를 얹습니다. 시드가 42로 고정되어 있어 같은 프롬프트로 다시 만들어도 같은 도시가 재현됩니다.

도시 블록 — 건물 높이가 제각각인 씬 배치 계산
새 창에서 열기

08 · 도시 블록 — 건물 높이가 제각각인 씬 배치 계산 — 측정 누적면적 1.497% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CSS custom properties@keyframesrandom

시드로 같은 난수열을 매번 재현하는 함수

function mulberry32(seed) {
  return function () {
    seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
    var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

rand()가 뽑는 값은 건물 높이(HMIN + rand() * HRANGE)·창문 개수·깜빡임 지연 세 곳에 쓰이는데, 이 편 아홉 항목 중 강도 평균이 17.3으로 가장 어두운 것도 밤 창문 배경 톤 때문입니다.

09히어로 씬 — 낮밤 조명과 시차가 함께 도는 완성판

포인터의 세로 위치를 목표값으로 삼아 curT를 매 프레임 0.14씩 보간하고, 하늘색·창문 불투명도·해와 달의 궤도가 모두 같은 curT로 계산됩니다. 05처럼 포인터 입력을 목표값으로 lerp 보간하는 계산을 하늘색 RGB 보간에 그대로 얹은 이 편의 마지막 항목입니다.

히어로 씬 — 낮밤 조명과 시차가 함께 도는 완성판
새 창에서 열기

09 · 히어로 씬 — 낮밤 조명과 시차가 함께 도는 완성판 — 측정 누적면적 21.418% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CSS custom propertiestransitionpointermove

curT 하나로 하늘색·창문·궤도를 함께 보간하는 paint 함수

function paint(now, t) {
  if (now - lastInput > IDLE_MS) targetT = (1 - Math.cos(t * 3)) / 2;
  curT += (targetT - curT) * 0.14;

  var r = Math.round(lerp(DAY[0], NIGHT[0], curT));
  var g = Math.round(lerp(DAY[1], NIGHT[1], curT));
  var b = Math.round(lerp(DAY[2], NIGHT[2], curT));
  sky.style.backgroundColor = 'rgb(' + r + ',' + g + ',' + b + ')';

  for (var i = 0; i < wins.length; i++) {
    wins[i].style.opacity = String((0.15 + curT * 0.85).toFixed(2));
  }

  var isNight = curT > 0.5;
  if (isNight !== wasNight) {
    field.classList.toggle('is-night', isNight);
    wasNight = isNight;
  }

  var orbY = mm ? 60 : 76;
  var ox = (curT - 0.5) * (mm ? 120 : 150);
  var oy = -Math.cos(curT * Math.PI) * orbY;
  sun.style.transform = 'translate3d(' + ox.toFixed(1) + 'px,' + oy.toFixed(1) + 'px,0)';

curT가 0.5를 넘는 순간 is-night 클래스가 한 번만 토글되고, 해와 달은 같은 ox·oy 좌표를 공유해 한쪽이 지면 다른 쪽이 그 자리로 떠오릅니다.

명도는 광원 각도다 — 면 셰이딩과 쌓임 순서 계산

이 아홉 판을 관통하는 두 계산은 03의 내적 셰이딩과 04의 z-index 정렬입니다. 둘 다 "그려질 때 한 번 정하는 값"이 아니라 "매번 같은 공식으로 다시 나오는 값"이라는 점이 같습니다.

내적으로 명도를 정하는 이유

면마다 밝기를 hex 색으로 고정하면 광원 각도를 바꿔도 그림이 따라오지 않습니다. 법선과 광원 벡터의 내적을 구해 0.32 + 0.92 * dot으로 환산하면, 광원 각도 변수 하나만 바꿔도 세 면 전부의 명도가 같은 계산으로 다시 나옵니다 — 03 항목이 이 공식을 씁니다.

z-index 를 매 프레임 다시 계산하는 이유

깊이 순서를 씬을 만들 때 한 번만 정해 두면 이동하는 블록이 격자를 가로지를 때 앞뒤가 뒤집히지 않습니다. z-index = Math.round((col + row) * 10)을 이동할 때마다 다시 계산해야, 04의 traveler와 06의 분해도 층이 항상 올바른 순서로 겹칩니다.

아홉 씬은 왜 이 순서인가

순서는 등각 행렬 하나를 세우는 일부터 시작해 그 행렬 위에 좌표·명도·깊이 계산을 하나씩 얹고 마지막에 완성 씬으로 묶는 서열입니다. 01이 진짜 등각 행렬 자체를 세우고, 02가 격자 좌표를 화면 좌표로 바꾸는 공식을 얹고, 03이 세 면의 명도를 내적으로 계산하고, 04가 깊이값을 z-index로 바꾸는 정렬을 더합니다. 05는 그 위에 카메라 역할의 포인터 시차를 얹고, 06은 04의 깊이 개념을 벌림 거리로 재사용한 호버이며, 07은 01~04를 그대로 조립한 방 한 채, 08은 같은 좌표·정렬 공식을 시드 난수와 묶은 도시 블록, 09는 05처럼 포인터 입력을 lerp로 좇으며 낮밤까지 함께 도는 완성판입니다. 등각 격자는 그대로 두고 카메라 각도만 계산으로 바꾼다는 점에서 포인터가 회전각을 넘겨주는 다른 3D 장면 아홉 개와 같은 갈래이고, 좌표에 행렬을 곱해 화면을 다시 그린다는 점은 반사 행렬로 대칭을 그리는 이웃 계산 드로잉과도 통합니다.

다이오라마 아홉 판을 실제로 어디에 붙이는가

랜딩 페이지 히어로 영역이라면 09 히어로 씬이 가장 잘 맞습니다. 낮밤 조명과 포인터 시차가 curT 값 하나로 함께 돌아 배경이 스크롤 없이도 계속 살아 있는 인상을 주고, 그 위에 얹는 텍스트·버튼은 z-index 정렬만 신경 쓰면 됩니다.

소개 페이지나 회사 연혁처럼 정적인 배경이 필요하면 07 방 다이오라마 쪽이 낫습니다 — 01~04 계산을 한 번만 조립해 배치가 끝나면 멈추는 편이라 글을 읽는 동안 시선을 뺏지 않습니다. 08 도시 블록은 반대로 서비스 소개나 통계 섹션의 배경으로 두면, 시드 난수로 고정된 건물 높이가 새로고침마다 달라져 페이지를 다시 열 때 같은 배경이 반복되지 않습니다.

포인터가 없는 모바일 트래픽이 많은 페이지라면 05 포인터 시차 단독보다는 09처럼 자동 훑기 경로가 같이 있는 항목을 붙이는 편이 화면이 죽어 보이지 않습니다.

rotateX(-30deg) 근사와 고정 z-index, 어디서 깨지는가

rotateX(-30deg) 근사를 등각이라 부르는 것

정확한 각도(-35.264deg)가 아니면 세 축의 축소 배율이 달라져 면적이 미세하게 틀어집니다. 01 항목의 두 큐브를 나란히 돌려 보면 근사 큐브와 정확 큐브의 세로/가로 비율 숫자가 계속 다르게 표시되는 것으로 이 차이를 바로 확인할 수 있습니다.

깊이 순서를 한 번만 정하고 고정해 두는 것

04의 traveler처럼 격자를 가로지르며 움직이는 블록에 z-index를 씬 생성 시점에만 매기면, 위치가 바뀌어도 쌓임 순서가 과거 값에 머물러 앞뒤가 뒤집힌 채 굳어 버립니다. 매 프레임 같은 공식을 다시 돌려야 이동 중에도 앞뒤가 저절로 맞습니다. 이 아홉 씬의 소스를 그대로 묶은 암호 zip의 비밀번호는 r5cadxdw 입니다 — 풀면 vanilla 판과 React 판이 항목마다 짝으로 들어 있습니다.

포인터 시차에 clamp 를 두지 않는 것

05·09처럼 포인터 좌표로 회전각·조명값을 정하는 항목에서 clamp를 빼면, 포인터가 화면 가장자리로 갈수록 각도가 과도해져 등각 격자 자체가 뭉개져 보입니다. 05는 LIMIT ±9deg로, 09는 0~1 범위 clamp로 이 상한을 반드시 둡니다.

등각 각도와 광원 값을 바꾸면 접근성까지 같이 보인다

값 하나를 바꾼 세 변형

이름 바꾼 값 느낌
등각 각도 01 35.264deg → 45deg(밀리터리 투영) 정확한 등각은 세 면이 똑같이 중요해 보이지만 45도로 눕히면 윗면이 강조돼 지도·게임판처럼 읽힌다
광원 각도 03 좌상단 45deg → 정면 0deg 좌상단 광원은 입체감이 또렷하지만 정면광은 명도 차가 줄어 평면 일러스트에 가까운 잔잔한 느낌이 된다
격자 밀도 02 6×6 → 16×16 격자가 촘촘해지면 도시 씬은 풍성해지지만 타일당 계산량이 늘어 저사양에서 프레임이 떨어질 수 있다

동작을 줄인 사용자에게 남기는 장면

08을 뺀 여덟 판은 시작 시 JS로 prefers-reduced-motion: reduce를 검사해 켜져 있으면 루프를 멈추고 배치가 끝난 정지 장면 한 장을 고정해서 보여주고, 08은 애초에 멈출 루프가 없는 대신 CSS @media (prefers-reduced-motion: reduce)로 창문 깜빡임 애니메이션만 멈추고 밝기를 고정값으로 둡니다. 05는 자동 훑기 경로의 한 지점에서 각도를 고정하고, 09는 낮과 밤 사이 한 시점의 하늘색·창문 상태로 멈춥니다.

스크린리더와 키보드로 만지는 아홉 판

판마다 role="application" 또는 role="img"·role="group"과 aria-label이 붙어 그 장면이 쓰는 계산을 한 줄로 밝히고, 05·06·07·09처럼 포인터·호버가 있는 항목은 tabindex="0"으로 키보드 포커스도 받습니다. 화면에 보이는 숫자(각도·명도·z-index)는 갱신되는 텍스트로 읽힙니다.

아홉 판을 실무에 붙이기 전에 자주 받는 질문

rotateX(-30deg)로 만든 등각 화면도 그냥 써도 되나요?

써도 되지만 정확한 등각은 아닙니다. 세 축의 축소 배율이 미세하게 달라 면적이 살짝 뒤틀리므로, 정밀한 비교나 격자 정렬이 중요한 장면이라면 01처럼 matrix3d로 -35.264deg를 직접 넣는 편이 안전합니다.

아이소메트릭 아이콘도 이 계산으로 만들 수 있나요?

가능합니다. 03의 면 셰이딩과 01의 정확한 등각 행렬만 있으면 큐브 하나짜리 아이콘도 광원 각도에 따라 명도가 실제로 바뀌는 입체 아이콘이 됩니다. 격자 반복이 필요 없다면 02·04의 좌표·정렬 계산은 생략해도 됩니다.

포인터가 없는 모바일에서는 시차 항목이 어떻게 보이나요?

05·09는 pointerleave나 1.1초 무입력 뒤 자동 훑기 경로로 전환되므로, 터치 기기에서도 손을 떼면 사인·코사인 곡선을 따라 계속 움직입니다. 완전히 멈춘 화면이 아니라 사람이 없어도 계산이 이어지는 장면입니다.

더 읽을거리

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

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