アイソメトリック デザイン — 陰影計算で組む3Dジオラマシーン9種
光源角度からキューブ各面の明度を計算し、格子座標を2:1公式で画面座標に変換して等角投影を組みます。深度でスタック順を並べるペインターズアルゴリズムまで — 部屋と街のジオラマ9種、コードはパスワードzipで。
01 isometric-true-projection-matrix
02 isometric-grid-coordinate-placement
03 isometric-face-shading-lambert
04 isometric-painter-stacking-order
05 isometric-pointer-parallax-tilt
06 isometric-exploded-view-hover
07 isometric-room-scene-build
08 isometric-city-block-scene
09 isometric-hero-day-night
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 本物の等角 — rotateX(-30deg)ではなく正確な投影行列
- 02 格子座標変換 — (col, row) を画面 (x, y) に変える2:1公式
- 03 面シェーディング — 光源角度で3面の明度を計算する
- 04 積み順の計算 — 深度をz-index式に変えるペインターズアルゴリズム
- 05 ポインター視差 — ジオラマ全体がわずかに追従する計算
- 06 分解図 — 深度順をそのまま展開距離に使うホバー
- 07 部屋のジオラマ — 01〜04を組み立てた完成シーン
- 08 街ブロック — 建物の高さがそれぞれ違うシーン配置計算
- 09 ヒーローシーン — 昼夜照明と視差が一緒に回る完成版
9つの順序は計算の依存関係そのものです。01が投影そのものを固定し、02がその投影の上にタイルを置く公式を担当し、03と04がその格子に陰影と深度を重ね、05と06がポインター入力を加え、07から09までが全部を組み上げて訪問者が部屋・街・ヒーローバナーとして認識できる完成シーンになります。
なぜ rotateX(-30deg) ではないのか — 真の等角投影行列と座標変換
チュートリアルでよく見るcssの等角キューブはrotateX(-30deg)で組まれています。近くまで見えますが、定規を当てると嘘がわかります。真の等角投影は3軸すべてをまったく同じ比率で縮め、その性質を満たす角度は1つしかありません。
正確な角度は-35.264度
その角度は-arctan(1/√2)、およそ-35.264度です。01はこの角度をrotateXのショートカットに任せず、JSで4×4回転行列を手で掛け合わせてから1本のmatrix3d()文字列にしてワールドコンテナに入れます。アイソメトリック web デザインで遠近感を出したくなっても、ここにCSSのperspective()を重ねてはいけません。等角は平行投影であり、視点を混ぜると消失点が生まれて等角そのものが崩れます。
格子座標は式で変える
ワールドが傾いた後、タイルの(col, row)はx = (col - row) * TILE_W / 2とy = (col + row) * TILE_H / 2で画面ピクセルに変わります。TILE_W/TILE_Hは34/17の2:1比率で、計算結果は--tx/--tyのCSS変数に入るため、格子の大きさを変えたら同じ式が全体を再配置します。目分量でdivを1つずつ動かす画面は、この記事の範囲外です。
9シーンの段階別コード
以下の9つはすべて、この節の等角行列と格子公式を再利用して、そこに陰影・積み順・ポインター入力・完成した構図のどれか1つだけを足します。コードフェンスはdemo/の実ファイルからそのまま抜き出したもので、完全なコード(vanilla 9種・React 9種)はパスワード付きzipに入っています。
01本物の等角 — rotateX(-30deg)ではなく正確な投影行列
近似のキューブと真の等角キューブを同じターンテーブル速度で並べて回し、歪みを理屈ではなく目で見えるようにした項目です。回転は毎秒24度(15秒で1周)で、各キューブの下に表示される縦横比は傾き角のcosからその場で計算されます。

