我以为是流体解算,结果只是一张贴图:复刻 Lusion 的屏幕流体特效

0 阅读11分钟

我以为是流体解算,结果只是一张贴图:复刻 Lusion 的屏幕流体特效

cover.png

前言

这里是西伯利亚小火龙。

大家好久不见呐,你的年更博主又回来啦!我还记得上次更新是25年四月,没想到到现在都一年多了,话说为啥这么久没有更新,嘿嘿,因为我家小崽子出生了。不过从我媳妇怀孕到小崽子出生已经耗费了我大部分精力了,平时还要当牛马,实在是没有精力继续学习与分享了,对不住了大家!(养孩子实在是太辛苦了,为我媳妇点赞O.o)

这不最近才又稍微能挤出时间,很早之前我就找到一个网站(Lusion)它的特效做的很好,最近刚好就想着捡起来,分析下里面的特效,翻到他们一个鼠标特效页的时候停住了:鼠标划过去,整个画面像被手指搅了一下,跟着走、被拽变形,边缘还带点彩虹色的散光。松手之后,画面不会立刻弹回去,而是像水面一样慢慢晃平。

好了,书归正传下面我们开始!

先上两张截图:

fluid-idle.png

fluid-stroke.png

说实话,我第一反应是这玩意儿肯定上了 Navier-Stokes,不然怎么会有这么自然的流动感。结果 F12 一看,好家伙,人家连速度场都是自己拿一张 8bit 贴图画的,从头到尾没解什么方程。

那就有意思了。这篇文章就把这套“障眼法”拆开,用 Three.js 从头写一遍。

先看看它到底长什么样

静止的时候,画面就是一张很素的底图:米白到冷灰的渐变,一蓝一红两团软斑,外加几圈细同心圆。

fluid-detail.png

看着平平无奇对吧?但细圆环是故意的。等会儿你就知道,这种细线在折射的时候最容易“露馅”,位移、色散都会被放大,看着就很有水感。

鼠标快速划一下,圆环会被掰弯,颜色也会跟着被拉开。松手之后墨迹不会马上消失,而是向四周摊开,再慢慢淡掉。

拆一下原理

整个效果其实就三层,我画个图:

鼠标轨迹
   │
   ▼
① ScreenPaint:1/4 分辨率的速度/墨量纹理
   │  (降采样 + 模糊成 1/8 速度场)
   ▼
② 底图渲染到 baseRT
   │  (底图 + 速度场)
   ▼
③ ScreenPaintDistortion:9 次偏移采样 + 色散
   │
   ▼
屏幕

抓住两个点就行。

速度场不是用来“算”的,是用来“推”的。它本身就是一张纹理,每帧被鼠标画一笔、被自己推一下、再衰减一点,仅此而已。

折射也不是改几何,是拿已经渲染好的画面当贴图,沿着速度方向多采几次样。你看到的“形变”,其实是采样位置错了。

想明白这两点,剩下的都是体力活。

用到的库

说出来你可能不信,这个效果正经用到的库就一个:

  • Three.js

对,没有流体库,没有 GSAP,后处理也是自己接的(后面会提一嘴)。工程外壳是 Vite + 原生 JS,没上框架,代码拷到哪儿都能跑。

好,工具就这些,开始动手。

把数据结构定下来

一张 RGBA 纹理,四个通道塞了两类东西:

通道存什么备注
R速度 x + 0.58bit 存不了负数,整体偏 0.5
G速度 y + 0.5同上
Bweight1笔刷“墨量”,决定可见强度
Aweight2边缘附加量,掉得更快

为什么用 8bit?穷。1/4 分辨率下一张 4K 屏的模拟纹理才 960×540,显存和带宽基本可以忽略。代价就是速度得做偏移编码,还要额外处理量化死区——这个坑后面单独说。

建 RT 的时候准备四张,模拟两张做 ping-pong,模糊两张:

const config = {
  minFilter: THREE.LinearFilter,
  magFilter: THREE.LinearFilter,
  format: THREE.RGBAFormat,
  type: THREE.UnsignedByteType, // 8bit,便宜
  depthBuffer: false,
  stencilBuffer: false,
};

