<section class="demo">
<div class="demo__stage" id="stage">
<canvas id="gl"></canvas>
</div>
</section>.demo {
background: #08080a;
height: 900vh; /* 이 높이가 곧 터널을 통과하는 스크롤 길이입니다 */
}
/* pin 대신 sticky. 스크롤이 없는 환경에서도 레이아웃이 유지됩니다. */
.demo__stage {
position: sticky;
top: 0;
height: 100dvh;
overflow: hidden;
}
#gl {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}gsap.registerPlugin(ScrollTrigger);
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, 6, 30);
const camera = new THREE.PerspectiveCamera(62, 1, 0.1, 100);
const WORDS = ["DESIGN", "CREATE", "MOTION", "INTERACTION", "SPACE", "RHYTHM"];
const DEPTH = 6.5; // 단어 사이의 Z 간격
const RINGS = 14;
// 글자를 캔버스에 그려 텍스처로 씁니다.
// 폰트 로더나 TextGeometry 없이도 또렷한 타이포를 얻는 가장 가벼운 방법입니다.
function makeWordTexture(word) {
const c = document.createElement("canvas");
c.width = 1024;
c.height = 256;
const ctx = c.getContext("2d");
ctx.clearRect(0, 0, 1024, 256);
ctx.fillStyle = "#f4f4f2";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "600 150px Helvetica, Arial, sans-serif";
ctx.fillText(word, 512, 136);
const texture = new THREE.CanvasTexture(c);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 4;
return texture;
}
const textures = WORDS.map(makeWordTexture);
const words = [];
for (let i = 0; i < RINGS; i += 1) {
const texture = textures[i % textures.length];
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(7.2, 1.8),
new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: 0.92 })
);
// 좌우로 번갈아 흩어 놓아야 터널 벽처럼 읽힙니다.
const side = i % 2 === 0 ? -1 : 1;
mesh.position.set(side * (0.8 + (i % 3) * 0.5), Math.sin(i * 1.3) * 1.5, -i * DEPTH);
mesh.rotation.y = side * 0.22;
mesh.rotation.z = Math.sin(i * 0.7) * 0.05;
scene.add(mesh);
words.push(mesh);
}
const TOTAL = RINGS * DEPTH;
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();
// 스크롤 진행도와, 카드용 자동 전진을 하나의 값으로 합칩니다.
const travel = { scroll: 0, drift: 0 };
let scrolled = false;
// 고정은 CSS sticky가 담당하므로 ScrollTrigger는 진행도만 읽습니다.
ScrollTrigger.create({
trigger: ".demo",
start: "top top",
end: "bottom bottom",
scrub: 0.8,
invalidateOnRefresh: true,
onUpdate: (self) => {
if (self.progress > 0.001) scrolled = true;
travel.scroll = self.progress * (TOTAL - DEPTH * 2);
},
});
gsap.ticker.add((time, delta) => {
resize();
// 스크롤이 시작되기 전에는 스스로 앞으로 흘러갑니다.
if (!scrolled) {
travel.drift += (delta / 1000) * 1.6;
}
const z = travel.scroll + travel.drift;
camera.position.z = 4 - z;
camera.position.x = Math.sin(z * 0.08) * 0.5;
camera.rotation.y = Math.sin(z * 0.08) * 0.04;
// 카메라를 지나친 단어는 뒤로 재배치해 터널이 끝나지 않게 합니다.
words.forEach((mesh) => {
if (mesh.position.z > camera.position.z + 4) {
mesh.position.z -= TOTAL;
}
});
renderer.render(scene, camera);
});