数字孪生混乱中生成世界10 CurlNoise 与 StreamLines Shader

欢迎继续一起踏上 The Journey of Chaos, 关于 Shader 生成技术的一些基础性学习。噪声会分为以下几篇内容学习

  1. 随机函数与白噪声
  2. 值噪声 ValueNoise
  3. 柏林噪声 Gradient Noise
  4. 多维柏林噪声
  5. 柏林噪声优化 Simplex Noise
  6. Cell Noise
  7. SmoothVoronoi 与VoroNoise
  8. Simplex Noise变种PsrdNoise
  9. PsrdNoise 应用 FlowNoise
  10. CurlNoise 与 StreamLines Shader(本篇)
  11. CurlNoise 调节与边界
  12. CurlNoise 计算优化 Bitangent Noise
  13. FBM深入

看到上面美丽的漩涡了吗?本文会理解其原理,主要是2007 curlNoise论文基础上进行学习,并理解 shadertoy上基于 curlnoise的一些视觉效果。 Curl是旋度的意思。想象在一个河流中有一块不规则的石头,河水是无法进入石头的,只能在石头表面流动。 石头不同位置水流速度不一样。怎么通过数学描述这个事情呢? 物理学提出了 Curl(旋度)这个概念。

一些数学概念

首先需要了解一些概念

标量场(Scalar Field)

标量场是定义在空间区域上的函数,每个空间点对应一个 标量值 (如温度、高度、密度等)。 特点

  • 无方向性,仅表示大小。
  • 可用于描述连续介质的属性(如压力场、灰度图像)。

看下面这个图像,它是由两个函数组成,暂且咱们不去管他是什么函数,我们把它想象成为动画片里面两座小山。 那么两条线就是两座小山的高度。如果我们要将两个小山合并呢? 是不是取两个线更大的值就可以了?

等等,前面的 SDF不是取两个值更小的值吗?怎么到这取最大值了呢。 于是我们第一个重点就来了,我换一个方式去理解,我们给两座小山画一个天空也就是最上面那个线

现在我们不在使用 两条线的 y值了,而是使用小山到天空的距离, 也就是 3.5 - y . 这样如果再去合并两个小山,是不是就要去小山到天空距离更短的.

同样在初中地理中,常常会通过下面这种等高线图用来表述地形,

我们一直学的SDF 是标量场的一种特殊形式,表示某点到最近物体表面的 带符号距离(内部为负,外部为正)。

向量场(Vector Field)

向量场是空间中每个点对应一个 向量(如速度、力场、电场)。 其特点有

  • 具有方向性,能描述流动、力等动态行为。
  • 可通过 微分操作 与标量场关联(如梯度、散度、旋度)。

设 <math xmlns="http://www.w3.org/1998/Math/MathML"> M ⊆ R n M \subseteq \mathbb{R}^n </math>M⊆Rn 是一个空间区域(如平面、三维空间或更高维度空间).向量场 是一个映射:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> F : M → R n \mathbf{F}: M \rightarrow \mathbb{R}^n </math>F:M→Rn

它将每个点 <math xmlns="http://www.w3.org/1998/Math/MathML"> p ∈ M \mathbf{p} \in M </math>p∈M 对应到一个向量 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( p ) ∈ R n \mathbf{F}(\mathbf{p}) \in \mathbb{R}^n </math>F(p)∈Rn

对空间区域 <math xmlns="http://www.w3.org/1998/Math/MathML"> M ⊆ R 3 M \subseteq \mathbb{R}^3 </math>M⊆R3,向量场可写为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> F ( x , y , z ) = ( F x ( x , y , z ) , F y ( x , y , z ) , F z ( x , y , z ) ) \mathbf{F}(x, y, z) = \big(F_x(x, y, z), F_y(x, y, z), F_z(x, y, z)\big) </math>F(x,y,z)=(Fx(x,y,z),Fy(x,y,z),Fz(x,y,z))

几何解释:在空间中每个点放置一个向量箭头,描述该点的方向与大小。其有以下关键特性

  1. 方向与大小:每个向量同时包含方向和大小信息。
  2. 光滑性 :若 ( F_x, F_y, F_z ) 是连续可微函数,则称向量场为 光滑向量场
  3. 可视化:通常用箭头图表示,箭头方向表示方向,长度表示大小。

