欢迎继续一起踏上 The Journey of Chaos, 关于 Shader 生成技术的一些基础性学习。噪声会分为以下几篇内容学习
- 随机函数与白噪声
- 值噪声 ValueNoise
- 柏林噪声 Gradient Noise
- 多维柏林噪声
- 柏林噪声优化 Simplex Noise
- Cell Noise
- SmoothVoronoi 与VoroNoise
- Simplex Noise变种PsrdNoise
- PsrdNoise 应用 FlowNoise
- CurlNoise 与 StreamLines Shader(本篇)
- CurlNoise 调节与边界
- CurlNoise 计算优化 Bitangent Noise
- 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))
几何解释:在空间中每个点放置一个向量箭头,描述该点的方向与大小。其有以下关键特性
- 方向与大小:每个向量同时包含方向和大小信息。
- 光滑性 :若 ( F_x, F_y, F_z ) 是连续可微函数,则称向量场为 光滑向量场。
- 可视化:通常用箭头图表示,箭头方向表示方向,长度表示大小。
常见的* 物理意义**, 向量场用于描述空间中的物理量分布,例如:
- 速度场:流体的流动速度(如风场、水流)。
- 力场:电场 ( \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
假设你在看一个 水管喷水 的场景:
- 散度为正:像喷水口(源头),水从这里 向外涌出(箭头从这里发散出去)。
- 散度为负:像排水口(漏洞),水从这里 被吸入(箭头向这里聚集)。
- 散度为零:水流过这里时,流进和流出的水量相同(如平缓的河流如不可压缩流体) 散度(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
表示该场无源无汇(纯旋转场散度为零)。
旋度
想象你 搅拌一杯咖啡:
- 旋度不为零:咖啡在杯子里 旋转,旋度的方向垂直于旋转面(用右手法则:四指弯曲方向与旋转一致,拇指指向旋度方向)。
- 旋度为零:水静止或平缓流动(没有旋转趋势)。
对于三维空间中的向量场 <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 个,就非常有感觉了。
另外这里有一个小彩蛋,我的鼠标运动,好像整个流场也做了相应的变换。 这也就是论文中提到的调节参数的 技巧。
资料
- curlNoise原始论文: www.cs.ubc.ca/~rbridson/d...
- 3b1b: 散度与旋度:麦克斯韦方程组、流体等所用到的语言: www.bilibili.com/video/BV19s...
- 3b1b: 微分方程概论: www.bilibili.com/video/BV1tb...
- 散度旋度: www.bilibili.com/video/BV1kX...
- 梯度散度旋度: www.bilibili.com/video/BV1a5...
- al-ro.github.io/projects/cu...