2D→3D
2D→3D の作り方
平らなドーナツが立体になって転がり、同じ波で一度ふくらんでから、また平らに戻る。フラットな見た目を保ったまま立体の動きが欲しいときの 2D グラフィックの作り方だ。縁のかたちを傾けて画面に落とし、その点群の凸包を輪郭にすると、平らな輪のまま立体に見えてくる。組み立てをコードつきで追う。
- Published
- 2026年6月14日
- Topics
- 2D→3D · Projection · Convex Hull · SVG
円盤を回し、投影で輪郭をつくる
- 置くのは厚い円盤 1 枚 — 傾いた軸を中心に半回転だけ倒して、また戻す
- 倒しながら、同じ波で円盤を一度ふくらませて、また縮める
- 幅も穴も手で描かない — 縁を回して画面に落とし、その外周をなぞれば輪郭になる
// フレーム番号をループの中の位置へ折り返す
const wrap = (frame, span) => ((frame % span) + span) % span;
// 往復の波: ピークまで上がって戻り、そのあと長い休み
const riseSpan = peakFrame - riseStart;
const fallSpan = fallEnd - peakFrame;
const restLo = fallEnd;
const restHi = riseStart + periodFrames;
const phaseAt = (local) => {
if (local >= restLo && local <= restHi) return null;
if (local <= peakFrame) {
const unwrapped = local >= restHi ? local - periodFrames : local;
return { p: (unwrapped - riseStart) / riseSpan, rising: true };
}
return { p: (local - peakFrame) / fallSpan, rising: false };
};
const scaleAt = (local) => {
const ph = phaseAt(local);
if (!ph) return restScale;
const amp = 1 - restScale;
if (ph.rising) return restScale + amp * cubicBezier(...scaleRise)(ph.p);
return restScale + amp * (1 - cubicBezier(...scaleFall)(ph.p));
};
const thetaAt = (local) => {
const ph = phaseAt(local);
if (!ph) return restTheta;
if (ph.rising) return 180 * cubicBezier(...thetaRise)(ph.p);
return 180 * (1 - cubicBezier(...thetaFall)(ph.p));
};scaleAt と thetaAt は、ひとつの山なりの波を分け合う。phaseAt が往路か復路かと、どこまで進んだか(0 から 1 の値)を返し、scaleAt は大きさを restScale から 1 へ往復させ、thetaAt は角度を半回転ぶん往復させる。波が終わると長い休みに入り、円盤は寝たまま待つ。scaleRise などは CSS の cubic-bezier() と同じ並びで、bezier-easing のような既製のライブラリにそのまま渡せる。
// 縁の点を傾いた軸で回し、奥行きを捨てて画面へ落とす
const projectAboutTiltedAxis = (x, y, z, axisCos, axisSin, th) => {
const c = Math.cos(th);
const s = Math.sin(th);
const t = 1 - c;
const px = (t * axisCos * axisCos + c) * x + t * axisCos * axisSin * y + s * axisSin * z;
const py = t * axisCos * axisSin * x + (t * axisSin * axisSin + c) * y + -s * axisCos * z;
return [px, py];
};
const axisTilt = axisTiltDeg * Math.PI / 180;
// フレーム番号 → 円盤の輪郭
const discAt = (frame) => {
const local = wrap(frame, periodFrames);
const scale = scaleAt(local);
const thetaDeg = thetaAt(local);
const th = thetaDeg * Math.PI / 180;
const r = peakOuterR * scale;
const onFall = local >= fallStart && local <= fallAxisEnd;
const axisCos = Math.cos(axisTilt);
const axisSin = Math.sin(axisTilt) * (onFall ? -1 : 1);
// 両側のリムを点群に投影する
const rim = [thicknessRatio * r, -thicknessRatio * r].flatMap((fz) =>
Array.from({ length: rimCount }, (_, i) => {
const a = 2 * Math.PI * i / rimCount;
const [x, y] = projectAboutTiltedAxis(r * Math.cos(a), r * Math.sin(a), fz, axisCos, axisSin, th);
return [x + pivot[0], y + pivot[1]];
}),
);
// 輪郭はその点群の凸包
const outline = convexHull(rim);
const edgeOnness = Math.abs(Math.sin(th));
return { outline, scale, thetaDeg, edgeOnness };
};ここが 2D→3D の正体だ。projectAboutTiltedAxis は、縁の点を傾いた軸で回し、奥行きを捨てて画面へ落とす — ロドリゲスの回転と正射影(奥行きを落とす投影)を 1 つにまとめた式で、サインとコサインでできている。縁を rimCount 個に分けて両側のリムを投影し、その点群の凸包(convexHull)をとれば、それが輪郭になる。立体らしい幅もこれで出てくる。convexHull は定番のアルゴリズムで、自分のスタックの実装を使えばいい。
穴も同じやり方だ。内側のリムを投影して、その囲みを楕円にする — 円盤が真横を向くと穴はふさがって消える。立体感の陰影は、輪郭の内側だけに切り抜いた 2 枚の楕円で出す。法線(円盤の面に垂直な向き)の反対側へ暗い側面を、法線の側へ明るい面を置き、どちらも円盤が横を向くほど濃く、平らになるほど薄くなる。輪郭そのものはこの陰影に左右されない。
画面に出すのは、輪郭のパスを 1 本描いて、穴がある間は穴の楕円を足し、偶数回かさなった所を抜く規則(even-odd)でくり抜く。色は決め打ちにせず、ページの地の色を受け取る。動かすのは requestAnimationFrame — 経過時間にコマ数をかけてフレーム番号にし、periodFrames で折り返して discAt にわたせばデモのループになる。遊ぶなら軸の傾き・厚み・穴の比・イージングを変える。rimCount は縁の刻み数で、上げるほど輪郭が滑らかになる。