一步一步实现 Shader 水波纹效果(入门到进阶)

水波纹的核心原理很简单:就像往水里扔石子,水面会泛起一圈圈涟漪。在 Shader 里,就是用正弦/余弦函数模拟这种"波动",再让纹理或顶点跟着波动"动起来"。

一、先搭个舞台:用 Three.js 加载图片当背景

要做水波纹,得先有个"水面"载体。这里我们用 Three.js 的 Shader 材质,加载一张图片当"水面"背景。

typescript 复制代码
import * as THREE from "three";
import BGImage from "../assets/bg.png";

export class Experience {
  private readonly scene: THREE.Scene;
  private readonly camera: THREE.OrthographicCamera;
  private readonly renderer: THREE.WebGLRenderer;
  private readonly bgTexture = new THREE.TextureLoader().load(BGImage);
  private readonly uniforms = {
    uTime: { value: 0 }, // 时间变量,用来控制波纹动画
    uResolution: {
      value: new THREE.Vector2(window.innerWidth, window.innerHeight),
    }, // 屏幕分辨率
    uClickPosition: { value: new THREE.Vector2(0, 0) }, // 鼠标点击位置
    uClickTime: { value: 0 }, // 点击发生的时间
    uTexture: { value: this.bgTexture }, // 背景纹理
    uAspect: { value: window.innerWidth / window.innerHeight }, // 屏幕宽高比
  };

  plane: THREE.Mesh | null = null;
  planeMaterial: THREE.ShaderMaterial | null = null;

  constructor() {
    this.scene = new THREE.Scene();
    // 正交相机,适合2D效果展示
    this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
    this.camera.position.set(0, 0, 1);

    this.renderer = new THREE.WebGLRenderer();
    this.renderer.setPixelRatio(window.devicePixelRatio);
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    document.body.appendChild(this.renderer.domElement);

    this.createPlane(); // 创建"水面"平面
    this.animation(); // 启动动画循环
    this.setupEventListeners(); // 监听鼠标点击
  }

  private createPlane() {
    const geometry = new THREE.PlaneGeometry(2, 2); // 创建立方体
    this.planeMaterial = new THREE.ShaderMaterial({
      transparent: true,
      side: THREE.DoubleSide, // 双面可见
      uniforms: this.uniforms,
      // 顶点着色器:传递纹理坐标给片元着色器
      vertexShader: /*glsl*/ `
        varying vec2 vUv;
        void main() {
          vUv = uv;
          gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
      `,
      // 片元着色器:暂时只显示原图
      fragmentShader: /*glsl*/ `
        varying vec2 vUv;
        uniform sampler2D uTexture;

        void main() {
          // 直接采样纹理颜色,相当于"静止的水面"
          gl_FragColor = texture2D(uTexture, vUv);
        }
      `,
    });
    this.plane = new THREE.Mesh(geometry, this.planeMaterial);
    this.scene.add(this.plane);
  }

  private setupEventListeners() {
    // 监听鼠标点击,记录点击位置和时间
    window.addEventListener("click", (e) => {
      this.uniforms.uClickTime.value = performance.now() / 1000;
      this.uniforms.uClickPosition.value = new THREE.Vector2(
        e.clientX / window.innerWidth,
        1 - e.clientY / window.innerHeight // 转换坐标,让点击位置和纹理坐标对应
      );
    });
  }

  private animation() {
    requestAnimationFrame(() => this.animation());
    this.uniforms.uTime.value = performance.now() / 1000; // 更新时间
    this.renderer.render(this.scene, this.camera); // 渲染场景
  }
}

关键代码解释

  • texture2D(uTexture, vUv):就像"贴墙纸",根据纹理坐标 vUvuTexture 中取对应位置的颜色。
  • gl_FragColor:片元的最终颜色,这里直接输出纹理颜色,所以屏幕上会显示原图。

二、画个圈圈:先实现一个静态圆环

水波纹是一圈圈扩散的,我们先从"画个圈圈"开始练手,搞个黑色圆环放在水面上。

glsl 复制代码
varying vec2 vUv;
uniform vec2 uResolution;
uniform float uAspect;
uniform sampler2D uTexture;

void main() {
  vec4 originalColor = texture2D(uTexture, vUv); // 原图颜色

  // 圆环三要素:中心、半径、厚度
  vec2 center = vec2(0.5, 0.5); // 屏幕中心
  float radius = 0.25; // 圆环半径
  float thickness = 0.05; // 圆环厚度

  // 计算当前像素到圆心的距离
  float dist = length(vUv - center);

  // 用 step 函数"切出"圆环:只保留距离在 [radius-thickness, radius] 之间的像素
  float ring = step(radius - thickness, dist) - step(radius, dist);

  // 混合颜色:圆环部分显示黑色,其他部分显示原图
  gl_FragColor = mix(originalColor, vec4(0.0, 0.0, 0.0, 1.0), ring);
}

效果如下:

