物理類比(基於定時器的動畫 11.2),定時器11.2
物理類比
即使使用了基於定時器的動畫來複製第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 使用物理學來對掉落的木箱建模
1 #import "ViewController.h" 2 #import 3 #import "chipmunk.h" 4 5 @interface Crate : UIImageView 6 7 @property (nonatomic, assign) cpBody *body; 8 @property (nonatomic, assign) cpShape *shape; 9 10 @end 11 12 @implementation Crate 13 14 #define MASS 100 15 16 - (id)initWithFrame:(CGRect)frame 17 { 18 if ((self = [super initWithFrame:frame])) { 19 //set image 20 self.image = [UIImage imageNamed:@"Crate.png"]; 21 self.contentMode = UIViewContentModeScaleAspectFill; 22 //create the body 23 self.body = cpBodyNew(MASS, cpMomentForBox(MASS, frame.size.width, frame.size.height)); 24 //create the shape 25 cpVect corners[] = { 26 cpv(0, 0), 27 cpv(0, frame.size.height), 28 cpv(frame.size.width, frame.size.height), 29 cpv(frame.size.width, 0), 30 }; 31 self.shape = cpPolyShapeNew(self.body, 4, corners, cpv(-frame.size.width/2, -frame.size.height/2)); 32 //set shape friction & elasticity 33 cpShapeSetFriction(self.shape, 0.5); 34 cpShapeSetElasticity(self.shape, 0.8); 35 //link the crate to the shape 36 //so we can refer to crate from callback later on 37 self.shape->data = (__bridge void *)self; 38 //set the body position to match view 39 cpBodySetPos(self.body, cpv(frame.origin.x + frame.size.width/2, 300 - frame.origin.y - frame.size.height/2)); 40 } 41 return self; 42 } 43 44 - (void)dealloc 45 { 46 //release shape and body 47 cpShapeFree(_shape); 48 cpBodyFree(_body); 49 } 50 51 @end 52 53 @interface ViewController () 54 55 @property (nonatomic, weak) IBOutlet UIView *containerView; 56 @property (nonatomic, assign) cpSpace *space; 57 @property (nonatomic, strong) CADisplayLink *timer; 58 @property (nonatomic, assign) CFTimeInterval lastStep; 59 60 @end 61 62 @implementation ViewController 63 64 #define GRAVITY 1000 65 66 - (void)viewDidLoad 67 { 68 //invert view coordinate system to match physics 69 self.containerView.layer.geometryFlipped = YES; 70 //set up physics space 71 self.space = cpSpaceNew(); 72 cpSpaceSetGravity(self.space, cpv(0, -GRAVITY)); 73 //add a crate 74 Crate *crate = [[Crate alloc] initWithFrame:CGRectMake(100, 0, 100, 100)]; 75 [self.containerView addSubview:crate]; 76 cpSpaceAddBody(self.space, crate.body); 77 cpSpaceAddShape(self.space, crate.shape); 78 //start the timer 79 self.lastStep = CACurrentMediaTime(); 80 self.timer = [CADisplayLink displayLinkWithTarget:self 81 selector:@selector(step:)]; 82 [self.timer addToRunLoop:[NSRunLoop mainRunLoop] 83 forMode:NSDefaultRunLoopMode]; 84 } 85 86 void updateShape(cpShape *shape, void *unused) 87 { 88 //get the crate object associated with the shape 89 Crate *crate = (__bridge Crate *)shape->data; 90 //update crate view position and angle to match physics shape 91 cpBody *body = shape->body; 92 crate.center = cpBodyGetPos(body); 93 crate.transform = CGAffineTransformMakeRotation(cpBodyGetAngle(body)); 94 } 95 96 - (void)step:(CADisplayLink *)timer 97 { 98 //calculate step duration 99 CFTimeInterval thisStep = CACurrentMediaTime();100 CFTimeInterval stepDuration = thisStep - self.lastStep;101 self.lastStep = thisStep;102 //update physics103 cpSpaceStep(self.space, stepDuration);104 //update all the shapes105 cpSpaceEachShape(self.space, &updateShape, NULL);106 }107 108 @end
View Code
圖11.1 一個木箱圖片,根據類比的重力掉落
添加使用者互動
下一步就是在視圖周圍添加一道不可見的牆,這樣木箱就不會掉落出螢幕之外。或許你會用另一個矩形的cpPolyShape
來實現,就和之前建立木箱那樣,但是我們需要檢測的是木箱何時離開視圖,而不是何時碰撞,所以我們需要一個空心而不是固體矩形。
我們可以通過給cpSpace
添加四個cpSegmentShape
對象(cpSegmentShape
代表一條直線,所以四個拼起來就是一個矩形)。然後賦給空間的staticBody
屬性(一個不被重力影響的結構體)而不是像木箱那樣一個新的cpBody
執行個體,因為我們不想讓這個邊框矩形滑出螢幕或者被一個下落的木箱擊中而消失。
同樣可以再添加一些木箱來做一些互動。最後再添加一個加速器,這樣可以通過傾斜手機來調整重力向量(為了測試需要在一台真實的裝置上運行程式,因為模擬器不支援加速器事件,即使旋轉螢幕)。清單11.4展示了更新後的代碼,運行結果見圖11.2。
由於樣本只支援橫屏模式,所以交換加速計向量的x和y值。如果在豎屏下運行程式,請把他們換回來,不然重力方向就錯亂了。試一下就知道了,木箱會沿著橫向移動。
清單11.4 使用圍牆和多個木箱的更新後的代碼
1 - (void)addCrateWithFrame:(CGRect)frame 2 { 3 Crate *crate = [[Crate alloc] initWithFrame:frame]; 4 [self.containerView addSubview:crate]; 5 cpSpaceAddBody(self.space, crate.body); 6 cpSpaceAddShape(self.space, crate.shape); 7 } 8 9 - (void)addWallShapeWithStart:(cpVect)start end:(cpVect)end10 {11 cpShape *wall = cpSegmentShapeNew(self.space->staticBody, start, end, 1);12 cpShapeSetCollisionType(wall, 2);13 cpShapeSetFriction(wall, 0.5);14 cpShapeSetElasticity(wall, 0.8);15 cpSpaceAddStaticShape(self.space, wall);16 }17 18 - (void)viewDidLoad19 {20 //invert view coordinate system to match physics21 self.containerView.layer.geometryFlipped = YES;22 //set up physics space23 self.space = cpSpaceNew();24 cpSpaceSetGravity(self.space, cpv(0, -GRAVITY));25 //add wall around edge of view26 [self addWallShapeWithStart:cpv(0, 0) end:cpv(300, 0)];27 [self addWallShapeWithStart:cpv(300, 0) end:cpv(300, 300)];28 [self addWallShapeWithStart:cpv(300, 300) end:cpv(0, 300)];29 [self addWallShapeWithStart:cpv(0, 300) end:cpv(0, 0)];30 //add a crates31 [self addCrateWithFrame:CGRectMake(0, 0, 32, 32)];32 [self addCrateWithFrame:CGRectMake(32, 0, 32, 32)];33 [self addCrateWithFrame:CGRectMake(64, 0, 64, 64)];34 [self addCrateWithFrame:CGRectMake(128, 0, 32, 32)];35 [self addCrateWithFrame:CGRectMake(0, 32, 64, 64)];36 //start the timer37 self.lastStep = CACurrentMediaTime();38 self.timer = [CADisplayLink displayLinkWithTarget:self39 selector:@selector(step:)];40 [self.timer addToRunLoop:[NSRunLoop mainRunLoop]41 forMode:NSDefaultRunLoopMode];42 //update gravity using accelerometer43 [UIAccelerometer sharedAccelerometer].delegate = self;44 [UIAccelerometer sharedAccelerometer].updateInterval = 1/60.0;45 }46 47 - (void)accelerometer:(UIAccelerometer *)accelerometer didAccelerate:(UIAcceleration *)acceleration48 {49 //update gravity50 cpSpaceSetGravity(self.space, cpv(acceleration.y * GRAVITY, -acceleration.x * GRAVITY));51 }
View Code
圖11.1 真實引力場下的木箱互動
類比時間以及固定的時間步長
對於實現動畫的緩衝效果來說,計算每幀持續的時間是一個很好的解決方案,但是對類比物理效果並不理想。通過一個可變的時間步長來實現有著兩個弊端:
如果時間步長不是固定的,精確的值,物理效果的類比也就隨之不確定。這意味著即使是傳入相同的輸入值,也可能在不同場合下有著不同的效果。有時候沒多大影響,但是在基於物理引擎的遊戲下,玩家就會由於相同的操作行為導致不同的結果而感到困惑。同樣也會讓測試變得麻煩。
由於效能故常造成的丟幀或者像電話呼入的中斷都可能會造成不正確的結果。考慮一個像子彈那樣快速移動物體,每一幀的更新都需要移動子彈,檢測碰撞。如果兩幀之間的時間加長了,子彈就會在這一步移動更遠的距離,穿過圍牆或者是別的障礙,這樣就丟失了碰撞。
我們想得到的理想的效果就是通過固定的時間步長來計算物理效果,但是在螢幕發生重繪的時候仍然能夠同步更新視圖(可能會由於在我們控制範圍之外造成不可預知的效果)。
幸運的是,由於我們的模型(在這個例子中就是Chipmunk的cpSpace
中的cpBody
)被視圖(就是螢幕上代表木箱的UIView
對象)分離,於是就很簡單了。我們只需要根據螢幕重新整理的時間跟蹤時間步長,然後根據每幀去計算一個或者多個類比出來的效果。
我們可以通過一個簡單的迴圈來實現。通過每次CADisplayLink
的啟動來通知螢幕將要重新整理,然後記錄下當前的CACurrentMediaTime()
。我們需要在一個小增量中提前重複物理類比(這裡用120分之一秒)直到趕上顯示的時間。然後更新我們的視圖,在螢幕重新整理的時候匹配當前物理結構體的顯示位置。
清單11.5展示了固定時間步長版本的代碼
清單11.5 固定時間步長的木箱類比
避免死亡螺旋
當使用固定的類比時間步長時候,有一件事情一定要注意,就是用來計算物理效果的現實世界的時間並不會加速類比時間步長。在我們的例子中,我們隨意選擇了120分之一秒來類比物理效果。Chipmunk很快,我們的例子也很簡單,所以cpSpaceStep()
會完成的很好,不會延遲幀的更新。
但是如果情境很複雜,比如有上百個物體之間的互動,物理計算就會很複雜,cpSpaceStep()
的計算也可能會超出1/120秒。我們沒有測量出物理步長的時間,因為我們假設了相對於幀重新整理來說並不重要,但是如果類比步長更久的話,就會延遲幀率。
如果幀重新整理的時間延遲的話會變得很糟糕,我們的類比需要執行更多的次數來同步真實的時間。這些額外的步驟就會繼續延遲幀的更新,等等。這就是所謂的死亡螺旋,因為最後的結果就是幀率變得越來越慢,直到最後應用程式卡死了。
我們可以通過添加一些代碼在裝置上來對物理步驟計算真實世界的時間,然後自動調整固定時間步長,但是實際上它不可行。其實只要保證你給容錯留下足夠的邊長,然後在期望支援的最慢的裝置上進行測試就可以了。如果物理計算超過了類比時間的50%,就需要考慮增加類比時間步長(或者簡化情境)。如果類比時間步長增加到超過1/60秒(一個完整的螢幕更新時間),你就需要減少動畫幀率到一秒30幀或者增加CADisplayLink
的frameInterval
來保證不會隨機丟幀,不然你的動畫將會看起來不平滑。