<section class="demo" id="stage">
<canvas id="scene"></canvas>
</section>.demo {
position: relative;
width: 100%;
height: 100%;
min-height: 100dvh;
background: #08080a;
overflow: hidden;
}
#scene {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}const canvas = document.querySelector("#scene");
const stage = document.querySelector("#stage");
const ctx = canvas.getContext("2d");
let particles = [];
let dpr = 1;
// 소스에서 픽셀을 샘플링해 파티클의 목표 좌표를 만듭니다.
// 사진을 쓰려면 drawImage로 그린 뒤 같은 방식으로 읽으면 됩니다.
function sampleTargets(width, height) {
const off = document.createElement("canvas");
off.width = width;
off.height = height;
const octx = off.getContext("2d");
octx.fillStyle = "#000";
octx.fillRect(0, 0, width, height);
const size = Math.min(width, height) * 0.34;
octx.fillStyle = "#fff";
octx.textAlign = "center";
octx.textBaseline = "middle";
octx.font = "700 " + size + "px Helvetica, Arial, sans-serif";
octx.fillText("FORM", width / 2, height / 2);
const image = octx.getImageData(0, 0, width, height).data;
const step = Math.max(3, Math.round(Math.min(width, height) / 150));
const targets = [];
for (let y = 0; y < height; y += step) {
for (let x = 0; x < width; x += step) {
// 알파가 아니라 밝기로 판정합니다 (배경을 검정으로 칠했기 때문).
if (image[(y * width + x) * 4] > 128) {
targets.push({ x, y });
}
}
}
return targets;
}
function build() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(stage.clientWidth * dpr);
canvas.height = Math.floor(stage.clientHeight * dpr);
const targets = sampleTargets(canvas.width, canvas.height);
particles = targets.map((target) => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
tx: target.x,
ty: target.y,
vx: 0,
vy: 0,
// 입자마다 복귀 속도를 다르게 줘야 뭉쳐서 움직이지 않습니다.
ease: 0.012 + Math.random() * 0.035,
size: (0.9 + Math.random() * 1.5) * dpr,
hue: Math.random(),
}));
}
// 매 프레임 표시 크기를 확인해 달라졌을 때만 다시 만듭니다.
// 레이아웃이 늦게 잡히는 환경에서 캔버스가 작게 굳는 것을 막습니다.
function ensureSize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(stage.clientWidth * dpr));
const h = Math.max(1, Math.floor(stage.clientHeight * dpr));
if (canvas.width === w && canvas.height === h) return;
build();
}
build();
// ---- 포인터 (카드에서도 살아 있도록 유휴 궤도를 돕니다) -----------------
const pointer = { x: -999, y: -999 };
let autoplay = true;
const idle = gsap.timeline({ repeat: -1, defaults: { ease: "sine.inOut", duration: 2.6 } })
.to(pointer, { x: 0.28, y: 0.42 })
.to(pointer, { x: 0.74, y: 0.58 })
.to(pointer, { x: 0.5, y: 0.5 });
pointer.x = 0.5;
pointer.y = 0.5;
stage.addEventListener("pointermove", (event) => {
if (autoplay) {
autoplay = false;
idle.kill();
}
const rect = stage.getBoundingClientRect();
pointer.x = (event.clientX - rect.left) / rect.width;
pointer.y = (event.clientY - rect.top) / rect.height;
});
const RADIUS = 120;
gsap.ticker.add(() => {
ensureSize();
ctx.clearRect(0, 0, canvas.width, canvas.height);
const px = pointer.x * canvas.width;
const py = pointer.y * canvas.height;
const radius = RADIUS * dpr;
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
// 1) 목표 위치로 되돌아가려는 힘
p.vx += (p.tx - p.x) * p.ease;
p.vy += (p.ty - p.y) * p.ease;
// 2) 커서가 밀어내는 힘. 거리의 제곱으로 떨어뜨려야 자연스럽습니다.
const dx = p.x - px;
const dy = p.y - py;
const dist = Math.hypot(dx, dy);
if (dist < radius) {
const force = Math.pow(1 - dist / radius, 2) * 14;
p.vx += (dx / (dist || 1)) * force;
p.vy += (dy / (dist || 1)) * force;
}
// 3) 감쇠
p.vx *= 0.86;
p.vy *= 0.86;
p.x += p.vx;
p.y += p.vy;
// 제자리에서 멀수록 붉게, 자리를 찾을수록 흰색에 가깝게
const offset = Math.min(1, Math.hypot(p.x - p.tx, p.y - p.ty) / (90 * dpr));
const r = Math.round(244 - offset * 20);
const g = Math.round(244 - offset * 132);
const b = Math.round(242 - offset * 147);
ctx.fillStyle = "rgb(" + r + "," + g + "," + b + ")";
ctx.fillRect(p.x, p.y, p.size, p.size);
}
});