04지평선으로 좁아지는 원근 격자 바닥
격자선 좌표를 원근 투영 공식으로 계산해 지평선에 가까울수록 선 사이 간격을 좁히고, 포인터가 움직이면 시점 위치도 같이 다시 계산한다.
쓰임레트로 신스웨이브 히어로 배경
projectGridPointGRID_PERSPECTIVE_PXnudgeCamHeight

<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 포함 · css 배경 애니메이션 효과 9가지 — 깊이 층 3D 장면
이 데모가 속한 글
css 배경 애니메이션 효과 9가지 — 깊이 층 3D 장면02번 층은 거리만큼 초점이 흐려지고, 07번 층은 거리만큼 그림자가 옅어지며, 09번 장면은 그 두 계산을 같은 화면 위에 겹쳐 놓는다. 이 아홉 가지 css 배경 애니메이션 효과는 층마다 다른 계산 하나로 깊이감 이 글의 데모 전부 보기


