概述
本篇讨论的内容是笔者基于B站视频《一种简单的程序化动画技术_哔哩哔哩_bilibili》所演示的内容,简单实现的Godot版本。
主要探讨如何设计一种关节链条结构,能够跟随鼠标进行位置运动,并用圆形链条结构模拟软体动物动态爬行的过程和其身体本身。
本文到发布为止,并没有完全实现视频中全部的内容,后续可能会在本文基础上扩展和更新。
下图就是实现的基础效果:
Chain类
我首先遇到的问题是,如何表示和创建一个每段之间间距不变的多线段链条结构。
我想到的办法是:
- 设计一个名叫
Chain
的类 - 在创建实例时,传入一个
PackedVector2Array
来表示初始的多个顺序点,计算两两点之间的距离,存储到一个名为lens
的数组 - 传入的
PackedVector2Array
则保存到points
属性中
下面的类图就是Chain
类最基础的核心结构类图和代码:
class_name Chain
var points:PackedVector2Array # 顶点数据
var lens:PackedFloat32Array # 保存所有的初始距离
func _init(points:PackedVector2Array) -> void:
self.points = points # 记录初始顶点数据
# 计算和记录每个初始顶点之间的距离并存储到 lens 数组中
if points.size()>1:
for i in range(points.size()-1):
var p1 = points[i]
var p2 = points[i+1]
lens.append(p1.distance_to(p2))
移动和更新
通过修改第一个点的位置,然后依次根据距离和方向更新后续点的位置,从而实现一种链条整体移动的效果。
其封装函数如下:
# 正向动力学 - 遍历定顶点,更新位置
func update_points_fk(target:Vector2) -> void:
points[0] = target
for i in range(1,points.size()):
var dir = points[i].direction_to(target) # 方向
points[i] = target - lens[i-1] * dir
target = points[i]
目前为止,其实已经可以基于CanvasItem
绘图函数和Chain
类型,构造和绘制能够跟随鼠标移动的链条结构。
extends Node2D
var chain:Chain # 链
var target:Vector2 # 记录鼠标位置
var points:PackedVector2Array = [
Vector2(0,0),
Vector2(50,0),
Vector2(100,0),
Vector2(150,0),
Vector2(250,0),
]
func _ready() -> void:
chain = Chain.new(points)
func _process(delta: float) -> void:
target = get_global_mouse_position()
chain.update_points_fk(target) # 根据鼠标位置,移动链
queue_redraw()
func _draw() -> void:
draw_polyline(chain.points,Color.WHITE,1)
for p in chain.points:
draw_circle(p,3,Color.AQUAMARINE)
CircleChain
在Chain
类型实现随鼠标移动的链条结构之后,我们创建一种特殊的链条结构:
- 每个节点之间的距离相等
- 在关节处绘制大小不等的圆,圆的半径由参数传入。
其基础类图结构如下:
可以看到:
CircleChain
是基于Chain
类型的,在其基础上添加了两个属性和一个绘制方法CircleChain
实例化时,传入的并不是初始的点集合,而是点之间的固定距离dis
和每个关节处要绘制的圆的半径所组成的数组radius
,半径数组有多少个元素,就创建多少个关节点,并绘制相应半径的圆。
基础代码如下:
class CircleChain extends Chain:
var dis:float: # 固定间隔
set(val):
dis = val
# 根据圆半径的个数,填充lens数组
lens.resize(radius.size())
lens.fill(dis)
var radius:PackedFloat32Array: # 圆形半径数列
set(val):
radius = val
# 根据圆半径的个数,填充lens数组
lens.resize(radius.size())
lens.fill(dis)
func _init(dis:float,radius:PackedFloat32Array) -> void:
self.dis = dis # 记录固定间隔
self.radius = radius # 记录圆的半径
# 计算出初始的位置值
for i in range(radius.size()):
points.append(Vector2(-dis,0) * i) # 沿X轴方向向左排列
# 绘制圆
func draw_circles(canvas:CanvasItem,color:=Color.WHITE,fill:=false,border_width:=1.0) -> void:
for i in range(points.size()):
canvas.draw_circle(points[i],radius[i],color,fill,border_width)
其中:
- 初始关节位置生成:将第一个定位在坐标系原点
(0,0)
,然后通过向左偏移固定距离dis
,获得其他点的初始位置
- 移动过程:直接调用继承自
Chain
类型的update_points_fk()
方法,所以更新思路基本一致,不过是在节点位置更新完毕之后,在新位置上重新画圆
测试
extends Node2D
var chain:CircleChain # 链
var target:Vector2 # 记录鼠标位置
var radius:PackedFloat32Array = [30,40,50,60]
func _ready() -> void:
chain = CircleChain.new(100,radius)
func _process(delta: float) -> void:
target = get_global_mouse_position()
chain.update_points_fk(target) # 根据鼠标位置,移动链
queue_redraw()
func _draw() -> void:
draw_polyline(chain.points,Color.WHITE,1)
for p in chain.points:
draw_circle(p,3,Color.AQUAMARINE)
chain.draw_circles(self)
绘制效果:
到此为止,已经介绍清楚了基本的不等长链条和等距圆链,以及其根据鼠标位置移动和更新的原理。
扩充绘制函数
在进一步的设计中,我只是在Chain
和CircleChain
中多添加了几个draw_
打头的函数,方便在测试场景根节点中参数化并有选择性的绘制链的元素,比如关节点、连线、圆或者连线的偏移多边形等。
具体函数可以去看文末的完整源码。以下是一些选择性或组合起来的绘制效果:
思考
这里通过在点的位置上绘制图形,实现了一种很特殊的效果,这让我第一时间想到的是《泰拉瑞亚》中的长条生物。或许在你的游戏中可以采用相似的思路创建这种怪物。
其实在这一步之后,我还尝试了更换首尾的图片,但是效果不佳,后续或许会发文介绍。
求边界轮廓
在圆链的基础上,通过求圆最左侧和最右侧的边界点,并将它们连在一起,组成多边形,就可以绘制出基于圆链的软体生物的外形。
到这一步,生物模拟的部分还没有完成,限于篇幅,这里点到为止。
关系类图
CircleChain
扩展自Chain
类型,所以,部分属性和方法是共用的,而一些方法被重写。
完整代码
Chain
# ========================================================
# 名称:Chain
# 类型:自定义类
# 描述:存储正向运动学或反向运动学链条结构的类,并提供简单的正向和反向动力学运动方法
# 创建时间:2024年9月10日20:28:28
# 修改时间:2024年9月12日00:50:13
# ========================================================
class_name Chain
# ============================ 属性 ============================
var points:PackedVector2Array # 顶点数据
var lens:PackedFloat32Array # 保存所有的初始距离
var max_angle:=90 # 顶点之间最大偏转角度
var limit_max_angle:=false # 是否限定顶点之间最大偏转角度
# ============================ 虚函数 ============================
func _init(points:PackedVector2Array) -> void:
self.points = points # 记录初始顶点数据
# 计算和记录每个初始顶点之间的距离并存储到 lens 数组中
if points.size()>1:
for i in range(points.size()-1):
var p1 = points[i]
var p2 = points[i+1]
lens.append(p1.distance_to(p2))
# ============================ 方法 ============================
# 链总长度
func length() -> float:
var len:float
for i in range(lens.size()):
len += lens[i]
return len
# 正向动力学 - 遍历定顶点,更新位置
func update_points_fk(target:Vector2) -> void:
points[0] = target
for i in range(1,points.size()):
var dir = points[i].direction_to(target) # 方向
points[i] = target - lens[i-1] * dir
target = points[i]
# 绘制顶点
func draw_points(canvas:CanvasItem,r:=3.0,color:=Color.WHITE,fill:=true,border_width:=1.0) -> void:
for i in range(points.size()):
if fill:
canvas.draw_circle(points[i],r,color,fill)
else:
canvas.draw_circle(points[i],r,color,fill,border_width)
# 在顶点处绘制指定的纹理
func draw_texture_in_points(
canvas:CanvasItem,
texture:Texture2D, # 要绘制的纹理
size:=Vector2(100,100), # 图片的绘制尺寸
rotate:=false, # 图片是否跟随曲线旋转
head_texture:Texture2D = null, # 头纹理,如果为 null ,则绘制 texture 参数指定的纹理
end_texture:Texture2D = null, # 尾部纹理,如果为 null ,则绘制 texture 参数指定的纹理
) -> void:
if !head_texture:head_texture = texture
if !end_texture:end_texture = texture
# 反序遍历关节点
for i in range(points.size()-1,-1,-1):
var dir = Vector2.RIGHT
if rotate:
if i==0:
dir = points[i].direction_to(points[i+1]) # 方向
else:
dir = points[i-1].direction_to(points[i]) # 方向
# 求取矩形
var rect = Rect2(-size/2.0,size)
canvas.draw_set_transform(points[i],dir.angle())
# 根据头尾还是中间绘制不同的图片
if i==0:
canvas.draw_texture_rect(head_texture,rect,false)
elif i == points.size()-1:
canvas.draw_texture_rect(end_texture,rect,false)
else:
canvas.draw_texture_rect(texture,rect,false)
canvas.draw_set_transform(Vector2(),0)
# 绘制连线
func draw_lines(canvas:CanvasItem,color:=Color.WHITE,border_width:=1.0) -> void:
canvas.draw_polyline(points,color,border_width)
# 绘制连线的偏移多边形
func draw_lines_offset_polygon(canvas:CanvasItem,offset:=10.0,color:=Color.WHITE,border_width:=1.0) -> void:
var polygon = Geometry2D.offset_polyline(points,offset)[0]
canvas.draw_polyline(polygon,color,border_width)
CircleChain
# ========================================================
# 名称:CircleChain
# 类型:自定义类
# 描述:圆链 - 等距链,记录圆半径数组,用于绘制不同半径的圆
# 创建时间:2024年9月10日20:28:28
# 修改时间:2024年9月12日00:50:29
# ========================================================
class_name CircleChain extends Chain
# ============================ 属性 ============================
var dis:float: # 固定间隔
set(val):
dis = val
# 根据圆半径的个数,填充lens数组
lens.resize(radius.size())
lens.fill(dis)
var radius:PackedFloat32Array: # 圆形半径数列
set(val):
radius = val
# 根据圆半径的个数,填充lens数组
lens.resize(radius.size())
lens.fill(dis)
# ============================ 虚函数 ============================
func _init(dis:float,radius:PackedFloat32Array) -> void:
self.dis = dis # 记录固定间隔
self.radius = radius # 记录圆的半径
# 计算出初始的位置值
for i in range(radius.size()):
points.append(Vector2(-dis,0) * i) # 沿X轴方向向左排列
# ============================ 方法 ============================
# 返回链总长度
func length() -> float:
return radius.size() * dis
# 获取边界点集合 (每个圆左侧和右侧点的集合)
func get_bound_points() -> PackedVector2Array:
var arr:PackedVector2Array = []
var arr1:PackedVector2Array = []
var arr2:PackedVector2Array = []
for i in range(points.size()):
var dir:Vector2
if i < points.size()-1:
dir = points[i].direction_to(points[i+1]) # 顶点之间的方向
else:
dir = points[i-1].direction_to(points[i]) # 顶点之间的方向
arr1.append(points[i] + dir.rotated(deg_to_rad(-90)) * radius[i])
arr2.append(points[i] + dir.rotated(deg_to_rad(90)) * radius[i])
# 求取头尾的半圆弧
var start_angle1 = rad_to_deg(points[0].direction_to(arr1[0]).angle()) + 180
var arc1 = Transform2D(0,points[0]) * arc(radius[0],start_angle1,start_angle1+180)
var start_angle2 = rad_to_deg(points[points.size()-1].direction_to(arr2[points.size()-1]).angle()) + 180
var arc2 = Transform2D(0,points[points.size()-1]) * arc(radius[points.size()-1],start_angle2,start_angle2+180)
arr.append_array(arc1)
arr.append_array(arr1)
arr.append_array(arc2)
arr2.reverse()
arr.append_array(arr2)
return arr
# 圆弧顶点求取函数
static func arc(
radius:float, # 所在圆的半径
start_angle:float, # 起始角度(度)
end_angle:float, # 结束角度(度)
edges:int = 0, # 分段数,默认为0,则表示采用 夹角θ * radius
) -> PackedVector2Array:
var points:PackedVector2Array = []
var angle = deg_to_rad(end_angle - start_angle) # 夹角
if edges <= 0:
edges = angle * radius # 要绘制的点的个数 = θ * r
var ang = angle/float(edges) # 每次旋转角度
for i in range(edges+1):
points.append(Vector2.RIGHT.rotated(i * ang + deg_to_rad(start_angle)) * radius)
return points
# 绘制圆
func draw_circles(canvas:CanvasItem,color:=Color.WHITE,fill:=false,border_width:=1.0) -> void:
for i in range(points.size()):
canvas.draw_circle(points[i],radius[i],color,fill,border_width)
# 绘制边界顶点
func draw_bound_points(canvas:CanvasItem,r:=3.0,color:=Color.ORANGE,fill:=true,border_width:=1.0) -> void:
var b_points = get_bound_points()
for i in range(b_points.size()):
if fill:
canvas.draw_circle(b_points[i],r,color,fill)
else:
canvas.draw_circle(b_points[i],r,color,fill,border_width)
# 绘制边界顶点组成的多边形
func draw_bound_polygon(canvas:CanvasItem,color:=Color.ORANGE,fill:=false,border_width:=1.0) -> void:
var b_points = get_bound_points()
if fill:
canvas.draw_polygon(b_points,[color])
else:
canvas.draw_polyline(b_points,color,border_width)