使用 Perlin Noise、Catmull-Rom 创建闭合平滑曲线

效果预览

fig.1 demo 演示效果

前几天在 openprocessing 闲逛时,偶然发现了一个特别吸引我的动画效果------闭合的平滑曲线如同水波般优雅地流动变换,无独有偶,在一款白噪音助眠应用"潮汐"的界面中也看到过极为相似的设计语言,流畅的曲线随着声音节奏轻轻起伏,营造出令人放松的视觉效果。

最让我惊讶的是,这个即简约又充满韵律感的动画效果,核心代码竟然只有短短几行

scss 复制代码
function setup() {
	createCanvas(windowWidth, windowHeight);
}

function draw() {
	blendMode(BLEND);
	background(245);
	blendMode(MULTIPLY);
	noStroke();
	translate(width/2,height/2);
	fill(255,150,0);
	drawLiq(8,100,80,200);
}

function drawLiq(vNnum,nm,sm,fcm){
	push();
	rotate(frameCount/fcm);
	let dr = TWO_PI/vNnum;
	beginShape();
	for(let i = 0; i  < vNnum + 3; i++){
		let ind = i%vNnum;
		let rad = dr *ind;
		let r = height*0.3 + noise(frameCount/nm + ind) * height*0.1 + sin(frameCount/sm + ind)*height*0.05;
		curveVertex(cos(rad)*r, sin(rad)*r);
	}
	endShape();
	pop();
}

而其中最关键的,是两个函数

出于好奇,我试着去研究它们的实现原理,这个过程让我再次感受到,数学的魅力无处不在。

Perlin Noise

认识 Noise 函数

Perlin Noise(柏林噪声)是由计算机科学家 Ken Perlin 在 1983 年提出的一种梯度噪声算法。它能够生成自然、连续且随机的数值,广泛应用于计算机图形学、游戏开发和模拟自然现象(如地形、云层、火焰等)。与纯粹的随机噪声不同,Perlin Noise 具有平滑过渡的特性,使其更接近真实世界的自然纹理。

Perlin Noise 通过在空间中划分网格,并在每个网格节点上赋予随机梯度向量,然后通过插值计算出任意点的噪声值。这种算法能够在多次调用(相同的输入参数)时保持一致的数值,适合用于需要连续性和一致性的场景。

Perlin Noise 原理

Perlin Noise 的核心思想是通过插值使随机值平滑过渡。其实现步骤大致如下

  1. 网格定义:将空间划分为规则的网格,每个网格顶点分配一个随机梯度向量(单位向量)。
  2. 点积计算:对于空间中的任意一点,找到其所在网格的四个顶点,并计算该点到顶点的向量与顶点梯度向量的点积。
  3. 插值平滑:使用缓和曲线(如五次多项式)对四个顶点的点积结果进行双线性插值,确保噪声平滑过渡。

通过调整频率(网格密度)和叠加多层噪声(分形噪声),可以生成更复杂的自然效果。Perlin Noise 因其计算高效和自然表现,成为程序生成内容的重要工具。

MatheMatica 演示 Perlin Noise 的计算

ini 复制代码
(* 1. 定义辅助函数 *)
(* 生成随机单位向量 *)
randomUnitVector := Normalize@RandomReal[{-1, 1}, 2]

(* 生成梯度向量网格 *)
generateGradientGrid[size_] := Table[randomUnitVector, {size}, {size}]

(* 平滑插值函数 *)
fade[t_] := 6 t^5 - 15 t^4 + 10 t^3;
(* 线性插值 *)
lerp[a_, b_, t_] := a + t*(b - a)

(* 2. Perlin Noise 核心函数 *)
perlinNoise2D[gradients_, x_, y_] := 
 Module[{x0, y0, x1, y1, sx, sy, u, v, a, b},
 (* 确定网格单元 *)
  x0 = Floor[x];
  y0 = Floor[y];
  x1 = x0 + 1; y1 = y0 + 1;
  (* 计算相对位置 *)
  sx = x - x0; sy = y - y0;
  (* 计算四个角点的贡献 *)
  u = dotProduct[gradients, x0, y0, x, y];
  v = dotProduct[gradients, x1, y0, x, y];
  a = lerp[u, v, fade[sx]];
  u = dotProduct[gradients, x0, y1, x, y];
  v = dotProduct[gradients, x1, y1, x, y];
  b = lerp[u, v, fade[sx]];
  lerp[a, b, fade[sy]]]
  
