介绍

贝塞尔曲线是计算机图形学中最重要的概念之一,以其在表示曲线时的灵活性和精确性而闻名。广泛应用于计算机图形学、动画、路径规划等领域的数学曲线。

贝塞尔曲线的数学原理基础是1912年成立的伯恩斯坦多项式。

简单来说,贝塞尔曲线是通过可变数量的点定义的。当控制点只有两个时,绘制出来的是一条直线,也称为线性贝塞尔曲线

具有三个控制点的贝塞尔曲线是 二次贝塞尔曲线,四个点控制的则是三次贝塞尔曲线,以此类推。

其中,二次和三次贝塞尔曲线比较常用,也是比较受欢迎的两种。因为他们在计算简单性和能够表示无限范围的曲线之间取得了平衡。


曲线方程

贝塞尔曲线方程可以表示为:

image

其中,$B(t)$ 是贝塞尔曲线在参数 t 上的点。

$n$是贝塞尔曲线的次数

$P_i$是控制点。

更具体的,对于一阶贝塞尔曲线,公式如下:

$B(t) = (1 - t) P_0 + t P_1 \quad \text{,其中 } t \in [0, 1]$

其中的$P_0$,$P_1$是两个控制点,曲线从$P_0$出发,经过$P_1$,且为一条直线。

二次贝塞尔曲线有三个控制点,通常用于平滑的路径绘制。该曲线依赖于一个控制点来弯曲直线,这种操作相比很多人都不陌生,我们在很多绘图软件中需要用到曲线或者带箭头的曲线时,都会通过鼠标拖动头尾之外的中间点来实现想要的弯曲效果。

$B(t) = (1 - t)^2 P_0 + 2(1 - t)t P_1 + t^2 P_2 \quad \text{,其中 } t \in [0, 1]$

$P_0$ 和 $P_2$ 是起始点和终点,$P_1$ 是控制点,影响曲线的弯曲度。

三次贝塞尔曲线有四个控制点,常用于图形和字体的平滑曲线,尤其是在矢量图形软件中。

$B(t) = (1 - t)^3 P_0 + 3(1 - t)^2 t P_1 + 3(1 - t) t^2 P_2 + t^3 P_3 \quad \text{,其中 } t \in [0, 1]$

类似的,其中的$p_0$和$p_3$作为起点和终点,其余两个参数作为曲线控制参数,分别控制曲线的起始和终止方向。

对于更高阶的贝塞尔曲线(如四次、五次等)可以用于更复杂的曲线绘制,它们的公式类似于三次贝塞尔曲线,只是控制点数量和计算复杂度增加。

$B(t) = \sum_{i=0}^{n} \binom{n}{i} (1 - t)^{n-i} t^i P_i$

这里$ \binom{n}{i}$ 是二项式系数,控制点的数量为 $n+1$,对这个公式展开后就是上面一开始给出的方程了。


尝试实现

