css 배경 애니메이션 효과 9가지 — 깊이 층 3D 장면
02번 층은 거리만큼 초점이 흐려지고, 07번 층은 거리만큼 그림자가 옅어지며, 09번 장면은 그 두 계산을 같은 화면 위에 겹쳐 놓는다. 이 아홉 가지 css 배경 애니메이션 효과는 층마다 다른 계산 하나로 깊이감을 만든다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 층마다 다른 속도로 밀리는 도형 시차 배경
- 02 거리만큼 초점이 흐려지는 보케 흐림 배경
- 03 층별로 천천히 흘러가는 그라데이션 메시 배경
- 04 지평선으로 좁아지는 원근 격자 바닥
- 05 포인터를 따라 기우는 궤도 위성 고리
- 06 층마다 옅어지는 빛줄기 배경
- 07 높이만큼 그림자가 커지는 부유 카드
- 08 거리에 따라 실시간으로 커지고 작아지는 입자 배경
- 09 여러 깊이 층을 한 화면에 합친 히어로 배경 장면
css 배경 애니메이션 효과 9가지 — 층마다 다른 계산이 쌓여 만드는 깊이감
아홉 항목은 모두 내부 계산 해상도를 CALC_W=160·CALC_H=100 으로 고정해 두고 화면 크기에 맞춰 비율(sx, sy)만 늘려 그린다. 여덟 항목은 seedRand() 로 씨앗을 고정한 난수로 시작 위치나 속도를 정해 매번 같은 순서로 재생되고, 유일한 예외인 04번은 애초에 난수를 쓰지 않는 고정 격자라 매번 완전히 같은 모양으로 시작한다. 층 하나에 적용되는 계산은 거리 배수·흐림 반경·주기 이동·투영 좌표·화가 알고리즘식 정렬·지수 감쇠·그림자 변환·크기 환산 중 하나(또는 09처럼 여럿)이고, 이 값들이 서로 다른 조합으로 섞이며 아홉 가지 다른 깊이감이 된다.
깊이 층 종류별 코드
여기 아홉 개 발췌는 값이 계산되는 지점만 담았고 소스 전체는 압축 파일에 들어 있다. 아홉 항목 모두 3D 라이브러리나 WebGL 없이 canvas 2D 컨텍스트 위에서 거리·흐림·그림자·기울기 값만 매 프레임 계산해서 그린다.
01층마다 다른 속도로 밀리는 도형 시차 배경
도형마다 가상의 거리값을 부여해 포인터 이동량에 그 거리에 반비례하는 배수를 곱하고, 가까운 도형은 크게 밀리고 먼 도형은 조금만 밀린다. distanceMultiplier 값은 0.18~3.1 사이로 한 번 더 잘라내 어떤 거리라도 도형이 화면 밖으로 튕겨 나가지 않고, 포인터가 없을 때는 시간 기반 가상 경로가 대신 좌표를 만들어 자동으로 재생된다. 24장 중 23장에서 도형이 움직였고 누적면적은 20.6%, 강도 평균은 61.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 () {
// 기법: 도형마다 가상 거리값(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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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,
02거리만큼 초점이 흐려지는 보케 흐림 배경
각 도형의 거리값을 흐림 반경으로 변환해 실시간으로 filter blur 값을 다시 계산해, 초점면에서 멀어질수록 도형 윤곽이 더 크게 번진다. 포인터가 없을 때도 시간에 따라 흐르는 장면이 계속 재생되지만, 초점면이 두 도형 거리 사이 평탄한 구간을 지날 때는 흐림 반경이 잠깐 거의 바뀌지 않는다. 24장 중 15장에서 흐림 정도가 바뀌었고 누적면적은 17.0%, 강도 평균은 15.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">각 도형의 거리값을 흐림 반경으로 변환해 실시간으로 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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;
03층별로 천천히 흘러가는 그라데이션 메시 배경
여러 겹의 흐린 그라데이션 덩어리에 서로 다른 이동 주기를 줘 겹쳐 그리고, 가까운 층일수록 더 크고 빠르게, 먼 층일수록 작고 느리게 흐른다. MESH_LAYER_COUNT 는 5로 고정돼 있어 층 수를 늘리려면 배열 길이 자체를 바꿔야 한다. 24장 중 23장에서 덩어리가 움직였고 누적면적은 52.5%, 강도 평균은 20.9였다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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) {
04지평선으로 좁아지는 원근 격자 바닥
격자선 좌표를 원근 투영 공식으로 계산해 지평선에 가까울수록 선 사이 간격을 좁히고, 포인터가 움직이면 시점 위치도 같이 다시 계산한다. zWorld 값이 카메라에 너무 가까워지면 4 밑으로 내려가지 않게 잘라, 격자선이 무한히 커지며 화면을 뒤덮는 계산 사고를 막는다. 24장 중 23장에서 격자가 움직였고 누적면적은 23.4%, 강도 평균은 39.8이었다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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
};
}
05포인터를 따라 기우는 궤도 위성 고리
작은 도형들의 위치를 구면 좌표식으로 매 프레임 계산하고 z값 순서로 다시 그려, 포인터 위치에 따라 고리 전체가 기울면서도 앞뒤 가림이 맞는다. zSortSatellites 는 매 프레임 위성 전체를 z값 순으로 다시 정렬해 그리므로, 기울기가 바뀌어 앞뒤 순서가 뒤집혀도 그리는 순서가 함께 뒤집힌다. 24장 중 23장에서 위성이 움직였고 누적면적은 10.2%, 강도 평균은 106.0이었다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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;
}
06층마다 옅어지는 빛줄기 배경
빛줄기 여러 겹을 그리고 각 겹의 불투명도를 거리값에 지수함수로 곱해 줄여, 가까운 겹은 선명하고 먼 겹은 배경에 거의 스며든다. Math.exp(-LIGHT_SHAFT_DECAY * depth) 로 옅어지는 정도를 지수적으로 줄이기 때문에, 거리 값이 조금만 늘어도 불투명도는 초반에 빠르게 떨어지고 이후엔 완만해진다. 24장 중 23장에서 빛줄기가 움직였고 누적면적은 30.5%, 강도 평균은 21.6이었다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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));
}
07높이만큼 그림자가 커지는 부유 카드
카드마다 떠 있는 높이값을 그림자의 흐림 반경과 옅기로 변환해, 높이 뜬 카드일수록 그림자가 크고 연해지고 낮게 뜬 카드는 그림자가 진하고 작다. shadowForHeight 는 높이값을 0~1 사이 비율로 정규화한 뒤 흐림·불투명도·가로 폭 세 값을 한 번에 계산해, 카드가 뜬 높이만 알면 그림자 모양 전체가 정해진다. 24장 중 23장에서 카드가 움직였고 누적면적은 21.4%, 강도 평균은 129.0이었다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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
};
}
08거리에 따라 실시간으로 커지고 작아지는 입자 배경
입자마다 z값을 시간에 따라 조금씩 바꾸고 그 값을 화면 크기로 매 프레임 다시 환산해, 다가오는 입자는 점점 커지고 멀어지는 입자는 점점 작아진다. zAt 은 시간에 따라 줄어드는 z값이 0 밑으로 내려가면 PARTICLE_Z_MAX 만큼 다시 더해 뒤쪽에서 새 입자로 나타나게 하므로, 입자 수는 늘지도 줄지도 않는다. 24장 중 23장에서 입자가 움직였고 누적면적은 30.6%, 강도 평균은 94.4였다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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));
}
09여러 깊이 층을 한 화면에 합친 히어로 배경 장면
시차·흐림·원근 격자·궤도·그림자·크기 변화를 같은 배경 위에서 동시에 계산해, 포인터가 움직이면 여러 층이 한 몸처럼 함께 반응한다. 이 장면은 01의 거리 배수, 04의 원근 투영, 05의 궤도 기울기 계산을 그대로 가져오되 HERO_ 접두가 붙은 자신만의 상수(HERO_PARALLAX_REF_DISTANCE_PX=200·HERO_BLUR_MAX_PX=10·HERO_ORBIT_TILT_MAX_DEG=16)로 세기만 다시 잡는다. 24장 중 23장에서 장면이 움직였고 누적면적은 23.1%, 강도 평균은 59.8이었다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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);
잰 css 배경 애니메이션 9항목의 실제 값 — 24장 캡처 기준
| 항목 | 누적면적 | 평균 강도 | 움직인 프레임 |
|---|---|---|---|
| 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 |
값이 깊이감을 바꾸는 곳 — 거리·흐림·그림자·기울기
PARALLAX_REF_DISTANCE_PX 는 01번 도형의 배수 계산 기준 거리를 정한다. 값을 낮추면 가까운 도형도 배수가 작게 나와 전체적으로 덜 밀리고, 값을 올리면 같은 거리라도 배수가 커져 도형이 더 크게 튕기듯 움직인다. GRID_PERSPECTIVE_PX 는 04번 격자가 css 3d perspective 계산을 흉내 낼 때 쓰는 값이라, 값을 줄이면 격자가 지평선 쪽으로 더 급하게 좁아져 원근감이 과장되고 값을 늘리면 완만하게 좁아져 평평한 배경처럼 보인다. ORBIT_TILT_MAX_DEG 는 05번 궤도가 기울 수 있는 최대 각도라, 값을 낮추면 포인터를 크게 움직여도 고리가 거의 눕지 않고 값을 올리면 조금만 움직여도 고리가 크게 기운다.
어디서 깨지는가 — 동작 줄임 설정·좁은 화면·층이 많을 때
깊이 층 아홉 개는 전부 matchMedia('(prefers-reduced-motion: reduce)') 로 동작 줄임 설정을 읽어 자동 재생 루프를 멈춘다 — 포인터로 움직이는 01·02·04·05·09 다섯 항목은 방향키 입력이 들어오면 그 프레임만 다시 계산해 화면을 갱신한다. 좁은 화면에서는 @media (max-width: 340px) 로 캡션 글자 크기만 10px 로 줄어들 뿐 캔버스 내부 계산 해상도는 그대로라, 화면이 작아질수록 층과 층 사이 깊이 차이가 시각적으로 더 좁아 보인다. 03·06·07·08 항목은 입력 없이 시간에 따라서만 흘러가고, 09번 히어로 장면은 여섯 계산(시차·흐림·투영·궤도·그림자·크기)을 한 화면 위에서 동시에 처리해 층이 많을수록 프레임마다 계산량도 함께 늘어난다. 깊이 층 아홉 개의 원본 소스는 zip 압축 파일로 묶여 있고, 여는 비밀번호는 gt5gztk5 이다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 최대 흐림 반경 조정 | 02 DEPTH_BLUR_MAX_PX 14 → 28 | 값을 줄이면 가장 먼 층도 살짝만 흐려져 장면이 납작해 보이고, 값을 늘리면 먼 층이 크게 번져 앞뒤 거리 차이가 뚜렷해진다. |
| 원근감 강도 조정 | 04 GRID_PERSPECTIVE_PX 800 → 400 | 값을 줄이면 바닥 격자가 더 급하게 좁아져 원근감이 과장되고, 값을 늘리면 격자선이 완만하게 좁아져 장면이 평평한 배경처럼 보인다. |
| 궤도 기울기 최대각 조정 | 05 ORBIT_TILT_MAX_DEG 18 → 32 | 값을 줄이면 포인터가 많이 움직여도 궤도가 거의 눕지 않고, 값을 늘리면 포인터가 조금만 움직여도 궤도가 크게 기울어 보인다. |
포인터가 있는 01·02·04·05·09 항목은 방향키로도 같은 포인터 좌표를 옮길 수 있어 마우스 없이도 장면을 확인할 수 있고, 03·06·07·08 항목은 애초에 입력이 필요 없는 자동 재생이라 prefers-reduced-motion 값이 켜지면 그대로 멈춘다.
css 3d transform 은 배경 장면에서 어떤 계산을 하는가?
실제로는 계산 방식이 다르다. 아홉 항목 모두 SCSS 안에 transform·perspective 속성을 쓰지 않고, canvas 2D 컨텍스트 위에서 거리 배수·투영 좌표·화가 알고리즘식 정렬·지수 감쇠 같은 수식을 매 프레임 좌표와 스타일 값으로 직접 바꿔 그린다. 04번은 원근 투영 공식으로 좌표를 계산해 지평선에 가까운 격자선일수록 간격을 좁히고, 05번은 구면 좌표로 위성 위치를 구한 뒤 z값 순서로 다시 그려 앞뒤 가림을 맞춘다. 여러 층을 겹쳐 한 줄로 붙이는 배경 계산은 CSS 배경 애니메이션 9종 — 한 줄 복붙으로 가, 포인터를 따라 도는 회전 계산은 마우스를 따라오는 CSS 3D 회전 장면 9종 이 각각 다룬다.
css 배경 애니메이션 9개에서 자주 나오는 질문
css 배경 그라데이션 애니메이션도 이 아홉 개 안에 있나요?
있다. 03번이 여러 겹의 흐린 그라데이션 덩어리를 서로 다른 주기로 움직여 겹쳐 그리는 방식이라, 층마다 크기와 속도만 다를 뿐 같은 계산을 공유한다.
포인터가 없는 화면에서도 장면이 움직이나요?
움직인다. 01·04·05·09 항목은 포인터가 없으면 시간 기반 가상 경로가 대신 좌표를 만들어 자동으로 재생되고, 02번은 포인터와 상관없이 항상 흐르는 시간 위에 초점 위치만 얹는 구조라 가만히 둬도 이미 움직이고 있다.
층을 더 늘려도 되나요?
소스에서 배열 길이나 카운트 상수를 늘리면 층이 늘어난다. 다만 05번처럼 위성 전체를 매 프레임 z값으로 다시 정렬하는 항목은 층(위성) 수가 늘수록 정렬과 그리기 계산량도 함께 늘어난다는 점을 감안해야 한다.