<main class="stage">
<div class="ip" id="ip">
<p class="ip__hint">近似のrotateX(-30°)とmatrix3dの正確な等角(35.264°)を同じ速度で回し、縦横比の差を見ます</p>
<div class="ip__row" role="img" aria-label="近似等角と正確な等角の立方体を同じ速度で回して比較する場面" id="scene">
<div class="ip__col">
<div class="ip__field"><div class="ip__cube3d" id="cubeApprox"></div></div>
<div class="ip__stat"><b class="ip__tag">rotateX(-30deg)</b><span class="ip__ratio" id="ratioApprox">0.000</span></div>
</div>
<div class="ip__col">
<div class="ip__field"><div class="ip__cube3d" id="cubeExact"></div></div>
<div class="ip__stat"><b class="ip__tag">matrix3d(-35.264°)</b><span class="ip__ratio" id="ratioExact">0.000</span></div>
</div>
</div>
<p class="ip__meta"><b class="ip__deg" id="deg">0</b><span class="ip__metaUnit">° ターンテーブル回転 · 縦/横比</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 진짜 등각(01) — matrix3d 로 근사(rotateX(-30)) 와 정확(-35.264°) 등각을 나란히 턴테이블처럼
// 돌린다. 회전 행렬은 JS 가 매 프레임 곱해 만든다(계산 축) — CSS @keyframes 는 없다.
// 큐브 세 면은 filter: brightness 로 고정 명도(1 · .72 · .48)를 얹는다 — 광원 계산은 03 의 몫.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.ip {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; max-width: 420px;
}
.ip__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream; text-align: center;
}
.ip__row { display: flex; gap: $sp-4; }
.ip__col { display: flex; flex-direction: column; align-items: center; gap: $sp-1; }
.ip__field {
width: clamp(96px, 24vw, 128px); height: clamp(96px, 24vw, 128px);
display: grid; place-items: center;
}
.ip__cube3d {
position: relative; width: 76px; height: 76px;
transform-style: preserve-3d;
transform-origin: center center;
}
.ip__face {
position: absolute; top: 0; left: 0;
backface-visibility: hidden;
border: 1px solid rgba($subject-ink, .28);
background: $subject-sky;
}
.ip__face--top { filter: brightness(1); }
.ip__face--right { filter: brightness(.72); }
.ip__face--front { filter: brightness(.48); }
.ip__stat { display: flex; align-items: baseline; gap: $sp-1; }
.ip__tag {
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px);
color: $stage-yellow; font-variant-numeric: tabular-nums;
}
.ip__ratio {
font-weight: 600; font-size: clamp(8px, 2vw, 10px);
color: rgba($subject-cream, .82); font-variant-numeric: tabular-nums;
}
.ip__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-cream, .82); }
.ip__deg { font-weight: 700; color: $stage-yellow; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.ip__metaUnit { color: rgba($subject-cream, .72); }
@media (max-width: 340px) {
.ip { gap: $sp-1; }
.ip__row { gap: $sp-2; }
.ip__field { width: 84px; height: 84px; }
.ip__hint { font-size: 8px; }
.ip__tag { font-size: 8px; }
.ip__ratio, .ip__meta { font-size: 7px; }
}
// reduce — 턴테이블 루프가 안 돈다. 20도 지점에 고정된 정지 두 큐브만 남긴다.
@media (prefers-reduced-motion: reduce) {
.ip.is-still .ip__cube3d { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ip {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.ip__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
text-align: center;
}
.ip__row {
display: flex;
gap: 16px;
}
.ip__col {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.ip__field {
width: clamp(96px, 24vw, 128px);
height: clamp(96px, 24vw, 128px);
display: grid;
place-items: center;
}
.ip__cube3d {
position: relative;
width: 76px;
height: 76px;
transform-style: preserve-3d;
transform-origin: center center;
}
.ip__face {
position: absolute;
top: 0;
left: 0;
backface-visibility: hidden;
border: 1px solid rgba(23, 20, 26, 0.28);
background: #6ec8ff;
}
.ip__face--top {
filter: brightness(1);
}
.ip__face--right {
filter: brightness(0.72);
}
.ip__face--front {
filter: brightness(0.48);
}
.ip__stat {
display: flex;
align-items: baseline;
gap: 4px;
}
.ip__tag {
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
color: #ffd23f;
font-variant-numeric: tabular-nums;
}
.ip__ratio {
font-weight: 600;
font-size: clamp(8px, 2vw, 10px);
color: rgba(255, 247, 230, 0.82);
font-variant-numeric: tabular-nums;
}
.ip__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.ip__deg {
font-weight: 700;
color: #ffd23f;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.ip__metaUnit {
color: rgba(255, 247, 230, 0.72);
}
@media (max-width: 340px) {
.ip {
gap: 4px;
}
.ip__row {
gap: 8px;
}
.ip__field {
width: 84px;
height: 84px;
}
.ip__hint {
font-size: 8px;
}
.ip__tag {
font-size: 8px;
}
.ip__ratio, .ip__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.ip.is-still .ip__cube3d {
transition: none;
}
}// 진짜 등각(01) — 흔히 쓰는 rotateX(-30deg) rotateZ(45deg) 조합은 근사값이다. 정확한 등각
// 각도는 -arctan(1/sqrt(2)) = -35.264deg. 두 각도로 만든 회전 행렬을 JS 로 직접 곱해
// matrix3d 에 넣고, 같은 턴테이블 회전(spin)을 매 프레임 다시 곱해 큐브를 돌린다.
// CSS @keyframes 는 없다 — 움직임은 전부 계산된 matrix3d 값이다.
(function () {
var root = document.getElementById('ip');
var cubeApprox = document.getElementById('cubeApprox');
var cubeExact = document.getElementById('cubeExact');
var degEl = document.getElementById('deg');
var ratioApproxEl = document.getElementById('ratioApprox');
var ratioExactEl = document.getElementById('ratioExact');
if (!root || !cubeApprox || !cubeExact || !degEl || !ratioApproxEl || !ratioExactEl) return;
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;
}
function mat4ToCss(m) {
var s = '';
for (var i = 0; i < m.length; i++) s += (i ? ',' : '') + m[i].toFixed(6);
return 'matrix3d(' + s + ')';
}
// 큐브 한 개를 세 면(top·right·front) div 로 짓는다 — backface-visibility 로 뒷면을 지운다
function buildCube(mount, size) {
var defs = [
{ cls: 'ip__face ip__face--top', t: 'rotateX(90deg) translateZ(' + (size / 2) + 'px)' },
{ cls: 'ip__face ip__face--right', t: 'rotateY(90deg) translateZ(' + (size / 2) + 'px)' },
{ cls: 'ip__face ip__face--front', t: 'translateZ(' + (size / 2) + 'px)' }
];
for (var i = 0; i < defs.length; i++) {
var f = document.createElement('div');
f.className = defs[i].cls;
f.style.width = size + 'px'; f.style.height = size + 'px';
f.style.transform = defs[i].t;
mount.appendChild(f);
}
}
var mm = window.matchMedia('(max-width: 340px)').matches;
var SIZE = mm ? 52 : 76;
buildCube(cubeApprox, SIZE);
buildCube(cubeExact, SIZE);
var TILT_APPROX = -30; // 흔한 근사값 — 수학적 등각이 아니다
var TILT_EXACT = -35.264; // 정확한 등각 각도 = -arctan(1/sqrt(2))
ratioApproxEl.textContent = Math.cos(TILT_APPROX * Math.PI / 180).toFixed(3);
ratioExactEl.textContent = Math.cos(TILT_EXACT * Math.PI / 180).toFixed(3);
function norm360(a) { a %= 360; return a < 0 ? a + 360 : a; }
function paint(spinDeg) {
var mA = mat4Mul(mat4RotateX(TILT_APPROX), mat4RotateZ(45 + spinDeg));
var mE = mat4Mul(mat4RotateX(TILT_EXACT), mat4RotateZ(45 + spinDeg));
cubeApprox.style.transform = mat4ToCss(mA);
cubeExact.style.transform = mat4ToCss(mE);
degEl.textContent = String(Math.round(norm360(spinDeg)));
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
paint(20);
root.classList.add('is-still');
} else {
var start = performance.now();
(function step(now) {
var t = (now - start) / 1000;
paint((t * 24) % 360); // 초당 24도 — 15초에 한 바퀴, 두 큐브가 같은 속도로 돈다
requestAnimationFrame(step);
})(start);
}
})();// IsometricTrueProjectionMatrix — vanilla 판(run/333/demo/01-isometric-true-projection-matrix/index.html)
// 의 React 포트. 흔한 근사 rotateX(-30deg) 와 정확한 등각 각도 -arctan(1/sqrt(2))=-35.264deg 로
// 만든 두 회전 행렬을 JS 로 직접 곱해 matrix3d 에 넣고, 같은 턴테이블 spin 값을 매 프레임
// 다시 곱해 큐브를 돌린다. CSS @keyframes 는 없다 — 움직임은 전부 계산된 matrix3d 값이다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./IsometricTrueProjectionMatrix.module.scss";
export type IsometricTrueProjectionMatrixProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const HINT = "근사(rotateX -30°)와 matrix3d 정확 등각(35.264°)을 같은 속도로 돌려 세로 비율 차이를 봅니다";
const ARIA_SCENE = "근사 등각과 정확 등각 큐브를 같은 속도로 돌리는 비교 장면";
const TAG_APPROX = "rotateX(-30deg)";
const TAG_EXACT = "matrix3d(-35.264°)";
const UNIT = "° 턴테이블 회전 · 세로/가로 비";
const TILT_APPROX = -30;
const TILT_EXACT = -35.264;
function mat4RotateX(deg: number): number[] {
const 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: number): number[] {
const 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: number[], b: number[]): number[] {
const out = new Array<number>(16);
for (let col = 0; col < 4; col++) {
for (let row = 0; row < 4; row++) {
let sum = 0;
for (let k = 0; k < 4; k++) sum += a[k * 4 + row] * b[col * 4 + k];
out[col * 4 + row] = sum;
}
}
return out;
}
function mat4ToCss(m: number[]): string {
return "matrix3d(" + m.map((v) => v.toFixed(6)).join(",") + ")";
}
function norm360(a: number): number {
const m = a % 360;
return m < 0 ? m + 360 : m;
}
export default function IsometricTrueProjectionMatrix({
duration = "2s",
easing = "ease-out",
color = "#2f6df6"
}: IsometricTrueProjectionMatrixProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cubeApproxRef = useRef<HTMLDivElement | null>(null);
const cubeExactRef = useRef<HTMLDivElement | null>(null);
const degRef = useRef<HTMLElement | null>(null);
const ratioApproxRef = useRef<HTMLElement | null>(null);
const ratioExactRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const cubeApprox = cubeApproxRef.current;
const cubeExact = cubeExactRef.current;
const root = rootRef.current;
if (!cubeApprox || !cubeExact || !root) return;
const mm = window.matchMedia("(max-width: 340px)").matches;
const size = mm ? 52 : 76;
function buildFaces(mount: HTMLDivElement, s: number): void {
const defs = [
{ cls: styles.face + " " + styles.faceTop, t: "rotateX(90deg) translateZ(" + s / 2 + "px)" },
{ cls: styles.face + " " + styles.faceRight, t: "rotateY(90deg) translateZ(" + s / 2 + "px)" },
{ cls: styles.face + " " + styles.faceFront, t: "translateZ(" + s / 2 + "px)" }
];
for (const d of defs) {
const f = document.createElement("div");
f.className = d.cls;
f.style.width = s + "px";
f.style.height = s + "px";
f.style.transform = d.t;
mount.appendChild(f);
}
}
buildFaces(cubeApprox, size);
buildFaces(cubeExact, size);
if (ratioApproxRef.current) ratioApproxRef.current.textContent = Math.cos((TILT_APPROX * Math.PI) / 180).toFixed(3);
if (ratioExactRef.current) ratioExactRef.current.textContent = Math.cos((TILT_EXACT * Math.PI) / 180).toFixed(3);
function paint(spinDeg: number): void {
const mA = mat4Mul(mat4RotateX(TILT_APPROX), mat4RotateZ(45 + spinDeg));
const mE = mat4Mul(mat4RotateX(TILT_EXACT), mat4RotateZ(45 + spinDeg));
if (cubeApprox) cubeApprox.style.transform = mat4ToCss(mA);
if (cubeExact) cubeExact.style.transform = mat4ToCss(mE);
if (degRef.current) degRef.current.textContent = String(Math.round(norm360(spinDeg)));
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
paint(20);
root.classList.add(styles.isStill);
} else {
const start = performance.now();
const step = (now: number): void => {
const t = (now - start) / 1000;
paint((t * 24) % 360);
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
return () => cancelAnimationFrame(rafRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div
className={styles.ip}
ref={rootRef}
style={{ "--duration": duration, "--easing": easing, "--color": color } as Vars}
>
<p className={styles.hint}>{HINT}</p>
<div className={styles.row} role="img" aria-label={ARIA_SCENE}>
<div className={styles.col}>
<div className={styles.field}>
<div className={styles.cube3d} ref={cubeApproxRef}></div>
</div>
<div className={styles.statRow}>
<b className={styles.tag}>{TAG_APPROX}</b>
<span className={styles.ratio} ref={ratioApproxRef}>0.000</span>
</div>
</div>
<div className={styles.col}>
<div className={styles.field}>
<div className={styles.cube3d} ref={cubeExactRef}></div>
</div>
<div className={styles.statRow}>
<b className={styles.tag}>{TAG_EXACT}</b>
<span className={styles.ratio} ref={ratioExactRef}>0.000</span>
</div>
</div>
</div>
<p className={styles.meta}>
<b className={styles.deg} ref={degRef}>0</b>
<span className={styles.metaUnit}>{UNIT}</span>
</p>
</div>
</main>
);
}01 · 本物の等角 — rotateX(-30deg)ではなく正確な投影行列 — 計測 累積面積 8.516% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
行列を手で掛ける理由
mat4RotateX(-35.264)とmat4RotateZ(45 + spin)を掛けた積をmatrix3d()文字列に変換するまでが、真の等角の本体です。
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;
}
function mat4ToCss(m) {
var s = '';
for (var i = 0; i < m.length; i++) s += (i ? ',' : '') + m[i].toFixed(6);
return 'matrix3d(' + s + ')';
}
測定値が教えるもの
累積面積8.516%・面積最大1.985%・強度平均67.5で、動いたフレームは23/23です。中央の小さな2キューブが主役の構図として妥当な値です。
02格子座標変換 — (col, row) を画面 (x, y) に変える2:1公式
6×6の36マスが、ループ1つで菱形の格子に組み替わります。各マスの持ち上げ位相は(col + row) * 0.9 - t * 3.1で、波が格子を横切るように全部のタイルが呼吸します。

<main class="stage">
<div class="gp" id="gp">
<p class="gp__hint">各タイルでx=(col-row)×タイル幅/2、y=(col+row)×タイル高さ/2を計算しひし形格子に並べ、波のように持ち上げます</p>
<div class="gp__field" role="img" aria-label="碁盤目座標をひし形格子に変え、波のように持ち上げる場面">
<div class="gp__world" id="world"></div>
</div>
<p class="gp__meta"><b class="gp__count" id="n">36</b><span class="gp__metaUnit">マス · 2:1 等角格子</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 격자 좌표 변환(02) — 무대는 노랑. 타일은 2:1 등각 공식으로 배치하고 파도(--tz)만
// 매 프레임 갱신한다. transform-style: preserve-3d 인 world 가 회전하고 있어 자식의
// translateZ 가 실제 화면 높이 이동으로 보인다(원근 없이도 등각은 이렇게 동작한다).
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.gp {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; max-width: 420px;
}
.gp__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.35;
color: $subject-ink; text-align: center;
}
.gp__field {
position: relative;
width: clamp(180px, 44vw, 240px); height: clamp(120px, 30vw, 150px);
display: grid; place-items: center;
}
.gp__world {
position: relative;
transform-style: preserve-3d;
width: 1px; height: 1px;
}
.gp__tile {
position: absolute; left: 0; top: 0;
width: calc(var(--tw) * 1px - 3px);
height: calc(var(--th) * 1px - 3px);
transform: translate3d(var(--tx), var(--ty), var(--tz, 0px)) translate(-50%, -50%);
background: $subject-mint;
border: 1px solid rgba($subject-ink, .3);
border-radius: $r-xs;
box-shadow: $shadow-press;
}
.gp__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .78); }
.gp__count { font-weight: 800; color: $subject-blue; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.gp__metaUnit { color: rgba($subject-ink, .72); }
@media (max-width: 340px) {
.gp { gap: $sp-1; }
.gp__field { width: 156px; height: 108px; }
.gp__hint { font-size: 8px; }
.gp__meta { font-size: 7px; }
}
// reduce — 파도가 안 돈다. 한 위상(0.6)에서 멈춘 정지 격자만 남는다.
@media (prefers-reduced-motion: reduce) {
.gp.is-still .gp__tile { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gp {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.gp__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.35;
color: #17141a;
text-align: center;
}
.gp__field {
position: relative;
width: clamp(180px, 44vw, 240px);
height: clamp(120px, 30vw, 150px);
display: grid;
place-items: center;
}
.gp__world {
position: relative;
transform-style: preserve-3d;
width: 1px;
height: 1px;
}
.gp__tile {
position: absolute;
left: 0;
top: 0;
width: calc(var(--tw) * 1px - 3px);
height: calc(var(--th) * 1px - 3px);
transform: translate3d(var(--tx), var(--ty), var(--tz, 0px)) translate(-50%, -50%);
background: #4cd4a6;
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 4px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.gp__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.78);
}
.gp__count {
font-weight: 800;
color: #2f6df6;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.gp__metaUnit {
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.gp {
gap: 4px;
}
.gp__field {
width: 156px;
height: 108px;
}
.gp__hint {
font-size: 8px;
}
.gp__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.gp.is-still .gp__tile {
transition: none;
}
}// 격자 좌표 변환(02) — 바둑판 (col,row) 를 등각 화면 좌표 (x,y) 로 바꾸는 공식은
// x=(col-row)*타일폭/2, y=(col+row)*타일높이/2 다. 값은 CSS 변수(--tx·--ty·--tz)로 두고
// calc() 로 실제 px 를 만들어 translate3d 에 넣는다. 세로로 들어 올리는 파도(--tz)만
// 매 프레임 새로 계산해 갱신한다 — 격자 자체의 좌표식은 한 번만 계산해 고정한다.
(function () {
var root = document.getElementById('gp');
var world = document.getElementById('world');
var nEl = document.getElementById('n');
if (!root || !world || !nEl) return;
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;
}
function mat4ToCss(m) {
var s = '';
for (var i = 0; i < m.length; i++) s += (i ? ',' : '') + m[i].toFixed(6);
return 'matrix3d(' + s + ')';
}
var TILT = -35.264; // 정확한 등각 각도
world.style.transform = mat4ToCss(mat4Mul(mat4RotateX(TILT), mat4RotateZ(45)));
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;
var els = [];
for (var i = 0; i < tiles.length; i++) {
var tdata = tiles[i];
var el = document.createElement('div');
el.className = 'gp__tile';
el.style.setProperty('--tw', String(TILE_W));
el.style.setProperty('--th', String(TILE_H));
el.style.setProperty('--tx', (tdata.x + shiftX).toFixed(2) + 'px');
el.style.setProperty('--ty', (tdata.y + shiftY).toFixed(2) + 'px');
el.style.setProperty('--tz', '0px');
world.appendChild(el);
els.push({ el: el, col: tdata.col, row: tdata.row });
}
var AMP = 13;
function paint(t) {
for (var i = 0; i < els.length; i++) {
var it = els[i];
var phase = (it.col + it.row) * 0.9 - t * 3.1;
var z = Math.max(0, Math.sin(phase)) * AMP;
it.el.style.setProperty('--tz', z.toFixed(2) + 'px');
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
paint(0.6);
root.classList.add('is-still');
} else {
var start = performance.now();
(function step(now) {
paint((now - start) / 1000);
requestAnimationFrame(step);
})(start);
}
})();// IsometricGridCoordinatePlacement — vanilla 판(run/333/demo/02-isometric-grid-coordinate-placement/index.html)
// 의 React 포트. 바둑판 (col,row) 를 등각 화면 좌표 (x,y) 로 바꾸는 공식
// x=(col-row)*tileWidth/2, y=(col+row)*tileHeight/2 을 한 번만 계산해 고정하고,
// 세로로 들어 올리는 파도(--tz)만 매 프레임 다시 계산해 갱신한다.
import { useEffect, useRef } from "react";
import styles from "./IsometricGridCoordinatePlacement.module.scss";
export type IsometricGridCoordinatePlacementProps = {
duration?: string;
easing?: string;
color?: string;
};
type Box<T> = { current: T };
type Tile = { el: HTMLDivElement; col: number; row: number };
const HINT = "타일마다 x=(col-row)×타일폭/2, y=(col+row)×타일높이/2 를 계산해 마름모 격자로 배치하고 파도처럼 들어 올립니다";
const ARIA_FIELD = "바둑판 좌표를 마름모 격자로 바꾸고 파도처럼 들어 올리는 장면";
const UNIT = "칸 · 2:1 등각 격자";
function mat4RotateX(deg: number): number[] {
const 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: number): number[] {
const 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: number[], b: number[]): number[] {
const out = new Array<number>(16);
for (let col = 0; col < 4; col++) {
for (let row = 0; row < 4; row++) {
let sum = 0;
for (let k = 0; k < 4; k++) sum += a[k * 4 + row] * b[col * 4 + k];
out[col * 4 + row] = sum;
}
}
return out;
}
function mat4ToCss(m: number[]): string {
return "matrix3d(" + m.map((v) => v.toFixed(6)).join(",") + ")";
}
const TILT = -35.264;
const N = 6;
const TILE_W = 34, TILE_H = 17;
const AMP = 13;
export default function IsometricGridCoordinatePlacement({
duration = "2s",
easing = "ease-out",
color = "#17141a"
}: IsometricGridCoordinatePlacementProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const worldRef = useRef<HTMLDivElement | null>(null);
const countRef = useRef<HTMLElement | null>(null);
const tilesRef = useRef<Tile[]>([]) as Box<Tile[]>;
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const world = worldRef.current;
const root = rootRef.current;
if (!world || !root) return;
world.style.transform = mat4ToCss(mat4Mul(mat4RotateX(TILT), mat4RotateZ(45)));
if (countRef.current) countRef.current.textContent = String(N * N);
const raw: { col: number; row: number; x: number; y: number }[] = [];
let minX = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let row = 0; row < N; row++) {
for (let col = 0; col < N; col++) {
const x = ((col - row) * TILE_W) / 2;
const y = ((col + row) * TILE_H) / 2;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
raw.push({ col, row, x, y });
}
}
const shiftX = -(minX + maxX) / 2;
const shiftY = -maxY / 2;
const tiles: Tile[] = [];
for (const t of raw) {
const el = document.createElement("div");
el.className = styles.tile;
el.style.setProperty("--tw", String(TILE_W));
el.style.setProperty("--th", String(TILE_H));
el.style.setProperty("--tx", (t.x + shiftX).toFixed(2) + "px");
el.style.setProperty("--ty", (t.y + shiftY).toFixed(2) + "px");
el.style.setProperty("--tz", "0px");
world.appendChild(el);
tiles.push({ el, col: t.col, row: t.row });
}
tilesRef.current = tiles;
function paint(t: number): void {
for (const it of tilesRef.current) {
const phase = (it.col + it.row) * 0.9 - t * 3.1;
const z = Math.max(0, Math.sin(phase)) * AMP;
it.el.style.setProperty("--tz", z.toFixed(2) + "px");
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
paint(0.6);
root.classList.add(styles.isStill);
} else {
const start = performance.now();
const step = (now: number): void => {
paint((now - start) / 1000);
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(rafRef.current);
while (world.firstChild) world.removeChild(world.firstChild);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.gp} ref={rootRef} data-duration={duration} data-easing={easing} data-color={color}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} role="img" aria-label={ARIA_FIELD}>
<div className={styles.world} ref={worldRef}></div>
</div>
<p className={styles.meta}>
<b className={styles.count} ref={countRef}>36</b>
<span className={styles.metaUnit}>{UNIT}</span>
</p>
</div>
</main>
);
}02 · 格子座標変換 — (col, row) を画面 (x, y) に変える2:1公式 — 計測 累積面積 8.835% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ループで回す2:1公式
xは(col - row)から、yは(col + row)から出ます。マス目が増えても式は変わりません。
var N = 6; // 一辺のマス数 — N×N格子
var TILE_W = 34, TILE_H = 17; // 等角2:1比率 — 幅は高さの2倍
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;
中央揃えも計算で
累積面積8.835%・面積最大3.228%・強度平均45.9・動いたフレーム23/23です。中央への寄せはmin/maxから导かれたshiftX・shiftYで行われ、マージンは固定値を使いません。
03面シェーディング — 光源角度で3面の明度を計算する
キューブの上面・右面・前面の法線と光源ベクトルの内積から明度を作り、filter: brightness()に代入します。光源角度thetaは毎秒40度(9秒で1周)で回り、3面の明度が同じ式から同時に出直します。

<main class="stage">
<div class="fs" id="fs">
<p class="fs__hint">面の法線と光源ベクトルの内積で明るさを求めfilter: brightness()に代入します — 光源が回ると3つの面が順に明るくなります</p>
<div class="fs__field" role="img" aria-label="光源が立方体の周りを回り、3面の明るさが計算で変わる場面">
<div class="fs__ground"></div>
<div class="fs__sun" id="sun"></div>
<div class="fs__cube3d" id="cube"></div>
</div>
<div class="fs__readout">
<span class="fs__stat">上面 <b id="pTop">100</b>%</span>
<span class="fs__stat">右面 <b id="pRight">70</b>%</span>
<span class="fs__stat">左面 <b id="pFront">45</b>%</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 면 셰이딩(03) — 무대는 주황. 광원 벡터를 매 프레임 회전시키고 세 면 법선과의 내적을
// filter: brightness() 로 바로 대입한다. 태양점(--sx·--sy)의 위치도 같은 각도에서 계산.
// 바닥의 linear-gradient 는 장식 그림자 — 광원과는 무관한 고정 배경이다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-orange);
.fs {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; max-width: 420px;
}
.fs__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.35;
color: $subject-ink; text-align: center;
}
.fs__field {
position: relative;
width: clamp(180px, 42vw, 220px); height: clamp(140px, 34vw, 170px);
display: grid; place-items: center;
}
.fs__ground {
position: absolute; left: 0; right: 0; bottom: $sp-2; height: 40%;
background: linear-gradient(180deg, rgba($subject-ink, 0), rgba($subject-ink, .22));
border-radius: $r-sm;
}
.fs__sun {
position: absolute; top: 50%; left: 50%;
width: $sp-2; height: $sp-2; border-radius: $r-pill;
background: $subject-cream;
box-shadow: 0 0 8px rgba($subject-cream, .8);
transform: translate3d(calc(-50% + var(--sx, 0px)), calc(-50% + var(--sy, 0px)), 0);
}
.fs__cube3d {
position: relative; width: 88px; height: 88px;
transform-style: preserve-3d;
transform-origin: center center;
}
.fs__face {
position: absolute; top: 0; left: 0;
backface-visibility: hidden;
border: 1px solid rgba($subject-ink, .28);
background: $subject-sky;
}
.fs__readout { display: flex; gap: $sp-3; flex-wrap: wrap; justify-content: center; }
.fs__stat {
font-weight: 600; font-size: clamp(9px, 2.2vw, 11px);
color: rgba($subject-ink, .82);
font-variant-numeric: tabular-nums;
}
.fs__stat b { font-weight: 800; color: $subject-ink; }
@media (max-width: 340px) {
.fs { gap: $sp-1; }
.fs__field { width: 156px; height: 118px; }
.fs__hint { font-size: 8px; }
.fs__stat { font-size: 7px; }
}
// reduce — 광원 회전이 안 돈다. 35도 지점 밝기로 고정된 정지 큐브만 남긴다.
@media (prefers-reduced-motion: reduce) {
.fs.is-still .fs__cube3d { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fs {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.fs__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.35;
color: #17141a;
text-align: center;
}
.fs__field {
position: relative;
width: clamp(180px, 42vw, 220px);
height: clamp(140px, 34vw, 170px);
display: grid;
place-items: center;
}
.fs__ground {
position: absolute;
left: 0;
right: 0;
bottom: 8px;
height: 40%;
background: linear-gradient(180deg, rgba(23, 20, 26, 0), rgba(23, 20, 26, 0.22));
border-radius: 8px;
}
.fs__sun {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
border-radius: 999px;
background: #fff7e6;
box-shadow: 0 0 8px rgba(255, 247, 230, 0.8);
transform: translate3d(calc(-50% + var(--sx, 0px)), calc(-50% + var(--sy, 0px)), 0);
}
.fs__cube3d {
position: relative;
width: 88px;
height: 88px;
transform-style: preserve-3d;
transform-origin: center center;
}
.fs__face {
position: absolute;
top: 0;
left: 0;
backface-visibility: hidden;
border: 1px solid rgba(23, 20, 26, 0.28);
background: #6ec8ff;
}
.fs__readout {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.fs__stat {
font-weight: 600;
font-size: clamp(9px, 2.2vw, 11px);
color: rgba(23, 20, 26, 0.82);
font-variant-numeric: tabular-nums;
}
.fs__stat b {
font-weight: 800;
color: #17141a;
}
@media (max-width: 340px) {
.fs {
gap: 4px;
}
.fs__field {
width: 156px;
height: 118px;
}
.fs__hint {
font-size: 8px;
}
.fs__stat {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.fs.is-still .fs__cube3d {
transition: none;
}
}// 면 셰이딩(03) — 세 면의 법선(top·right·left)과 광원 벡터의 내적(dot product)을 매 프레임
// 계산해 밝기 비율을 구하고 filter: brightness() 에 대입한다. 광원 각도(theta) 하나만
// 회전시켜도 세 면 전부의 명도가 같은 계산식으로 다시 나온다 — CSS @keyframes 는 없다.
(function () {
var root = document.getElementById('fs');
var cube = document.getElementById('cube');
var sun = document.getElementById('sun');
var pTop = document.getElementById('pTop');
var pRight = document.getElementById('pRight');
var pFront = document.getElementById('pFront');
if (!root || !cube || !sun || !pTop || !pRight || !pFront) return;
function buildCube(mount, size) {
var defs = [
{ cls: 'fs__face fs__face--top', t: 'rotateX(90deg) translateZ(' + (size / 2) + 'px)' },
{ cls: 'fs__face fs__face--right', t: 'rotateY(90deg) translateZ(' + (size / 2) + 'px)' },
{ cls: 'fs__face fs__face--front', t: 'translateZ(' + (size / 2) + 'px)' }
];
var out = {};
for (var i = 0; i < defs.length; i++) {
var f = document.createElement('div');
f.className = defs[i].cls;
f.style.width = size + 'px'; f.style.height = size + 'px';
f.style.transform = defs[i].t;
mount.appendChild(f);
out[defs[i].cls.split('--')[1]] = f;
}
return out;
}
var mm = window.matchMedia('(max-width: 340px)').matches;
var SIZE = mm ? 62 : 88;
var TILT = -35.264, SPIN = 45;
cube.style.transform = 'rotateX(' + TILT + 'deg) rotateZ(' + SPIN + 'deg)';
var faces = buildCube(cube, SIZE);
// 세 면의 법선 — 큐브 로컬 좌표계 기준(上面 +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) + ')';
pTop.textContent = String(Math.round(bTop * 100));
pRight.textContent = String(Math.round(bRight * 100));
pFront.textContent = String(Math.round(bFront * 100));
var R = mm ? 58 : 84;
sun.style.setProperty('--sx', (Math.cos(r) * R).toFixed(1) + 'px');
sun.style.setProperty('--sy', (Math.sin(r) * R * 0.5).toFixed(1) + 'px');
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
shade(35);
root.classList.add('is-still');
} else {
var start = performance.now();
(function step(now) {
var t = (now - start) / 1000;
shade((t * 40) % 360); // 초당 40도 — 9초에 광원이 한 바퀴 돈다
requestAnimationFrame(step);
})(start);
}
})();// IsometricFaceShadingLambert — vanilla 판(run/333/demo/03-isometric-face-shading-lambert/index.html)
// 의 React 포트. 면 법선(top/right/front)과 광원 벡터의 내적(Lambert)으로 밝기를 구해
// filter: brightness() 에 대입한다. 광원이 궤도를 돌며 매 프레임 세 면을 다시 계산한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./IsometricFaceShadingLambert.module.scss";
export type IsometricFaceShadingLambertProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--sx"?: string; "--sy"?: string };
type Box<T> = { current: T };
type Faces = { top: HTMLDivElement; right: HTMLDivElement; front: HTMLDivElement };
const HINT = "면 법선과 광원 벡터의 내적으로 밝기를 구해 filter: brightness() 에 대입합니다 — 광원이 돌면 세 면이 차례로 밝아집니다";
const ARIA_SCENE = "광원이 큐브 둘레를 돌며 세 면의 밝기가 계산으로 바뀌는 장면";
const LABEL_TOP = "윗면 ";
const LABEL_RIGHT = "오른면 ";
const LABEL_FRONT = "왼면 ";
const N_TOP = [0, 0, 1];
const N_RIGHT = [1, 0, 0];
const N_FRONT = [0, 1, 0];
function dot3(a: number[], b: number[]): number {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function normalize(v: number[]): number[] {
const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) || 1;
return [v[0] / len, v[1] / len, v[2] / len];
}
function clamp01(v: number): number {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function brightnessOf(n: number[], l: number[]): number {
return 0.32 + 0.92 * clamp01(dot3(n, l));
}
export default function IsometricFaceShadingLambert({
duration = "9s",
easing = "linear",
color = "#6ec8ff"
}: IsometricFaceShadingLambertProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cubeRef = useRef<HTMLDivElement | null>(null);
const sunRef = useRef<HTMLDivElement | null>(null);
const pTopRef = useRef<HTMLElement | null>(null);
const pRightRef = useRef<HTMLElement | null>(null);
const pFrontRef = useRef<HTMLElement | null>(null);
const facesRef = useRef<Faces | null>(null) as Box<Faces | null>;
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const cube = cubeRef.current;
const root = rootRef.current;
if (!cube || !root) return;
const mm = window.matchMedia("(max-width: 340px)").matches;
const size = mm ? 52 : 76;
function buildCube(mount: HTMLDivElement, s: number): Faces {
const top = document.createElement("div");
top.className = styles.face + " " + styles.faceTop;
top.style.width = s + "px";
top.style.height = s + "px";
top.style.transform = "rotateX(90deg) translateZ(" + s / 2 + "px)";
const right = document.createElement("div");
right.className = styles.face + " " + styles.faceRight;
right.style.width = s + "px";
right.style.height = s + "px";
right.style.transform = "rotateY(90deg) translateZ(" + s / 2 + "px)";
const front = document.createElement("div");
front.className = styles.face + " " + styles.faceFront;
front.style.width = s + "px";
front.style.height = s + "px";
front.style.transform = "translateZ(" + s / 2 + "px)";
mount.appendChild(top);
mount.appendChild(right);
mount.appendChild(front);
return { top, right, front };
}
const faces = buildCube(cube, size);
facesRef.current = faces;
cube.style.transform = "rotateX(-35.264deg) rotateZ(45deg)";
function shade(theta: number): void {
const r = (theta * Math.PI) / 180;
const L = normalize([Math.cos(r), Math.sin(r), 0.55]);
const bTop = brightnessOf(N_TOP, L);
const bRight = brightnessOf(N_RIGHT, L);
const bFront = brightnessOf(N_FRONT, L);
const f = facesRef.current;
if (!f) return;
f.top.style.filter = "brightness(" + bTop.toFixed(3) + ")";
f.right.style.filter = "brightness(" + bRight.toFixed(3) + ")";
f.front.style.filter = "brightness(" + bFront.toFixed(3) + ")";
if (pTopRef.current) pTopRef.current.textContent = String(Math.round(bTop * 100));
if (pRightRef.current) pRightRef.current.textContent = String(Math.round(bRight * 100));
if (pFrontRef.current) pFrontRef.current.textContent = String(Math.round(bFront * 100));
if (sunRef.current) {
sunRef.current.style.setProperty("--sx", (Math.cos(r) * 40).toFixed(2) + "px");
sunRef.current.style.setProperty("--sy", (Math.sin(r) * 40).toFixed(2) + "px");
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
shade(35);
root.classList.add(styles.isStill);
} else {
const start = performance.now();
const step = (now: number): void => {
const t = (now - start) / 1000;
shade((t * 40) % 360);
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(rafRef.current);
while (cube.firstChild) cube.removeChild(cube.firstChild);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.fs} ref={rootRef} data-duration={duration} data-easing={easing} data-color={color}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} role="img" aria-label={ARIA_SCENE}>
<div className={styles.ground}></div>
<div className={styles.sun} ref={sunRef} style={{} as Vars}></div>
<div className={styles.cube3d} ref={cubeRef}></div>
</div>
<p className={styles.readout}>
<span className={styles.stat}><b>{LABEL_TOP}</b><em ref={pTopRef}>32</em>%</span>
<span className={styles.stat}><b>{LABEL_RIGHT}</b><em ref={pRightRef}>32</em>%</span>
<span className={styles.stat}><b>{LABEL_FRONT}</b><em ref={pFrontRef}>32</em>%</span>
</p>
</div>
</main>
);
}03 · 面シェーディング — 光源角度で3面の明度を計算する — 計測 累積面積 5.965% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
内積から明度への変換
明度式は0.32 + 0.92 * clamp(dot, 0, 1)です。光源角度の変数を1つ動かすと3面すべてが同じ計算で追従します。
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) + ')';
pTop.textContent = String(Math.round(bTop * 100));
pRight.textContent = String(Math.round(bRight * 100));
pFront.textContent = String(Math.round(bFront * 100));
var R = mm ? 58 : 84;
sun.style.setProperty('--sx', (Math.cos(r) * R).toFixed(1) + 'px');
sun.style.setProperty('--sy', (Math.sin(r) * R * 0.5).toFixed(1) + 'px');
}
光源位置も同じ角度から
太陽の表示位置は光源角度のcos・sinそのもので、別のアニメーションはありません。累積面積5.965%・面積最大5.09%・強度平均67.9・動いたフレーム23/23です。
04積み順の計算 — 深度をz-index式に変えるペインターズアルゴリズム
固定ブロックのz-indexはMath.round((col + row) * 10)を1度だけ計算して固定し、移動ブロック(traveler)は自分の(col, row)が変わるたびに同じ式で再計算します。深度バッファのない2D描画で「奥から描く」を再現するのがペインターズアルゴリズムです。

<main class="stage">
<div class="ps" id="ps">
<p class="ps__hint">z-index = (col+row)×10 を毎フレーム計算して代入します — 移動ブロックが格子を横切ると前後関係が自動で入れ替わります</p>
<div class="ps__field" role="img" aria-label="移動するブロックが固定格子ブロックの間を通り、前後順序が計算で変わる場面">
<div class="ps__world" id="world"></div>
</div>
<p class="ps__meta">移動ブロックのz-index = <b id="z">0</b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 페인터 알고리즘 정렬(04) — 무대는 페이퍼. 고정 격자 블록은 z-index 를 한 번만 계산해
// 박아 두고, 이동 블록만 매 프레임 같은 공식((col+row)*10)을 다시 계산해 z-index 를
// 갱신한다 — 그래서 격자 사이를 지나가며 앞뒤가 저절로 뒤집힌다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.ps {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; max-width: 420px;
}
.ps__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.35;
color: $subject-ink; text-align: center;
}
.ps__field {
position: relative;
width: clamp(180px, 42vw, 220px); height: clamp(140px, 34vw, 168px);
display: grid; place-items: center;
}
.ps__world { position: relative; transform-style: preserve-3d; width: 1px; height: 1px; }
.ps__block {
position: absolute; left: 0; top: 0;
width: calc(var(--w) - 3px); height: calc(var(--w) - 3px);
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, -50%);
background: $subject-blue;
border: 1px solid rgba($subject-ink, .3);
border-radius: $r-xs;
box-shadow: $shadow-press;
}
.ps__block--traveler {
background: $subject-orange-deep;
box-shadow: $shadow-raised;
}
.ps__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .82); }
.ps__meta b { font-weight: 800; color: $subject-orange-deep; font-variant-numeric: tabular-nums; }
@media (max-width: 340px) {
.ps { gap: $sp-1; }
.ps__field { width: 156px; height: 112px; }
.ps__hint { font-size: 8px; }
.ps__meta { font-size: 7px; }
}
// reduce — 이동 블록이 안 돈다. 0.8 위상에 고정된 정지 장면만 남긴다.
@media (prefers-reduced-motion: reduce) {
.ps.is-still .ps__block { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ps {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.ps__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.35;
color: #17141a;
text-align: center;
}
.ps__field {
position: relative;
width: clamp(180px, 42vw, 220px);
height: clamp(140px, 34vw, 168px);
display: grid;
place-items: center;
}
.ps__world {
position: relative;
transform-style: preserve-3d;
width: 1px;
height: 1px;
}
.ps__block {
position: absolute;
left: 0;
top: 0;
width: calc(var(--w) - 3px);
height: calc(var(--w) - 3px);
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, -50%);
background: #2f6df6;
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 4px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ps__block--traveler {
background: #c93000;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ps__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.82);
}
.ps__meta b {
font-weight: 800;
color: #c93000;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.ps {
gap: 4px;
}
.ps__field {
width: 156px;
height: 112px;
}
.ps__hint {
font-size: 8px;
}
.ps__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.ps.is-still .ps__block {
transition: none;
}
}// 페인터 알고리즘 정렬(04) — 등각 장면에는 depth buffer 가 없다. 대신 "뒤에 있는 것부터
// 그린다"(화가의 순서)를 z-index 숫자로 흉내낸다. col+row 가 클수록 화면 앞쪽(카메라에
// 가깝다) 이므로 z-index 도 커야 한다. 이동 블록은 매 프레임 자신의 (col,row) 로 같은
// 공식을 다시 계산해 z-index 를 갱신한다 — 그래서 격자를 지나가면 앞뒤가 자동으로 뒤집힌다.
(function () {
var root = document.getElementById('ps');
var world = document.getElementById('world');
var zEl = document.getElementById('z');
if (!root || !world || !zEl) return;
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;
}
function mat4ToCss(m) {
var s = '';
for (var i = 0; i < m.length; i++) s += (i ? ',' : '') + m[i].toFixed(6);
return 'matrix3d(' + s + ')';
}
var TILT = -35.264;
world.style.transform = mat4ToCss(mat4Mul(mat4RotateX(TILT), mat4RotateZ(45)));
var mm = window.matchMedia('(max-width: 340px)').matches;
var N = 4;
var TILE_W = mm ? 30 : 38, TILE_H = mm ? 15 : 19;
var SIDE = mm ? 26 : 34;
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);
}
}
// 이동 블록 — 매 프레임 (col,row) 를 다시 계산해 같은 공식으로 z-index 를 갱신한다
var traveler = document.createElement('div');
traveler.className = 'ps__block ps__block--traveler';
traveler.style.setProperty('--w', SIDE + 'px');
world.appendChild(traveler);
var CENTER = (N - 1) / 2;
var RADIUS = CENTER + 0.35;
function paint(t) {
var ang = t * 0.9;
var col = CENTER + Math.cos(ang) * RADIUS;
var row = CENTER + Math.sin(ang) * RADIUS;
place(traveler, col, row);
var z = Math.round((col + row) * 10) + 1; // +1 — 같은 칸일 때 이동 블록이 위로
traveler.style.zIndex = String(z);
zEl.textContent = String(z);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
paint(0.8);
root.classList.add('is-still');
} else {
var start = performance.now();
(function step(now) {
paint((now - start) / 1000);
requestAnimationFrame(step);
})(start);
}
})();// IsometricPainterStackingOrder — vanilla 판(run/333/demo/04-isometric-painter-stacking-order/index.html)
// 의 React 포트. 고정 블록은 z-index = round((col+row)*10) 을 한 번만 계산해 굳히고,
// 궤도를 도는 이동 블록만 매 프레임 같은 공식으로 z-index 를 다시 계산해 앞뒤를 뒤집는다.
import { useEffect, useRef } from "react";
import styles from "./IsometricPainterStackingOrder.module.scss";
export type IsometricPainterStackingOrderProps = {
duration?: string;
easing?: string;
color?: string;
};
type Box<T> = { current: T };
const HINT = "z-index = (col+row)×10 을 매 프레임 계산해 대입합니다 — 이동 블록이 격자를 가로지르며 앞뒤가 저절로 뒤집힙니다";
const ARIA_FIELD = "이동하는 블록이 고정 격자 블록들 사이를 지나며 앞뒤 순서가 계산으로 바뀌는 장면";
const LABEL_Z = "이동 블록 z-index = ";
const N = 4;
const TILE_W = 40, TILE_H = 20;
const CENTER = (N - 1) / 2;
const RADIUS = CENTER + 0.35;
function toScreen(col: number, row: number): { x: number; y: number } {
return { x: ((col - row) * TILE_W) / 2, y: ((col + row) * TILE_H) / 2 };
}
export default function IsometricPainterStackingOrder({
duration = "2s",
easing = "linear",
color = "#c93000"
}: IsometricPainterStackingOrderProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const worldRef = useRef<HTMLDivElement | null>(null);
const travelerRef = useRef<HTMLDivElement | null>(null);
const zTextRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const world = worldRef.current;
const traveler = travelerRef.current;
const root = rootRef.current;
if (!world || !traveler || !root) return;
let minX = Infinity, maxX = -Infinity, maxY = -Infinity;
const blocks: { x: number; y: number }[] = [];
for (let row = 0; row < N; row++) {
for (let col = 0; col < N; col++) {
const p = toScreen(col, row);
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
blocks.push(p);
}
}
const shiftX = -(minX + maxX) / 2;
const shiftY = -maxY / 2;
let idx = 0;
for (let row = 0; row < N; row++) {
for (let col = 0; col < N; col++) {
const p = blocks[idx++];
const el = document.createElement("div");
el.className = styles.block;
el.style.setProperty("--tx", (p.x + shiftX).toFixed(2) + "px");
el.style.setProperty("--ty", (p.y + shiftY).toFixed(2) + "px");
el.style.zIndex = String(Math.round((col + row) * 10));
world.appendChild(el);
}
}
world.appendChild(traveler);
function paint(t: number): void {
const ang = t * 0.9;
const col = CENTER + Math.cos(ang) * RADIUS;
const row = CENTER + Math.sin(ang) * RADIUS;
const p = toScreen(col, row);
traveler!.style.setProperty("--tx", (p.x + shiftX).toFixed(2) + "px");
traveler!.style.setProperty("--ty", (p.y + shiftY).toFixed(2) + "px");
const z = Math.round((col + row) * 10) + 1;
traveler!.style.zIndex = String(z);
if (zTextRef.current) zTextRef.current.textContent = String(z);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
paint(0.7);
root.classList.add(styles.isStill);
} else {
const start = performance.now();
const step = (now: number): void => {
paint((now - start) / 1000);
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(rafRef.current);
while (world.firstChild) world.removeChild(world.firstChild);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.ps} ref={rootRef} data-duration={duration} data-easing={easing} data-color={color}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} role="img" aria-label={ARIA_FIELD}>
<div className={styles.world} ref={worldRef}>
<div className={styles.blockTraveler} ref={travelerRef}></div>
</div>
</div>
<p className={styles.meta}>
<span className={styles.metaLabel}>{LABEL_Z}</span>
<b className={styles.zVal} ref={zTextRef}>0</b>
</p>
</div>
</main>
);
}04 · 積み順の計算 — 深度をz-index式に変えるペインターズアルゴリズム — 計測 累積面積 4.119% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
同じ式を2回計算する
配置を担うplace()は02と同じ座標式です。固定と移動の違いは、式を1回だけ呼ぶか毎フレーム呼ぶかだけです。
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);
}
}
// 移動ブロック — 毎フレーム(col,row)を再計算し同じ式でz-indexを更新する
var traveler = document.createElement('div');
traveler.className = 'ps__block ps__block--traveler';
同じマスでは+1で上に重なる
travelerのz-indexは式に+1した値で、同じマスに来たとき手前に出ます。累積面積4.119%・面積最大0.933%・強度平均127.4(9種で最も明るい)・動いたフレーム23/23です。
05ポインター視差 — ジオラマ全体がわずかに追従する計算
pointermove座標をフィールド中心基準で-1〜1に正規化し、clamp(±9度)で抑えた目標角をrequestAnimationFrameのループの中で毎フレーム0.16ずつ補間します。等角格子はそのままで、カメラだけが回ったように見える計算です。

