<section class="demo" id="stage">
<canvas id="gl"></canvas>
</section>.demo {
position: relative;
width: 100%;
height: 100%;
min-height: 100dvh;
background: #08080a;
overflow: hidden;
}
#gl {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}const canvas = document.querySelector("#gl");
const stage = document.querySelector("#stage");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x08080a, 10, 34);
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 100);
const PALETTE = ["#6f7bd8", "#4f9bd8", "#e0705f", "#d8b26f", "#8fb98a", "#b9bcc2", "#7f6fd8", "#d86f9b"];
const COUNT = 16;
const GAP = 4.2;
const TOTAL = COUNT * GAP;
// 실제 사진을 쓰려면 TextureLoader로 교체하세요. 개수만 맞으면 나머지는 그대로입니다.
function makeCardTexture(index) {
const c = document.createElement("canvas");
c.width = 384;
c.height = 512;
const ctx = c.getContext("2d");
const base = PALETTE[index % PALETTE.length];
const grad = ctx.createLinearGradient(0, 0, 384, 512);
grad.addColorStop(0, base);
grad.addColorStop(1, "#0b0b0f");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 384, 512);
ctx.globalAlpha = 0.14;
ctx.fillStyle = "#fff";
for (let i = 0; i < 5; i += 1) {
ctx.fillRect(0, 90 + i * 78, 384, 2);
}
ctx.globalAlpha = 1;
ctx.fillStyle = "rgba(244,244,242,0.9)";
ctx.font = "600 40px Helvetica, Arial, sans-serif";
ctx.fillText(String(index + 1).padStart(2, "0"), 28, 472);
const texture = new THREE.CanvasTexture(c);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
const cards = [];
for (let i = 0; i < COUNT; i += 1) {
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(2.1, 2.8),
new THREE.MeshBasicMaterial({ map: makeCardTexture(i), transparent: true })
);
// 좌우 교대 + 높이 변주. 일렬로 세우면 복도가 아니라 터널이 되어 지루합니다.
const side = i % 2 === 0 ? -1 : 1;
mesh.position.set(side * (2.2 + (i % 3) * 0.35), Math.sin(i * 0.9) * 0.9, -i * GAP);
mesh.rotation.y = side * -0.42;
scene.add(mesh);
cards.push(mesh);
}
function resize() {
const w = Math.max(1, stage.clientWidth);
const h = Math.max(1, stage.clientHeight);
const dpr = renderer.getPixelRatio();
if (canvas.width === Math.floor(w * dpr) && canvas.height === Math.floor(h * dpr)) return;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
// 크기는 렌더 루프에서 매 프레임 확인합니다(위 조기 반환 덕분에 비용은 없습니다).
resize();
// ---- 전진: 휠로 가속하고, 놓아두면 일정 속도로 흘러갑니다 ---------------
// previewMode가 full이라 문서 스크롤이 없습니다. wheel 이벤트를 직접 받습니다.
const DRIFT = 0.028;
let velocity = DRIFT;
let z = 0;
stage.addEventListener(
"wheel",
(event) => {
event.preventDefault();
velocity += event.deltaY * 0.0012;
velocity = gsap.utils.clamp(-0.5, 0.6, velocity);
},
{ passive: false }
);
gsap.ticker.add(() => {
resize();
// 휠 입력이 끊기면 기본 흐름 속도로 되돌아갑니다.
velocity += (DRIFT - velocity) * 0.035;
z += velocity;
camera.position.z = 5 - z;
camera.position.x = Math.sin(z * 0.06) * 0.6;
camera.rotation.y = Math.sin(z * 0.06) * 0.05;
cards.forEach((mesh) => {
// 카메라 뒤로 넘어간 카드를 행렬 맨 뒤로 옮깁니다.
// 이 재활용 덕분에 16장으로 끝없는 복도가 만들어집니다.
if (mesh.position.z > camera.position.z + 3) {
mesh.position.z -= TOTAL;
} else if (mesh.position.z < camera.position.z - TOTAL + 3) {
mesh.position.z += TOTAL;
}
});
renderer.render(scene, camera);
});