概述
基于CanvasItem
提供的绘图函数进行线段绘制只需要直接调用draw_line
函数就可以了。
但是对于可以保存和赋值节点直接使用的纹理图片,却需要依靠Image
类。而Image
类没有直接提供基于像素的绘图函数。只能依靠set_pixel
或set_pixelv
进行逐个像素的填色。
所以问题就变成了获取线段上两点之间所有经过的点的位置的问题。
基于向量插值的尝试
作为一个数学学渣,首先想到的是基于Vector2
进行插值(lerp
)或者使用move_toward()
来获取每个点坐标。
因此,我首先搭建了如下的测试场景:
然后为TextureRect
节点添加如下代码:
# 基于向量插值求线段所有经过的点
extends TextureRectvar img:Image
# 起点和终点
var start:Vector2 = Vector2()
var end:Vector2func _ready() -> void:# 创建画布img = Image.create(500,500,false,Image.FORMAT_RGBA8)# 鼠标移动,绘制线段
func _gui_input(event: InputEvent) -> void:if event is InputEventMouseMotion:end = event.positionprint(end)draw_line_in_image(img,start,end)# 在Image上逐个像素绘制线段
func draw_line_in_image(image:Image,p1:Vector2,p2:Vector2) -> void:image.fill(Color.WHITE)var d = p2 - p1var steps = max(abs(d.x),abs(d.y)) # 步幅# 用插值绘制for i in range(steps):var p = p1.lerp(p2,i/steps)image.set_pixelv(p,Color.BLACK)update_texture()# 更新显示图片
func update_texture():texture = ImageTexture.create_from_image(img)
想法虽好,然而结果却不太完美,在一些情况下会丢失点或位置不正确从而导致线段不连续。
想进行四舍五入方面的控制,但是尝试半天也没有多大改进,于是不使用数学公式的方法就此作罢。
使用增量法
也就是所谓的“Bresenham算法”。知道这个算法是在去年,但是一直没有搞出成功的代码。经过昨晚复习直线基础的定义和概念,以及复习B站关于此算法的视频,再通过自己的一番画图理解,终于有所开窍,成功实现了GDScript版本的算法代码。
感谢互联网,感谢分享基础数学知识的人们!让我这个学渣可以随时方便的补习到数学知识。
言归正传,这里我画了一张图:
- 平面上任意两点
A(x1,y1)
、B(x2,y2)
,经过这两点可以定义一条直线 y = k x + b y=kx+b y=kx+b,其中k
是该直线的斜率,也就是AB
向量与X轴正方向夹角α
的正切值 t a n α tanα tanα。 - k = t a n α = d y / d x = ( y 2 − y 1 ) / ( x 2 − x 1 ) k = tanα = dy/dx = (y_2-y_1)/(x_2-x_1) k=tanα=dy/dx=(y2−y1)/(x2−x1)
增量法的核心是首先确定在像素网格中绘制一条线段AB,需要绘制多少个点。而通过比较dy
或dx
大小,可以分为3种情况:
dx>dy
时,我们需要在像素网格上绘制dx
个点dx=dy
时,我们需要在像素网格上绘制dx
(或dy
)个点dx<dy
时,我们需要在像素网格上绘制dy
个点
确定需要绘制的点的个数,接下来就确定坐标计算的规则。以上图中两个线段为例:
- 图左线段:
dx=14,dy=6
,dx>dy
,也就是斜率k<1
,所以我们需要从(x1,y1)
开始,绘制14个点,x
每增加1
,y
增加k
- 图右线段:
dx=9,dy=14
,dy>dx
,也就是斜率k>1
,所以我们需要从(x1,y1)
开始,绘制14个点,y
每增加1
,x
增加1/k
还有一点就是根据增量是否>0.5
,可以对计算获得的非整数x
或y
坐标进行四舍五入,从而确定一个唯一的像素位置(可以理解为Vector2i
)。
你可以手动控制这里的四舍五入规则,或者也可以直接享受Image
类型set_pixel
或set_pixelv
提供的从自动四舍五入(因为你不可能在一个非整数像素坐标上设置颜色)。
以上就是增量法的核心原理。但是上面只考虑了第一象限,在一个平面直角坐标系中,直线的斜率还要考虑在其他象限的情况:
好在,根据绘图和分析,直线斜率k
的变化可以认为是Y轴对称的:
所以就有了3种情况:
-1≤k≤1
,此时dx≥dy
,steps = dx
。也就是我们需要计算和绘制dx
个点的坐标:- 遍历
0
到dx
,单个点的坐标就是:(x1 + i,y1 + k * i)
,也就是,x
每增加1
,y
增加k
。
- 遍历
var min_p = p1 if p1.x< p2.x else p2 # 左侧点(x比较小的点)
for x in range(steps):var p = Vector2(min_p.x + x,min_p.y + k * x)
k>1
或k<-1
,此时dy>dx
,steps = dy
。此时,y
每增加1
,x
增加1/k
。单个点的坐标就成了:(x1 + 1/k * y,y1 + y)
。
var min_p = p1 if p1.y< p2.y else p2 # 下侧点(y比较小的点)
for y in range(steps):var p = Vector2(min_p.x + y/k,min_p.y + y)
dx = 0
,k
不存在,因为还是dy>dx
,所以steps = dy
,此时y
每增加1
,x
增加0
:
var min_p = p1 if p1.y< p2.y else p2 # 下侧点
for y in range(steps):var p = Vector2(min_p.x + 0,min_p.y + y)
像素线段点求取函数
有了上面的分析,则可以编写出一个如下的函数:
# 返回两点之间绘制线段所需要着色的点的集合
func get_line_points(p1:Vector2,p2:Vector2) -> PackedVector2Array:var arr:PackedVector2Arrayvar d = p2 - p1 # 端点坐标相减var k = d.y/d.x if d.x != 0 else null # 斜率var steps = max(abs(d.x),abs(d.y)) # 步幅 - 需要添加的点的数目,dx或dy中比较大的那个的绝对值if k == null: # 斜率不存在var min_p = p1 if p1.y< p2.y else p2 # 下侧点for y in range(steps):var p = Vector2(min_p.x + 0,min_p.y + y)arr.append(p)else:if k<=1 and k >= -1: # 斜率在[-1,1]var min_p = p1 if p1.x< p2.x else p2 # 左侧点for x in range(steps):var p = Vector2(min_p.x + x,min_p.y + k * x)arr.append(p)else: # 斜率在 [-∞,-1)或[1,+∞)var min_p = p1 if p1.y< p2.y else p2 # 下侧点for y in range(steps):var p = Vector2(min_p.x + y/k,min_p.y + y)arr.append(p)return arr
我们只需要传入线段两个端点的坐标,就可以返回所有需要着色的点。有了这些点,我们就可以用Image
的方法进行图片像素的着色,绘制出线段。
测试
搭建如下测试场景:
我们用一个500×500px
的TextureRect
作为绘制像素直线的的画布。为其添加如下代码:
extends TextureRectvar img:Image
# 线段起止点
var start:Vector2 = Vector2(250,250)
var end:Vector2@export var bg_color:Color = Color.WHITE # 画布背景色
@export var line_color:Color = Color.BLACK # 线条颜色func _ready() -> void:# 创建画布img = Image.create(500,500,false,Image.FORMAT_RGBA8)draw_my_line(start,end,line_color)# 鼠标移动时绘制起点到终点之间的线段
func _gui_input(event: InputEvent) -> void:if event is InputEventMouseMotion:end = event.positiondraw_my_line(start,end,line_color)# 在指定的Image上进行线段的绘制
func draw_my_line(p1:Vector2,p2:Vector2,color:Color = Color.BLACK) -> void:img.fill(bg_color) # 填充背景色var points = get_line_points(p1,p2) # 获取线段点集合for p in points:img.set_pixelv(p,color)texture = ImageTexture.create_from_image(img)# 更新显示图片
- 我们创建与
TextureRect
大小一致的Image
,并在其上填充白色背景,用黑色绘制线段之间的所有点。 - 我们将线段的起始点放在
(250,250)
也就是TextureRect
中心点,终点则由鼠标的局部位置决定。
运行后的效果:
可以看到线段被正常显示。
总结
搞定绘制线段后,就可以基于多个点连线绘制多边形,也就可以绘制出其他常见的几何图形。
基于Image
搞自己的绘图函数,其目的不言而喻,就是为了实现像素画绘制工具,以及实现基于像素的程序化纹理生成。
搞定第一步,很开心。
参考
- Bilibili - 编程挑战:画一条直线