【Godot4.3】链条结构图形与生物模拟概论

概述

本篇讨论的内容是笔者基于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)

绘制效果:

到此为止,已经介绍清楚了基本的不等长链条和等距圆链,以及其根据鼠标位置移动和更新的原理。

扩充绘制函数

在进一步的设计中,我只是在ChainCircleChain中多添加了几个draw_打头的函数,方便在测试场景根节点中参数化并有选择性的绘制链的元素,比如关节点、连线、圆或者连线的偏移多边形等。

具体函数可以去看文末的完整源码。以下是一些选择性或组合起来的绘制效果:

在这里插入图片描述
在这里插入图片描述


思考

这里通过在点的位置上绘制图形,实现了一种很特殊的效果,这让我第一时间想到的是《泰拉瑞亚》中的长条生物。或许在你的游戏中可以采用相似的思路创建这种怪物。

其实在这一步之后,我还尝试了更换首尾的图片,但是效果不佳,后续或许会发文介绍。


求边界轮廓

在圆链的基础上,通过求圆最左侧和最右侧的边界点,并将它们连在一起,组成多边形,就可以绘制出基于圆链的软体生物的外形。

在这里插入图片描述

到这一步,生物模拟的部分还没有完成,限于篇幅,这里点到为止。

关系类图

  • CircleChain扩展自Chain类型,所以,部分属性和方法是共用的,而一些方法被重写。

在这里插入图片描述

完整代码

Chain

# ========================================================
# 名称:Chain
# 类型:自定义类
# 描述:存储正向运动学或反向运动学链条结构的类,并提供简单的正向和反向动力学运动方法
# 创建时间:202491020:28:28
# 修改时间:202491200: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
# 类型:自定义类
# 描述:圆链 - 等距链,记录圆半径数组,用于绘制不同半径的圆
# 创建时间:202491020:28:28
# 修改时间:202491200: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)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

巽星石

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值