<main class="stage">
<div class="pt" id="pt">
<p class="pt__hint">等角の基本角度の上に、clamp()で制限したポインター位置の分だけ傾けます — 手を離すと自動で走査します</p>
<div class="pt__field" role="application" aria-label="ポインターを乗せると等角の場面が視差分だけ傾き、離すと自動で走査する場面" tabindex="0" id="field">
<div class="pt__tilt" id="tilt">
<div class="pt__world" id="world"></div>
</div>
</div>
<p class="pt__meta">傾き x <b id="rx">0</b>° · y <b id="ry">0</b>°</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 포인터 시차 기울임(05) — 무대는 먹. 등각 고정각(35.264°) 위에 포인터 목표각을 clamp() 로
// 가둔 만큼만 rotateX/rotateY 로 더한다. lerp(0.16) 로 부드럽게 따라가고, 입력이 없으면
// requestAnimationFrame 루프가 자동 경로로 대신 훑는다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-light;
@include stage($stage-ink);
.pt {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; max-width: 420px;
}
.pt__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.35;
color: $subject-cream; text-align: center;
}
.pt__field {
position: relative;
width: clamp(180px, 42vw, 220px); height: clamp(140px, 34vw, 168px);
display: grid; place-items: center;
perspective: 700px;
outline: none;
}
.pt__tilt { position: relative; transform-style: preserve-3d; width: 1px; height: 1px; }
.pt__world { position: relative; transform-style: preserve-3d; width: 1px; height: 1px; }
.pt__block {
position: absolute; left: 0; top: 0;
width: calc(38px - 3px); height: calc(38px - 3px);
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, -50%);
background: $subject-blue-light;
border: 1px solid rgba($subject-cream, .3);
border-radius: $r-xs;
box-shadow: $shadow-press;
}
.pt__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-cream, .82); }
.pt__meta b { font-weight: 800; color: $stage-yellow; font-variant-numeric: tabular-nums; }
@media (max-width: 340px) {
.pt { gap: $sp-1; }
.pt__field { width: 156px; height: 112px; }
.pt__block { width: calc(30px - 3px); height: calc(30px - 3px); }
.pt__hint { font-size: 8px; }
.pt__meta { font-size: 7px; }
}
// reduce — 자동 훑기·lerp 추적이 멎는다. 0.7 위상 각도로 고정된 정지 장면만 남긴다.
@media (prefers-reduced-motion: reduce) {
.pt.is-still .pt__tilt { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pt {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.pt__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.35;
color: #fff7e6;
text-align: center;
}
.pt__field {
position: relative;
width: clamp(180px, 42vw, 220px);
height: clamp(140px, 34vw, 168px);
display: grid;
place-items: center;
perspective: 700px;
outline: none;
}
.pt__tilt {
position: relative;
transform-style: preserve-3d;
width: 1px;
height: 1px;
}
.pt__world {
position: relative;
transform-style: preserve-3d;
width: 1px;
height: 1px;
}
.pt__block {
position: absolute;
left: 0;
top: 0;
width: 35px;
height: 35px;
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, -50%);
background: #5b8cf8;
border: 1px solid rgba(255, 247, 230, 0.3);
border-radius: 4px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.pt__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.pt__meta b {
font-weight: 800;
color: #ffd23f;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.pt {
gap: 4px;
}
.pt__field {
width: 156px;
height: 112px;
}
.pt__block {
width: 27px;
height: 27px;
}
.pt__hint {
font-size: 8px;
}
.pt__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.pt.is-still .pt__tilt {
transition: none;
}
}// 포인터 시차 기울임(05) — 등각 고정 각도(35.264°) 는 그대로 두고, 그 바깥 래퍼에
// 포인터 좌표를 clamp() 로 제한한 소량의 rotateX/rotateY 를 더해 "시차"를 만든다.
// 실제 포인터가 들어오면 pointermove 로 목표각을 갱신하고, 1.1초 넘게 입력이 없으면
// requestAnimationFrame 루프가 자체 계산한 경로로 자동 훑기를 재개한다(자동 시연).
(function () {
var root = document.getElementById('pt');
var field = document.getElementById('field');
var tilt = document.getElementById('tilt');
var world = document.getElementById('world');
var rxEl = document.getElementById('rx');
var ryEl = document.getElementById('ry');
if (!root || !field || !tilt || !world || !rxEl || !ryEl) return;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
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;
}
function mat4ToCss(m) {
var s = '';
for (var i = 0; i < m.length; i++) s += (i ? ',' : '') + m[i].toFixed(6);
return 'matrix3d(' + s + ')';
}
var TILT = -35.264;
world.style.transform = mat4ToCss(mat4Mul(mat4RotateX(TILT), mat4RotateZ(45)));
var mm = window.matchMedia('(max-width: 340px)').matches;
var TILE_W = mm ? 30 : 38, TILE_H = mm ? 15 : 19;
var N = 3;
for (var row = 0; row < N; row++) {
for (var col = 0; col < N; col++) {
var b = document.createElement('div');
b.className = 'pt__block';
var x = (col - row) * TILE_W / 2;
var y = (col + row) * TILE_H / 2;
b.style.setProperty('--tx', x.toFixed(2) + 'px');
b.style.setProperty('--ty', y.toFixed(2) + 'px');
b.style.zIndex = String(Math.round((col + row) * 10));
world.appendChild(b);
}
}
var LIMIT = 9; // 최대 기울임 각도(도) — clamp 의 상하한
var targetX = 0, targetY = 0;
var curX = 0, curY = 0;
var lastInput = -Infinity;
var IDLE_MS = 1100;
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);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
autoPath(0.7);
curX = targetX; curY = targetY;
paint(0, 0.7);
root.classList.add('is-still');
} else {
var start = performance.now();
(function step(now) {
paint(now, (now - start) / 1000);
requestAnimationFrame(step);
})(start);
}
})();// IsometricPointerParallaxTilt — vanilla 판(run/333/demo/05-isometric-pointer-parallax-tilt/index.html)
// 의 React 포트. 등각 기본 각도(-35.264deg) 위에 포인터 위치를 clamp() 로 가둔 만큼만
// 덧기울인다. 손을 떼면 자동 경로(sin/cos)로 같은 보간을 계속한다.
import { useEffect, useRef } from "react";
import styles from "./IsometricPointerParallaxTilt.module.scss";
export type IsometricPointerParallaxTiltProps = {
duration?: string;
easing?: string;
color?: string;
};
type Box<T> = { current: T };
const HINT = "등각 기본 각도 위에 포인터 위치를 clamp() 로 가둔 만큼만 더 기울입니다 — 손을 떼면 자동으로 훑습니다";
const ARIA_FIELD = "포인터를 올리면 등각 장면이 시차만큼 기울고, 손을 떼면 자동으로 훑는 장면";
const LABEL_TILT = "기울임 x ";
const TILT = -35.264;
const LIMIT = 9;
const IDLE_MS = 1100;
function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
export default function IsometricPointerParallaxTilt({
duration = "600ms",
easing = "ease-out",
color = "#5b8cf8"
}: IsometricPointerParallaxTiltProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const tiltRef = useRef<HTMLDivElement | null>(null);
const tiltXRef = useRef<HTMLElement | null>(null);
const stateRef = useRef({
targetX: 0,
targetY: 0,
curX: 0,
curY: 0,
lastInput: -Infinity
}) as Box<{ targetX: number; targetY: number; curX: number; curY: number; lastInput: number }>;
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const field = fieldRef.current;
const tilt = tiltRef.current;
const root = rootRef.current;
if (!field || !tilt || !root) return;
function onMove(e: PointerEvent): void {
if (!field) return;
const rect = field.getBoundingClientRect();
const nx = (e.clientX - rect.left) / rect.width - 0.5;
const ny = (e.clientY - rect.top) / rect.height - 0.5;
stateRef.current.targetX = clamp(nx * LIMIT * 2, -LIMIT, LIMIT);
stateRef.current.targetY = clamp(ny * LIMIT * 2, -LIMIT, LIMIT);
stateRef.current.lastInput = performance.now();
}
function onLeave(): void {
stateRef.current.lastInput = -Infinity;
}
field.addEventListener("pointermove", onMove);
field.addEventListener("pointerleave", onLeave);
function autoPath(t: number): { x: number; y: number } {
return { x: Math.sin(t * 0.6) * LIMIT * 0.85, y: Math.cos(t * 0.42) * LIMIT };
}
function paint(now: number, t: number): void {
const s = stateRef.current;
const idle = now - s.lastInput > IDLE_MS;
if (idle) {
const p = autoPath(t);
s.targetX = p.x;
s.targetY = p.y;
}
s.curX += (s.targetX - s.curX) * 0.16;
s.curY += (s.targetY - s.curY) * 0.16;
tilt!.style.transform =
"rotateX(" + (TILT + s.curY).toFixed(3) + "deg) rotateY(" + s.curX.toFixed(3) + "deg)";
if (tiltXRef.current) tiltXRef.current.textContent = s.curX.toFixed(1);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
paint(0, 0.8);
root.classList.add(styles.isStill);
} else {
const start = performance.now();
const step = (now: number): void => {
paint(now, (now - start) / 1000);
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(rafRef.current);
field.removeEventListener("pointermove", onMove);
field.removeEventListener("pointerleave", onLeave);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.pt} ref={rootRef} data-duration={duration} data-easing={easing} data-color={color}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} ref={fieldRef} role="img" aria-label={ARIA_FIELD} tabIndex={0}>
<div className={styles.tilt} ref={tiltRef}>
<div className={styles.grid3}>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className={styles.block}></div>
))}
</div>
</div>
</div>
<p className={styles.meta}>
<span className={styles.metaLabel}>{LABEL_TILT}</span>
<b className={styles.tiltVal} ref={tiltXRef}>0.0</b>
</p>
</div>
</main>
);
}05 · ポインター視差 — ジオラマ全体がわずかに追従する計算 — 計測 累積面積 1.605% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
clampで抑えてlerpで滑らかに
目標角を即座に適用しないのが滑らかさの源です。1.1秒入力がなければ自動走査に切り替わります。
var IDLE_MS = 1100;
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);
}
人が離れても計算は止まらない
自動走査はsinとcosの経路で、キャプチャでも静止画にならずに済みます。累積面積1.605%・面積最大0.517%・強度平均67.0・動いたフレーム23/23です。
06分解図 — 深度順をそのまま展開距離に使うホバー
04の深度順の考え方を層インデックスiに置き換え、目標translateZをGAP × (LAYERS.length - i)で1度だけ計算して--tz-openに保存します。層はroof・attic・room・floorの4層で、ホバー時に上の層ほど遠くへ押し出される分解図になります。