常见的* 物理意义**, 向量场用于描述空间中的物理量分布,例如:

  • 速度场:流体的流动速度(如风场、水流)。
  • 力场:电场 ( \mathbf{E} )、磁场 ( \mathbf{B} )、引力场。
  • 梯度场:标量场(如温度、电势)的梯度 ( \nabla f )。

向量场通过为空间中的每一点赋予一个向量,描述了方向和大小的分布规律,是分析物理现象和几何结构的核心工具。
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∇ f = ( ∂ f ∂ x , ∂ f ∂ y , ∂ f ∂ z ) \nabla f = \left( \frac{\partial f}{\partial x}, \frac{\partial f}{\partial y}, \frac{\partial f}{\partial z} \right) </math>∇f=(∂x∂f,∂y∂f,∂z∂f)

而SDF 的梯度场是单位法向量场,即以下指向表面法线方向。
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∇ SDF ( p ) \nabla \text{SDF}(\mathbf{p}) </math>∇SDF(p)

散度 Divergence

假设你在看一个 水管喷水 的场景:

  1. 散度为正:像喷水口(源头),水从这里 向外涌出(箭头从这里发散出去)。
  2. 散度为负:像排水口(漏洞),水从这里 被吸入(箭头向这里聚集)。
  3. 散度为零:水流过这里时,流进和流出的水量相同(如平缓的河流如不可压缩流体) 散度(Divlevergence)是向量场的一个标量性质,用于描述向量场在某一点的"发散程度"或"汇聚程度"。

对于三维空间中的向量场 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x , y , z ) = ( F x , F y , F z ) \mathbf{F}(x, y, z) = (F_x, F_y, F_z) </math>F(x,y,z)=(Fx,Fy,Fz),其散度定义为各分量对各自坐标的偏导数之和:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> div   F = ∇ ⋅ F = ∂ F x ∂ x + ∂ F y ∂ y + ∂ F z ∂ z \text{div}\, \mathbf{F} = \nabla \cdot \mathbf{F} = \frac{\partial F_x}{\partial x} + \frac{\partial F_y}{\partial y} + \frac{\partial F_z}{\partial z} </math>divF=∇⋅F=∂x∂Fx+∂y∂Fy+∂z∂Fz

其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> ∇ = ( ∂ ∂ x , ∂ ∂ y , ∂ ∂ z ) \nabla = \left( \frac{\partial}{\partial x}, \frac{\partial}{\partial y}, \frac{\partial}{\partial z} \right) </math>∇=(∂x∂,∂y∂,∂z∂)是梯度算子。

例1:径向场

向量场 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x , y , z ) = ( x , y , z ) \mathbf{F}(x, y, z) = (x, y, z) </math>F(x,y,z)=(x,y,z) 的散度为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∇ ⋅ F = ∂ x ∂ x + ∂ y ∂ y + ∂ z ∂ z = 1 + 1 + 1 = 3 \nabla \cdot \mathbf{F} = \frac{\partial x}{\partial x} + \frac{\partial y}{\partial y} + \frac{\partial z}{\partial z} = 1 + 1 + 1 = 3 </math>∇⋅F=∂x∂x+∂y∂y+∂z∂z=1+1+1=3

表示空间中每点都是强度为3的"源"。

例2:旋转场

向量场 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x , y ) = ( − y , x ) \mathbf{F}(x, y) = (-y, x) </math>F(x,y)=(−y,x)(绕原点旋转)的散度为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∇ ⋅ F = ∂ ( − y ) ∂ x + ∂ x ∂ y = 0 + 0 = 0 \nabla \cdot \mathbf{F} = \frac{\partial (-y)}{\partial x} + \frac{\partial x}{\partial y} = 0 + 0 = 0 </math>∇⋅F=∂x∂(−y)+∂y∂x=0+0=0

表示该场无源无汇(纯旋转场散度为零)。

旋度

想象你 搅拌一杯咖啡:

  1. 旋度不为零:咖啡在杯子里 旋转,旋度的方向垂直于旋转面(用右手法则:四指弯曲方向与旋转一致,拇指指向旋度方向)。
  2. 旋度为零:水静止或平缓流动(没有旋转趋势)。

