一步一步实现 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,波纹完全消失。

相关推荐
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅13 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅14 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment14 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅14 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊14 小时前
jwt介绍
前端
爱敲代码的小鱼14 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax