<section class="demo" id="stage">
<canvas id="gl"></canvas>
</section>.demo {
position: relative;
width: 100%;
height: 100%;
min-height: 100dvh;
background: radial-gradient(110% 80% at 50% 30%, #191920 0%, #08080a 70%);
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));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(0, 0.9, 7);
camera.lookAt(0, 0.1, 0);
// ---- 조명: 키 / 필 / 림 세 개면 형태가 충분히 읽힙니다 -------------------
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
const key = new THREE.DirectionalLight(0xffffff, 2.4);
key.position.set(4, 6, 4);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 1;
key.shadow.camera.far = 20;
key.shadow.radius = 4;
scene.add(key);
const fill = new THREE.DirectionalLight(0x6f7bd8, 1.1);
fill.position.set(-5, 1, 2);
scene.add(fill);
const rim = new THREE.DirectionalLight(0xe0705f, 1.6);
rim.position.set(-2, 2, -5);
scene.add(rim);
// ---- 오브젝트 ----------------------------------------------------------
// .glb 모델 대신 기본 지오메트리를 씁니다. 실제 모델을 쓰려면
// GLTFLoader로 불러와 이 group에 add 하면 나머지는 그대로 동작합니다.
const group = new THREE.Group();
scene.add(group);
const object = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.35, 0),
new THREE.MeshStandardMaterial({
color: 0xf1f1ef,
metalness: 0.55,
roughness: 0.22,
flatShading: true,
})
);
object.castShadow = true;
group.add(object);
// 얇은 와이어 껍질을 하나 더 씌우면 회전이 훨씬 잘 읽힙니다.
const shell = new THREE.Mesh(
new THREE.IcosahedronGeometry(1.85, 1),
new THREE.MeshBasicMaterial({ color: 0x4a4a55, wireframe: true, transparent: true, opacity: 0.32 })
);
group.add(shell);
// 그림자를 받는 바닥. ShadowMaterial은 그림자만 그리고 면 자체는 투명합니다.
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(24, 24),
new THREE.ShadowMaterial({ opacity: 0.5 })
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -2.3;
floor.receiveShadow = true;
scene.add(floor);
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 pointer = { x: 0.5, y: 0.5 };
let autoplay = true;
const idle = gsap.timeline({ repeat: -1, yoyo: true, defaults: { duration: 4, ease: "sine.inOut" } })
.to(pointer, { x: 0.74, y: 0.36 })
.to(pointer, { x: 0.26, y: 0.64 });
stage.addEventListener("pointermove", (event) => {
if (autoplay) {
autoplay = false;
idle.kill();
}
const rect = stage.getBoundingClientRect();
gsap.to(pointer, {
x: (event.clientX - rect.left) / rect.width,
y: (event.clientY - rect.top) / rect.height,
duration: 0.6,
ease: "power3.out",
overwrite: true,
});
});
gsap.ticker.add((time) => {
resize();
// 상시 회전 + 부유. 커서는 "기울기"만 더합니다.
object.rotation.y += 0.004;
object.rotation.x += 0.0016;
shell.rotation.y -= 0.0025;
shell.rotation.z += 0.0012;
group.position.y = Math.sin(time * 0.9) * 0.16;
const tiltX = (pointer.y - 0.5) * 0.7;
const tiltY = (pointer.x - 0.5) * 1.1;
group.rotation.x += (tiltX - group.rotation.x) * 0.06;
group.rotation.y += (tiltY - group.rotation.y) * 0.06;
// 그림자가 오브젝트를 따라가도록 키 라이트를 함께 움직입니다.
key.position.x = 4 - (pointer.x - 0.5) * 5;
key.position.z = 4 - (pointer.y - 0.5) * 3;
renderer.render(scene, camera);
});