基于 Three.js + GSAP 实现水波涟漪特效图片滑块

   <iframe 
      src="https://weibo.com/tv/show/1034:5296761200115784?from=old_pc_videoshow"
      allow="autoplay; fullscreen"
      allowfullscreen
      webkitallowfullscreen
      mozallowfullscreen>
    </iframe>

HTML

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Ripple Displacement Slider | Codegrid</title>
    <link rel="stylesheet" href="/styles.css" />
  </head>
  <body>
    <div class="slider">
      <div class="slide-content">
        <div class="slide-title">
          <h1>Blackwater '91</h1>
        </div>
        <div class="slide-description">
          <p>
            Flickering lanterns and twisted masks welcome unwanted visitors into
            a strange celebration beyond the forest trail.
          </p>
        </div>
      </div>
    </div>
    <script type="module" src="/script.js"></script>
  </body>
</html>

JS

import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import * as THREE from "three";
import { vertexShader, fragmentShader } from "./shaders.js";
import { slides } from "./slides.js";

gsap.registerPlugin(SplitText);

let currentIndex = 0;
let isTransitioning = false;
let rippleTween = null;

const slider = document.querySelector(".slider");

function splitTitle(container) {
  const heading = container.querySelector(".slide-title h1");
  if (!heading) return null;

  return SplitText.create(heading, {
    type: "words, chars",
    mask: "chars",
    wordsClass: "word",
    charsClass: "char",
  });
}

function splitDescription(container) {
  const paragraphs = container.querySelectorAll(".slide-description p");
  const allLines = [];

  paragraphs.forEach((p) => {
    const split = SplitText.create(p, {
      type: "lines",
      mask: "lines",
      linesClass: "line",
    });
    allLines.push(...split.lines);
  });

  return allLines;
}

function buildSlideContent(slide) {
  const el = document.createElement("div");
  el.className = "slide-content";
  el.style.opacity = "0";

  el.innerHTML = `
    <div class="slide-title"><h1>${slide.title}</h1></div>
    <div class="slide-description">
      <p>${slide.description}</p>
    </div>
  `;

  return el;
}

function animateTextOut(container) {
  const titleSplit = splitTitle(container);
  const lines = splitDescription(container);

  const tl = gsap.timeline();

  if (titleSplit) {
    tl.to(titleSplit.chars, {
      y: "-100%",
      duration: 0.6,
      stagger: 0.02,
      ease: "power2.inOut",
    });
  }

  tl.to(
    lines,
    { y: "-100%", duration: 0.6, stagger: 0.02, ease: "power2.inOut" },
    0.1,
  );

  return tl;
}

function animateTextIn(container) {
  const titleSplit = splitTitle(container);
  const lines = splitDescription(container);

  const chars = titleSplit ? titleSplit.chars : [];

  gsap.set([chars, lines], { y: "100%" });
  gsap.set(container, { opacity: 1 });

  return gsap
    .timeline()
    .to(chars, {
      y: "0%",
      duration: 0.5,
      stagger: 0.02,
      ease: "power2.inOut",
    })
    .to(
      lines,
      { y: "0%", duration: 0.5, stagger: 0.05, ease: "power2.out" },
      0.1,
    );
}

const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0.01, 10);
camera.position.z = 1;

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000000, 0);
slider.prepend(renderer.domElement);

const textureLoader = new THREE.TextureLoader();
const textures = [];

for (const slide of slides) {
  const texture = await new Promise((resolve) =>
    textureLoader.load(slide.image, resolve),
  );
  texture.minFilter = THREE.LinearFilter;
  texture.magFilter = THREE.LinearFilter;
  texture.wrapS = THREE.ClampToEdgeWrapping;
  texture.wrapT = THREE.ClampToEdgeWrapping;
  textures.push(texture);
}

const rippleConfig = {
  waveFreq: 25.0,
  wavePow: 0.035,
  waveWidth: 0.5,
  falloff: 10.0,
  boostStrength: 0.5,
  crossfadeWidth: 0.05,
  duration: 3.0,
  endValue: 1.0,
  ease: "power2.out",
};

const uniforms = {
  uTexCurrent: { value: textures[0] },
  uTexNext: { value: textures[1] },
  uProgress: { value: 0.0 },
  uResolution: { value: new THREE.Vector2() },
  uImageRes: { value: new THREE.Vector2(1920, 1280) },
  uWaveFreq: { value: rippleConfig.waveFreq },
  uWavePow: { value: rippleConfig.wavePow },
  uWaveWidth: { value: rippleConfig.waveWidth },
  uFalloff: { value: rippleConfig.falloff },
  uBoostStrength: { value: rippleConfig.boostStrength },
  uCrossfadeWidth: { value: rippleConfig.crossfadeWidth },
  uMobile: { value: window.innerWidth <= 1000 ? 1.0 : 0.0 },
};

