工場裏のアーカイブス

素人によるiPhoneアプリ開発の学習記

クォータニオンによる視点回転操作

iPhone上で3Dグラフィックスを扱うのであれば、やはり画面タッチ操作により3Dモデルを自由に回転させたりしてみたいもの。画面がタッチされたことや、ドラッグの移動距離の検出自体は touchBegin/touchMoved/touchEnded メソッドを用いて簡単に実現出来ますが、それをモデルの回転とどう結びつけたら良いでしょうか。

単純なモデル回転方法と問題点

単純な発想としては、例えば垂直方向、水平方向へのドラッグ距離に応じた角度で、それぞれx軸、y軸回りにモデルを回転させるような方法があります。GLKitを用いるのであれば、以下のように GLKMatrix4Rotate() 関数などを用いれば実装は容易でしょう。

//angleX、angleYは角度を表す変数であり、touchMoved メソッドなどの中で
//タッチ移動距離に応じた角度を計算して代入してあるとする。
baseModelViewMatrix = GLKMatrix4Rotate(baseModelViewMatrix, angleX, 1.0f, 0.0f, 0.0f);
baseModelViewMatrix = GLKMatrix4Rotate(baseModelViewMatrix, angleY, 0.0f, 1.0f, 0.0f);

 
こんな方法でも一応は目的を実現出来ます。しかし、この方法だとモデルの向き次第では、例えば画面水平方向にタッチ移動したのにモデルが上下に回転したり、ドリルのようにz軸中心に回転したり、といったことが起こってしまい、あまり直感的なモデル回転が出来ません。

 

クォータニオンを用いたモデル回転

そこで「クォータニオン」という数学的概念を用いると、もっと直感的なモデル回転を実現することが可能です。クォータニオンは高校数学で習う複素数をさらに拡張したような概念です。複素数はその乗算(掛け算)によって平面における回転操作を表現することが出来ますが、クォータニオンでは3次元空間における任意の軸回りの回転操作を表現することが出来ます。

…とは言いながら、どうしてクォータニオンの演算により回転操作が実現出来るのかについての数式的な理解などはまだ出来ていません(爆)。しかし、ひとまず道具としてクォータニオンを使ってみることは可能です。ここでは床井研究室様*1サンプルソースを参考に、GLKitを用いてドラッグ操作により3Dモデルを回転する簡単なプログラムを作成してみました。

XCode(この記事執筆時点のバージョンは4.6)で「OpenGL Game」テンプレートを選択してプロジェクトを作成し、ViewController.m の中身を以下のソースコードに書き換えれば動作します(はずです)。

#import "ViewController.h"

#define BUFFER_OFFSET(i) ((char *)NULL + (i))
#define SCALE (2.0f * M_PI)

GLfloat gCubeVertexData[216] =
{
    0.5f, -0.5f, -0.5f,        1.0f, 0.0f, 0.0f,
    0.5f, 0.5f, -0.5f,         1.0f, 0.0f, 0.0f,
    0.5f, -0.5f, 0.5f,         1.0f, 0.0f, 0.0f,
    0.5f, -0.5f, 0.5f,         1.0f, 0.0f, 0.0f,
    0.5f, 0.5f, -0.5f,          1.0f, 0.0f, 0.0f,
    0.5f, 0.5f, 0.5f,         1.0f, 0.0f, 0.0f,
    
    0.5f, 0.5f, -0.5f,         0.0f, 1.0f, 0.0f,
    -0.5f, 0.5f, -0.5f,        0.0f, 1.0f, 0.0f,
    0.5f, 0.5f, 0.5f,          0.0f, 1.0f, 0.0f,
    0.5f, 0.5f, 0.5f,          0.0f, 1.0f, 0.0f,
    -0.5f, 0.5f, -0.5f,        0.0f, 1.0f, 0.0f,
    -0.5f, 0.5f, 0.5f,         0.0f, 1.0f, 0.0f,
    
    -0.5f, 0.5f, -0.5f,        -1.0f, 0.0f, 0.0f,
    -0.5f, -0.5f, -0.5f,       -1.0f, 0.0f, 0.0f,
    -0.5f, 0.5f, 0.5f,         -1.0f, 0.0f, 0.0f,
    -0.5f, 0.5f, 0.5f,         -1.0f, 0.0f, 0.0f,
    -0.5f, -0.5f, -0.5f,       -1.0f, 0.0f, 0.0f,
    -0.5f, -0.5f, 0.5f,        -1.0f, 0.0f, 0.0f,
    
    -0.5f, -0.5f, -0.5f,       0.0f, -1.0f, 0.0f,
    0.5f, -0.5f, -0.5f,        0.0f, -1.0f, 0.0f,
    -0.5f, -0.5f, 0.5f,        0.0f, -1.0f, 0.0f,
    -0.5f, -0.5f, 0.5f,        0.0f, -1.0f, 0.0f,
    0.5f, -0.5f, -0.5f,        0.0f, -1.0f, 0.0f,
    0.5f, -0.5f, 0.5f,         0.0f, -1.0f, 0.0f,
    
    0.5f, 0.5f, 0.5f,          0.0f, 0.0f, 1.0f,
    -0.5f, 0.5f, 0.5f,         0.0f, 0.0f, 1.0f,
    0.5f, -0.5f, 0.5f,         0.0f, 0.0f, 1.0f,
    0.5f, -0.5f, 0.5f,         0.0f, 0.0f, 1.0f,
    -0.5f, 0.5f, 0.5f,         0.0f, 0.0f, 1.0f,
    -0.5f, -0.5f, 0.5f,        0.0f, 0.0f, 1.0f,
    
    0.5f, -0.5f, -0.5f,        0.0f, 0.0f, -1.0f,
    -0.5f, -0.5f, -0.5f,       0.0f, 0.0f, -1.0f,
    0.5f, 0.5f, -0.5f,         0.0f, 0.0f, -1.0f,
    0.5f, 0.5f, -0.5f,         0.0f, 0.0f, -1.0f,
    -0.5f, -0.5f, -0.5f,       0.0f, 0.0f, -1.0f,
    -0.5f, 0.5f, -0.5f,        0.0f, 0.0f, -1.0f
};