(* 点积计算辅助函数 *)
dotProduct[gradients_, ix_, iy_, x_, y_] := 
 Module[{gradient, dx, dy}, gradient = gradients[[iy + 1, ix + 1]];
  dx = x - ix; dy = y - iy;
  gradient . {dx, dy}]

(* 3. 生成噪声图 *)
(* 设置参数 *)
(* 梯度网格大小 *)
gridSize = 3;
(* 噪声分辨率 *)
noiseRes = 0.1;
(* 生成梯度场 *)
gradients = generateGradientGrid[gridSize];

(* 生成噪声数据 *)
noiseData = 
  Table[perlinNoise2D[gradients, x, y], {y, 0, 
    gridSize - 1 - noiseRes, noiseRes}, {x, 0, 
    gridSize - 1 - noiseRes, noiseRes}];

(* 4. 可视化 *)
(* 自定义颜色函数:z 值小->白色,z 值大->蓝色 *)
customColorFunction[z_] := Blend[{{0, White}, {1, Blue}}, z]

(* 基本密度图 *)
plot1 = ListDensityPlot[noiseData, 
   ColorFunction -> customColorFunction, PlotRange -> All, 
   ImageSize -> 500, PlotLabel -> "2D Perlin Noise"];
(* 3D表面图 *)
plot2 = 
  ListPlot3D[noiseData, ColorFunction -> customColorFunction, 
   PlotRange -> All, ImageSize -> 500, PlotLabel -> "3D Perlin Noise"];
(* 并排显示 *)
Row[{plot1, plot2}]

fig.2 2D Perlin Noise

fig.3 3D Perlin Noise

Catmull-Rom

认识 Catmull-Rom 样条曲线

Catmull-Rom 样条曲线是一种插值样条曲线,由 Edwin Catmull 和 Raphael Rom 提出。它能够平滑地穿过给定的控制点,适用于动画路径、相机轨迹和曲线拟合等场景。与 Bézier 曲线不同,Catmull-Rom 曲线保证经过每一个控制点,同时保持局部平滑性,使其在交互式设计中非常实用。

Catmull-Rom 样条曲线原理

Catmull-Rom 曲线的计算基于分段三次插值,其核心步骤如下:

  1. 局部控制:每四个相邻控制点(Pi−1,Pi,Pi+1,Pi+2)确定一段曲线,仅影响 Pi 到 Pi+1 之间的路径。

  2. 插值公式:使用 Hermite 插值形式,计算当前点 t∈[0,1] 的位置:

    <math xmlns="http://www.w3.org/1998/Math/MathML"> P ( t ) = 0.5 ⋅ ( ( 2 P i ) + ( P i + 1 − P i − 1 ) t + ( 2 P i − 1 − 5 P i + 4 P i + 1 − P i + 2 ) t 2 + ( − P i − 1 + 3 P i − 3 P i + 1 + P i + 2 ) t 3 ) P(t)=0.5⋅((2Pi)+(Pi+1−Pi−1)t+(2Pi−1−5Pi+4Pi+1−Pi+2)t2+(−Pi−1+3Pi−3Pi+1+Pi+2)t3) </math>P(t)=0.5⋅((2Pi)+(Pi+1−Pi−1)t+(2Pi−1−5Pi+4Pi+1−Pi+2)t2+(−Pi−1+3Pi−3Pi+1+Pi+2)t3)

  3. 张力参数(可选):可通过调整参数控制曲线的"紧度",默认值为 0.5(均匀 Catmull-Rom 曲线)。

Catmull-Rom 曲线无需额外锚点,计算高效,且天然保持 C1 连续性,适合实时应用。

MatheMatica 演示 Catmull-Rom 样条曲线的计算

ini 复制代码
p0 = {1, 4};
p1 = {3, 5};
p2 = {4, 3};
p3 = {6, 4};
alpha = 0.5; (* 张力参数 *)

t0 = 0;
t1 = EuclideanDistance[p1, p0];
t2 = t1 + EuclideanDistance[p2, p1] ^ alpha;
t3 = t2 + EuclideanDistance[p3, p2] ^ alpha;

CA1[t_] := ((t1 - t)/(t1 - t0)) * p0 + ((t - t0)/(t1 - t0)) * p1;
CA2[t_] := ((t2 - t)/(t2 - t1)) * p1 + ((t - t1)/(t2 - t1)) * p2;
CA3[t_] := ((t3 - t)/(t3 - t2)) * p2 + ((t - t2)/(t3 - t2)) * p3;