const material = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  uniforms,
  transparent: true,
});

const plane = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
scene.add(plane);

function getMaxCornerDist() {
  const ratio = window.innerHeight / window.innerWidth;
  const cx = 0.5;
  const cy = 0.5 * ratio;
  return Math.sqrt(cx * cx + cy * cy);
}

function handleResize() {
  const width = slider.clientWidth;
  const height = slider.clientHeight;
  renderer.setSize(width, height);
  uniforms.uResolution.value.set(width, height);
  uniforms.uMobile.value = window.innerWidth <= 1000 ? 1.0 : 0.0;
  rippleConfig.endValue = getMaxCornerDist() + rippleConfig.waveWidth;
  rippleConfig.duration = window.innerWidth <= 1000 ? 1.5 : 3.0;
}

window.addEventListener("resize", handleResize);
handleResize();

const initialSlide = document.querySelector(".slide-content");
const initialTitle = splitTitle(initialSlide);
const initialLines = splitDescription(initialSlide);

gsap.fromTo(
  initialTitle.chars,
  { y: "100%" },
  { y: "0%", duration: 0.8, stagger: 0.025, ease: "power2.out" },
);

gsap.fromTo(
  initialLines,
  { y: "100%" },
  { y: "0%", duration: 0.8, stagger: 0.025, ease: "power2.out", delay: 0.2 },
);

function transition() {
  if (isTransitioning) return;
  isTransitioning = true;

  if (rippleTween) {
    rippleTween.kill();
    uniforms.uProgress.value = 0.0;
    rippleTween = null;
  }

  const nextIndex = (currentIndex + 1) % slides.length;
  const currentSlide = document.querySelector(".slide-content");

  const exitTimeline = animateTextOut(currentSlide);

  uniforms.uTexCurrent.value = textures[currentIndex];
  uniforms.uTexNext.value = textures[nextIndex];
  uniforms.uProgress.value = 0.0;

  let clickUnlocked = false;

  rippleTween = gsap.to(uniforms.uProgress, {
    value: rippleConfig.endValue,
    duration: rippleConfig.duration,
    ease: rippleConfig.ease,
    delay: 0.3,
    onUpdate() {
      if (!clickUnlocked && uniforms.uProgress.value > 0.7) {
        clickUnlocked = true;
        currentIndex = nextIndex;
        isTransitioning = false;
      }
    },
    onComplete() {
      uniforms.uTexCurrent.value = textures[currentIndex];
      uniforms.uProgress.value = 0.0;
      rippleTween = null;

      if (!clickUnlocked) {
        currentIndex = nextIndex;
        isTransitioning = false;
      }
    },
  });

  exitTimeline.then(() => {
    currentSlide.remove();

    const nextSlide = buildSlideContent(slides[nextIndex]);
    slider.appendChild(nextSlide);

    requestAnimationFrame(() => {
      animateTextIn(nextSlide);
    });
  });
}

slider.addEventListener("click", transition);

function render() {
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}

render();

css

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: "PP Neue Montreal", sans-serif;
}

h1 {
  font-size: clamp(2rem, 4vw, 6rem);
  font-weight: 500;
  letter-spacing: -2%;
  line-height: 1.25;
}

p {
  font-weight: 500;
}

.slider {
  position: fixed;
  width: 100%;
  height: 100svh;
  background: #e0ddcf;
  overflow: hidden;
}

.slider canvas {
  display: block;
  width: 100%;
  height: 100%;
}

.slide-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  mix-blend-mode: difference;
  user-select: none;
  pointer-events: none;
  z-index: 2;
}

.slide-title {
  position: absolute;
  top: 50%;
  left: 3rem;
  transform: translate(0%, -50%);
  width: max-content;
  color: #fff;
}

.slide-description {
  position: absolute;
  top: 50%;
  right: 3rem;
  transform: translate(0%, -50%);
  width: 15%;
  min-width: 250px;
  color: #fff;
  display: flex;
  flex-direction: column;
  gap: 2rem;
  z-index: 2;
}

.char,
.line {
  display: inline-block;
  will-change: transform;
  position: relative;
}

@media (max-width: 1000px) {
  .slide-title {
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }

  .slide-description {
    width: 75%;
    text-align: center;
    top: unset;
    bottom: 5%;
    left: 50%;
    transform: translate(-50%, -50%);
  }
}
让链接同时具备两种打开方式
获取源码: CODE
下载数:1人次, 文件大小: 7.2 MB, 上传日期: 2026年-5 月-09日

公众号回复:gcode  获取解压密码

qrcode_for_gh_6ea2c28a1709_258 (1)

2 人查阅
Avatar photo

code