// 模拟:当前帧 / 上一帧
this._currPaintRT = new THREE.WebGLRenderTarget(1, 1, config);
this._prevPaintRT = new THREE.WebGLRenderTarget(1, 1, config);

// 低分辨率速度场:原始 / 模糊中转
this._lowRT = new THREE.WebGLRenderTarget(1, 1, config);
this._lowBlurRT = new THREE.WebGLRenderTarget(1, 1, config);

尺寸是原站写死的比例:模拟 1/4,速度场 1/8。

const paintW = Math.max(1, width >> 2);
const paintH = Math.max(1, height >> 2);
const lowW = Math.max(1, width >> 3);
const lowH = Math.max(1, height >> 3);

还有个细节,清屏不能清成黑色。速度 0 编码之后是 0.5,权重 0 就是 0,所以清屏色得是 (0.5, 0.5, 0, 0):

renderer.setClearColor(new THREE.Color(0.5, 0.5, 0), 0);
renderer.clear();

这里我直接传线性 RGB,不走 hex。写成 new THREE.Color('#808080') 的话,Three 会把它当成 sRGB 再转一次,速度就偏了。

开始画

每帧的模拟 pass,说穿了就三件事:把上一帧推一下、扣一点、再把本帧的鼠标线段画进去。

推:半拉格朗日平流

对每个像素,沿着速度反方向回溯到上一帧的位置去采样:

vec4 lowData = texture2D(u_lowPaintTexture, v_uv);
// 0.5 - 编码值 = -速度,再乘一个强度
vec2 velInv = (0.5 - lowData.xy) * u_pushStrength;

vec4 data = texture2D(
  u_prevPaintTexture,
  v_uv + velInv * u_paintTexelSize
);
data.xy -= 0.5; // 解码

u_pushStrength 给 25。这个值越大,墨迹被带得越远,拖尾越长,手感上有点像在糖浆里搅。

扣:三个衰减系数

// 速度 0.975 / weight1 0.95 / weight2 0.8
vec4 delta = (u_dissipations.xxyz - 1.0) * data;

// 本帧鼠标速度 × 覆盖率 = 新注入的速度
vec2 newVel = u_vel * d;
delta += vec4(newVel, radiusWeight.yy * d);

data += delta;
data.xy += 0.5;
gl_FragColor = clamp(data, vec4(0.0), vec4(1.0));

注意这里是 (dissipation - 1.0) * data,不是直接乘。因为衰减系数是乘在旧值上的,写成增量形式才能和注入量加在一起。

画:笔刷是一条有半径的线段

鼠标一帧只有一个点,直接按点画会断成一节一节。所以得把上一帧的终点当成本帧起点,连成线段,再算点到线段的距离:

vec2 sdSegment(vec2 p, vec2 a, vec2 b) {
  vec2 pa = p - a;
  vec2 ba = b - a;
  float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
  return vec2(length(pa - ba * h), h);
}

返回的第二个值是投影位置,拿它插值起点和终点的半径:

vec2 radiusWeight = mix(u_drawFrom.zw, u_drawTo.zw, res.y);
float d = 1.0 - smoothstep(-0.01, radiusWeight.x, res.x);

半径不是固定的,跟鼠标速度走:

let radius = fit(speed, 0, 100, this.minRadius, this.maxRadius);
if (!hadMoved) radius = 0;

maxRadius 取 max(40, 视口宽 / 20)。所以快速甩一下,会拖出一条又粗又长的水袖;慢慢挪,就是一根细线。这个映射是整套效果“手感”的来源,调它比调 shader 管用。

为什么速度场要降采样再模糊

一开始我没加这步,直接用 1/4 的原始速度场回溯,结果画面全是网格状的锯齿,笔刷边缘也硬得像一个个圆盘子。

原因不复杂:8bit 本身精度就低,再叠上低分辨率,平流的时候误差会被放大。所以在模拟结束之后,把结果拷到 1/8,横向纵向各来一次 9-tap 模糊:

