📚 离职知识库

网页波浪/流体着色器背景 —— 逆向识别 + 复刻 SOP

#这份文档是干嘛的

覆盖两件事:

  1. 拿到任意一个网站,把它首页那种"流动的波浪/流体渐变背景"逆向到底——用了哪个库、哪个效果节点、每个参数具体是多少、鼠标怎么参与交互。
  2. 用零依赖的原生 WebGL 复刻出同款观感,不必买商业库。

把本文件拖进一个新的 Claude Code 会话即可直接开工,命令都是可粘贴的原文。

沉淀自 2026-08-14 对 https://flowan.com.cn/zh-cn(流岸 FlowanAI)首页背景的实测逆向。


#环境与前置条件

  • macOS,本机已装:curlpython3(带 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))
EOF

flowan 命中 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/topcontain: 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])
EOF

Swirl 抠出来长这样(关键发现:一个噪声函数都没有,全是 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 * U

ChromaFlow 则不是纯着色器,是 CPU 端一套 128×128 的流体场模拟,每帧:

  1. 整场按 1-dt 衰减
  2. 墨水场做半拉格朗日平流(沿速度场往回采样,步长 50 × momentum × dt
  3. 在鼠标处按高斯核 exp(-d²/r²) 注入速度和墨水,注入半径 0.05*radius × min(|v|²×20, 1)
  4. 两张 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.jsugrep: 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 全下下来,扫了一遍,WebGPURenderercolorNode、GLSL uniform 一个都没搜到,几乎得出"这站没用 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"
  • 原因threeoglregl 这些库名都是常见英文子串(ogl 还会命中 Google
  • 正确做法:搜 API 标识符而不是包名——WebGLRendererShaderMaterialBufferGeometrygl_FragColorcolorNode

#坑 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 上是 euoelP 这种),只有 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(与原库一一对应,hgl_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/

下一步可做

  1. wave-chromaflow.html 拆成 React 组件(<ShaderBackground />,用 useEffect 建 GL 上下文、卸载时 cancelAnimationFrame + gl.getExtension('WEBGL_lose_context').loseContext()),塞进现有 Next.js 项目当首屏背景
  2. 加移动端降级:窄屏直接渲染一张静态渐变图,省电且避开 iOS Safari 上 WebGL 的内存限制
  3. 如果要更省体积又想要更多现成效果,评估 @paper-design/shaders-react(免费开源,几 KB)或 OGL(~10KB)——注意这两个都还没实测过,属于待验证选项

来源:沉淀/网页波浪着色器背景复刻-SOP.md(整理于 2026-08-18)