CB1[t_] := ((t2 - t)/(t2 - t0)) * CA1[t] + ((t - t0)/(t2 - t0)) *  
    CA2[t] ;
CB2[t_] := ((t3 - t)/(t3 - t1)) * CA2[t] + ((t - t1)/(t3 - t1)) *  
    CA3[t] ;

CR[t_] := ((t2 - t)/(t2 - t1)) * CB1[t] + ((t - t1)/(t2 - t1)) * 
    CB2[t];
    

Show[
     ParametricPlot[
          CA1[t], {t, 0, 6},
          PlotStyle -> {Thin, Dashing[{0.01, 0.02}], Gray}, (* 红色虚线 *)
          PlotRange -> {{-1, 8}, {-1, 8}},
          GridLines -> Automatic,
          GridLinesStyle -> LightGray,
          AxesLabel -> {"X", "Y"}
      ],
     ParametricPlot[
          CA2[t], {t, 0, 6},
          PlotStyle -> {Thick, Dashing[{0.01, 0.02}], Gray} (* 红色虚线 *)
      ],
     ParametricPlot[
          CA3[t], {t, 0, 6},
         PlotStyle -> {Thick, Dashing[{0.01, 0.02}], Gray} (* 红色虚线 *)
      ],
   ParametricPlot[
          CB1[t], {t, 0, 6},
          PlotStyle -> {Thick, Green}
      ],
     ParametricPlot[
          CB2[t], {t, 0, 6},
          PlotStyle -> {Thick, Green}
      ],
      ParametricPlot[
          CR[t], {t, 0, 6},
          PlotStyle -> {Thick, Red}
      ],
  Graphics[{
       PointSize[0.02], Black, Point /@ {p0, p1, p2, p3},
       Black, FontSize -> 12,
       Text["p0", p0, {0, 1.5}], Text["p1", p1, {0, 1.5}],
       Text["p2", p2, {0, 1.5}], Text["p3", p3, {0, 1.5}]
   }]
 ]

fig.4 Catmull-Rom 计算示例

感兴趣的可以对比下 使用四段三次 Bézier 曲线拟合圆 中的贝塞尔曲线的计算示例图,同样四个点下不同的曲线表现

Android 中的实现

使用 Android 版的 Processing 开源框架

克隆源码 processing (android),主要使用其中的 processing-core 模块, 可在 module 下的 build.gradle 文件中引入

java 复制代码
implementation project(':libs:processing-core')

在 Activity 布局中添加 PFragment

kotlin 复制代码
class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::inflate){
    override fun initViews() {
        super.initViews()
        val sketch = SketchBubble()
        val fragment = PFragment(sketch)
        fragment.setView(binding.flContent, this)
    }
}

SketchBubble 中即是具体的动画实现

scss 复制代码
package com.dafay.demo.lab.noise.processing

import android.graphics.PointF
import com.dafay.demo.lib.base.utils.dp2px
import processing.core.PApplet

class SketchBubble : PApplet() {
    private val pointList = mutableListOf<PointF>()
    override fun settings() {
        size(400.dp2px, 400.dp2px)
    }

    override fun setup() {
        hint(DISABLE_DEPTH_TEST)
        background(0f, 255f, 255f)
    }

    override fun draw() {
        // 绘制背景,覆盖上一帧的图像
        background(0f, 255f, 255f)
        translate(width / 2f, height / 2f)
        rotate(frameCount.toFloat() / 500)
        drawBubble()
    }

    /**
     * 刷新 point
     */
    private fun updatePointList() {
        pointList.clear()
        val dr = TWO_PI / 8.toFloat()
        for (i in 0 until 11) {
            val ind = i % 8
            val rad = dr * ind
            val value1 = height * 0.3
            val value2 = noise(frameCount / 500.toFloat() + ind) * height * 0.01
            val value3 = sin(frameCount / 80.toFloat() + ind) * height * 0.05
            val r = value1 + value2 + value3
            val x = cos(rad) * r
            var y = sin(rad) * r
            pointList.add(PointF(x.toFloat(), y.toFloat()))
        }
    }