vec4 color = texture2D(u_texture, v_uv) * 0.1633;
vec2 delta = u_delta;

color += texture2D(u_texture, v_uv - delta) * 0.1531;
color += texture2D(u_texture, v_uv + delta) * 0.1531;

delta += u_delta;
color += texture2D(u_texture, v_uv - delta) * 0.12245;
color += texture2D(u_texture, v_uv + delta) * 0.12245;

delta += u_delta;
color += texture2D(u_texture, v_uv - delta) * 0.0918;
color += texture2D(u_texture, v_uv + delta) * 0.0918;

delta += u_delta;
color += texture2D(u_texture, v_uv - delta) * 0.0510;
color += texture2D(u_texture, v_uv + delta) * 0.0510;

权重和刚好是 1,不用再除。半径写的是 8 个 texel,实际乘了 0.25,等效大概 2 个 texel。

这套组合下来,速度场又平滑又便宜,是整个效果能跑满帧的关键。

让画面“荡”起来

速度场准备好了,接下来才是你眼睛看到的部分。

折射 pass 不碰任何几何体,它只做一件事:拿已经画好的画面当贴图,沿着速度方向连续采 9 次,再取平均。

vec4 data = texture2D(u_screenPaintTexture, v_uv);
float weight = (data.z + data.w) * 0.5;
vec2 vel = (0.5 - data.xy - 0.001) * 2.0 * weight;

vec2 velocity = vel * u_amount / 4.0 * u_screenPaintTexelSize * u_multiplier;

// 起点加一点噪声抖动,打散 9 个采样点的条带感
vec2 uv = v_uv + bnoise * velocity;
vec4 color = vec4(0.0);
for (int i = 0; i < 9; i++) {
  color += texture2D(u_texture, uv);
  uv += velocity;
}
color /= 9.0;

几个参数:

参数默认值干什么的
amount3单步位移倍率,越大拖影越长
multiplier5整体折射幅度
rgbShift0.5色散强度
colorMultiplier10彩色条纹强度
shade1.25彩色条纹亮度

然后是最出效果的一步,按通道错相的 sin 条纹:

vec3 rainbow =
  sin(vec3(vel.x + vel.y) * 40.0 + vec3(0.0, 2.0, 4.0) * u_rgbShift)
  * smoothstep(0.4, -0.9, weight)
  * u_shade
  * max(abs(vel.x), abs(vel.y))
  * u_colorMultiplier;

color.rgb += rainbow;

RGB 三个通道的相位分别错开 0、2、4,红绿蓝的条纹就不重合了,看起来就是色散。smoothstep(0.4, -0.9, weight) 是个反向区间,让条纹集中在墨量从 0 到 1 的过渡区,也就是笔刷边缘,而不是正中间。

fluid-chroma.png

松手之后

这里有个很容易被忽略的问题。

只做平流的话,鼠标停下之后残留速度还在,整团墨迹会顺着最后的方向继续平移,看着就像受重力往下滑,特别假。

原站本身有速度衰减,我在这个基础上又补了一段收力逻辑:停手 60ms 后开始收,140ms 内把“推动墨迹”的外部速度关掉。

this._idleTime = hadMoved ? 0 : this._idleTime + dt;
const idle = fit(this._idleTime, 0.06, 0.2, 0, 1);
u.u_active.value = 1 - idle;
u.u_idleDamping.value = 1 - this.idleDampingStrength * idle;

对应到 shader:

// 关掉整体推进
velInv *= u_active;

// 按墨量梯度向外扩散:墨从浓的地方流向稀的地方
vec2 weightGrad = vec2(
  (lowR.z + lowR.w) - (lowL.z + lowL.w),
  (lowU.z + lowU.w) - (lowD.z + lowD.w)
);
vec2 spread = weightGrad * (u_idleSpread * (1.0 - u_active));

// CFL 限制:单帧回溯不超过 0.75 个 texel,不然会数值发散
float spreadLen = length(spread);
if (spreadLen > 0.75) spread *= 0.75 / spreadLen;
velInv += spread;

改完之后的观感差别挺大:墨迹不再整体滑走,而是像滴在纸上的墨水一样向四周摊开,再淡掉。

