html+three.js制作球形照片墙送女友

797 阅读5分钟

最近帮朋友弄一个订婚的简单网页,我就搜了一下关键词: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>