css パララックス 背景 9選 — 奥行き3Dシーン
css パララックス 背景 のアニメーションを9パターンまとめました。02番の層は距離に応じてピントがぼやけ、07番の層は距離に応じて影が薄くなり、09番のシーンはその二つの計算を同じ画面に重ねる。
01 depth3d-shape-layer-parallax
02 depth3d-bokeh-blur-falloff
03 depth3d-gradient-mesh-drift
04 depth3d-grid-floor-perspective
05 depth3d-orbit-ring-satellites
06 depth3d-light-shaft-depth
07 depth3d-card-float-shadow-depth
08 depth3d-scale-depth-sizing
09 depth3d-hero-scene-composite
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 層ごとに異なる速度でずれる図形パララックス背景
- 02 距離に応じてピントがぼやけるボケ背景
- 03 層ごとにゆっくり流れるグラデーションメッシュ背景
- 04 地平線に向かって狭まる遠近グリッド床
- 05 ポインターに合わせて傾く軌道衛星リング
- 06 層ごとに薄くなる光の筋の背景
- 07 高さに応じて影が大きくなる浮遊カード
- 08 距離に応じて実時間で拡大縮小する粒子背景
- 09 複数の奥行き層を一画面にまとめたヒーロー背景シーン
css パララックス 背景 9つ — 層ごとに違う計算が積み重なる奥行き
以下の9つはすべて同じ2Dキャンバスループの上で動いています。距離やポインターの値を読み取り、オフセット・ぼかし・不透明度・傾きのどれかに変換して、実際に動いたピクセルだけを描き直すという流れです。項目01はそのループが一番わかりやすく現れた層で、保存した奥行き値の逆数で視差のオフセットが決まる図形の集まりです。項目05になると同じループが固定の奥行きの代わりにポインター位置を読み取ってリングを傾け、項目09では視差・ぼかし・遠近・傾き・影・拡大縮小をひとつのヒーローシーンにまとめています — これはマウス 追従 アニメーション css で作る3D場面9選が背景の代わりにカードをポインターに追従させているのと同じ重ね方です。以下の並びは1枚の視差層から、ぼかしと漂い、格子の遠近、軌道の傾き、光の減衰、影の高さを経て、最後にすべてを合わせた複合シーンへと進みます。
01距離ごとに違う速さで視差する浮遊図形
distanceMultiplier() は図形が持つ仮想の奥行き値をオフセットの倍率に変換し、stepSim() はポインターの動きをその倍率でなめらかに反映させます。奥行きの浅い図形ほど大きく動き、深い図形はほとんど動かないため、層ごとの視差の差がひと目でわかります。

