<section class="demo" id="stage">
<canvas id="gl"></canvas>
</section>.demo {
position: relative;
width: 100%;
height: 100%;
min-height: 100dvh;
background: radial-gradient(120% 90% at 50% 45%, #16161c 0%, #08080a 72%);
overflow: hidden;
cursor: grab;
}
.demo:active { cursor: grabbing; }
#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();
const camera = new THREE.PerspectiveCamera(48, 1, 0.1, 100);
camera.position.z = 8.2;
// 구 전체를 담는 그룹. 회전은 이 그룹에만 겁니다.
const globe = new THREE.Group();
scene.add(globe);
const PALETTE = ["#6f7bd8", "#4f9bd8", "#e0705f", "#d8b26f", "#8fb98a", "#b9bcc2"];
// 실제 사진을 쓰려면 new THREE.TextureLoader().load(url)로 바꾸세요.
function makeTileTexture(index) {
const c = document.createElement("canvas");
c.width = 256;
c.height = 320;
const ctx = c.getContext("2d");
const base = PALETTE[index % PALETTE.length];
const grad = ctx.createLinearGradient(0, 0, 256, 320);
grad.addColorStop(0, base);
grad.addColorStop(1, "#0d0d11");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 256, 320);
ctx.strokeStyle = "rgba(255,255,255,0.16)";
ctx.lineWidth = 3;
ctx.strokeRect(6, 6, 244, 308);
ctx.fillStyle = "rgba(255,255,255,0.82)";
ctx.font = "600 34px Helvetica, Arial, sans-serif";
ctx.fillText(String(index + 1).padStart(2, "0"), 22, 292);
const texture = new THREE.CanvasTexture(c);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
// 피보나치 구면 분포. 위경도 격자와 달리 극지방에 타일이 몰리지 않습니다.
const COUNT = 46;
const RADIUS = 3.5;
const golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < COUNT; i += 1) {
const y = 1 - (i / (COUNT - 1)) * 2;
const ring = Math.sqrt(1 - y * y);
const theta = golden * i;
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(0.86, 1.08),
new THREE.MeshBasicMaterial({
map: makeTileTexture(i),
side: THREE.DoubleSide,
transparent: true,
})
);
plane.position.set(Math.cos(theta) * ring * RADIUS, y * RADIUS, Math.sin(theta) * ring * RADIUS);
// 각 타일이 구의 바깥을 향하도록 세웁니다.
plane.lookAt(0, 0, 0);
globe.add(plane);
}
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 velocity = { x: 0.0016, y: 0.0009 };
let dragging = false;
let last = { x: 0, y: 0 };
stage.addEventListener("pointerdown", (event) => {
dragging = true;
last = { x: event.clientX, y: event.clientY };
stage.setPointerCapture(event.pointerId);
});
stage.addEventListener("pointermove", (event) => {
if (!dragging) return;
const dx = event.clientX - last.x;
const dy = event.clientY - last.y;
last = { x: event.clientX, y: event.clientY };
velocity.x = dx * 0.00042;
velocity.y = dy * 0.00042;
});
function release() { dragging = false; }
stage.addEventListener("pointerup", release);
stage.addEventListener("pointercancel", release);
stage.addEventListener("pointerleave", release);
gsap.ticker.add(() => {
resize();
if (!dragging) {
// 마찰. 완전히 0으로 죽이지 않고 최소 회전을 남겨 카드에서도 살아 있게 합니다.
velocity.x *= 0.96;
velocity.y *= 0.96;
if (Math.abs(velocity.x) < 0.0016) velocity.x += (0.0016 - Math.abs(velocity.x)) * 0.05;
}
globe.rotation.y += velocity.x;
globe.rotation.x += velocity.y;
globe.rotation.x = THREE.MathUtils.clamp(globe.rotation.x, -0.85, 0.85);
renderer.render(scene, camera);
});