CALayerで3Dグラフィックス?

ちょっと3Dグラフィックスやってみようかと思って、もしかしたら座標変換のライブラリはないかとドキュメントを見ていたら、Core Animationの関数を使ってアフィン変換・透視変換とかできる上に、そのまま描画もできるらしいということが分かった。

やってみた結論としては、がっつり3DやるならやっぱりOpenGLとか使った方が良いと思う。けど、簡単な図形や画像をちょっと俯瞰っぽく見せたい、ぐらいなら使えるかも。


ちなみにOSバージョンはSnow Leopard(10.6.6)です。


追記: これで立体迷路アプリ作ってみた

準備1. CocoaのクラスにCALayerを貼付ける

とりあえず適当なサイズのウィンドウを表示させてCALayerを貼付けてみるよ。

#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <QuartzCore/CoreAnimation.h>

@interface WindowDelegate : NSObject
@end
@implementation WindowDelegate
- (BOOL)windowShouldClose:(id)sender
{
    [[NSApplication sharedApplication] terminate:nil];
    return YES;
}
@end

// 画面の一辺のサイズ
#define SCREEN_SIZE 400

int
main (int argc, const char * argv[])
{
    NSAutoreleasePool * pool = [[NSAutoreleasePool alloc] init];
    
    NSApplication *application = [NSApplication sharedApplication];
    NSRect contentRect = NSMakeRect(0, 0, SCREEN_SIZE, SCREEN_SIZE);
    NSWindow *window = [[[NSWindow alloc]
                         initWithContentRect:contentRect
                         styleMask:NSTitledWindowMask | NSClosableWindowMask
                         backing:NSBackingStoreBuffered defer:NO] autorelease];
    [window setTitle:@"CALayer Sample"];
    [window setMovableByWindowBackground:YES];
    [window setDelegate:[[[WindowDelegate alloc] init] autorelease]];

    // 他のLayerを貼付けるための下地
    CALayer *baseLayer = [CALayer layer];
    baseLayer.frame = contentRect;

    NSView *contentView = [window contentView];
    [contentView setLayer:baseLayer];
    [contentView setWantsLayer:YES];
    
    /* ここでいろいろする */

    [window orderFront:nil];
    [application run];

    [pool drain];
    return 0;
}
// gcc -Wall -ObjC -framework Foundation -framework AppKit \
//    -framework QuartzCore -o CALayerAnimation CALayerAnimation.m

400x400のウィンドウを一つ作り、CALayerのインスタンスをそのcontentViewにsetLayer:でセットする。
これで、baseLayerに対していろいろすることで、ウィンドウ内に表示を行えるようになる。
※これだけでは特に何も表示されず、グレーのウィンドウが出るだけ。

準備2. CALayerの中に図形を表示する。

NSBezierPathなどを使ってCALayer内で表示を行う。
普通Cocoaで何か描画する際は、Viewの-drawRect:内などに処理を書くことが多いけど、CALayerを使用する場合、CALayerのサブクラスやdelegateにメソッドを実装する。
例えば、描画領域全体を白色に塗り、青い枠を表示するなら、次のようなクラスを作って

@interface LayerDrawer : NSObject
@end
@implementation LayerDrawer
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)context;
{
    [NSGraphicsContext saveGraphicsState];
    NSGraphicsContext *graphicsContext =
        [NSGraphicsContext graphicsContextWithGraphicsPort:context flipped:NO];
    [NSGraphicsContext setCurrentContext:graphicsContext];

    NSBezierPath *borderPath = [NSBezierPath bezierPathWithRect:layer.bounds];
    [[NSColor whiteColor] set];     // 背景は白色
    [borderPath fill];              // 枠を塗りつぶす
    [[NSColor blueColor] set];      // 線は青色
    [borderPath setLineWidth:15.0]; // 線の太さを15に設定
    [borderPath stroke];            // 枠線を書く

    [NSGraphicsContext restoreGraphicsState];
}
@end

描画先のLayerにdelegateとしてセットする。(上の「/* ここでいろいろする */」のところに追加)

    CALayer *drawLayer = [CALayer layer];  // 描画用のレイヤ作成
    [baseLayer addSublayer:drawLayer];     // ベースのレイヤのサブレイヤとして追加
    drawLayer.frame = contentRect;         // 親レイヤと同じ大きさにセット
    drawLayer.delegate = [[[LayerDrawer alloc] init] autorelease]; // delegateとして設定
    [drawLayer setNeedsDisplay];           // 要描画フラグを立てる

描画してみる。


座標系が分かりやすいように、layerのpositionを原点に座標軸を描画してみる。