<main class="stage">
<div class="d3 is-demo" id="d3Root" tabindex="0">
<canvas class="d3__canvas" id="d3Canvas" width="480" height="300"></canvas>
<p class="d3__caption">図形ごとに仮の距離値を持たせ、ポインターの移動量にその距離に反比例する倍率を掛けることで、近い図形は大きくずれ遠い図形はわずかしかずれない</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.d3 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.d3 { cursor: default; }
}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);
}
.d3 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.d3 {
cursor: default;
}
}(function () {
// 기법: 도형마다 가상 거리값(distancePx)을 갖고, 포인터 오프셋에 distanceMultiplier(거리에
// 반비례하는 배수)를 곱해 목표 위치를 정한다. 목표 위치는 순간 이동하지 않고 매 프레임
// INERTIA_LERP_RATE 비율로 관성 보간(lerp)해 따라간다 — 가까운 도형은 크게, 먼 도형은
// 조금만 밀리며 부드럽게 뒤쫓는다.
var root = document.getElementById('d3Root');
var canvas = document.getElementById('d3Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var CX = CALC_W * 0.5, CY = CALC_H * 0.5;
var PARALLAX_REF_DISTANCE_PX = 70;
var PARALLAX_MAX_OFFSET_PX = 26;
var INERTIA_LERP_RATE = 5.2;
var SHAPE_COUNT = 16;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rnd = seedRand(429011);
var palette = ['#2f6df6', '#ff9ebb', '#4cd4a6', '#b9a0ff', '#6ec8ff'];
function distanceMultiplier(distancePx) {
var m = PARALLAX_REF_DISTANCE_PX / distancePx;
return Math.max(0.18, Math.min(3.1, m));
}
var shapes = [];
for (var i = 0; i < SHAPE_COUNT; i += 1) {
var distancePx = 26 + rnd() * 150;
var ang = rnd() * Math.PI * 2;
var rad = 14 + rnd() * 62;
shapes.push({
distancePx: distancePx,
baseX: CX + Math.cos(ang) * rad,
baseY: CY + Math.sin(ang) * rad * 0.72,
size: Math.max(3.2, Math.min(15, 620 / distancePx)),
round: rnd() < 0.55,
color: palette[i % palette.length],
curX: 0, curY: 0
});
}
shapes.sort(function (a, b) { return b.distancePx - a.distancePx; });
var pointerNormX = 0, pointerNormY = 0;
var pointerActive = false;
function stepSim(dtMs) {
var dtS = dtMs / 1000;
var rate = Math.min(1, INERTIA_LERP_RATE * dtS);
for (var i = 0; i < shapes.length; i += 1) {
var s = shapes[i];
var mult = distanceMultiplier(s.distancePx);
var tx = pointerNormX * PARALLAX_MAX_OFFSET_PX * mult;
var ty = pointerNormY * PARALLAX_MAX_OFFSET_PX * mult * 0.6;
s.curX += (tx - s.curX) * rate;
s.curY += (ty - s.curY) * rate;
}
}
function drawScene() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < shapes.length; i += 1) {
var s = shapes[i];
var alpha = Math.max(0.35, Math.min(1, 1 - s.distancePx / 190));
var dx = (s.baseX + s.curX) * sx, dy = (s.baseY + s.curY) * sy;
var dr = s.size * ((sx + sy) / 2);
ctx.globalAlpha = alpha;
ctx.fillStyle = s.color;
ctx.beginPath();
if (s.round) {
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
} else {
ctx.save();
ctx.translate(dx, dy);
ctx.rotate(s.distancePx * 0.01);
ctx.fillRect(-dr, -dr * 0.7, dr * 2, dr * 1.4);
ctx.restore();
ctx.beginPath();
}
ctx.fill();
}
ctx.globalAlpha = 1;
}
var cw = 480, ch = 300;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawScene();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
function toNorm(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
return {
x: Math.max(-1, Math.min(1, ((t0.clientX - r.left) / r.width - 0.5) * 2)),
y: Math.max(-1, Math.min(1, ((t0.clientY - r.top) / r.height - 0.5) * 2))
};
}
wrap.addEventListener('pointermove', function (e) {
pointerActive = true;
var n = toNorm(e);
pointerNormX = n.x; pointerNormY = n.y;
if (reduce) { stepSim(16); drawScene(); }
});
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
wrap.addEventListener('keydown', function (e) {
var moved = true;
if (e.key === 'ArrowLeft') pointerNormX = Math.max(-1, pointerNormX - 0.14);
else if (e.key === 'ArrowRight') pointerNormX = Math.min(1, pointerNormX + 0.14);
else if (e.key === 'ArrowUp') pointerNormY = Math.max(-1, pointerNormY - 0.14);
else if (e.key === 'ArrowDown') pointerNormY = Math.min(1, pointerNormY + 0.14);
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) { stepSim(16); drawScene(); }
}
});
function virtualPointerPath(t) {
return {
x: Math.sin(t * 0.00072) * 0.85,
y: Math.cos(t * 0.00104) * 0.6
};
}
var elapsed = 0;
var warmSteps = reduce ? 160 : 120;
for (var w = 0; w < warmSteps; w += 1) {
elapsed += 20;
if (!pointerActive) { var v0 = virtualPointerPath(elapsed); pointerNormX = v0.x; pointerNormY = v0.y; }
stepSim(20);
}
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (!pointerActive) {
var v = virtualPointerPath(elapsed);
pointerNormX = v.x; pointerNormY = v.y;
}
stepSim(dt);
drawScene();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawScene();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./Depth3dShapeLayerParallax.module.scss";
type ShapeItem = {
distancePx: number;
baseX: number;
baseY: number;
size: number;
round: boolean;
color: string;
curX: number;
curY: number;
};
export default function Depth3dShapeLayerParallax() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const CX = CALC_W * 0.5, CY = CALC_H * 0.5;
const PARALLAX_REF_DISTANCE_PX = 70;
const PARALLAX_MAX_OFFSET_PX = 26;
const INERTIA_LERP_RATE = 5.2;
const SHAPE_COUNT = 16;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(429011);
const palette = ["#2f6df6", "#ff9ebb", "#4cd4a6", "#b9a0ff", "#6ec8ff"];
function distanceMultiplier(distancePx: number) {
const m = PARALLAX_REF_DISTANCE_PX / distancePx;
return Math.max(0.18, Math.min(3.1, m));
}
const shapes: ShapeItem[] = [];
for (let i = 0; i < SHAPE_COUNT; i += 1) {
const distancePx = 26 + rnd() * 150;
const ang = rnd() * Math.PI * 2;
const rad = 14 + rnd() * 62;
shapes.push({
distancePx,
baseX: CX + Math.cos(ang) * rad,
baseY: CY + Math.sin(ang) * rad * 0.72,
size: Math.max(3.2, Math.min(15, 620 / distancePx)),
round: rnd() < 0.55,
color: palette[i % palette.length],
curX: 0, curY: 0
});
}
shapes.sort((a, b) => b.distancePx - a.distancePx);
let pointerNormX = 0, pointerNormY = 0;
let pointerActive = false;
function stepSim(dtMs: number) {
const dtS = dtMs / 1000;
const rate = Math.min(1, INERTIA_LERP_RATE * dtS);
for (let i = 0; i < shapes.length; i += 1) {
const s = shapes[i];
const mult = distanceMultiplier(s.distancePx);
const tx = pointerNormX * PARALLAX_MAX_OFFSET_PX * mult;
const ty = pointerNormY * PARALLAX_MAX_OFFSET_PX * mult * 0.6;
s.curX += (tx - s.curX) * rate;
s.curY += (ty - s.curY) * rate;
}
}
let cw = 480, ch = 300;
function drawScene() {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < shapes.length; i += 1) {
const s = shapes[i];
const alpha = Math.max(0.35, Math.min(1, 1 - s.distancePx / 190));
const dx = (s.baseX + s.curX) * sx, dy = (s.baseY + s.curY) * sy;
const dr = s.size * ((sx + sy) / 2);
ctx!.globalAlpha = alpha;
ctx!.fillStyle = s.color;
ctx!.beginPath();
if (s.round) {
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
} else {
ctx!.save();
ctx!.translate(dx, dy);
ctx!.rotate(s.distancePx * 0.01);
ctx!.fillRect(-dr, -dr * 0.7, dr * 2, dr * 1.4);
ctx!.restore();
ctx!.beginPath();
}
ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawScene();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
function toNorm(e: PointerEvent) {
const r = wrap.getBoundingClientRect();
return {
x: Math.max(-1, Math.min(1, ((e.clientX - r.left) / r.width - 0.5) * 2)),
y: Math.max(-1, Math.min(1, ((e.clientY - r.top) / r.height - 0.5) * 2))
};
}
function onPointerMove(e: PointerEvent) {
pointerActive = true;
const n = toNorm(e);
pointerNormX = n.x; pointerNormY = n.y;
if (reduce) { stepSim(16); drawScene(); }
}
function onPointerLeave() { pointerActive = false; }
function onKeydown(e: KeyboardEvent) {
let moved = true;
if (e.key === "ArrowLeft") pointerNormX = Math.max(-1, pointerNormX - 0.14);
else if (e.key === "ArrowRight") pointerNormX = Math.min(1, pointerNormX + 0.14);
else if (e.key === "ArrowUp") pointerNormY = Math.max(-1, pointerNormY - 0.14);
else if (e.key === "ArrowDown") pointerNormY = Math.min(1, pointerNormY + 0.14);
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) { stepSim(16); drawScene(); }
}
}
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerleave", onPointerLeave);
wrap.addEventListener("keydown", onKeydown);
function virtualPointerPath(t: number) {
return {
x: Math.sin(t * 0.00072) * 0.85,
y: Math.cos(t * 0.00104) * 0.6
};
}
let elapsed = 0;
const warmSteps = reduce ? 160 : 120;
for (let w = 0; w < warmSteps; w += 1) {
elapsed += 20;
if (!pointerActive) { const v0 = virtualPointerPath(elapsed); pointerNormX = v0.x; pointerNormY = v0.y; }
stepSim(20);
}
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (!pointerActive) {
const v = virtualPointerPath(elapsed);
pointerNormX = v.x; pointerNormY = v.y;
}
stepSim(dt);
drawScene();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawScene();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
wrap.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.d3} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.d3__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.d3__caption}>도형마다 가상의 거리값을 부여해 포인터 이동량에 그 거리에 반비례하는 배수를 곱하고, 가까운 도형은 크게 밀리고 먼 도형은 조금만 밀린다</p>
</div>
</main>
);
}01 · 層ごとに異なる速度でずれる図形パララックス背景 — 計測 累積面積 20.586% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function stepSim(dtMs) {
var dtS = dtMs / 1000;
var rate = Math.min(1, INERTIA_LERP_RATE * dtS);
for (var i = 0; i < shapes.length; i += 1) {
var s = shapes[i];
var mult = distanceMultiplier(s.distancePx);
var tx = pointerNormX * PARALLAX_MAX_OFFSET_PX * mult;
var ty = pointerNormY * PARALLAX_MAX_OFFSET_PX * mult * 0.6;
s.curX += (tx - s.curX) * rate;
s.curY += (ty - s.curY) * rate;
}
}
function drawScene() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < shapes.length; i += 1) {
var s = shapes[i];
var alpha = Math.max(0.35, Math.min(1, 1 - s.distancePx / 190));
var dx = (s.baseX + s.curX) * sx, dy = (s.baseY + s.curY) * sy;
項目01は面積20.6%・強度平均61.1で、23/23 フレームが動きました。
02距離をシミュレートしてピントがぼやける背景
focalDepthAt() は FOCAL_CYCLE_MS 4200のサイン波でピントの位置を前後に動かし、blurForDepth() がその位置との距離を DEPTH_BLUR_MAX_PX 14を上限としたぼかし量に変換します。ピントが今どこにあるかによって、手前の円と奥の円のどちらがくっきり見えるかが入れ替わります。

<main class="stage">
<div class="d3 is-demo" id="d3Root" tabindex="0">
<canvas class="d3__canvas" id="d3Canvas" width="480" height="300"></canvas>
<p class="d3__caption">各図形の距離値をぼかし半径に変換し、filter blurの値をリアルタイムで再計算することで、焦点面から遠いほど図形の輪郭が大きくにじむ</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.d3 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-yellow;
outline: none;
cursor: crosshair;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.d3 { cursor: pointer; }
}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);
}
.d3 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ffd23f;
outline: none;
cursor: crosshair;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.d3 {
cursor: pointer;
}
}(function () {
// 기법: 원마다 가상 거리값(distancePx)을 갖고, 매 프레임 focalDepth(초점면 거리, 시간에 따라
// 오간다)와의 차이를 blurForDepth 로 흐림 반경(px)으로 바꿔 ctx.filter = blur(...) 를 다시
// 세팅한 뒤 그린다 — 초점면에 가까운 원은 선명하게, 멀어질수록 DEPTH_BLUR_MAX_PX 까지 크게 번진다.
var root = document.getElementById('d3Root');
var canvas = document.getElementById('d3Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var DEPTH_BLUR_MAX_PX = 14;
var FOCAL_CYCLE_MS = 4200;
var FOCAL_RANGE_NEAR = 30, FOCAL_RANGE_FAR = 160;
var BOKEH_COUNT = 14;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rnd = seedRand(429021);
var palette = ['#ffe6b3', '#ffd23f', '#ff9ebb', '#6ec8ff', '#4cd4a6'];
var circles = [];
for (var i = 0; i < BOKEH_COUNT; i += 1) {
circles.push({
distancePx: 20 + rnd() * 150,
baseX: 14 + rnd() * (CALC_W - 28),
baseY: 12 + rnd() * (CALC_H - 24),
size: 5 + rnd() * 11,
phase: rnd() * Math.PI * 2,
driftAmp: 3 + rnd() * 5,
color: palette[i % palette.length]
});
}
function focalDepthAt(t) {
var f = (Math.sin(t / FOCAL_CYCLE_MS * Math.PI * 2) + 1) / 2;
return FOCAL_RANGE_NEAR + f * (FOCAL_RANGE_FAR - FOCAL_RANGE_NEAR);
}
function blurForDepth(distancePx, focalDepth) {
var diff = Math.abs(distancePx - focalDepth);
return Math.max(0, Math.min(DEPTH_BLUR_MAX_PX, diff / 9));
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var savg = (sx + sy) / 2;
var focalDepth = focalDepthAt(nowMs);
ctx.clearRect(0, 0, cw, ch);
ctx.globalCompositeOperation = 'lighter';
var order = circles.slice().sort(function (a, b) { return b.distancePx - a.distancePx; });
for (var i = 0; i < order.length; i += 1) {
var c = order[i];
var blurPx = blurForDepth(c.distancePx, focalDepth);
var alpha = Math.max(0.3, Math.min(0.95, 1 - c.distancePx / 210));
var driftX = Math.sin(nowMs * 0.0006 + c.phase) * c.driftAmp;
var driftY = Math.cos(nowMs * 0.0005 + c.phase) * c.driftAmp * 0.6;
var dx = (c.baseX + driftX) * sx, dy = (c.baseY + driftY) * sy;
var dr = c.size * savg;
ctx.filter = blurPx > 0.15 ? 'blur(' + (blurPx * savg).toFixed(1) + 'px)' : 'none';
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, c.color);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = alpha;
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
ctx.filter = 'none';
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawScene(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var focalNudge = 0;
wrap.addEventListener('pointermove', function (e) {
var r = wrap.getBoundingClientRect();
focalNudge = ((e.clientX - r.left) / r.width - 0.5) * 2;
if (reduce) drawScene(elapsed + focalNudge * FOCAL_CYCLE_MS * 0.25);
});
wrap.addEventListener('keydown', function (e) {
var moved = true;
if (e.key === 'ArrowLeft') focalNudge = Math.max(-1, focalNudge - 0.12);
else if (e.key === 'ArrowRight') focalNudge = Math.min(1, focalNudge + 0.12);
else moved = false;
if (moved) {
e.preventDefault();
if (reduce) drawScene(elapsed + focalNudge * FOCAL_CYCLE_MS * 0.25);
}
});
var warmSteps = reduce ? 160 : 120;
for (var w = 0; w < warmSteps; w += 1) elapsed += 20;
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed + focalNudge * FOCAL_CYCLE_MS * 0.25);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawScene(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./Depth3dBokehBlurFalloff.module.scss";
type Bokeh = {
distancePx: number;
baseX: number;
baseY: number;
size: number;
phase: number;
driftAmp: number;
color: string;
};
export default function Depth3dBokehBlurFalloff() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const DEPTH_BLUR_MAX_PX = 14;
const FOCAL_CYCLE_MS = 4200;
const FOCAL_RANGE_NEAR = 30, FOCAL_RANGE_FAR = 160;
const BOKEH_COUNT = 14;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(429021);
const palette = ["#ffe6b3", "#ffd23f", "#ff9ebb", "#6ec8ff", "#4cd4a6"];
const circles: Bokeh[] = [];
for (let i = 0; i < BOKEH_COUNT; i += 1) {
circles.push({
distancePx: 20 + rnd() * 150,
baseX: 14 + rnd() * (CALC_W - 28),
baseY: 12 + rnd() * (CALC_H - 24),
size: 5 + rnd() * 11,
phase: rnd() * Math.PI * 2,
driftAmp: 3 + rnd() * 5,
color: palette[i % palette.length]
});
}
function focalDepthAt(t: number) {
const f = (Math.sin(t / FOCAL_CYCLE_MS * Math.PI * 2) + 1) / 2;
return FOCAL_RANGE_NEAR + f * (FOCAL_RANGE_FAR - FOCAL_RANGE_NEAR);
}
function blurForDepth(distancePx: number, focalDepth: number) {
const diff = Math.abs(distancePx - focalDepth);
return Math.max(0, Math.min(DEPTH_BLUR_MAX_PX, diff / 9));
}
let elapsed = 0;
let cw = 480, ch = 300;
let focalNudge = 0;
function drawScene(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const savg = (sx + sy) / 2;
const focalDepth = focalDepthAt(nowMs);
ctx!.clearRect(0, 0, cw, ch);
ctx!.globalCompositeOperation = "lighter";
const order = circles.slice().sort((a, b) => b.distancePx - a.distancePx);
for (let i = 0; i < order.length; i += 1) {
const c = order[i];
const blurPx = blurForDepth(c.distancePx, focalDepth);
const alpha = Math.max(0.3, Math.min(0.95, 1 - c.distancePx / 210));
const driftX = Math.sin(nowMs * 0.0006 + c.phase) * c.driftAmp;
const driftY = Math.cos(nowMs * 0.0005 + c.phase) * c.driftAmp * 0.6;
const dx = (c.baseX + driftX) * sx, dy = (c.baseY + driftY) * sy;
const dr = c.size * savg;
ctx!.filter = blurPx > 0.15 ? "blur(" + (blurPx * savg).toFixed(1) + "px)" : "none";
const g = ctx!.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, c.color);
g.addColorStop(1, "rgba(0,0,0,0)");
ctx!.globalAlpha = alpha;
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
ctx!.filter = "none";
ctx!.globalAlpha = 1;
ctx!.globalCompositeOperation = "source-over";
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawScene(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
function onPointerMove(e: PointerEvent) {
const r = wrap.getBoundingClientRect();
focalNudge = ((e.clientX - r.left) / r.width - 0.5) * 2;
if (reduce) drawScene(elapsed + focalNudge * FOCAL_CYCLE_MS * 0.25);
}
function onKeydown(e: KeyboardEvent) {
let moved = true;
if (e.key === "ArrowLeft") focalNudge = Math.max(-1, focalNudge - 0.12);
else if (e.key === "ArrowRight") focalNudge = Math.min(1, focalNudge + 0.12);
else moved = false;
if (moved) {
e.preventDefault();
if (reduce) drawScene(elapsed + focalNudge * FOCAL_CYCLE_MS * 0.25);
}
}
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("keydown", onKeydown);
const warmSteps = reduce ? 160 : 120;
for (let w = 0; w < warmSteps; w += 1) elapsed += 20;
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed + focalNudge * FOCAL_CYCLE_MS * 0.25);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawScene(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.d3} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.d3__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.d3__caption}>각 도형의 거리값을 흐림 반경으로 변환해 실시간으로 filter blur 값을 다시 계산해, 초점면에서 멀어질수록 도형 윤곽이 더 크게 번진다</p>
</div>
</main>
);
}02 · 距離に応じてピントがぼやけるボケ背景 — 計測 累積面積 16.966% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function focalDepthAt(t) {
var f = (Math.sin(t / FOCAL_CYCLE_MS * Math.PI * 2) + 1) / 2;
return FOCAL_RANGE_NEAR + f * (FOCAL_RANGE_FAR - FOCAL_RANGE_NEAR);
}
function blurForDepth(distancePx, focalDepth) {
var diff = Math.abs(distancePx - focalDepth);
return Math.max(0, Math.min(DEPTH_BLUR_MAX_PX, diff / 9));
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var savg = (sx + sy) / 2;
var focalDepth = focalDepthAt(nowMs);
ctx.clearRect(0, 0, cw, ch);
ctx.globalCompositeOperation = 'lighter';
var order = circles.slice().sort(function (a, b) { return b.distancePx - a.distancePx; });
for (var i = 0; i < order.length; i += 1) {
var c = order[i];
var blurPx = blurForDepth(c.distancePx, focalDepth);
項目02は面積17.0%・強度平均15.1で、15/23 フレームが動きました — ピントの周期がゆっくりな分、動きの少ない区間が長く続くため9項目の中で最も低い値です。
03層ごとに違う速さで漂うグラデーションメッシュ
blobPositionAt() は各ブロブを自分専用の reachPx で幅を決めたサイン波に沿って動かし、MESH_LAYER_COUNT 5層に振り分けた MESH_DRIFT_PERIOD_BASE_MS で漂う速さを層ごとに変えます。drawScene() は globalCompositeOperation を lighter にした上で各ブロブごとの createRadialGradient() を重ねるため、重なった部分は単に色が積み重なるのではなく明るくなります。

<main class="stage">
<div class="d3 is-demo" id="d3Root">
<canvas class="d3__canvas" id="d3Canvas" width="480" height="300"></canvas>
<p class="d3__caption">複数のぼかしたグラデーション塊に異なる移動周期を与えて重ねて描き、近い層ほど大きく速く、遠い層ほど小さくゆっくり流れる</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.d3 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-orange;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.d3 { cursor: default; }
}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);
}
.d3 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ff4d1f;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.d3 {
cursor: default;
}
}(function () {
// 기법: meshLayers 배열의 층마다 이동 주기(period)·반경·이동 폭을 다르게 줘 Lissajous 곡선으로
// 흐르게 한다 — 가까운 층(인덱스 0)은 MESH_DRIFT_PERIOD_BASE_MS 그대로 짧게·크게·빠르게, 먼 층일수록
// 주기를 늘리고 반경·이동 폭을 줄여 작고 느리게 흐르는 것처럼 보이게 한다.
var root = document.getElementById('d3Root');
var canvas = document.getElementById('d3Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var MESH_DRIFT_PERIOD_BASE_MS = 3200;
var MESH_LAYER_COUNT = 5;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rnd = seedRand(429031);
var palette = ['#ff9ebb', '#ffb37a', '#ffd23f', '#ff8a5c', '#ffcf99'];
var meshLayers = [];
for (var i = 0; i < MESH_LAYER_COUNT; i += 1) {
var depthIndex = i;
meshLayers.push({
depthIndex: depthIndex,
period: MESH_DRIFT_PERIOD_BASE_MS * (1 + depthIndex * 0.62),
centerX: 20 + rnd() * (CALC_W - 40),
centerY: 18 + rnd() * (CALC_H - 36),
reachPx: Math.max(10, 42 - depthIndex * 6),
radius: Math.max(14, 46 - depthIndex * 7),
phaseX: rnd() * Math.PI * 2,
phaseY: rnd() * Math.PI * 2,
color: palette[i % palette.length]
});
}
meshLayers.sort(function (a, b) { return b.depthIndex - a.depthIndex; });
function blobPositionAt(layer, t) {
var ax = Math.sin(t / layer.period * Math.PI * 2 + layer.phaseX) * layer.reachPx;
var ay = Math.cos(t / layer.period * Math.PI * 2.3 + layer.phaseY) * layer.reachPx * 0.7;
return { x: layer.centerX + ax, y: layer.centerY + ay };
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var savg = (sx + sy) / 2;
ctx.clearRect(0, 0, cw, ch);
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < meshLayers.length; i += 1) {
var layer = meshLayers[i];
var pos = blobPositionAt(layer, nowMs);
var alpha = Math.max(0.3, 0.72 - layer.depthIndex * 0.1);
var dx = pos.x * sx, dy = pos.y * sy, dr = layer.radius * savg;
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, layer.color);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = alpha;
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawScene(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 0 : 90;
for (var w = 0; w < warmSteps; w += 1) elapsed += 20;
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawScene(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./Depth3dGradientMeshDrift.module.scss";
type MeshLayer = {
depthIndex: number;
period: number;
centerX: number;
centerY: number;
reachPx: number;
radius: number;
phaseX: number;
phaseY: number;
color: string;
};
export default function Depth3dGradientMeshDrift() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const MESH_DRIFT_PERIOD_BASE_MS = 3200;
const MESH_LAYER_COUNT = 5;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(429031);
const palette = ["#ff9ebb", "#ffb37a", "#ffd23f", "#ff8a5c", "#ffcf99"];
const meshLayers: MeshLayer[] = [];
for (let i = 0; i < MESH_LAYER_COUNT; i += 1) {
const depthIndex = i;
meshLayers.push({
depthIndex,
period: MESH_DRIFT_PERIOD_BASE_MS * (1 + depthIndex * 0.62),
centerX: 20 + rnd() * (CALC_W - 40),
centerY: 18 + rnd() * (CALC_H - 36),
reachPx: Math.max(10, 42 - depthIndex * 6),
radius: Math.max(14, 46 - depthIndex * 7),
phaseX: rnd() * Math.PI * 2,
phaseY: rnd() * Math.PI * 2,
color: palette[i % palette.length]
});
}
meshLayers.sort((a, b) => b.depthIndex - a.depthIndex);
function blobPositionAt(layer: MeshLayer, t: number) {
const ax = Math.sin(t / layer.period * Math.PI * 2 + layer.phaseX) * layer.reachPx;
const ay = Math.cos(t / layer.period * Math.PI * 2.3 + layer.phaseY) * layer.reachPx * 0.7;
return { x: layer.centerX + ax, y: layer.centerY + ay };
}
let elapsed = 0;
let cw = 480, ch = 300;
function drawScene(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const savg = (sx + sy) / 2;
ctx!.clearRect(0, 0, cw, ch);
ctx!.globalCompositeOperation = "lighter";
for (let i = 0; i < meshLayers.length; i += 1) {
const layer = meshLayers[i];
const pos = blobPositionAt(layer, nowMs);
const alpha = Math.max(0.3, 0.72 - layer.depthIndex * 0.1);
const dx = pos.x * sx, dy = pos.y * sy, dr = layer.radius * savg;
const g = ctx!.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, layer.color);
g.addColorStop(1, "rgba(0,0,0,0)");
ctx!.globalAlpha = alpha;
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
ctx!.globalAlpha = 1;
ctx!.globalCompositeOperation = "source-over";
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawScene(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 0 : 90;
for (let w = 0; w < warmSteps; w += 1) elapsed += 20;
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawScene(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.d3} is-demo`} ref={rootRef}>
<canvas className={styles.d3__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.d3__caption}>여러 겹의 흐린 그라데이션 덩어리에 서로 다른 이동 주기를 줘 겹쳐 그리고, 가까운 층일수록 더 크고 빠르게, 먼 층일수록 작고 느리게 흐른다</p>
</div>
</main>
);
}03 · 層ごとにゆっくり流れるグラデーションメッシュ背景 — 計測 累積面積 52.471% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function blobPositionAt(layer, t) {
var ax = Math.sin(t / layer.period * Math.PI * 2 + layer.phaseX) * layer.reachPx;
var ay = Math.cos(t / layer.period * Math.PI * 2.3 + layer.phaseY) * layer.reachPx * 0.7;
return { x: layer.centerX + ax, y: layer.centerY + ay };
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var savg = (sx + sy) / 2;
ctx.clearRect(0, 0, cw, ch);
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < meshLayers.length; i += 1) {
var layer = meshLayers[i];
var pos = blobPositionAt(layer, nowMs);
var alpha = Math.max(0.3, 0.72 - layer.depthIndex * 0.1);
var dx = pos.x * sx, dy = pos.y * sy, dr = layer.radius * savg;
var g = ctx.createRadialGradient(dx, dy, 0, dx, dy, dr);
g.addColorStop(0, layer.color);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalAlpha = alpha;
項目03は面積52.5%・強度平均20.9で、23/23 フレームが動きました — 9項目の中でもっとも広い面積を塗り替えています。
04水平線に向かって収束する遠近グリッド床
projectGridPoint() は GRID_PERSPECTIVE_PX 800を各行の奥行きで割って平らな床のグリッドを一点の水平線へ収束させ、nudgeCamHeight() は仮想カメラの高さが変わるたびにその水平線の位置をずらします。9項目のうち seedRand() を呼ばないのはこの項目だけで、グリッドの行と水平線が固定位置から始まり乱数を必要としないためです。

<main class="stage">
<div class="d3 is-demo" id="d3Root" tabindex="0">
<canvas class="d3__canvas" id="d3Canvas" width="480" height="300"></canvas>
<p class="d3__caption">グリッド線の座標を透視投影の式で計算して地平線に近いほど線の間隔を狭め、ポインターが動くたびに視点の位置も再計算する</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.d3 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-paper;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: #fff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.d3 { cursor: default; }
}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);
}
.d3 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ffffff;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.d3 {
cursor: default;
}
}(function () {
// 기법: 바닥의 각 격자 교차점을 projectGridPoint 로 원근 투영해(카메라 높이 ÷ 거리 z 에
// GRID_PERSPECTIVE_PX 를 곱하는 표준 투영식) 화면 좌표를 얻는다. 지평선에 가까운(= z 가 큰)
// 줄일수록 화면 간격이 좁아지고, 포인터가 좌우로 움직이면 viewOffsetX 가 바뀌어 시점이
// 옆으로 패닝하듯 다시 계산된다. z 는 시간에 따라 계속 흘러 바닥이 앞으로 흘러오는 것처럼 보인다.
var root = document.getElementById('d3Root');
var canvas = document.getElementById('d3Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var HORIZON_Y = CALC_H * 0.4;
var camHeight = 46;
var GRID_PERSPECTIVE_PX = 800;
var ROW_SPACING = 16;
var ROW_COUNT = 9;
var COL_SPACING = 14;
var COL_COUNT = 11;
var SCROLL_SPEED = 18;
var viewOffsetX = 0;
function projectGridPoint(xWorld, zWorld) {
var z = Math.max(4, zWorld);
var scale = GRID_PERSPECTIVE_PX / (z * 40);
return {
x: CALC_W * 0.5 + (xWorld - viewOffsetX) * scale,
y: HORIZON_Y + camHeight * scale
};
}
var elapsed = 0;
var scrollZ = 0;
var cw = 480, ch = 300;
function drawScene() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = 'rgba(255,140,40,.16)';
var sunR = 30 + Math.sin(elapsed * 0.0009) * 4;
ctx.beginPath();
ctx.arc(CALC_W * 0.5 * sx, HORIZON_Y * sy, sunR * sx, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(120,90,200,.7)';
ctx.lineWidth = 1.2;
for (var r = 1; r <= ROW_COUNT; r += 1) {
var z = r * ROW_SPACING - scrollZ;
if (z <= 2) continue;
var pL = projectGridPoint(-COL_COUNT * COL_SPACING * 0.5, z);
var pR = projectGridPoint(COL_COUNT * COL_SPACING * 0.5, z);
ctx.beginPath();
ctx.moveTo(pL.x * sx, pL.y * sy);
ctx.lineTo(pR.x * sx, pR.y * sy);
ctx.stroke();
}
var zNear = ROW_SPACING - scrollZ % ROW_SPACING + 2;
var zFar = ROW_COUNT * ROW_SPACING;
for (var c = 0; c <= COL_COUNT; c += 1) {
var xw = -COL_COUNT * COL_SPACING * 0.5 + c * COL_SPACING;
var pn = projectGridPoint(xw, zNear);
var pf = projectGridPoint(xw, zFar);
ctx.beginPath();
ctx.moveTo(pn.x * sx, pn.y * sy);
ctx.lineTo(pf.x * sx, pf.y * sy);
ctx.stroke();
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawScene();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var pointerActive = false;
wrap.addEventListener('pointermove', function (e) {
pointerActive = true;
var r = wrap.getBoundingClientRect();
var norm = ((e.clientX - r.left) / r.width - 0.5) * 2;
viewOffsetX = norm * 46;
if (reduce) drawScene();
});
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
wrap.addEventListener('keydown', function (e) {
var moved = true;
if (e.key === 'ArrowLeft') viewOffsetX = Math.max(-46, viewOffsetX - 6);
else if (e.key === 'ArrowRight') viewOffsetX = Math.min(46, viewOffsetX + 6);
else if (e.key === 'ArrowUp') nudgeCamHeight(6);
else if (e.key === 'ArrowDown') nudgeCamHeight(-6);
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) drawScene();
}
});
var camHeightBoost = 0;
function nudgeCamHeight(d) {
camHeightBoost = Math.max(-18, Math.min(18, camHeightBoost + d));
camHeight = 46 + camHeightBoost;
}
function virtualViewOffsetAt(t) {
return Math.sin(t * 0.00048) * 40;
}
var warmSteps = reduce ? 40 : 100;
for (var w = 0; w < warmSteps; w += 1) {
elapsed += 20;
scrollZ = (scrollZ + SCROLL_SPEED * 0.02) % ROW_SPACING;
if (!pointerActive) viewOffsetX = virtualViewOffsetAt(elapsed);
}
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
scrollZ = (scrollZ + SCROLL_SPEED * (dt / 1000)) % ROW_SPACING;
if (!pointerActive) viewOffsetX = virtualViewOffsetAt(elapsed);
drawScene();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawScene();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./Depth3dGridFloorPerspective.module.scss";
export default function Depth3dGridFloorPerspective() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const HORIZON_Y = CALC_H * 0.4;
let camHeight = 46;
const GRID_PERSPECTIVE_PX = 800;
const ROW_SPACING = 16;
const ROW_COUNT = 9;
const COL_SPACING = 14;
const COL_COUNT = 11;
const SCROLL_SPEED = 18;
let viewOffsetX = 0;
function projectGridPoint(xWorld: number, zWorld: number) {
const z = Math.max(4, zWorld);
const scale = GRID_PERSPECTIVE_PX / (z * 40);
return {
x: CALC_W * 0.5 + (xWorld - viewOffsetX) * scale,
y: HORIZON_Y + camHeight * scale
};
}
let elapsed = 0;
let scrollZ = 0;
let cw = 480, ch = 300;
function drawScene() {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
ctx!.fillStyle = "rgba(255,140,40,.16)";
const sunR = 30 + Math.sin(elapsed * 0.0009) * 4;
ctx!.beginPath();
ctx!.arc(CALC_W * 0.5 * sx, HORIZON_Y * sy, sunR * sx, 0, Math.PI * 2);
ctx!.fill();
ctx!.strokeStyle = "rgba(120,90,200,.7)";
ctx!.lineWidth = 1.2;
for (let r = 1; r <= ROW_COUNT; r += 1) {
const z = r * ROW_SPACING - scrollZ;
if (z <= 2) continue;
const pL = projectGridPoint(-COL_COUNT * COL_SPACING * 0.5, z);
const pR = projectGridPoint(COL_COUNT * COL_SPACING * 0.5, z);
ctx!.beginPath();
ctx!.moveTo(pL.x * sx, pL.y * sy);
ctx!.lineTo(pR.x * sx, pR.y * sy);
ctx!.stroke();
}
const zNear = ROW_SPACING - (scrollZ % ROW_SPACING) + 2;
const zFar = ROW_COUNT * ROW_SPACING;
for (let c = 0; c <= COL_COUNT; c += 1) {
const xw = -COL_COUNT * COL_SPACING * 0.5 + c * COL_SPACING;
const pn = projectGridPoint(xw, zNear);
const pf = projectGridPoint(xw, zFar);
ctx!.beginPath();
ctx!.moveTo(pn.x * sx, pn.y * sy);
ctx!.lineTo(pf.x * sx, pf.y * sy);
ctx!.stroke();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawScene();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
let pointerActive = false;
let camHeightBoost = 0;
function nudgeCamHeight(d: number) {
camHeightBoost = Math.max(-18, Math.min(18, camHeightBoost + d));
camHeight = 46 + camHeightBoost;
}
function onPointerMove(e: PointerEvent) {
pointerActive = true;
const r = wrap.getBoundingClientRect();
const norm = ((e.clientX - r.left) / r.width - 0.5) * 2;
viewOffsetX = norm * 46;
if (reduce) drawScene();
}
function onPointerLeave() { pointerActive = false; }
function onKeydown(e: KeyboardEvent) {
let moved = true;
if (e.key === "ArrowLeft") viewOffsetX = Math.max(-46, viewOffsetX - 6);
else if (e.key === "ArrowRight") viewOffsetX = Math.min(46, viewOffsetX + 6);
else if (e.key === "ArrowUp") nudgeCamHeight(6);
else if (e.key === "ArrowDown") nudgeCamHeight(-6);
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) drawScene();
}
}
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerleave", onPointerLeave);
wrap.addEventListener("keydown", onKeydown);
function virtualViewOffsetAt(t: number) {
return Math.sin(t * 0.00048) * 40;
}
const warmSteps = reduce ? 40 : 100;
for (let w = 0; w < warmSteps; w += 1) {
elapsed += 20;
scrollZ = (scrollZ + SCROLL_SPEED * 0.02) % ROW_SPACING;
if (!pointerActive) viewOffsetX = virtualViewOffsetAt(elapsed);
}
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
scrollZ = (scrollZ + SCROLL_SPEED * (dt / 1000)) % ROW_SPACING;
if (!pointerActive) viewOffsetX = virtualViewOffsetAt(elapsed);
drawScene();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawScene();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
wrap.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.d3} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.d3__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.d3__caption}>격자선 좌표를 원근 투영 공식으로 계산해 지평선에 가까울수록 선 사이 간격을 좁히고, 포인터가 움직이면 시점 위치도 같이 다시 계산한다</p>
</div>
</main>
);
}04 · 地平線に向かって狭まる遠近グリッド床 — 計測 累積面積 23.426% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function projectGridPoint(xWorld, zWorld) {
var z = Math.max(4, zWorld);
var scale = GRID_PERSPECTIVE_PX / (z * 40);
return {
x: CALC_W * 0.5 + (xWorld - viewOffsetX) * scale,
y: HORIZON_Y + camHeight * scale
};
}
var elapsed = 0;
var scrollZ = 0;
var cw = 480, ch = 300;
function drawScene() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = 'rgba(255,140,40,.16)';
var sunR = 30 + Math.sin(elapsed * 0.0009) * 4;
ctx.beginPath();
ctx.arc(CALC_W * 0.5 * sx, HORIZON_Y * sy, sunR * sx, 0, Math.PI * 2);
ctx.fill();
項目04は面積23.4%・強度平均39.8で、23/23 フレームが動きました。
05ポインターを追って傾く軌道衛星リング
zSortSatellites() は毎フレーム computeTransformed() の奥行きで衛星の描画順を並べ替えるため、リングの奥へ回った衛星は手前の衛星より先に描かれます。ポインターの動きはリング全体を ORBIT_TILT_MAX_DEG 18を上限に傾け、各衛星の並べ替えに使われる奥行きもその同じ傾き角から決まります。

<main class="stage">
<div class="d3 is-demo" id="d3Root" tabindex="0">
<canvas class="d3__canvas" id="d3Canvas" width="480" height="300"></canvas>
<p class="d3__caption">小さな図形の位置を球面座標の式で毎フレーム計算しz値の順に描き直すことで、ポインター位置に応じてリング全体が傾きながらも前後の重なりが正しく保たれる</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.d3 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.d3 { cursor: default; }
}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);
}
.d3 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.d3 {
cursor: default;
}
}(function () {
// 기법: 위성마다 satelliteAngleAt 로 궤도각을 구해 고리 평면(XZ) 위 좌표를 얻은 뒤, 포인터로
// 정해지는 tiltDeg(최대 ORBIT_TILT_MAX_DEG) 만큼 X축 회전을 적용해 y·z 를 다시 계산한다.
// 회전 뒤 z값(깊이) 기준으로 zSortSatellites 가 매 프레임 다시 정렬해 뒤에서 앞 순서로 그려야
// 고리가 기울어도 앞뒤 가림이 맞는다(화가 알고리즘).
var root = document.getElementById('d3Root');
var canvas = document.getElementById('d3Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var CX = CALC_W * 0.5, CY = CALC_H * 0.52;
var RING_RADIUS = 40;
var ORBIT_TILT_MAX_DEG = 18;
var SAT_COUNT = 9;
var ANGULAR_SPEED = 0.00062;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rnd = seedRand(429051);
var palette = ['#6ec8ff', '#b9a0ff', '#ff9ebb', '#4cd4a6', '#ffd23f'];
var satellites = [];
for (var i = 0; i < SAT_COUNT; i += 1) {
satellites.push({
baseAngle: (Math.PI * 2 / SAT_COUNT) * i + rnd() * 0.3,
radiusScale: 0.72 + rnd() * 0.32,
size: 3.2 + rnd() * 3.4,
color: palette[i % palette.length]
});
}
function satelliteAngleAt(sat, t) {
return sat.baseAngle + t * ANGULAR_SPEED;
}
var tiltDeg = 0, azimuthOffset = 0;
var pointerActive = false;
function computeTransformed(sat, t) {
var ang = satelliteAngleAt(sat, t) + azimuthOffset;
var r = RING_RADIUS * sat.radiusScale;
var lx = Math.cos(ang) * r;
var lz = Math.sin(ang) * r;
var tiltRad = tiltDeg * Math.PI / 180;
var ry = -lz * Math.sin(tiltRad);
var rz = lz * Math.cos(tiltRad);
return { x: lx, y: ry, z: rz };
}
function zSortSatellites(list, t) {
var withZ = list.map(function (sat) {
var p = computeTransformed(sat, t);
return { sat: sat, p: p };
});
withZ.sort(function (a, b) { return a.p.z - b.p.z; });
return withZ;
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var savg = (sx + sy) / 2;
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = 'rgba(150,170,255,.14)';
ctx.beginPath();
ctx.arc(CX * sx, CY * sy, RING_RADIUS * 0.5 * savg, 0, Math.PI * 2);
ctx.fill();
var ordered = zSortSatellites(satellites, nowMs);
for (var i = 0; i < ordered.length; i += 1) {
var p = ordered[i].p;
var sat = ordered[i].sat;
var perspScale = Math.max(0.55, Math.min(1.5, 1 + p.z / RING_RADIUS * 0.45));
var alpha = Math.max(0.4, Math.min(1, 0.62 + p.z / RING_RADIUS * 0.4));
var dx = (CX + p.x) * sx, dy = (CY + p.y) * sy;
var dr = sat.size * perspScale * savg;
ctx.globalAlpha = alpha;
ctx.fillStyle = sat.color;
ctx.beginPath();
ctx.arc(dx, dy, dr, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawScene(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
wrap.addEventListener('pointermove', function (e) {
pointerActive = true;
var r = wrap.getBoundingClientRect();
var ny = ((e.clientY - r.top) / r.height - 0.5) * 2;
var nx = ((e.clientX - r.left) / r.width - 0.5) * 2;
tiltDeg = Math.max(-ORBIT_TILT_MAX_DEG, Math.min(ORBIT_TILT_MAX_DEG, ny * ORBIT_TILT_MAX_DEG));
azimuthOffset = nx * 0.9;
if (reduce) drawScene(elapsed);
});
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
wrap.addEventListener('keydown', function (e) {
var moved = true;
if (e.key === 'ArrowUp') tiltDeg = Math.max(-ORBIT_TILT_MAX_DEG, tiltDeg - 3);
else if (e.key === 'ArrowDown') tiltDeg = Math.min(ORBIT_TILT_MAX_DEG, tiltDeg + 3);
else if (e.key === 'ArrowLeft') azimuthOffset -= 0.16;
else if (e.key === 'ArrowRight') azimuthOffset += 0.16;
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) drawScene(elapsed);
}
});
function virtualTiltAt(t) {
return Math.sin(t * 0.00058) * ORBIT_TILT_MAX_DEG;
}
var warmSteps = reduce ? 0 : 100;
for (var w = 0; w < warmSteps; w += 1) {
elapsed += 20;
if (!pointerActive) tiltDeg = virtualTiltAt(elapsed);
}
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (!pointerActive) tiltDeg = virtualTiltAt(elapsed);
drawScene(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawScene(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./Depth3dOrbitRingSatellites.module.scss";
type Satellite = {
baseAngle: number;
radiusScale: number;
size: number;
color: string;
};
export default function Depth3dOrbitRingSatellites() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const CX = CALC_W * 0.5, CY = CALC_H * 0.52;
const RING_RADIUS = 40;
const ORBIT_TILT_MAX_DEG = 18;
const SAT_COUNT = 9;
const ANGULAR_SPEED = 0.00062;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(429051);
const palette = ["#6ec8ff", "#b9a0ff", "#ff9ebb", "#4cd4a6", "#ffd23f"];
const satellites: Satellite[] = [];
for (let i = 0; i < SAT_COUNT; i += 1) {
satellites.push({
baseAngle: (Math.PI * 2 / SAT_COUNT) * i + rnd() * 0.3,
radiusScale: 0.72 + rnd() * 0.32,
size: 3.2 + rnd() * 3.4,
color: palette[i % palette.length]
});
}
function satelliteAngleAt(sat: Satellite, t: number) {
return sat.baseAngle + t * ANGULAR_SPEED;
}
let tiltDeg = 0, azimuthOffset = 0;
let pointerActive = false;
function computeTransformed(sat: Satellite, t: number) {
const ang = satelliteAngleAt(sat, t) + azimuthOffset;
const r = RING_RADIUS * sat.radiusScale;
const lx = Math.cos(ang) * r;
const lz = Math.sin(ang) * r;
const tiltRad = tiltDeg * Math.PI / 180;
const ry = -lz * Math.sin(tiltRad);
const rz = lz * Math.cos(tiltRad);
return { x: lx, y: ry, z: rz };
}
function zSortSatellites(list: Satellite[], t: number) {
const withZ = list.map((sat) => ({ sat, p: computeTransformed(sat, t) }));
withZ.sort((a, b) => a.p.z - b.p.z);
return withZ;
}
let elapsed = 0;
let cw = 480, ch = 300;
function drawScene(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const savg = (sx + sy) / 2;
ctx!.clearRect(0, 0, cw, ch);
ctx!.fillStyle = "rgba(150,170,255,.14)";
ctx!.beginPath();
ctx!.arc(CX * sx, CY * sy, RING_RADIUS * 0.5 * savg, 0, Math.PI * 2);
ctx!.fill();
const ordered = zSortSatellites(satellites, nowMs);
for (let i = 0; i < ordered.length; i += 1) {
const p = ordered[i].p;
const sat = ordered[i].sat;
const perspScale = Math.max(0.55, Math.min(1.5, 1 + p.z / RING_RADIUS * 0.45));
const alpha = Math.max(0.4, Math.min(1, 0.62 + p.z / RING_RADIUS * 0.4));
const dx = (CX + p.x) * sx, dy = (CY + p.y) * sy;
const dr = sat.size * perspScale * savg;
ctx!.globalAlpha = alpha;
ctx!.fillStyle = sat.color;
ctx!.beginPath();
ctx!.arc(dx, dy, dr, 0, Math.PI * 2);
ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawScene(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
function onPointerMove(e: PointerEvent) {
pointerActive = true;
const r = wrap.getBoundingClientRect();
const ny = ((e.clientY - r.top) / r.height - 0.5) * 2;
const nx = ((e.clientX - r.left) / r.width - 0.5) * 2;
tiltDeg = Math.max(-ORBIT_TILT_MAX_DEG, Math.min(ORBIT_TILT_MAX_DEG, ny * ORBIT_TILT_MAX_DEG));
azimuthOffset = nx * 0.9;
if (reduce) drawScene(elapsed);
}
function onPointerLeave() { pointerActive = false; }
function onKeydown(e: KeyboardEvent) {
let moved = true;
if (e.key === "ArrowUp") tiltDeg = Math.max(-ORBIT_TILT_MAX_DEG, tiltDeg - 3);
else if (e.key === "ArrowDown") tiltDeg = Math.min(ORBIT_TILT_MAX_DEG, tiltDeg + 3);
else if (e.key === "ArrowLeft") azimuthOffset -= 0.16;
else if (e.key === "ArrowRight") azimuthOffset += 0.16;
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) drawScene(elapsed);
}
}
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerleave", onPointerLeave);
wrap.addEventListener("keydown", onKeydown);
function virtualTiltAt(t: number) {
return Math.sin(t * 0.00058) * ORBIT_TILT_MAX_DEG;
}
const warmSteps = reduce ? 0 : 100;
for (let w = 0; w < warmSteps; w += 1) {
elapsed += 20;
if (!pointerActive) tiltDeg = virtualTiltAt(elapsed);
}
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
if (!pointerActive) tiltDeg = virtualTiltAt(elapsed);
drawScene(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawScene(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
wrap.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.d3} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.d3__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.d3__caption}>작은 도형들의 위치를 구면 좌표식으로 매 프레임 계산하고 z값 순서로 다시 그려, 포인터 위치에 따라 고리 전체가 기울면서도 앞뒤 가림이 맞는다</p>
</div>
</main>
);
}05 · ポインターに合わせて傾く軌道衛星リング — 計測 累積面積 10.212% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function satelliteAngleAt(sat, t) {
return sat.baseAngle + t * ANGULAR_SPEED;
}
var tiltDeg = 0, azimuthOffset = 0;
var pointerActive = false;
function computeTransformed(sat, t) {
var ang = satelliteAngleAt(sat, t) + azimuthOffset;
var r = RING_RADIUS * sat.radiusScale;
var lx = Math.cos(ang) * r;
var lz = Math.sin(ang) * r;
var tiltRad = tiltDeg * Math.PI / 180;
var ry = -lz * Math.sin(tiltRad);
var rz = lz * Math.cos(tiltRad);
return { x: lx, y: ry, z: rz };
}
function zSortSatellites(list, t) {
var withZ = list.map(function (sat) {
var p = computeTransformed(sat, t);
return { sat: sat, p: p };
});
項目05は面積10.2%・強度平均106.0で、23/23 フレームが動きました — 9項目の中でもっとも狭い面積を、高い強度と組み合わせています。
06層ごとにフェードするライトシャフト背景
shaftOpacityForDepth() は LIGHT_SHAFT_DECAY 0.42で指数的に減衰する計算を行い、SHAFT_LAYER_COUNT 7本のシャフトは depthFor() の値が光源から遠いほど速く消えていきます。drawScene() は globalCompositeOperation を lighter にしてすべてのシャフトを重ねるため、交差する部分は打ち消し合わずに明るくなります。

<main class="stage">
<div class="d3 is-demo" id="d3Root" tabindex="0">
<canvas class="d3__canvas" id="d3Canvas" width="480" height="300"></canvas>
<p class="d3__caption">光の筋を複数枚描き、各層の不透明度を距離値に指数関数を掛けて下げることで、近い層はくっきり見え遠い層は背景にほぼ溶け込む</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.d3 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-yellow;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: #fff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.d3 { cursor: default; }
}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);
}
.d3 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ffd23f;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.d3 {
cursor: default;
}
}(function () {
// 기법: 겹마다 깊이값(거리값)이 시간에 따라 완만히 흔들리고, shaftOpacityForDepth 가 그 깊이값에
// LIGHT_SHAFT_DECAY 를 지수(exp)로 곱해 불투명도를 구한다 — 거리가 커질수록 불투명도가 기하급수로
// 줄어 먼 겹은 배경에 거의 스며들고 가까운 겹만 선명하게 남는다.
var root = document.getElementById('d3Root');
var canvas = document.getElementById('d3Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var SOURCE_X = CALC_W * 0.5, SOURCE_Y = -6;
var SHAFT_LAYER_COUNT = 7;
var LIGHT_SHAFT_DECAY = 0.42;
var MAX_OPACITY = 0.62;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rnd = seedRand(429061);
var shafts = [];
for (var i = 0; i < SHAFT_LAYER_COUNT; i += 1) {
shafts.push({
angleBase: -0.62 + (1.24 / (SHAFT_LAYER_COUNT - 1)) * i,
distanceBase: 1.1 + i * 0.62,
swayRange: 0.6 + rnd() * 0.6,
swaySpeed: 0.0013 + rnd() * 0.0011,
phase: rnd() * Math.PI * 2,
width: 8 + rnd() * 6,
hue: i % 2 === 0 ? '90,60,170' : '40,30,110'
});
}
function depthFor(shaft, t) {
return shaft.distanceBase + Math.sin(t * shaft.swaySpeed + shaft.phase) * shaft.swayRange;
}
function shaftOpacityForDepth(depth) {
var v = Math.exp(-LIGHT_SHAFT_DECAY * depth) * MAX_OPACITY;
return Math.max(0, Math.min(MAX_OPACITY, v));
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(t) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = 'rgba(60,40,120,.55)';
ctx.beginPath();
ctx.arc(SOURCE_X * sx, SOURCE_Y * sy, 10 * sx, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < shafts.length; i += 1) {
var shaft = shafts[i];
var depth = depthFor(shaft, t);
var op = shaftOpacityForDepth(depth);
var angle = shaft.angleBase + Math.sin(t * shaft.swaySpeed * 0.7 + shaft.phase) * 0.13;
var length = 118 + depth * 6;
var endX = SOURCE_X + Math.sin(angle) * length;
var endY = SOURCE_Y + Math.cos(angle) * length;
var grad = ctx.createLinearGradient(SOURCE_X * sx, SOURCE_Y * sy, endX * sx, endY * sy);
grad.addColorStop(0, 'rgba(' + shaft.hue + ',' + op + ')');
grad.addColorStop(1, 'rgba(' + shaft.hue + ',0)');
var perpX = Math.cos(angle) * shaft.width;
var perpY = -Math.sin(angle) * shaft.width;
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo((SOURCE_X - perpX * 0.15) * sx, (SOURCE_Y - perpY * 0.15) * sy);
ctx.lineTo((SOURCE_X + perpX * 0.15) * sx, (SOURCE_Y + perpY * 0.15) * sy);
ctx.lineTo((endX + perpX) * sx, (endY + perpY) * sy);
ctx.lineTo((endX - perpX) * sx, (endY - perpY) * sy);
ctx.closePath();
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawScene(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 0 : 120;
for (var w = 0; w < warmSteps; w += 1) elapsed += 20;
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawScene(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./Depth3dLightShaftDepth.module.scss";
type Shaft = {
angleBase: number;
distanceBase: number;
swayRange: number;
swaySpeed: number;
phase: number;
width: number;
hue: string;
};
export default function Depth3dLightShaftDepth() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const SOURCE_X = CALC_W * 0.5, SOURCE_Y = -6;
const SHAFT_LAYER_COUNT = 7;
const LIGHT_SHAFT_DECAY = 0.42;
const MAX_OPACITY = 0.62;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(429061);
const shafts: Shaft[] = [];
for (let i = 0; i < SHAFT_LAYER_COUNT; i += 1) {
shafts.push({
angleBase: -0.62 + (1.24 / (SHAFT_LAYER_COUNT - 1)) * i,
distanceBase: 1.1 + i * 0.62,
swayRange: 0.6 + rnd() * 0.6,
swaySpeed: 0.0013 + rnd() * 0.0011,
phase: rnd() * Math.PI * 2,
width: 8 + rnd() * 6,
hue: i % 2 === 0 ? "90,60,170" : "40,30,110"
});
}
function depthFor(shaft: Shaft, t: number) {
return shaft.distanceBase + Math.sin(t * shaft.swaySpeed + shaft.phase) * shaft.swayRange;
}
function shaftOpacityForDepth(depth: number) {
const v = Math.exp(-LIGHT_SHAFT_DECAY * depth) * MAX_OPACITY;
return Math.max(0, Math.min(MAX_OPACITY, v));
}
let elapsed = 0;
let cw = 480, ch = 300;
function drawScene(t: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
ctx!.fillStyle = "rgba(60,40,120,.55)";
ctx!.beginPath();
ctx!.arc(SOURCE_X * sx, SOURCE_Y * sy, 10 * sx, 0, Math.PI * 2);
ctx!.fill();
ctx!.globalCompositeOperation = "lighter";
for (let i = 0; i < shafts.length; i += 1) {
const shaft = shafts[i];
const depth = depthFor(shaft, t);
const op = shaftOpacityForDepth(depth);
const angle = shaft.angleBase + Math.sin(t * shaft.swaySpeed * 0.7 + shaft.phase) * 0.13;
const length = 118 + depth * 6;
const endX = SOURCE_X + Math.sin(angle) * length;
const endY = SOURCE_Y + Math.cos(angle) * length;
const grad = ctx!.createLinearGradient(SOURCE_X * sx, SOURCE_Y * sy, endX * sx, endY * sy);
grad.addColorStop(0, `rgba(${shaft.hue},${op})`);
grad.addColorStop(1, `rgba(${shaft.hue},0)`);
const perpX = Math.cos(angle) * shaft.width;
const perpY = -Math.sin(angle) * shaft.width;
ctx!.fillStyle = grad;
ctx!.beginPath();
ctx!.moveTo((SOURCE_X - perpX * 0.15) * sx, (SOURCE_Y - perpY * 0.15) * sy);
ctx!.lineTo((SOURCE_X + perpX * 0.15) * sx, (SOURCE_Y + perpY * 0.15) * sy);
ctx!.lineTo((endX + perpX) * sx, (endY + perpY) * sy);
ctx!.lineTo((endX - perpX) * sx, (endY - perpY) * sy);
ctx!.closePath();
ctx!.fill();
}
ctx!.globalCompositeOperation = "source-over";
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawScene(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 0 : 120;
for (let w = 0; w < warmSteps; w += 1) elapsed += 20;
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawScene(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.d3} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.d3__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.d3__caption}>빛줄기 여러 겹을 그리고 각 겹의 불투명도를 거리값에 지수함수로 곱해 줄여, 가까운 겹은 선명하고 먼 겹은 배경에 거의 스며든다</p>
</div>
</main>
);
}06 · 層ごとに薄くなる光の筋の背景 — 計測 累積面積 30.528% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function depthFor(shaft, t) {
return shaft.distanceBase + Math.sin(t * shaft.swaySpeed + shaft.phase) * shaft.swayRange;
}
function shaftOpacityForDepth(depth) {
var v = Math.exp(-LIGHT_SHAFT_DECAY * depth) * MAX_OPACITY;
return Math.max(0, Math.min(MAX_OPACITY, v));
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(t) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = 'rgba(60,40,120,.55)';
ctx.beginPath();
ctx.arc(SOURCE_X * sx, SOURCE_Y * sy, 10 * sx, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < shafts.length; i += 1) {
項目06は面積30.5%・強度平均21.6で、23/23 フレームが動きました。
07高さに応じて影が伸びる浮遊カード
cardBobAt() は CARD_FLOAT_AMPLITUDE_PX 22を上限にしたサイン波で各カードを上下させ、shadowForHeight() はその高さをぼかし半径・不透明度・横方向の影の伸びに変換します。カードが浮き上がった瞬間は影が広く薄くなり、沈んだ瞬間は同じ式のまま影が狭く濃くなります。

<main class="stage">
<div class="d3 is-demo" id="d3Root" tabindex="0">
<canvas class="d3__canvas" id="d3Canvas" width="480" height="300"></canvas>
<p class="d3__caption">カードごとの浮遊高さの値を影のぼかし半径と薄さに変換し、高く浮くカードほど影は大きく薄く、低く浮くカードは影が濃く小さくなる</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.d3 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-orange;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: #fff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.d3 { cursor: default; }
}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);
}
.d3 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ff4d1f;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.d3 {
cursor: default;
}
}(function () {
// 기법: cardBobAt 이 카드마다 CARD_FLOAT_AMPLITUDE_PX 범위에서 뜬 높이값을 매 프레임 계산하고,
// shadowForHeight 가 그 높이값을 그림자의 흐림 반경·불투명도로 변환한다 — 높이 뜬 카드는 그림자
// 반경이 커지고 옅어지며, 낮게 뜬 카드는 반경이 작고 진해진다(속성 트윈이 아니라 매핑 계산).
var root = document.getElementById('d3Root');
var canvas = document.getElementById('d3Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var CARD_FLOAT_AMPLITUDE_PX = 22;
var CARD_COUNT = 6;
var BASELINE_Y = CALC_H * 0.72;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rnd = seedRand(429071);
var palette = ['#6ec8ff', '#b9a0ff', '#ff9ebb', '#4cd4a6', '#ffd23f', '#ffffff'];
var cards = [];
for (var i = 0; i < CARD_COUNT; i += 1) {
cards.push({
x: 18 + (CALC_W - 36) * (i / (CARD_COUNT - 1)),
w: 16, h: 22,
bobSpeed: 0.0011 + rnd() * 0.0009,
phase: rnd() * Math.PI * 2,
color: palette[i % palette.length]
});
}
function cardBobAt(card, t) {
return (Math.sin(t * card.bobSpeed + card.phase) * 0.5 + 0.5) * CARD_FLOAT_AMPLITUDE_PX;
}
function shadowForHeight(h) {
var norm = h / CARD_FLOAT_AMPLITUDE_PX;
return {
blur: 3 + norm * 13,
alpha: 0.4 - norm * 0.26,
scaleX: 1 + norm * 0.35
};
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(t) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < cards.length; i += 1) {
var card = cards[i];
var h = cardBobAt(card, t);
var sh = shadowForHeight(h);
ctx.save();
ctx.filter = 'blur(' + (sh.blur * sx) + 'px)';
ctx.fillStyle = 'rgba(20,16,24,' + sh.alpha + ')';
ctx.beginPath();
ctx.ellipse(card.x * sx, BASELINE_Y * sy, card.w * sh.scaleX * 0.5 * sx, card.h * 0.16 * sy, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
var cardY = BASELINE_Y - card.h * 0.5 - h;
ctx.fillStyle = card.color;
var rx = card.x * sx - (card.w * 0.5) * sx;
var ry = cardY * sy - (card.h * 0.5) * sy;
var rw = card.w * sx, rh = card.h * sy;
var rad = 4 * sx;
ctx.beginPath();
ctx.moveTo(rx + rad, ry);
ctx.arcTo(rx + rw, ry, rx + rw, ry + rh, rad);
ctx.arcTo(rx + rw, ry + rh, rx, ry + rh, rad);
ctx.arcTo(rx, ry + rh, rx, ry, rad);
ctx.arcTo(rx, ry, rx + rw, ry, rad);
ctx.closePath();
ctx.fill();
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawScene(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 0 : 90;
for (var w = 0; w < warmSteps; w += 1) elapsed += 20;
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawScene(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./Depth3dCardFloatShadowDepth.module.scss";
type Card = {
x: number;
w: number;
h: number;
bobSpeed: number;
phase: number;
color: string;
};
export default function Depth3dCardFloatShadowDepth() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const CARD_FLOAT_AMPLITUDE_PX = 22;
const CARD_COUNT = 6;
const BASELINE_Y = CALC_H * 0.72;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(429071);
const palette = ["#6ec8ff", "#b9a0ff", "#ff9ebb", "#4cd4a6", "#ffd23f", "#ffffff"];
const cards: Card[] = [];
for (let i = 0; i < CARD_COUNT; i += 1) {
cards.push({
x: 18 + (CALC_W - 36) * (i / (CARD_COUNT - 1)),
w: 16, h: 22,
bobSpeed: 0.0011 + rnd() * 0.0009,
phase: rnd() * Math.PI * 2,
color: palette[i % palette.length]
});
}
function cardBobAt(card: Card, t: number) {
return (Math.sin(t * card.bobSpeed + card.phase) * 0.5 + 0.5) * CARD_FLOAT_AMPLITUDE_PX;
}
function shadowForHeight(h: number) {
const norm = h / CARD_FLOAT_AMPLITUDE_PX;
return {
blur: 3 + norm * 13,
alpha: 0.4 - norm * 0.26,
scaleX: 1 + norm * 0.35
};
}
let elapsed = 0;
let cw = 480, ch = 300;
function drawScene(t: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < cards.length; i += 1) {
const card = cards[i];
const h = cardBobAt(card, t);
const sh = shadowForHeight(h);
ctx!.save();
ctx!.filter = `blur(${sh.blur * sx}px)`;
ctx!.fillStyle = `rgba(20,16,24,${sh.alpha})`;
ctx!.beginPath();
ctx!.ellipse(card.x * sx, BASELINE_Y * sy, card.w * sh.scaleX * 0.5 * sx, card.h * 0.16 * sy, 0, 0, Math.PI * 2);
ctx!.fill();
ctx!.restore();
const cardY = BASELINE_Y - card.h * 0.5 - h;
ctx!.fillStyle = card.color;
const rx = card.x * sx - (card.w * 0.5) * sx;
const ry = cardY * sy - (card.h * 0.5) * sy;
const rw = card.w * sx, rh = card.h * sy;
const rad = 4 * sx;
ctx!.beginPath();
ctx!.moveTo(rx + rad, ry);
ctx!.arcTo(rx + rw, ry, rx + rw, ry + rh, rad);
ctx!.arcTo(rx + rw, ry + rh, rx, ry + rh, rad);
ctx!.arcTo(rx, ry + rh, rx, ry, rad);
ctx!.arcTo(rx, ry, rx + rw, ry, rad);
ctx!.closePath();
ctx!.fill();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawScene(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 0 : 90;
for (let w = 0; w < warmSteps; w += 1) elapsed += 20;
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawScene(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.d3} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.d3__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.d3__caption}>카드마다 떠 있는 높이값을 그림자의 흐림 반경과 옅기로 변환해, 높이 뜬 카드일수록 그림자가 크고 연해지고 낮게 뜬 카드는 그림자가 진하고 작다</p>
</div>
</main>
);
}07 · 高さに応じて影が大きくなる浮遊カード — 計測 累積面積 21.405% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function cardBobAt(card, t) {
return (Math.sin(t * card.bobSpeed + card.phase) * 0.5 + 0.5) * CARD_FLOAT_AMPLITUDE_PX;
}
function shadowForHeight(h) {
var norm = h / CARD_FLOAT_AMPLITUDE_PX;
return {
blur: 3 + norm * 13,
alpha: 0.4 - norm * 0.26,
scaleX: 1 + norm * 0.35
};
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(t) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < cards.length; i += 1) {
var card = cards[i];
var h = cardBobAt(card, t);
項目07は面積21.4%・強度平均129.0で、23/23 フレームが動きました — 9項目の中でもっとも高い平均強度です。
08距離をシミュレートして拡大縮小するパーティクル背景
zAt() は各パーティクルの奥行きを自分専用の zAdvanceSpeed() で手前へ進め、PARTICLE_Z_MAX 120を超えると奥の面へ巻き戻します。sizeForDepth() は固定の FOCAL 60をその時点の奥行きで割って画面上の半径に変換するため、手前にあるパーティクルほど大きく描かれます。

<main class="stage">
<div class="d3 is-demo" id="d3Root" tabindex="0">
<canvas class="d3__canvas" id="d3Canvas" width="480" height="300"></canvas>
<p class="d3__caption">粒子ごとのz値を時間とともに少しずつ変化させ、その値を毎フレーム画面上のサイズに換算し直すことで、近づく粒子は徐々に大きく、遠ざかる粒子は徐々に小さくなる</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.d3 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-paper;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .78);
color: #fff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.d3 { cursor: default; }
}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);
}
.d3 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ffffff;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.d3 {
cursor: default;
}
}(function () {
// 기법: 입자마다 zAdvanceSpeed 로 z값이 PARTICLE_Z_MAX 에서 0 쪽으로 시간에 따라 줄어들다가
// 나머지 연산으로 다시 멀리 순환하고, sizeForDepth 가 그 z값을 매 프레임 화면 크기·화면 좌표로
// 다시 환산한다 — 가까워질수록(z 작을수록) 입자가 커지고 화면 중심에서 바깥으로 퍼진다.
var root = document.getElementById('d3Root');
var canvas = document.getElementById('d3Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var CX = CALC_W * 0.5, CY = CALC_H * 0.5;
var PARTICLE_Z_MAX = 120;
var PARTICLE_COUNT = 34;
var FOCAL = 60;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rnd = seedRand(429081);
var particles = [];
for (var i = 0; i < PARTICLE_COUNT; i += 1) {
particles.push({
worldX: (rnd() - 0.5) * 140,
worldY: (rnd() - 0.5) * 90,
zOffset: rnd() * PARTICLE_Z_MAX,
speedScale: 0.6 + rnd() * 0.8,
color: i % 3 === 0 ? '#6ec8ff' : (i % 3 === 1 ? '#b9a0ff' : '#4cd4a6')
});
}
function zAdvanceSpeed(p) {
return 0.012 * p.speedScale;
}
function zAt(p, t) {
var raw = (p.zOffset - t * zAdvanceSpeed(p)) % PARTICLE_Z_MAX;
if (raw < 0) raw += PARTICLE_Z_MAX;
return raw + 1;
}
function sizeForDepth(z) {
var v = (FOCAL / z) * 3.4;
return Math.max(0.8, Math.min(7, v));
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(t) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
var z = zAt(p, t);
var scale = FOCAL / z;
var sizePx = sizeForDepth(z);
var alpha = Math.max(0.18, Math.min(1, scale * 0.55));
var dx = (CX + p.worldX * scale * 0.34) * sx;
var dy = (CY + p.worldY * scale * 0.34) * sy;
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(dx, dy, sizePx * sx, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawScene(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 0 : 130;
for (var w = 0; w < warmSteps; w += 1) elapsed += 20;
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawScene(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./Depth3dScaleDepthSizing.module.scss";
type Particle = {
worldX: number;
worldY: number;
zOffset: number;
speedScale: number;
color: string;
};
export default function Depth3dScaleDepthSizing() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const CX = CALC_W * 0.5, CY = CALC_H * 0.5;
const PARTICLE_Z_MAX = 120;
const PARTICLE_COUNT = 34;
const FOCAL = 60;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(429081);
const particles: Particle[] = [];
for (let i = 0; i < PARTICLE_COUNT; i += 1) {
particles.push({
worldX: (rnd() - 0.5) * 140,
worldY: (rnd() - 0.5) * 90,
zOffset: rnd() * PARTICLE_Z_MAX,
speedScale: 0.6 + rnd() * 0.8,
color: i % 3 === 0 ? "#6ec8ff" : (i % 3 === 1 ? "#b9a0ff" : "#4cd4a6")
});
}
function zAdvanceSpeed(p: Particle) {
return 0.012 * p.speedScale;
}
function zAt(p: Particle, t: number) {
let raw = (p.zOffset - t * zAdvanceSpeed(p)) % PARTICLE_Z_MAX;
if (raw < 0) raw += PARTICLE_Z_MAX;
return raw + 1;
}
function sizeForDepth(z: number) {
const v = (FOCAL / z) * 3.4;
return Math.max(0.8, Math.min(7, v));
}
let elapsed = 0;
let cw = 480, ch = 300;
function drawScene(t: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
const z = zAt(p, t);
const scale = FOCAL / z;
const sizePx = sizeForDepth(z);
const alpha = Math.max(0.18, Math.min(1, scale * 0.55));
const dx = (CX + p.worldX * scale * 0.34) * sx;
const dy = (CY + p.worldY * scale * 0.34) * sy;
ctx!.globalAlpha = alpha;
ctx!.fillStyle = p.color;
ctx!.beginPath();
ctx!.arc(dx, dy, sizePx * sx, 0, Math.PI * 2);
ctx!.fill();
}
ctx!.globalAlpha = 1;
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawScene(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 0 : 130;
for (let w = 0; w < warmSteps; w += 1) elapsed += 20;
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
drawScene(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawScene(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.d3} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.d3__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.d3__caption}>입자마다 z값을 시간에 따라 조금씩 바꾸고 그 값을 화면 크기로 매 프레임 다시 환산해, 다가오는 입자는 점점 커지고 멀어지는 입자는 점점 작아진다</p>
</div>
</main>
);
}08 · 距離に応じて実時間で拡大縮小する粒子背景 — 計測 累積面積 30.615% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function zAdvanceSpeed(p) {
return 0.012 * p.speedScale;
}
function zAt(p, t) {
var raw = (p.zOffset - t * zAdvanceSpeed(p)) % PARTICLE_Z_MAX;
if (raw < 0) raw += PARTICLE_Z_MAX;
return raw + 1;
}
function sizeForDepth(z) {
var v = (FOCAL / z) * 3.4;
return Math.max(0.8, Math.min(7, v));
}
var elapsed = 0;
var cw = 480, ch = 300;
function drawScene(t) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < particles.length; i += 1) {
項目08は面積30.6%・強度平均94.4で、23/23 フレームが動きました。
09奥行き層のすべての技法をひとつにまとめたヒーロー背景シーン
ソース内のコメントには、同じポインター入力の上で6つの技法を1つのシーンに重ねると明記されています — 項目01の視差と項目04の遠近グリッドは、それぞれ独自の distanceMultiplier() と projectGridPoint() としてこのファイル内だけで再定義され、HERO_ を冠した3つの定数が項目02のような奥行きぼかしと項目05のようなポインター傾きを、それぞれの視差の届きとともにスケールします。残る2つ — 項目07の高さに応じた影の不透明度と項目08の奥行きに応じた拡大縮小 — も shadowForHeight() や sizeForDepth() を呼ぶのではなく、それぞれ独自のインライン計算として書き直されており、6つすべてが他の8ファイルから読み込まれるのではなくこの1ファイルの中だけで再現されています。

<main class="stage">
<div class="d3 is-demo" id="d3Root" tabindex="0">
<canvas class="d3__canvas" id="d3Canvas" width="480" height="300"></canvas>
<p class="d3__caption">視差・ぼかし・遠近グリッド・軌道・影・サイズ変化を同じ背景上で同時に計算し、ポインターが動くと複数の層が一体となって反応する</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.d3 {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 78%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.d3 { cursor: default; }
}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);
}
.d3 {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
outline: none;
}
.d3__canvas {
display: block;
width: 100%;
height: 100%;
}
.d3__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 78%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.d3__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.d3 {
cursor: default;
}
}(function () {
// 기법: 01(시차)·02(흐림)·04(원근 격자)·05(궤도 z정렬)·07(높이→그림자)·08(z→크기 환산) 의 계산을
// 한 좌표계 위에서 같은 포인터 입력(nx, ny)으로 동시에 구동한다. 격자는 HERO_PARALLAX_REF_DISTANCE_PX
// 로 시차 배수를, 도형은 HERO_BLUR_MAX_PX 로 흐림을, 궤도는 HERO_ORBIT_TILT_MAX_DEG 로 기울기를 받는다.
var root = document.getElementById('d3Root');
var canvas = document.getElementById('d3Canvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 160, CALC_H = 100;
var HORIZON_Y = CALC_H * 0.36;
var HERO_PARALLAX_REF_DISTANCE_PX = 200;
var HERO_BLUR_MAX_PX = 10;
var HERO_ORBIT_TILT_MAX_DEG = 16;
var camHeight = 40;
var GRID_PERSPECTIVE_PX = 760;
var ROW_SPACING = 16, ROW_COUNT = 7, COL_SPACING = 15, COL_COUNT = 9;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rnd = seedRand(429091);
var RING_RADIUS = 26, SAT_COUNT = 6, ANGULAR_SPEED = 0.00058;
var satellites = [];
for (var i = 0; i < SAT_COUNT; i += 1) {
satellites.push({
baseAngle: (Math.PI * 2 / SAT_COUNT) * i + rnd() * 0.3,
radiusScale: 0.75 + rnd() * 0.28,
size: 2.6 + rnd() * 2.2,
color: ['#6ec8ff', '#b9a0ff', '#ff9ebb', '#4cd4a6'][i % 4]
});
}
var SHAPE_COUNT = 6;
var shapes = [];
for (var j = 0; j < SHAPE_COUNT; j += 1) {
shapes.push({
baseX: 22 + (CALC_W - 44) * (j / (SHAPE_COUNT - 1)),
baseY: CALC_H * 0.68 + (rnd() - 0.5) * 14,
depthNorm: (j % 3) / 2,
size: 6 + rnd() * 4,
bobSpeed: 0.001 + rnd() * 0.0007,
bobPhase: rnd() * Math.PI * 2,
zSpeed: 0.0012 + rnd() * 0.0008,
zPhase: rnd() * Math.PI * 2,
color: ['#ffd23f', '#ff9ebb', '#6ec8ff', '#4cd4a6', '#b9a0ff', '#ffffff'][j]
});
}
var nx = 0, ny = 0;
var offsetCur = 0, offsetCurY = 0;
var INERTIA_LERP_RATE = 5;
function distanceMultiplier(distancePx) {
var m = HERO_PARALLAX_REF_DISTANCE_PX / Math.max(20, distancePx);
return Math.max(0.2, Math.min(2.6, m));
}
function projectGridPoint(xWorld, zWorld, viewOffsetX) {
var z = Math.max(4, zWorld);
var scale = GRID_PERSPECTIVE_PX / (z * 40);
return { x: CALC_W * 0.5 + (xWorld - viewOffsetX) * scale, y: HORIZON_Y + camHeight * scale };
}
var elapsed = 0, scrollZ = 0;
var cw = 480, ch = 300;
function drawScene(t, dtS) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var viewOffsetX = nx * 42;
var tiltDeg = ny * HERO_ORBIT_TILT_MAX_DEG;
var azimuthOffset = nx * 0.7;
var targetOffsetX = nx * 14, targetOffsetY = ny * 9;
var rate = Math.min(1, INERTIA_LERP_RATE * dtS);
offsetCur += (targetOffsetX - offsetCur) * rate;
offsetCurY += (targetOffsetY - offsetCurY) * rate;
var pointerDistancePx = Math.max(20, Math.hypot(offsetCur, offsetCurY) * 6 + 60);
var mult = distanceMultiplier(pointerDistancePx);
ctx.clearRect(0, 0, cw, ch);
ctx.strokeStyle = 'rgba(140,150,230,.5)';
ctx.lineWidth = 1;
for (var r = 1; r <= ROW_COUNT; r += 1) {
var z = r * ROW_SPACING - scrollZ;
if (z <= 2) continue;
var pL = projectGridPoint(-COL_COUNT * COL_SPACING * 0.5, z, viewOffsetX);
var pR = projectGridPoint(COL_COUNT * COL_SPACING * 0.5, z, viewOffsetX);
ctx.beginPath();
ctx.moveTo(pL.x * sx, pL.y * sy);
ctx.lineTo(pR.x * sx, pR.y * sy);
ctx.stroke();
}
var withZ = satellites.map(function (sat) {
var ang = sat.baseAngle + t * ANGULAR_SPEED + azimuthOffset;
var rr = RING_RADIUS * sat.radiusScale;
var lx = Math.cos(ang) * rr, lz = Math.sin(ang) * rr;
var tiltRad = tiltDeg * Math.PI / 180;
var ry = -lz * Math.sin(tiltRad), rz = lz * Math.cos(tiltRad);
return { sat: sat, x: lx, y: ry, z: rz };
});
withZ.sort(function (a, b) { return a.z - b.z; });
for (var k = 0; k < withZ.length; k += 1) {
var p = withZ[k];
var perspScale = Math.max(0.55, Math.min(1.5, 1 + p.z / RING_RADIUS * 0.45));
var alpha = Math.max(0.4, Math.min(1, 0.6 + p.z / RING_RADIUS * 0.4));
var cx2 = (CALC_W * 0.5 + p.x) * sx, cy2 = (CALC_H * 0.42 + p.y) * sy;
ctx.globalAlpha = alpha;
ctx.fillStyle = p.sat.color;
ctx.beginPath();
ctx.arc(cx2, cy2, p.sat.size * perspScale * sx, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
for (var m2 = 0; m2 < shapes.length; m2 += 1) {
var shape = shapes[m2];
var blurPx = HERO_BLUR_MAX_PX * shape.depthNorm;
var bobH = (Math.sin(t * shape.bobSpeed + shape.bobPhase) * 0.5 + 0.5) * 16;
var zVar = 1 + Math.sin(t * shape.zSpeed + shape.zPhase) * 0.22;
var shX = (shape.baseX + offsetCur * (1 - shape.depthNorm) * mult) * sx;
var shY = (shape.baseY - bobH + offsetCurY * (1 - shape.depthNorm) * mult) * sy;
ctx.save();
ctx.filter = 'blur(' + (2 * sx) + 'px)';
ctx.fillStyle = 'rgba(20,16,24,' + (0.32 - shape.depthNorm * 0.12) + ')';
ctx.beginPath();
ctx.ellipse(shX, (shape.baseY + 10) * sy, shape.size * 0.7 * sx, shape.size * 0.22 * sy, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.save();
ctx.filter = 'blur(' + (blurPx * sx) + 'px)';
ctx.fillStyle = shape.color;
ctx.beginPath();
ctx.arc(shX, shY, shape.size * zVar * sx, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
drawScene(elapsed, 0.016);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var pointerActive = false;
wrap.addEventListener('pointermove', function (e) {
pointerActive = true;
var r = wrap.getBoundingClientRect();
nx = ((e.clientX - r.left) / r.width - 0.5) * 2;
ny = ((e.clientY - r.top) / r.height - 0.5) * 2;
if (reduce) drawScene(elapsed, 0.016);
});
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
wrap.addEventListener('keydown', function (e) {
var moved = true;
if (e.key === 'ArrowLeft') nx = Math.max(-1, nx - 0.12);
else if (e.key === 'ArrowRight') nx = Math.min(1, nx + 0.12);
else if (e.key === 'ArrowUp') ny = Math.max(-1, ny - 0.12);
else if (e.key === 'ArrowDown') ny = Math.min(1, ny + 0.12);
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) drawScene(elapsed, 0.016);
}
});
function virtualPointerAt(t) {
return { nx: Math.sin(t * 0.00046) * 0.85, ny: Math.cos(t * 0.00033) * 0.7 };
}
var warmSteps = reduce ? 0 : 110;
for (var w = 0; w < warmSteps; w += 1) {
elapsed += 20;
scrollZ = (scrollZ + 18 * 0.02) % ROW_SPACING;
if (!pointerActive) { var vp = virtualPointerAt(elapsed); nx = vp.nx; ny = vp.ny; }
}
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
scrollZ = (scrollZ + 18 * (dt / 1000)) % ROW_SPACING;
if (!pointerActive) { var vp = virtualPointerAt(elapsed); nx = vp.nx; ny = vp.ny; }
drawScene(elapsed, dt / 1000);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
drawScene(elapsed, 0.016);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./Depth3dHeroSceneComposite.module.scss";
type Satellite = { baseAngle: number; radiusScale: number; size: number; color: string };
type Shape = {
baseX: number; baseY: number; depthNorm: number; size: number;
bobSpeed: number; bobPhase: number; zSpeed: number; zPhase: number; color: string;
};
export default function Depth3dHeroSceneComposite() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const CALC_W = 160, CALC_H = 100;
const HORIZON_Y = CALC_H * 0.36;
const HERO_PARALLAX_REF_DISTANCE_PX = 200;
const HERO_BLUR_MAX_PX = 10;
const HERO_ORBIT_TILT_MAX_DEG = 16;
const camHeight = 40;
const GRID_PERSPECTIVE_PX = 760;
const ROW_SPACING = 16, ROW_COUNT = 7, COL_SPACING = 15, COL_COUNT = 9;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rnd = seedRand(429091);
const RING_RADIUS = 26, SAT_COUNT = 6, ANGULAR_SPEED = 0.00058;
const satellites: Satellite[] = [];
for (let i = 0; i < SAT_COUNT; i += 1) {
satellites.push({
baseAngle: (Math.PI * 2 / SAT_COUNT) * i + rnd() * 0.3,
radiusScale: 0.75 + rnd() * 0.28,
size: 2.6 + rnd() * 2.2,
color: ["#6ec8ff", "#b9a0ff", "#ff9ebb", "#4cd4a6"][i % 4]
});
}
const SHAPE_COUNT = 6;
const shapes: Shape[] = [];
for (let j = 0; j < SHAPE_COUNT; j += 1) {
shapes.push({
baseX: 22 + (CALC_W - 44) * (j / (SHAPE_COUNT - 1)),
baseY: CALC_H * 0.68 + (rnd() - 0.5) * 14,
depthNorm: (j % 3) / 2,
size: 6 + rnd() * 4,
bobSpeed: 0.001 + rnd() * 0.0007,
bobPhase: rnd() * Math.PI * 2,
zSpeed: 0.0012 + rnd() * 0.0008,
zPhase: rnd() * Math.PI * 2,
color: ["#ffd23f", "#ff9ebb", "#6ec8ff", "#4cd4a6", "#b9a0ff", "#ffffff"][j]
});
}
let nx = 0, ny = 0;
let offsetCur = 0, offsetCurY = 0;
const INERTIA_LERP_RATE = 5;
function distanceMultiplier(distancePx: number) {
const m = HERO_PARALLAX_REF_DISTANCE_PX / Math.max(20, distancePx);
return Math.max(0.2, Math.min(2.6, m));
}
function projectGridPoint(xWorld: number, zWorld: number, viewOffsetX: number) {
const z = Math.max(4, zWorld);
const scale = GRID_PERSPECTIVE_PX / (z * 40);
return { x: CALC_W * 0.5 + (xWorld - viewOffsetX) * scale, y: HORIZON_Y + camHeight * scale };
}
let elapsed = 0, scrollZ = 0;
let cw = 480, ch = 300;
function drawScene(t: number, dtS: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
const viewOffsetX = nx * 42;
const tiltDeg = ny * HERO_ORBIT_TILT_MAX_DEG;
const azimuthOffset = nx * 0.7;
const targetOffsetX = nx * 14, targetOffsetY = ny * 9;
const rate = Math.min(1, INERTIA_LERP_RATE * dtS);
offsetCur += (targetOffsetX - offsetCur) * rate;
offsetCurY += (targetOffsetY - offsetCurY) * rate;
const pointerDistancePx = Math.max(20, Math.hypot(offsetCur, offsetCurY) * 6 + 60);
const mult = distanceMultiplier(pointerDistancePx);
ctx!.clearRect(0, 0, cw, ch);
ctx!.strokeStyle = "rgba(140,150,230,.5)";
ctx!.lineWidth = 1;
for (let r = 1; r <= ROW_COUNT; r += 1) {
const z = r * ROW_SPACING - scrollZ;
if (z <= 2) continue;
const pL = projectGridPoint(-COL_COUNT * COL_SPACING * 0.5, z, viewOffsetX);
const pR = projectGridPoint(COL_COUNT * COL_SPACING * 0.5, z, viewOffsetX);
ctx!.beginPath();
ctx!.moveTo(pL.x * sx, pL.y * sy);
ctx!.lineTo(pR.x * sx, pR.y * sy);
ctx!.stroke();
}
const withZ = satellites.map((sat) => {
const ang = sat.baseAngle + t * ANGULAR_SPEED + azimuthOffset;
const rr = RING_RADIUS * sat.radiusScale;
const lx = Math.cos(ang) * rr, lz = Math.sin(ang) * rr;
const tiltRad = tiltDeg * Math.PI / 180;
const ry = -lz * Math.sin(tiltRad), rz = lz * Math.cos(tiltRad);
return { sat, x: lx, y: ry, z: rz };
});
withZ.sort((a, b) => a.z - b.z);
for (let k = 0; k < withZ.length; k += 1) {
const p = withZ[k];
const perspScale = Math.max(0.55, Math.min(1.5, 1 + p.z / RING_RADIUS * 0.45));
const alpha = Math.max(0.4, Math.min(1, 0.6 + p.z / RING_RADIUS * 0.4));
const cx2 = (CALC_W * 0.5 + p.x) * sx, cy2 = (CALC_H * 0.42 + p.y) * sy;
ctx!.globalAlpha = alpha;
ctx!.fillStyle = p.sat.color;
ctx!.beginPath();
ctx!.arc(cx2, cy2, p.sat.size * perspScale * sx, 0, Math.PI * 2);
ctx!.fill();
}
ctx!.globalAlpha = 1;
for (let m2 = 0; m2 < shapes.length; m2 += 1) {
const shape = shapes[m2];
const blurPx = HERO_BLUR_MAX_PX * shape.depthNorm;
const bobH = (Math.sin(t * shape.bobSpeed + shape.bobPhase) * 0.5 + 0.5) * 16;
const zVar = 1 + Math.sin(t * shape.zSpeed + shape.zPhase) * 0.22;
const shX = (shape.baseX + offsetCur * (1 - shape.depthNorm) * mult) * sx;
const shY = (shape.baseY - bobH + offsetCurY * (1 - shape.depthNorm) * mult) * sy;
ctx!.save();
ctx!.filter = `blur(${2 * sx}px)`;
ctx!.fillStyle = `rgba(20,16,24,${0.32 - shape.depthNorm * 0.12})`;
ctx!.beginPath();
ctx!.ellipse(shX, (shape.baseY + 10) * sy, shape.size * 0.7 * sx, shape.size * 0.22 * sy, 0, 0, Math.PI * 2);
ctx!.fill();
ctx!.restore();
ctx!.save();
ctx!.filter = `blur(${blurPx * sx}px)`;
ctx!.fillStyle = shape.color;
ctx!.beginPath();
ctx!.arc(shX, shY, shape.size * zVar * sx, 0, Math.PI * 2);
ctx!.fill();
ctx!.restore();
}
}
function fit() {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
drawScene(elapsed, 0.016);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
let pointerActive = false;
function onPointerMove(e: PointerEvent) {
pointerActive = true;
const r = wrap.getBoundingClientRect();
nx = ((e.clientX - r.left) / r.width - 0.5) * 2;
ny = ((e.clientY - r.top) / r.height - 0.5) * 2;
if (reduce) drawScene(elapsed, 0.016);
}
function onPointerLeave() { pointerActive = false; }
function onKeydown(e: KeyboardEvent) {
let moved = true;
if (e.key === "ArrowLeft") nx = Math.max(-1, nx - 0.12);
else if (e.key === "ArrowRight") nx = Math.min(1, nx + 0.12);
else if (e.key === "ArrowUp") ny = Math.max(-1, ny - 0.12);
else if (e.key === "ArrowDown") ny = Math.min(1, ny + 0.12);
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
if (reduce) drawScene(elapsed, 0.016);
}
}
wrap.addEventListener("pointermove", onPointerMove);
wrap.addEventListener("pointerleave", onPointerLeave);
wrap.addEventListener("keydown", onKeydown);
function virtualPointerAt(t: number) {
return { nx: Math.sin(t * 0.00046) * 0.85, ny: Math.cos(t * 0.00033) * 0.7 };
}
const warmSteps = reduce ? 0 : 110;
for (let w = 0; w < warmSteps; w += 1) {
elapsed += 20;
scrollZ = (scrollZ + 18 * 0.02) % ROW_SPACING;
if (!pointerActive) { const vp = virtualPointerAt(elapsed); nx = vp.nx; ny = vp.ny; }
}
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
elapsed += dt;
scrollZ = (scrollZ + 18 * (dt / 1000)) % ROW_SPACING;
if (!pointerActive) { const vp = virtualPointerAt(elapsed); nx = vp.nx; ny = vp.ny; }
drawScene(elapsed, dt / 1000);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
drawScene(elapsed, 0.016);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
wrap.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.d3} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.d3__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.d3__caption}>시차·흐림·원근 격자·궤도·그림자·크기 변화를 같은 배경 위에서 동시에 계산해, 포인터가 움직이면 여러 층이 한 몸처럼 함께 반응한다</p>
</div>
</main>
);
}09 · 複数の奥行き層を一画面にまとめたヒーロー背景シーン — 計測 累積面積 23.106% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function drawScene(t, dtS) {
var sx = cw / CALC_W, sy = ch / CALC_H;
var viewOffsetX = nx * 42;
var tiltDeg = ny * HERO_ORBIT_TILT_MAX_DEG;
var azimuthOffset = nx * 0.7;
var targetOffsetX = nx * 14, targetOffsetY = ny * 9;
var rate = Math.min(1, INERTIA_LERP_RATE * dtS);
offsetCur += (targetOffsetX - offsetCur) * rate;
offsetCurY += (targetOffsetY - offsetCurY) * rate;
var pointerDistancePx = Math.max(20, Math.hypot(offsetCur, offsetCurY) * 6 + 60);
var mult = distanceMultiplier(pointerDistancePx);
ctx.clearRect(0, 0, cw, ch);
ctx.strokeStyle = 'rgba(140,150,230,.5)';
ctx.lineWidth = 1;
for (var r = 1; r <= ROW_COUNT; r += 1) {
var z = r * ROW_SPACING - scrollZ;
if (z <= 2) continue;
var pL = projectGridPoint(-COL_COUNT * COL_SPACING * 0.5, z, viewOffsetX);
var pR = projectGridPoint(COL_COUNT * COL_SPACING * 0.5, z, viewOffsetX);
ctx.beginPath();
項目09は面積23.1%・強度平均59.8で、23/23 フレームが動きました。
奥行き層の種類別コード
この9つはすべて CALC_W/CALC_H 160×100という同じ計算空間を共有し、fit() がキャンバス要素の実測サイズに合わせて拡大縮小するため、奥行きの計算自体はページの実ピクセルサイズを一切気にしません。prefers-reduced-motion は9つすべてで matchMedia を通じて読み取られ、常時オンの自動再生フラグを残すのではなく、読み込み時に一度だけ自前の .is-demo クラスを外します。9項目のうち5つ — 項目01・02・04・05・09 — は keydown も監視していて、マウスが動かすのと同じポインター駆動の値をキーボードだけで動かせるため、css 3d animation codepen としてコピーしてもキーボード操作だけで動きます。9項目のうち8つはローカルの seedRand() で乱数を決めていますが、項目04だけは例外で、グリッドの行と水平線が固定位置から始まるため乱数の初期値を必要としません。この9つはどれも css 背景画像 パララックス の考え方を、静止画像ではなくキャンバス計算に置き換えたものです。
項目03の重なり合うグラデーションブロブはどの層よりも広い面積を塗り替え、項目07の影付きカードはもっとも高い平均強度で動き、項目02のゆっくりしたピント移動はもっとも低い強度で、15/23 フレームしか動かなかった唯一の項目です。
| 層 | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 図形の視差 | 20.6% | 61.1 | 23/23 |
| 02 ボケの遠近ぼかし | 17.0% | 15.1 | 15/23 |
| 03 グラデーションメッシュの漂い | 52.5% | 20.9 | 23/23 |
| 04 遠近グリッド床 | 23.4% | 39.8 | 23/23 |
| 05 軌道リングの傾き | 10.2% | 106.0 | 23/23 |
| 06 ライトシャフトの減衰 | 30.5% | 21.6 | 23/23 |
| 07 浮遊カードの影 | 21.4% | 129.0 | 23/23 |
| 08 パーティクルの奥行き拡縮 | 30.6% | 94.4 | 23/23 |
| 09 ヒーロー複合シーン | 23.1% | 59.8 | 23/23 |
値が奥行きを変える場所 — 距離・ぼかし・影・傾き
下の3つの定数がほとんどの奥行き感を決めています。DEPTH_BLUR_MAX_PX は項目02のもっとも遠い円がどこまでぼやけられるかを、GRID_PERSPECTIVE_PX は項目04の床グリッドが水平線へどれだけ鋭く収束するかを、ORBIT_TILT_MAX_DEG は項目05の衛星リングが平らな状態からどれだけ傾けるかを決めます。3つとも描かれるもの自体は変えず、同じ背景がどれだけ立体的な css パララックス 背景 に見えるかだけを変えるという点が、横に動くだけのアニメーションと奥行き層のあるシーンの違いのほとんどを占めています。css ボタン 立体感 9種 — クリックまで再現も背景層全体ではなく1つのホバーボタンの上で、似た傾きの定数を調整しています。
どこで壊れるか — 動作を減らす設定・狭い画面・層が多いとき
この9つすべてで matchMedia を使って prefers-reduced-motion を読み取り、キャンバス自体を止めるのではなく読み込み時に自動再生用の .is-demo クラスを外しています。そのため項目05は実際にポインターが動いたときだけリングが傾き、項目09も実際にポインターを動かしたときだけシーンが動きます。fit() は160×100の計算空間をキャンバス要素の実測サイズに合わせて縮小するため、狭いスマートフォン幅では奥行きの計算自体は変えずに各層の見た目のサイズだけが小さくなります。低スペック端末で特に注意したいのは項目03で、MESH_LAYER_COUNT 5層のグラデーションブロブがそれぞれ globalCompositeOperation を lighter にして独自の createRadialGradient() を描くため、すでに面積52.5%を塗り替えており、同じ方法で層を増やせば重力や描画よりも先にこの組み合わせでフレームが落ちやすくなります。これら9つのビルドの裏にある index.html/style.scss のペア(gt5gztk5)は、これだけ少ない css パララックス 背景 のコードで9つ同時に動かせることを確かめる一番早い方法です。
変形3つとアクセシビリティ
| バリエーション | 値の変更 | 感触 |
|---|---|---|
| 最大ぼかし半径を調整する | 02 DEPTH_BLUR_MAX_PX 14 → 28 |
値を下げるともっとも遠い層でもわずかにしかぼやけず場面が平坦に見え、上げると遠い層が強くぼやけて奥行きの差がはっきりします。 |
| 遠近の強さを調整する | 04 GRID_PERSPECTIVE_PX 800 → 400 |
値を下げると床グリッドが鋭く収束して遠近感が強調され、上げると収束がゆるんで場面が平らな背景のように見えます。 |
| 最大軌道傾斜角を調整する | 05 ORBIT_TILT_MAX_DEG 18 → 32 |
値を下げるとポインターを大きく動かしても軌道リングはほぼ平らなままで、上げるとわずかなポインターの動きでもリングが大きく傾きます。 |
9項目すべてが自分のスクリプトの先頭で一度だけ prefers-reduced-motion を確認し、個々のプロパティを止めるのではなく自動再生クラスを外すことで応じているため、動作を減らしたい訪問者にも空白のキャンバスではなく静止した層が見えます。矢印キーは9項目のうち5つ、項目01・02・04・05・09で使え、これらは keydown を監視して仮想ポインター・フォーカス位置・カメラのオフセット・傾き角・ヒーローシーンの視点のいずれかをマウスなしで動かせます。MDNには3つとも文書化されています。CanvasRenderingContext2D は描画面そのもの、requestAnimationFrame() はそれを駆動するループ、prefers-reduced-motion は上のアクセシビリティ確認の裏にあるメディアクエリです。
css 背景 固定 パララックス は奥行きシーンで何を計算しているのか?
この9つはどれも3Dエンジンや WebGL コンテキストを読み込んでいません。距離に応じた倍率、ピント距離に応じたぼかし、遠近除算によるグリッド投影、傾きに応じたzソート、単純なzからサイズへの変換のどれかを、すべて素の CanvasRenderingContext2D の呼び出しだけで描いています。9項目のうち8つは開始位置や角度をローカルの seedRand() で決めているため再読み込みしても毎回同じ見た目になりますが、項目04だけは例外で、グリッドの行と水平線が固定位置から始まるため乱数シードを必要としません。3D 地球儀サイト演出9選 — Three.jsなしのcanvas計算も、層状の背景の代わりに回転する球へ点を投影する似た計算をライブラリなしで行っています。
css パララックス 背景 レイヤーのFAQ でよくある質問
この9つはビルド手順なしで css パララックス 背景 の代わりにそのまま使えますか?
はい。どのデモもスクリプトを内蔵した単一の index.html で完結しているため、ファイルと対になる style.scss をコピーするだけで動きます。バンドラーにも外部の3Dライブラリにも依存していません。
項目02の動いたフレームが他より少ないのはなぜですか?
項目02のピント位置は FOCAL_CYCLE_MS 4200というゆっくりしたサイン波で前後に動くため、23フレームのうち大きなぼかしの変化として記録されるのは15フレームだけで、残り8項目はすべて23/23 フレームで動いています。
この9つの奥行き層に WebGL や3Dライブラリは必要ですか?
必要ありません。ここにある9つはどれも距離の倍率・遠近除算・zソート・zからサイズへの変換のいずれかで奥行きを計算していて、WebGL コンテキストも読み込んだエンジンも一切使っていません。









