# 物理模擬
即使使用了基于定時器的動畫來復制第10章中關鍵幀的行為,但還是會有一些本質上的區別:在關鍵幀的實現中,我們提前計算了所有幀,但是在新的解決方案中,我們實際上實在按需要在計算。意義在于我們可以根據用戶輸入實時修改動畫的邏輯,或者和別的實時動畫系統例如物理引擎進行整合。
### Chipmunk
我們來基于物理學創建一個真實的重力模擬效果來取代當前基于緩沖的彈性動畫,但即使模擬2D的物理效果就已近極其復雜了,所以就不要嘗試去實現它了,直接用開源的物理引擎庫好了。
我們將要使用的物理引擎叫做Chipmunk。另外的2D物理引擎也同樣可以(例如Box2D),但是Chipmunk使用純C寫的,而不是C++,好處在于更容易和Objective-C項目整合。Chipmunk有很多版本,包括一個和Objective-C綁定的“indie”版本。C語言的版本是免費的,所以我們就用它好了。在本書寫作的時候6.1.4是最新的版本;你可以從[http://chipmunk-physics.net下載它。](http://chipmunk-physics.xn--net-128du92d9r7e./)
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 = [super initWithFrame: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));
}
return self;
}
- (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`來保證不會隨機丟幀,不然你的動畫將會看起來不平滑。
# 物理模擬
- Introduction
- 1. 圖層樹
- 1.1 圖層與視圖
- 1.2 圖層的能力
- 1.3 使用圖層
- 1.4 總結
- 2. 寄宿圖
- 2.1 contents屬性
- 2.2 Custom Drawing
- 2.3 總結
- 3. 圖層幾何學
- 3.1 布局
- 3.2 錨點
- 3.3 坐標系
- 3.4 Hit Testing
- 3.5 自動布局
- 3.6 總結
- 4. 視覺效果
- 4.1 圓角
- 4.2 圖層邊框
- 4.3 陰影
- 4.4 圖層蒙板
- 4.5 拉伸過濾
- 4.6 組透明
- 4.7 總結
- 5. 變換
- 5.1 仿射變換
- 5.2 3D變換
- 5.3 固體對象
- 5.4 總結
- 6. 專用圖層
- 6.1 CAShapeLayer
- 6.2 CATextLayer
- 6.3 CATransformLayer
- 6.4 CAGradientLayer
- 6.5 CAReplicatorLayer
- 6.6 CAScrollLayer
- 6.7 CATiledLayer
- 6.8 CAEmitterLayer
- 6.9 CAEAGLLayer
- 6.10 AVPlayerLayer
- 6.11 總結
- 7. 隱式動畫
- 7.1 事務
- 7.2 完成塊
- 7.3 圖層行為
- 7.4 呈現與模型
- 7.5 總結
- 8. 顯式動畫
- 8.1 屬性動畫
- 8.2 動畫組
- 8.3 過渡
- 8.4 在動畫過程中取消動畫
- 8.5 總結
- 9. 圖層時間
- 9.1 CAMediaTiming協議
- 9.2 層級關系時間
- 9.3 手動動畫
- 9.4 總結
- 10. 緩沖
- 10.1 動畫速度
- 10.2 自定義緩沖函數
- 10.3 總結
- 11. 基于定時器的動畫
- 11.1 定時幀
- 11.2 物理模擬
- 12. 性能調優
- 12.1. CPU VS GPU
- 12.2 測量,而不是猜測
- 12.3 Instruments
- 12.4 總結
- 13. 高效繪圖
- 13.1 軟件繪圖
- 13.2 矢量圖形
- 13.3 臟矩形
- 13.4 異步繪制
- 13.5 總結
- 14. 圖像IO
- 14.1 加載和潛伏
- 14.2 緩存
- 14.3 文件格式
- 14.4 總結
- 15. 圖層性能
- 15.1 隱式繪制
- 15.2 離屏渲染
- 15.3 混合和過度繪制
- 15.4 減少圖層數量
- 15.5 總結