<main class="stage">
<div class="ev" id="ev">
<p class="ev__hint">ホバーすると層ごとにtranslateZ(--tz)の間隔が開きます — 層の数だけ同じ間隔を掛けた値です</p>
<div class="ev__field" role="group" tabindex="0" aria-label="ホバーすると4つの層が上に開く分解図、離すと再び重なる場面" id="field">
<div class="ev__world" id="world"></div>
</div>
<p class="ev__meta">層間隔 <b id="gap">0</b>px</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 분해도 호버(06) — 무대는 노랑. 네 층을 겹쳐 두고 --tz-open 값(층마다 GAP 배수, 한 번만
// 계산)까지 translateZ 로 밀어 올린다. 실제 사용자는 :hover·:focus-visible 로, 캡처는
// is-open 클래스 토글로 같은 transition 을 재생한다.
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.ev {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; max-width: 420px;
}
.ev__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.35;
color: $subject-ink; text-align: center;
}
.ev__field {
position: relative;
width: clamp(160px, 38vw, 200px); height: clamp(150px, 36vw, 180px);
display: grid; place-items: center;
border-radius: $r-sm;
outline: none;
}
.ev__field:focus-visible { box-shadow: 0 0 0 2px rgba($subject-ink, .5); }
.ev__world {
position: relative;
transform-style: preserve-3d;
transform: rotateX(-35.264deg) rotateZ(45deg);
width: 1px; height: 1px;
}
.ev__layer {
position: absolute; left: 0; top: 0;
width: var(--w); height: var(--w);
transform: translate3d(-50%, -50%, 0);
transition: transform $duration $easing;
border: 1px solid rgba($subject-ink, .3);
border-radius: $r-xs;
box-shadow: $shadow-press;
}
.ev__layer--roof { background: $subject-orange-deep; }
.ev__layer--attic { background: $subject-cream; }
.ev__layer--room { background: $subject-sky; }
.ev__layer--floor { background: $subject-blue; }
.ev__field:hover .ev__layer,
.ev__field:focus-visible .ev__layer,
.ev__field.is-open .ev__layer {
transform: translate3d(-50%, -50%, var(--tz-open));
}
.ev__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .78); }
.ev__meta b { font-weight: 800; color: $subject-orange-deep; font-variant-numeric: tabular-nums; }
@media (max-width: 340px) {
.ev { gap: $sp-1; }
.ev__field { width: 140px; height: 128px; }
.ev__hint { font-size: 8px; }
.ev__meta { font-size: 7px; }
}
// reduce — 열고 닫는 transition 이 멎는다. 겹친(닫힌) 정지 상태만 남긴다.
@media (prefers-reduced-motion: reduce) {
.ev.is-still .ev__layer { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ev {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.ev__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.35;
color: #17141a;
text-align: center;
}
.ev__field {
position: relative;
width: clamp(160px, 38vw, 200px);
height: clamp(150px, 36vw, 180px);
display: grid;
place-items: center;
border-radius: 8px;
outline: none;
}
.ev__field:focus-visible {
box-shadow: 0 0 0 2px rgba(23, 20, 26, 0.5);
}
.ev__world {
position: relative;
transform-style: preserve-3d;
transform: rotateX(-35.264deg) rotateZ(45deg);
width: 1px;
height: 1px;
}
.ev__layer {
position: absolute;
left: 0;
top: 0;
width: var(--w);
height: var(--w);
transform: translate3d(-50%, -50%, 0);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 4px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ev__layer--roof {
background: #c93000;
}
.ev__layer--attic {
background: #fff7e6;
}
.ev__layer--room {
background: #6ec8ff;
}
.ev__layer--floor {
background: #2f6df6;
}
.ev__field:hover .ev__layer,
.ev__field:focus-visible .ev__layer,
.ev__field.is-open .ev__layer {
transform: translate3d(-50%, -50%, var(--tz-open));
}
.ev__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.78);
}
.ev__meta b {
font-weight: 800;
color: #c93000;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.ev {
gap: 4px;
}
.ev__field {
width: 140px;
height: 128px;
}
.ev__hint {
font-size: 8px;
}
.ev__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.ev.is-still .ev__layer {
transition: none;
}
}// 분해도 호버(06) — 층 i 의 목표 translateZ 는 i * GAP 로 한 번만 정해 둔다. 실제 사용자는
// :hover/:focus-visible 로 켠다(진짜 transition). 캡처(headless)에는 포인터가 없으므로
// setInterval 로 같은 클래스(is-open)를 2초 주기로 여닫아 같은 transition 을 재생시킨다 —
// 페이지 로드 150ms 시점엔 아직 켜지지 않아 애니메이션 0개로 시작하고, 실시간으로 진행된다.
(function () {
var root = document.getElementById('ev');
var field = document.getElementById('field');
var world = document.getElementById('world');
var gapEl = document.getElementById('gap');
if (!root || !field || !world || !gapEl) return;
var mm = window.matchMedia('(max-width: 340px)').matches;
var GAP = mm ? 12 : 18;
gapEl.textContent = String(GAP);
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);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.add('is-still');
return;
}
var open = false;
function toggle() {
open = !open;
field.classList.toggle('is-open', open);
}
setInterval(toggle, 1000); // 1초마다 여닫기 — 2초 캡처 창에 정확히 한 번의 열림+닫힘
})();// IsometricExplodedViewHover — vanilla 판(run/333/demo/06-isometric-exploded-view-hover/index.html)
// 의 React 포트. 층마다 --tz-open = GAP * (층 수 - i) 를 한 번만 계산해 고정하고,
// 실제 hover/focus-visible 로 열리게 두되, 캡처용으로 1초 간격 setInterval 토글을 더한다.
import { useEffect, useRef } from "react";
import styles from "./IsometricExplodedViewHover.module.scss";
export type IsometricExplodedViewHoverProps = {
duration?: string;
easing?: string;
color?: string;
};
type Box<T> = { current: T };
const HINT = "올리면 층마다 translateZ(--tz) 간격이 벌어집니다 — 층 수만큼 같은 간격을 곱한 값입니다";
const ARIA_FIELD = "올리면 네 개 층이 위로 벌어지는 분해도, 떼면 다시 겹치는 장면";
const LABEL_GAP = "층 간격 ";
const LAYERS = [
{ key: "roof", width: 96, cls: styles.layerRoof },
{ key: "attic", width: 84, cls: styles.layerAttic },
{ key: "room", width: 84, cls: styles.layerRoom },
{ key: "floor", width: 96, cls: styles.layerFloor }
];
export default function IsometricExplodedViewHover({
duration = "600ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#c93000"
}: IsometricExplodedViewHoverProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const gapRef = useRef<HTMLElement | null>(null);
const timerRef = useRef(0) as Box<number>;
useEffect(() => {
const field = fieldRef.current;
const root = rootRef.current;
if (!field || !root) return;
const mm = window.matchMedia("(max-width: 340px)").matches;
const GAP = mm ? 12 : 18;
if (gapRef.current) gapRef.current.textContent = String(GAP);
const layerEls = Array.from(field.querySelectorAll<HTMLDivElement>("[data-layer]"));
layerEls.forEach((el, i) => {
const tz = GAP * (LAYERS.length - i);
el.style.setProperty("--tz-open", tz + "px");
});
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
root.classList.add(styles.isStill);
return;
}
function toggle(): void {
if (!field) return;
field.classList.toggle(styles.isOpen);
}
timerRef.current = window.setInterval(toggle, 1000);
return () => window.clearInterval(timerRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.ev} ref={rootRef} data-duration={duration} data-easing={easing} data-color={color}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} ref={fieldRef} role="img" aria-label={ARIA_FIELD} tabIndex={0}>
{LAYERS.map((l) => (
<div
key={l.key}
data-layer={l.key}
className={styles.layer + " " + l.cls}
style={{ width: l.width + "px" }}
></div>
))}
</div>
<p className={styles.meta}>
<span className={styles.metaLabel}>{LABEL_GAP}</span>
<b className={styles.gapVal} ref={gapRef}>18</b>px
</p>
</div>
</main>
);
}06 · 分解図 — 深度順をそのまま展開距離に使うホバー — 計測 累積面積 11.103% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
層番号に間隔を掛ける
GAPはデスクトップ18px・モバイル12pxです。戻る際も同じ層番号が順序を保証するため、レイヤー同士が飛び越えて重なりません。
var GAP = mm ? 12 : 18;
gapEl.textContent = String(GAP);
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);
}
展開と静止の区間
累積面積11.103%・面積最大7.998%・強度平均99.1で、動いたフレームは12/23です。ホバーしていない区間は閉じた積み上げを保つため、フレームに静止区間があります。
07部屋のジオラマ — 01〜04を組み立てた完成シーン
床は最初からその場に動かずに置かれ、左壁・右壁・屋根だけが同じtransition関数を--d(遅延)だけ変えて順番に立ち上がります。遅延は0ms・140ms・320msの3つで、組み立て順はこの3つの数字だけで決まり、3つの別々のアニメーションは存在しません。