Shader 黑科技:step 和 mix 函数

  • step(阈值, 数值):相当于"开关",数值 ≥ 阈值返回 1.0,否则返回 0.0。这里用两个 step 相减,精准"抠出"圆环区域。
  • mix(颜色A, 颜色B, 混合因子):相当于"调色盘",混合因子为 1 时显示颜色 B,为 0 时显示颜色 A,中间值则混合两者。

等价于"人话"代码

如果用 if 语句写,效果一样但效率低,Shader 里优先用内置函数:

glsl 复制代码
if (dist >= radius - thickness && dist <= radius) {
  gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); // 圆环部分变黑
} else {
  gl_FragColor = originalColor; // 其他部分显示原图
}

三、修复"椭圆 bug":让圆环在任意屏幕比例下都正

刚才的圆环在非 1:1 屏幕上会变成椭圆,就像把圆形按扁了。这是因为纹理坐标的宽高比和屏幕不一致,我们需要"矫正"坐标。

glsl 复制代码
varying vec2 vUv;
uniform vec2 uResolution;
uniform float uAspect;
uniform sampler2D uTexture;

void main() {
  vec4 originalColor = texture2D(uTexture, vUv);

  vec2 center = vec2(0.5, 0.5);
  float radius = 0.25;
  float thickness = 0.05;

  // 关键修复:矫正坐标比例
  vec2 correctedPos = vUv - center;
  correctedPos.x *= uResolution.x / uResolution.y; // 按屏幕宽高比调整x轴

  // 用矫正后的坐标计算距离
  float dist = length(correctedPos);

  float ring = step(radius - thickness, dist) - step(radius, dist);
  gl_FragColor = mix(originalColor, vec4(0.0, 0.0, 0.0, 1.0), ring);
}

原理

屏幕宽大于高时,x 轴会被"拉长",乘以 uResolution.x / uResolution.y 相当于把 x 轴"压缩"回去,让圆环恢复圆形。


四、让圈圈动起来:实现黑色部分的水波纹

现在圆环是静态的,我们要让它变成"涟漪"。核心是用正弦函数模拟波动,扰动纹理坐标,让颜色跟着"晃"。

glsl 复制代码
varying vec2 vUv;
uniform vec2 uResolution;
uniform float uAspect;
uniform sampler2D uTexture;

void main() {
  vec4 originalColor = texture2D(uTexture, vUv);

  vec2 center = vec2(0.5, 0.5);
  float radius = 0.25;
  float thickness = 0.05;

  vec2 correctedPos = vUv - center;
  correctedPos.x *= uResolution.x / uResolution.y;
  float dist = length(correctedPos);

  float ring = step(radius - thickness, dist) - step(radius, dist);

  // 🔥 波纹核心:用正弦函数模拟波动
  float waveFreq = 50.0; // 波纹密度,数值越大波纹越密
  float wave = sin(dist * waveFreq); // 正弦函数产生周期性波动
  float ripple = wave * 0.5 + 0.5; // 把波动范围从[-1,1]映射到[0,1]

  // 让波纹沿圆心向外扩散
  vec2 rippleDir = normalize(correctedPos); // 计算从圆心到当前像素的方向
  vec2 distortedUV = vUv + rippleDir * ripple * 0.02; // 扰动纹理坐标
  vec4 rippleColor = texture2D(uTexture, distortedUV); // 采样扰动后的颜色

  // 圆环部分显示波纹效果,其他部分显示原图
  gl_FragColor = mix(originalColor, rippleColor, ring);
}

效果如下:

原理

正弦函数的周期性刚好模拟了水波的起伏,扰动纹理坐标相当于让"水面"上的像素跟着波动偏移,从而产生波纹视觉效果。


五、互动起来:鼠标点击哪里,波纹就从哪里来

之前的波纹固定在屏幕中心,现在我们让它"听指挥",点击屏幕任意位置,波纹就从点击处扩散。

glsl 复制代码
varying vec2 vUv;
uniform vec2 uResolution;
uniform vec2 uClickPosition; // 鼠标点击位置(从Three.js传递过来)
uniform float uAspect;
uniform sampler2D uTexture;

void main() {
  vec4 originalColor = texture2D(uTexture, vUv);

  // 把圆心换成鼠标点击位置
  vec2 center = uClickPosition;
  float radius = 0.25;
  float thickness = 0.05;

  vec2 correctedPos = vUv - center;
  correctedPos.x *= uResolution.x / uResolution.y;
  float dist = length(correctedPos);

  float ring = step(radius - thickness, dist) - step(radius, dist);

  float waveFreq = 50.0;
  float wave = sin(dist * waveFreq);
  float ripple = wave * 0.5 + 0.5;
  vec2 rippleDir = normalize(correctedPos);
  vec2 distortedUV = vUv + rippleDir * ripple * 0.02;
  vec4 rippleColor = texture2D(uTexture, distortedUV);

  gl_FragColor = mix(originalColor, rippleColor, ring);
}

关键

Three.js 中已经通过 setupEventListeners 记录了点击位置 uClickPosition,这里直接用它作为圆心即可。


