04Perspective Grid Floor Converging Toward the Horizon
Grid-line coordinates run through a perspective-projection formula that narrows the spacing near the horizon, and the viewpoint itself is recalculated whenever the pointer moves.
Best forRetro synthwave hero background
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">Grid-line coordinates run through a perspective-projection formula that narrows the spacing near the horizon, and the viewpoint itself is recalculated whenever the pointer moves</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 Perspective Grid Floor Converging Toward the Horizon — Measured area 23.426% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 CSS 3D Background Animation Depth Layers
The post this demo lives in
9 CSS 3D Background Animation Depth LayersThis css 3d background animation set has nine layers below. Layer 02 loses focus with distance, layer 07 loses shadow st See all demos in this post
If you like this effect - similar demos
Interactions02 Grid-to-screen — the 2:1 formula turning (col, row) into (x, y)Isometric CSS: 9 True-Projection 3D Dioramas
Interactions02 Dice Thrown on an Arc That Bounce on the FloorDice Roll Animation: 9 Scenes with Top-Face Math
Interactions01 A Wireframe Polyhedron That Eases Toward the Pointer and Fades With DepthJavaScript 3D Animation: 9 Pointer-Led Models