网页波浪/流体着色器背景 —— 逆向识别 + 复刻 SOP
#这份文档是干嘛的
覆盖两件事:
- 拿到任意一个网站,把它首页那种"流动的波浪/流体渐变背景"逆向到底——用了哪个库、哪个效果节点、每个参数具体是多少、鼠标怎么参与交互。
- 用零依赖的原生 WebGL 复刻出同款观感,不必买商业库。
把本文件拖进一个新的 Claude Code 会话即可直接开工,命令都是可粘贴的原文。
沉淀自 2026-08-14 对 https://flowan.com.cn/zh-cn(流岸 FlowanAI)首页背景的实测逆向。
#环境与前置条件
- macOS,本机已装:
curl、python3(带 PIL/Pillow,用于像素比对) - Playwright MCP 已接入 Claude Code(用户级 MCP,本机已装好)。没有的话
npx playwright install chromium后走 CLI 也行 - 一个能起本地静态服务器的目录(复刻 demo 要在
http://下跑,见坑 2) - 目标站无需登录、无需任何凭据;全程只读公开静态资源
- 已产出的文件都在
~/Desktop/波浪背景动效-Demo/
#正确流程(照着做)
#第 1 步:先看 canvas 的属性,这是最快的一刀
很多着色器库会在 canvas 上留指纹。用 Playwright 打开页面后执行:
() => {
const c = document.querySelector('canvas');
return { attrs: [...c.attributes].map(a => a.name + '=' + a.value), size: c.width + 'x' + c.height };
}flowan 的实测返回:
data-renderer=shaders
data-engine=three.js r181 webgpu ← 一行就定死了:three.js r181 + WebGPU 渲染器成功判据:拿到 data-engine / data-renderer 之类的属性。没有就进第 2 步。
#第 2 步:列出运行时真正加载的 JS,找出体积异常的那个
() => performance.getEntriesByType('resource')
.filter(r => r.name.endsWith('.js'))
.map(r => r.name.split('/_next/static/chunks/')[1] || r.name)flowan 共 20 个 chunk,其中 2cd7115e-845dd2075a2f508f.js 传输 604KB / 解压 1.88MB,一眼就是 three.js 本体 + 着色器库打在一起。
用 curl 拖下来(必须逐个下,别只下 HTML 里 <script> 引的,见坑 3):
curl -s "https://flowan.com.cn/_next/static/chunks/2cd7115e-845dd2075a2f508f.js" -o big.js#第 3 步:在 chunk 里搜库特征(用 python,别用 grep)
python3 - <<'EOF'
s = open('big.js', encoding='utf8', errors='ignore').read()
for k in ['WebGPURenderer','ShaderMaterial','colorNode','license','License','data-renderer','mx_fractal']:
print(k, s.count(k))
EOFflowan 命中 License 3 次,取上下文就拿到了库的身份:
console.warn("Shaders is running in development mode.
Please purchase a license at https://shaders.com to use it in production.")→ 用的是 shaders.com 这个商业 React 着色器库(底层封装 three.js WebGPU + TSL)。
#第 4 步:把库的效果节点清单全导出来
python3 - <<'EOF'
import re
from collections import defaultdict
s = open('big.js', encoding='utf8', errors='ignore').read()
g = defaultdict(set)
for n, c in re.findall(r'name:"([A-Za-z0-9 ]+)",category:"([A-Za-z]+)"', s):
g[c].add(n)
for c in sorted(g):
print(c, sorted(g[c]))
EOF实测导出 shaders.com 的全部 35 个节点:
| 分类 | 节点 |
|---|---|
| Base Layers | Swirl(等,其余分类名格式不同未全量列出) |
| Interactive | ChromaFlow, CursorTrail, GridDistortion, Liquify |
| Distortions | Bulge, GlassTiles, PolarCoordinates, RectangularCoordinates, Twirl, WaveDistortion |
| Blurs | AngularBlur, Blur, ChannelBlur, DiffuseBlur, LinearBlur, ProgressiveBlur, TiltShift, ZoomBlur |
| Stylize | Ascii, CRTScreen, ChromaticAberration, Dither, FilmGrain, Glow, Halftone, Pixelate |
| Adjustments | Duotone, Grayscale, HueShift, Invert, Posterize, Saturation, Tritone, Vibrance |
| Utilities | Group |
#第 5 步:从 React fiber 读出线上实际用的效果和每一个参数值 ★最关键
这一步是整套流程里价值最高的——它直接给出人家调好的参数,省掉全部猜测。
() => {
const el = document.querySelector('.shader'); // 换成目标 canvas 的容器
const key = Object.keys(el).find(k => k.startsWith('__reactFiber$'));
const f = el[key];
const out = [];
(function walk(n, d){
if(!n || d > 30) return;
const p = n.memoizedProps;
if (p && typeof p === 'object' && !Array.isArray(p)) {
const keys = Object.keys(p).filter(k => k !== 'children');
if (keys.length) {
const o = {};
keys.slice(0,14).forEach(k => {
const v = p[k];
o[k] = (typeof v === 'object' && v !== null) ? '{obj}' : v;
});
out.push(o);
}
}
walk(n.child, d+1); walk(n.sibling, d+1);
})(f, 0);
return out.slice(0, 14);
}flowan 首页实测返回(这就是原站的全部配方):
// 第一层 · Base Layers → Swirl("Flowing swirl pattern with multi-layered noise")
{ "colorA": "#e19136", "colorB": "#2662FF", "speed": 0.6, "detail": 0.8, "blend": 50,
"coarseX": 40, "coarseY": 40, "mediumX": 40, "mediumY": 40, "fineX": 40, "fineY": 40 }
// 第二层 · Interactive → ChromaFlow(鼠标方向染色)
{ "baseColor": "#2662FF", "upColor": "#e19136", "downColor": "#1a1a1a",
"leftColor": "#2662FF", "rightColor": "#2662FF",
"intensity": 0.9, "radius": 1.8, "momentum": 25,
"maskType": "alpha", "opacity": 0.97 }成功判据:能看到带业务语义的 props(颜色、speed、intensity 等),而不是一堆 {obj}。
#第 6 步:搞清"鼠标怎么参与"
ChromaFlow 的参数结构本身就把机制说明白了:
- 库内有一个全局鼠标管理器,
window.addEventListener("mousemove"/"touchmove"/"mouseup"/"touchend")统一分发 - 上/下/左/右各配一个颜色(
upColor/downColor/leftColor/rightColor)——按鼠标移动方向决定往画面里注入哪种颜色,不是按位置 radius: 1.8是影响半径,intensity: 0.9是强度momentum: 25是惯性:鼠标停下后颜色不会立刻消失,会拖着余韵慢慢散开
flowan 的取值决定了它很难被肉眼察觉:左右两色都等于 baseColor(#2662FF),所以横向划鼠标几乎没有视觉变化;只有向上划才会注入橙色 #e19136。
顺带一提,页面上那对跟着鼠标走的小圆圈是另一套东西,纯 DOM,源码在 99836-*.js,还原出来是:
const lerp = (a, b, t) => a + (b - a) * t;
// rAF 循环里:pos 以 0.15 的系数逼近 target,两个 div 共用同一个 pos
pos.x = lerp(pos.x, target.x, 0.15);
pos.y = lerp(pos.y, target.y, 0.15);
ring.style.transform = `translate3d(${pos.x}px, ${pos.y}px, 0) translate(-50%,-50%) scale(${hover ? 1.5 : 1})`;
dot .style.transform = `translate3d(${pos.x}px, ${pos.y}px, 0) translate(-50%,-50%) scale(${hover ? 0.5 : 1})`;
// hover 判定
hover = getComputedStyle(e.target).cursor === 'pointer' || e.target.tagName === 'BUTTON' || e.target.tagName === 'A';要点:mix-blend-difference 自动反色(省掉按背景切换颜色的逻辑)、位置只写 transform 不写 left/top、
contain: layout style paint 隔离重排、hidden md:block 触屏端不渲染。
两个 div 共用同一个 pos,所以完全同步、没有拖尾;想要"点跟手、环拖尾"得给两层不同的插值系数。
同一个文件里还有个磁吸按钮:鼠标在按钮内时,按钮按 0.15 * (clientX - rect.left - rect.width/2) 偏移跟随。
噪点层的完整写法(别漏 mixBlendMode):
<div className="pointer-events-none fixed inset-0 z-50 opacity-[0.08]"
style={{ backgroundImage: "url(data:image/svg+xml,...feTurbulence baseFrequency='0.9' numOctaves='4'...)",
mixBlendMode: "overlay" }} />#第 7 步:把算法从库产物里逐行抠出来 ★★ 这一步决定成败
不要靠观感调参去"复现",直接抄算法。 库是 minify 过的,但 TSL 节点图本质是 JS 表达式, 变量名混淆了、结构和常量全在,逐行翻译成 GLSL 完全可行。
python3 - <<'EOF'
s = open('big.js', encoding='utf8', errors='ignore').read()
i = s.find('name:"Swirl"') # 换成第 5 步查到的节点名
seg = s[i:i+6000]
print(seg[seg.find('fragmentNode'):][:3000])
EOFSwirl 抠出来长这样(关键发现:一个噪声函数都没有,全是 sin/cos 的三层域扭曲):
// 原库 minify 后的 TSL,变量名是 b/v/S/M/w/A/R/C…
d = 累积时间 × speed; c = detail
g = coarseX*0.02, y = coarseY*0.02
x = vec2(h.x + sin(h.y*(c*y*1.7) + d*0.8)*0.12,
h.y + cos(h.x*(c*g*1.3) - d*0.6)*0.12)
b = sin(x.x*(c*g*2.1) + x.y*(c*y*1.8) + d*0.4)
// medium 层倍率 c*1.4、频率系数 0.028、偏移 0.06
// fine 层倍率 c*2.8、频率系数 0.056、偏移 0.03
C = b*0.5 + M*0.3 + R*0.2
P = smoothstep(0.3, 0.7, C*0.5 + 0.5 + (blend-50)*0.006)
I = mix(colorA, colorB, P)
U = sin(d*2.5 + C*8)*0.015 + 1 // 极轻的整体呼吸
return I * UChromaFlow 则不是纯着色器,是 CPU 端一套 128×128 的流体场模拟,每帧:
- 整场按
1-dt衰减 - 墨水场做半拉格朗日平流(沿速度场往回采样,步长
50 × momentum × dt) - 在鼠标处按高斯核
exp(-d²/r²)注入速度和墨水,注入半径0.05*radius × min(|v|²×20, 1) - 两张
RG32F纹理传给着色器,着色器只做 5 点平滑 + 按速度方向在四个颜色间取色
这就是"波浪跟着鼠标推开、松手还有拖尾"的真正来源——是真流体,不是按当前鼠标位置染色。
成品:~/Desktop/波浪背景动效-Demo/wave-exact.html,单文件零依赖(WebGL2),直接双击不行(见坑 2):
cd ~/Desktop/波浪背景动效-Demo
nohup python3 -m http.server 8899 >/tmp/srv.log 2>&1 &
sleep 2
curl -s --noproxy '*' -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8899/wave-exact.html # 期望 200
open http://127.0.0.1:8899/wave-exact.html成功判据:整屏是饱和的宝蓝,左上角一道淡紫粉的斜向色带缓慢漂移——和线上首屏一致; 鼠标划过时橙色被推开并留下拖尾。
#踩过的坑
#坑 1 · grep 处理 minify 后的单行大文件会直接报错
- 现象:
grep -oE '.{0,60}shader.{0,60}' home-page.js报ugrep: error at position 82 ... exceeds complexity limits - 原因:webpack 产物是一整行几万到几十万字符,正则回溯量爆炸;
grep -c .返回 1 也印证了"整个文件只有一行" - 正确做法:一律改用 python 读进内存再
str.count()/re.finditer(),别跟 grep 较劲
#坑 2 · Playwright 打不开 file://
- 现象:
Error: Access to "file:" protocol is blocked - 原因:Playwright MCP 出于安全禁用了 file 协议
- 正确做法:
python3 -m http.server 8899起个本地静态服务,走http://127.0.0.1:8899/
#坑 3 · curl 访问 127.0.0.1 返回 000
- 现象:本地服务器明明起来了,
curl http://127.0.0.1:8899/x.html返回000、exit code 7 - 原因:本机环境变量里有 HTTP 代理,curl 把本地请求也发给了代理
- 正确做法:
curl --noproxy '*' ...
#坑 4 · ⚠️ 隐性坑:只扒 HTML 里引用的 chunk,会漏掉真正干活的那个
- 现象:把首页 HTML 里
<script src>/ flight 数据里出现的 chunk 全下下来,扫了一遍,WebGPURenderer、colorNode、GLSLuniform一个都没搜到,几乎得出"这站没用 three.js,是手写 WebGL"的错误结论 - 原因:着色器组件是运行时动态 import 的,最大那个 1.88MB 的 vendor chunk 不在初始 HTML 里
- 正确做法:用
performance.getEntriesByType('resource')拿运行时真实加载的完整列表,再逐个下载
#坑 5 · ⚠️ 隐性坑:搜英文单词做技术栈判定会被正文误伤
- 现象:
grep -i three *.js命中了某个 chunk,差点判定它引了 three.js。实际命中的是页面文案里的"No coffee before the reading, three measurements on the left arm" - 原因:
three、ogl、regl这些库名都是常见英文子串(ogl还会命中Google) - 正确做法:搜 API 标识符而不是包名——
WebGLRenderer、ShaderMaterial、BufferGeometry、gl_FragColor、colorNode
#坑 6 · ⚠️ 隐性坑:靠"截图像素差"判断鼠标是否影响背景,会得出反的结论
现象:设计了严谨的控制变量实验——鼠标不动截两张(基线),鼠标瞬移到对角再截一张,比较平均像素差:
基线 200ms 无鼠标动作 : 均值 15.78 鼠标瞬移后 200ms : 均值 19.14 ← 同量级,看不出差别据此差点写下"波浪本身不跟随鼠标,用户感知到的是自定义光标造成的错觉"
原因:两件事叠在一起了 ——(a)背景动画自身随时间演化,1.5 秒的漂移量就有 48,把鼠标的贡献淹没了;(b)flowan 的 ChromaFlow 左右色 = 基色,横向移动本就几乎无变化,而我恰好测的是横向对角移动
正确做法:别用像素统计去推行为,直接读代码/读组件 props(第 5 步)。像素比对只适合做"已知开关的 A/B 验证"——比如在自己的 demo 里把速度 uniform 从 0 改成 0.5,中心像素从
(33,57,127)变成(204,137,68),那才是有效验证
#坑 6.5 · ⚠️ 隐性坑:靠"逐帧采位移"反推缓动类型,容易把 lerp 说成 spring
现象:另一份对同一站点的分析报告称光标是 framer-motion 的
useSpring,证据是采到的位移序列969→742→829→867→896→893→871→832有明显过冲振荡;页面上确实存在MotionIsMounted全局,看起来很自洽原因:页面同时有 42 个带 transform 的元素在跑 framer-motion 动画,采样容易采到别的元素; 而
MotionIsMounted的存在只说明 framer-motion 被用于页面滚动动画,不代表它驱动光标正确做法:先读源码,再用受控实验验证。实测把光标从 x≈55 一次性跳到 1600,逐帧采到:
287, 484, 651, 794, 915, 1017, 1105, 1179, 1242, 1296, 1342, ... 1588严格单调、逐帧减速、零过冲,且
55.5 + (1600-55.5)*0.15 = 287.2与第一帧完全吻合 —— 这是 lerp 0.15 的指数逼近曲线,弹簧一定会过冲。判据很简单:过冲 = spring,单调 = lerp
#坑 7 · uv 不按短边归一化,画面会随窗口比例被拉伸
- 现象:宽屏下漩涡被横向拉长,换个窗口尺寸质感就变了
- 正确做法:
vec2 uv = (gl_FragCoord.xy - 0.5*uRes) / min(uRes.x, uRes.y);—— 除以短边,不要分别除以宽高
#坑 8 · DPR 不封顶,Retina/4K 上白白多算几倍像素
- 现象:全屏着色器在高分屏上掉帧、风扇起飞
- 正确做法:
const dpr = Math.min(window.devicePixelRatio || 1, 2),2 倍封顶肉眼已无差别
#坑 9 · ⚠️⚠️ 最大的坑:靠"观感调参"去复刻,是彻头彻尾的死路
- 现象:前两版 demo 全靠猜——先用 5 个八度的 fbm,出来是深蓝烟雾;再压低频率、从原站截图采样配色、反复调权重, 调到自以为"很接近"了,拿给用户看,一句 "和他们的根本不一样"。因为方向从一开始就错了
- 原因:Swirl 里根本没有 fbm,一个噪声函数都没有,全是 sin/cos 的解析式。 用噪声去逼近三角函数,纹理的"性格"从根上就不同——噪声是随机絮状,sin/cos 是规则的大波带, 再怎么调参数也调不出对方那种干净的斜向色带。同理 ChromaFlow 是 CPU 流体场, 用"按当前鼠标速度直接染色"去模拟,永远没有平流拖尾
- 正确做法:先花十分钟把算法从 minify 产物里抠出来,再动手写。
minify 只混淆变量名,不改结构和常量,
s.find('name:"Swirl"')就能定位,往后 3000 字符就是完整实现。 抠出来逐行翻译,一次就对——比调参快十倍,而且是真的一样
#坑 10 · ⚠️ 隐性坑:three 的颜色在线性空间,直接拿 hex 算会明显偏亮
- 现象:算法逐行翻译对了,颜色还是不对,整体发灰发亮
- 原因:
transform: GJ把 hex 喂给THREE.Color,而 three 开了 ColorManagement,会自动 sRGB→Linear; 节点里的mix是在线性空间做的,渲染器输出时再 Linear→sRGB。 节点定义里那句colorSpace: { default: "linear" }说的是插值方式(linear/oklch/oklab),不是这件事,别混淆 - 正确做法:GLSL 里如实照做三步——hex 转 sRGB 后先
srgbToLinear,全程线性空间计算,gl_FragColor前再linearToSrgb。两个转换函数用精确版(带 0.0031308 / 0.04045 分段),别用pow(c, 2.2)糊弄
#坑 11 · screenUV 不做宽高比校正,是原库的既定行为
Swirl 用的是 three 的 screenUV(0..1 的屏幕归一化坐标),没有除以短边。
这意味着图案会随窗口比例拉伸——这是原站的实际行为,复刻时要照抄 gl_FragCoord.xy / uRes,
不要套用坑 7 那个"按短边归一化"的通用建议,否则跟线上对不上。
坑 7 只适用于自己从零写着色器的场合。
#坑 10 · 想抄配色就直接从截图采样,别肉眼估
原站 props 里的 #e19136 / #2662FF 是输入色,经过 blend、opacity 0.97、暗角之后,落到屏幕上完全是另一组值。直接采样成品:
python3 - <<'EOF'
from PIL import Image
im = Image.open('原站首屏截图.png').convert('RGB'); w,h = im.size; p = im.load()
for k,(x,y) in {'左上':(int(w*.08),int(h*.12)),'正中':(w//2,h//2),'右下':(int(w*.90),int(h*.88))}.items():
print(k, p[x,y])
EOF实测:左上 (31,54,136)、正中 (105,81,106)、右下 (131,91,63) —— demo 里直接用这三个值当 colorA/B/C,观感立刻对上。
#已知限制 / 死路
- shaders.com 是商业库:非
shaders.com/localhost/127.0.0.1域名下运行会console.warn要求购买 license,代码里还有 telemetry 上报分支。想要 100% 同款就得付费;本 SOP 的复刻走的是自写 WebGL 路线,不碰它的代码。 没能拿到 Swirl 的着色器源码—— 这条已被推翻。产物里没有 GLSL 字符串(因为是 TSL 节点图), 但 TSL 就是 JS 表达式,minify 后照样可读,算法已完整抠出并逐行翻译成 GLSL,见第 7 步。 教训:「搜不到 GLSL 字符串」不等于「拿不到算法」。- React 组件名全被 minify(fiber 上是
euo、elP这种),只有memoizedProps的键名保留了语义——所以第 5 步读 props 有效,读组件名无效。 - 像素差分实验对"是否响应鼠标"无效,见坑 6,别再重复设计这类实验。
- 本 SOP 只覆盖 WebGL/WebGPU 全屏着色器背景这一类。CSS 渐变动画、Lottie、视频背景是另外的路子。
#可复用的代码/脚本
都在 ~/Desktop/波浪背景动效-Demo/:
| 文件 | 作用 |
|---|---|
wave-exact.html |
主产物,用户已确认"这个是对的"。零依赖单文件(WebGL2),Swirl 逐行翻译 + ChromaFlow 完整 CPU 流体场 + lerp 光标 + 噪点层,参数全用线上实测值 |
原站首屏截图.png |
flowan 线上首屏,用于比对 |
精确还原效果.png |
主产物效果,与线上一致 |
_废弃_观感猜的版本/ |
靠 fbm 猜的两版,留作反面教材,别再拿来用(见坑 9) |
Swirl 的 GLSL(与原库一一对应,h 是 gl_FragCoord.xy/uRes):
vec3 swirl(vec2 h){
float d = uTimeSwirl; // 每帧累加 deltaTime × speed
float c = uDetail;
float g = uCoarseX * 0.02, y = uCoarseY * 0.02;
vec2 x = vec2(h.x + sin(h.y * (c*y*1.7) + d*0.8) * 0.12,
h.y + cos(h.x * (c*g*1.3) - d*0.6) * 0.12);
float b = sin(x.x * (c*g*2.1) + x.y * (c*y*1.8) + d*0.4);
float v = c * 1.4;
float mx = uMediumX * 0.028, my = uMediumY * 0.028;
vec2 S = vec2(x.x + cos(x.y * (v*my*1.9) - d*0.5) * 0.06,
x.y + sin(x.x * (v*mx*2.3) + d*0.7) * 0.06);
float Mv = cos(S.x * (v*mx*1.6) - S.y * (v*my*2.4) + d*0.3);
float w = c * 2.8;
float nx = uFineX * 0.056, ny = uFineY * 0.056;
vec2 A = vec2(S.x + sin(S.y * (w*ny*1.1) + d*0.9) * 0.03,
S.y + cos(S.x * (w*nx*0.9) - d*0.4) * 0.03);
float R = sin(A.x * (w*nx*1.3) + A.y * (w*ny*1.7) - d*0.6);
float C = b*0.5 + Mv*0.3 + R*0.2;
float Pp = smoothstep(0.3, 0.7, C*0.5 + 0.5 + (uBlend - 50.0)*0.006);
return mix(uColorA, uColorB, Pp) * (sin(d*2.5 + C*8.0)*0.015 + 1.0);
}ChromaFlow 的 CPU 端每帧三件事(完整实现见文件,128×128、两张 RG32F 纹理):
const decay = 1 - dt; // 1) 整场衰减
smVx = 0.85*smVx + 0.15*(pointer.x - prevX)/dt; // 鼠标速度先平滑
// 2) 墨水沿速度场半拉格朗日平流
const adv = 50 * momentum * dt;
const sx = c - vel[k]*adv, sy = r - vel[k+1]*adv; // 往回采样 + 双线性插值
// 3) 鼠标处高斯注入
const gr = 0.05*radius * Math.min(speedMag*speedMag*20, 1);
if (dist < 2*gr && moving) {
const e = Math.exp(-dist*dist/(gr*gr));
nv[k] += smVx * e * (100*intensity) * dt * 0.01;
nm[k] += 100*intensity * e * dt * 0.01 * Math.min(10*speedMag, 1);
}着色器侧按速度方向取色:
vec2 O = /* 速度场 5 点平滑 */;
float W = length(O), j = smoothstep(0.01, 0.1, W);
float qx = O.x/(W+0.001), qy = O.y/(W+0.001);
float rW = smoothstep(0.0,0.7, max( qx,0.0)), lW = smoothstep(0.0,0.7, max(-qx,0.0));
float uW = smoothstep(0.0,0.7, max( qy,0.0)), dW = smoothstep(0.0,0.7, max(-qy,0.0));
vec3 col = mix(uBase, (uLeft*lW+uRight*rW)*(hs/tot) + (uDown*dW+uUp*uW)*(vs/tot), j);
// 输出 vec4(col * L, L),L 由墨水浓度 smoothstep(0,0.1,C) 得到省电/无障碍两件套(原站也有,别漏):
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; // 系统开了减弱动效就只渲染一帧
new IntersectionObserver(([e]) => { running = e.isIntersecting; }) // 滚出视口就停渲染
.observe(canvas);#下次可以直接从这里开始
当前进度:flowan 首页背景已完全逆向到算法级——库 = shaders.com,效果 = Base Layers/Swirl + Interactive/ChromaFlow,
参数见第 5 步、算法见第 7 步。零依赖复刻版 wave-exact.html 已跑通,用户确认与线上一致。
关键结论一句话:这类站的复刻不要靠调参,算法就在 minify 产物里,抠出来逐行翻译,一次就对。
产物位置:~/Desktop/波浪背景动效-Demo/
下一步可做:
- 把
wave-chromaflow.html拆成 React 组件(<ShaderBackground />,用useEffect建 GL 上下文、卸载时cancelAnimationFrame+gl.getExtension('WEBGL_lose_context').loseContext()),塞进现有 Next.js 项目当首屏背景 - 加移动端降级:窄屏直接渲染一张静态渐变图,省电且避开 iOS Safari 上 WebGL 的内存限制
- 如果要更省体积又想要更多现成效果,评估
@paper-design/shaders-react(免费开源,几 KB)或 OGL(~10KB)——注意这两个都还没实测过,属于待验证选项
来源:沉淀/网页波浪着色器背景复刻-SOP.md(整理于 2026-08-18)