最近帮朋友弄一个订婚的简单网页,我就搜了一下关键词:3D 球形 照片 html+three.js制作球形照片墙送女友,嗨,你猜怎么着,就挺难的(我不是前端开发的),我想着这大前端怎么这个效果还藏着掖着嘞,有的还要收钱才能买到源码。 行,来吧,用AI来调教一下吧。
看下效果还行:(这里是预览模式,图片是根据画布大小自动布局的, 有需要的可以将代码复制到index.html中,然后在浏览器中打开)
附上全部源码,或者去我的代码片段中获取。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>3D 球形照片墙</title>
<style>
html, body { height: 100%; margin: 0; }
body { background: radial-gradient(1200px 700px at 50% 40%, #0f1420, #0b0e13 60%); color: #c8d3e5; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
#app { position: fixed; inset: 0; display: grid; place-items: center; overflow: hidden; }
.stage {
position: relative;
width: min(92vmin, 920px);
height: min(92vmin, 920px);
perspective: 1300px;
perspective-origin: 50% 50%;
}
.scene {
position: absolute;
inset: 0;
transform-style: preserve-3d;
will-change: transform;
}
.photo {
position: absolute;
left: 50%; top: 50%;
transform-style: preserve-3d;
width: 108px; height: 72px;
margin: -36px -54px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0,0,0,.35), inset 0 0 0 1px rgba(255,255,255,.05);
background: #111723;
will-change: transform, filter, opacity;
filter: blur(var(--blur, 0px)) saturate(1.02);
opacity: var(--opacity, 1);
}
.photo:hover { --hs: 1.06; box-shadow: 0 14px 42px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.1); }
.photo .face { position: absolute; inset: 0; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
.photo .face img { width: 100%; height: 100%; object-fit: cover; display: block; transform: translateZ(0); }
.photo .face.back { transform: rotateY(180deg); }
.photo::after {
content: "";
position: absolute; inset: 0;
background: radial-gradient(600px 140px at var(--mx,50%) var(--my,50%), rgba(255,255,255,.18), rgba(255,255,255,0) 40%);
pointer-events: none;
opacity: .0; transition: opacity .25s ease;
}
.photo:hover::after { opacity: 1; }
.hud { position: fixed; left: 12px; bottom: 12px; font-size: 12px; opacity: .8; user-select: none; }
.hud b { color: #aaccff; }
.lightbox {
position: fixed;
inset: 0;
background: rgba(0,0,0,.86);
display: none;
align-items: center;
justify-content: center;
z-index: 20;
padding: 4vmin;
box-sizing: border-box;
opacity: 0; transition: opacity .22s ease;
}
.lightbox.open { display: flex; opacity: 1; }
.lightbox img {
max-width: 92vw; max-height: 92vh;
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0,0,0,.6);
transition: transform .22s ease;
}
.lightbox .hint {
position: fixed; bottom: 14px; left: 50%; transform: translateX(-50%);
color: #aaccff; font-size: 12px; opacity: .8; user-select: none;
}
.nav-btn {
position: fixed; top: 50%; transform: translateY(-50%);
width: 44px; height: 44px; border-radius: 999px;
display: grid; place-items: center; cursor: pointer;
color: #e8f0ff; background: rgba(255,255,255,.1);
box-shadow: 0 4px 18px rgba(0,0,0,.35);
user-select: none; -webkit-user-select: none;
transition: background .2s ease, transform .2s ease;
}
.nav-btn:hover { background: rgba(255,255,255,.18); transform: translateY(-50%) scale(1.06); }
.nav-prev { left: 18px; }
.nav-next { right: 18px; }
</style>
</head>
<body>
<div id="app">
<div class="stage">
<div id="scene" class="scene"></div>
</div>
</div>
<div class="hud">拖拽旋转 · 滚轮缩放(可放大进球内)· 点击图片预览 · ←/→ 切换 · <b>自动旋转</b></div>
<div id="lightbox" class="lightbox" aria-hidden="true">
<div class="nav-btn nav-prev" id="navPrev" aria-label="上一张">‹</div>
<img id="lightboxImg" alt="preview" />
<div class="nav-btn nav-next" id="navNext" aria-label="下一张">›</div>
<div class="hint">点击空白处或按 ESC 关闭</div>
</div>
<script>
const sceneEl = document.getElementById('scene');
const lightboxEl = document.getElementById('lightbox');
const lightboxImg = document.getElementById('lightboxImg');
const navPrev = document.getElementById('navPrev');
const navNext = document.getElementById('navNext');
// Config
const PHOTO_W = 108;
const PHOTO_H = 72;
const SPACING_X = 1.25;
const SPACING_Y = 1.35;
const NUM_PHOTOS = 160;
const RADIUS_VMIN = 40;
const AUTO_ROTATE_Y = 0.014;
const DAMPING = 0.93;
const MIN_SCALE = 0.6;
const MAX_SCALE = 3.5; // 允许放大“进入”球体内部
const imageUrls = Array.from({ length: NUM_PHOTOS }, (_, i) =>
`https://picsum.photos/seed/sphere-${i}/400/300`
);
function vmin() {
const w = window.innerWidth;
const h = window.innerHeight;
return Math.min(w, h) / 100;
}
function radians(deg) { return (deg * Math.PI) / 180; }
function clamp(val, lo, hi) { return Math.max(lo, Math.min(hi, val)); }
function createPhoto(url, index) {
const el = document.createElement('div');
el.className = 'photo';
el.dataset.index = String(index);
const front = document.createElement('div');
front.className = 'face front';
const imgF = document.createElement('img');
imgF.decoding = 'async';
imgF.loading = 'lazy';
imgF.src = url;
front.appendChild(imgF);
const back = document.createElement('div');
back.className = 'face back';
const imgB = document.createElement('img');
imgB.decoding = 'async';
imgB.loading = 'lazy';
imgB.src = url;
back.appendChild(imgB);
el.appendChild(front);
el.appendChild(back);
el.addEventListener('mousemove', (e) => {
const r = el.getBoundingClientRect();
const mx = ((e.clientX - r.left) / r.width) * 100;
const my = ((e.clientY - r.top) / r.height) * 100;
el.style.setProperty('--mx', mx + '%');
el.style.setProperty('--my', my + '%');
}, { passive: true });
el.addEventListener('mouseenter', () => el.style.setProperty('--hs', '1.06'));
el.addEventListener('mouseleave', () => el.style.setProperty('--hs', '1'));
return el;
}
function vectorToEuler(x, y, z) {
const yaw = Math.atan2(x, z);
const hyp = Math.sqrt(x * x + z * z);
const pitch = Math.atan2(-y, hyp);
return { yaw, pitch };
}
function layeredSpherePoints(R) {
const pts = [];
const deltaTheta = (PHOTO_H * SPACING_Y) / R;
const thetaStart = deltaTheta * 0.5;
const thetaEnd = Math.PI - deltaTheta * 0.5;
let ringIndex = 0;
for (let theta = thetaStart; theta <= thetaEnd + 1e-6; theta += deltaTheta) {
const ringRadius = R * Math.sin(theta);
const circumference = 2 * Math.PI * ringRadius;
const targetSpacing = PHOTO_W * SPACING_X;
const count = Math.max(1, Math.floor(circumference / targetSpacing));
const phiOffset = (ringIndex % 2) * (Math.PI / count);
for (let j = 0; j < count; j++) {
const phi = (2 * Math.PI * j) / count + phiOffset;
const x = Math.cos(phi) * Math.sin(theta);
const y = Math.cos(theta);
const z = Math.sin(phi) * Math.sin(theta);
pts.push({ x, y, z });
}
ringIndex++;
}
return pts;
}
const photos = [];
function placePhotos() {
const R = RADIUS_VMIN * vmin();
const pts = layeredSpherePoints(R);
const frag = document.createDocumentFragment();
photos.length = 0;
pts.forEach((p, idx) => {
const el = createPhoto(imageUrls[idx % imageUrls.length], idx);
const { yaw, pitch } = vectorToEuler(p.x, p.y, p.z);
el.style.transform = `rotateY(${yaw}rad) rotateX(${pitch}rad) translateZ(${R}px) scale(var(--hs,1)) scale(var(--ds,1))`;
frag.appendChild(el);
photos.push({ el, p });
});
sceneEl.innerHTML = '';
sceneEl.appendChild(frag);
}
let rotationX = radians(-5);
let rotationY = radians(20);
let scale = 1;
let velX = 0;
let velY = 0;
let autoRotate = true;
let lightboxOpen = false;
let currentIndex = -1;
function rotateVector(p, rx, ry) {
const sinX = Math.sin(rx), cosX = Math.cos(rx);
const sinY = Math.sin(ry), cosY = Math.cos(ry);
const y1 = p.y * cosX - p.z * sinX;
const z1 = p.y * sinX + p.z * cosX;
const x1 = p.x;
const x2 = x1 * cosY + z1 * sinY;
const z2 = -x1 * sinY + z1 * cosY;
const y2 = y1;
return { x: x2, y: y2, z: z2 };
}
function render() {
if (autoRotate && !lightboxOpen) {
rotationY += AUTO_ROTATE_Y;
}
rotationX += velX;
rotationY += velY;
velX *= DAMPING;
velY *= DAMPING;
if (Math.abs(velX) < 1e-4) velX = 0;
if (Math.abs(velY) < 1e-4) velY = 0;
rotationX = clamp(rotationX, radians(-80), radians(80));
scale = clamp(scale, MIN_SCALE, MAX_SCALE);
for (let i = 0; i < photos.length; i++) {
const { el, p } = photos[i];
const v = rotateVector(p, rotationX, rotationY);
const d = (v.z + 1) / 2;
const sizeScale = 0.88 + d * 0.32;
const opacity = 0.6 + d * 0.4;
const blur = (1 - d) * 2;
el.style.setProperty('--ds', String(sizeScale));
el.style.setProperty('--opacity', String(opacity));
el.style.setProperty('--blur', blur.toFixed(2) + 'px');
el.style.zIndex = String(1000 + Math.floor(d * 1000));
}
sceneEl.style.transform = `scale(${scale}) rotateX(${rotationX}rad) rotateY(${rotationY}rad)`;
requestAnimationFrame(render);
}
let dragging = false;
let lastX = 0;
let lastY = 0;
let dragDist = 0;
function onPointerDown(e) {
if (lightboxOpen) return;
dragging = true;
autoRotate = false;
dragDist = 0;
const p = getPoint(e);
lastX = p.x;
lastY = p.y;
}
function onPointerMove(e) {
if (lightboxOpen || !dragging) return;
const p = getPoint(e);
const dx = p.x - lastX;
const dy = p.y - lastY;
lastX = p.x;
lastY = p.y;
dragDist += Math.hypot(dx, dy);
const s = 2 / Math.min(window.innerWidth, window.innerHeight);
velY = dx * s;
velX = dy * s;
}
function onPointerUp() {
if (lightboxOpen) return;
dragging = false;
setTimeout(() => { if (!dragging && !lightboxOpen) autoRotate = true; }, 1600);
}
function getPoint(e) {
if (e.touches && e.touches[0]) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
return { x: e.clientX, y: e.clientY };
}
function onWheel(e) {
if (lightboxOpen) return;
e.preventDefault();
const delta = Math.sign(e.deltaY);
const step = 0.06;
scale *= delta > 0 ? (1 - step) : (1 + step);
}
function onResize() {
placePhotos();
}
function openLightboxByIndex(index) {
currentIndex = (index + imageUrls.length) % imageUrls.length;
lightboxImg.src = imageUrls[currentIndex];
lightboxEl.classList.add('open');
lightboxEl.setAttribute('aria-hidden', 'false');
lightboxOpen = true;
autoRotate = false;
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
lightboxEl.classList.remove('open');
lightboxEl.setAttribute('aria-hidden', 'true');
lightboxOpen = false;
document.body.style.overflow = '';
setTimeout(() => { if (!dragging) autoRotate = true; }, 500);
}
function showNext(step) { openLightboxByIndex(currentIndex + step); }
sceneEl.addEventListener('click', (e) => {
if (lightboxOpen) return;
if (dragDist > 6) { dragDist = 0; return; }
const photo = e.target.closest('.photo');
if (!photo) return;
const idx = Number(photo.dataset.index || 0);
openLightboxByIndex(idx);
});
lightboxEl.addEventListener('click', (e) => {
if (e.target === lightboxEl) closeLightbox();
});
navPrev.addEventListener('click', (e) => { e.stopPropagation(); showNext(-1); });
navNext.addEventListener('click', (e) => { e.stopPropagation(); showNext(1); });
window.addEventListener('keydown', (e) => {
if (!lightboxOpen) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') showNext(-1);
if (e.key === 'ArrowRight') showNext(1);
});
let lx = 0; let ly = 0;
lightboxEl.addEventListener('touchstart', (e) => { if (!lightboxOpen) return; lx = e.touches[0].clientX; ly = e.touches[0].clientY; }, { passive: true });
lightboxEl.addEventListener('touchend', (e) => {
if (!lightboxOpen) return;
const dx = (e.changedTouches[0].clientX - lx);
const dy = (e.changedTouches[0].clientY - ly);
if (Math.abs(dx) > 30 && Math.abs(dx) > Math.abs(dy)) {
showNext(dx < 0 ? 1 : -1);
} else {
closeLightbox();
}
}, { passive: true });
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
autoRotate = false;
} else if (!lightboxOpen) {
autoRotate = true;
}
});
function init() {
placePhotos();
render();
const target = document.body;
target.addEventListener('mousedown', onPointerDown, { passive: true });
target.addEventListener('mousemove', onPointerMove, { passive: true });
window.addEventListener('mouseup', onPointerUp, { passive: true });
target.addEventListener('touchstart', onPointerDown, { passive: true });
target.addEventListener('touchmove', onPointerMove, { passive: true });
window.addEventListener('touchend', onPointerUp, { passive: true });
window.addEventListener('wheel', onWheel, { passive: false });
window.addEventListener('resize', onResize, { passive: true });
}
init();
</script>
</body>
</html>