概述
贝塞尔曲线是一种常见的曲线形式,你可以在很多地方看到它的身影,比如PS的钢笔工具,Godot中的Path2D、Curve2D等。
贝塞尔曲线求解本身涉及向量的线性插值,好在Vector2已经提供了lerp()方法。
本篇就介绍二次和三次贝塞尔曲线的基础原理,并写出相应的点求取函数。
二次贝塞尔曲线
在实际上手绘制之前,让我们先来理解一下贝塞尔曲线的求点原理与本质——向量线性插值。

在平面上有三个点A、B、C:
- AB相连,形成一个向量AB⃗\vec{AB}AB,BC相连,形成另一个向量BC⃗\vec{BC}BC;
- 对AB⃗\vec{AB}AB和BC⃗\vec{BC}BC同步进行
0.0到1.0插值,设插值变量为t。 - 在插值的每一时刻,会从AB⃗\vec{AB}AB和BC⃗\vec{BC}BC上各获得一个点
D和E。

- 连接DE,对DE⃗\vec{DE}DE进行
0.0到1.0插值,而且插值与此时的t一致。则获得一个点F。 - 也就是说,同步对AB⃗\vec{AB}AB和BC⃗\vec{BC}BC、DE⃗\vec{DE}DE进行
0.0到1.0插值。

从DE⃗\vec{DE}DE插值获取的所有点F连起来就是一条由A到B的贝塞尔曲线。整个插值过程也就是官方文档中的这张动图:

所以二次贝塞尔曲线是同时进行三个向量插值获得的点的集合。
二次贝塞尔曲线点求取函数
基于原理,很容易就可以写出一个求二阶贝塞尔曲线的函数。
# 求两点之间的二阶贝塞尔曲线点集合
func l2_bezier_curve(
p1:Vector2,p2:Vector2, # 起止点
ctl:Vector2, # 控制点
steps:=10 # 插值次数,值越大,曲线越平滑
) -> PackedVector2Array:
var points:PackedVector2Array
for i in range(steps+1):
var t = i/float(steps)
var l1 = p1.lerp(ctl,t)
var l2 = ctl.lerp(p2,t)
var p = l1.lerp(l2,t)
points.append(p)
return points
其中:
p1和p2是曲线的起点和终点ctl是曲线的控制点steps是插值的步数,步数越大,曲线上点越多,曲线越平滑
测试代码:
extends Node2D
var curve:PackedVector2Array
var p1:=Vector2(200,200)
var p2:=Vector2(400,200)
var ctl:Vector2
func _process(delta: float) -> void:
ctl = get_global_mouse_position()
curve = l2_bezier_curve(p1,p2,ctl)
queue_redraw()
func _draw() -> void:
draw_polyline(curve,Color.AQUAMARINE,1) # 绘制路径
draw_dashed_line(p1,ctl,Color.WHITE,1)
draw_dashed_line(p2,ctl,Color.WHITE,1)
draw_circle(p1,3,Color.ORANGE_RED)
draw_circle(p2,3,Color.ORANGE_RED)
draw_circle(ctl,3,Color.ORANGE_RED)
测试效果:

三次贝塞尔曲线
平面上四个点A、B、C、D:
- 分别组成三个向量AB⃗\vec{AB}AB、BC⃗\vec{BC}BC和CD⃗\vec{CD}CD
- 在三个向量上同步插值获得三个点E、F、G
- EF和FG相连,组成向量EF⃗\vec{EF}EF、FG⃗\vec{FG}FG
- 在EF⃗\vec{EF}EF、FG⃗\vec{FG}FG上同步插值获得点H和I
- EF⃗\vec{EF}EF上同步插值获得点J
- 整个同步插值过程获得的点J的集合,顺序相连,绘制处的就是三次贝塞尔曲线。

动态过程如下(也就是官方文档的动图):