对于三维空间中的向量场 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x , y , z ) = ( F x , F y , F z ) \mathbf{F}(x, y, z) = (F_x, F_y, F_z) </math>F(x,y,z)=(Fx,Fy,Fz),其旋度定义为梯度算子 <math xmlns="http://www.w3.org/1998/Math/MathML"> ∇ \nabla </math>∇ 与向量场的叉乘:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> curl   F = ∇ × F = ( ∂ F z ∂ y − ∂ F y ∂ z , ∂ F x ∂ z − ∂ F z ∂ x , ∂ F y ∂ x − ∂ F x ∂ y ) \text{curl}\, \mathbf{F} = \nabla \times \mathbf{F} = \left( \frac{\partial F_z}{\partial y} - \frac{\partial F_y}{\partial z}, \frac{\partial F_x}{\partial z} - \frac{\partial F_z}{\partial x}, \frac{\partial F_y}{\partial x} - \frac{\partial F_x}{\partial y} \right) </math>curlF=∇×F=(∂y∂Fz−∂z∂Fy,∂z∂Fx−∂x∂Fz,∂x∂Fy−∂y∂Fx)

例1:旋转场

向量场 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x , y ) = ( − y , x ) \mathbf{F}(x, y) = (-y, x) </math>F(x,y)=(−y,x)(绕原点逆时针旋转)的旋度(二维)为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> curl   F = ∂ x ∂ x − ∂ ( − y ) ∂ y = 1 − ( − 1 ) = 2 \text{curl}\, \mathbf{F} = \frac{\partial x}{\partial x} - \frac{\partial (-y)}{\partial y} = 1 - (-1) = 2 </math>curlF=∂x∂x−∂y∂(−y)=1−(−1)=2

表示该场具有均匀的旋转强度(旋度方向垂直向外)。

例2:三维场

向量场 <math xmlns="http://www.w3.org/1998/Math/MathML"> F ( x , y , z ) = ( 0 , z , 0 ) \mathbf{F}(x, y, z) = (0, z, 0) </math>F(x,y,z)=(0,z,0)的旋度为:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∇ × F = ( ∂ 0 ∂ y − ∂ z ∂ z , ∂ 0 ∂ z − ∂ 0 ∂ x , ∂ z ∂ x − ∂ 0 ∂ y ) = ( − 1 , 0 , 0 ) \nabla \times \mathbf{F} = \left( \frac{\partial 0}{\partial y} - \frac{\partial z}{\partial z}, \frac{\partial 0}{\partial z} - \frac{\partial 0}{\partial x}, \frac{\partial z}{\partial x} - \frac{\partial 0}{\partial y} \right) = (-1, 0, 0) </math>∇×F=(∂y∂0−∂z∂z,∂z∂0−∂x∂0,∂x∂z−∂y∂0)=(−1,0,0)

表示场绕 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x 轴负方向旋转。

散度旋度对比和向量点积差积

点积描述两个向量在方向的相似程度,如果反方向>90° 则为负。 差积描述两个向量是否接近于垂直,如果垂直这值最大。

散度和旋度与点差积特性有某种相似性,所以也会这么表示,