这一节中尝试探讨一下贝塞尔曲线的实现,采用C++和``javascript`代码进行实践。需要说明的是,出于学习的目的,我们这里直接采用递归的实现方法,当然,对于复杂、大数值的贝塞尔曲线,递归可能不够高效,还可能出现栈溢出的问题,因此可以采用迭代的方式,由于我们这里只实现常用的二次和三次曲线,因此就直接使用递归了。

需要说明的是,我们最终需要的是通过代码绘制出来一条贝塞尔曲线,因此就不能通过普通的方法去实现,需要借助一些可以绘图的工具,在C++中,可以使用SFML或者UE等游戏引擎,当然,出于懒惰,趁我UE还没关就直接用它来实现了。

对于JS语言,可以借助Css来配合实现可视化。

基于C++在UE5中的实现

出于简单的目的,这里直接采用UE中的图形化调试类DrawDebugHelpers来实现了,但这不代表实现方式唯一,仅供参考。

  • ABezierCurveActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "BezierCurveActor.generated.h"

UCLASS()
class LEARN_1_API ABezierCurveActor : public AActor
{
	GENERATED_BODY()

public:
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Bezier")
	TArray<FVector> ControlPoints;
	ABezierCurveActor();

	UFUNCTION(BlueprintCallable, Category = "Bezier")
	FVector Bezier(const TArray<FVector>& Points, float t);
protected:
	virtual void BeginPlay() override;

public:	
	virtual void Tick(float DeltaTime) override;

};

  • ABezierCurveActor.cpp
// Fill out your copyright notice in the Description page of Project Settings.

#include "BezierCurveActor.h"
#include "DrawDebugHelpers.h"

// 构造函数
ABezierCurveActor::ABezierCurveActor()
{
	// 启用 Tick,使该 Actor 在每帧调用 Tick() 函数
	PrimaryActorTick.bCanEverTick = true;

	// 初始化控制点,定义三次贝塞尔曲线
	ControlPoints =
	{
		FVector(0,0,0),       // 起点
		FVector(100,200,0),   // 控制点1
		FVector(200,300,0),   // 控制点2
		FVector(300,0,0)      // 终点
	};
}

// 游戏开始时调用
void ABezierCurveActor::BeginPlay()
{
	Super::BeginPlay();
}

// 递归计算贝塞尔曲线上的点
// 使用 De Casteljau 算法逐步插值计算贝塞尔曲线
FVector ABezierCurveActor::Bezier(const TArray<FVector>& Points, float t)
{
	// 递归终止条件:当只剩下一个点时,返回该点
	if (Points.Num() == 1) return Points[0];

	// 存储新计算的插值点
	TArray<FVector> NewPoints;

	// 对当前点集进行线性插值,计算新的点集
	for (int32 i {0}; i < Points.Num() - 1; ++i)
	{
		NewPoints.Add(FMath::Lerp(Points[i], Points[i+1], t));
	}

	// 递归计算直到收敛到一个点
	return Bezier(NewPoints, t);
}

// 每帧调用,用于动态绘制贝塞尔曲线
void ABezierCurveActor::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	// 细分曲线,决定曲线的平滑度
	const int32 NumSegments {50};

	// 计算曲线起始点
	FVector LastPoint = Bezier(ControlPoints, 0.0f);

	// 逐步计算曲线上的点,并绘制线段
	for (int32 i {1}; i <= NumSegments; ++i)
	{
		// 计算当前插值参数 t,范围为 [0,1]
		float t = i / static_cast<float>(NumSegments);

		// 计算贝塞尔曲线在 t 处的点
		FVector NewPoint = Bezier(ControlPoints, t);

		// 在世界中绘制线段,连接上一个点与当前点
		DrawDebugLine(GetWorld(), LastPoint, NewPoint, FColor::Green, false, -1, 0, 2.0f);

		// 更新 LastPoint,作为下一段线段的起点
		LastPoint = NewPoint;
	}
}

image-20250330183234228

对于这种实现方式,虽然写的是三次耳塞尔的实现,但是可以通过调整ControlPoints的参数来实现二次和三次的转变,因为我们的方法是通用的(理论上支持任意阶的贝塞尔曲线,但是在实际应用中,更高阶的曲线建议使用迭代方式实现)。

比如,对于二次贝塞尔曲线,我们需要三个控制点,那么参数可能是下面这样的:

ControlPoints = {
    FVector(0, 0, 0),      // 起点
    FVector(100, 200, 0),  // 控制点
    FVector(200, 0, 0)     // 终点
};

使用JavaScript配合Canvas API实现

这种方式的好处是门槛低,简单的前端三剑客就可以实现,并且交互也不错。

本来是想花点时间做个分步骤教程的,但是想想好像没必要,有点小题大做了,所以这就直接贴一下完整代码好了。

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>贝塞尔曲线可视化</title>
    <style>
        body { text-align: center; }
        canvas {
            border: 1px solid black;
            cursor: pointer;
        }
    </style>
</head>
<body>

<h2>贝塞尔曲线可视化 (支持二次 & 三次)</h2>
<label>
    <input type="radio" name="degree" value="quadratic" checked> 二次贝塞尔
</label>
<label>
    <input type="radio" name="degree" value="cubic"> 三次贝塞尔
</label>
<br><br>
<canvas id="bezierCanvas" width="500" height="500"></canvas>

<script>
    const canvas = document.getElementById("bezierCanvas");
    const ctx = canvas.getContext("2d");

    // 控制点(默认二次贝塞尔)
    let controlPoints = [
        { x: 100, y: 400 }, // 起点
        { x: 250, y: 100 }, // 控制点
        { x: 400, y: 400 }  // 终点
    ];

    let draggingPoint = null;

    // 计算贝塞尔曲线(递归)
    function bezier(points, t) {
        if (points.length === 1) return points[0]; // 递归终止

        let newPoints = [];
        for (let i = 0; i < points.length - 1; i++) {
            newPoints.push({
                x: (1 - t) * points[i].x + t * points[i + 1].x,
                y: (1 - t) * points[i].y + t * points[i + 1].y
            });
        }
        return bezier(newPoints, t);
    }

    // 绘制曲线
    function drawBezierCurve() {
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        // 画控制点连线
        ctx.strokeStyle = "gray";
        ctx.beginPath();
        ctx.moveTo(controlPoints[0].x, controlPoints[0].y);
        for (let i = 1; i < controlPoints.length; i++) {
            ctx.lineTo(controlPoints[i].x, controlPoints[i].y);
        }
        ctx.stroke();

        // 画控制点
        ctx.fillStyle = "red";
        controlPoints.forEach(p => {
            ctx.beginPath();
            ctx.arc(p.x, p.y, 5, 0, Math.PI * 2);
            ctx.fill();
        });

        // 画贝塞尔曲线
        ctx.strokeStyle = "blue";
        ctx.beginPath();
        ctx.moveTo(controlPoints[0].x, controlPoints[0].y);
        for (let t = 0; t <= 1; t += 0.01) {
            let { x, y } = bezier(controlPoints, t);
            ctx.lineTo(x, y);
        }
        ctx.stroke();
    }

    // 监听鼠标拖动控制点
    canvas.addEventListener("mousedown", (e) => {
        let mouseX = e.off_setX, mouseY = e.off_setY;
        draggingPoint = controlPoints.find(p => Math.hypot(p.x - mouseX, p.y - mouseY) < 10);
    });

    canvas.addEventListener("mousemove", (e) => {
        if (draggingPoint) {
            draggingPoint.x = e.off_setX;
            draggingPoint.y = e.off_setY;
            drawBezierCurve();
        }
    });

    canvas.addEventListener("mouseup", () => { draggingPoint = null; });

    // 监听用户选择曲线阶数
    document.querySelectorAll("input[name='degree']").forEach(radio => {
        radio.addEventListener("change", (e) => {
            if (e.target.value === "quadratic") {
                controlPoints = [
                    { x: 100, y: 400 }, // 起点
                    { x: 250, y: 100 }, // 控制点
                    { x: 400, y: 400 }  // 终点
                ];
            } else {
                controlPoints = [
                    { x: 100, y: 400 },  // 起点
                    { x: 180, y: 100 },  // 控制点1
                    { x: 320, y: 100 },  // 控制点2
                    { x: 400, y: 400 }   // 终点
                ];
            }
            drawBezierCurve();
        });
    });

    // 初始化绘制
    drawBezierCurve();
</script>

</body>
</html>

image-20250330204444732

因为是可拖动交互,简单做了一个gif,看起来效果还不错。

Bezier1


使用贝塞尔曲线进行移动

除了为应用程序和游戏提供平滑的动画外,贝塞尔曲线还可以用来定义游戏对象移动的曲线路径。考虑一个 2D 射击游戏,其中一些敌人沿着不同的路径移动。

虽然直线或圆形等直接路径可以硬编码,但这种方法缺乏灵活性,调整和可视化路径也更具挑战性。

对于这种情况,我们也可以使用贝塞尔曲线轻松地可视化和设计复杂的路径。以下是一个示例(通过改进js实现的代码来实现),展示了物体沿着由一组贝塞尔曲线控制点定义的可视化曲线路径移动:

image-20250330210256765

这里有一个基本的工作原理:贝塞尔曲线提供了一组基于时间参数 t 的位置。通过将对象的当前位置更新为这些点,它能够平滑地穿越路径。

obj

See you hala!