# 海浪沙滩 Beach Waves > 俯拍海浪冲上沙滩的背景;海在下、沙滩在上,浪一道道冲上来再退回去,留下湿沙和泡沫。 分类:背景 [全部组件和流程](https://www.dachuiai.com/llms.txt) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:度假、民宿、旅行、生活方式类的首屏,要放松、清爽的感觉。 - 不适合:深色风格、科技感的网站;背景上要放大段文字(浪花一直在动)。 - 适合的网站:官网、本地小店、活动页、内容站 - 放在:首屏、背景 - 手机上:好用:着色器在手机上也能跑,竖屏时看到的是更窄的一段海岸。 - 性能代价:中:一个全屏着色器,一般电脑 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,不用另外安装 - 文件:原样存成 `beach-waves.tsx`(比如 `src/components/beach-waves.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/beach-waves.tsx),比手抄准:`curl -fsSL -o src/components/beach-waves.tsx https://www.dachuiai.com/code/beach-waves.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`mountBeachWaves`、`BeachWaves` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 注意:背景铺满它所在的容器:容器写 relative,组件写 className="absolute inset-0",内容放在它后面并设 relative 或 absolute 盖在上面。滚出屏幕、切走标签页时自己暂停。不是 React(纯 HTML、网页 PPT、Vue):文件里的 mountBeachWaves(元素) 不依赖 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、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 **mountBeachWaves** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `host` | | 要铺满背景的那个元素(DOM 节点),它要有大小 | **BeachWaves** | 参数 | 默认值 | 作用 | | --- | --- | --- | | `className` | `''` | 加在最外层的类名,用来调大小、位置、间距 | ## 你可以说 > 用全屏片元着色器写一个俯拍海岸背景,海在下、沙滩在上:以到岸线的距离当水深,水色从深蓝过渡到浅绿松石,浅水能看到沙底和焦散;主涌浪用尖峰正弦(GPU Gems 的做法),越靠岸越陡;海面风纹用 16 道指数正弦波叠加并做域扭曲;每道浪在哪里开始碎随噪声变化,白浪从一处向两边铺开;泡沫用 Voronoi(F2−F1)做花边;浪冲上沙滩留下薄水层和湿沙;少量太阳闪光,整体颜色柔和。 ## 完整代码 ```tsx import {useEffect, useRef} from 'react'; /* --------------------------------------------------------------- * BeachWaves 海浪沙滩 * 从天上往下拍海岸:海在下、沙滩在上。水按深浅从深蓝过渡到浅绿松石,浅水透出沙底和焦散; * 浪一道道涌过来,越靠岸越陡,从一处碎开、白浪往两边铺,冲上沙滩再退回去,留下湿沙和泡沫花边。 * 镜头沿着海岸慢慢移动,鼠标让它稍微偏一点。 * 用 WebGL2 全屏着色器实时画,不用视频和图片。滚出屏幕、切到别的标签页时暂停;开了"减弱动态效果"时停在一帧。 * ------------------------------------------------------------- */ const SCENE = `#version 300 es precision highp float; in vec2 vUv; out vec4 o; uniform vec2 uRes; uniform float uTime; uniform vec2 uCam; 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); } float noise(vec2 p){ vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f); return mix(mix(h21(i), h21(i+vec2(1,0)), f.x), mix(h21(i+vec2(0,1)), h21(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*noise(p); p = p*2.03 + 17.1; a *= 0.5; } return v; } // 细胞噪声:到最近一个移动特征点的距离(泡沫花边和焦散) float cell(vec2 p){ vec2 i = floor(p), f = fract(p); float d = 8.0; for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++) { vec2 g = vec2(x, y); vec2 o = vec2(h21(i+g), h21(i+g+7.3)); o = 0.5 + 0.45*sin(uTime*0.6 + 6.2831*o); d = min(d, length(g + o - f)); } return d; } // 细胞之间网状的线(F2 − F1):海底的焦散光纹、泡沫花边 float web(vec2 p, float speed){ vec2 i = floor(p), f = fract(p); float d1 = 8.0, d2 = 8.0; for (int y = -1; y <= 1; y++) for (int x = -1; x <= 1; x++) { vec2 g = vec2(x, y); vec2 o = vec2(h21(i+g), h21(i+g+7.3)); o = 0.5 + 0.45*sin(uTime*speed + 6.2831*o); float d = length(g + o - f); if (d < d1) { d2 = d1; d1 = d; } else if (d < d2) d2 = d; } return d2 - d1; } const float PERIOD = 6.5; // 两道浪之间隔几秒 // 到岸线的距离(米):> 0 在海里,< 0 在沙滩上 float shoreDist(vec2 w){ float along = w.x; float shore = 1.6 * sin(along * 0.11) + 0.6 * sin(along * 0.31 + 1.3) + 0.25 * fbm(vec2(along * 0.2, 3.0)); return w.y - shore + 3.4; // 岸线不在正中:海比沙滩多一些 } // 此时此地,水冲上沙滩冲了多远(米) float runup(float along, float t){ float ph = fract(t / PERIOD + 0.08 * sin(along * 0.37) + 0.05 * fbm(vec2(along * 0.15, 9.0))); float rise = smoothstep(0.0, 0.22, ph), fall = 1.0 - smoothstep(0.22, 0.95, ph); float reach = 2.3 + 0.9 * fbm(vec2(along * 0.25, floor(t / PERIOD))); return mix(-0.3, reach, rise * fall); } // 风浪:很多方向的尖峰波叠加,每一道都把下一道的采样位置推一下 // (GPU Gems 的指数正弦波加域扭曲),小浪尖才会像真的水一样挤在一起;返回坡度 vec3 chop(vec2 p, float t){ float h = 0.0, wsum = 0.0, f = 3.0, a = 1.0; vec2 g = vec2(0.0); for (int i = 0; i < 16; i++) { float fi = float(i); float ang = 0.25 + 0.6 * sin(fi * 2.4); // 大致的风向:浪尖是横在风向上的短褶 vec2 d = vec2(sin(ang), -cos(ang)); float x = -dot(d, p) * f + t * sqrt(9.8 * f) * 0.45 + fi * 1.9; float e = exp(sin(x) - 1.0), de = e * cos(x); h += a * e; g += a * de * (-f * d); wsum += a; p += d * de * a * 0.15; f *= 1.15; a *= 0.8; } return vec3(h, g) / wsum; } void main(){ vec2 uv = (gl_FragCoord.xy - 0.5*uRes) / uRes.y; // 俯拍:海在下、沙滩在上,岸线横着走 vec2 w = vec2(-uv.x, -uv.y) * 40.0 + uCam; float t = uTime; float n = shoreDist(w); // > 0 在海里(下),< 0 在沙滩上(上) float along = w.x; float depth = clamp(n, 0.0, 40.0); float shoal = smoothstep(14.0, 2.0, depth); // 外海是 0,浅水是 1 // 浪(参考 GPU Gems《Effective Water Simulation》):主涌浪正对着岸冲过来, // 到浅水变陡变高、然后碎开;上面再叠风浪 float warp = 1.2 * fbm(vec2(along * 0.12, n * 0.05 + t * 0.03)); float phase = (n + warp) * 0.55 + t * (6.2831 / PERIOD); // 主涌浪 float c = max(0.5 + 0.5 * cos(phase), 0.0); // max():pow() 碰到极小的负数会出 NaN(一串亮点) float age = fract(phase / 6.2831); // 浪尖处是 0,往海的方向变大 float kid = floor(phase / 6.2831); // 这块水在第几道浪后面 float p0 = mix(2.0, 6.0, shoal); // 越靠岸浪尖越尖 float A0 = 0.3 * (0.45 + 0.9 * shoal); float hn = pow(c, p0); // 0 是浪谷 … 1 是浪尖 float hgt = 2.0 * A0 * hn; vec2 grad = vec2(0.0, -A0 * p0 * pow(c, p0 - 1.0) * sin(phase) * 0.55); // 主涌浪沿离岸方向的坡度 vec3 ch = chop(w, t); float calm = mix(0.4, 1.0, smoothstep(0.5, 5.0, depth)); // 很浅的地方风浪变平 vec3 N = normalize(vec3(-(grad * 0.9 + ch.yz * 0.16 * calm), 1.0)); // 沙滩上的水边:一层薄水冲上来又滑回去 float run = runup(along, t); float edgeNoise = (fbm(vec2(along * 0.9, t * 0.2)) - 0.5) * 0.7 + (fbm(vec2(along * 3.5, t * 0.4)) - 0.5) * 0.25; float wet = n + run; float water = smoothstep(-0.06, 0.06, wet + edgeNoise); float sheet = smoothstep(1.6, 0.0, n) * water; // 水边沙子上的薄水层 // 沙子 vec3 sandDry = vec3(0.8, 0.71, 0.55), sandWet = vec3(0.52, 0.42, 0.3); // 偏暖、不太亮的沙 float grain = noise(w * 9.0) * 0.05 + noise(w * 37.0) * 0.04; float ripples = 0.5 + 0.5 * sin(dot(w, vec2(2.4, 0.35)) + 3.0 * fbm(w * 0.3)); float maxReach = 3.4; float wetBand = smoothstep(-maxReach - 0.6, -0.3, n + edgeNoise * 0.5); vec3 sand = mix(sandDry, sandWet, wetBand * 0.85) * (0.95 + grain + 0.03 * ripples * (1.0 - wetBand)); float gloss = wetBand * (1.0 - smoothstep(0.0, 0.8, wet + 1.4)); // 刚打湿的沙会反光 sand += vec3(0.16, 0.2, 0.24) * gloss * (0.6 + 0.4 * noise(w * 4.0)); // 水色看深浅;浅水能透出沙底、沙纹和焦散 vec3 shallow = vec3(0.26, 0.77, 0.73), mid = vec3(0.0, 0.43, 0.58), deep = vec3(0.0, 0.11, 0.27); float cdep = depth + hgt * 1.2; // 浪尖下水多:颜色深一点 vec3 wc = mix(shallow, mid, smoothstep(0.3, 4.5, cdep)); wc = mix(wc, deep, smoothstep(3.5, 15.0, depth)); wc *= 0.9 + 0.2 * fbm(w * 0.08 + t * 0.01); float clarity = exp(-depth * 0.45); vec2 cq = w * 0.8 + vec2(fbm(w * 0.25 + t * 0.07), fbm(w * 0.25 + 5.0 - t * 0.06)) * 2.2 + N.xy * 0.4; float caus = smoothstep(0.09, 0.0, web(cq, 0.7)) * 0.7 * smoothstep(0.2, 0.6, fbm(w * 0.4 + 2.0)); vec3 bed = mix(sandDry, sandWet, 0.4) * (0.8 + 0.2 * ripples) * (0.78 + 0.22 * caus) + vec3(0.9, 1.0, 0.95) * caus * 0.22; wc = mix(wc, bed * vec3(0.74, 0.95, 0.93), clarity * 0.72); // 明暗:朝向天空亮处的面更亮,背过去的更暗 float tilt = dot(N.xy, normalize(vec2(-0.3, 0.6))); wc *= 1.0 + tilt * 1.6; wc += vec3(0.5, 0.62, 0.7) * max(tilt, 0.0) * 0.5 * smoothstep(0.5, 3.0, depth); // 浅水里浪的陡面透光,泛出绿松石色 float face = smoothstep(0.72, 0.97, age) * smoothstep(1.0, 0.975, age); wc += vec3(0.04, 0.36, 0.32) * face * shoal * 0.55; // 泡沫由气泡组成:两种大小的花边,网格慢慢扭动,再撕成一片片 vec2 fw = w + vec2(fbm(w * 0.35 + t * 0.05), fbm(w * 0.35 - t * 0.05 + 7.0)) * 1.6; float bubbles = smoothstep(0.13, 0.0, web(fw * 1.3, 0.25)) * 0.65 + smoothstep(0.1, 0.0, web(fw * 3.2 + 2.0, 0.4)) * 0.45; float patches = smoothstep(0.32, 0.68, fbm(fw * 0.7 + vec2(0.0, t * 0.05))); float churn = fbm(vec2(along * 1.8, n * 1.4) + vec2(0.0, t * 0.6)); // 每道浪在哪儿碎,沿着海岸、一道一道都不一样:浪尖先从一处碎开, // 白浪再从那里往两边铺开 float bd = clamp(4.5 + 9.0 * (fbm(vec2(along * 0.12 + kid * 5.3, kid * 1.7)) - 0.5), 1.8, 7.5); float broken = smoothstep(bd + 0.4, bd - 0.4, depth); float edgeWob = (fbm(vec2(along * 1.2, t * 0.3)) - 0.5) * 0.08; float tear = (fbm(fw * 1.1 + vec2(0.0, t * 0.2)) - 0.5) * 0.16; // 白浪的后沿撕成花边 float core = broken * smoothstep(0.17 + edgeWob + tear, 0.02 + tear * 0.3, age) * smoothstep(0.0, 0.015, age) * (0.75 + 0.5 * churn) * (0.8 + 0.3 * bubbles); float field = broken * exp(-age * 4.0) * (0.45 + 0.55 * patches) * (bubbles * 0.9 + 0.4); float lingering = smoothstep(4.5, 0.0, depth) * patches * bubbles * 0.55; // 浅水里漂着的旧泡沫 // 碎开之前浪尖变薄、发亮,上面冒出第一点点白 float kN = floor(phase / 6.2831 + 0.5); float bdN = clamp(4.5 + 9.0 * (fbm(vec2(along * 0.12 + kN * 5.3, kN * 1.7)) - 0.5), 1.8, 7.5); float pre = smoothstep(bdN + 2.4, bdN + 0.3, depth) * smoothstep(bdN - 0.4, bdN + 0.3, depth); float lip = pow(c, 90.0) * pre; wc += vec3(0.25, 0.42, 0.38) * lip * 0.6; float fleck = lip * smoothstep(0.55, 0.8, fbm(vec2(along * 2.2, n * 2.0 + kN * 3.1) + vec2(t * 0.2, 0.0))) * smoothstep(bdN + 1.2, bdN + 0.3, depth); float foam = clamp(core * 1.6 + field * 1.1 + lingering + fleck * 0.8, 0.0, 1.0); // 冲上沙滩的水边:一条亮的花边,退回去时留下一点泡沫 float edgeLine = smoothstep(0.32, 0.0, abs(wet + edgeNoise)) * smoothstep(-0.2, 0.5, run); foam = max(foam, edgeLine * (0.55 + 0.45 * bubbles)); vec3 foamCol = vec3(0.9, 0.92, 0.92) * (0.9 + 0.1 * churn); // 泡沫是米白,不是纸白 vec3 waterCol = mix(wc, foamCol, foam); // 水纹上的太阳闪光 vec3 L = normalize(vec3(-0.2, 0.34, 1.0)), V = vec3(0.0, 0.0, 1.0); // 只有陡一点的水纹能反到太阳:零星几点小闪光 float spec = pow(max(dot(N, normalize(L + V)), 0.0), 1500.0) * (1.0 - foam) * smoothstep(0.5, 3.0, depth); waterCol += vec3(1.0, 0.97, 0.9) * spec * 0.55; // 闪光调暗,它们会闪 // 沙子上的薄水层是透明的 waterCol = mix(waterCol, mix(sand * vec3(0.8, 0.95, 0.97), waterCol, 0.35 + 0.65 * foam), sheet * (1.0 - smoothstep(0.0, 1.6, n))); vec3 col = mix(sand, waterCol, water); 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); }; } /* ---------------- 挂载:不依赖 React,纯 HTML 页面、网页 PPT 里也能直接调用 ---------------- */ /** 在 host 里铺满海浪沙滩背景,返回清理函数。host 要有大小(比如 position: absolute; inset: 0)。 */ export function mountBeachWaves(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 ptr = pointer(host, 1.5); 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); }; const stop = animate(host, fit, (t, dt) => { ptr.update(dt); post.begin(); scene.use(); gl.uniform2f(scene.u.uRes, post.scene.w, post.scene.h); gl.uniform1f(scene.u.uTime, t); gl.uniform2f(scene.u.uCam, -t * 0.9 - (ptr.x - 0.5) * 2.0, -(ptr.y - 0.5) * 2.0); draw(); post.end({bloom: 0.2, threshold: 1.1, exposure: 0.68, vignette: 0.15, grain: 0.012, time: t, sat: 1.12}); }, 4); return () => { stop(); ptr.off(); gl.getExtension('WEBGL_lose_context')?.loseContext(); canvas.remove(); }; } /** React:放在一个 relative 的容器里当背景, */ export function BeachWaves({className = ''}: {className?: string}) { const ref = useRef(null); useEffect(() => mountBeachWaves(ref.current!), []); return