# 交互液态流体 Fluid Simulation > 颜色像颜料入水、跟着鼠标走的流体背景;没人操作时隔几秒自己划一笔。基于开源 WebGL Fluid Simulation(MIT)。 分类:背景 [全部组件和流程](https://www.dachuiai.com/llms.txt) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:作品集、设计工作室、创意活动的首屏,要一个能玩的、会回应鼠标的背景。 - 不适合:严肃的企业、金融、政务页面;后台;要安静阅读的内容页。 - 适合的网站:作品集、活动页、官网 - 放在:首屏、背景 - 手机上:好用:手指划过同样会搅动;手机上染料分辨率会自动降低。 - 性能代价:中:GPU 上每帧解一次流体(约 30 次小计算),一般电脑轻松 60 帧。 - 常搭配:[乱码解码 Scramble Text](https://www.dachuiai.com/c/scramble-text.md)、[光标跟随 Custom Cursor](https://www.dachuiai.com/c/custom-cursor.md)、[磁吸 Magnetic](https://www.dachuiai.com/c/magnetic.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `liquid-fluid.tsx`(比如 `src/components/liquid-fluid.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/liquid-fluid.tsx),比手抄准:`curl -fsSL -o src/components/liquid-fluid.tsx https://www.dachuiai.com/code/liquid-fluid.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`mountLiquidFluid`、`LiquidFluid` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:背景铺满它所在的容器:容器写 relative,组件写 className="absolute inset-0",内容放在它后面并设 relative 或 absolute 盖在上面。滚出屏幕、切走标签页时自己暂停。颜色在文件开头的 TONES 里改(色相、饱和度、亮度)。保留文件开头 Pavel Dobryakov 的 MIT 许可声明。不是 React(纯 HTML、网页 PPT、Vue):文件里的 mountLiquidFluid(元素) 不依赖 React,删掉类型标注存成 .js 就能调用,返回的函数用来清理;那个元素要有大小(比如 position:absolute; inset:0)。 - 保留代码里 `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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **mountLiquidFluid** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `host` | | 要铺满背景的那个元素(DOM 节点),它要有大小 | **LiquidFluid** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 基于开源项目 WebGL Fluid Simulation(MIT)做一个全屏流体背景:在 GPU 上解不可压缩流体(平流、涡度增强、压力投影),染料加辉光和光束;颜色在玫瑰红、深青、绯红、灰粉之间轮换;模拟时间放慢一半;最亮的颜色通道超过拐点后平滑压到上限,浓的地方不发白;没人操作时每隔几秒自动划一笔,鼠标划过也会注入颜色和力。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; /* --------------------------------------------------------------- * LiquidFluid 交互液态流体 * 鼠标一划,颜色就像颜料泼进水里,跟着手走。底下是开源项目 WebGL Fluid Simulation(MIT,作者 Pavel Dobryakov) * 的流体求解器:在 GPU 上解不可压缩流体(平流、涡度增强、压力投影),染料加辉光和光束。 * 这一版的调法:颜色在玫瑰红、深青、绯红、灰粉之间轮换;模拟时间放慢一半,当背景不晃眼; * 最亮的颜色通道超过拐点后平滑压到上限,浓的地方不发白;没人操作时每隔几秒自动划一笔。 * 滚出屏幕、切到别的标签页时暂停;开了"减弱动态效果"时停在一帧。 * ------------------------------------------------------------- */ /* ---------------- 流体求解器 ---------------- * 来自 WebGL Fluid Simulation(https://github.com/PavelDoGreat/WebGL-Fluid-Simulation), * 求解器原样保留,改写成 TypeScript,去掉了截图、设置面板这些用不上的部分,加了颜色、显示着色器、预跑的入口。 * * MIT License * * Copyright (c) 2017 Pavel Dobryakov * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ type RGB = {r: number; g: number; b: number}; type FBO = {texture: WebGLTexture; fbo: WebGLFramebuffer; width: number; height: number; texelSizeX: number; texelSizeY: number; attach: (id: number) => number}; type DoubleFBO = {width: number; height: number; texelSizeX: number; texelSizeY: number; read: FBO; write: FBO; swap: () => void}; type Format = {internalFormat: number; format: number}; type FluidConfig = typeof FLUID_DEFAULTS; const FLUID_DEFAULTS = { SIM_RESOLUTION: 128, DYE_RESOLUTION: 1024, DENSITY_DISSIPATION: 0.35, VELOCITY_DISSIPATION: 0.2, PRESSURE: 0.8, PRESSURE_ITERATIONS: 20, CURL: 30, SPLAT_RADIUS: 0.25, SHADING: true, BACK_COLOR: {r: 0, g: 0, b: 0}, BLOOM: true, BLOOM_ITERATIONS: 8, BLOOM_RESOLUTION: 256, BLOOM_INTENSITY: 0.8, BLOOM_THRESHOLD: 0.6, BLOOM_SOFT_KNEE: 0.7, SUNRAYS: true, SUNRAYS_RESOLUTION: 196, SUNRAYS_WEIGHT: 1.0, }; const BASE_VS = ` precision highp float; attribute vec2 aPosition; varying vec2 vUv; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB; uniform vec2 texelSize; void main () { vUv = aPosition * 0.5 + 0.5; vL = vUv - vec2(texelSize.x, 0.0); vR = vUv + vec2(texelSize.x, 0.0); vT = vUv + vec2(0.0, texelSize.y); vB = vUv - vec2(0.0, texelSize.y); gl_Position = vec4(aPosition, 0.0, 1.0); }`; const COPY_FS = ` precision mediump float; precision mediump sampler2D; varying highp vec2 vUv; uniform sampler2D uTexture; void main () { gl_FragColor = texture2D(uTexture, vUv); }`; const CLEAR_FS = ` precision mediump float; precision mediump sampler2D; varying highp vec2 vUv; uniform sampler2D uTexture; uniform float value; void main () { gl_FragColor = value * texture2D(uTexture, vUv); }`; const COLOR_FS = ` precision mediump float; uniform vec4 color; void main () { gl_FragColor = color; }`; const SPLAT_FS = ` precision highp float; precision highp sampler2D; varying vec2 vUv; uniform sampler2D uTarget; uniform float aspectRatio; uniform vec3 color; uniform vec2 point; uniform float radius; void main () { vec2 p = vUv - point.xy; p.x *= aspectRatio; vec3 splat = exp(-dot(p, p) / radius) * color; vec3 base = texture2D(uTarget, vUv).xyz; gl_FragColor = vec4(base + splat, 1.0); }`; const ADVECTION_FS = ` precision highp float; precision highp sampler2D; varying vec2 vUv; uniform sampler2D uVelocity; uniform sampler2D uSource; uniform vec2 texelSize; uniform float dt; uniform float dissipation; void main () { vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize; vec4 result = texture2D(uSource, coord); float decay = 1.0 + dissipation * dt; gl_FragColor = result / decay; }`; const DIVERGENCE_FS = ` precision mediump float; precision mediump sampler2D; varying highp vec2 vUv; varying highp vec2 vL; varying highp vec2 vR; varying highp vec2 vT; varying highp vec2 vB; uniform sampler2D uVelocity; void main () { float L = texture2D(uVelocity, vL).x; float R = texture2D(uVelocity, vR).x; float T = texture2D(uVelocity, vT).y; float B = texture2D(uVelocity, vB).y; vec2 C = texture2D(uVelocity, vUv).xy; if (vL.x < 0.0) { L = -C.x; } if (vR.x > 1.0) { R = -C.x; } if (vT.y > 1.0) { T = -C.y; } if (vB.y < 0.0) { B = -C.y; } float div = 0.5 * (R - L + T - B); gl_FragColor = vec4(div, 0.0, 0.0, 1.0); }`; const CURL_FS = ` precision mediump float; precision mediump sampler2D; varying highp vec2 vUv; varying highp vec2 vL; varying highp vec2 vR; varying highp vec2 vT; varying highp vec2 vB; uniform sampler2D uVelocity; void main () { float L = texture2D(uVelocity, vL).y; float R = texture2D(uVelocity, vR).y; float T = texture2D(uVelocity, vT).x; float B = texture2D(uVelocity, vB).x; float vorticity = R - L - T + B; gl_FragColor = vec4(0.5 * vorticity, 0.0, 0.0, 1.0); }`; const VORTICITY_FS = ` precision highp float; precision highp sampler2D; varying vec2 vUv; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB; uniform sampler2D uVelocity; uniform sampler2D uCurl; uniform float curl; uniform float dt; void main () { float L = texture2D(uCurl, vL).x; float R = texture2D(uCurl, vR).x; float T = texture2D(uCurl, vT).x; float B = texture2D(uCurl, vB).x; float C = texture2D(uCurl, vUv).x; vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L)); force /= length(force) + 0.0001; force *= curl * C; force.y *= -1.0; vec2 velocity = texture2D(uVelocity, vUv).xy; velocity += force * dt; velocity = min(max(velocity, -1000.0), 1000.0); gl_FragColor = vec4(velocity, 0.0, 1.0); }`; const PRESSURE_FS = ` precision mediump float; precision mediump sampler2D; varying highp vec2 vUv; varying highp vec2 vL; varying highp vec2 vR; varying highp vec2 vT; varying highp vec2 vB; uniform sampler2D uPressure; uniform sampler2D uDivergence; void main () { float L = texture2D(uPressure, vL).x; float R = texture2D(uPressure, vR).x; float T = texture2D(uPressure, vT).x; float B = texture2D(uPressure, vB).x; float divergence = texture2D(uDivergence, vUv).x; float pressure = (L + R + B + T - divergence) * 0.25; gl_FragColor = vec4(pressure, 0.0, 0.0, 1.0); }`; const GRADIENT_SUBTRACT_FS = ` precision mediump float; precision mediump sampler2D; varying highp vec2 vUv; varying highp vec2 vL; varying highp vec2 vR; varying highp vec2 vT; varying highp vec2 vB; uniform sampler2D uPressure; uniform sampler2D uVelocity; void main () { float L = texture2D(uPressure, vL).x; float R = texture2D(uPressure, vR).x; float T = texture2D(uPressure, vT).x; float B = texture2D(uPressure, vB).x; vec2 velocity = texture2D(uVelocity, vUv).xy; velocity.xy -= vec2(R - L, T - B); gl_FragColor = vec4(velocity, 0.0, 1.0); }`; const BLUR_VS = ` precision highp float; attribute vec2 aPosition; varying vec2 vUv; varying vec2 vL; varying vec2 vR; uniform vec2 texelSize; void main () { vUv = aPosition * 0.5 + 0.5; float offset = 1.33333333; vL = vUv - texelSize * offset; vR = vUv + texelSize * offset; gl_Position = vec4(aPosition, 0.0, 1.0); }`; const BLUR_FS = ` precision mediump float; precision mediump sampler2D; varying vec2 vUv; varying vec2 vL; varying vec2 vR; uniform sampler2D uTexture; void main () { vec4 sum = texture2D(uTexture, vUv) * 0.29411764; sum += texture2D(uTexture, vL) * 0.35294117; sum += texture2D(uTexture, vR) * 0.35294117; gl_FragColor = sum; }`; const BLOOM_PREFILTER_FS = ` precision mediump float; precision mediump sampler2D; varying vec2 vUv; uniform sampler2D uTexture; uniform vec3 curve; uniform float threshold; void main () { vec3 c = texture2D(uTexture, vUv).rgb; float br = max(c.r, max(c.g, c.b)); float rq = clamp(br - curve.x, 0.0, curve.y); rq = curve.z * rq * rq; c *= max(rq, br - threshold) / max(br, 0.0001); gl_FragColor = vec4(c, 0.0); }`; const BLOOM_BLUR_FS = ` precision mediump float; precision mediump sampler2D; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB; uniform sampler2D uTexture; void main () { vec4 sum = vec4(0.0); sum += texture2D(uTexture, vL); sum += texture2D(uTexture, vR); sum += texture2D(uTexture, vT); sum += texture2D(uTexture, vB); gl_FragColor = sum * 0.25; }`; const BLOOM_FINAL_FS = ` precision mediump float; precision mediump sampler2D; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB; uniform sampler2D uTexture; uniform float intensity; void main () { vec4 sum = vec4(0.0); sum += texture2D(uTexture, vL); sum += texture2D(uTexture, vR); sum += texture2D(uTexture, vT); sum += texture2D(uTexture, vB); gl_FragColor = sum * 0.25 * intensity; }`; const SUNRAYS_MASK_FS = ` precision highp float; precision highp sampler2D; varying vec2 vUv; uniform sampler2D uTexture; void main () { vec4 c = texture2D(uTexture, vUv); float br = max(c.r, max(c.g, c.b)); c.a = 1.0 - min(max(br * 20.0, 0.0), 0.8); gl_FragColor = c; }`; const SUNRAYS_FS = ` precision highp float; precision highp sampler2D; varying vec2 vUv; uniform sampler2D uTexture; uniform float weight; #define ITERATIONS 16 void main () { float Density = 0.3; float Decay = 0.95; float Exposure = 0.7; vec2 coord = vUv; vec2 dir = vUv - 0.5; dir *= 1.0 / float(ITERATIONS) * Density; float illuminationDecay = 1.0; float color = texture2D(uTexture, vUv).a; for (int i = 0; i < ITERATIONS; i++) { coord -= dir; float col = texture2D(uTexture, coord).a; color += col * illuminationDecay * weight; illuminationDecay *= Decay; } gl_FragColor = vec4(color * Exposure, 0.0, 0.0, 1.0); }`; type FluidOptions = { config: Partial; display: string; // 显示着色器(把染料画到屏幕上) defines: string[]; // 显示着色器里打开的开关,比如 SHADING、BLOOM dpr: number; // 画布分辨率最多按屏幕像素的几倍算 timeScale: number; // < 1 时整个模拟放慢 color: () => RGB; // 自动注入的颜色 burst: number; // 自动注入时颜色放大几倍 }; function createFluid(host: HTMLElement, canvas: HTMLCanvasElement, gl: WebGL2RenderingContext, o: FluidOptions) { const config: FluidConfig = {...FLUID_DEFAULTS, ...o.config}; gl.getExtension('EXT_color_buffer_float'); if (/Mobi|Android/i.test(navigator.userAgent)) config.DYE_RESOLUTION = 512; // 半浮点纹理能不能当渲染目标:R16F → RG16F → RGBA16F 依次退 const supports = (internalFormat: number, format: number) => { const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, 4, 4, 0, format, gl.HALF_FLOAT, null); const f = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, f); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0); const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; gl.deleteFramebuffer(f); gl.deleteTexture(t); return ok; }; const pick = (list: [number, number][]): Format => { const f = list.find(([i, fm]) => supports(i, fm)); if (!f) throw new Error('half float'); return {internalFormat: f[0], format: f[1]}; }; const rgba = pick([[gl.RGBA16F, gl.RGBA]]); const rg = pick([[gl.RG16F, gl.RG], [gl.RGBA16F, gl.RGBA]]); const r1 = pick([[gl.R16F, gl.RED], [gl.RG16F, gl.RG], [gl.RGBA16F, gl.RGBA]]); const compile = (type: number, src: string, defines: string[] = []) => { const s = gl.createShader(type)!; gl.shaderSource(s, defines.map((d) => `#define ${d}\n`).join('') + src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? 'shader'); return s; }; const baseVS = compile(gl.VERTEX_SHADER, BASE_VS); const make = (fs: string, vs = baseVS, defines: string[] = []) => { const p = gl.createProgram()!; gl.attachShader(p, vs); gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs, defines)); gl.bindAttribLocation(p, 0, 'aPosition'); gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) ?? 'link'); const u: Record = {}; for (let i = 0, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); i < n; i++) { const name = gl.getActiveUniform(p, i)!.name; u[name] = gl.getUniformLocation(p, name); } return {u, bind: () => gl.useProgram(p)}; }; const copyP = make(COPY_FS), clearP = make(CLEAR_FS), colorP = make(COLOR_FS), splatP = make(SPLAT_FS), advectionP = make(ADVECTION_FS); const divergenceP = make(DIVERGENCE_FS), curlP = make(CURL_FS), vorticityP = make(VORTICITY_FS), pressureP = make(PRESSURE_FS), gradientP = make(GRADIENT_SUBTRACT_FS); const displayP = make(o.display, baseVS, o.defines); const blurVS = compile(gl.VERTEX_SHADER, BLUR_VS); const blurP = make(BLUR_FS, blurVS), prefilterP = make(BLOOM_PREFILTER_FS), bloomBlurP = make(BLOOM_BLUR_FS), bloomFinalP = make(BLOOM_FINAL_FS); const raysMaskP = make(SUNRAYS_MASK_FS), raysP = make(SUNRAYS_FS); // 辉光的抖动纹理:一张 64×64 的随机噪点,消掉辉光里的色带 const dither = (() => { const N = 64, px = new Uint8Array(N * N * 4).map(() => Math.random() * 255); const t = gl.createTexture()!; gl.bindTexture(gl.TEXTURE_2D, t); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, N, N, 0, gl.RGBA, gl.UNSIGNED_BYTE, px); return {texture: t, width: N, height: N}; })(); // 全屏的两个三角形 gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0); const blit = (target: FBO | null, clear = false) => { if (target == null) { gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); gl.bindFramebuffer(gl.FRAMEBUFFER, null); } else { gl.viewport(0, 0, target.width, target.height); gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo); } if (clear) { gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); } gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0); }; const createFBO = (w: number, h: number, f: Format, param: number): FBO => { gl.activeTexture(gl.TEXTURE0); const texture = gl.createTexture()!; gl.bindTexture(gl.TEXTURE_2D, texture); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, param); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, param); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D(gl.TEXTURE_2D, 0, f.internalFormat, w, h, 0, f.format, gl.HALF_FLOAT, null); const fbo = gl.createFramebuffer()!; gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0); gl.viewport(0, 0, w, h); gl.clear(gl.COLOR_BUFFER_BIT); return {texture, fbo, width: w, height: h, texelSizeX: 1 / w, texelSizeY: 1 / h, attach: (id) => { gl.activeTexture(gl.TEXTURE0 + id); gl.bindTexture(gl.TEXTURE_2D, texture); return id; }}; }; const createDoubleFBO = (w: number, h: number, f: Format, param: number): DoubleFBO => { let a = createFBO(w, h, f, param), b = createFBO(w, h, f, param); return { width: w, height: h, texelSizeX: a.texelSizeX, texelSizeY: a.texelSizeY, get read() { return a; }, set read(v) { a = v; }, get write() { return b; }, set write(v) { b = v; }, swap() { const t = a; a = b; b = t; }, }; }; const resizeDoubleFBO = (target: DoubleFBO, w: number, h: number, f: Format, param: number) => { if (target.width === w && target.height === h) return target; const next = createFBO(w, h, f, param); copyP.bind(); gl.uniform1i(copyP.u.uTexture, target.read.attach(0)); blit(next); target.read = next; target.write = createFBO(w, h, f, param); target.width = w; target.height = h; target.texelSizeX = 1 / w; target.texelSizeY = 1 / h; return target; }; const resolution = (res: number) => { let aspect = gl.drawingBufferWidth / gl.drawingBufferHeight; if (aspect < 1) aspect = 1 / aspect; const min = Math.round(res), max = Math.round(res * aspect); return gl.drawingBufferWidth > gl.drawingBufferHeight ? {width: max, height: min} : {width: min, height: max}; }; let dye: DoubleFBO, velocity: DoubleFBO, divergence: FBO, curl: FBO, pressure: DoubleFBO; let bloom: FBO, sunrays: FBO, sunraysTemp: FBO; const bloomFBOs: FBO[] = []; const initFramebuffers = () => { const sim = resolution(config.SIM_RESOLUTION), dyeRes = resolution(config.DYE_RESOLUTION); gl.disable(gl.BLEND); dye = dye ? resizeDoubleFBO(dye, dyeRes.width, dyeRes.height, rgba, gl.LINEAR) : createDoubleFBO(dyeRes.width, dyeRes.height, rgba, gl.LINEAR); velocity = velocity ? resizeDoubleFBO(velocity, sim.width, sim.height, rg, gl.LINEAR) : createDoubleFBO(sim.width, sim.height, rg, gl.LINEAR); divergence = createFBO(sim.width, sim.height, r1, gl.NEAREST); curl = createFBO(sim.width, sim.height, r1, gl.NEAREST); pressure = createDoubleFBO(sim.width, sim.height, r1, gl.NEAREST); const b = resolution(config.BLOOM_RESOLUTION); bloom = createFBO(b.width, b.height, rgba, gl.LINEAR); bloomFBOs.length = 0; for (let i = 0; i < config.BLOOM_ITERATIONS; i++) { const w = b.width >> (i + 1), h = b.height >> (i + 1); if (w < 2 || h < 2) break; bloomFBOs.push(createFBO(w, h, rgba, gl.LINEAR)); } const s = resolution(config.SUNRAYS_RESOLUTION); sunrays = createFBO(s.width, s.height, r1, gl.LINEAR); sunraysTemp = createFBO(s.width, s.height, r1, gl.LINEAR); }; const resizeCanvas = () => { const r = host.getBoundingClientRect(), k = Math.min(devicePixelRatio || 1, o.dpr); const w = Math.max(64, Math.round(r.width * k)), h = Math.max(64, Math.round(r.height * k)); if (canvas.width === w && canvas.height === h) return false; canvas.width = w; canvas.height = h; return true; }; const splat = (x: number, y: number, dx: number, dy: number, color: RGB) => { splatP.bind(); gl.uniform1i(splatP.u.uTarget, velocity.read.attach(0)); gl.uniform1f(splatP.u.aspectRatio, canvas.width / canvas.height); gl.uniform2f(splatP.u.point, x, y); gl.uniform3f(splatP.u.color, dx, dy, 0); const aspect = canvas.width / canvas.height; gl.uniform1f(splatP.u.radius, (config.SPLAT_RADIUS / 100) * (aspect > 1 ? aspect : 1)); blit(velocity.write); velocity.swap(); gl.uniform1i(splatP.u.uTarget, dye.read.attach(0)); gl.uniform3f(splatP.u.color, color.r, color.g, color.b); blit(dye.write); dye.swap(); }; const multipleSplats = (amount: number) => { for (let i = 0; i < amount; i++) { const c = o.color(); splat(Math.random(), Math.random(), 1000 * (Math.random() - 0.5), 1000 * (Math.random() - 0.5), {r: c.r * o.burst, g: c.g * o.burst, b: c.b * o.burst}); } }; const step = (dt: number) => { gl.disable(gl.BLEND); curlP.bind(); gl.uniform2f(curlP.u.texelSize, velocity.texelSizeX, velocity.texelSizeY); gl.uniform1i(curlP.u.uVelocity, velocity.read.attach(0)); blit(curl); vorticityP.bind(); gl.uniform2f(vorticityP.u.texelSize, velocity.texelSizeX, velocity.texelSizeY); gl.uniform1i(vorticityP.u.uVelocity, velocity.read.attach(0)); gl.uniform1i(vorticityP.u.uCurl, curl.attach(1)); gl.uniform1f(vorticityP.u.curl, config.CURL); gl.uniform1f(vorticityP.u.dt, dt); blit(velocity.write); velocity.swap(); divergenceP.bind(); gl.uniform2f(divergenceP.u.texelSize, velocity.texelSizeX, velocity.texelSizeY); gl.uniform1i(divergenceP.u.uVelocity, velocity.read.attach(0)); blit(divergence); clearP.bind(); gl.uniform1i(clearP.u.uTexture, pressure.read.attach(0)); gl.uniform1f(clearP.u.value, config.PRESSURE); blit(pressure.write); pressure.swap(); pressureP.bind(); gl.uniform2f(pressureP.u.texelSize, velocity.texelSizeX, velocity.texelSizeY); gl.uniform1i(pressureP.u.uDivergence, divergence.attach(0)); for (let i = 0; i < config.PRESSURE_ITERATIONS; i++) { gl.uniform1i(pressureP.u.uPressure, pressure.read.attach(1)); blit(pressure.write); pressure.swap(); } gradientP.bind(); gl.uniform2f(gradientP.u.texelSize, velocity.texelSizeX, velocity.texelSizeY); gl.uniform1i(gradientP.u.uPressure, pressure.read.attach(0)); gl.uniform1i(gradientP.u.uVelocity, velocity.read.attach(1)); blit(velocity.write); velocity.swap(); advectionP.bind(); gl.uniform2f(advectionP.u.texelSize, velocity.texelSizeX, velocity.texelSizeY); const vid = velocity.read.attach(0); gl.uniform1i(advectionP.u.uVelocity, vid); gl.uniform1i(advectionP.u.uSource, vid); gl.uniform1f(advectionP.u.dt, dt); gl.uniform1f(advectionP.u.dissipation, config.VELOCITY_DISSIPATION); blit(velocity.write); velocity.swap(); gl.uniform1i(advectionP.u.uVelocity, velocity.read.attach(0)); gl.uniform1i(advectionP.u.uSource, dye.read.attach(1)); gl.uniform1f(advectionP.u.dissipation, config.DENSITY_DISSIPATION); blit(dye.write); dye.swap(); }; const applyBloom = (source: FBO, destination: FBO) => { if (bloomFBOs.length < 2) return; let last = destination; gl.disable(gl.BLEND); prefilterP.bind(); const knee = config.BLOOM_THRESHOLD * config.BLOOM_SOFT_KNEE + 0.0001; gl.uniform3f(prefilterP.u.curve, config.BLOOM_THRESHOLD - knee, knee * 2, 0.25 / knee); gl.uniform1f(prefilterP.u.threshold, config.BLOOM_THRESHOLD); gl.uniform1i(prefilterP.u.uTexture, source.attach(0)); blit(last); bloomBlurP.bind(); for (const dest of bloomFBOs) { gl.uniform2f(bloomBlurP.u.texelSize, last.texelSizeX, last.texelSizeY); gl.uniform1i(bloomBlurP.u.uTexture, last.attach(0)); blit(dest); last = dest; } gl.blendFunc(gl.ONE, gl.ONE); gl.enable(gl.BLEND); for (let i = bloomFBOs.length - 2; i >= 0; i--) { const base = bloomFBOs[i]; gl.uniform2f(bloomBlurP.u.texelSize, last.texelSizeX, last.texelSizeY); gl.uniform1i(bloomBlurP.u.uTexture, last.attach(0)); gl.viewport(0, 0, base.width, base.height); blit(base); last = base; } gl.disable(gl.BLEND); bloomFinalP.bind(); gl.uniform2f(bloomFinalP.u.texelSize, last.texelSizeX, last.texelSizeY); gl.uniform1i(bloomFinalP.u.uTexture, last.attach(0)); gl.uniform1f(bloomFinalP.u.intensity, config.BLOOM_INTENSITY); blit(destination); }; const applySunrays = (source: FBO, mask: FBO, destination: FBO) => { gl.disable(gl.BLEND); raysMaskP.bind(); gl.uniform1i(raysMaskP.u.uTexture, source.attach(0)); blit(mask); raysP.bind(); gl.uniform1f(raysP.u.weight, config.SUNRAYS_WEIGHT); gl.uniform1i(raysP.u.uTexture, mask.attach(0)); blit(destination); }; const blur = (target: FBO, temp: FBO) => { blurP.bind(); gl.uniform2f(blurP.u.texelSize, target.texelSizeX, 0); gl.uniform1i(blurP.u.uTexture, target.attach(0)); blit(temp); gl.uniform2f(blurP.u.texelSize, 0, target.texelSizeY); gl.uniform1i(blurP.u.uTexture, temp.attach(0)); blit(target); }; const render = () => { if (config.BLOOM) applyBloom(dye.read, bloom); if (config.SUNRAYS) { applySunrays(dye.read, dye.write, sunrays); blur(sunrays, sunraysTemp); } gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); gl.enable(gl.BLEND); const bc = config.BACK_COLOR; colorP.bind(); gl.uniform4f(colorP.u.color, bc.r / 255, bc.g / 255, bc.b / 255, 1); blit(null); displayP.bind(); gl.uniform2f(displayP.u.texelSize, 1 / gl.drawingBufferWidth, 1 / gl.drawingBufferHeight); gl.uniform1i(displayP.u.uTexture, dye.read.attach(0)); if (config.BLOOM) { gl.uniform1i(displayP.u.uBloom, bloom.attach(1)); gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, dither.texture); gl.uniform1i(displayP.u.uDithering, 2); gl.uniform2f(displayP.u.ditherScale, gl.drawingBufferWidth / dither.width, gl.drawingBufferHeight / dither.height); } if (config.SUNRAYS) gl.uniform1i(displayP.u.uSunrays, sunrays.attach(3)); blit(null); }; resizeCanvas(); initFramebuffers(); return { config, splat, multipleSplats, render, step: (dt: number) => step(dt * o.timeScale), resize: () => { if (resizeCanvas()) initFramebuffers(); }, }; } // 显示着色器:明暗(SHADING)、辉光(BLOOM)、光束(SUNRAYS),最后把最亮的通道压到 CAP 以下 const CAP = 0.68; const DISPLAY = ` precision highp float; precision highp sampler2D; varying vec2 vUv; varying vec2 vL; varying vec2 vR; varying vec2 vT; varying vec2 vB; uniform sampler2D uTexture; uniform sampler2D uBloom; uniform sampler2D uSunrays; uniform sampler2D uDithering; uniform vec2 ditherScale; uniform vec2 texelSize; vec3 linearToGamma (vec3 color) { color = max(color, vec3(0)); return max(1.055 * pow(color, vec3(0.416666667)) - 0.055, vec3(0)); } void main () { vec3 c = texture2D(uTexture, vUv).rgb; #ifdef SHADING vec3 lc = texture2D(uTexture, vL).rgb; vec3 rc = texture2D(uTexture, vR).rgb; vec3 tc = texture2D(uTexture, vT).rgb; vec3 bc = texture2D(uTexture, vB).rgb; float dx = length(rc) - length(lc); float dy = length(tc) - length(bc); vec3 n = normalize(vec3(dx, dy, length(texelSize))); float diffuse = clamp(dot(n, vec3(0.0, 0.0, 1.0)) + 0.7, 0.7, 1.0); c *= diffuse; #endif #ifdef BLOOM vec3 bloom = texture2D(uBloom, vUv).rgb; #endif #ifdef SUNRAYS float sunrays = texture2D(uSunrays, vUv).r; c *= sunrays; #ifdef BLOOM bloom *= sunrays; #endif #endif #ifdef BLOOM float noise = texture2D(uDithering, vUv * ditherScale).r; noise = noise * 2.0 - 1.0; bloom += noise / 255.0; bloom = linearToGamma(bloom); c += bloom; #endif // 超过拐点后,最亮的通道慢慢靠近上限,颜色不变:浓的地方不会烧成白色或黄色 float m = max(c.r, max(c.g, c.b)), kn = ${(CAP * 0.6).toFixed(3)}, room = ${(CAP * 0.4).toFixed(3)}; if (m > kn) c *= (kn + room * (1.0 - exp(-(m - kn) / room))) / m; gl_FragColor = vec4(c, max(c.r, max(c.g, c.b))); }`; // 颜色:玫瑰红、深青、绯红、灰粉轮流用(每种连用 16 次),偶尔插一点青灰 const TONES = [[0.95, 0.75, 0.85], [0.49, 0.72, 0.62], [0.985, 0.8, 0.78], [0.93, 0.5, 0.8], [0.54, 0.6, 0.6]]; const hsv = (h: number, s: number, v: number) => { const f = (n: number) => { const k = (n + h * 6) % 6; return v - v * s * Math.max(0, Math.min(k, 4 - k, 1)); }; return {r: f(5), g: f(3), b: f(1)}; }; // 没人操作时的"自动划一笔":一段弯曲的路线划 on 秒,停 off 秒(返回 null 表示这会儿不划) function strokes(on: number, off: number, seed: number, margin = 0.15) { let s = seed; const rnd = () => (s = (s * 16807) % 2147483647) / 2147483647; const cache: number[][][] = []; return (t: number) => { const i = Math.floor(t / (on + off)), u = (t - i * (on + off)) / on; if (u > 1) return null; while (cache.length <= i) { const p = () => [margin + rnd() * (1 - 2 * margin), margin + rnd() * (1 - 2 * margin)]; const a = p(), b = p(), c = p(); cache.push([a, c, b]); } const [a, c, b] = cache[i], e = u * u * (3 - 2 * u); const q = (k: number) => (1 - e) * (1 - e) * a[k] + 2 * (1 - e) * e * c[k] + e * e * b[k]; return [q(0), q(1)]; }; } /* ---------------- 挂载:不依赖 React,纯 HTML 页面、网页 PPT 里也能直接调用 ---------------- */ /** 在 host 里铺满液态流体背景,返回清理函数。host 要有大小(比如 position: absolute; inset: 0)。 */ export function mountLiquidFluid(host: HTMLElement): () => void { const canvas = document.createElement('canvas'); canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block'; const gl = canvas.getContext('webgl2', {alpha: true, depth: false, stencil: false, antialias: false, preserveDrawingBuffer: false}); if (!gl) return () => {}; // 不支持 WebGL2:留着 host 自己的底色 if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; host.append(canvas); let n = 0; const color = () => { const [h, s, v] = TONES[((n++ >> 4) % 4) + (Math.random() < 0.04 ? 4 - ((n >> 4) % 4) : 0)]; const c = hsv(h, s, v); return {r: c.r * 0.14, g: c.g * 0.14, b: c.b * 0.14}; }; let fluid: ReturnType; try { fluid = createFluid(host, canvas, gl, { config: {DENSITY_DISSIPATION: 0.6, BLOOM_INTENSITY: 0.2, BLOOM_THRESHOLD: 0.8, SUNRAYS_WEIGHT: 0.6}, display: DISPLAY, defines: ['SHADING', 'BLOOM', 'SUNRAYS'], dpr: 2, timeScale: 0.5, color, burst: 4.5, }); } catch { canvas.remove(); return () => {}; } // 一层很淡的暗纱,让流体退到后面 const veil = document.createElement('div'); veil.style.cssText = 'position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,#0a080760,#0a080710 45%,#0a080740)'; host.append(veil); fluid.multipleSplats(6); // 鼠标划过:按移动的距离注入颜色和力;在按钮、输入框上不算 const FORCE = 2200; let lastPoint: {x: number; y: number} | null = null, lastReal = -1e9; const stir = (x: number, y: number) => { if (lastPoint && Math.hypot(x - lastPoint.x, y - lastPoint.y) > 0.002) fluid.splat(x, y, (x - lastPoint.x) * FORCE, (y - lastPoint.y) * FORCE, color()); lastPoint = {x, y}; }; const onMove = (e: PointerEvent) => { const r = host.getBoundingClientRect(), x = (e.clientX - r.left) / r.width, y = 1 - (e.clientY - r.top) / r.height; if (x < 0 || x > 1 || y < 0 || y > 1 || (e.target as Element | null)?.closest?.('button,input,select,textarea')) { lastPoint = null; return; } lastReal = performance.now(); stir(x, y); }; addEventListener('pointermove', onMove); // 循环:只在看得见时画(滚出屏幕、标签页隐藏、减弱动态效果时停) const reduce = matchMedia('(prefers-reduced-motion: reduce)'); const path = strokes(0.8, 2.4, 11); let raf = 0, last = 0, seen = false, auto = 0, idle = 0, wasIdle = false; const tick = (now: number) => { raf = requestAnimationFrame(tick); const dt = last ? Math.min((now - last) / 1000, 1 / 60) : 1 / 60; last = now; // 鼠标 3 秒没动:自己隔几秒划一笔 if (performance.now() - lastReal > 3000) { if (!wasIdle) { wasIdle = true; lastPoint = null; } idle += dt; const p = path(idle); if (p) stir(p[0], p[1]); else lastPoint = null; } else wasIdle = false; fluid.resize(); fluid.step(dt); fluid.render(); auto += dt * 0.5; if (auto > 3.2) { auto = 0; fluid.multipleSplats(1); } }; const sync = () => { const run = seen && !document.hidden && !reduce.matches; if (run && !raf) { last = 0; raf = requestAnimationFrame(tick); } if (!run && raf) { cancelAnimationFrame(raf); raf = 0; } }; // 减弱动态效果:先跑 2 秒让颜色散开,再停在这一帧 if (reduce.matches) for (let i = 0; i < 120; i++) fluid.step(1 / 60); fluid.render(); const ro = new ResizeObserver(() => { fluid.resize(); if (!raf) fluid.render(); }); ro.observe(host); const io = new IntersectionObserver(([e]) => { seen = e.isIntersecting; sync(); }); io.observe(host); document.addEventListener('visibilitychange', sync); reduce.addEventListener('change', sync); return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); removeEventListener('pointermove', onMove); document.removeEventListener('visibilitychange', sync); reduce.removeEventListener('change', sync); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); veil.remove(); }; } /** React:放在一个 relative 的容器里当背景, */ export function LiquidFluid({className = ''}: {className?: string}) { const ref = useRef(null); useEffect(() => mountLiquidFluid(ref.current!), []); return