@implementation LayerDrawer
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)context;
{
    [NSGraphicsContext saveGraphicsState];
    NSGraphicsContext *graphicsContext =
        [NSGraphicsContext graphicsContextWithGraphicsPort:context flipped:NO];
    [NSGraphicsContext setCurrentContext:graphicsContext];
    
    // 背景を白に塗る
    [[NSColor whiteColor] set];
    NSBezierPath *borderPath = [NSBezierPath bezierPathWithRect:layer.bounds];
    [borderPath fill];

    // グレーでx軸の線と矢印を書く
    [[NSColor darkGrayColor] set];
    NSBezierPath *xAxisPath = [NSBezierPath bezierPath];
    [xAxisPath setLineWidth:5.0];
    [xAxisPath moveToPoint:NSMakePoint(0.0, layer.position.y)];
    [xAxisPath lineToPoint:NSMakePoint(layer.bounds.size.width, layer.position.y)];
    [xAxisPath lineToPoint:NSMakePoint(layer.bounds.size.width - 20.0, layer.position.y + 20.0)];
    [xAxisPath moveToPoint:NSMakePoint(layer.bounds.size.width, layer.position.y)];
    [xAxisPath lineToPoint:NSMakePoint(layer.bounds.size.width - 20.0, layer.position.y - 20.0)];
    [xAxisPath stroke];
    // 矢印の先端付近に「x」と表示
    [@"x" drawAtPoint:NSMakePoint(layer.bounds.size.width - 40.0, layer.position.y + 20.0)
       withAttributes:[NSDictionary dictionaryWithObjectsAndKeys:
                       [NSFont labelFontOfSize:42], NSFontAttributeName,
                       [NSColor darkGrayColor], NSForegroundColorAttributeName,
                       nil]];

    // グレーでy軸の線と矢印を書く
    NSBezierPath *yAxisPath = [NSBezierPath bezierPath];
    [yAxisPath setLineWidth:5.0];
    [yAxisPath moveToPoint:NSMakePoint(layer.position.x, 0.0)];
    [yAxisPath lineToPoint:NSMakePoint(layer.position.x, layer.bounds.size.height)];
    [yAxisPath lineToPoint:NSMakePoint(layer.position.x + 20.0, layer.bounds.size.height - 20.0)];
    [yAxisPath moveToPoint:NSMakePoint(layer.position.x, layer.bounds.size.height)];
    [yAxisPath lineToPoint:NSMakePoint(layer.position.x - 20.0, layer.bounds.size.height - 20.0)];
    [yAxisPath stroke];
    // 矢印の先端付近に「y」と表示
    [@"y" drawAtPoint:NSMakePoint(layer.position.x + 20.0, layer.bounds.size.height - 40.0)
       withAttributes:[NSDictionary dictionaryWithObjectsAndKeys:
                       [NSFont labelFontOfSize:42], NSFontAttributeName,
                       [NSColor darkGrayColor], NSForegroundColorAttributeName,
                       nil]];
    [NSGraphicsContext restoreGraphicsState];
}
@end

実行するとこうなる。

2Dの座標変換・変換の合成

CALayerには、transformというプロパティがあり、CATransform3D型の値をセットしてやることで、座標系を変更することが出来る。
例えば、X軸を-30、Y軸を-100ずらすなら、

    // 水平移動(-30、-100)
    CATransform3D transform = CATransform3DMakeTranslation(-30.0, -100.0, 0.0);
    drawLayer.transform = transform;


反時計回りに60度回転してみる。*1

    // Z軸を中心に60度(=1/3ラジアン)回転
    CATransform3D transform = CATransform3DMakeRotation(1.0/3 * M_PI, 0.0, 0.0, 1.0);
    drawLayer.transform = transform;


X軸のスケールを1/2にY軸のスケールを1/3にする。

    // Y軸のスケールを1/2
    CATransform3D transform = CATransform3DMakeScale(1.0/2, 1.0/3, 1.0);
    drawLayer.transform = transform;


移動/回転/拡大縮小を組み合わせることもできる。
例えば、X軸を-30、Y軸を-100ずらした後に60度回転するなら、

    CATransform3D transform = CATransform3DMakeTranslation(-30.0, -100.0, 0.0);
    transform = CATransform3DRotate(transform, 1.0/3 * M_PI, 0.0, 0.0, 1.0);
    drawLayer.transform = transform;