fluid-trail.png

被扭曲的底图也有讲究

折射需要一张“有参照物”的底图。纯色看不出形变,纯噪声又看不出方向。

所以我这张底图是程序化生成的,四样东西:

  • 米白到冷灰的竖向渐变;
  • 一团电光蓝、一团珊瑚红,用低频正余弦慢慢漂;
  • 一圈圈细同心圆;
  • 一层很轻的颗粒噪点,专门盖 8bit 色带。

圆环那段代码就三行:

float f = fract(length(p) * 6.5 - u_time * 0.10);
float ring = smoothstep(0.94, 1.0, f);
color = mix(color, vec3(0.06, 0.07, 0.10), ring * 0.16);

为什么非得是细线?因为细线对位移和色散最敏感。同样的速度,放在大色块上你什么都看不出来,放在圆环上立刻就能看到“被掰弯”。很多流体 Demo 喜欢用网格、棋盘格,也是这个道理。

顺手接进后处理

这套东西本质上是两段式后处理,如果想让画面再电影一点,可以继续往后接 pass。我把折射包成了一个 Pass:

class DistortionPass extends Pass {
  constructor(distortion, sourceRT) {
    super();
    this.distortion = distortion;
    this.sourceRT = sourceRT;
  }

  render(renderer, writeBuffer) {
    this.distortion.render(
      this.sourceRT.texture,
      this.renderToScreen ? null : writeBuffer
    );
  }
}

接完的链路是:

DistortionPass → Final(暗角/颗粒)→ Smaa → Fsr

每个都能单独开关,调参的时候方便对比。

几个我踩过的坑

都是实打实卡了我半天的那种。

1. 8bit 有量化死区。 按浮点思维写 data *= 0.95,存回 8bit 的时候小于 1/255 的值会被四舍五入回 1,尾巴永远散不掉,最后整屏发灰。必须给一个明确的最小衰减量,我这边是每帧 0.004。

delta.zw = sign(delta.zw) * max(vec2(0.004), abs(delta.zw));

2. ping-pong 一定要交换。 同一张纹理又读又写在 WebGL 里是未定义行为。每帧先把 prev 和 curr 换一下,再往新的 curr 里写。

3. 两套坐标别混。 绘制用的是 NDC(左下原点,y 向上),算速度用的是 CSS 像素(左上原点,y 向下)。混着用的话,笔刷会出现在奇怪的位置,或者速度方向整个反过来。

4. dt 要限幅。 切后台再回来,dt 可能是几秒。一帧就能把速度场推到发散。我这边是 Math.min(dt, 0.05)。

5. resize 之后重置鼠标基准。 窗口大小一变,上一帧的鼠标坐标就失效了,第一帧会算出一个巨大的“跳变速度”,整屏直接被甩飞。

6. 颜色空间。 清 RT 的时候用线性值 (0.5, 0.5, 0),别用 hex,避免被 sRGB 转换。

怎么跑起来

代码就在这个仓库里,一个最小可跑工程,依赖只有 Three.js:

GITHUB

入口是 index.html + main.js,核心文件四个:

  • src/ScreenPaint.js:速度场模拟
  • src/ScreenPaintDistortion.js:折射、拖影、色散
  • src/fluidBase.js:被扭曲的底图
  • src/FluidExperience.js:渲染循环和 pass 编排

另外还有 Input.js(鼠标输入)、FluidGUI.js(右上角参数面板)、math.js(几个小工具),后处理那三个 pass(Final / Smaa / Fsr)也一起放进去了,不需要的话把 src/FluidExperience.js 里对应的 addPass 删掉就行。

原效果来自 Lusion,这里只是学习复刻,模型、贴图和视觉设计的版权都归他们。

最后说两句

这个效果最有意思的地方在于,它压根没解流体方程,但通过“速度编码 + 半拉格朗日平流 + 方向性模糊采样”这三板斧,把流体的视觉特征拆开又拼了回去。

如果这篇文章对你有帮助,点个赞再走。后面有空的话,我再看看其他的特效.