性质 散度 ( <math xmlns="http://www.w3.org/1998/Math/MathML"> ( ∇ ⋅ F (\nabla \cdot \mathbf{F} </math>(∇⋅F) 旋度 ( <math xmlns="http://www.w3.org/1998/Math/MathML"> ∇ × F \nabla \times \mathbf{F} </math>∇×F)
类型 标量场 向量场(三维)或标量(二维)
物理意义 场的"源"或"汇"强度 场的旋转强度和方向
运算类型 点积 叉积
守恒条件 不可压缩场: <math xmlns="http://www.w3.org/1998/Math/MathML"> ∇ ⋅ F = 0 \nabla \cdot \mathbf{F} = 0 </math>∇⋅F=0 保守场: <math xmlns="http://www.w3.org/1998/Math/MathML"> ∇ × F = 0 \nabla \times \mathbf{F} = \mathbf{0} </math>∇×F=0

一些物理概念

Curl Noise

正常模拟高效模拟不可压缩流体的湍流运动(如烟雾、蒸汽等)基于解复杂的偏微分方程, 例如Navier-Stokes 方程。 而这篇论文提出了一种基于Curl Noise的流体速度场生成方法,即是对一个随机向量场,进行 Curl 操作之后得到的新场。因为满足散度为 0 的特性,所以这个场看上去就具有流体的视觉特性. 计算简单 ,并支持固体边界约束空间调制.

基本原理

在流体力学中,二维中的势可以被称为"流函数":它的等值线就是流动的流线。这意味着,流函数描述了流体在空间中的运动方式,其等值线上的点代表了相同的速度场。因此,通过绘制流函数的等值线,我们可以清晰地了解流体的流动方向和速度。如下图所示,其等速的先 streamlines

如果一个场的散度为 0,称之为无源场(divergence-free),表示这个场是不可压缩流体,特别像真实世界的水流。 现在的问题就是一个向量场如何转化为 无源场?也就是要满足以下公式
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ∇ ⋅ ( v x , v y ) = 0 \nabla \cdot (v_x, v_y) = 0 </math>∇⋅(vx,vy)=0

根据散度和旋度的定义,有
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> v = ∇ × Ψ \mathbf{v} = \nabla \times \mathbf{\Psi} </math>v=∇×Ψ

于是以 2D为例,当速度满足以下条件,生成了divergence-free
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ( v x , v y ) = ( ∂ ψ ∂ y , − ∂ ψ ∂ x ) (v_x, v_y) = \left( \frac{\partial \psi}{\partial y}, -\frac{\partial \psi}{\partial x} \right) </math>(vx,vy)=(∂y∂ψ,−∂x∂ψ)

于是乎我们可以得到以下的伪代码。

scss 复制代码
// 函数定义
float noise( in vec2 p )
float grad(in float n)
// curl操作
float n = noise(p);
vec2 grad = grad(n);
vec2 curl = (grad.y, -grad.x);

数值求解

接下去就是求x,y 的偏导数,这里使用了数值的方法去求导数,这种方法在计算机图形里面非常常见,比如之前这篇阴影的讲解中。导数的含义其实就是微分趋于0的极限值,而用一个非常小的微分h可以得到导数的近视值。假设我们的曲面函数为f(p)
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> f ( p ) = 0 n = n o r m a l i z e ( Δ f ( p ) ) Δ f ( p ) = { d f ( p ) d x , d f ( p ) d y , d f ( p ) d z } d f ( p ) d x ≈ f ( p + { h , 0 , 0 } ) − f ( p ) h \begin{aligned} f(p) = 0 \\ n = normalize(\Delta f(p)) \\ \Delta f(p) = \lbrace \frac{df(p)}{dx} , \frac{df(p)}{dy} ,\frac{df(p)}{dz} \rbrace \\ \frac{df(p)}{dx} \approx \frac{f(p+\lbrace h,0,0 \rbrace)-f(p)}{h} \end{aligned} </math>f(p)=0n=normalize(Δf(p))Δf(p)={dxdf(p),dydf(p),dzdf(p)}dxdf(p)≈hf(p+{h,0,0})−f(p)

当然上面的近视值只考虑了正数方向,如果从函数图像的左边趋近,我们可以叫做逆向差分Backward Difference, 于是有代码

ini 复制代码
float n0 = simplex_noise(p);
float n1 = simplex_noise(p + ds.xy);
float n2 = simplex_noise(p + ds.yx);

vec2 grad = vec2(n1 - n0, n2 - n0) / ds.x;

来一个漂亮的速度场吧

有了上述两个基础,我们就可以实现文章开头漂亮的流场图片啦。代码来源 www.shadertoy.com/view/wstGDN 。 这里不在赘述之前 simplexNoise的做法了,直接给出 curl的函数.

ini 复制代码
vec3 curl_noise(vec2 p)
{
    const float dt = 1e-4;
    vec2 ds = vec2(dt, 0.0);
    
    float n0 = simplex_noise(p);
    float n1 = simplex_noise(p + ds.xy);
    float n2 = simplex_noise(p + ds.yx);
    
    vec2 grad = vec2(n1 - n0, n2 - n0) / ds.x;
    vec2 curl = vec2(grad.y, -grad.x);
    return vec3(curl, n0) ;
}

不过这里会用到一些 Shader的技巧,主要是双缓冲(BufferA 和 BufferB)实现了粒子的运动轨迹和渲染 。为了解释这个技巧,我们先从最简单的情况开始。 如下图所示屏幕中有一个粒子. 这里构建一个 BufferB(iChannel1),用 x表示存储灰度值,使用 y存储粒子存活 时间。并且在每一个渲染将存活时间-1,当时间<0时候,进入新一轮绘制。

scss 复制代码
vec2 sdSegment(vec2 p1, vec2 p2)
{
	vec2 d = p2 - p1;
	vec2 p = p1;
    float lp = dot(p, d) / dot(d, d);
    return p - d * clamp(lp, 0.0, 1.0);
}

float drawLine(vec2 p1, vec2 p2, float width) {
	return smoothstep(width, 0.0, length(sdSegment(p1, p2)))
}

void mainImage(out vec2 store, in vec2 uv) {
	vec2 pos = vec2(0., 0.);
	vec2 nextPos = vec2(0., 0.);

	// 取得上一次渲染的值
	vec2 store = texelFetch(iChannel1, ivec2(uv), 0);
	
	store.x = drawLine(pos, nextPos);
	if (store.y > -1.0) {
		store.y --;
	} else {
		store.y = 1000.;
	}
}

由于我们在上面代码中 pos和 nextPos都为 中心点, 所以屏幕中永远只有一个点。 接下去我们将计算 curl,求得粒子在在速度场中的速度。 从而让粒子运动起来, 其原理非常简单,就是用 一个 **BufferA(iChannel0)**存储点的当前位置和上一个位置, 当粒子生命周期为 0 时,进行位置回归原始,其最简单的代码如下

ini 复制代码
void mainImage( out vec4 fragColor, in vec2 fragCoord ){
    vec2 puv = (fragCoord * 2.0 - iResolution.xy) / iResolution.y;


	// 上一轮渲染的时候,点的最新位置
	vec2 p0 = texelFetch(iChannel0, ivec2(fragCoord), 0).xy;
	// curl之后得到的运动方向与速度, 向量表示
	vec2 v0 = curl_noise(p0).xy;
	// 新的位置
	vec2 p1 = p0 + v0 * iTimeDelta;

	// 从上面获取粒子的信息,(x表示灰度值,y表示生命周期)
	vec4 c = texelFetch(iChannel1, ivec2(fragCoord), 0);
	if (c. y <=0.0) {
		// 回归到最初位置
	    fragColor = puv.xyxy;
	} else {
		// 存储该点与下一个点
		fragColor = vec4(p1, p0);
	}
}

这个时候,我们再去修改**BufferB(iChannel1)**中的位置代码,变成

ini 复制代码
vec4 ppos = texelFetch(iChannel0, gid, 0);
vec2 pos = ppos.zw;
vec2 nextPos = ppos.xy;

并且对之前的灰度值做一个 fade

ini 复制代码
vec4 col = texelFetch(iChannel1, ivec2(fragCoord), 0);
col.x *= TRAIL_FADE;

这样就可以获得一个运动的粒子

没什么感觉对不对,这个时候我们增加到 40 * 20 = 800 个,就非常有感觉了。

另外这里有一个小彩蛋,我的鼠标运动,好像整个流场也做了相应的变换。 这也就是论文中提到的调节参数的 技巧。

资料

  1. curlNoise原始论文: www.cs.ubc.ca/~rbridson/d...
  2. 3b1b: 散度与旋度:麦克斯韦方程组、流体等所用到的语言: www.bilibili.com/video/BV19s...
  3. 3b1b: 微分方程概论: www.bilibili.com/video/BV1tb...
  4. 散度旋度: www.bilibili.com/video/BV1kX...
  5. 梯度散度旋度: www.bilibili.com/video/BV1a5...
  6. al-ro.github.io/projects/cu...
相关推荐
全马必破三4 分钟前
CSS 盒模型
前端·css
野生的程序媛11 分钟前
重生之我在学Vue--第12天 Vue 3 性能优化实战指南
前端·javascript·vue.js
vvilkim31 分钟前
Vue.js 中的计算属性、监听器与方法:区别与使用场景
前端·javascript·vue.js
乘风!33 分钟前
SpringBoot前后端不分离,前端如何解析后端返回html所携带的参数
前端·spring boot·后端
Anlici2 小时前
跨域解决方案还有优劣!?
前端·面试
庸俗今天不摸鱼2 小时前
【万字总结】构建现代Web应用的全方位性能优化体系学习指南(二)
前端·性能优化·webp
追寻光2 小时前
Java 绘制图形验证码
java·前端
前端snow2 小时前
爬取数据利用node也行,你知道吗?
前端·javascript·后端
村头一颗草2 小时前
高德爬取瓦片和vue2使用
前端·javascript·vue.js
远山无期2 小时前
vue3+vite项目接入qiankun微前端关键点
前端·vue.js