CATransform3DMakeXXXで作ったtransformをCATransform3DConcatで合成する方法もある。※順番に注意

    // 上と同じ変換
    CATransform3D transform = CATransform3DMakeTranslation(-30.0, -100.0, 0.0);
    transform = CATransform3DConcat(
                    CATransform3DMakeRotation(1.0/3 * M_PI, 0.0, 0.0, 1.0), transform);
    drawLayer.transform = transform;

変換の合成は順序を入れ替えると一般に違う結果になる。*2 例えば、移動と回転を逆にするなら

    // 回転してから移動
    CATransform3D transform = CATransform3DMakeRotation(1.0/3 * M_PI, 0.0, 0.0, 1.0);
    transform = CATransform3DTranslate(transform, -30.0, -100.0, 0.0);
    drawLayer.transform = transform;

アニメーション

回転をおこなった結果の図形を描画する方法は分かったので、次はリアルタイムにぐるぐる回してみたい。
CAAnimationオブジェクトをレイヤに追加することで、座標変換の途中の過程を連続して描画しアニメーションすることが出来る。
下のように、transformをlayer.transformに直接セットする代わりに、キー @"transform"でCABasicAnimationを作成してtoValueにtransformをセットしそのCABasicAnimationをlayerにaddAnimation:forKey:する。

    CATransform3D transform = CATransform3DMakeRotation(0.5 * M_PI, 0.0, 0.0, 1.0); // 90度回転
    CABasicAnimation* animation = [CABasicAnimation animationWithKeyPath:@"transform"];    
    animation.toValue = [NSNumber valueWithCATransform3D:transform:]; // 最終状態をセット
    animation.duration = 1.0;       // 1秒掛けて表示
    [drawLayer addAnimation:animation forKey:@"RotationSample"];

回転が終わったら最初の状態に戻ってしまう。これは仕様らしい。


ずっとぐるぐる回転するには、repeatCountとcumulativeを設定してやる。cumulative=YESにすりと、効果が累積される。つまり90度回転を4回繰り返すことで360度回転出来る。

    CATransform3D transform = CATransform3DMakeRotation(0.5 * M_PI, 0.0, 0.0, 1.0); // 90度回転
    CABasicAnimation* animation = [CABasicAnimation animationWithKeyPath:@"transform"];    
    animation.toValue = [NSNumber valueWithCATransform3D:transform]; // 最終状態をセット
    animation.duration = 1.0;       // 1秒掛けて表示
    animation.repeatCount = 100000; // 100000回繰り返す
    animation.cumulative = YES;     // 効果が累積させる
    [drawLayer addAnimation:animation forKey:@"RotationSample"];


なんか波打ってるように見えるんだが……あんまり賢い補完じゃないっぽい。


CABasicAnimationはtransformだけじゃなくて他の属性もいろいろアニメーションできる。例えばopacityをアニメーションさせると、徐々に透明度を上げたり下げたり出来る。

    CABasicAnimation* colorAnimation = [CABasicAnimation animationWithKeyPath:@"opacity"];    
    colorAnimation.fromValue = [NSNumber numberWithFloat:1.0];  // 開始状態をセット
    colorAnimation.toValue = [NSNumber numberWithFloat:0.0];   // 最終状態をセット
    colorAnimation.duration = 1.0;       // 1秒掛けて表示
    colorAnimation.repeatCount = 100000; // 100000回繰り返す
    colorAnimation.autoreverses = YES;   // 終了時に自動で逆のアニメーションをする
    [drawLayer addAnimation:colorAnimation forKey:@"OpacitySample"];


ちなみにアニメーション終了時に元の状態に戻るのではなく、終了時の状態になるようにするには、アニメーションが始まる前に先にlayerに終了時の値をセットしてやれば良い。
セットした瞬間に画面上でアニメーションがおこるので、それを防ぐにはトランザクションを利用して描画アクションを抑制してやる。

    CATransform3D transform = CATransform3DMakeRotation(0.5 * M_PI, 0.0, 0.0, 1.0);

    [CATransaction begin];
    [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions]; // 描画を抑制
    drawLayer.transform = transform; // 先に終了状態をセット
    
    CABasicAnimation* animation = [CABasicAnimation animationWithKeyPath:@"transform"];  
    animation.fromValue = [NSNumber valueWithCATransform3D:CATransform3DIdentity]; // 0度から
    animation.toValue = [NSNumber valueWithCATransform3D:transform];               // 90度まで
    animation.duration = 1.0;       // 1秒掛けて表示
    [drawLayer addAnimation:animation forKey:@"RotationSample"];
    
    [CATransaction commit];

透視変換の導入

