# 水墨晕染 Ink Wash > 墨滴在宣纸上晕开的水墨背景;只用四种深浅的黑,一打开就已经晕开一片,之后一直轻轻流动。 分类:背景 [全部组件和流程](https://www.dachuiai.com/llms.txt) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:国风、茶、书法、文化、非遗类的首屏或 PPT 封面。 - 不适合:科技、潮流、深色风格的网站;要放彩色大图的页面(会抢)。 - 适合的网站:官网、本地小店、内容站、活动页 - 放在:首屏、背景 - 手机上:好用:手指划过会带一点淡墨;手机上染料分辨率会自动降低。 - 性能代价:中:GPU 上每帧解一次流体,一般电脑轻松 60 帧。 - 常搭配:[滚动出现 Scroll Reveal](https://www.dachuiai.com/c/scroll-reveal.md)、[打字机 Typewriter](https://www.dachuiai.com/c/typewriter.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `ink-wash.tsx`(比如 `src/components/ink-wash.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/ink-wash.tsx),比手抄准:`curl -fsSL -o src/components/ink-wash.tsx https://www.dachuiai.com/code/ink-wash.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`mountInkWash`、`InkWash` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:背景铺满它所在的容器:容器写 relative,组件写 className="absolute inset-0",内容放在它后面并设 relative 或 absolute 盖在上面。滚出屏幕、切走标签页时自己暂停。底色是浅色宣纸,上面的字用深色。保留文件开头 Pavel Dobryakov 的 MIT 许可声明。不是 React(纯 HTML、网页 PPT、Vue):文件里的 mountInkWash(元素) 不依赖 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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **mountInkWash** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `host` | | 要铺满背景的那个元素(DOM 节点),它要有大小 | **InkWash** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 用 WebGL Fluid Simulation 的流体求解器做一个水墨背景:底色是带纤维纹理的宣纸;墨只用四种深浅的黑,按吸光(相乘)叠在纸上,边缘有晕圈;打开页面前先预跑约 4.5 秒、落 12 滴墨,所以一打开就已经晕开一片;落点按网格加随机分散,保持均匀;再加缓慢的环境水流,让墨持续轻轻流动。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; /* --------------------------------------------------------------- * InkWash 水墨晕染 * 墨滴落在宣纸上,慢慢晕开。流动用的是开源项目 WebGL Fluid Simulation(MIT,作者 Pavel Dobryakov)的流体求解器; * 画法是这一版自己写的:底色是带纤维纹理的宣纸,墨只用四种深浅的黑,按吸光(相乘)叠在纸上,边缘有晕圈。 * 打开之前先预跑约 4.5 秒、落 12 滴墨,一打开就已经晕开一片;落点按网格加随机分散,保持均匀; * 再加缓慢的环境水流,墨一直轻轻流动。鼠标划过会带一点淡墨;没人动鼠标时,隔几秒自己慢慢划一笔。 * 滚出屏幕、切到别的标签页时暂停;开了"减弱动态效果"时停在一帧。 * ------------------------------------------------------------- */ /* ---------------- 流体求解器 ---------------- * 来自 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}, }; 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); }`; 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); // 全屏的两个三角形 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; 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 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 render = () => { 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)); blit(null); }; resizeCanvas(); initFramebuffers(); return { config, splat, multipleSplats, render, step: (dt: number) => step(dt * o.timeScale), resize: () => { if (resizeCanvas()) initFramebuffers(); }, }; } // 显示着色器:把染料当成吸光的墨,叠在宣纸上 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 vec2 texelSize; float h(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } float n(vec2 p){ vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f); return mix(mix(h(i), h(i+vec2(1,0)), f.x), mix(h(i+vec2(0,1)), h(i+vec2(1,1)), f.x), f.y); } float fbm(vec2 p){ float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a*n(p); p = p*2.03 + 7.1; a *= 0.5; } return v; } void main(){ vec2 asp = vec2(texelSize.y / texelSize.x, 1.0); vec2 P = vUv * asp; vec3 ink = texture2D(uTexture, vUv).rgb; vec3 l = texture2D(uTexture, vL).rgb, r = texture2D(uTexture, vR).rgb, t = texture2D(uTexture, vT).rgb, b = texture2D(uTexture, vB).rgb; // 墨的浓淡变化处,颜料往边上聚:一圈更深的边 float edge = length(vec2(length(r) - length(l), length(t) - length(b))); // 宣纸:暖白底、长纤维、细细的纸纹 float fib = fbm(P * vec2(9.0, 40.0)) * 0.6 + fbm(P * vec2(40.0, 7.0)) * 0.4; float tooth = n(P * 900.0); vec3 paper = vec3(0.945, 0.918, 0.862) * (0.965 + 0.05 * fib) - 0.015 * tooth; paper *= 1.0 - 0.18 * pow(length((vUv - 0.5) * vec2(1.0, 0.8)), 2.0); float gran = 0.82 + 0.36 * fbm(P * 260.0); vec3 absorb = ink * gran * 3.2 + vec3(edge) * 3.5 * min(length(ink) * 4.0, 1.0); gl_FragColor = vec4(paper * exp(-absorb), 1.0); }`; // 墨色:淡、中、深、焦四种(每个颜色通道吸光一样多,所以是中性的黑) const TONES = [0.07, 0.13, 0.2, 0.28]; const BLACK = {r: 0, g: 0, b: 0}; // 没人操作时的"自动划一笔":一段弯曲的路线划 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 mountInkWash(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 fluid: ReturnType; try { fluid = createFluid(host, canvas, gl, { config: {SIM_RESOLUTION: 224, DYE_RESOLUTION: 1536, DENSITY_DISSIPATION: 0.15, VELOCITY_DISSIPATION: 0.35, CURL: 7, SPLAT_RADIUS: 0.18, PRESSURE: 0.8, SHADING: true, BACK_COLOR: {r: 240, g: 234, b: 220}}, display: DISPLAY, defines: [], dpr: 2, timeScale: 1, color: () => BLACK, burst: 1, }); } catch { canvas.remove(); return () => {}; } const cfg = fluid.config; // 落点:把画面分成 4×5 格,打乱顺序轮流落,墨就均匀铺满整张纸 const cells = Array.from({length: 20}, (_, i) => i); for (let i = cells.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [cells[i], cells[j]] = [cells[j], cells[i]]; } let drops = 0, clock = 0, next = 0.05, current = 0, first = true; const tone = () => { drops++; return TONES[[2, 0, 3, 1, 2, 0, 1, 3][drops % 8]]; }; const onFrame = (dt: number) => { clock += dt; current += dt; // 环境水流:每半秒在随机位置轻轻推一下,墨一直慢慢流 if (current > 0.5) { current = 0; const r0 = cfg.SPLAT_RADIUS; cfg.SPLAT_RADIUS = 0.9; const a = Math.random() * Math.PI * 2; fluid.splat(0.1 + Math.random() * 0.8, 0.1 + Math.random() * 0.8, Math.cos(a) * 120, Math.sin(a) * 120, BLACK); cfg.SPLAT_RADIUS = r0; } if (clock < next) return; // 一滴墨轻轻落下:几乎不推,中间浓、外面一圈淡晕,靠水流把它化开。第一次一下落 12 滴 const count = first ? 12 : 1; first = false; for (let k = 0; k < count; k++) { const c = tone(), cell = cells[drops % cells.length]; const x = 0.1 + ((cell % 4) + 0.2 + Math.random() * 0.6) / 4 * 0.8, y = 0.08 + (Math.floor(cell / 4) + 0.2 + Math.random() * 0.6) / 5 * 0.86; const r0 = cfg.SPLAT_RADIUS; cfg.SPLAT_RADIUS = 0.05 + Math.random() * 0.05; fluid.splat(x, y, (Math.random() - 0.5) * 50, (Math.random() - 0.5) * 50, {r: c * 2.4, g: c * 2.4, b: c * 2.4}); cfg.SPLAT_RADIUS = 0.22; fluid.splat(x, y, 0, 0, {r: c * 0.35, g: c * 0.35, b: c * 0.35}); cfg.SPLAT_RADIUS = r0; } next = clock + 0.8 + Math.random() * 0.4; }; // 先预跑 4.5 秒:一打开就是已经晕开的样子,不是刚落下的圆点 for (let i = 0; i < 270; i++) { fluid.step(1 / 60); onFrame(1 / 60); } // 鼠标划过:带一点很淡的墨,推一推水;没人动鼠标时,隔几秒自己慢慢划一笔,把墨拖出丝 const FORCE = 420, TRACE = {r: 0.004, g: 0.004, b: 0.004}; 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, TRACE); 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(2.2, 3.4, 3, 0.2); let raf = 0, last = 0, seen = false, 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; 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(); onFrame(dt); }; const sync = () => { const run = seen && !document.hidden && !reduce.matches; if (run && !raf) { last = 0; raf = requestAnimationFrame(tick); } if (!run && raf) { cancelAnimationFrame(raf); raf = 0; } }; 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(); }; } /** React:放在一个 relative 的容器里当背景, */ export function InkWash({className = ''}: {className?: string}) { const ref = useRef(null); useEffect(() => mountInkWash(ref.current!), []); return