游戏编程 - 从Godot引擎中的lerp(...)函数到线性插值
从Godot引擎中的lerp(…)函数到线性插值
1。 What’s LERP?
- lerp < linear interpolation < 线性插值 < LERP索引页 < 黑客词典
2。lerp(…)函数做了什么?
- 先让我们给出godot中lerp的函数签名,方便我们下面讨论:
Variant lerp(from: Variant, to: Variant, weight: float)
- Variant是gdscript中的一种元数据,这里暂且可以理解成一种数据类型的占位符,表示这个函数有针对多个数据类型的版本,比如:int、float、Vector2、Vector3、Color。
2。1。对两个数做lerp
- 比如:为什么
lerp(0, 4, 0.75)
的返回值是3
? - 可以将这里的lerp看成针对int(或float)的版本:
int lerp(from: int, to: int, weight: float)
。 3
这个返回值是怎么得来的?lerp在内部是这样干的:return = (1- weight) * from + weight * to
;也就是:(1 - 0.75) * 0 + 0.75 * 4 = 3
。- 为什么要这样做?上面的式子是两个数之间的插值公式。当然了,也没必要非得往数学上扯,这只不过是我们想在0~4范围内取一个数,于是我们对lerp说:“我要取0~4这个范围内的0.75比重处的那个数”。lerp寻思了一会说:“好,那个数是3”。
- 当我们在说比重(weight)时,那意味着什么?再看一眼那个公式,没错,这个公式的确是from与to的加权平均值,毕竟两个数之间的插值就是这样定义的。所以“比重”就是“权重”。(1)当weight是0时,我们的意思是第一个数的权重是1,第二个数的权重是0,所以加权平均值等于第一个数;(2)当weight是1时,我们的意思是第一个数的权重是0,第二个数的权重是1,所以加权平均值等于第二个数;(3)当weight是0.5时,我们的意思是两个数的权重各占一半,这个时候的加权平均值也就是两个数的平均值,也就是两个数中点处的那个数值;……
- 两个数的加权平均值,本质上是在干一件什么事?试着回答一个问题(如下图):
如果把范围0~1当成范围from~to,那么0~1内的0.75处相当于from~to的哪一处?0~1内的weight处呢?
对于lerp两个数(一维),更好的理解是看成一种范围映射操作。lerp的源码可能更有说服力:
static ALWAYS_INLINE float lerp(float p_from, float p_to, float p_weight)
{return p_from + (p_to - p_from) * p_weight;
}
weight
的范围当然可以在[0,1]之外,只不过那并不常用,这个我们稍后说~让我们想个演示,实数可以表示什么呢,也许角度(弧度表示)是个不错的开始:
extends Node2D
const fan_speed = 1
func _process(delta):
$Sprite.rotation = lerp($Sprite.rotation, 8 * PI, delta * fan_speed)
2。2。对两个点做lerp
- 比如:为什么
lerp(Vector2(1, 5), Vector2(3, 2), 0.5)
的返回值是Vector2(2, 3.5)
? - 所以这里的lerp是针对Vector2的版本:
Vector2 lerp(from: Vector2, to: Vector2, weight: float)
- 针对某个类型的lerp版本和int/float的稍有些不同,当我们对某个类型调用lerp时,实际上lerp内部转而调用了对应类型的
linear_interpolate(...)
成员函数,这时的lerp函数只是一个包装,为的是对外提供统一的接口。 下面是Vector2的linear_interpolate函数源码,这和【2。1】中一样,只不过现在是对两个维度上分别做加权平均。所以
1 + 0.5 * (3-1) = 2
;5 + 0.5 * (2-5) = 3.5
;=> Vector2(2, 3.5)
Vector2 Vector2::linear_interpolate(const Vector2 &p_a, const Vector2 &p_b, real_t p_t) {
Vector2 res = p_a;
res.x += (p_t * (p_b.x - p_a.x));
res.y += (p_t * (p_b.y - p_a.y));
return res;
}
- 从根本上说,这是两个点之间线性插值公式。(具体看第三部分)
看看下面这个对点做lerp演示,我们对红圆圈的位置做了lerp,而白圆圈的位置只是单纯的赋值:
extends Node2D
const FOLLOW_SPEED = 4.0
func _physics_process(delta):
var mouse_pos = get_local_mouse_position()
$Sprite.position = mouse_pos
$Sprite2.position = $Sprite2.position.linear_interpolate(mouse_pos, delta * FOLLOW_SPEED)
2。3。对两个颜色做lerp
- 这里lerp的版本是针对Color这个类的:
Color lerp(from: Color, to: Color, weight: float)
这里lerp内部也是对Color类的
linear_interpolate(...)
成员函数的调用,下面是它的源码,和Vector2基本一样,如果非要统一起来,那或许是Vector4?FORCE_INLINE Color linear_interpolate(const Color &p_b, float p_t) const {
Color res = *this;
res.r += (p_t * (p_b.r - r));
res.g += (p_t * (p_b.g - g));
res.b += (p_t * (p_b.b - b));
res.a += (p_t * (p_b.a - a));
return res;
}
Vector2的改变意味着位置的变化,那么Color的改变意味着……
extends Node2D
const changed_speed = 1
func _process(delta):
# 初始为蓝色rgba(0, 0, 1, 1)
$ColorRect.color = $ColorRect.color.linear_interpolate(Color(1, 1, 1, 1), delta * changed_speed)
2。4。对两个矩阵做lerp
- 对矩阵做lerp的内容是我中途加的,可能是我觉得对lerp的铺垫还不够。
- 这里我们说到矩阵时,我们的意思是2D变换。
- 2D变换在godot引擎里对应的类是Transform2D,这个类有自己的做lerp的成员函数
interpolate_with(...)
。 - 简单回忆一下2D变换的内容:
- 图片来自 Wikipedia - Transformation matrix
- 如果在使用godot引擎中的Transform2D时硬套用上面数学书中的理论,也许并不好过,原因在于:(1)大多数游戏引擎(包括godot)在2D中使用的是左手坐标系,它的Y轴朝下,和数学中坐标系的Y轴相反;(2)Transform2D内对矩阵数据的组织方式也和我们在数学中所使用的2D变换矩阵不同(看下图)。
庆幸的是,看懂“将怪物扔出去”的演示并不需要掌握Transform2D的底层细节。
extends Node2D
const transform_speed = 1
func _process(delta):
# 初始位置是Vector2(20, 40)
$Sprite.transform = $Sprite.transform.interpolate_with( \
Transform2D(PI, Vector2(300, 40)), delta * transform_speed)
当然了,godot中肯定存在Transform(3D)和
Transform(3D)::interpolate_with(...)
这种东西了~- 如果仍然对前面几个例子中精灵所表现出来的行为感到怪异,试图把握一点:每次做lerp时,起始状态在变,而
weight
不变!
2。5。对某某某做lerp
- What we did?
- 我们总是在找一些可以变化的东西,比如,实数、位置、颜色、矩阵……;我们总是提供给了lerp操作这些可变化东西的两个状态,比如,两个不相等的实数、两个不同的位置、两个不一样的颜色、两个不等价的矩阵……;当然,最后,我们还给了lerp一个比重
weight
,意思是:“我要这两个状态的混合物,并且混合后的重心在weight
处。” - 线性:两个变量成比例变化,叫线性关系,所以
weight
的变化量和状态的变化量也应该成比例。 - 插值:我们明明只有两个边界状态,却试图得到两个边界状态的某个中间状态,于是我们给这中间插值。
3。 线性插值
3。1。事出何为
- 在数学中,线性插值是一种使用线性多项式进行曲线拟合的方法,可以在一组离散的已知数据点范围内构造新的数据点。- Wikipedia - Linear interpolation
- 游戏中,使用线性插值从根本上是为了获得一种过渡。运用于平滑动作、控制相机、制作动画等。
- 拿两个点的线性插值来研究,现在我们有平面上两个点A、B,注意我的意思是我们只有这两个点,中间是空白一片(看下图),当然它们的坐标
(xA, yA)
、(xB, yB)
是已知的。当我们在区间xA
到xB
上随便找一点x?
,并试图计算出这一点对应的纵坐标y?
时,实际上我们就是在研究一个插值问题。 - 通常已知一个点的横坐标,求它对应的纵坐标,是一个水到渠成的问题,这里的水也就是过这点的函数方程,题目中经常会给出各种暗示,很重要的一点是:这些暗示对于确定出这个函数方程是完备的。
- 我们现在面对的似乎也是这种问题,不过我们的已知条件有且只有A、B两点的坐标,很明显,这没法确定出过
(x?, y?)
点的方程(看下图)。的确,没人知道纵坐标是多少那么任何答案都是正确答案,“猜一个”,这种想法很好,但是当别人问你为什么的时候,你应该能扯出来,也就是可以自圆其说,这才比较数学。 - 我们完全没必要这么被动,我想是不是我们在学校刷题太多,遇到条件不完备的题目总感觉题目出错了?
- 题目的意思是计算一个点的纵坐标,而且没有限制我们过这点的曲线(方程)应该是什么样,唯一那一点要求是过A、B两个点,换句话说,题目给了我们充分的自由度,我们学过什么一次(线性、直线)函数、二次(抛物线)函数、三次(回归抛物线)函数、三角函数等等。(1)随便选一个,比如一次函数(图中圈4),(2)用两点式求出这条直线方程,(3)把先前自己臆想的那个点的横坐标
x?
代入直线方程待定系数,(4)于是求y?
。当别人问你为什么是y?
而不是y??
或y???
时,告诉它们,你用的是线性方程,在A、B两点之间插入(x?, y?)
点的方式是线性方式,依据的数学理论是线性插值理论。 - 至于圈1、圈2、圈3也许叫二次插值或非线性插值什么的。
3。2。推导
- 根据两点写出直线方程:
- 移项:
- 恒等变形:
- 换个元:
- 所以
x
是: - 代回原式,所以
y
是: - 回到这篇blog的开头。
- 把上述两个表示
x
、y
的式子运用结合律变形: 回到godot引擎Vector2的线性插值函数
linear_interpolate(...)
的源码:Vector2 Vector2::linear_interpolate(const Vector2 &p_a, const Vector2 &p_b, real_t p_t) {
Vector2 res = p_a;
res.x += (p_t * (p_b.x - p_a.x));
res.y += (p_t * (p_b.y - p_a.y));
return res;
}
3。3。Where are we?
- 恩,线性插值是最简单也是最常用的一种插值方式。
- 每种插值方式游戏中都可能用的到,比如Bezier曲线?
3。4。内插与外推(线性)
- 还记得之前说过
weight
的范围并不需要非得在[0, 1]
之间吗?当weight
不在这个范围时,x?
就会跑出[xA, xB]
区间。相当于我们要在A、B两点之外(而非之间)插值,又叫外推。
4。_lerp_函数族(不完全)
lerp(...)
linear_interpolate(...)
interpolate_with(...)
lerp_angle(...)
- 对两个弧度表示的角度插值inverse_lerp(...)
- 反插值,也许也可以叫unlerprange_lerp(...)
- 真正的范围映射,也许也可以叫remapcubic_interpolate(...)
- 三次插值,构造Bezier曲线
5。参考文章
- https://en.wikipedia.org/wiki/Linear_interpolation
- https://docs.godotengine.org/en/stable/tutorials/math/interpolation.html
- https://docs.godotengine.org/en/stable/tutorials/math/beziers_and_curves.html#doc-beziers-and-curves
- https://github.com/godotengine/godot/tree/3.2
- https://limnu.com/sketch-lerp-unlerp-remap/
- 《数值计算方法》 科学出版社 魏毅强等编
6。其它
个人公众号,偶尔发些技术文,欢迎一起讨论游戏开发~
还没有评论,来说两句吧...