三阶贝塞尔曲线点求取函数
# 求两点之间的三阶贝塞尔曲线点集合
func l3_bezier_curve(
p1:Vector2,p2:Vector2, # 起止点
ctl_1:=Vector2(),ctl_2:=Vector2(), # 控制点
steps:=10, # 插值次数,值越大,曲线越平滑
) -> PackedVector2Array:
var points:PackedVector2Array = []
# 求曲线点集
for i in range(steps+1):
var p = p1.bezier_interpolate(p1+ctl_1,p2+ctl_2,p2,i/float(steps))
points.append(p)
return points
其中:
p1和p2是曲线的起点和终点ctl1是起点的出控制点,ctl2是终点的入控制点,注意这两个控制点的坐标是相对于起点和终点steps是插值的步数,步数越大,曲线上点越多,曲线越平滑
测试代码:
extends Node2D
var curve:PackedVector2Array
var p1:=Vector2(200,200)
var p2:=Vector2(400,200)
var ctl1:=Vector2(0,50)
var ctl2:=Vector2(50,0)
func _process(delta: float) -> void:
ctl2 = get_global_mouse_position() - p2
curve = l3_bezier_curve(p1,p2,ctl1,ctl2)
queue_redraw()
func _draw() -> void:
draw_polyline(curve,Color.AQUAMARINE,1) # 绘制路径
draw_dashed_line(p1,p1+ctl1,Color.WHITE,1)
draw_dashed_line(p2,p2+ctl2,Color.WHITE,1)
draw_circle(p1,3,Color.ORANGE_RED)
draw_circle(p2,3,Color.ORANGE_RED)
draw_circle(p1+ctl1,3,Color.ORANGE_RED)
draw_circle(p2+ctl2,3,Color.ORANGE_RED)
测试效果:

导数
Vector2类型提供了一个名叫bezier_derivative()的方法,用来求贝塞尔曲线上t处的“导数”。
经过实际测试,这个所谓的“导数”返回一个点,连接贝塞尔曲线上t处的点与该点,刚好是一个切线段。
我们以下面的代码进行测试:
extends Node2D
var p1 = Vector2(100,100) # 起点
var p2 = Vector2(200,200) # 终点
var ctl_1 = Vector2(50,0) # 控制点1
var ctl_2 = Vector2(50,0) # 控制点2
var points:PackedVector2Array = [] # 曲线点集合
var ds:PackedVector2Array = [] # 曲线点导数集合
var steps = 100; # 点的数目,越多曲线越平滑
var curve_color:= Color.WHITE # 曲线绘制颜色
var ctl_color:= Color.AQUAMARINE # 控制点和连线绘制颜色
func _ready() -> void:
# 求曲线点集
for i in range(steps+1):
var p = p1.bezier_interpolate(p1+ctl_1,p2-ctl_2,p2,i/float(steps))
points.append(p)
var d = p1.bezier_derivative(p1+ctl_1,p2-ctl_2,p2,i/float(steps))
ds.append(d)
func _draw() -> void:
draw_polyline(points,curve_color,1)
var i = 0
draw_line(points[i],points[i]+ds[i],ctl_color,1)
print(points[i]," ",points[i]+ds[i])
其中i是指曲线上点的索引,不同的i可以从points[i]中获取代表在i/float(steps)处的点。
以下是一些i值下对应点与“导数”点连线的情况:

将极坐标点函数运用于贝塞尔控制点
# 极坐标点函数 - 通过角度和长度定义一个点
func pVector2(angle:float = 0.0,length:float =0.0) -> Vector2:
var dir = Vector2.RIGHT.rotated(deg_to_rad(angle))
return dir * length
Vector2很难直观的表达方向和距离信息,pVector2则可以,所以在设定贝塞尔控制点时,可以使用极坐标点函数。
总结
本文历经修改,从最初的冗长删减为至今的模样,也总结出了二阶和三阶贝塞尔曲线点的求取函数,基本完成其使命。
1293

被折叠的 条评论
为什么被折叠?