<main class="stage">
<div class="rb" id="rb">
<p class="rb__hint">床 → 2つの壁 → 屋根の順に、--d(遅延)の値だけ変えた同じtransitionが順番に実行されます</p>
<div class="rb__field" role="group" tabindex="0" aria-label="床の上に2つの壁が立ち、屋根が閉じて部屋が組み立てられる場面" id="field">
<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>
</div>
<p class="rb__meta">組み立て段階 <b id="step">0</b>/3</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 방 조립(07) — 무대는 주황. 세계 좌표계(matrix3d) 는 고정, 바닥은 그대로 두고 두 벽은
// scaleY(바닥 기준 transform-origin: bottom) 로 눕힌 상태에서 서는 상태로, 지붕은
// translateZ 로 위에서 내려앉는다. 셋 다 같은 transition 이고 --d(지연) 만 다르다.
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.rb {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; max-width: 420px;
}
.rb__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.35;
color: $subject-ink; text-align: center;
}
.rb__field {
position: relative;
width: clamp(180px, 42vw, 220px); height: clamp(150px, 36vw, 178px);
display: grid; place-items: center;
border-radius: $r-sm;
outline: none;
}
.rb__field:focus-visible { box-shadow: 0 0 0 2px rgba($subject-ink, .5); }
.rb__world { position: relative; transform-style: preserve-3d; width: 1px; height: 1px; }
.rb__floor {
position: absolute; left: 0; top: 0;
width: 84px; height: 84px;
transform: translate3d(-50%, -50%, 0);
background: $subject-blue;
border: 1px solid rgba($subject-ink, .3);
border-radius: $r-xs;
box-shadow: $shadow-press;
}
.rb__wall {
position: absolute; left: 0; top: 0;
width: 56px; height: 46px;
transform-origin: bottom center;
transition: transform $duration $easing;
transition-delay: var(--d, 0ms);
border: 1px solid rgba($subject-ink, .3);
border-radius: $r-xs;
}
.rb__wall--left {
background: $subject-sky;
transform: translate3d(-46px, -10px, 0) scaleY(.06);
}
.rb__wall--right {
background: $subject-mint;
transform: translate3d(2px, -10px, 0) scaleY(.06);
}
.rb__field.is-open .rb__wall--left { transform: translate3d(-46px, -10px, 0) scaleY(1); }
.rb__field.is-open .rb__wall--right { transform: translate3d(2px, -10px, 0) scaleY(1); }
.rb__roof {
position: absolute; left: 0; top: 0;
width: 92px; height: 92px;
transform: translate3d(-50%, -50%, 120px);
transition: transform $duration $easing;
transition-delay: var(--d, 0ms);
background: $subject-orange-deep;
border: 1px solid rgba($subject-ink, .3);
border-radius: $r-xs;
box-shadow: $shadow-raised;
}
.rb__field.is-open .rb__roof { transform: translate3d(-50%, -50%, 58px); }
.rb__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .82); }
.rb__meta b { font-weight: 800; color: $subject-ink; font-variant-numeric: tabular-nums; }
@media (max-width: 340px) {
.rb { gap: $sp-1; }
.rb__field { width: 156px; height: 124px; }
.rb__hint { font-size: 8px; }
.rb__meta { font-size: 7px; }
}
// reduce — 조립 transition 이 멎는다. 접힌(해체) 정지 상태만 남긴다.
@media (prefers-reduced-motion: reduce) {
.rb.is-still .rb__wall,
.rb.is-still .rb__roof { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rb {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.rb__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.35;
color: #17141a;
text-align: center;
}
.rb__field {
position: relative;
width: clamp(180px, 42vw, 220px);
height: clamp(150px, 36vw, 178px);
display: grid;
place-items: center;
border-radius: 8px;
outline: none;
}
.rb__field:focus-visible {
box-shadow: 0 0 0 2px rgba(23, 20, 26, 0.5);
}
.rb__world {
position: relative;
transform-style: preserve-3d;
width: 1px;
height: 1px;
}
.rb__floor {
position: absolute;
left: 0;
top: 0;
width: 84px;
height: 84px;
transform: translate3d(-50%, -50%, 0);
background: #2f6df6;
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 4px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.rb__wall {
position: absolute;
left: 0;
top: 0;
width: 56px;
height: 46px;
transform-origin: bottom center;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: var(--d, 0ms);
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 4px;
}
.rb__wall--left {
background: #6ec8ff;
transform: translate3d(-46px, -10px, 0) scaleY(0.06);
}
.rb__wall--right {
background: #4cd4a6;
transform: translate3d(2px, -10px, 0) scaleY(0.06);
}
.rb__field.is-open .rb__wall--left {
transform: translate3d(-46px, -10px, 0) scaleY(1);
}
.rb__field.is-open .rb__wall--right {
transform: translate3d(2px, -10px, 0) scaleY(1);
}
.rb__roof {
position: absolute;
left: 0;
top: 0;
width: 92px;
height: 92px;
transform: translate3d(-50%, -50%, 120px);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: var(--d, 0ms);
background: #c93000;
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 4px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.rb__field.is-open .rb__roof {
transform: translate3d(-50%, -50%, 58px);
}
.rb__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.82);
}
.rb__meta b {
font-weight: 800;
color: #17141a;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.rb {
gap: 4px;
}
.rb__field {
width: 156px;
height: 124px;
}
.rb__hint {
font-size: 8px;
}
.rb__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.rb.is-still .rb__wall,
.rb.is-still .rb__roof {
transition: none;
}
}// 방 조립(07) — 세계 좌표계 자체는 matrix3d(고정 등각 행렬, JS 로 한 번 계산)로 눕혀 두고,
// 그 안의 바닥·벽·지붕은 각자 --d(지연) 만 다르게 받은 같은 transition 으로 차례로 선다.
// 계산은 지연값 배정 한 번뿐 — 실행은 CSS 가 한다. 캡처는 06 과 같은 방식으로 1초마다
// is-open 을 토글해 2초 창에 조립 1회를 재생한다.
(function () {
var root = document.getElementById('rb');
var field = document.getElementById('field');
var world = document.getElementById('world');
var stepEl = document.getElementById('step');
if (!root || !field || !world || !stepEl) return;
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;
}
function mat4ToCss(m) {
var s = '';
for (var i = 0; i < m.length; i++) s += (i ? ',' : '') + m[i].toFixed(6);
return 'matrix3d(' + s + ')';
}
// 세계 좌표계는 한 번만 계산해 고정한다 — 조립 애니메이션과는 무관한 값
world.style.transform = mat4ToCss(mat4Mul(mat4RotateX(-35.264), mat4RotateZ(45)));
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.add('is-still');
stepEl.textContent = '3';
return;
}
var open = false;
function toggle() {
open = !open;
field.classList.toggle('is-open', open);
stepEl.textContent = open ? '3' : '0';
}
setInterval(toggle, 1000); // 1초마다 조립/해체 — 2초 캡처 창에 한 번의 조립을 담는다
})();// IsometricRoomSceneBuild — vanilla 판(run/333/demo/07-isometric-room-scene-build/index.html)
// 의 React 포트. 등각 세계 행렬은 한 번만 계산해 고정하고, 바닥 → 두 벽 → 지붕 순서로
// --d(지연)만 다르게 준 같은 transition 을 setInterval 토글로 반복 재생한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./IsometricRoomSceneBuild.module.scss";
export type IsometricRoomSceneBuildProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--d"?: string };
type Box<T> = { current: T };
const HINT = "바닥 → 두 벽 → 지붕 순서로 --d(지연) 값만 다르게 준 같은 transition 이 차례로 실행됩니다";
const ARIA_FIELD = "바닥 위에 두 벽이 서고 지붕이 닫히며 방이 조립되는 장면";
const LABEL_STEP = "조립 단계 ";
const DELAYS = { left: 0, right: 140, roof: 320 };
export default function IsometricRoomSceneBuild({
duration = "600ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6"
}: IsometricRoomSceneBuildProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const stepRef = useRef<HTMLElement | null>(null);
const timerRef = useRef(0) as Box<number>;
useEffect(() => {
const field = fieldRef.current;
const root = rootRef.current;
if (!field || !root) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
field.classList.add(styles.isOpen);
if (stepRef.current) stepRef.current.textContent = "3";
root.classList.add(styles.isStill);
return;
}
function toggle(): void {
if (!field) return;
const open = field.classList.toggle(styles.isOpen);
if (stepRef.current) stepRef.current.textContent = open ? "3" : "0";
}
timerRef.current = window.setInterval(toggle, 1400);
return () => window.clearInterval(timerRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.rb} ref={rootRef} data-duration={duration} data-easing={easing} data-color={color}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} ref={fieldRef} role="img" aria-label={ARIA_FIELD} tabIndex={0}>
<div className={styles.floor}></div>
<div className={styles.wallLeft} style={{ "--d": DELAYS.left + "ms" } as Vars}></div>
<div className={styles.wallRight} style={{ "--d": DELAYS.right + "ms" } as Vars}></div>
<div className={styles.roof} style={{ "--d": DELAYS.roof + "ms" } as Vars}></div>
</div>
<p className={styles.meta}>
<span className={styles.metaLabel}>{LABEL_STEP}</span>
<b className={styles.stepVal} ref={stepRef}>0</b>
<span className={styles.metaUnit}>/3</span>
</p>
</div>
</main>
);
}07 · 部屋のジオラマ — 01〜04を組み立てた完成シーン — 計測 累積面積 9.435% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
同じtransitionに3つの遅延
壁はscaleY(0.06)からscaleY(1)へ開きます。ワールド座標系はmat4ToCss(mat4Mul(mat4RotateX(-35.264), mat4RotateZ(45)))で一度だけ計算して固定する、01と同じ行列です。
.rb__wall {
position: absolute;
left: 0;
top: 0;
width: 56px;
height: 46px;
transform-origin: bottom center;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: var(--d, 0ms);
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 4px;
}
.rb__wall--left {
background: #6ec8ff;
transform: translate3d(-46px, -10px, 0) scaleY(0.06);
}
.rb__wall--right {
background: #4cd4a6;
transform: translate3d(2px, -10px, 0) scaleY(0.06);
}
完成した部屋の測定値
累積面積9.435%・面積最大6.259%・強度平均65.7で、動いたフレームは16/23です。1秒ごとの組み立て・分解サイクルで、2秒のキャプチャ窓に1回の組み立てが収まります。
08街ブロック — 建物の高さがそれぞれ違うシーン配置計算
3×3の9棟それぞれについて、建物の高さ(HMIN + rand() × HRANGE)・窓の数(2〜3個)・点滅の開始位相を、シード固定のmulberry32乱数で決めます。座標とz-indexは02・04と同じ式で、Math.random()は一度も呼ばれません。

