本文轉載自:http://www.cocoachina.com/ios/20150106/10839.html? 為了防止cocochina以后刪除該文章,故轉載至此;
基于定時器的動畫
我可以指導你,但是你必須按照我說的做。 -- 駭客帝國
在第10章“緩沖”中,我們研究了CAMediaTimingFunction,它是一個通過控制動畫緩沖來模擬物理效果例如加速或者減速來增強現實感的東西,那么如果想更加真實地模擬
物理交互或者實時根據用戶輸入修改動畫改怎么辦呢?在這一章中,我們將繼續探索一種能夠允許我們精確地控制一幀一幀展示的基于定時器的動畫。
定時幀
動畫看起來是用來顯示一段連續的運動過程,但實際上當在固定位置上展示像素的時候并不能做到這一點。一般來說這種顯示都無法做到連續的移動,能做的僅僅是足夠快地展示一系列靜態圖片,只是看起來像是做了運動。
我們之前提到過iOS按照每秒60次刷新屏幕,然后CAAnimation計算出需要展示的新的幀,然后在每次屏幕更新的時候同步繪制上去,CAAnimation最機智的地方在于每次刷新需要展示的時候去計算插值和緩沖。
在第10章中,我們解決了如何自定義緩沖函數,然后根據需要展示的幀的數組來告訴CAKeyframeAnimation的實例如何去繪制。所有的Core Animation實際上都是按照一定的序列來顯示這些幀,那么我們可以自己做到這些么?
NSTimer
實際上,我們在第三章“圖層幾何學”中已經做過類似的東西,就是時鐘那個例子,我們用了NSTimer來對鐘表的指針做定時動畫,一秒鐘更新一次,但是如果我們把頻率調整成一秒鐘更新60次的話,原理是完全相同的。
我們來試著用NSTimer來修改第十章中彈性球的例子。由于現在我們在定時器啟動之后連續計算動畫幀,我們需要在類中添加一些額外的屬性來存儲動畫的fromValue,toValue,duration和當前的timeOffset(見清單11.1)。
清單11.1 使用NSTimer實現彈性球動畫
@interface?ViewController?()
@property?(nonatomic,?weak)?IBOutlet?UIView?*containerView;
@property?(nonatomic,?strong)?UIImageView?*ballView;
@property?(nonatomic,?strong)?NSTimer?*timer;
@property?(nonatomic,?assign)?NSTimeInterval?duration;
@property?(nonatomic,?assign)?NSTimeInterval?timeOffset;
@property?(nonatomic,?strong)?id?fromValue;
@property?(nonatomic,?strong)?id?toValue;
@end
@implementation?ViewController
-?(void)viewDidLoad
{
[superviewDidLoad];
//add?ball?image?view
UIImage?*ballImage?=?[UIImage?imageNamed:@"Ball.png"];
self.ballView?=?[[UIImageView?alloc]?initWithImage:ballImage];
[self.containerView?addSubview:self.ballView];
//animate
[self?animate];
}
-?(void)touchesBegan:(NSSet?*)touches?withEvent:(UIEvent?*)event
{
//replay?animation?on?tap
[self?animate];
}
float?interpolate(float?from,?float?to,?float?time)
{
return(to?-?from)?*?time?+?from;
}
-?(id)interpolateFromValue:(id)fromValue?toValue:(id)toValue?time:(float)time
{
if([fromValue?isKindOfClass:[NSValue?class]])?{
//get?type
const?char?*type?=?[(NSValue?*)fromValue?objCType];
if(strcmp(type,?@encode(CGPoint))?==?0)?{
CGPoint?from?=?[fromValue?CGPointValue];
CGPoint?to?=?[toValue?CGPointValue];
CGPoint?result?=?CGPointMake(interpolate(from.x,?to.x,?time),?interpolate(from.y,?to.y,?time));
return[NSValue?valueWithCGPoint:result];
}
}
//provide?safe?default?implementation
return(time?<?0.5)??fromValue:?toValue;
}
float?bounceEaseOut(float?t)
{
if(t?<?4/11.0)?{
return(121?*?t?*?t)/16.0;
}elseif(t?<?8/11.0)?{
return(363/40.0?*?t?*?t)?-?(99/10.0?*?t)?+?17/5.0;
}elseif(t?<?9/10.0)?{
return(4356/361.0?*?t?*?t)?-?(35442/1805.0?*?t)?+?16061/1805.0;
}
return(54/5.0?*?t?*?t)?-?(513/25.0?*?t)?+?268/25.0;
}
-?(void)animate
{
//reset?ball?to?top?of?screen
self.ballView.center?=?CGPointMake(150,?32);
//configure?the?animation
self.duration?=?1.0;
self.timeOffset?=?0.0;
self.fromValue?=?[NSValue?valueWithCGPoint:CGPointMake(150,?32)];
self.toValue?=?[NSValue?valueWithCGPoint:CGPointMake(150,?268)];
//stop?the?timer?if?it's?already?running
[self.timer?invalidate];
//start?the?timer
self.timer?=?[NSTimer?scheduledTimerWithTimeInterval:1/60.0
target:self
selector:@selector(step:)
userInfo:nil
repeats:YES];
}
-?(void)step:(NSTimer?*)step
{
//update?time?offset
self.timeOffset?=?MIN(self.timeOffset?+?1/60.0,?self.duration);
//get?normalized?time?offset?(in?range?0?-?1)
float?time?=?self.timeOffset?/?self.duration;
//apply?easing
time?=?bounceEaseOut(time);
//interpolate?position
id?position?=?[self?interpolateFromValue:self.fromValue
toValue:self.toValue
time:time];
//move?ball?view?to?new?position
self.ballView.center?=?[position?CGPointValue];
//stop?the?timer?if?we've?reached?the?end?of?the?animation
if(self.timeOffset?>=?self.duration)?{
[self.timer?invalidate];
self.timer?=?nil;
}
}
@end
很贊,而且和基于關鍵幀例子的代碼一樣很多,但是如果想一次性在屏幕上對很多東西做動畫,很明顯就會有很多問題。
NSTimer并不是最佳方案,為了理解這點,我們需要確切地知道NSTimer是如何工作的。iOS上的每個線程都管理了一個NSRunloop,字面上看就是通過一個循環來完成一些任務列表。但是對主線程,這些任務包含如下幾項:
處理觸摸事件
發送和接受網絡數據包
執行使用gcd的代碼
處理計時器行為
屏幕重繪
當你設置一個NSTimer,他會被插入到當前任務列表中,然后直到指定時間過去之后才會被執行。但是何時啟動定時器并沒有一個時間上限,而且它只會在列表中上一個任務完成之后開始執行。這通常會導致有幾毫秒的延遲,但是如果上一個任務過了很久才完成就會導致延遲很長一段時間。
屏幕重繪的頻率是一秒鐘六十次,但是和定時器行為一樣,如果列表中上一個執行了很長時間,它也會延遲。這些延遲都是一個隨機值,于是就不能保證定時器精準地一秒鐘執行六十次。有時候發生在屏幕重繪之后,這就會使得更新屏幕會有個延遲,看起來就是動畫卡殼了。有時候定時器會在屏幕更新的時候執行兩次,于是動畫看起來就跳動了。
我們可以通過一些途徑來優化:
我們可以用CADisplayLink讓更新頻率嚴格控制在每次屏幕刷新之后。
基于真實幀的持續時間而不是假設的更新頻率來做動畫。
調整動畫計時器的run loop模式,這樣就不會被別的事件干擾。
CADisplayLink
CADisplayLink是CoreAnimation提供的另一個類似于NSTimer的類,它總是在屏幕完成一次更新之前啟動,它的接口設計的和NSTimer很類似,所以它實際上就是一個內置實現的替代,但是和timeInterval以秒為單位不同,CADisplayLink有一個整型的frameInterval屬性,指定了間隔多少幀之后才執行。默認值是1,意味著每次屏幕更新之前都會執行一次。但是如果動畫的代碼執行起來超過了六十分之一秒,你可以指定frameInterval為2,就是說動畫每隔一幀執行一次(一秒鐘30幀)或者3,也就是一秒鐘20次,等等。
用CADisplayLink而不是NSTimer,會保證幀率足夠連續,使得動畫看起來更加平滑,但即使CADisplayLink也不能保證每一幀都按計劃執行,一些失去控制的離散的任務或者事件(例如資源緊張的后臺程序)可能會導致動畫偶爾地丟幀。當使用NSTimer的時候,一旦有機會計時器就會開啟,但是CADisplayLink卻不一樣:如果它丟失了幀,就會直接忽略它們,然后在下一次更新的時候接著運行。
計算幀的持續時間
無論是使用NSTimer還是CADisplayLink,我們仍然需要處理一幀的時間超出了預期的六十分之一秒。由于我們不能夠計算出一幀真實的持續時間,所以需要手動測量。我們可以在每幀開始刷新的時候用CACurrentMediaTime()記錄當前時間,然后和上一幀記錄的時間去比較。
通過比較這些時間,我們就可以得到真實的每幀持續的時間,然后代替硬編碼的六十分之一秒。我們來更新一下上個例子(見清單11.2)。
清單11.2 通過測量沒幀持續的時間來使得動畫更加平滑
@interface?ViewController?()
@property?(nonatomic,?weak)?IBOutlet?UIView?*containerView;
@property?(nonatomic,?strong)?UIImageView?*ballView;
@property?(nonatomic,?strong)?CADisplayLink?*timer;
@property?(nonatomic,?assign)?CFTimeInterval?duration;
@property?(nonatomic,?assign)?CFTimeInterval?timeOffset;
@property?(nonatomic,?assign)?CFTimeInterval?lastStep;
@property?(nonatomic,?strong)?id?fromValue;
@property?(nonatomic,?strong)?id?toValue;
@end
@implementation?ViewController
...
-?(void)animate
{
//reset?ball?to?top?of?screen
self.ballView.center?=?CGPointMake(150,?32);
//configure?the?animation
self.duration?=?1.0;
self.timeOffset?=?0.0;
self.fromValue?=?[NSValue?valueWithCGPoint:CGPointMake(150,?32)];
self.toValue?=?[NSValue?valueWithCGPoint:CGPointMake(150,?268)];
//stop?the?timer?if?it's?already?running
[self.timer?invalidate];
//start?the?timer
self.lastStep?=?CACurrentMediaTime();
self.timer?=?[CADisplayLink?displayLinkWithTarget:self
selector:@selector(step:)];
[self.timer?addToRunLoop:[NSRunLoop?mainRunLoop]
forMode:NSDefaultRunLoopMode];
}
-?(void)step:(CADisplayLink?*)timer
{
//calculate?time?delta
CFTimeInterval?thisStep?=?CACurrentMediaTime();
CFTimeInterval?stepDuration?=?thisStep?-?self.lastStep;
self.lastStep?=?thisStep;
//update?time?offset
self.timeOffset?=?MIN(self.timeOffset?+?stepDuration,?self.duration);
//get?normalized?time?offset?(in?range?0?-?1)
float?time?=?self.timeOffset?/?self.duration;
//apply?easing
time?=?bounceEaseOut(time);
//interpolate?position
id?position?=?[self?interpolateFromValue:self.fromValue?toValue:self.toValue
time:time];
//move?ball?view?to?new?position
self.ballView.center?=?[position?CGPointValue];
//stop?the?timer?if?we've?reached?the?end?of?the?animation
if(self.timeOffset?>=?self.duration)?{
[self.timer?invalidate];
self.timer?=?nil;
}
}
@end
Run Loop 模式
注意到當創建CADisplayLink的時候,我們需要指定一個run loop和run loop mode,對于run loop來說,我們就使用了主線程的run loop,因為任何用戶界面的更新都需要在主線程執行,但是模式的選擇就并不那么清楚了,每個添加到run loop的任務都有一個指定了優先級的模式,為了保證用戶界面保持平滑,iOS會提供和用戶界面相關任務的優先級,而且當UI很活躍的時候的確會暫停一些別的任務。
一個典型的例子就是當是用UIScrollview滑動的時候,重繪滾動視圖的內容會比別的任務優先級更高,所以標準的NSTimer和網絡請求就不會啟動,一些常見的run loop模式如下:
NSDefaultRunLoopMode - 標準優先級
NSRunLoopCommonModes - 高優先級
UITrackingRunLoopMode - 用于UIScrollView和別的控件的動畫
在我們的例子中,我們是用了NSDefaultRunLoopMode,但是不能保證動畫平滑的運行,所以就可以用NSRunLoopCommonModes來替代。但是要小心,因為如果動畫在一個高幀率情況下運行,你會發現一些別的類似于定時器的任務或者類似于滑動的其他iOS動畫會暫停,直到動畫結束。
同樣可以同時對CADisplayLink指定多個run loop模式,于是我們可以同時加入NSDefaultRunLoopMode和UITrackingRunLoopMode來保證它不會被滑動打斷,也不會被其他UIKit控件動畫影響性能,像這樣:
self.timer?=?[CADisplayLink?displayLinkWithTarget:self?selector:@selector(step:)];
[self.timer?addToRunLoop:[NSRunLoop?mainRunLoop]?forMode:NSDefaultRunLoopMode];
[self.timer?addToRunLoop:[NSRunLoop?mainRunLoop]?forMode:UITrackingRunLoopMode];
和CADisplayLink類似,NSTimer同樣也可以使用不同的run loop模式配置,通過別的函數,而不是+scheduledTimerWithTimeInterval:構造器
self.timer?=?[NSTimer?timerWithTimeInterval:1/60.0
target:self
selector:@selector(step:)
userInfo:nil
repeats:YES];
[[NSRunLoop?mainRunLoop]?addTimer:self.timer
forMode:NSRunLoopCommonModes];
物理模擬
即使使用了基于定時器的動畫來復制第10章中關鍵幀的行為,但還是會有一些本質上的區別:在關鍵幀的實現中,我們提前計算了所有幀,但是在新的解決方案中,我們實際上實在按需要在計算。意義在于我們可以根據用戶輸入實時修改動畫的邏輯,或者和別的實時動畫系統例如物理引擎進行整合。
Chipmunk
我們來基于物理學創建一個真實的重力模擬效果來取代當前基于緩沖的彈性動畫,但即使模擬2D的物理效果就已近極其復雜了,所以就不要嘗試去實現它了,直接用開源的物理引擎庫好了。
我們將要使用的物理引擎叫做Chipmunk。另外的2D物理引擎也同樣可以(例如Box2D),但是Chipmunk使用純C寫的,而不是C++,好處在于更容易和Objective-C項目整合。Chipmunk有很多版本,包括一個和Objective-C綁定的“indie”版本。C語言的版本是免費的,所以我們就用它好了。在本書寫作的時候6.1.4是最新的版本;你可以從http://chipmunk-physics.net下載它。
Chipmunk完整的物理引擎相當巨大復雜,但是我們只會使用如下幾個類:
cpSpace - 這是所有的物理結構體的容器。它有一個大小和一個可選的重力矢量
cpBody - 它是一個固態無彈力的剛體。它有一個坐標,以及其他物理屬性,例如質量,運動和摩擦系數等等。
cpShape - 它是一個抽象的幾何形狀,用來檢測碰撞。可以給結構體添加一個多邊形,而且cpShape有各種子類來代表不同形狀的類型。
在例子中,我們來對一個木箱建模,然后在重力的影響下下落。我們來創建一個Crate類,包含屏幕上的可視效果(一個UIImageView)和一個物理模型(一個cpBody和一個cpPolyShape,一個cpShape的多邊形子類來代表矩形木箱)。
用C版本的Chipmunk會帶來一些挑戰,因為它現在并不支持Objective-C的引用計數模型,所以我們需要準確的創建和釋放對象。為了簡化,我們把cpShape和cpBody的生命周期和Crate類進行綁定,然后在木箱的-init方法中創建,在-dealloc中釋放。木箱物理屬性的配置很復雜,所以閱讀了Chipmunk文檔會很有意義。
視圖控制器用來管理cpSpace,還有和之前一樣的計時器邏輯。在每一步中,我們更新cpSpace(用來進行物理計算和所有結構體的重新擺放)然后迭代對象,然后再更新我們的木箱視圖的位置來匹配木箱的模型(在這里,實際上只有一個結構體,但是之后我們將要添加更多)。
Chipmunk使用了一個和UIKit顛倒的坐標系(Y軸向上為正方向)。為了使得物理模型和視圖之間的同步更簡單,我們需要通過使用geometryFlipped屬性翻轉容器視圖的集合坐標(第3章中有提到),于是模型和視圖都共享一個相同的坐標系。
具體的代碼見清單11.3。注意到我們并沒有在任何地方釋放cpSpace對象。在這個例子中,內存空間將會在整個app的生命周期中一直存在,所以這沒有問題。但是在現實世界的場景中,我們需要像創建木箱結構體和形狀一樣去管理我們的空間,封裝在標準的Cocoa對象中,然后來管理Chipmunk對象的生命周期。圖11.1展示了掉落的木箱。
清單11.3 使用物理學來對掉落的木箱建模
#import?"ViewController.h"
#import?#import?"chipmunk.h"
@interface?Crate?:?UIImageView
@property?(nonatomic,?assign)?cpBody?*body;
@property?(nonatomic,?assign)?cpShape?*shape;
@end
@implementation?Crate
#define?MASS?100
-?(id)initWithFrame:(CGRect)frame
{
if((self?=?[superinitWithFrame:frame]))?{
//set?image
self.image?=?[UIImage?imageNamed:@"Crate.png"];
self.contentMode?=?UIViewContentModeScaleAspectFill;
//create?the?body
self.body?=?cpBodyNew(MASS,?cpMomentForBox(MASS,?frame.size.width,?frame.size.height));
//create?the?shape
cpVect?corners[]?=?{
cpv(0,?0),
cpv(0,?frame.size.height),
cpv(frame.size.width,?frame.size.height),
cpv(frame.size.width,?0),
};
self.shape?=?cpPolyShapeNew(self.body,?4,?corners,?cpv(-frame.size.width/2,?-frame.size.height/2));
//set?shape?friction?&?elasticity
cpShapeSetFriction(self.shape,?0.5);
cpShapeSetElasticity(self.shape,?0.8);
//link?the?crate?to?the?shape
//so?we?can?refer?to?crate?from?callback?later?on
self.shape->data?=?(__bridge?void?*)self;
//set?the?body?position?to?match?view
cpBodySetPos(self.body,?cpv(frame.origin.x?+?frame.size.width/2,?300?-?frame.origin.y?-?frame.size.height/2));
}
returnself;
}
-?(void)dealloc
{
//release?shape?and?body
cpShapeFree(_shape);
cpBodyFree(_body);
}
@end
@interface?ViewController?()
@property?(nonatomic,?weak)?IBOutlet?UIView?*containerView;
@property?(nonatomic,?assign)?cpSpace?*space;
@property?(nonatomic,?strong)?CADisplayLink?*timer;
@property?(nonatomic,?assign)?CFTimeInterval?lastStep;
@end
@implementation?ViewController
#define?GRAVITY?1000
-?(void)viewDidLoad
{
//invert?view?coordinate?system?to?match?physics
self.containerView.layer.geometryFlipped?=?YES;
//set?up?physics?space
self.space?=?cpSpaceNew();
cpSpaceSetGravity(self.space,?cpv(0,?-GRAVITY));
//add?a?crate
Crate?*crate?=?[[Crate?alloc]?initWithFrame:CGRectMake(100,?0,?100,?100)];
[self.containerView?addSubview:crate];
cpSpaceAddBody(self.space,?crate.body);
cpSpaceAddShape(self.space,?crate.shape);
//start?the?timer
self.lastStep?=?CACurrentMediaTime();
self.timer?=?[CADisplayLink?displayLinkWithTarget:self
selector:@selector(step:)];
[self.timer?addToRunLoop:[NSRunLoop?mainRunLoop]
forMode:NSDefaultRunLoopMode];
}
void?updateShape(cpShape?*shape,?void?*unused)
{
//get?the?crate?object?associated?with?the?shape
Crate?*crate?=?(__bridge?Crate?*)shape->data;
//update?crate?view?position?and?angle?to?match?physics?shape
cpBody?*body?=?shape->body;
crate.center?=?cpBodyGetPos(body);
crate.transform?=?CGAffineTransformMakeRotation(cpBodyGetAngle(body));
}
-?(void)step:(CADisplayLink?*)timer
{
//calculate?step?duration
CFTimeInterval?thisStep?=?CACurrentMediaTime();
CFTimeInterval?stepDuration?=?thisStep?-?self.lastStep;
self.lastStep?=?thisStep;
//update?physics
cpSpaceStep(self.space,?stepDuration);
//update?all?the?shapes
cpSpaceEachShape(self.space,?&updateShape,?NULL);
}
@end
圖11.1 一個木箱圖片,根據模擬的重力掉落
添加用戶交互
下一步就是在視圖周圍添加一道不可見的墻,這樣木箱就不會掉落出屏幕之外。或許你會用另一個矩形的cpPolyShape來實現,就和之前創建木箱那樣,但是我們需要檢測的是木箱何時離開視圖,而不是何時碰撞,所以我們需要一個空心而不是固體矩形。
我們可以通過給cpSpace添加四個cpSegmentShape對象(cpSegmentShape代表一條直線,所以四個拼起來就是一個矩形)。然后賦給空間的staticBody屬性(一個不被重力影響的結構體)而不是像木箱那樣一個新的cpBody實例,因為我們不想讓這個邊框矩形滑出屏幕或者被一個下落的木箱擊中而消失。
同樣可以再添加一些木箱來做一些交互。最后再添加一個加速器,這樣可以通過傾斜手機來調整重力矢量(為了測試需要在一臺真實的設備上運行程序,因為模擬器不支持加速器事件,即使旋轉屏幕)。清單11.4展示了更新后的代碼,運行結果見圖11.2。
由于示例只支持橫屏模式,所以交換加速計矢量的x和y值。如果在豎屏下運行程序,請把他們換回來,不然重力方向就錯亂了。試一下就知道了,木箱會沿著橫向移動。
清單11.4 使用圍墻和多個木箱的更新后的代碼
-?(void)addCrateWithFrame:(CGRect)frame
{
Crate?*crate?=?[[Crate?alloc]?initWithFrame:frame];
[self.containerView?addSubview:crate];
cpSpaceAddBody(self.space,?crate.body);
cpSpaceAddShape(self.space,?crate.shape);
}
-?(void)addWallShapeWithStart:(cpVect)start?end:(cpVect)end
{
cpShape?*wall?=?cpSegmentShapeNew(self.space->staticBody,?start,?end,?1);
cpShapeSetCollisionType(wall,?2);
cpShapeSetFriction(wall,?0.5);
cpShapeSetElasticity(wall,?0.8);
cpSpaceAddStaticShape(self.space,?wall);
}
-?(void)viewDidLoad
{
//invert?view?coordinate?system?to?match?physics
self.containerView.layer.geometryFlipped?=?YES;
//set?up?physics?space
self.space?=?cpSpaceNew();
cpSpaceSetGravity(self.space,?cpv(0,?-GRAVITY));
//add?wall?around?edge?of?view
[self?addWallShapeWithStart:cpv(0,?0)?end:cpv(300,?0)];
[self?addWallShapeWithStart:cpv(300,?0)?end:cpv(300,?300)];
[self?addWallShapeWithStart:cpv(300,?300)?end:cpv(0,?300)];
[self?addWallShapeWithStart:cpv(0,?300)?end:cpv(0,?0)];
//add?a?crates
[self?addCrateWithFrame:CGRectMake(0,?0,?32,?32)];
[self?addCrateWithFrame:CGRectMake(32,?0,?32,?32)];
[self?addCrateWithFrame:CGRectMake(64,?0,?64,?64)];
[self?addCrateWithFrame:CGRectMake(128,?0,?32,?32)];
[self?addCrateWithFrame:CGRectMake(0,?32,?64,?64)];
//start?the?timer
self.lastStep?=?CACurrentMediaTime();
self.timer?=?[CADisplayLink?displayLinkWithTarget:self
selector:@selector(step:)];
[self.timer?addToRunLoop:[NSRunLoop?mainRunLoop]
forMode:NSDefaultRunLoopMode];
//update?gravity?using?accelerometer
[UIAccelerometer?sharedAccelerometer].delegate?=?self;
[UIAccelerometer?sharedAccelerometer].updateInterval?=?1/60.0;
}
-?(void)accelerometer:(UIAccelerometer?*)accelerometer?didAccelerate:(UIAcceleration?*)acceleration
{
//update?gravity
cpSpaceSetGravity(self.space,?cpv(acceleration.y?*?GRAVITY,?-acceleration.x?*?GRAVITY));
}
圖11.1 真實引力場下的木箱交互
模擬時間以及固定的時間步長
對于實現動畫的緩沖效果來說,計算每幀持續的時間是一個很好的解決方案,但是對模擬物理效果并不理想。通過一個可變的時間步長來實現有著兩個弊端:
如果時間步長不是固定的,精確的值,物理效果的模擬也就隨之不確定。這意味著即使是傳入相同的輸入值,也可能在不同場合下有著不同的效果。有時候沒多大影響,但是在基于物理引擎的游戲下,玩家就會由于相同的操作行為導致不同的結果而感到困惑。同樣也會讓測試變得麻煩。
由于性能故常造成的丟幀或者像電話呼入的中斷都可能會造成不正確的結果。考慮一個像子彈那樣快速移動物體,每一幀的更新都需要移動子彈,檢測碰撞。如果兩幀之間的時間加長了,子彈就會在這一步移動更遠的距離,穿過圍墻或者是別的障礙,這樣就丟失了碰撞。
我們想得到的理想的效果就是通過固定的時間步長來計算物理效果,但是在屏幕發生重繪的時候仍然能夠同步更新視圖(可能會由于在我們控制范圍之外造成不可預知的效果)。
幸運的是,由于我們的模型(在這個例子中就是Chipmunk的cpSpace中的cpBody)被視圖(就是屏幕上代表木箱的UIView對象)分離,于是就很簡單了。我們只需要根據屏幕刷新的時間跟蹤時間步長,然后根據每幀去計算一個或者多個模擬出來的效果。
我們可以通過一個簡單的循環來實現。通過每次CADisplayLink的啟動來通知屏幕將要刷新,然后記錄下當前的CACurrentMediaTime()。我們需要在一個小增量中提前重復物理模擬(這里用120分之一秒)直到趕上顯示的時間。然后更新我們的視圖,在屏幕刷新的時候匹配當前物理結構體的顯示位置。
清單11.5展示了固定時間步長版本的代碼
清單11.5 固定時間步長的木箱模擬
#define?SIMULATION_STEP?(1/120.0)
-?(void)step:(CADisplayLink?*)timer
{
//calculate?frame?step?duration
CFTimeInterval?frameTime?=?CACurrentMediaTime();
//update?simulation
while(self.lastStep?<?frameTime)?{
cpSpaceStep(self.space,?SIMULATION_STEP);
self.lastStep?+=?SIMULATION_STEP;
}
?
//update?all?the?shapes
cpSpaceEachShape(self.space,?&updateShape,?NULL);
}
避免死亡螺旋
當使用固定的模擬時間步長時候,有一件事情一定要注意,就是用來計算物理效果的現實世界的時間并不會加速模擬時間步長。在我們的例子中,我們隨意選擇了120分之一秒來模擬物理效果。Chipmunk很快,我們的例子也很簡單,所以cpSpaceStep()會完成的很好,不會延遲幀的更新。
但是如果場景很復雜,比如有上百個物體之間的交互,物理計算就會很復雜,cpSpaceStep()的計算也可能會超出1/120秒。我們沒有測量出物理步長的時間,因為我們假設了相對于幀刷新來說并不重要,但是如果模擬步長更久的話,就會延遲幀率。
如果幀刷新的時間延遲的話會變得很糟糕,我們的模擬需要執行更多的次數來同步真實的時間。這些額外的步驟就會繼續延遲幀的更新,等等。這就是所謂的死亡螺旋,因為最后的結果就是幀率變得越來越慢,直到最后應用程序卡死了。
我們可以通過添加一些代碼在設備上來對物理步驟計算真實世界的時間,然后自動調整固定時間步長,但是實際上它不可行。其實只要保證你給容錯留下足夠的邊長,然后在期望支持的最慢的設備上進行測試就可以了。如果物理計算超過了模擬時間的50%,就需要考慮增加模擬時間步長(或者簡化場景)。如果模擬時間步長增加到超過1/60秒(一個完整的屏幕更新時間),你就需要減少動畫幀率到一秒30幀或者增加CADisplayLink的frameInterval來保證不會隨機丟幀,不然你的動畫將會看起來不平滑。
總結
在這一章中,我們了解了如何通過一個計時器創建一幀幀的實時動畫,包括緩沖,物理模擬等等一系列動畫技術,以及用戶輸入(通過加速計)。
在第三部分中,我們將研究動畫性能是如何被被設備限制所影響的,以及如何調整我們的代碼來活的足夠好的幀率。
--------------------------------------------------------------------------------------------------------------------------------------------------------