透視変換をおこなうには、写像をおこなうlayerのsublayerTransformの内容を直接操作する、具体的には、行列のm34の箇所にスクリーンの奥行きの逆数をセットする。

    CATransform3D perspectiveTransform = CATransform3DIdentity;
    perspectiveTransform.m34 = 1.0 / -SCREEN_SIZE; // 一辺の長さと同じ位の奥行きとする
    baseLayer.sublayerTransform = perspectiveTransform;

これだけでは、画面上は特に変化はない。図形を描画しているレイヤのz座標を変えてやると、遠近法が表示状の大きさが変わる。

    // Z座標を設定
    drawLayer.zPosition = -200.0; // 奥方向に200移動


少し小さくなった。


さらに回転してみると、遠近法の働き具合が分かりやすい。
z軸ではなく、x軸で回転してみる。

    // X軸を中心に反時計回りに60度(=1/3ラジアン)回転
    CATransform3D transform = CATransform3DMakeRotation(-1.0/3 * M_PI, 1.0, 0.0, 0.0);
    drawLayer.transform = transform;

さらにz軸で回転してみる。

    // X軸を中心に反時計回りに60度(=1/3ラジアン)回転
    CATransform3D transform = CATransform3DMakeRotation(-1.0/3 * M_PI, 1.0, 0.0, 0.0);
    // Z軸を中心に時計回りに60度(=1/3ラジアン)回転
    transform = CATransform3DRotate(transform, 1.0/3 * M_PI, 0.0, 0.0, 1.0);
    drawLayer.transform = transform;

空間的に図形を組み合わせる

3Dグラフィックス的には、世界座標系のLayerの上にローカル座標系のLayerを追加して視野座標系(カメラ)を動かすと……という風にやりたいのだが、親レイヤの平面から立体的に飛び出したレイヤを作成したりは出来ないようだ。
仕方がないので、全ての図形を透視変換の写像を行っているレイヤ(baseLayer)に対してフラットに追加する。
ちょっとベタでみっともないけど、3つのレイヤを直交するように並べてみる。

    // 描画用のレイヤ作成
    CALayer *drawLayer = [CALayer layer];
    [baseLayer addSublayer:drawLayer];
    drawLayer.frame = contentRect; 
    drawLayer.delegate = [[[LayerDrawer alloc] init] autorelease];
    drawLayer.zPosition = -200.0;
    [drawLayer setNeedsDisplay];

    CATransform3D transform = CATransform3DMakeRotation(-1.0/3 * M_PI, 1.0, 0.0, 0.0);
    transform = CATransform3DRotate(transform, 1.0/4 * M_PI, 0.0, 0.0, 1.0);
    drawLayer.transform = transform;
    
    // 描画用のレイヤ作成2
    CALayer *drawLayer2 = [CALayer layer];
    [baseLayer addSublayer:drawLayer2];
    drawLayer2.frame = contentRect;
    drawLayer2.delegate = [[[LayerDrawer alloc] init] autorelease];
    drawLayer2.zPosition = -200.0;
    [drawLayer2 setNeedsDisplay];
    
    CATransform3D transform2 = CATransform3DMakeRotation(-1.0/3 * M_PI, 1.0, 0.0, 0.0);
    transform2 = CATransform3DRotate(transform2, 1.0/4 * M_PI, 0.0, 0.0, 1.0);
    // ここまではtransform1と同じ
    // さらにY軸で-90度回転
    transform2 = CATransform3DRotate(transform2, -0.5 * M_PI, 0.0, 1.0, 0.0);
    // さらにZ軸方向に半画面分移動
    transform2 = CATransform3DTranslate(transform2, 0.0, 0.0, -SCREEN_SIZE/2.0);
    drawLayer2.transform = transform2;
    
    // 描画用のレイヤ作成3
    CALayer *drawLayer3 = [CALayer layer];
    [baseLayer addSublayer:drawLayer3];
    drawLayer3.frame = contentRect;
    drawLayer3.delegate = [[[LayerDrawer alloc] init] autorelease];
    drawLayer3.zPosition = -200.0;
    [drawLayer3 setNeedsDisplay];
    
    CATransform3D transform3 = CATransform3DMakeRotation(-1.0/3 * M_PI, 1.0, 0.0, 0.0);
    transform3 = CATransform3DRotate(transform3, 1.0/4 * M_PI, 0.0, 0.0, 1.0);
    // ここまではtransform1と同じ
    // さらにX軸で-90度回転
    transform3 = CATransform3DRotate(transform3, 0.5 * M_PI, 1.0, 0.0, 0.0);
    // さらにZ軸方向に半画面分移動
    transform3 = CATransform3DTranslate(transform3, 0.0, 0.0, -SCREEN_SIZE/2.0);
    drawLayer3.transform = transform3;