<main class="stage">
<div class="cb" id="cb">
<p class="cb__hint">建物の高さはシード42の乱数で、窓の点滅遅延も同じシード乱数で決め、毎回同じ都市になります</p>
<div class="cb__field" role="img" aria-label="高さの違う9棟の建物が並び、窓がそれぞれ違うリズムで点滅する都市ブロックの場面">
<div class="cb__world" id="world"></div>
</div>
<p class="cb__meta">シード <b id="seed">42</b> · 建物 <b id="n">9</b>棟</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 도시 블록 씬(08) — 무대는 페이퍼. 건물 높이(--h)·창문 개수·깜빡임 지연(--delay) 은
// mulberry32(시드 42) 로 한 번만 뽑아 고정한다. 창문 깜빡임만 진짜 @keyframes 무한
// 애니메이션으로 계속 돈다 — 나머지(위치·높이)는 정적 값이다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.cb {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; max-width: 420px;
}
.cb__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.35;
color: $subject-ink; text-align: center;
}
.cb__field {
position: relative;
width: clamp(180px, 42vw, 220px); height: clamp(140px, 34vw, 168px);
display: grid; place-items: center;
}
.cb__world { position: relative; transform-style: preserve-3d; width: 1px; height: 1px; }
.cb__bld {
position: absolute; left: 0; top: 0;
width: 26px; height: var(--h);
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, 0) translateY(calc(var(--h) * -1));
background: $subject-blue;
border: 1px solid rgba($subject-ink, .3);
border-radius: $r-xs $r-xs 0 0;
box-shadow: $shadow-press;
}
.cb__roof {
position: absolute; left: 50%; top: 0;
width: 26px; height: 10px;
transform: translate3d(-50%, -70%, 0);
background: $subject-orange-deep;
border-radius: $r-xs;
}
.cb__win {
position: absolute; left: 50%; width: 9px; height: 9px;
transform: translateX(-50%);
background: $subject-ink;
border-radius: $r-xs;
animation: cbTwinkle 3s ease-in-out infinite;
animation-delay: var(--delay, 0s);
}
@keyframes cbTwinkle {
0%, 100% { background: $subject-ink; box-shadow: none; }
50% { background: $stage-yellow; box-shadow: 0 0 9px rgba($stage-yellow, .9); }
}
.cb__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .78); }
.cb__meta b { font-weight: 800; color: $subject-blue; font-variant-numeric: tabular-nums; }
@media (max-width: 340px) {
.cb { gap: $sp-1; }
.cb__field { width: 156px; height: 112px; }
.cb__hint { font-size: 8px; }
.cb__meta { font-size: 7px; }
}
// reduce — 창문 깜빡임이 멎는다. 중간 밝기(.6)로 고정된 정지 도시만 남긴다.
// (건물 배치는 JS 가 한 번만 그리는 정적 값이라 여기엔 자바스크립트 분기가 필요 없다)
@media (prefers-reduced-motion: reduce) {
.cb__win { animation: none; background: $stage-yellow; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.cb__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.35;
color: #17141a;
text-align: center;
}
.cb__field {
position: relative;
width: clamp(180px, 42vw, 220px);
height: clamp(140px, 34vw, 168px);
display: grid;
place-items: center;
}
.cb__world {
position: relative;
transform-style: preserve-3d;
width: 1px;
height: 1px;
}
.cb__bld {
position: absolute;
left: 0;
top: 0;
width: 26px;
height: var(--h);
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, 0) translateY(calc(var(--h) * -1));
background: #2f6df6;
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 4px 4px 0 0;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cb__roof {
position: absolute;
left: 50%;
top: 0;
width: 26px;
height: 10px;
transform: translate3d(-50%, -70%, 0);
background: #c93000;
border-radius: 4px;
}
.cb__win {
position: absolute;
left: 50%;
width: 9px;
height: 9px;
transform: translateX(-50%);
background: #17141a;
border-radius: 4px;
animation: cbTwinkle 3s ease-in-out infinite;
animation-delay: var(--delay, 0s);
}
@keyframes cbTwinkle {
0%, 100% {
background: #17141a;
box-shadow: none;
}
50% {
background: #ffd23f;
box-shadow: 0 0 9px rgba(255, 210, 63, 0.9);
}
}
.cb__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.78);
}
.cb__meta b {
font-weight: 800;
color: #2f6df6;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.cb {
gap: 4px;
}
.cb__field {
width: 156px;
height: 112px;
}
.cb__hint {
font-size: 8px;
}
.cb__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb__win {
animation: none;
background: #ffd23f;
}
}// 도시 블록 씬(08) — mulberry32(シード 고정) 로 건물 높이와 창문 깜빡임 지연을 뽑는다.
// 같은 시드는 항상 같은 도시를 만든다(재현성). 창문 깜빡임은 진짜 @keyframes 무한
// 애니메이션이라 렌더가 결정론 모드(Animation.currentTime 스크럽)로 잡아 정확히 찍는다.
(function () {
var root = document.getElementById('cb');
var world = document.getElementById('world');
var nEl = document.getElementById('n');
if (!root || !world || !nEl) return;
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;
};
}
var SEED = 42;
var rand = mulberry32(SEED);
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;
}
function mat4ToCss(m) {
var s = '';
for (var i = 0; i < m.length; i++) s += (i ? ',' : '') + m[i].toFixed(6);
return 'matrix3d(' + s + ')';
}
world.style.transform = mat4ToCss(mat4Mul(mat4RotateX(-35.264), mat4RotateZ(45)));
var mm = window.matchMedia('(max-width: 340px)').matches;
var N = 3;
var TILE_W = mm ? 30 : 38, TILE_H = mm ? 15 : 19;
var HMIN = mm ? 24 : 34, HRANGE = mm ? 30 : 44;
nEl.textContent = String(N * N);
document.getElementById('seed').textContent = String(SEED);
for (var row = 0; row < N; row++) {
for (var col = 0; col < N; col++) {
var h = HMIN + rand() * HRANGE; // 건물 높이 — シード 난수
var x = (col - row) * TILE_W / 2;
var y = (col + row) * TILE_H / 2;
var bld = document.createElement('div');
bld.className = 'cb__bld';
bld.style.setProperty('--tx', x.toFixed(2) + 'px');
bld.style.setProperty('--ty', y.toFixed(2) + 'px');
bld.style.setProperty('--h', h.toFixed(1) + 'px');
bld.style.zIndex = String(Math.round((col + row) * 10));
world.appendChild(bld);
var roof = document.createElement('div');
roof.className = 'cb__roof';
bld.appendChild(roof);
var winCount = 2 + Math.floor(rand() * 2); // 2~3개 창문 — シード 난수
for (var w = 0; w < winCount; w++) {
var win = document.createElement('div');
win.className = 'cb__win';
var delay = -(rand() * 3).toFixed(2); // 시작 위상도 シード 난수 — 창마다 다르게
win.style.setProperty('--delay', delay + 's');
win.style.bottom = (10 + w * 13) + 'px';
bld.appendChild(win);
}
}
}
})();// IsometricCityBlockScene — vanilla 판(run/333/demo/08-isometric-city-block-scene/index.html)
// 의 React 포트. 건물 높이·창문 개수·깜빡임 지연은 mulberry32(시드 42) 로 한 번만 뽑아
// 고정한다 — 배치는 정적, 창문 깜빡임만 진짜 @keyframes 무한 애니메이션으로 계속 돈다.
import { useEffect, useRef } from "react";
import styles from "./IsometricCityBlockScene.module.scss";
export type IsometricCityBlockSceneProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "건물 높이는 시드 42 난수로, 창문 깜빡임 지연도 같은 시드 난수로 정해 매번 같은 도시가 나옵니다";
const ARIA_FIELD = "높이가 저마다 다른 건물 아홉 채가 늘어서고 창문이 각자 다른 박자로 깜빡이는 도시 블록 장면";
const LABEL_SEED = "시드 ";
const LABEL_BUILDINGS = " · 건물 ";
const LABEL_UNIT = "채";
const SEED = 42;
const N = 3;
const TILE_W = 40, TILE_H = 20;
function mulberry32(seed: number): () => number {
let a = seed;
return function (): number {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
type Building = { x: number; y: number; h: number; windows: number[] };
export default function IsometricCityBlockScene({
duration = "3s",
easing = "ease-in-out",
color = "#2f6df6"
}: IsometricCityBlockSceneProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const worldRef = useRef<HTMLDivElement | null>(null);
const countRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const world = worldRef.current;
const root = rootRef.current;
if (!world || !root) return;
const rand = mulberry32(SEED);
const buildings: Building[] = [];
let minX = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let row = 0; row < N; row++) {
for (let col = 0; col < N; col++) {
const x = ((col - row) * TILE_W) / 2;
const y = ((col + row) * TILE_H) / 2;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
const h = 40 + Math.round(rand() * 60);
const winCount = 2 + Math.round(rand() * 3);
const windows: number[] = [];
for (let i = 0; i < winCount; i++) windows.push(rand() * 3);
buildings.push({ x, y, h, windows });
}
}
const shiftX = -(minX + maxX) / 2;
const shiftY = -maxY / 2;
while (world.firstChild) world.removeChild(world.firstChild);
for (const b of buildings) {
const bld = document.createElement("div");
bld.className = styles.bld;
bld.style.setProperty("--tx", (b.x + shiftX).toFixed(2) + "px");
bld.style.setProperty("--ty", (b.y + shiftY).toFixed(2) + "px");
bld.style.setProperty("--h", b.h + "px");
const roof = document.createElement("div");
roof.className = styles.roof;
bld.appendChild(roof);
const winRows = Math.max(1, Math.floor(b.h / 22));
let wi = 0;
for (let r = 0; r < winRows && wi < b.windows.length; r++) {
const win = document.createElement("div");
win.className = styles.win;
win.style.top = 8 + r * 16 + "px";
win.style.setProperty("--delay", b.windows[wi].toFixed(2) + "s");
bld.appendChild(win);
wi++;
}
world.appendChild(bld);
}
if (countRef.current) countRef.current.textContent = String(buildings.length);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.add(styles.isStill);
return () => {
while (world.firstChild) world.removeChild(world.firstChild);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.cb} ref={rootRef} data-duration={duration} data-easing={easing} data-color={color}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} role="img" aria-label={ARIA_FIELD}>
<div className={styles.world} ref={worldRef}></div>
</div>
<p className={styles.meta}>
<span>{LABEL_SEED}</span>
<b>{SEED}</b>
<span>{LABEL_BUILDINGS}</span>
<b ref={countRef}>9</b>
<span>{LABEL_UNIT}</span>
</p>
</div>
</main>
);
}08 · 街ブロック — 建物の高さがそれぞれ違うシーン配置計算 — 計測 累積面積 1.497% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
Math.randomの代わりにシード
mulberry32(42)は実行のたびに同じ数列を返すため、スクリーンショット・gifキャプチャ・再読み込みがすべて同じ街並みになります。
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;
};
}
var SEED = 42;
窓の点滅も同じシードから
窓は--delayに-3〜0秒の位相を受け取り、@keyframesの無限ループで点滅します。累積面積1.497%・面積最大0.291%・強度平均17.3(9種で最も暗い夜のトーン)・動いたフレーム23/23です。
09ヒーローシーン — 昼夜照明と視差が一緒に回る完成版
ポインターの縦位置をclamp(0, 1)した値をtargetTとし、curTを毎フレーム0.14ずつ補間します。この1つの0〜1の数が空の色([91,140,248]から[23,20,26]へ)・窓の不透明度(0.15 + curT × 0.85)・太陽と月の軌道を同時に動かし、curTが0.5を超えるとis-nightクラスが切り替わります。