@interface ViewController () {
    GLfloat _rotation;
    
    GLuint _vertexArray;
    GLuint _vertexBuffer;
    
    GLKMatrix4 modelViewMatrix1;
    GLKMatrix4 modelViewMatrix2;
    
    CGPoint _touchBegan;
    CGPoint _touchFinished;
    
    GLfloat eyeDist;
    
    //視点操作用のクオータニオンおよび行列
    GLKQuaternion cquat;
    GLKQuaternion tquat;
    GLKMatrix4 rotMat;
}
@property (strong, nonatomic) EAGLContext *context;
@property (strong, nonatomic) GLKBaseEffect *effect;

- (void)setupGL;
- (void)tearDownGL;

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    self.context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
    
    if (!self.context) {
        NSLog(@"Failed to create ES context");
    }
    
    GLKView *view = (GLKView *)self.view;
    view.context = self.context;
    view.drawableDepthFormat = GLKViewDrawableDepthFormat24;
    
    //クオータニオンおよび行列の初期化
    cquat = GLKQuaternionIdentity;
    tquat = GLKQuaternionIdentity;
    rotMat = GLKMatrix4MakeWithQuaternion(cquat);

    eyeDist = -10.0f;
    
    [self setupGL];
}

- (void)dealloc
{
    [self tearDownGL];
    
    if ([EAGLContext currentContext] == self.context) {
        [EAGLContext setCurrentContext:nil];
    }
}

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
    _touchBegan = [[touches anyObject] locationInView:self.view];
}

-(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event{
    //ドラッグの移動距離から、回転のクォータニオンを求め、更に行列に変換する。
    _touchFinished = [[touches anyObject] locationInView:self.view];
    GLfloat dx = (_touchFinished.x - _touchBegan.x) / self.view.bounds.size.width;
    GLfloat dy = (_touchFinished.y - _touchBegan.y) / self.view.bounds.size.height;
    
    GLfloat a = sqrtf(dx * dx + dy * dy);
    
    if(a != 0.0){
        GLfloat ar = a * SCALE * 0.5;
        GLfloat as = sinf(ar) / a;

        GLKQuaternion dquat = GLKQuaternionMake(dy * as, dx * as, 0.0f, cosf(ar));

        tquat = GLKQuaternionMultiply(dquat, cquat);
        rotMat = GLKMatrix4MakeWithQuaternion(tquat);
    }
}

-(void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event{
    cquat = tquat;
}

- (void)didReceiveMemoryWarning
{
    [super didReceiveMemoryWarning];
    
    if ([self isViewLoaded] && ([[self view] window] == nil)) {
        self.view = nil;
        
        [self tearDownGL];
        
        if ([EAGLContext currentContext] == self.context) {
            [EAGLContext setCurrentContext:nil];
        }
        self.context = nil;
    }
}

- (void)setupGL
{
    [EAGLContext setCurrentContext:self.context];
    
    self.effect = [[GLKBaseEffect alloc] init];
    self.effect.light0.enabled = GL_TRUE;
    self.effect.light0.diffuseColor = GLKVector4Make(1.0f, 0.4f, 0.4f, 1.0f);
    
    glEnable(GL_DEPTH_TEST);
    
    glGenVertexArraysOES(1, &_vertexArray);
    glBindVertexArrayOES(_vertexArray);
    
    glGenBuffers(1, &_vertexBuffer);
    glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer);
    glBufferData(GL_ARRAY_BUFFER, sizeof(gCubeVertexData), gCubeVertexData, GL_STATIC_DRAW);
    
    glEnableVertexAttribArray(GLKVertexAttribPosition);
    glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, sizeof(float)*6, BUFFER_OFFSET(0));
    glEnableVertexAttribArray(GLKVertexAttribNormal);
    glVertexAttribPointer(GLKVertexAttribNormal, 3, GL_FLOAT, GL_FALSE, sizeof(float)*6, BUFFER_OFFSET(12));

    glBindVertexArrayOES(0);
}

