# 液态扭曲 Liquid Distortion > 鼠标划过图片,图片像水一样流动,边缘带一点彩色折射;换图时像水面翻过去。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](https://www.dachuiai.com/llms.txt) · [动效选型规则](https://www.dachuiai.com/guides/motion.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[动效选型规则](https://www.dachuiai.com/guides/motion.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:作品集、摄影、时尚和艺术品牌的大图。 - 不适合:商品图(会把商品扭变形)、带字的截图、后台;同一页已经有别的 WebGL 或 Canvas 主角。 - 适合的网站:作品集、官网、活动页 - 放在:首屏、卡片 - 手机上:要改:手机上没有悬停,几乎看不到扭曲;手机上直接显示静态图。 - 性能代价:高:WebGL 每帧着色,一页最多一张。 - 常搭配:[光标跟随 Custom Cursor](https://www.dachuiai.com/c/custom-cursor.md)、[乱码解码 Scramble Text](https://www.dachuiai.com/c/scramble-text.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `liquid-distortion.tsx`(比如 `src/components/liquid-distortion.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/liquid-distortion.tsx),比手抄准:`curl -fsSL -o src/components/liquid-distortion.tsx https://www.dachuiai.com/code/liquid-distortion.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`LiquidImage` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 图片:示例用了本站的 [/art/fluid-rose.jpg](https://www.dachuiai.com/art/fluid-rose.jpg)、[/art/dunes.jpg](https://www.dachuiai.com/art/dunes.jpg)、[/art/aurora.jpg](https://www.dachuiai.com/art/aurora.jpg)、[/art/ice.jpg](https://www.dachuiai.com/art/ice.jpg)。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `src` | | 图片地址 | | `strength` | `0.11` | 扭曲的力度,越大水波越强 | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 作品大图加 Liquid Distortion 液态扭曲:鼠标划过时图片像水一样流动,边缘带一点彩色折射,用 WebGL 着色器做。 ## 完整代码 ```tsx import {useEffect, useRef, useState} from 'react'; /* --------------------------------------------------------------- * LiquidDistortion 液态扭曲 * 鼠标划过图片,图片像水一样流动,边缘还带一点彩色折射。 * 做法:用一张很小的"流速格子"(48×30)记录鼠标搅动的方向, * 每帧慢慢散开、衰减;WebGL 着色器按这张格子去错位取图。 * 切换图片时也走同一套扭曲,像水面翻过去。不依赖任何 3D 库。 * ------------------------------------------------------------- */ const VERT = `attribute vec2 p; varying vec2 v; void main(){ v = p * .5 + .5; gl_Position = vec4(p, 0., 1.); }`; const FRAG = `precision highp float; varying vec2 v; uniform sampler2D a, b, flow; uniform vec2 ca, cb; // 两张图的 cover 缩放 uniform float mixv, time, strength; float hash(vec2 p){ return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5); } float noise(vec2 p){ vec2 i = floor(p), f = fract(p); f = f * f * (3. - 2. * f); return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); } vec3 pick(sampler2D t, vec2 c, vec2 uv, vec2 d){ vec2 q = (uv - .5) * c + .5; return vec3(texture2D(t, q + d * 1.18).r, texture2D(t, q + d).g, texture2D(t, q + d * .82).b); } void main(){ vec2 f = (texture2D(flow, v).rg - .5) * 2.; float m = length(f); vec2 d = -f * strength; d += vec2(sin(v.y * 14. + time * 1.1), cos(v.x * 12. + time * .9)) * .0014; // 静止时也有一点水感 float n = noise(v * 5. + time * .15); float w = sin(mixv * 3.14159); // 切换到一半时扭曲最强 vec2 sw = (vec2(n, noise(v * 5. + 9.)) - .5) * w * .22; float edge = smoothstep(n * .6 - .05, n * .6 + .35, mixv * 1.4 - .1); vec3 col = mix(pick(a, ca, v, d + sw), pick(b, cb, v, d - sw * (1. - mixv)), edge); col += m * m * .22; // 被搅动的地方亮一点,像水面反光 gl_FragColor = vec4(col, 1.); }`; const GX = 48, GY = 30; function makeProgram(gl: WebGLRenderingContext) { const sh = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); return s; }; const pr = gl.createProgram()!; gl.attachShader(pr, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, FRAG)); gl.linkProgram(pr); gl.useProgram(pr); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const loc = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); return pr; } function texture(gl: WebGLRenderingContext, unit: number) { const t = gl.createTexture()!; gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t); for (const [k, val] of [[gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE], [gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE], [gl.TEXTURE_MIN_FILTER, gl.LINEAR], [gl.TEXTURE_MAG_FILTER, gl.LINEAR]]) gl.texParameteri(gl.TEXTURE_2D, k, val); return t; } export function LiquidImage({src, strength = 0.11, className = ''}: {src: string; strength?: number; className?: string}) { const ref = useRef(null); const [fallback, setFallback] = useState(false); const change = useRef<(src: string) => void>(() => {}); useEffect(() => { const canvas = ref.current!; const gl = canvas.getContext('webgl', {antialias: false, alpha: false}); if (!gl) return setFallback(true); const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const pr = makeProgram(gl); const U = (n: string) => gl.getUniformLocation(pr, n); const texA = texture(gl, 0), texB = texture(gl, 1); texture(gl, 2); gl.uniform1i(U('a'), 0); gl.uniform1i(U('b'), 1); gl.uniform1i(U('flow'), 2); gl.uniform1f(U('strength'), reduce ? 0 : strength); // 流速格子:每格两个分量 const vel = new Float32Array(GX * GY * 2), tmp = new Float32Array(GX * GY * 2), bytes = new Uint8Array(GX * GY * 4); const size = {a: [1, 1], b: [1, 1]}; let W = 1, H = 1, raf = 0, mixv = 0, target = '', running = false, t0 = performance.now(); const mouse = {x: 0, y: 0, px: 0, py: 0, on: false}; const cover = ([iw, ih]: number[]) => { const r = W / H / (iw / ih); return r > 1 ? [1, 1 / r] : [r, 1]; }; const upload = (unit: number, tex: WebGLTexture, img: HTMLImageElement) => { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, tex); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); }; const load = (url: string) => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = url; }); const shown = {a: null as HTMLImageElement | null}; change.current = async (url) => { if (url === target) return; target = url; const img = await load(url); if (url !== target) return; // 连续点击时只认最后一次 if (!shown.a) { shown.a = img; size.a = [img.width, img.height]; upload(0, texA, img); size.b = size.a; upload(1, texB, img); } else { size.b = [img.width, img.height]; upload(1, texB, img); const from = performance.now(); const step = (now: number) => { if (url !== target) return; const x = Math.min(1, (now - from) / (reduce ? 1 : 1100)); mixv = 1 - Math.pow(1 - x, 3); if (x < 1) requestAnimationFrame(step); else { shown.a = img; size.a = size.b; upload(0, texA, img); mixv = 0; } }; requestAnimationFrame(step); } }; const tick = (now: number) => { raf = requestAnimationFrame(tick); // 把鼠标这一帧的移动写进格子 if (mouse.on) { const dx = mouse.x - mouse.px, dy = mouse.y - mouse.py; mouse.px = mouse.x; mouse.py = mouse.y; const gx = mouse.x * GX, gy = mouse.y * GY, R = 5.5; for (let y = Math.max(0, (gy - R) | 0); y < Math.min(GY, gy + R + 1); y++) for (let x = Math.max(0, (gx - R) | 0); x < Math.min(GX, gx + R + 1); x++) { const d = Math.hypot(x - gx, y - gy); if (d > R) continue; const f = (1 - d / R) ** 2, i = (y * GX + x) * 2; vel[i] += dx * f * 14; vel[i + 1] += dy * f * 14; } } // 向四周散开一点 + 衰减 for (let y = 0; y < GY; y++) for (let x = 0; x < GX; x++) { const i = (y * GX + x) * 2, l = (y * GX + Math.max(0, x - 1)) * 2, r = (y * GX + Math.min(GX - 1, x + 1)) * 2, u = (Math.max(0, y - 1) * GX + x) * 2, d = (Math.min(GY - 1, y + 1) * GX + x) * 2; for (let c = 0; c < 2; c++) tmp[i + c] = (vel[i + c] * 0.72 + (vel[l + c] + vel[r + c] + vel[u + c] + vel[d + c]) * 0.07) * 0.972; } vel.set(tmp); for (let i = 0, n = GX * GY; i < n; i++) { bytes[i * 4] = Math.max(0, Math.min(255, 127.5 + vel[i * 2] * 127.5)); bytes[i * 4 + 1] = Math.max(0, Math.min(255, 127.5 + vel[i * 2 + 1] * 127.5)); } gl.activeTexture(gl.TEXTURE2); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, GX, GY, 0, gl.RGBA, gl.UNSIGNED_BYTE, bytes); gl.uniform1f(U('time'), (now - t0) / 1000); gl.uniform1f(U('mixv'), mixv); gl.uniform2fv(U('ca'), cover(size.a)); gl.uniform2fv(U('cb'), cover(size.b)); gl.drawArrays(gl.TRIANGLES, 0, 3); }; const start = () => { if (!running) { running = true; raf = requestAnimationFrame(tick); } }; const stop = () => { running = false; cancelAnimationFrame(raf); }; const resize = () => { const r = canvas.getBoundingClientRect(), dpr = Math.min(devicePixelRatio, 2); W = Math.max(1, r.width); H = Math.max(1, r.height); canvas.width = W * dpr; canvas.height = H * dpr; gl.viewport(0, 0, canvas.width, canvas.height); }; const move = (e: PointerEvent) => { const r = canvas.getBoundingClientRect(); mouse.x = (e.clientX - r.left) / r.width; mouse.y = 1 - (e.clientY - r.top) / r.height; if (!mouse.on) { mouse.px = mouse.x; mouse.py = mouse.y; mouse.on = true; } }; const leave = () => { mouse.on = false; }; const ro = new ResizeObserver(resize); ro.observe(canvas); const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop())); io.observe(canvas); canvas.addEventListener('pointermove', move); canvas.addEventListener('pointerleave', leave); change.current(src); return () => { stop(); ro.disconnect(); io.disconnect(); canvas.removeEventListener('pointermove', move); canvas.removeEventListener('pointerleave', leave); change.current = () => {}; }; // src 的变化走下面那个 effect,不重建 WebGL // eslint-disable-next-line react-hooks/exhaustive-deps }, [strength]); useEffect(() => { change.current(src); }, [src]); if (fallback) return ; return ; } /* ---------------- 示例:作品集首屏 ---------------- */ const WORKS = [ {title: '流体', sub: 'Fluid', img: '/art/fluid-rose.jpg'}, {title: '沙丘', sub: 'Dunes', img: '/art/dunes.jpg'}, {title: '极光', sub: 'Aurora', img: '/art/aurora.jpg'}, {title: '冰川', sub: 'Glacier', img: '/art/ice.jpg'}, ]; export default function LiquidDistortionDemo() { const [i, setI] = useState(0); const w = WORKS[i]; return (
{w.sub.toUpperCase()}

{w.title}

); } ```