# 雨夜玻璃 Rain on Glass > 隔着雨窗看夜城的背景;灯光虚焦,水珠自己往下滑、吞掉别的水珠,鼠标像手指一样擦掉雾。 分类:背景 [全部组件和流程](https://www.dachuiai.com/llms.txt) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:讲故事、讲情绪的页面:电影、音乐、个人博客、深夜电台。 - 不适合:明亮、积极的产品页;电商;要看清楚很多信息的页面。 - 适合的网站:内容站、作品集、活动页 - 放在:首屏、背景 - 手机上:好用:手指划过同样能擦掉雾。 - 性能代价:中:水珠在 Canvas 2D 上模拟(最多 1600 颗),再交给着色器折射;一般电脑 60 帧。 - 常搭配:[打字机 Typewriter](https://www.dachuiai.com/c/typewriter.md)、[逐字点亮 Scroll Text Highlight](https://www.dachuiai.com/c/scroll-highlight.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:只用 react,不用另外安装 - 文件:原样存成 `rainy-glass.tsx`(比如 `src/components/rainy-glass.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/rainy-glass.tsx),比手抄准:`curl -fsSL -o src/components/rainy-glass.tsx https://www.dachuiai.com/code/rainy-glass.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`mountRainyGlass`、`RainyGlass` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:背景铺满它所在的容器:容器写 relative,组件写 className="absolute inset-0",内容放在它后面并设 relative 或 absolute 盖在上面。滚出屏幕、切走标签页时自己暂停。不是 React(纯 HTML、网页 PPT、Vue):文件里的 mountRainyGlass(元素) 不依赖 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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **mountRainyGlass** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `host` | | 要铺满背景的那个元素(DOM 节点),它要有大小 | **RainyGlass** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 做一个雨夜玻璃背景:在 Canvas 2D 上模拟水珠,每颗有大小和速度,越大滑得越快,沿玻璃弯弯曲曲往下走,碰到别的水珠就合并,滑过的地方留下小水珠和擦痕;把水珠画成法线贴图,交给 WebGL 着色器去折射背后虚焦的城市灯光,水珠里的景更清楚并上下颠倒;没被擦过的玻璃有一层雾;灯光颜色暖、亮度克制。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; /* --------------------------------------------------------------- * RainyGlass 雨夜玻璃 * 隔着下雨的窗看夜里的城市:后面的灯是虚焦的光斑,玻璃上起了雾,每颗水珠都是一个小透镜, * 里面是更清楚、上下颠倒的灯光。水珠是一个小的物理模拟(在 Canvas 2D 上):大水珠匀速往下滑、左右游走, * 吞掉挡路的小水珠(面积守恒,所以越滑越大越快),身后留下一串小水珠,并把雾擦出一条路;细雾一直在凝结。 * 鼠标像手指一样擦掉雾。滑落的做法参考了 Lucas Bebber 在 Codrops 公开的 RainEffect 思路,代码是重新写的。 * 用 WebGL2 着色器把水珠画成法线贴图去折射后面的灯光。滚出屏幕、切到别的标签页时暂停;开了"减弱动态效果"时停在一帧。 * ------------------------------------------------------------- */ const SCENE = `#version 300 es precision highp float; in vec2 vUv; out vec4 o; uniform vec2 uRes; uniform float uTime; uniform sampler2D uDrops, uWipe; float h11(float p){ p = fract(p*0.1031); p *= p + 33.33; p *= p + p; return fract(p); } float h21(vec2 p){ vec3 q = fract(vec3(p.xyx)*0.1031); q += dot(q, q.yzx+33.33); return fract((q.x+q.y)*q.z); } vec2 h22(vec2 p){ vec3 q = fract(vec3(p.xyx)*vec3(0.1031,0.1030,0.0973)); q += dot(q, q.yzx+33.33); return fract((q.xx+q.yz)*q.zy); } // 虚焦的城市:一排排路灯和招牌画成散景光斑;blur 越大边缘越软 vec3 city(vec2 p, float blur){ vec3 col = mix(vec3(0.010, 0.022, 0.028), vec3(0.05, 0.035, 0.03), smoothstep(0.6, -0.6, p.y)); col += vec3(0.20, 0.09, 0.03) * exp(-pow((p.y + 0.35) * 2.2, 2.0)) * 0.35; // 街道的暖色光晕 for (int k = 0; k < 4; k++) { float fk = float(k); float sc = 4.0 + fk * 2.6; vec2 q = p * sc; float row = floor(q.y); q.x += uTime * (h11(row + fk*7.0) - 0.5) * 0.35 * (fk < 1.5 ? 1.0 : 0.3); // 有些灯在挪:车流 vec2 id = floor(q), f = fract(q) - 0.5; float r = h21(id + fk*13.1); if (r < 0.62) continue; vec2 c = (h22(id + fk*3.7) - 0.5) * 0.36; float rad = min(mix(0.16, 0.34, h21(id + 5.0)) * (1.0 - fk*0.12), 0.48 - max(abs(c.x), abs(c.y))); float d = length(f - c); float edge = mix(0.02, 0.22, blur) * rad / 0.3; float disc = smoothstep(rad, rad - edge, d); float rim = 1.0 + 0.6 * smoothstep(rad*0.55, rad, d) * (1.0 - blur); float pick = h21(id + 9.1 + fk); vec3 lc = pick < 0.45 ? vec3(1.0, 0.62, 0.22) : (pick < 0.68 ? vec3(1.0, 0.18, 0.12) : (pick < 0.85 ? vec3(0.2, 0.85, 1.0) : vec3(0.95, 0.72, 0.5))); // 不要白灯 float flick = 0.85 + 0.15 * sin(uTime * (1.0 + 3.0 * h21(id)) + r * 30.0); col += lc * disc * rim * flick * (1.2 - fk*0.2) * (0.35 + 0.65 * r) * 0.62; // 当背景用,亮度压低,不刺眼 } return col; } void main(){ vec2 uv = (gl_FragCoord.xy - 0.5*uRes) / uRes.y; vec2 tuv = vec2(vUv.x, 1.0 - vUv.y); // 水珠画布是从上往下画的 vec4 d = texture(uDrops, tuv); float a = d.a; vec2 nrm = a > 0.0 ? (d.rg / max(a, 1e-3) - 0.5) * 2.0 : vec2(0.0); nrm.y = -nrm.y; float thick = a > 0.0 ? d.b / max(a, 1e-3) : 0.0; float wiped = clamp(texture(uWipe, tuv).r * 1.4, 0.0, 1.0); // 起雾的玻璃:很柔、很暗;被擦过的地方城市清楚一点 vec3 fog = city(uv * 0.98, 1.0) * 0.42 + vec3(0.012, 0.02, 0.024); vec3 clearer = city(uv * 0.98, 0.7) * 0.5 + vec3(0.008, 0.013, 0.016); // 稍微清楚一点,亮度和雾一样 vec3 col = mix(fog, clearer, wiped * 0.7); // 水珠:每一颗都折射出一小块更清楚、上下颠倒的城市 vec3 lens = city(uv * 0.9 - nrm * (0.1 + 0.1 * thick), 0.08) * 1.3 + vec3(0.01, 0.015, 0.02); lens *= 1.0 - 0.75 * smoothstep(0.5, 1.0, length(nrm)); // 水珠边缘把光折走了,是一圈暗边 col = mix(col, lens, clamp(a * 1.15, 0.0, 1.0)); col += vec3(1.0, 0.93, 0.85) * a * pow(max(dot(normalize(vec3(nrm, 0.7)), normalize(vec3(-0.35, 0.55, 0.75))), 0.0), 40.0) * 0.6; o = vec4(col, 1.0); }`; /* ---------------- WebGL2 小工具:着色器、全屏三角形 ---------------- */ type Prog = {p: WebGLProgram; u: Record; use: () => void}; const VS = `#version 300 es in vec2 aPos; out vec2 vUv; void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`; function program(gl: WebGL2RenderingContext, fs: string): Prog { const sh = (type: number, src: string) => { const s = gl.createShader(type)!; gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) ?? 'shader'); return s; }; const p = gl.createProgram()!; gl.attachShader(p, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs)); gl.bindAttribLocation(p, 0, 'aPos'); gl.linkProgram(p); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) ?? 'link'); const cache: Record = {}; const u = new Proxy(cache, {get: (c, k: string) => (k in c ? c[k] : (c[k] = gl.getUniformLocation(p, k)))}); return {p, u, use: () => gl.useProgram(p)}; } // 一个大三角形盖住整个画面(比两个三角形拼的矩形少一条对角线接缝) function triangle(gl: WebGL2RenderingContext) { const vao = gl.createVertexArray(); gl.bindVertexArray(vao); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); return () => { gl.bindVertexArray(vao); gl.drawArrays(gl.TRIANGLES, 0, 3); }; } /* ---------------- 辉光和色调映射:场景先画进半浮点缓冲,6 级下采样 / 上采样出辉光,再调色、暗角、加一点颗粒 ---------------- */ type Target = {tex: WebGLTexture; fb: WebGLFramebuffer; w: number; h: number}; type Grade = {bloom: number; threshold: number; exposure: number; vignette: number; grain: number; time: number; sat: number; tone?: 'aces' | 'agx'}; const DOWN = `#version 300 es precision highp float; in vec2 vUv; out vec4 o; uniform sampler2D uSrc; uniform vec2 uTexel; uniform float uFirst; uniform float uThreshold; vec3 s(vec2 p){ return texture(uSrc, p).rgb; } void main(){ vec2 t = uTexel; vec3 a = s(vUv + t*vec2(-2, 2)), b = s(vUv + t*vec2(0, 2)), c = s(vUv + t*vec2(2, 2)); vec3 d = s(vUv + t*vec2(-2, 0)), e = s(vUv), f = s(vUv + t*vec2(2, 0)); vec3 g = s(vUv + t*vec2(-2, -2)), h = s(vUv + t*vec2(0, -2)), i = s(vUv + t*vec2(2, -2)); vec3 j = s(vUv + t*vec2(-1, 1)), k = s(vUv + t*vec2(1, 1)), l = s(vUv + t*vec2(-1, -1)), m = s(vUv + t*vec2(1, -1)); vec3 col = e*0.125 + (a+c+g+i)*0.03125 + (b+d+f+h)*0.0625 + (j+k+l+m)*0.125; if (uFirst > 0.5) { float br = max(col.r, max(col.g, col.b)); col *= max(br - uThreshold, 0.0) / max(br, 1e-4); col = min(col, vec3(60.0)); } o = vec4(col, 1.0); }`; const UP = `#version 300 es precision highp float; in vec2 vUv; out vec4 o; uniform sampler2D uSrc; uniform vec2 uTexel; void main(){ vec2 t = uTexel; vec3 c = vec3(0.0); c += texture(uSrc, vUv + t*vec2(-1, 1)).rgb + texture(uSrc, vUv + t*vec2(1, 1)).rgb + texture(uSrc, vUv + t*vec2(-1, -1)).rgb + texture(uSrc, vUv + t*vec2(1, -1)).rgb; c += 2.0*(texture(uSrc, vUv + t*vec2(0, 1)).rgb + texture(uSrc, vUv + t*vec2(0, -1)).rgb + texture(uSrc, vUv + t*vec2(-1, 0)).rgb + texture(uSrc, vUv + t*vec2(1, 0)).rgb); c += 4.0*texture(uSrc, vUv).rgb; o = vec4(c / 16.0, 1.0); }`; const FINAL = `#version 300 es precision highp float; in vec2 vUv; out vec4 o; uniform sampler2D uScene, uBloom; uniform float uBloomStrength, uExposure, uVignette, uGrain, uTime, uSat; uniform vec2 uRes; uniform int uTone; vec3 aces(vec3 x){ const float a=2.51,b=0.03,c=2.43,d=0.59,e=0.14; return clamp((x*(a*x+b))/(x*(c*x+d)+e),0.0,1.0); } // AgX 式的柔和压缩:很亮的颜色不会太早变成一片白 vec3 agx(vec3 v){ mat3 inM = mat3(0.842479062253094,0.0423282422610123,0.0423756549057051, 0.0784335999999992,0.878468636469772,0.0784336, 0.0792237451477643,0.0791661274605434,0.879142973793104); mat3 outM = mat3(1.19687900512017,-0.0528968517574562,-0.0529716355144438, -0.0980208811401368,1.15190312990417,-0.0980434501171241, -0.0990297440797205,-0.0989611768448433,1.15107367264116); v = inM * max(v, 0.0); v = clamp(log2(max(v,1e-10)), -12.47393, 4.026069); v = (v + 12.47393) / 16.49999; vec3 x2 = v*v, x4 = x2*x2; v = 15.5*x4*x2 - 40.14*x4*v + 31.96*x4 - 6.868*x2*v + 0.4298*x2 + 0.1191*v - 0.00232; v = outM * v; return clamp(pow(max(v,0.0), vec3(2.2)), 0.0, 1.0); } float hash(vec2 p){ p = fract(p*vec2(443.897,441.423)); p += dot(p, p.yx+19.19); return fract((p.x+p.y)*p.x); } void main(){ vec3 c = (texture(uScene, vUv).rgb + texture(uBloom, vUv).rgb * uBloomStrength) * uExposure; c = uTone == 1 ? agx(c) : aces(c); float l = dot(c, vec3(0.2126,0.7152,0.0722)); c = mix(vec3(l), c, uSat); vec2 q = vUv - 0.5; q.x *= uRes.x/uRes.y; c *= mix(1.0, smoothstep(1.15, 0.25, length(q)), uVignette); c = pow(c, vec3(1.0/2.2)); c += (hash(vUv*uRes + fract(uTime)*91.0) - 0.5) * uGrain; o = vec4(c, 1.0); }`; function createPost(gl: WebGL2RenderingContext, draw: () => void, levels = 6) { gl.getExtension('EXT_color_buffer_float'); gl.getExtension('OES_texture_float_linear'); const down = program(gl, DOWN), up = program(gl, UP), fin = program(gl, FINAL); const target = (w: number, h: number): Target => { const tex = gl.createTexture()!; gl.bindTexture(gl.TEXTURE_2D, tex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null); 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.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); const fb = gl.createFramebuffer()!; gl.bindFramebuffer(gl.FRAMEBUFFER, fb); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); return {tex, fb, w, h}; }; let scene: Target | null = null, mips: Target[] = [], W = 0, H = 0; const bind = (p: Prog, name: string, unit: number, tex: WebGLTexture) => { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, tex); gl.uniform1i(p.u[name], unit); }; return { get scene() { return scene!; }, resize(w: number, h: number, sceneScale: number) { const sw = Math.max(2, Math.round(w * sceneScale)), sh = Math.max(2, Math.round(h * sceneScale)); if (scene && scene.w === sw && scene.h === sh && W === w && H === h) return; for (const t of [scene, ...mips]) if (t) { gl.deleteTexture(t.tex); gl.deleteFramebuffer(t.fb); } W = w; H = h; scene = target(sw, sh); mips = []; let mw = sw, mh = sh; for (let i = 0; i < levels; i++) { mw = Math.max(2, mw >> 1); mh = Math.max(2, mh >> 1); mips.push(target(mw, mh)); } }, begin() { gl.bindFramebuffer(gl.FRAMEBUFFER, scene!.fb); gl.viewport(0, 0, scene!.w, scene!.h); }, end(g: Grade) { gl.disable(gl.BLEND); let src = scene!; down.use(); mips.forEach((m, i) => { gl.bindFramebuffer(gl.FRAMEBUFFER, m.fb); gl.viewport(0, 0, m.w, m.h); bind(down, 'uSrc', 0, src.tex); gl.uniform2f(down.u.uTexel, 1 / src.w, 1 / src.h); gl.uniform1f(down.u.uFirst, i === 0 ? 1 : 0); gl.uniform1f(down.u.uThreshold, g.threshold); draw(); src = m; }); up.use(); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); for (let i = mips.length - 1; i > 0; i--) { const s = mips[i], d = mips[i - 1]; gl.bindFramebuffer(gl.FRAMEBUFFER, d.fb); gl.viewport(0, 0, d.w, d.h); bind(up, 'uSrc', 0, s.tex); gl.uniform2f(up.u.uTexel, 1 / s.w, 1 / s.h); draw(); } gl.disable(gl.BLEND); gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, W, H); fin.use(); bind(fin, 'uScene', 0, scene!.tex); bind(fin, 'uBloom', 1, mips[0].tex); gl.uniform1f(fin.u.uBloomStrength, g.bloom / mips.length); gl.uniform1f(fin.u.uExposure, g.exposure); gl.uniform1f(fin.u.uVignette, g.vignette); gl.uniform1f(fin.u.uGrain, g.grain); gl.uniform1f(fin.u.uTime, g.time); gl.uniform1f(fin.u.uSat, g.sat); gl.uniform2f(fin.u.uRes, W, H); gl.uniform1i(fin.u.uTone, g.tone === 'agx' ? 1 : 0); draw(); }, }; } /* ---------------- 鼠标和循环 ---------------- */ // 鼠标在画面上时跟着走(带平滑),移出画面后慢慢回到中间。x 从左到右、y 从下到上,都是 0–1 function pointer(host: HTMLElement, smooth: number) { const p = {x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, moved: -1e9, update(dt: number) { const k = 1 - Math.exp(-smooth * dt); p.x += (p.tx - p.x) * k; p.y += (p.ty - p.y) * k; }, off() { removeEventListener('pointermove', on); }}; const on = (e: PointerEvent) => { const r = host.getBoundingClientRect(), x = (e.clientX - r.left) / r.width, y = 1 - (e.clientY - r.top) / r.height; const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1; p.tx = inside ? x : 0.5; p.ty = inside ? y : 0.5; if (inside) p.moved = performance.now(); }; addEventListener('pointermove', on); return p; } // 只在看得见的时候画:滚出屏幕、切到别的标签页、开了"减弱动态效果"都会停。停着时改了大小,就按 still 秒的画面重画一帧 function animate(host: HTMLElement, fit: () => void, frame: (t: number, dt: number) => void, still: number) { const reduce = matchMedia('(prefers-reduced-motion: reduce)'); let raf = 0, last = 0, time = 0, seen = false; const tick = (now: number) => { raf = requestAnimationFrame(tick); const dt = last ? Math.min((now - last) / 1000, 0.1) : 1 / 60; last = now; time += dt; frame(time, 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; } }; const redraw = () => { if (!raf) frame(reduce.matches ? still : time, 0); }; const ro = new ResizeObserver(() => { fit(); redraw(); }); ro.observe(host); const io = new IntersectionObserver(([e]) => { seen = e.isIntersecting; sync(); }); io.observe(host); const onMotion = () => { sync(); redraw(); }; document.addEventListener('visibilitychange', sync); reduce.addEventListener('change', onMotion); return () => { cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', sync); reduce.removeEventListener('change', onMotion); }; } /* ---------------- 水珠模拟(单位:1 = 画面高度的千分之一) ---------------- */ type Drop = {x: number; y: number; r: number; vx: number; vy: number; kick: number; wob: number; wobF: number; trail: number; dead: boolean; still?: boolean}; const MAXR = 34, SLIDE_R = 11; const rand = (a: number, b: number) => a + Math.random() * (b - a); const makeDrop = (x: number, y: number, r: number, still = false): Drop => ({x, y, r, vx: 0, vy: 0, kick: 0, wob: Math.random() * 6.28, wobF: rand(0.5, 1.4), trail: rand(8, 20), dead: false, still}); // 一颗水珠的小图:红绿 = 表面朝向,蓝 = 厚度,透明度 = 覆盖 function dropSprite() { const N = 64, c = document.createElement('canvas'); c.width = c.height = N; const x = c.getContext('2d')!, img = x.createImageData(N, N); for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) { const u = (i + 0.5) / N * 2 - 1, v = (j + 0.5) / N * 2 - 1, d = Math.hypot(u, v), k = (j * N + i) * 4; if (d >= 1) continue; img.data[k] = 128 + 127 * u; img.data[k + 1] = 128 + 127 * v; img.data[k + 2] = 255 * Math.sqrt(1 - d * d); img.data[k + 3] = 255 * Math.min(1, (1 - d) / 0.12); } x.putImageData(img, 0, 0); return c; } function createRain() { const sprite = dropSprite(); const drops = document.createElement('canvas'), mist = document.createElement('canvas'), wipe = document.createElement('canvas'); const dctx = drops.getContext('2d')!, mctx = mist.getContext('2d')!, wctx = wipe.getContext('2d')!; const sim = {W: 600, H: 1000, list: [] as Drop[], mistAcc: 0, rainAcc: 0, bigAcc: 0}; const S = () => drops.height / sim.H; // 模拟单位 → 画布像素 const drawDrop = (d: Drop, s: number) => { const stretch = 1 + Math.min(0.35, d.vy / 600); // 在滑的水珠稍微拉长 const w = d.r * 2 * s, h = d.r * 2 * s * stretch; dctx.drawImage(sprite, d.x * s - w / 2, d.y * s - h * 0.6, w, h); }; const resize = (cssW: number, cssH: number) => { const k = Math.min(1, 900 / cssH); // 贴图大约是画面的 CSS 像素大小 for (const c of [drops, mist]) { c.width = Math.max(2, Math.round(cssW * k)); c.height = Math.max(2, Math.round(cssH * k)); } wipe.width = Math.max(2, Math.round(drops.width / 4)); wipe.height = Math.max(2, Math.round(drops.height / 4)); sim.W = 1000 * cssW / cssH; wctx.fillStyle = '#000'; wctx.fillRect(0, 0, wipe.width, wipe.height); }; const step = (dt: number) => { const s = S(); // 下雨:大多是小水珠,偶尔一颗大的 sim.rainAcc += dt * 24; sim.bigAcc += dt * 1.1; while (sim.rainAcc > 1) { sim.rainAcc--; sim.list.push(makeDrop(rand(0, sim.W), rand(-20, sim.H * 0.95), Math.pow(Math.random(), 2) * 8 + 2)); } while (sim.bigAcc > 1) { sim.bigAcc--; sim.list.push(makeDrop(rand(0, sim.W), rand(-30, sim.H * 0.55), rand(12, 19))); } // 细雾一直在凝结 sim.mistAcc += dt * 900; mctx.globalCompositeOperation = 'source-over'; while (sim.mistAcc > 1) { sim.mistAcc--; const r = rand(0.5, 1.8) * s, x = rand(0, mist.width), y = rand(0, mist.height); mctx.drawImage(sprite, x - r, y - r, r * 2, r * 2); } // 擦过的玻璃慢慢又起雾 wctx.globalCompositeOperation = 'source-over'; wctx.fillStyle = `rgba(0,0,0,${Math.min(1, dt * 0.08)})`; wctx.fillRect(0, 0, wipe.width, wipe.height); // 按格子分组,碰撞只查附近 const CELL = 40, grid = new Map(), key = (i: number, j: number) => i * 100003 + j; for (const d of sim.list) { const k = key(Math.floor(d.x / CELL), Math.floor(d.y / CELL)); let a = grid.get(k); if (!a) grid.set(k, (a = [])); a.push(d); } const born: Drop[] = []; for (const d of sim.list) { if (d.dead) continue; if (d.r >= SLIDE_R) { // 平滑地滑:速度跟着大小走(不会一停一走),左右游走,吞了别的水珠会被拉过去一点 const target = 12 * Math.pow(d.r - SLIDE_R + 1, 1.2); d.vy += (target - d.vy) * Math.min(1, dt * 2.2); d.wob += dt * d.wobF; d.kick *= Math.exp(-dt * 2.5); d.vx = Math.sin(d.wob) * 9 + Math.sin(d.wob * 2.3 + 1.7) * 4 + d.kick; const oy = d.y; d.x += d.vx * dt; d.y += d.vy * dt; // 身后留下一串小水珠,并在雾和细雾里擦出一条路 d.trail -= d.y - oy; if (d.trail < 0) { d.trail = rand(9, 22); born.push(makeDrop(d.x + rand(-0.25, 0.25) * d.r, d.y - d.r * 0.9, d.r * rand(0.18, 0.32), true)); d.r *= 0.985; } mctx.globalCompositeOperation = 'destination-out'; mctx.beginPath(); mctx.ellipse(d.x * s, d.y * s - d.r * s * 0.3, d.r * s * 0.95, d.r * s * 1.4, 0, 0, 6.2832); mctx.fill(); const ws = wipe.height / sim.H; wctx.globalCompositeOperation = 'lighter'; wctx.fillStyle = 'rgba(255,255,255,0.3)'; wctx.beginPath(); wctx.arc(d.x * ws, d.y * ws, d.r * ws * 0.7, 0, 6.2832); wctx.fill(); if (d.y - d.r > sim.H + 20) { d.dead = true; continue; } // 吞掉碰到的水珠 const ci = Math.floor(d.x / CELL), cj = Math.floor(d.y / CELL); for (let i = ci - 1; i <= ci + 1; i++) for (let j = cj - 1; j <= cj + 1; j++) { for (const e of grid.get(key(i, j)) ?? []) { if (e === d || e.dead) continue; const dx = e.x - d.x, dy = e.y - d.y; if (Math.hypot(dx, dy) < (d.r + e.r) * 0.82 && (e.r <= d.r || e.r < SLIDE_R)) { const a1 = d.r * d.r, a2 = e.r * e.r; d.r = Math.min(MAXR, Math.sqrt(a1 + a2 * 0.92)); d.x += dx * a2 / (a1 + a2) * 0.6; d.kick += dx * 2.2; d.vy += 14 * a2 / (a1 + 1); e.dead = true; } } } } else { // 小水珠挂在玻璃上,慢慢蒸发 d.r -= dt * (d.still ? 0.25 : 0.06); if (d.r < 1) d.dead = true; } } sim.list = sim.list.filter((d) => !d.dead).concat(born); if (sim.list.length > 1600) sim.list.splice(0, sim.list.length - 1600); // 合成水珠贴图:先铺细雾,再画每一颗水珠 dctx.clearRect(0, 0, drops.width, drops.height); dctx.drawImage(mist, 0, 0); for (const d of sim.list) drawDrop(d, s); }; // 鼠标像手指一样擦雾(x 从左到右、y 从下到上,0–1) const wipeAt = (x: number, y: number) => { const r = wipe.height * 0.035; wctx.globalCompositeOperation = 'lighter'; wctx.fillStyle = 'rgba(255,255,255,0.5)'; wctx.beginPath(); wctx.arc(x * wipe.width, (1 - y) * wipe.height, r, 0, 6.2832); wctx.fill(); mctx.globalCompositeOperation = 'destination-out'; mctx.beginPath(); mctx.arc(x * mist.width, (1 - y) * mist.height, r * 4, 0, 6.2832); mctx.fill(); }; return {drops, wipe, resize, step, wipeAt}; } /* ---------------- 挂载:不依赖 React,纯 HTML 页面、网页 PPT 里也能直接调用 ---------------- */ /** 在 host 里铺满雨夜玻璃背景,返回清理函数。host 要有大小(比如 position: absolute; inset: 0)。 */ export function mountRainyGlass(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', {antialias: false, alpha: false, depth: false, stencil: false, powerPreference: 'high-performance'}); if (!gl) return () => {}; // 不支持 WebGL2:留着 host 自己的底色 let scene: Prog, post: ReturnType; const draw = triangle(gl); try { scene = program(gl, SCENE); post = createPost(gl, draw); } catch { return () => {}; } if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; host.append(canvas); const tex = () => { 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.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); return t; }; const dropTex = tex(), wipeTex = tex(); const rain = createRain(); const ptr = pointer(host, 12); let warm = false; const fit = () => { const r = host.getBoundingClientRect(), dpr = Math.min(devicePixelRatio || 1, 1.5); canvas.width = Math.max(2, Math.round(r.width * dpr)); canvas.height = Math.max(2, Math.round(r.height * dpr)); post.resize(canvas.width, canvas.height, 1); rain.resize(Math.max(1, r.width), Math.max(1, r.height)); // 先让玻璃上积一些水珠再出第一帧 if (!warm) { warm = true; for (let i = 0; i < 240; i++) rain.step(1 / 30); } }; const stop = animate(host, fit, (t, dt) => { ptr.update(dt); if (dt) rain.step(dt); if (performance.now() - ptr.moved < 200) rain.wipeAt(ptr.x, ptr.y); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true); gl.bindTexture(gl.TEXTURE_2D, dropTex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, rain.drops); gl.bindTexture(gl.TEXTURE_2D, wipeTex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, rain.wipe); gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false); post.begin(); scene.use(); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, dropTex); gl.uniform1i(scene.u.uDrops, 0); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, wipeTex); gl.uniform1i(scene.u.uWipe, 1); gl.uniform2f(scene.u.uRes, post.scene.w, post.scene.h); gl.uniform1f(scene.u.uTime, t); draw(); post.end({bloom: 0.5, threshold: 0.75, exposure: 0.95, vignette: 0.3, grain: 0.02, time: t, sat: 1.1}); }, 3); return () => { stop(); ptr.off(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); }; } /** React:放在一个 relative 的容器里当背景, */ export function RainyGlass({className = ''}: {className?: string}) { const ref = useRef(null); useEffect(() => mountRainyGlass(ref.current!), []); return