- (void)tearDownGL
{
    [EAGLContext setCurrentContext:self.context];
    
    glDeleteBuffers(1, &_vertexBuffer);
    glDeleteVertexArraysOES(1, &_vertexArray);
    
    self.effect = nil;
}

- (void)update
{
    float aspect = fabsf(self.view.bounds.size.width / self.view.bounds.size.height);
    GLKMatrix4 projectionMatrix = GLKMatrix4MakePerspective(GLKMathDegreesToRadians(65.0f), aspect, 0.1f, 300.0f);
    
    self.effect.transform.projectionMatrix = projectionMatrix;
    
    GLKMatrix4 baseModelViewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, eyeDist);
    //回転のクォータニオンから変換された行列を、モデルビュー行列に乗じる
    baseModelViewMatrix = GLKMatrix4Multiply(baseModelViewMatrix, rotMat);
    
    GLKMatrix4 modelViewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, -1.5f);
    modelViewMatrix = GLKMatrix4Rotate(modelViewMatrix, _rotation, 1.0f, 1.0f, 1.0f);
    modelViewMatrix = GLKMatrix4Multiply(baseModelViewMatrix, modelViewMatrix);
    
    modelViewMatrix1 = GLKMatrix4MakeTranslation(0.0f, 0.0f, -1.5f);
    modelViewMatrix1 = GLKMatrix4Rotate(modelViewMatrix1, _rotation, -1.0f, -1.0f, 1.0f);
    modelViewMatrix1 = GLKMatrix4Multiply(baseModelViewMatrix, modelViewMatrix1);
    
    modelViewMatrix2 = GLKMatrix4MakeTranslation(0.0f, 0.0f, 1.5f);
    modelViewMatrix2 = GLKMatrix4Rotate(modelViewMatrix2, _rotation, 1.0f, 1.0f, 1.0f);
    modelViewMatrix2 = GLKMatrix4Scale(modelViewMatrix2, 2.0f, 2.0f, 2.0f);
    modelViewMatrix2 = GLKMatrix4Multiply(baseModelViewMatrix, modelViewMatrix2);
    
    _rotation += self.timeSinceLastUpdate * 0.5f;
}

- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect
{
    glClearColor(0.65f, 0.65f, 0.65f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    glBindVertexArrayOES(_vertexArray);
    
    self.effect.light0.diffuseColor = GLKVector4Make(1.0f, 0.4f, 0.4f, 1.0f);
    self.effect.transform.modelviewMatrix = modelViewMatrix1;
    [self.effect prepareToDraw];
    glDrawArrays(GL_TRIANGLES, 0, 36);
    
    self.effect.light0.diffuseColor = GLKVector4Make(0.4f, 0.4f, 1.0f, 1.0f);
    self.effect.transform.modelviewMatrix = modelViewMatrix2;
    [self.effect prepareToDraw];
    glDrawArrays(GL_TRIANGLES, 0, 36);
}
@end

 
クォータニオン関連の操作については、ほとんどサンプルソースをGLKit向けに移植したようなモノとなっておりますが、GLKitにはあらかじめクォータニオンを扱うための構造体(GLKQuarternion)や関数群が用意されておりますので、それを活用しています。そしてtouchBegin/touchMoved/touchEnded メソッドを用い、この中でクォータニオン関連の操作を行っています。

実行すると例によって、下図のように2つの立方体が表示されますが、ドラッグ操作によってモデル全体(別の言い方をすれば視点)をグリグリ回転させることが可能です。常にドラッグした方向通りに、直感的に回転をしてくれます。

f:id:fleron:20130410000525p:plain


追記(2013/09/08)

ソースコードに一部誤りがあったので訂正しました。viewDidLoad の中のクオータニオンおよび行列を初期化する部分で、tquat を単位クオータニオンで初期化する1行が抜けていました。

    //クオータニオンおよび行列の初期化
    cquat = GLKQuaternionIdentity;
    tquat = GLKQuaternionIdentity;  //この1行が抜けていた
    rotMat = GLKMatrix4MakeWithQuaternion(cquat);

この行が抜けていると、回転の位置によってはモデルが一切表示されなくなる不具合が生じてしまうようです。これまで本ソースを参考にされた方には失礼致しました。

*1:和歌山大学システム工学部デザイン情報学科の研究室。公式ブログにOpenGL関連の解説やサンプルが多数掲載されており、非常に参考になります