六、让波纹扩散:点击后圆环自动放大

现在波纹是固定大小的,我们要让它像真实水波一样,点击后从中心慢慢放大。

glsl 复制代码
varying vec2 vUv;
uniform vec2 uResolution;
uniform vec2 uClickPosition;
uniform float uTime; // 全局时间
uniform float uClickTime; // 点击发生的时间
uniform float uAspect;
uniform sampler2D uTexture;

void main() {
  vec4 originalColor = texture2D(uTexture, vUv);

  vec2 center = uClickPosition;
  // 🔥 半径随时间增长:点击后,半径 = 时间差 * 扩散速度
  float radius = (uTime - uClickTime) * 0.5;
  float thickness = 0.05;

  vec2 correctedPos = vUv - center;
  correctedPos.x *= uResolution.x / uResolution.y;
  float dist = length(correctedPos);

  float ring = step(radius - thickness, dist) - step(radius, dist);

  float waveFreq = 50.0;
  float wave = sin(dist * waveFreq);
  float ripple = wave * 0.5 + 0.5;
  vec2 rippleDir = normalize(correctedPos);
  vec2 distortedUV = vUv + rippleDir * ripple * 0.02;
  vec4 rippleColor = texture2D(uTexture, distortedUV);

  gl_FragColor = mix(originalColor, rippleColor, ring);
}

原理

uTime - uClickTime 是点击后的时间差,乘以扩散速度(0.5),半径就会随时间线性增长,看起来就是波纹在扩散。


七、优化视觉:让圆环边缘更平滑

之前的圆环边缘很锋利,真实水波边缘是渐变的,我们用 smoothstep 函数替换 step,让边缘变得平滑。

glsl 复制代码
// 🔥 关键修改:用 smoothstep 替代 step
float ring = smoothstep(radius - thickness, radius, dist) - smoothstep(radius, radius + thickness, dist);

smoothstep 函数

step 类似,但会在阈值区间内做平滑过渡,避免生硬的边缘。这里让圆环的内边缘和外边缘都有渐变,看起来更柔和。


八、模拟真实衰减:波纹扩散后慢慢消失

真实水波会越扩散越弱,最后消失。我们给波纹加个淡出效果,让它扩散到一定范围后慢慢变回原图。

glsl 复制代码
varying vec2 vUv;
uniform vec2 uResolution;
uniform vec2 uClickPosition;
uniform float uTime;
uniform float uClickTime;
uniform float uAspect;
uniform sampler2D uTexture;

void main() {
  vec4 originalColor = texture2D(uTexture, vUv);

  vec2 center = uClickPosition;
  float radius = (uTime - uClickTime) * 0.5;
  float thickness = 0.05;

  vec2 correctedPos = vUv - center;
  correctedPos.x *= uResolution.x / uResolution.y;
  float dist = length(correctedPos);

  float ring = smoothstep(radius - thickness, radius, dist) - smoothstep(radius, radius + thickness, dist);

  float waveFreq = 50.0;
  float wave = sin(dist * waveFreq);
  float ripple = wave * 0.5 + 0.5;
  vec2 rippleDir = normalize(correctedPos);
  vec2 distortedUV = vUv + rippleDir * ripple * 0.02;
  vec4 rippleColor = texture2D(uTexture, distortedUV);

  // 🔥 淡出逻辑:波纹扩散到一定半径后开始消失
  float fadeStart = 0.5; // 开始淡出的半径
  float fadeEnd = 1.0; // 完全消失的半径
  float fade = smoothstep(fadeStart, fadeEnd, radius); // 计算淡出因子

  // 波纹颜色随半径增大逐渐变回原图颜色
  rippleColor = mix(rippleColor, originalColor, fade);

  gl_FragColor = mix(originalColor, rippleColor, ring);
}

原理

当半径小于 fadeStart 时,fade 为 0,波纹效果完全显示;当半径在 fadeStartfadeEnd 之间时,fade 从 0 过渡到 1,波纹逐渐变淡;当半径大于 fadeEnd 时,fade 为 1,波纹完全消失。

相关推荐
lemonboy2 小时前
可视化大屏适配方案:用 Tailwind CSS 直接写设计稿像素值
前端·vue.js
鹏仔工作室2 小时前
vue中实现1小时不操作则退出登录功能
前端·javascript·vue.js
海云前端12 小时前
前端必备 Nginx 实战指南 8 个核心场景直接抄
前端
坚持就完事了2 小时前
001-初识HTML
前端·html
sophie旭2 小时前
一个偶现bug引发的onKeyDown 和 onChange之战
前端·javascript·react.js
前端加油站2 小时前
几种虚拟列表技术方案调研
前端·javascript·vue.js
玲小珑3 小时前
LangChain.js 完全开发手册(十八)AI 应用安全与伦理实践
前端·langchain·ai编程
JarvanMo3 小时前
8 个你可能忽略了的 Flutter 小部件(一)
前端
JarvanMo3 小时前
Flutter 中的微服务架构:拆解你的应用
前端