<main class="stage">
<div class="dn" id="dn">
<p class="dn__hint">ポインターの縦位置が昼夜の進行度(t)を決めます — 空と窓はtで毎フレーム補間、太陽→月はtransitionで切り替わります</p>
<div class="dn__field" role="application" tabindex="0" aria-label="ポインターを上下に動かすと等角の街が昼から夜に変わる場面" id="field">
<div class="dn__sky" id="sky">
<div class="dn__orb dn__orb--sun" id="sun"></div>
<div class="dn__orb dn__orb--moon" id="moon"></div>
</div>
<div class="dn__world" id="world"></div>
</div>
<p class="dn__meta">夜の進行度 <b id="pct">0</b>%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 낮밤 히어로(09) — 무대는 먹. 하늘색·창문 밝기는 JS 가 낮밤 진행도(t)로 매 프레임 직접
// 보간해 칠한다(transition 없이 매끄럽다). 해→달 교체만 t=0.5 문턱에서 is-night 클래스로
// 진짜 opacity transition 크로스페이드를 튼다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-light;
@include stage($stage-ink);
.dn {
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
width: 100%; max-width: 420px;
}
.dn__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.35;
color: $subject-cream; text-align: center;
}
.dn__field {
position: relative;
width: clamp(180px, 42vw, 220px); height: clamp(140px, 34vw, 168px);
display: grid; place-items: center;
border-radius: $r-sm;
overflow: hidden;
outline: none;
}
.dn__field:focus-visible { box-shadow: inset 0 0 0 2px rgba($subject-cream, .6); }
.dn__sky {
position: absolute; inset: 0;
background-color: $subject-blue-light;
border-radius: $r-sm;
}
.dn__orb {
position: absolute; left: 50%; top: 50%;
width: 16px; height: 16px; border-radius: $r-pill;
margin-left: -8px; margin-top: -8px;
transition: opacity $duration $easing;
}
.dn__orb--sun { background: $stage-yellow; box-shadow: 0 0 10px rgba($stage-yellow, .8); opacity: 1; }
.dn__orb--moon { background: $subject-cream; box-shadow: 0 0 8px rgba($subject-cream, .7); opacity: 0; }
.dn__field.is-night .dn__orb--sun { opacity: 0; }
.dn__field.is-night .dn__orb--moon { opacity: 1; }
.dn__world { position: relative; transform-style: preserve-3d; width: 1px; height: 1px; }
.dn__bld {
position: absolute; left: 0; top: 0;
width: 34px; height: 34px;
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, -50%);
background: $subject-ink;
border: 1px solid rgba($subject-cream, .2);
border-radius: $r-xs;
box-shadow: $shadow-press;
}
.dn__win {
position: absolute; left: 50%; top: 50%;
width: 6px; height: 6px;
transform: translate(-50%, -50%);
background: $stage-yellow;
border-radius: $r-xs;
}
.dn__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-cream, .82); }
.dn__meta b { font-weight: 800; color: $stage-yellow; font-variant-numeric: tabular-nums; }
@media (max-width: 340px) {
.dn { gap: $sp-1; }
.dn__field { width: 156px; height: 112px; }
.dn__hint { font-size: 8px; }
.dn__meta { font-size: 7px; }
}
// reduce — 포인터 추적·자동 순환이 멎는다. 0.6(밤 쪽) 진행도로 고정된 정지 장면만 남긴다.
@media (prefers-reduced-motion: reduce) {
.dn.is-still .dn__orb { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
max-width: 420px;
}
.dn__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.35;
color: #fff7e6;
text-align: center;
}
.dn__field {
position: relative;
width: clamp(180px, 42vw, 220px);
height: clamp(140px, 34vw, 168px);
display: grid;
place-items: center;
border-radius: 8px;
overflow: hidden;
outline: none;
}
.dn__field:focus-visible {
box-shadow: inset 0 0 0 2px rgba(255, 247, 230, 0.6);
}
.dn__sky {
position: absolute;
inset: 0;
background-color: #5b8cf8;
border-radius: 8px;
}
.dn__orb {
position: absolute;
left: 50%;
top: 50%;
width: 16px;
height: 16px;
border-radius: 999px;
margin-left: -8px;
margin-top: -8px;
transition: opacity 300ms ease-out;
}
.dn__orb--sun {
background: #ffd23f;
box-shadow: 0 0 10px rgba(255, 210, 63, 0.8);
opacity: 1;
}
.dn__orb--moon {
background: #fff7e6;
box-shadow: 0 0 8px rgba(255, 247, 230, 0.7);
opacity: 0;
}
.dn__field.is-night .dn__orb--sun {
opacity: 0;
}
.dn__field.is-night .dn__orb--moon {
opacity: 1;
}
.dn__world {
position: relative;
transform-style: preserve-3d;
width: 1px;
height: 1px;
}
.dn__bld {
position: absolute;
left: 0;
top: 0;
width: 34px;
height: 34px;
transform: translate3d(var(--tx), var(--ty), 0) translate(-50%, -50%);
background: #17141a;
border: 1px solid rgba(255, 247, 230, 0.2);
border-radius: 4px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.dn__win {
position: absolute;
left: 50%;
top: 50%;
width: 6px;
height: 6px;
transform: translate(-50%, -50%);
background: #ffd23f;
border-radius: 4px;
}
.dn__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.dn__meta b {
font-weight: 800;
color: #ffd23f;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.dn {
gap: 4px;
}
.dn__field {
width: 156px;
height: 112px;
}
.dn__hint {
font-size: 8px;
}
.dn__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.dn.is-still .dn__orb {
transition: none;
}
}// 낮밤 히어로(09) — 포인터 세로 위치 → 낮밤 진행도 t(0~1). 하늘색·창문 밝기는 t 로
// 매 프레임 직접 보간(JS lerp, transition 없이 매끄럽다). 해↔달 교체만 t=0.5 문턱에서
// 클래스를 토글해 진짜 CSS transition(opacity)으로 크로스페이드한다. 입력이 없으면
// requestAnimationFrame 루프가 자체 경로로 낮밤을 순환시킨다(자동 시연).
(function () {
var root = document.getElementById('dn');
var field = document.getElementById('field');
var sky = document.getElementById('sky');
var world = document.getElementById('world');
var sun = document.getElementById('sun');
var moon = document.getElementById('moon');
var pctEl = document.getElementById('pct');
if (!root || !field || !sky || !world || !sun || !moon || !pctEl) return;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, t) { return a + (b - a) * t; }
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;
}
function mat4ToCss(m) {
var s = '';
for (var i = 0; i < m.length; i++) s += (i ? ',' : '') + m[i].toFixed(6);
return 'matrix3d(' + s + ')';
}
world.style.transform = mat4ToCss(mat4Mul(mat4RotateX(-35.264), mat4RotateZ(45)));
var mm = window.matchMedia('(max-width: 340px)').matches;
var N = 2;
var TILE_W = mm ? 32 : 40, TILE_H = mm ? 16 : 20;
var wins = [];
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;
var bld = document.createElement('div');
bld.className = 'dn__bld';
bld.style.setProperty('--tx', x.toFixed(2) + 'px');
bld.style.setProperty('--ty', y.toFixed(2) + 'px');
bld.style.zIndex = String(Math.round((col + row) * 10));
var win = document.createElement('div');
win.className = 'dn__win';
bld.appendChild(win);
world.appendChild(bld);
wins.push(win);
}
}
// 낮 하늘색 → 밤 하늘색 (RGB 보간, day.length === night.length)
var DAY = [91, 140, 248]; // $subject-blue-light 근사
var NIGHT = [23, 20, 26]; // $stage-ink
var targetT = 0, curT = 0;
var lastInput = -Infinity;
var IDLE_MS = 1100;
field.addEventListener('pointermove', function (ev) {
var rect = field.getBoundingClientRect();
var ny = (ev.clientY - rect.top) / rect.height;
targetT = clamp(ny, 0, 1);
lastInput = performance.now();
});
field.addEventListener('pointerleave', function () { lastInput = -Infinity; });
var wasNight = false;
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)';
moon.style.transform = 'translate3d(' + ox.toFixed(1) + 'px,' + oy.toFixed(1) + 'px,0)';
pctEl.textContent = String(Math.round(curT * 100));
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
targetT = 0.6; curT = 0.6;
paint(0, 0.3);
root.classList.add('is-still');
} else {
var start = performance.now();
(function step(now) {
paint(now, (now - start) / 1000);
requestAnimationFrame(step);
})(start);
}
})();// IsometricHeroDayNight — vanilla 판(run/333/demo/09-isometric-hero-day-night/index.html)
// 의 React 포트. 포인터 세로 위치가 낮밤 진행도(t)를 정한다 — 하늘·창문 색은 t 로 매
// 프레임 RGB 보간하고, 해→달은 t>0.5 에서 클래스 토글로 한 프레임에 컷 교체한다.
import { useEffect, useRef } from "react";
import styles from "./IsometricHeroDayNight.module.scss";
export type IsometricHeroDayNightProps = {
duration?: string;
easing?: string;
color?: string;
};
type Box<T> = { current: T };
const HINT = "포인터 세로 위치가 낮밤 진행도(t)를 정합니다 — 하늘·창문은 t 로 매 프레임 보간, 해→달은 transition 으로 교체됩니다";
const ARIA_FIELD = "포인터를 위아래로 움직이면 등각 마을이 낮에서 밤으로 바뀌는 장면";
const LABEL_NIGHT = "밤 진행도 ";
const SKY_DAY = [110, 200, 255];
const SKY_NIGHT = [23, 20, 26];
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function mixRgb(a: number[], b: number[], t: number): string {
return (
"rgb(" +
Math.round(lerp(a[0], b[0], t)) +
"," +
Math.round(lerp(a[1], b[1], t)) +
"," +
Math.round(lerp(a[2], b[2], t)) +
")"
);
}
export default function IsometricHeroDayNight({
duration = "600ms",
easing = "ease-out",
color = "#5b8cf8"
}: IsometricHeroDayNightProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const skyRef = useRef<HTMLDivElement | null>(null);
const nightPctRef = useRef<HTMLElement | null>(null);
const stateRef = useRef({ targetT: 0.2, curT: 0.2 }) as Box<{ targetT: number; curT: number }>;
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const field = fieldRef.current;
const sky = skyRef.current;
const root = rootRef.current;
if (!field || !sky || !root) return;
function onMove(e: PointerEvent): void {
if (!field) return;
const rect = field.getBoundingClientRect();
const ny = (e.clientY - rect.top) / rect.height;
stateRef.current.targetT = ny < 0 ? 0 : ny > 1 ? 1 : ny;
}
field.addEventListener("pointermove", onMove);
function paint(): void {
const s = stateRef.current;
s.curT += (s.targetT - s.curT) * 0.12;
sky!.style.background = mixRgb(SKY_DAY, SKY_NIGHT, s.curT);
const isNight = s.curT > 0.5;
field!.classList.toggle(styles.isNight, isNight);
if (nightPctRef.current) nightPctRef.current.textContent = String(Math.round(s.curT * 100));
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
stateRef.current.curT = 0.5;
stateRef.current.targetT = 0.5;
paint();
root.classList.add(styles.isStill);
} else {
const step = (): void => {
paint();
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(rafRef.current);
field.removeEventListener("pointermove", onMove);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.hn} ref={rootRef} data-duration={duration} data-easing={easing} data-color={color}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} ref={fieldRef} role="img" aria-label={ARIA_FIELD} tabIndex={0}>
<div className={styles.sky} ref={skyRef}></div>
<div className={styles.sun}></div>
<div className={styles.moon}></div>
<div className={styles.town}>
<div className={styles.house}></div>
<div className={styles.house}></div>
<div className={styles.house}></div>
</div>
</div>
<p className={styles.meta}>
<span className={styles.metaLabel}>{LABEL_NIGHT}</span>
<b className={styles.nightVal} ref={nightPctRef}>20</b>%
</p>
</div>
</main>
);
}09 · ヒーローシーン — 昼夜照明と視差が一緒に回る完成版 — 計測 累積面積 21.418% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
1つの数が空と窓と軌道を動かす
天体の位置はox = (curT - 0.5) × 150、oy = -cos(curT × π) × 76の軌道です。1.1秒無入力なら(1 - 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)';
moon.style.transform = 'translate3d(' + ox.toFixed(1) + 'px,' + oy.toFixed(1) + 'px,0)';
pctEl.textContent = String(Math.round(curT * 100));
昼夜の境目は0.5
累積面積21.418%・面積最大21.219%・強度平均37.6で、9種で最も広い面積です。動いたフレームは23/23で、昼と夜の往復が最後まで続きます。
明度は光源角度だ — 面シェーディングと積み順の計算
9つのうち2つが残り全部を支えています。この記事で「3Dらしく」見えるものはすべて、明度の数か積み順の数のどちらかで、描かれた影や手で並べたレイヤー順は1つもありません。
面の明るさは毎フレーム再計算
03のshade(theta)と09の昼夜空はどちらも内積から明度を出す同じ考えを毎フレーム走らせているため、光源角度の変数を動かすと影響を受ける面が全部同時に変わります。1つの面だけ固定のhex色を書いていると、そこだけ取り残されます。
積み順も毎フレーム再計算
04のtravelerと06の展開層は、z-indexをプロパティではなく式の出力として扱います。移動ブロックは(col + row) × 10、展開層はGAP × (LAYERS.length - i)で、格子を横切ったりスタックを開いたりすると数字が変わり、DOMを手で並べ替える必要がありません。ポインター座標をclampとlerpで角度に変える立体シーンの9種や、停止角を数式で出し続ける回転盤の9種も、同じ「式を毎フレーム再計算する」作法で動く隣の実装です。
rotateX近似と固定z-indexは、どこで壊れるか
どの失敗も「等角に見えるが等角ではない画面」を作ります。rotateX(-30deg)を等角と呼ぶのは本当の角度を飛ばして3面の面積比率をわずかに歪ませることで、さらにその上にCSSのperspective()を重ねるのは悪化です。等角は平行投影なので、視点を混ぜると消失点が生まれて等角と衝突します。格子座標を式ではなく目分量のpxで固定すると、サイズ変更のたびに1つずつ測り直す作業に戻り、面ごとの明るさを固定のhex色で指定すると光源角度をいくら動かしても面間の明度関係が変わりません。移動ブロックのz-indexを作成時に1度だけ決めて毎フレーム再計算しないのは、等角で最も多いバグで、「手前」に読まれるべきブロックが誰も確認しないまま奥に留まります。05の視差にclampを設けないと画面端で回転角が過大になり場面がにじみ、08の建物の高さをシード固定でなくMath.random()で引き直すと、この記事のスクリーンショットと同じプロンプトからの再現が取れません — 9シーンの完全なコード(vanilla 9種・React 9種)はパスワード付きzipに入っていて、r5cadxdwを入力すると解凍できるので、シード付きの出力と自分の実行を突き合わせて確認できます。誰もいないときに05や09を止めたままにするとgifキャプチャが1枚の死んだフレームで終わり、prefers-reduced-motionのユーザーに空のキャンバスを見せるのは動きを減らせという要求を情報を消せという意味に読み違えたものです。
9枚をどの画面に実際に置くか
ヒーローセクションには09が向いています。昼夜照明とポインター視差が同じcurTの値で一緒に動くため、スクロールなしでも背景が生きて見え、見出しやCTAボタンを重ねるときはz-indexの順番だけ気にすればすみます。
会社概要ページのような静的な背景には07の部屋ジオラマが合います — 01〜04の計算を1度だけ組み立てて、演出が終われば止まるので、本文を読んでいる間に視線を奪いません。逆に料金表や実績セクションの背景には08の街ブロックが向いていて、シード乱数で決まる建物の高さが再読み込みのたびに変わるため、同じ背景が毎回同じに見えて飽きることがありません。
タッチ中心のトラフィックが多いページでは、05のポインター視差だけに頼らず、09のように1.1秒操作がないと自動で軌道を描く項目を選ぶと、画面が止まって見えません。
等角の角度と光源値を動かすとアクセシビリティも見えてくる
| バリエーション | 変える値 | トレードオフ |
|---|---|---|
| 等角の角度 | 01の傾き -35.264deg → 45deg(ミリタリー投影) | 正確な等角は3面が同じ重みで読め、45度に倒すと上面が強調されて地図やゲーム盤のように読める |
| 光源角度 | 03の左上45deg → 正面0deg | 左上の光源は立体感が明確で、正面光は面間の明度差が狭まりアイソメトリック フラット デザインに近い落ち着いた印象になる |
| 格子の密度 | 02の6×6 → 16×16 | 密な格子は街シーンを豊かにするが、タイルごとの計算量が増えて低スペック環境ではフレームが落ちることがある |
prefers-reduced-motionでは完成した1枚を見せる
08を除く8つの項目は読み込み時にJSでwindow.matchMedia('(prefers-reduced-motion: reduce)')を1回だけ確認し、どの項目も空のフレームには戻りません。08だけはそもそも止めるループを持たず、CSSの@media (prefers-reduced-motion: reduce)で窓の点滅animationだけを止めて明度を固定値にします。03は光源角度を35度で止め、06は4層を閉じた積み上げのまま保ち、09はcurTを昼夜の境目を越えた0.6に置いて、減速を求めた訪問者には灯りのついた窓と昇った月を見せます。
9枚のシーンを組み込む前によくある質問
アイソメトリック デザイン 作り方として最初に組み上げるべきシーンはどれですか
07の部屋です。01の行列・02の座標・03の陰影・04の積み順がすべて1つの完成した部屋に入っていて、07が動けば残りのシーンは部品の付け足しで作れます。逆に01を飛ばして02から始めると、投影の歪みが全部の後段に持ち込まれます。
等角のmatrix3dにCSSのperspectiveを足してもよいですか
足しません。等角は平行投影で、perspective()は消失点を作る遠近の仕組みです。2つを重ねると遠いブロックが小さくなる歪みが等角の上に乗り、-35.264度の角度が前提とする縮尺関係が崩れます。
08の街が読み込むたびに違って見えるのはなぜですか
違って見えるなら、どこかでシードが変わっています。mulberry32(42)は決定論的で、同じシードは同じ建物の高さと窓の点滅遅延を毎回返す作りです。再読み込みとキャプチャで街並みが一致しない場合は、Math.random()が紛れ込んでいないかを先に疑ってください。