大分3Dらしくなった。

全体を回転させる

transformを設定したCALayerをCAAnimationで連続的に回転させようとしたけど上手く行かない。
仕方がないので自分で開始・終了時のtransformを計算し、回転終了のタイミングをdelegateメソッドで拾って再度アニメーションを追加することで、連続して回転させた。

@interface ContinuableTransformAnimator : NSObject
{
    CALayer *layer;          // 対象レイヤ
    CATransform3D transform; // 変形
    CFTimeInterval duration; // 所要時間
}
@property(retain) CALayer *layer;
@property CATransform3D transform;
@property CFTimeInterval duration;
- (id)initWithLayer:(CALayer *)theLayer
    transform:(CATransform3D)theTransform duration:(CFTimeInterval)duration;
@end

@implementation ContinuableTransformAnimator
@synthesize layer;
@synthesize transform;
@synthesize duration;
- (id)initWithLayer:(CALayer *)theLayer
    transform:(CATransform3D)theTransform duration:(CFTimeInterval)theDuration
{
    if (self = [super init]) {
        self.layer = theLayer;
        self.transform = theTransform;
        self.duration = theDuration;
    }
    return self;
}
// アニメーションをスタート
- (void)start
{
    CABasicAnimation* animation = [CABasicAnimation animationWithKeyPath:@"transform"];    
    animation.fromValue =
        [NSNumber valueWithCATransform3D:layer.transform];
    animation.toValue =
        [NSNumber valueWithCATransform3D:CATransform3DConcat(layer.transform, transform)];
    
    [CATransaction begin];
    [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions];
    
    layer.transform = CATransform3DConcat(layer.transform, transform);
    
    [CATransaction commit];
    
    animation.duration = self.duration;
    animation.repeatCount = 1;
    animation.delegate = self;
    [layer addAnimation:animation forKey:@"ContinuableTransformAnimation"];
}
// アニメーション終了時に再度アニメーションを開始する
- (void)animationDidStop:(CAAnimation *)animation finished:(BOOL)flag
{
    if (flag) [self start];
}
- (void)dealloc
{
    self.layer = nil;
    
    [super dealloc];
}
@end

呼び出し側

    [[baseLayer sublayers] enumerateObjectsUsingBlock:
     ^(id obj, NSUInteger idx, BOOL *stop) {
         CALayer *layer = (CALayer *)obj;

         CGFloat angle = 1.0/3 * M_PI;
         [[[[ContinuableTransformAnimator alloc]
            initWithLayer:layer
            transform:CATransform3DMakeRotation(1.0/12 * M_PI, 0,  sin(angle), cos(angle))
            duration:2.0]
           autorelease] start];
         
     }];


動画でみる

感想

今回の方法で実際に3Dで何かしようと思ったら不便と思うことがいくつかある。


一つは、上に書いたように複数の立体的な座標系を階層化出来ないことで、例えばモデル単位で移動や回転をする為には、そのモデルを構成する全レイヤを動かさないといけない。


もう一つは、奥行きでクリッピング出来ない*3こと。特に手前側のクリッピングが出来ないと、ポリゴンが背後に行った時にスクリーンに超巨大に表示されるので、ポリゴンの中に入って行くとかができない。


まあ3Dをごりごり動かす為の物ではなくて、基本的には2D用だと思う。


アニメーションで面白かったのは、additiveとcumulativeで、これを設定することでいろんなものを累積的にアニメーションできる。additiveについてヘッダに

/* When true the value specified by the animation will be "added" to
 * the current presentation value of the property to produce the new
 * presentation value. The addition function is type-dependent, e.g.
 * for affine transforms the two matrices are concatenated. Defaults to
 * false. */

って書いてある通り、内部的にプロパティの型ごとに加算が定義されているらしい。ちょうど『7つの言語 7つの世界』を読んでいたらSimon PJが「時間とともに変化する値は関数型プログラムによって操作可能な一つの値とみなすことができるのだ」という考え*4について述べていたんだけど、多分そういうことを言っているんだと思う。関数型言語でアニメーションのライブラリを書くとしたら、AnimatableなプロパティはまずMonoidとして定義するとかそういう感じ。

*1:実のところ座標上は回転は時計回り。z軸の向きが奥から手前向きの為、回転方法が逆になる。

*2:いわゆる非可換

*3:方法が分からないだけでやり方はあるかも?

*4:『7つの言語 7つの世界』 Bruce A. Tate著 オーム社