三.移動
3.1 基本移動算法
靜態的(Statics)
存儲移動信息的數據結構如下:
struct Static:
position # a 2D vector
orientation # a single point value
動力學(Kenematics)
存儲移動信息的數據結構如下:
struct Kinematic:
position # a 2 or 3D vector
orientation # a single floating point value
velocity # another 2 or 3D vector
rotation # a single floating point value
Steering behaviors使用動力學的數據結構,返回加速度和角速度:
struct SteeringOutput:
linear # a 2 or 3D vector
angular # a single floating point value
如果遊戲裏邊有物理層那麼它負責來更新角色位置和方向,如果需要自己手動更新的話,可以使用如下更新算法:
struct Kinematic:
# other Member data before...
def update(steering, time):
# Update the posiiton and orientation
position += velocity * time + 0.5 * steering.linear * time * time
orientation += rotation * time + 0.5 * steering.angular * time * time
# and the velocity and rotation
velocity += steering.linear * time
# 原代碼:orientation += steering.angular * time
rotation += steering.angular * time
然而當遊戲以很高頻率運行時,每次更新位置和方向的加速度變化非常的小,可以使用一種更常用的比較粗糙的更新算法:
struct Kinematic:
# other Member data before...
def update(steering, time):
# Update the posiiton and orientation
position += velocity * time
orientation += rotation * time
# and the velocity and rotation
velocity += steering.linear * time
# 原代碼:orientation += steering.angular * time
rotation += steering.angular * time
在真實世界中我們通過施加力來驅動物體,而不能直接給予物體加速度。這也是一般遊戲物理層給予物理對象的接口。
3.2 動力學移動算法(Kinematic Movement Algorithms)
動力學移動算法使用靜態數據(位置和方向,沒有速度)輸出一個期望的速度,輸出通常是根據當前到目標的方向,以全速移動或者靜止。
動力學中的方向
很多遊戲只是簡單的以當前移動方向作爲對象的方向(朝向),如果對象靜止,則保持之前方向,獲取方向實現如下:
def getNewOrientation(currentOriention, velocity):
if velocity.length() > 0:
# 源代碼是 return atans(-static.x, static.z)
return atans(-velocity.x, velocity.z)
else:
return currentOriention
尋找(Seek)
以最大速度勻速朝一個目標移動,最終在目標附近來回穿插或者靜止不動(恰好到目標位置):
算法如下:
struct KinematicSteeringOutput:
velocity
rotation # 應爲朝向依據速度,所以該信息無用
class KinematicSeek:
# 存儲當前控制的對象和目標對象
character
target
# 存儲當前對象移動的最大速度
maxSpeed
def getSteering():
steering = new KinematicSteeringOutput()
# 計算移動速度
steering.velocity = target.position - character.position
steering.velocity.normalize()
steering.velocity *= maxSpeed
# 我覺得也可以利用上rotation, steering.rotation = getNewOrientation(character.orientation, steering.velocity)
character.orientation = getNewOrientation(character.orientation, steering.velocity)
steering.rotation = 0
return steering
逃離(Flee)
逃離和尋找唯一區別是移動方向相反,差異代碼如下:
steering.velocity = character.position - target.position
抵達(Arriving)
抵達目的是進入目標的一個範圍之內,在範圍之外和尋找一樣朝目標移動,進入一定距離後逐漸減速(由timeToTarget和maxSpeed決定何時減速),進入目標的半徑範圍內靜止。實現代碼如下:
class KinematicArrive:
character
target
maxSpeed
# 進入目標該半徑之內不再移動
radius
# 如果radius爲0時,從減速到停止共花費時間timeToTarget
timeToTarget = 0.25
def getSteering():
steering = new KinematicSteeringOutput()
steering.velocity = target.position - character.position
# 抵達目標半徑內,停止移動
if steering.velocity.length() < radius:
return None
# 控制速度
steering.velocity /= timeToTarget
if steering.velocity.length() > maxSpeed:
steering.velocity.normalize()
steering.velocity *= maxSpeed
character.orientation = getNewOrientation(character.orientation, steering.velocity)
steering.rotation = 0
return steering
巡邏(Wander)
每次隨機一個朝向偏移值,改變朝向,並朝這個方向全速移動,(如果每幀都要改變朝向的話,會一直在抖動,效果並不好..)
實現代碼如下:
class KinematicWander:
character
maxSpeed
# 旋轉偏移隨機在[-maxRotation,maxRotation]範圍之內
maxRotation
# return [-1, 1]
def randomBinomial():
return random() - random()
def getSteering():
steering = new KinematicSteeringOutput()
steering.velocity = maxSpeed * character.orientation.asVector()
steering.rotation = randomBinomial() * maxRotation
return steering
3.3 轉向行爲(Steering Behaviors)
尋找(Seek)和逃離(Flee)
轉向行爲將基於動力學行爲增加加速圖的支持,對象的位置信息更新代碼如下:
struct Kinematic:
# 其他成員變量
def update(steering, maxSpeed, time):
position += velocity * time
orientation += rotation * time
velocity += steering.linear * time
orientation += steering.angular * time
if velocity.length() > maxSpeed:
velocity.normalize()
velocity *= maxSpeed
轉向行爲尋找和動力學算法中表現得不同的是它將呈螺旋路徑的方式靠近目標,而不是直線向目標移動。實現代碼如下:
class Seek:
character
target
# 最大加速度
maxAcceleration
def getSteering():
steering = new SteeringOutput()
steering.linear = target.position - character.position
steering.linear.normalize();
# 一直施加最大加速度
steering.linear *= maxAcceleration;
steering.angular = 0
return steering
逃離實現與尋找唯一差別如下:
steering.linear = character.position - target.position
Seek和Flee行爲路徑
抵達(Arrive)
由於尋找行爲一直以最大加速度移動所以它不會在目標處停止而是一直在目標附近徘徊,抵達行爲與尋找表現差異如下圖:
抵達行爲實現代碼如下:
class Arrive:
character
target
maxAcceleration
maxSpeed
# 進入目標該範圍算是抵達目標
targetRadius
# 開始減速的範圍半徑
slowRadius
# 減速時長
timeToTarget
def getSteering():
steering = new SteeringOutput()
direction = target.position - character.position
distance = direction.length()
# 好像有問題,進入這個範圍會勻速出去,並不會停
if distance < targetRadius
return None
if distance > slowRadius:
targetSpeed = maxSpeed
else:
targetSpeed = maxSpeed * distance / slowRadius
targetVelocity = direction
targetVelocity.normalize()
targetVelocity *= targetSpeed
steering.linear = targetVelocity - character.velocity
steering.linear /= timeToTarget
if steering.linear.length() > maxAcceleration:
steering.linear.normalize()
steering.linear *= maxAcceleration
steering.angular = 0
return steering
排列(Align)
排列使角色的轉向保持與目標一致,它不關注角色或者目標的位置或速度,轉向將不直接和動力學行爲中的速度直接相關。
實現代碼如下:
class Align:
character
target
maxAngularAcceleration
maaxRotation
# 和目標朝向保持一致的範圍
targetRadius
# 開始減速的半徑
slowRadius
timeToTarget = 0.1
def getSteering():
steering = new SteeringOutput()
rotation = target.orientation - character.orientation
# 角度值轉到(-pi, pi)之間
rotation = mapToRange(rotation)
# 源代碼是:rotationSize = abs(rotationDirection)
rotationSize = abs(rotation)
# 已經達到目標
if rotationSize < targetRadius:
return None
# 如果在減速半徑之外,全速轉動
if rotationSize > slowRadius:
targetRotation = maxRotation
else:
targetRotation = maxRotation * rotationSize / slowRadius
# -1或1
signDir = rotation / rotationSize
targetRotation *= signDir
# 原代碼好像有問題, targetRotation是一個差值
steering.angular = targetRotation - character.rotation
steering.angular /= timeToTarget
angularAcceleration = abs(steering.angular)
if angularAcceleration > maxAngularAcceleration:
steering.angular /= angularAcceleration
steering.angular *= maxAngularAcceleration
steering.linear = 0
return steering
速度匹配(Velocity Matching)
速度匹配目的是使角色速度與目標一致,可以用來模仿目標的運動,他自身很少使用,更常見於和其他行爲組合使用,比如說它是羣集行爲的一個組成部分。
實現代碼如下:
class VelocityMatch:
character
target
maxAcceleration
timeToTarget
def getSteering():
steering = new SteeringOutput()
steering.linear = target.velocity - character.velocity
steering.linear /= timeToTarget
if steering.linear.length() > maxAcceleration:
steering.linear.normalize()
steering.linear *= maxAcceleration
steering.angular = 0
return steering
委託行爲(Delegated Behaviors)
我們之前介紹的一些基本行爲,可以衍生創建出更多其他行爲。探尋,逃離,抵達和排列這些基礎行爲可以表現出更多行爲,以下介紹的一些代理行爲如追逐等就是這種情況,他們同樣計算出目標的位置或者朝向,代理一個或者多個其他行爲來產生轉向輸出。
追逐和躲避(Pursue And Evade)
到目前爲止我們移動角色僅僅基於位置,如果我們在追逐一個移動的目標,那麼只是朝向他當前位置將不再有效。在角色抵達目標當前位置時,目標已經離開了,如果他們距離足夠近還好,如果它們之間距離足夠遠,我們就需要預測目標的未來位置,這就是追組行爲。他使用探尋(Seek)這個基礎行爲,假設目標將以當前速度保持移動進行預測將來位置。
追逐和探尋表現差異如下圖:
實現代碼如下:
# 繼承自Seek
class Pursue(Seek):
# 最大預測時間
maxPrediction
# 我們要追逐的目標
target
# 其他一些繼承自父類的信息
def getSteering():
direction = target.position - character.position
distance = direction.length()
speed = character.velocity.length()
# 根據當前速度計算預測時間
if speed <= distance / maxPrediction:
prediction = maxPrediction
else:
prediction = distance / speed
# 原代碼:Seek.target = explicitTarget
Seek.target = target
Seek.target.position += target.velocity * prediction
return Seek.getSteering()
躲避與追逐的唯一區別是躲避使用Flee行爲。
面對(Face)
面對行爲使角色看向目標,它代理排列行通過計算目標的朝向爲來表現旋轉。
實現代碼如下:
class Face(Align):
target
# 其他繼承自父類的數據
def getSteering():
direction = target.position - character.position
if direction.length == 0:
return target
# 原代碼 Align.target = explicitTarget
Align.target = target
Align.target.orientation = atan2(-direction.x, direction.z)
return Align.getSteering()
朝你移動的方向看(Looking Where You’re Going)
以角色當前速度方向作爲目標的朝向計算。
實現代碼如下:
class LookWhereYouAreGoing(Align):
def getSteering():
if character.velocity.length() == 0:
return
target.orientation = atan2(-character.velocity.x, character.velocity.z)
return Align.getSteering()
遊蕩(Wander)
該遊蕩行爲爲解決抽動問題,與原本行爲區別:
實現代碼:
class Wander(Face):
# 目標偏移圓的中心偏移點和半徑
wanderOffset
wanderRadius
# 最大隨機旋轉偏移值
wanderRate
# 當前遊蕩對象的朝向
wanderOrientation
def getSteering():
wanderOrientation += randomBinomial() * wanderRate
targetOrientation = wanderOrientation + character.orientation
target = character.position + wanderOffset * character.orientation.asVector()
target += wanderRadius * targetOrientation.asVector()
# Face.target = target
steering = Face.getSteering()
# 角色加速度保持滿速
steering.linear = maxAcceleration * character.orientation.asVector()
return steering
路徑跟隨(Path Following)
路徑跟隨是一個以一整條路徑作爲目標的轉向行爲。一個擁有路徑跟隨行爲的角色應該沿着路徑的一個方向移動。他也是一個代理行爲,根據當前角色的位置和路徑來計算出目標的位置,然後使用探索(Seek)行爲去移動。
目標位置的計算有兩個階段。第一,將角色位置轉換到路徑上的一個最近點;第二,在路徑上選中一個目標而不是路徑上的一個固定距離的映射點(a target is selected which is further along the path than the mapped point by a fixed distance)。
如圖所示:
同時可能出現越過一段路徑,如下圖:
實現代碼如下所示:
class FollowPath(Seek):
path
# 路徑中產生目標的距離,如果是反方向時值爲負數
pathOffset
# 路徑中當前位置
currentParam
# 預測未來時間角色的位置
predictTime = 0.1
def getSteering():
# 計算預測位置
futurePos = character.position + character.velocity * predictTime
# 尋找路徑中的當前位置
currentParam = path.getParam(futurePos, currentPos)
targetParam = currentParam + pathOffset
# 獲取目標位置
target.position = path.getPosition(targetParam)
return Seek.getSteering()
未實現Path類
class Path:
def getParam(position, lastParam)
def getPosition(param)
現在有一種更簡單的實現方式,即使用一系列座標點組成路徑,使用Seek行爲一個個抵達目標。
分離(Separation)
分離行爲針對於擁有大致相同方向的一堆目標,使他們保持距離。但是它在目標相互移動穿插的情況無法作用,這時候需要之後介紹的碰撞避免行爲。
有兩種分離算法:
1. 線性分離
strength = maxAcceleration * (threshold - distance) / threshold
- 平方反比算法
strength = min(k / (distance * distance), maxAcceleration)
分離行爲實現代碼如下:
class Separation:
character
targets
threshold
decayCoefficient
maxAcceleration
def getSteering():
steering = new SteeringOutput()
for target in targets:
direction = target.position - character.position
distance = direction.length()
if distance < threshold:
strength = min(decayCoefficient/ (distance * distance), maxAcceleration)
direction.normalize()
steering.linear += strength * direction
return steering
碰撞避免(Collision Avoidance)
判斷兩個目標達到最近距離的時間:
其中
如果
達到最近距離時,角色和目標的座標分別是:
躲避多個角色的實現並非合併平均他們的座標和速度,算法需要找到最早會達到最近點的目標,然後規避該目標即可。
實現代碼如下:
class CollisionAvoidance:
character
targets
maxAcceleration
# 角色的碰撞半徑(假設都一樣)
radius
def getSteering():
shortestTime = infinity
# 存儲當前已經碰撞的目標
curMinDistance = infinity
curMinTarget = None
# 存儲即將碰撞最近目標信息
firstTarget = None
firstMinSeparation
firstDistance
firstRelativePos
firstRelativeVel
for target in targets:
# 計算達到最近點的時間
relativePos = target.position - character.position
relativeVel = target.velocity - character.velocity
relativeSpeed = relativeVel.length()
timeToCollision =- (relativePos * relativeVel) / (relativeSpeed * relativeSpeed)
# 判斷是否會發生碰撞
distance = relativePos.length()
# 原代碼 minSeparation = distance - relativeSpeed * shortestTime
futureMinPos = relativePos - relativeVel * timeToCollision
minSeparation = futureMinPos.length()
if minSeparation > 2 * radius:
continue
# 判斷是否是最先達到最近位置的目標
if timeToCollision > 0 and timeToCollision < shortestTime:
shortestTime = timeToCollision
firstTarget = target
firstMinSeparation = minSeparation
firstDistance = distance
firstRelativePos = relativePos
firstRelativeVel = relativeVel
else if distance <= 2 * radius and distance < curMinDistance:
curMinDistance = distance
curMinTarget = target
end
if not firstTarget and not curMinTarget:
return None
# 原代碼 if firstMinSeparation <= 0 or distance <= 2 * radius:
# relativePos = firstTarget.position - character.position
# distance 是哪一個?
# 如果是當前最近的目標距離,那麼firstTarget就是當前已經碰撞(因爲他們距離小於半徑2倍)的目標,並不是將來最先碰撞到的目標
# 同時firstMinSeparation <= 0不能說明任何問題,只能說如果一直是當前速度下,角色和目標之前發生過碰撞,但是事實是角色和目標的當前速度可能只有在現在這一刻是這樣。所以應該只需要考慮distance<=2*raidus就可以了
# 如果已經發生碰撞
if curMinDistance <= 2 * radius:
relativePos = curMinTarget.position - character.position
else if firstMinSeparation > 0:
relativePos = firstRelativePos + firstRelativeVel * shortestTime
relativePos.normalize()
steering.linear = relativePos * maxAcceleration
return steering;
障礙和牆躲避(Obstacle And Wall Avoidance)
碰撞躲避行爲假設目標是球形的,它關注於躲避目標的中心位置。而障礙和牆躲避行爲的目標形狀更復雜,所以實現方式也不一樣。通過從移動的角色前方發射一條或者多條有限長度的射線,如果碰撞到障礙,角色就開始進行躲避,根據碰撞信息獲得一個移動的目標,進行探尋行爲。
效果圖如下示:
實現代碼如下:
class ObstacleAvoidance(Seek):
# 碰撞探測器,檢測與障礙的碰撞
collisionDetector
# 選擇躲避點距離碰撞點的距離
avoidDistance
# 角色朝前方射出射線的距離
lookahead
def getSteering():
rayVector = character.velocity
rayVector.normalize()
rayVector *= lookahead
collision = collisionDetector.getCollision(character.position, rayVector)
if not collision:
return None
# Seek.target = target
target = collision.position + collision.normal * avoidDistance
return Seek.getSteering()
class CollisionDetector:
def getCollision(position, moveAmount)
struct Collision:
position
normal
碰撞檢測的問題
目前爲止我們假設用一條射線檢測碰撞,在使用上,這並不是一個好的解決辦法。
下圖顯示了一條射線可能遇到的問題以及可以的一種解決方法:
因此一般情況下需要多條射線一起作用來躲避障礙,以下是可能的幾種情況:
這裏並沒有一種有力並且快速的規則來決定哪一種射線方式是更好地,每一種都有他們自己的特質。單獨一條短射線並且帶有短的觸鬚通常是最好的初始嘗試配置能夠讓角色很容易從緊密的通道中行走。單獨一條射線配置被用在凹面環境中但是無法避免碰撞到凸面障礙物。平行射線配置在非常大的鈍角環境中工作很好,但是很容易在角落中被困住,下邊將有介紹。
拐角困境(The Corner Trap)
多條射線躲避牆壁算法會遭遇到尖銳的夾角障礙的問題,導致朝任意兩邊移動另一條射線都會碰撞到牆面,最終仍然撞向障礙物。如下圖所示:
扇形結構,如果有足夠大的扇形夾角,可以減輕這個問題,通常這需要一些權衡。擁有一個大的夾角避免這種拐角困境或者小的夾角來通過小的通道。最差的情況,角色有一個180度角度的射線,角色將不能夠很快速的對兩邊射線的碰撞檢測進行反應從而導致仍然碰到牆上。
一些開發者提出了一些可接受的適應性扇形夾角,如果角色移動中沒有檢測到碰撞,那麼夾角就變小,如果檢測到了碰撞,那麼扇形夾角將保持變大,減少出現拐角困境的機會。
其他一些開發者實現了一些特殊的專門解決拐角困境的代碼,如果發生這種情況,那麼只有其中一條射線的碰撞信息需要考慮,無視掉另外一條射線的碰撞檢測信息。
除了以上兩種方法,還有一種更加完整的方法,通過使用一個投影體積而不是使用射線來檢測碰撞,如下圖所示:
許多遊戲引擎能夠做這些事情(例如Unity3d的Physics類),爲了模擬真實的物理。不像是ai,物理中使用的投影距離通常很小(Unlike AI, the projection distance required by physics are typically very small),然而,用在轉向行爲中計算將很慢。
到目前爲止,最實用的解決方案是使用一個扇形夾角,中間一條射線兩邊有兩條短觸鬚。
介紹過的轉向行爲總結
實現代碼:
基於以上Kinematic Behavior和Steering Behavior的僞代碼,使用Unity3d(版本5.6.0f3)進行了實現,package包下載地址在此。
關於ai介紹SteeringBehavior的相關博客:https://tutsplus.com/authors/fernando-bevilacqua;
http://natureofcode.com/book/chapter-6-autonomous-agents/ (The Nature of Code);
一份網絡上關於ai-move的筆記:https://web.cs.ship.edu/~djmoon/gaming/gaming-notes/ai-movement.pdf