1. 程式人生 > >物理模擬(基於定時器的動畫 11.2)

物理模擬(基於定時器的動畫 11.2)

步長 call 可選 es2017 containe 屏幕 crate 建模 問題

物理模擬

即使使用了基於定時器的動畫來復制第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的引用計數模型,所以我們需要準確的創建和釋放對象。為了簡化,我們把cpShapecpBody的生命周期和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 physics
103     cpSpaceStep(self.space, stepDuration);
104     //update all the shapes
105     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)end
10 {
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)viewDidLoad
19 {
20     //invert view coordinate system to match physics
21     self.containerView.layer.geometryFlipped = YES;
22     //set up physics space
23     self.space = cpSpaceNew();
24     cpSpaceSetGravity(self.space, cpv(0, -GRAVITY));
25     //add wall around edge of view
26     [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 crates
31     [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 timer
37     self.lastStep = CACurrentMediaTime();
38     self.timer = [CADisplayLink displayLinkWithTarget:self
39                                              selector:@selector(step:)];
40     [self.timer addToRunLoop:[NSRunLoop mainRunLoop]
41                      forMode:NSDefaultRunLoopMode];
42     //update gravity using accelerometer
43     [UIAccelerometer sharedAccelerometer].delegate = self;
44     [UIAccelerometer sharedAccelerometer].updateInterval = 1/60.0;
45 }
46 
47 - (void)accelerometer:(UIAccelerometer *)accelerometer didAccelerate:(UIAcceleration *)acceleration
48 {
49     //update gravity
50     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幀或者增加CADisplayLinkframeInterval來保證不會隨機丟幀,不然你的動畫將會看起來不平滑。

物理模擬(基於定時器的動畫 11.2)