    private fun drawBubble() {
        stroke(255f, 255f, 125f)
        fill(255f, 255f, 0f)
        // 刷新点的位置
        updatePointList()
        beginShape()
        pointList.forEach {
            // 绘制曲线
            curveVertex(it.x, it.y)
        }
        endShape()
        // 绘制点
        strokeWeight(PI * 3)
        stroke(0f, 0f, 255f)
        pointList.forEach {
            point(it.x, it.y)
        }
    }
}
  • 你可能会遇到的问题

    processing (android) 中 curve 相关的绘制有个 bug ------无法清除之前的绘制,调试发现 path 没有进行重置,具体代码如下

    ini 复制代码
    // PGraphicsAndroid2D.java 文件
    public class PGraphicsAndroid2D extends PGraphics {
        static public boolean useBitmap = true;
        ...
        @Override
        protected void curveVertexSegment(float x1, float y1,
                                        float x2, float y2,
                                        float x3, float y3,
                                        float x4, float y4) {
            curveCoordX[0] = x1;
            curveCoordY[0] = y1;
    
            curveCoordX[1] = x2;
            curveCoordY[1] = y2;
    
            curveCoordX[2] = x3;
            curveCoordY[2] = y3;
    
            curveCoordX[3] = x4;
            curveCoordY[3] = y4;
    
            curveToBezierMatrix.mult(curveCoordX, curveDrawX);
            curveToBezierMatrix.mult(curveCoordY, curveDrawY);
    
            // since the paths are continuous,
            // only the first point needs the actual moveto
            if (vertexCount == 0) {
            //  if (path == null) {
            // TODO: 这里添加一行代码,对路径进行重置
            path.reset();
            path.moveTo(curveDrawX[0], curveDrawY[0]);
            vertexCount = 1;
        }
        ...
    }
  • 创意编程还是直接使用 Processing JavaScript 更为高效。相比之下,使用 Android 进行开发不仅繁琐,还受到诸多限制,这让整个过程失去了不少乐趣。

自己动手实现

Android 可以使用自定义 View,覆盖 View::onDraw(),使用 Canvas 进行绘制

kotlin 复制代码
class BubbleView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
    View(context, attrs, defStyleAttr) {
    // 画笔
    private val paint = Paint().apply {
        style = Paint.Style.FILL_AND_STROKE
        strokeWidth = 6f
        color = Color.YELLOW
        isAntiAlias = true
        blendMode = BlendMode.SRC_OVER
    }

    // 噪声生成器
    private val noiseGenerator = NoiseGenerator()

    // 绘制曲线
    private val curveVertexRenderer = CurveVertexRenderer()
    private val pointList = mutableListOf<PointF>()
    private var frameCount = 0
    private val vNum = 8       // 顶点数量
    private val nm = 200f      // 噪声参数
    private val sm = 80f       // 正弦参数
    private val fcm = 15f     // 旋转参数
    private val animator = ValueAnimator.ofInt(0, Int.MAX_VALUE).apply {
        duration = Int.MAX_VALUE.toLong()
        repeatCount = ValueAnimator.INFINITE
        addUpdateListener {
            frameCount += 1
            invalidate()
        }
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        animator.start()
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        animator.cancel()
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.translate(width / 2f, height / 2f)
        canvas.rotate(frameCount.toFloat() / fcm)
        drawBubble(canvas)
    }

    /**
     * 刷新 point
     */
    private fun updatePointList() {
        pointList.clear()
        val dr: Float = ((2 * PI) / vNum.toFloat()).toFloat()
        for (i in 0 until vNum + 3) {
            val ind: Int = i % vNum
            val rad: Float = dr * ind
            val value1 = height * 0.3
            val value2 = noiseGenerator.noise(frameCount / nm + ind) * height * 0.01f
            val value3 = sin(frameCount / sm.toDouble() + ind) * height * 0.03f
            val r = (value1 + value2 + value3).toFloat()
            val x = cos(rad.toDouble()).toFloat() * r
            val y = sin(rad.toDouble()).toFloat() * r
            pointList.add(PointF(x, y))
        }
    }

    fun drawBubble(canvas: Canvas) {
        updatePointList()
        paint.color = Color.YELLOW
        curveVertexRenderer.beginShape()
        pointList.forEach {
            curveVertexRenderer.curveVertex(it.x, it.y)
        }
        curveVertexRenderer.endShape(canvas, paint)
        paint.color = Color.BLUE
        pointList.forEach {
            canvas.drawCircle(it.x, it.y, 6f, paint)
        }
    }

    /**
     * 模拟 noise 函数
     * 使用随机数模拟 noise 函数
     */
    class NoiseGenerator(private val seed: Int = 0) {
        private val permutation = IntArray(512).apply {
            val random = Random(seed.toLong())
            val p = IntArray(256) { it }
            p.shuffle(random)
            for (i in 0 until 512) {
                this[i] = p[i and 255]
            }
        }

        private fun grad(hash: Int, x: Float): Float {
            val h = hash and 15
            var grad = 1f + (h and 7)  // 梯度值 1-8
            if (h and 8 != 0) grad = -grad  // 随机一半是负数
            return grad * x
        }

        fun noise(x: Float): Float {
            val xi = x.toInt() and 255
            val xf = x - x.toInt()

            val u = fade(xf)

            val a = permutation[xi]
            val b = permutation[xi + 1]

            return lerp(u, grad(a, xf), grad(b, xf - 1f)) * 0.5f + 0.5f
        }

        private fun fade(t: Float): Float = t * t * t * (t * (t * 6f - 15f) + 10f)
        private fun lerp(amount: Float, a: Float, b: Float) = a + amount * (b - a)
    }

    /**
     * Catmull-Rom 样条曲线绘制
     */
    class CurveVertexRenderer {
        private val points: MutableList<PointF> = ArrayList()
        // p1,p2 之间有多少点,点越多曲线越平滑
        private val diff = 0.1f
        fun beginShape() {
            points.clear()
        }

        /**
         * 收集所有参与绘制的点
         */
        fun curveVertex(x: Float, y: Float) {
            points.add(PointF(x, y))
        }

        /**
         * 执行绘制
         */
        fun endShape(canvas: Canvas, paint: Paint) {
            if (points.size < 4) {
                return  // 至少需要4个点才能生成曲线
            }
            val path = Path()
            for (i in 0..points.size - 4) {
                val p0 = points[i]
                val p1 = points[i + 1]
                val p2 = points[i + 2]
                val p3 = points[i + 3]

                for (i in 0..(1/diff).toInt()) {
                    if (i == 0) {
                        path.lineTo(p1.x, p1.y)
                    }
                    val point = catmullRomInterpolation(p0, p1, p2, p3, i*diff)
                    path.lineTo(point.x, point.y)
                }
            }
            canvas.drawPath(path, paint)
        }

        /**
         * catmull-rom 计算,张力默认 0.5
         */
        fun catmullRomInterpolation(
            p0: PointF,  // 前一个点
            p1: PointF,  // 起点
            p2: PointF,  // 终点
            p3: PointF,  // 后一个点
            t: Float     // 插值参数 [0,1]
        ): PointF {
            // 计算 x 坐标
            val x = 0.5f * (
                    (2 * p1.x) +
                            (-p0.x + p2.x) * t +
                            (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t * t +
                            (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t * t * t
                    )

            // 计算 y 坐标
            val y = 0.5f * (
                    (2 * p1.y) +
                            (-p0.y + p2.y) * t +
                            (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t * t +
                            (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t * t * t
                    )

            return PointF(x, y)
        }
    }
}

参考文档

原文链接
Smooth Paths Using Catmull-Rom Splines
Bubble circle liquified (openprocessing)
Processing for Android
计算机曲线简史 计算机曲线简史(中文翻译版)
Centripetal Catmull--Rom spline (Wikipedia)
Parameterization of Catmull-Rom Curves(张力示意图)

相关推荐
雪铃儿8 分钟前
Shorebird 之外,Flutter Android 热更新还有什么选择
android·前端
张筱竼1 小时前
Android开发中的MVC、MVP与MVVM详解
android
星光技术人2 小时前
投机采样 Speculative Decoding 核心笔记
人工智能·笔记·深度学习·计算机视觉·语言模型·自动驾驶
阿巴斯甜4 小时前
必看4
android
Carson带你学Android4 小时前
Android 17 最后一个 Beta 发布,7 件事必须现在做
android·ai编程
ooseabiscuit4 小时前
Laravel 9.x重磅升级:PHP8新特性全解析
android
帅次4 小时前
深入 MaterialTheme:掌握 ColorScheme 与 Typography 的设计核心
android·kotlin·gradle·android jetpack·compose
阿巴斯甜4 小时前
必看2
android
DragonnAi5 小时前
论文解读:SFINet 空间-频率统一学习框架用于多模态图像融合
深度学习·学习·计算机视觉