アニメーション・三次元グラフィックス

目次

今回は自由課題前の最後の講義ということで,アニメーション,三次元グラフィックスの初歩について学びます.前回までで学んだ二次元のグラフィクスと異なり三次元のグラフィクスは球や四面体といった基本の図形を表示するにも,多くのことを設定する必要があり,大変複雑です.

今回の講義は,テンプレートのプログラムを改変して自由課題で用いることができるよう,三次元グラフィクスの基本事項に触れ流とともに,使い方に慣れることを目標とします.

GLUT によるアニメーション

これまでに学んだGLUTの使い方においては,描画関数 (これまでのプログラムでは display 関数) が呼び出されるタイミングは以下の二つに限定されていました.

もちろん,これで最低限,何かを表示することはできるのですが,自動的に表示内容が切り替わる,すなわちアニメーションの表示をすることはできません.アニメーションを実現するためには,定期的に描画関数が呼び出されなければならないからです.

GLUTでアニメーションを実現するため仕組み,すなわちユーザが何もしなくても定期的に描画関数を呼び出すための仕組みは二通り存在します.一つは計算の待ち時間を利用する glutIdleFunc を用いるもの,もう一つはあらかじめ決められたタイミングで再描画を促す glutTimerFunc を用いるものです.

この両者はどちらも glutDisplayFunc などと同様に,決まった条件下で呼び出されるコールバック関数を登録します. glutIdleFunc で登録されたコールバック関数は,計算負荷が一定以下になっていて,特に処理するイベントがない時に呼び出されます.一方 glutTimerFunc で登録されたコールバック関数は,イベントがあるかどうかはともかくとして,あらかじめ決められた時間後1度だけ呼び出されます.

それでは glutIdleFuncglutTimerFunc の違いを理解するために,それぞれを利用したプログラムを見てみましょう.

glutIdleFuncを用いたプログラム

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#ifndef __APPLE__  // OSがMacでない (= WindowsやLinux)
#include <GL/glut.h>  // Windows, Linuxの場合のヘッダ
#else
#include <GLUT/glut.h>  // Macの場合のヘッダ
#endif

#include <math.h>

int WINDOW_WIDTH = 500;   // ウィンドウの横幅
int WINDOW_HEIGHT = 500;  // ウィンドウの高さ

double TIME = 0.0;
const double RECT_SIZE = 300.0;

void display() {
    // ウィンドウ表示内容のクリア
    glClear(GL_COLOR_BUFFER_BIT);

    // 正方形の4つ角の位置を計算
    double scale = (cos(TIME / 18.0) + 1.0) * 0.5;
    double x0 = WINDOW_WIDTH / 2 - RECT_SIZE / 2 * scale;
    double y0 = WINDOW_HEIGHT / 2 - RECT_SIZE / 2 * scale;
    double x1 = WINDOW_WIDTH / 2 + RECT_SIZE / 2 * scale;
    double y1 = WINDOW_HEIGHT / 2 + RECT_SIZE / 2 * scale;
    
    // 正方形の描画
    glRectd(x0, y0, x1, y1);
    glFlush();
}

void reshape(int width, int height) {
    // OpenGLウィンドウの描画範囲を設定
    // 下記は描画範囲が[0, width] x [0, height]となるように設定している
    glViewport(0, 0, width, height);
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    gluOrtho2D(0.0, (double)width, 0.0, (double)height);
    WINDOW_WIDTH = width;
    WINDOW_HEIGHT = height;
}

void idle() {
    // アニメーションに使う時間を更新
    TIME += 1.0;

    // 再描画
    glutPostRedisplay();
}

int main(int argc, char **argv) {
    // GLUTの初期化
    glutInit(&argc, argv);
    // ウィンドウのサイズを設定
    glutInitWindowSize(WINDOW_WIDTH, WINDOW_HEIGHT);
    // ウィンドウの作成 (引数はウィンドウのタイトル)
    glutCreateWindow("GLUT: Idle");
    // 描画に使う関数の登録
    glutDisplayFunc(display);
    // ウィンドウのサイズ変更時に呼ばれる関数の登録
    glutReshapeFunc(reshape);
    // 計算待ち時間を利用する関数の登録
    glutIdleFunc(idle);
    // 描画ループの開始
    glutMainLoop();
}

glutTimerFuncを用いたプログラム

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#ifndef __APPLE__  // OSがMacでない (= WindowsやLinux)
#include <GL/glut.h>  // Windows, Linuxの場合のヘッダ
#else
#include <GLUT/glut.h>  // Macの場合のヘッダ
#endif

#include <math.h>

int WINDOW_WIDTH = 500;   // ウィンドウの横幅
int WINDOW_HEIGHT = 500;  // ウィンドウの高さ

double TIME = 0.0;
const double RECT_SIZE = 300.0;

void display() {
    // ウィンドウ表示内容のクリア
    glClear(GL_COLOR_BUFFER_BIT);

    // 正方形の4つ角の位置を計算
    double scale = (cos(TIME / 18.0) + 1.0) * 0.5;
    double x0 = WINDOW_WIDTH / 2 - RECT_SIZE / 2 * scale;
    double y0 = WINDOW_HEIGHT / 2 - RECT_SIZE / 2 * scale;
    double x1 = WINDOW_WIDTH / 2 + RECT_SIZE / 2 * scale;
    double y1 = WINDOW_HEIGHT / 2 + RECT_SIZE / 2 * scale;
    
    // 正方形の描画
    glRectd(x0, y0, x1, y1);
    glFlush();
}

void reshape(int width, int height) {
    // OpenGLウィンドウの描画範囲を設定
    // 下記は描画範囲が[0, width] x [0, height]となるように設定している
    glViewport(0, 0, width, height);
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    gluOrtho2D(0.0, (double)width, 0.0, (double)height);
    WINDOW_WIDTH = width;
    WINDOW_HEIGHT = height;
}

void timer(int value) {
    // アニメーションに使う時間を更新
    TIME += 1.0;

    // 再描画
    glutPostRedisplay();

    // 100ミリ秒後にtimer関数を引数0で自分自身を呼び出す
    glutTimerFunc(100, timer, 0);
}

int main(int argc, char **argv) {
    // GLUTの初期化
    glutInit(&argc, argv);
    // ウィンドウのサイズを設定
    glutInitWindowSize(WINDOW_WIDTH, WINDOW_HEIGHT);
    // ウィンドウの作成 (引数はウィンドウのタイトル)
    glutCreateWindow("GLUT: Timer");
    // 描画に使う関数の登録
    glutDisplayFunc(display);
    // ウィンドウのサイズ変更時に呼ばれる関数の登録
    glutReshapeFunc(reshape);
    // 100ミリ秒後にtimer関数を引数0で呼び出す
    glutTimerFunc(100, timer, 0);
    // 描画ループの開始
    glutMainLoop();
}


実行結果 (glutTimerFunc を使ったプログラム)

この二つのプログラムはいずれも,四角形の大きさをコールバック関数 idle あるは timer が呼び出されるために変更して,周期的にサイズが変わるようにしたものです.まずは,それぞれのプログラムをコンパイルして実行し,その違いを確かめてみましょう.

おそらく,皆さんが多くお使いのコンピュータであれば idle を使ったものの方が少し早く四角形のサイズが変化するのではないかと思います (環境によるものなので timer の方が早くても問題はありません).これは glutIdleFunc により登録された idle 関数は計算に余裕がある時にすぐに呼び出されるためで,おそらく皆さんが上記のプログラムを実行している時には,他にそれを妨げるような動作の重いプログラムは実行していないでしょうから,idle関数は処理の限界に近い速度で呼び出されていることになります.

一方でglutTimerFuncは,その第一引数に与えらた時間 (単位はミリ秒) だけ後に,第2引数に与えられた timer 関数を呼び出されることを予約しています.すると,呼び出された timer 関数の中で再び100ミリ秒後に自分自身が呼ばれるように再予約がなされ,結果として,timer関数が100ミリ秒に1回呼び出されるようになります.なおglutTimerFunc の第3引数に与えられる値は,そのまま timer 関数の引数として渡されるので,これを処理内容の分岐に使うこともできます(特に決まった使い方があるわけではない).

さて,結局,どちらを使えばいいのでしょうか?これに関しては,時と場合による部分もありますが glutIdleFunc でできることは,ほとんどglutTimerFuncを使えばできてしまう (第1引数に0を入れればいい) ので,どちらか一方だけを覚えるのならば glutTimerFunc を使うのが良いでしょう.

コンピュータ・アニメーションの観点に立つならば,やはり実行環境に依存せずに決まった時間間隔で描画を行ってくれるglutTimerFuncの方が使い勝手が良いと言えます. glutIdleFunc の場合には速いコンピュータを使っていると速く,遅いコンピュータを使っていると遅く描画されてしまうので,表示のスピードが計算環境に依存してしまうためです.

ダブルバッファ

現在のプログラムは,画面に映されている内容が変更される際に,その内容が表示されたまま,走査線上に画面の内容を更新していきます.画面に表示される内容を保持しているメモリ領域のことをバッファといい,この場合は,実際の表示内容が直接単一のバッファと結びついているためシングルバッファと呼ばれます.

現在は,表示している内容がとても単純であるため,シングルバッファで問題になることはありませんが,より描画内容が複雑,より具体的には1画素1画素の描画にかかる計算不可が大きくなると,画面にちらつきが起こる可能性があります (ただし,現在のコンピュータは十分に速いため,この講義の範囲内でそれを感じることは少ないと思います).

これを防ぐために,アニメーションを表示する際には,ダブルバッファを用いるのが普通です.シングルバッファとダブルバッファの違いについては,以下の図を見てください (クリックすると画像が大きくなります)

上段に示されたシングルバッファの例では「表示」と書かれた,描画の途中において,中途半端に描画がなされた状態のものも表示されてしまい,それがちらつきの原因になります.一方でダブルバッファの場合には,バッファがフロントバッファとバックバッファの二つ用意されていて,新しい内容の描画は(初期設定では)バックバッファに行われます(図では後ろ側にある画像がフロントバッファに対応).

この間,画面には特に変化が起こらないフロントバッファを表示しておき,バックバッファの描画が終了したら,フロントバッファとバックバッファを入れ替えます.こうすることで,描画の途中の中途半端な状態を画面に映すことなく新しい内容を描画していくことができるのです.

ダブルバッファを有効化すること自体は難しくなく glutCreateWindow を呼び出す前に下記のようにディスプレイのモード設定を行います.

glutInitDisplayMode(GLUT_DOUBLE)

実際に描画内容を表示する時には glFlush の代わりにフロントバッファとバックバッファの内容を入れ替える処理である

glutSwapBuffers()

を用います.

さきほどのプログラムをダブルバッファを用いて書き換えたのが次のプログラムです.おそらく表示内容を見ただけでは,何も変化が内容に見えると思いますが,これで正しくダブルバッファが使えるようになっています.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#ifndef __APPLE__  // OSがMacでない (= WindowsやLinux)
#include <GL/glut.h>  // Windows, Linuxの場合のヘッダ
#else
#include <GLUT/glut.h>  // Macの場合のヘッダ
#endif

#include <math.h>

int WINDOW_WIDTH = 500;   // ウィンドウの横幅
int WINDOW_HEIGHT = 500;  // ウィンドウの高さ

double TIME = 0.0;
const double RECT_SIZE = 300.0;

void display() {
    // ウィンドウ表示内容のクリア
    glClear(GL_COLOR_BUFFER_BIT);

    // 正方形の4つ角の位置を計算
    double scale = (cos(TIME / 18.0) + 1.0) * 0.5;
    double x0 = WINDOW_WIDTH / 2 - RECT_SIZE / 2 * scale;
    double y0 = WINDOW_HEIGHT / 2 - RECT_SIZE / 2 * scale;
    double x1 = WINDOW_WIDTH / 2 + RECT_SIZE / 2 * scale;
    double y1 = WINDOW_HEIGHT / 2 + RECT_SIZE / 2 * scale;
    
    // 正方形の描画
    glRectd(x0, y0, x1, y1);
    
    // バッファの入れ替え
    glutSwapBuffers();
}

void reshape(int width, int height) {
    // OpenGLウィンドウの描画範囲を設定
    // 下記は描画範囲が[0, width] x [0, height]となるように設定している
    glViewport(0, 0, width, height);
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    gluOrtho2D(0.0, (double)width, 0.0, (double)height);
    WINDOW_WIDTH = width;
    WINDOW_HEIGHT = height;
}

void timer(int value) {
    // アニメーションに使う時間を更新
    TIME += 1.0;

    // 再描画
    glutPostRedisplay();

    // 100ミリ秒後にtimer関数を引数0で自分自身を呼び出す
    glutTimerFunc(10, timer, 0);
}

int main(int argc, char **argv) {
    // GLUTの初期化
    glutInit(&argc, argv);
    // GLUTの機能の有効化
    glutInitDisplayMode(GLUT_DOUBLE);
    // ウィンドウのサイズを設定
    glutInitWindowSize(WINDOW_WIDTH, WINDOW_HEIGHT);
    // ウィンドウの作成 (引数はウィンドウのタイトル)
    glutCreateWindow("GLUT: Timer");
    // 描画に使う関数の登録
    glutDisplayFunc(display);
    // ウィンドウのサイズ変更時に呼ばれる関数の登録
    glutReshapeFunc(reshape);
    // 100ミリ秒後にtimer関数を引数0で呼び出す
    glutTimerFunc(10, timer, 0);
    // 描画ループの開始
    glutMainLoop();
}

マウスドラッグ

次はマウスドラッグを扱ってみます.マウスドラッグ操作 (マウスのボタンを押しながらマウスを動かすこと) を実現するためには,マウスの位置が変化するごとに何らかの処理を行う必要がありますが,既に学んだ glutMouseFunc では,ボタンの上げ下げ(GLUT_DOWNGLUT_UP)しか検出することができません.

マウスの動いた時にイベントを受け取るには glutMouseFunc とは別に glutMotionFunc を用いて,専用のコールバック関数を登録する必要があります.これで登録されるべきコールバック関数のプロトタイプ宣言は以下のようになります.

void motion(int x, int y);

これを見てわかる通り,関数の引数には現在のマウスの位置を表す x ならびに y のみが渡されていて,どのボタンが押されたか,といった情報はありません.そのため,マウスドラッグを実現するためにはglutMouseFuncで登録されたマウスボタンのイベント処理と,glutMotionFuncで登録されたマウスの動きに関するイベント処理を組み合わせなくてはなりません.

以下のサンプルプログラムでは,マウスのどのボタンが押されているのかを記憶しておくために PRESS_BUTTON というグローバル変数を定義しています.motion 関数はマウスのボタンが押されていなくても,マウスが動いてさえいれば呼び出されるため motion関数の中で PRESS_BUTTON に有効なボタン (GLUT_LEFT_BUTTONGLUT_RIGHT_BUTTON) が記憶されているかどうかを見て,実際の処理を行うかを決めています.また,マウスが動いた時の処理が頻繁に呼び出されないように,マウスの移動距離が一定以上の場合にのみ,目的の処理 (下の例では四角形のサイズ変更) が行われるようにしています.

本来,マウスのドラッグだけを扱うのであれば,マウスが動いた時に glutPostRedisplay 関数を呼び出せば良いのですが,以下の例ではドラッグの処理とは別に timer 関数により一定間隔で描画が行われるようにしており,マウスドラッグのたびに glutPostRedisplay関数を呼び出さなくても良いようにしています.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
#ifndef __APPLE__  // OSがMacでない (= WindowsやLinux)
#include <GL/glut.h>  // Windows, Linuxの場合のヘッダ
#else
#include <GLUT/glut.h>  // Macの場合のヘッダ
#endif

#include <stdio.h>
#include <math.h>

int WINDOW_WIDTH = 500;   // ウィンドウの横幅
int WINDOW_HEIGHT = 500;  // ウィンドウの高さ

double TIME = 0.0;
const double RECT_SIZE = 300.0;

int PREV_X = -1;
int PREV_Y = -1;
int PRESS_BUTTON = -1;

void display() {
    // ウィンドウ表示内容のクリア
    glClear(GL_COLOR_BUFFER_BIT);

    // 正方形の4つ角の位置を計算
    double scale = (cos(TIME / 18.0) + 1.0) * 0.5;
    double x0 = WINDOW_WIDTH / 2 - RECT_SIZE / 2 * scale;
    double y0 = WINDOW_HEIGHT / 2 - RECT_SIZE / 2 * scale;
    double x1 = WINDOW_WIDTH / 2 + RECT_SIZE / 2 * scale;
    double y1 = WINDOW_HEIGHT / 2 + RECT_SIZE / 2 * scale;
    
    // 正方形の描画
    glRectd(x0, y0, x1, y1);
    
    // バッファの入れ替え
    glutSwapBuffers();
}

void reshape(int width, int height) {
    // OpenGLウィンドウの描画範囲を設定
    // 下記は描画範囲が[0, width] x [0, height]となるように設定している
    glViewport(0, 0, width, height);
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    gluOrtho2D(0.0, (double)width, 0.0, (double)height);
    WINDOW_WIDTH = width;
    WINDOW_HEIGHT = height;
}

void timer(int value) {
    // 再描画
    glutPostRedisplay();

    // 100ミリ秒後にtimer関数を引数0で自分自身を呼び出す
    glutTimerFunc(10, timer, 0);
}

void mouse(int button, int state, int x, int y) {
    // ボタンが押されたら押されたボタンを記憶しておく
    if (state == GLUT_DOWN) {
        PRESS_BUTTON = button;
        PREV_X = x;
        PREV_Y = y;
    }

    // ボタンが離されたらボタンの記憶をクリアする
    if (state == GLUT_UP) {
        PRESS_BUTTON = -1;
        PREV_X = -1;
        PREV_Y = -1;
    }
}

void motion(int x, int y) {
    // あまり頻繁に描画内容が更新されないように前の位置からの
    // 距離が一定以上になっているかを判定する
    int dx = PREV_X - x;
    int dy = PREV_Y - y;
    if (dx * dx + dy * dy > 25.0) {
        if (PRESS_BUTTON == GLUT_LEFT_BUTTON) {
            TIME += 36.0 * dy / WINDOW_HEIGHT;
            PREV_X = x;
            PREV_Y = y;
        }
    }
}

int main(int argc, char **argv) {
    // GLUTの初期化
    glutInit(&argc, argv);
    // GLUTの機能の有効化
    glutInitDisplayMode(GLUT_DOUBLE);
    // ウィンドウのサイズを設定
    glutInitWindowSize(WINDOW_WIDTH, WINDOW_HEIGHT);
    // ウィンドウの作成 (引数はウィンドウのタイトル)
    glutCreateWindow("GLUT: Timer");
    // 描画に使う関数の登録
    glutDisplayFunc(display);
    // ウィンドウのサイズ変更時に呼ばれる関数の登録
    glutReshapeFunc(reshape);
    // 100ミリ秒後にtimer関数を引数0で呼び出す
    glutTimerFunc(10, timer, 0);
    // マウス関係のコールバック関数
    glutMouseFunc(mouse);
    glutMotionFunc(motion);
    // 描画ループの開始
    glutMainLoop();
}


実行結果

課題1 (配点: 2点)

次のようなプログラムを作ってください.

実行結果の例

キーボードの使用

マウスと同様にGLUTではキーボードからの入力を受け付けることも可能です.キーボード入力を処理するコールバック関数の登録はglutKeyboardFuncで行うことができ,登録されるコールバック関数は以下のようなプロトタイプ宣言に対応します.

void keyboard(unsigned char key, int x, int y);

関数の引数の中で key は押されたキーのアスキーコード (詳細は後述),x ならびに y はキーが押された時にマウスの位置を表します.

key に格納されている値は unsigned char 型になっていますが,unsigned char 型というのは実は 0から255 の整数を表しています (1バイト=8ビットで表せる符号なし整数).この256個の整数のうち 0-127の数字にはアスキーコードと呼ばれる文字が割り当てられていて,例えば小文字のa なら97,大文字のA なら65が割り当てられています.

key にはアルファベットのキー(例えばAなど)が押された時には,その小文字に対応するアスキーコード(Aならばaに対応する97)が入っており,数字の場合には単純にそれに対応するアスキーコード (1ならば49)が格納されています.ただ,この数字を覚える必要はなく,C言語(およびその他の多くの言語)ではシングルクオーテーションをつけて 'a'と書くと97を表し,'1'と書くと49を表します.従って,

if (key == 'a') {
    // Aのキーが押された時の処理
} else if (key == '1') {
    // 1のキーが押された時の処理
}

のように条件分岐を書くことでキーボードからの入力を処理できます.ちなみにShiftCtrlなどのキーは特殊キーと呼ばれ glutKeyboardFunc で受け取ることはできません.これらのキーの入力を調べたい場合には glutSpecialFunc を使う必要がありますが,ここでは使い方の説明は省略します.

課題2 (配点: 2点)

課題1の動く点のプログラムを改良し,点の色をキーボードで制御できようにせよ.今回はR (つまりrのアスキーコードが得られる,他のキーも同じ) が押されたら点の色を赤に,Gが押されたら点の色を緑に,Bが押されたら点の色を青にするようにせよ.またESC (アスキーコードは27,あるいは'\e') が押された時には exit(1) (stdlib.hが必要) を呼び出して,プログラムを終了するようにせよ.マウスの右クリックには点の色を変える代わりに,点を一旦消去する機能を割り当てよ.

実行結果の例

三次元グラフィクス

三次元グラフィクスは,二次元グラフィクスと比べると大幅に複雑です.この複雑さはカメラである風景の写真を撮ることを想像すると分かりやすいです.もちろん,実際に写真を撮る時にこれらを意識することはありませんが,写真を撮るという行為をコンピュータに模倣させようとすれば,

などの事項が必要になってきます.

残念ながら,これらの多くの事項を1回の講義で学ぶことはやや難しいです(が,基本事項なら1日もあれば理解できると思います).そこで,この講義の中では,上記の諸々の設定が行われたサンプルプログラムを改変しつつ,少なくとも三次元グラフィックスを用いた可視化のプログラムが作成できるようになることを目指します.

以下のサンプルプログラムでは,照明の設定,視点の設定等が既に設定されていますので,後は,以降の説明に従い,描画すべきオブジェクトを設定するだけで三次元グラフィクスが表示できます.ソースコードはこちらからダウンロードすることもできます.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
#ifndef __APPLE__  // OSがMacでない (= WindowsやLinux)
#include <GL/glut.h>  // Windows, Linuxの場合のヘッダ
#else
#include <GLUT/glut.h>  // Macの場合のヘッダ
#endif

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

#define _USE_MATH_DEFINES
#include <math.h>

#define min(a, b) (((a) < (b)) ? (a) : (b))
#define max(a, b) (((a) > (b)) ? (a) : (b))
#define swap(a, b) do { \
    double t = (a);     \
    a = b;              \
    b = t;              \
} while( 0 );

int WINDOW_WIDTH = 500;     // ウィンドウの横幅
int WINDOW_HEIGHT = 500;    // ウィンドウの高さ

// 変形に使う変数
double TIME = 0.0;
double SCALE = 1.0;

// マウスの状態を表す変数
int MOUSE_BUTTON = -1;
int PREV_X = -1;
int PREV_Y = -1;

// カメラの位置
static const float CAMERA_POS[3] = { 3.0f, 4.0f, 5.0f };

// OpenGLの初期化関数
void init() {
    glClearColor(0.0, 0.0, 0.0, 1.0);
    glEnable(GL_DEPTH_TEST);

    // ライティング機能の有効化
    glEnable(GL_LIGHTING);
    glEnable(GL_LIGHT0);

    // 法線の長さが常に1になるようにする
    // glScalefを使う時に特に必要になる
    glEnable(GL_NORMALIZE);

    // シェーディングのやり方
    // GL_FLAT: フラットシェーディング
    // GL_SMOOTH: スムースシェーディング
    glShadeModel(GL_SMOOTH);

    // マテリアルの初期値を設定
    float ambient[4] = { 1.0f, 1.0f, 1.0f, 1.0f };
    glMaterialfv(GL_FRONT, GL_AMBIENT, ambient);
    float diffuse[4] = { 1.0f, 1.0f, 1.0f, 1.0f };
    glMaterialfv(GL_FRONT, GL_DIFFUSE, diffuse);
    float specular[4] = { 1.0f, 1.0f, 1.0f, 1.0f };
    glMaterialfv(GL_FRONT, GL_SPECULAR, specular);
    float shininess = 32.0f;
    glMaterialfv(GL_FRONT, GL_SHININESS, &shininess);

    // --- 自分独自の初期化を追加する場合はこの下に記述する ---

    // --- 自分独自の初期化を追加する場合はこの上に記述する ---
}

// 描画関数
void display() {
    // 描画内容のクリア
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    // マウスによる変形の設定
    glPushMatrix();
    glScaled(SCALE, SCALE, SCALE);

    // --- この下を変更すると良い ----------------------------

    glutSolidTeapot(1.0);

    // --- この上を変更すると良い ----------------------------

    // マウスによる変形の破棄
    glPopMatrix();

    // 描画命令
    glutSwapBuffers();
}

// ウィンドウサイズ変更時の処理関数
void reshape(int width, int height) {
    // ビューポートの設定
    glViewport(0, 0, width, height);
    WINDOW_WIDTH = width;
    WINDOW_HEIGHT = height;

    // 投影変換行列の設定
    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    const double aspect = (double)WINDOW_WIDTH / (double)WINDOW_HEIGHT;
    gluPerspective(45.0, aspect, 0.1, 100.0);

    // モデルビュー行列の設定
    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();
    gluLookAt(CAMERA_POS[0], CAMERA_POS[1], CAMERA_POS[2],   // カメラの位置
              0.0, 0.0, 0.0,   // カメラが見ている位置の中心
              0.0, 1.0, 0.0);  // カメラの上方向

    // ライトの位置の設定
    // 好みがなければカメラの位置と同じにすると良い
    float light_position[4] = { CAMERA_POS[0], CAMERA_POS[1], CAMERA_POS[2], 1.0f };
    glLightfv(GL_LIGHT0, GL_POSITION, light_position);
}

// マウスが押された時の処理
void mouse(int button, int state, int x, int y) {
    if (state == GLUT_DOWN) {
        MOUSE_BUTTON = button;
        PREV_X = x;
        PREV_Y = y;
    }

    if (state == GLUT_UP) {
        MOUSE_BUTTON = -1;
        PREV_X = -1;
        PREV_Y = -1;
    }
}

// 線形問題を解く (マウスの処理の補助)
// matrixライブラリをリンクしなくて良いようにしている
bool linsolve(double *A, double *b, int rows, int cols) {
    static const double epsilon = 1.0e-12;

    // ガウスの消去法
    int pivot = 0;
    for (int i = 0; i < rows - 1; i++) {
        // ピボット選択
        pivot = i;
        for (int j = i + 1; j < rows; j++) {
            if (fabs(A[pivot * cols + i]) < fabs(A[j * cols + i])) {
                pivot = j;
            }
        }

        // 行の入れ替え
        for (int j = i; j < cols; j++) {
            swap(A[i * cols + j], A[pivot * cols + j]);
        }
        swap(b[i], b[pivot]);

        // それでもピボットが0ならば特異行列
        if (fabs(A[i * cols + i]) < epsilon) {
            fprintf(stderr, "[ ERROR ] matrix is singular!\n");
            return false;
        }

        // ピボットより下の行を消去する
        for (int j = i + 1; j < rows; j++) {
            const double ratio = A[j * cols + i] / A[i * cols + i];
            for (int k = i; k < cols; k++) {
                A[j * cols + k] -= ratio * A[i * cols + k];
            }
            b[j] -= ratio * b[i];
        }
    }

    // 後退代入
    for (int i = rows - 1; i >= 0; i--) {
        for (int j = i + 1; j < rows; j++) {
            b[i] -= A[i * cols + j] *b[j];
        }
        b[i] /= A[i * cols + i];
    }

    return true;    
}

// マウスが動いた時の処理
// 回転の処理の内容に興味がある人は
// アークボール操作について調べると良い
// https://en.wikibooks.org/wiki/OpenGL_Programming/Modern_OpenGL_Tutorial_Arcball
void motion(int x, int y) {
    int dx = x - PREV_X;
    int dy = y - PREV_Y;
    if (dx * dx + dy * dy >= 25.0) {
        if (MOUSE_BUTTON == GLUT_LEFT_BUTTON) {
            double x0 = (double)PREV_X / (double)WINDOW_WIDTH * 2.0 - 1.0;
            double y0 = -1.0 * ((double)PREV_Y / (double)WINDOW_HEIGHT * 2.0 - 1.0);
            double z0 = sqrt(1.0 - min(x0 * x0 + y0 * y0, 1.0));
            double x1 = (double)x / (double)WINDOW_WIDTH * 2.0 - 1.0;
            double y1 = -1.0 * ((double)y / (double)WINDOW_HEIGHT * 2.0 - 1.0);
            double z1 = sqrt(1.0 - min(x1 * x1 + y1 * y1, 1.0));

            double axisX = y0 * z1 - z0 * y1; 
            double axisY = z0 * x1 - x0 * z1; 
            double axisZ = x0 * y1 - y0 * x1;
            double norm = axisX * axisX + axisY * axisY + axisZ * axisZ;
            if (norm > 0.0) {
                norm = sqrt(norm);
                axisX /= norm;
                axisY /= norm;
                axisZ /= norm;
                double angle = 180.0 * acos(max(-1.0, min(x0 * x1 + y0 * y1 + z0 * z1, 1.0))) / M_PI;

                double mv[16];
                glGetDoublev(GL_MODELVIEW_MATRIX, mv);

                double A[9];
                for (int i = 0; i < 3; i++) {
                    for (int j = 0; j < 3; j++) {
                        A[j * 3 + i] = mv[i * 4 + j];
                    }
                }
                double b[3] = { axisX, axisY, axisZ };

                if (linsolve(A, b, 3, 3)) {
                    glMatrixMode(GL_MODELVIEW);
                    glRotated(angle, b[0], b[1], b[2]);
                }
            }
        }

        if (MOUSE_BUTTON == GLUT_RIGHT_BUTTON) {
            SCALE -= 10.0 * dy / WINDOW_HEIGHT;
            SCALE = max(0.1, min(SCALE, 10.0));
        }

        PREV_X = x;
        PREV_Y = y;
    }
}

// キーボードが押された時の処理
void keyboard(unsigned char key, int x, int y) {
    switch (key) {
        case '\e':  // Escキー
            exit(1);
            break;

        default:
            break;
    }
}

// アニメーションの処理
void timer(int value) {
    TIME += 1.0;
    glutPostRedisplay();
    glutTimerFunc(30, timer, 0);
}

// メイン関数
int main(int argc, char **argv) {
    glutInit(&argc, argv);
    glutInitDisplayMode(GLUT_RGBA | GLUT_DOUBLE | GLUT_DEPTH);
    glutInitWindowSize(WINDOW_WIDTH, WINDOW_HEIGHT);
    glutCreateWindow("GLUT 3D graphics");

    glutDisplayFunc(display);
    glutReshapeFunc(reshape);
    glutMouseFunc(mouse);
    glutMotionFunc(motion);
    glutKeyboardFunc(keyboard);
    glutTimerFunc(30, timer, 0);

    init();

    glutMainLoop();
}


実行結果

三次元オブジェクトの描画

ここまでの描画処理では glVertex2d という関数を使って二次元座標で頂点の位置を指定してきましたが,glVertex3d という関数を使うと,頂点を三次元座標で指定することができます.ちなみに glVertex2d を使った場合は内部的にはz座標に自動的に0が代入されています.

三次元物体をコンピュータを用いて表現する場合,有限の多角形の集合により物体表面を表すことが一般的です.例えば,球を表示したい場合にも,球体そのものを表示するのではなく,球を多面体を用いて近似することになります(球のような関数で表せる曲面なら表示法も無いわけではない).通常は多角形の中でも扱いやすい三角形の集合として物体表面を表します.

では,例として,四面体を表示してみましょう.まずは,座標値だけを指定して四面体を描いてみます.上記のサンプルプログラムで glutSolidTeapot(1.0) でティーポットを描画している部分を以下のコードで置き換えましょう (上下のコードは省略されていることに注意)

void display() {
    ...

    // --- この下を変更すると良い ----------------------------

    glBegin(GL_TRIANGLES);

    // 四面体のサイズ
    double SIZE = 1.0;
    
    // 1つ目
    glVertex3d(0, SIZE, 0);
    glVertex3d(-SIZE, -SIZE/2.0, 0);
    glVertex3d(SIZE, -SIZE/2.0, 0);
    // 2つ目
    glVertex3d(0, 0, SIZE);
    glVertex3d(-SIZE, -SIZE/2.0, 0);
    glVertex3d(SIZE, -SIZE/2.0, 0);
    // 3つ目
    glVertex3d(0, SIZE, 0);
    glVertex3d(0, 0, SIZE);
    glVertex3d(SIZE, -SIZE/2.0, 0);
    // 4つ目
    glVertex3d(0, SIZE, 0);
    glVertex3d(-SIZE, -SIZE/2.0, 0);
    glVertex3d(0, 0, SIZE);

    glEnd();

    // --- この上を変更すると良い ----------------------------

    ...
}

実行結果

いかがでしょうか?何やら灰色の薄暗い四角形や三角形のようなものが表示されますが,三次元の物体というにはあまり立体感がありません.実は,人間は輪郭としての物体の形だけでなく,その物体にかかる陰影の効果 (と表面の模様) によって三次元物体の形を認識しています.

物体にかかる陰影は,物理的には,光がある位置とその光が当たる面の向きの関係性によって決まります.面の向きというのは,面の法線 (平面であれば面に垂直な方向のこと) によって表されます.以下では,四面体の各面に法線を設定してみます.

法線の設定

法線の設定 頂点位置や色などと似た glNormal3d という関数で行うことができます. glNormal3d で一度法線が設定されると glVertex3d で頂点の位置が指定されるごとに,その頂点の法線がglNormal3d指定した法線になります.そのため,法線の設定は頂点ごとに行うことができますが,以下では簡単のために各面を構成する3点に同じ法線を与えます.

それでは,さっそく先ほどのプログラムを書き直してみましょう.

void display() {
    ...

    // --- この下を変更すると良い ----------------------------

    glBegin(GL_TRIANGLES);

    // 四面体のサイズ
    double SIZE = 1.0;

    // 1つ目
    glNormal3d(0.0, 0.0, -1.0);
    glVertex3d(0, SIZE, 0);
    glVertex3d(-SIZE, -SIZE/2.0, 0);
    glVertex3d(SIZE, -SIZE/2.0, 0);
    // 2つ目
    glNormal3d(0.0, -2.0, 1.0);
    glVertex3d(0, 0, SIZE);
    glVertex3d(-SIZE, -SIZE/2.0, 0);
    glVertex3d(SIZE, -SIZE/2.0, 0);
    // 3つ目
    glNormal3d(1.5, 1.0, 1.0);
    glVertex3d(0, SIZE, 0);
    glVertex3d(0, 0, SIZE);
    glVertex3d(SIZE, -SIZE/2.0, 0);
    // 4つ目
    glNormal3d(-1.5, 1.0, 1.0);
    glVertex3d(0, SIZE, 0);
    glVertex3d(-SIZE, -SIZE/2.0, 0);
    glVertex3d(0, 0, SIZE);

    glEnd();

    // --- この上を変更すると良い ----------------------------

    ...
}

実行結果

いかがでしょうか?先ほどと比べてだいぶ四面体に立体感が出たのではないでしょうか?ちなみに法線は通常長さが1のベクトルですが,今回は init 関数の中で指定した glEnable(GL_NORMALIZE) の効果によって,各法線の長さは陰影を計算する時には1に正規化されるようになっています.

材質の設定

OpenGLにおける材質の色は環境光 (アンビエント, ambient),拡散反射光 (ディフューズ, diffuse),ならびに光沢反射光 (スペキュラ, specular) の三つの成分で表されます.このそれぞれの成分は,

を表します.それぞれの成分の強さは glMaterialfv という関数で設定することが可能で一般的な使い方は,

float color[4] = { red, blue, green, 1.0f };
glMaterialfv(GL_FRONT, GL_XXXXXX, color);

のような形です.

第1引数はどちら向きの面に対して陰影付けを行うかを表すもので通常 GL_FRONT を指定します.第二引数は GL_AMBIENT, GL_DIFFUSE, GL_SPECULAR といったどの成分を変更するかを指定します.最後の color は長さが4のfloatの配列で色を指定します.これまでは glColor3d(red, green, blue) のように別々の要素として指定していましたが,glMatrixfv には,このようなベクトルで指定する方法のみしか用意されていないので注意が必要です (逆に,色の方には glColor3fv というものがあります).

これら全てを変更して,材質感を調整するのは少し大変なので,以下では環境光と拡散反射光を GL_AMBIENT_AND_DIFFUSEを第二引数に指定することで同時に変更しています.ここに指定する色は概ね物体の色と言っても良いので,ある程度直感的な指定が可能です.

void display() {
    ...

    // --- この下を変更すると良い ----------------------------

    glBegin(GL_TRIANGLES);

    // 四面体のサイズ
    double SIZE = 1.0;

    // 1つ目
    float c1[4] = { 1.0f, 1.0f, 0.0f, 1.0f };
    glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, c1);
    glNormal3d(0.0, 0.0, -1.0);
    glVertex3d(0, SIZE, 0);
    glVertex3d(-SIZE, -SIZE/2.0, 0);
    glVertex3d(SIZE, -SIZE/2.0, 0);
    // 2つ目
    float c2[4] = { 1.0f, 0.0f, 0.0f, 1.0f };
    glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, c2);
    glNormal3d(0.0, -2.0, 1.0);
    glVertex3d(0, 0, SIZE);
    glVertex3d(-SIZE, -SIZE/2.0, 0);
    glVertex3d(SIZE, -SIZE/2.0, 0);
    // 3つ目
    float c3[4] = { 0.0f, 0.0f, 1.0f, 1.0f };
    glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, c3);
    glNormal3d(1.5, 1.0, 1.0);
    glVertex3d(0, SIZE, 0);
    glVertex3d(0, 0, SIZE);
    glVertex3d(SIZE, -SIZE/2.0, 0);
    // 4つ目
    float c4[4] = { 0.0f, 1.0f, 0.0f, 1.0f };
    glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, c4);
    glNormal3d(-1.5, 1.0, 1.0);
    glVertex3d(0, SIZE, 0);
    glVertex3d(-SIZE, -SIZE/2.0, 0);
    glVertex3d(0, 0, SIZE);

    glEnd();

    // --- この上を変更すると良い ----------------------------

    ...
}

実行結果

今回のサンプルプログラムでは適度に光沢感が出るように光沢反射光を設定しているので,色を設定したことでだいぶ見た目の質も向上してきたのではないでしょうか?


定義済み形状データ

ここまでの内容が分かると,あとは三次元形状のデータさえあれば,どのような形でも表示できます.ですが実は三次元グラフィクスで一番難しいのはデータの作成であることもまた事実です.

幸いなことに,GLUT ライブラリには,いくつかの定義済みサンプルのモデルが用意されているので,試しに表示してみましょう.

glutSolidTeapot(1.0);

とすると,サイズ 1.0 のティーポットが表示されます.

また,

glutSolidSphere(1.0, 64, 32);

とすると,直径 1.0 の球が表示されます.引数にある 6432 はそれぞれ経度方向の分割数と緯度方向の分割数を表しています.経度方向が球1週分あるのに対して,は緯度方向は半周分なので,緯度方向の分割数を経度方向の分割数の2分の1にしています.

この他にも glutSolidCube (正六面体) や glutSolidCone (円錐), glutSolidIcosahedron (正二十面体) などが用意されているので,必要に応じて使用してみてください.

モデルビュー行列と行列の一時退避

上記の定義済み形状サンプルは,全て中心が原点になるように描画されます.もし自分自身で glVertex3d などを用いて位置を指定していれば,それらの位置を平行移動すれば良いのですが,これらの定義済みサンプルを動かす場合にはそうも行きません.

実は,OpenGLでは,頂点の位置が分かっているかどうかに関わらず,物体を平行移動したり回転したりすることができるようになっています.例えば平行移動なら,

glTranslated(moveX, moveY, moveZ);

のようにに指定することで (moveX, moveY, moveZ) の分だけ物体が平行移動して表示されます.同様に glRotated で回転を,glScaled で拡大・縮小を行うこともできます.

ただ,これらの関数を利用するには少し注意が必要です.以下の例を見てください.

void display() {
    ...

    // --- この下を変更すると良い ----------------------------

    glTranslated(1.0, 0.0, 0.0);
    glutSolidSphere(1.0, 64, 32);

    glTranslated(-1.0, 0.0, 0.0);
    glutSolidSphere(1.0, 64, 32);

    // --- この上を変更すると良い ----------------------------

    ...
}

この例では二つの球を描いていて,一つをX方向に+1動かし,もう一方をX方向に-1動かしています.これらの球の半径は1ですので,このように動かせば球が接するように描画されるはずです.ただ,残念ながら,結果はそうならず,球が重なって表示されてしまっているはずです.

これは glTranslated などの関数が変形を積み重ねていくためで,上記の例では,一つ目の球は確かにX方向に+1動くのですが,二つ目の球はX方向に+1動いた後に,再びX方向に-1動いた後の距離 (=元々の位置) に描画されてしまいます.

これを防ぐための安直な方法は,一度位置を元に戻すように

glTranslated(1.0, 0.0, 0.0);
glutSolidSphere(1.0, 64, 32);
glTranslated(-1.0, 0.0, 0.0);  // 1つ目の変換を打ち消す変換

glTranslated(-1.0, 0.0, 0.0);
glutSolidSphere(1.0, 64, 32);
glTranslated(1.0, 0.0, 0.0);  // 2つ目の変換を打ち消す変換

のようにすることですが,これは複雑な変換になるとプログラムが長くなり,さらに良くないことに何度も描画すると,数値誤差がよってうまく変換が打ち消されなくなってきます.

このためにOpenGLにはより賢い方法が用意されており,そのために用いるのが glPushMatrix ならびに glPopMatrix です.これまで詳しい言及は避けてきましたが,OpenGLに限らず,物体の三次元座標を画面上の二次元座標に変換する数学的な変換は同次座標空間と呼ばれる空間上での4x4の行列の掛け算によって行われます.そのため,実際に三次元物体が描画されるときには,あらかじめ指定された行列 (reshape 関数の中で指定している) を glVertex3d などで指定された頂点座標に掛け算して,二次元座標上の位置を決定しているのです.

このような行列を座標変換行列といいますが glPushMatrixglPopMatrix は,このような座標変換行列を記憶している「行列スタック」を操作する関数です.物体の描画には行列スタック上の行列が全て掛け合わされたものが用いられますが,glPushMatrix は,このスタックに新しい行列を追加する関数で,glPopMatrix はスタックの中で一番最後に追加された行列を取り除く関数です.

glTranslatedだけを使う場合


glPushMatrix/glPopMatrixを使う場合

上の図を見てください.glTranslated を使う場合には,行列スタックの一番上にある行列Bが最後に追加された行列ですが,glTranslated が呼ばれると,この行列BとglTranslated で計算される平行移動の座標変換行列が掛け算されて,一番上の行列が行列B’に書き換わります.これを打ち消すにはglTranslatedで反対方向に平行移動をすれば良いのですが,これを繰り返すと上記の通り,誤差が溜まってしまいます.

一方で glPushMatrix を行うと,行列スタックの一番上に新しく単位行列が用意されます.この状態で glTranslated が呼び出されると,単位行列と平行移動行列の掛け算がなされますが,この操作は元々あった行列Bや行列Aには一切影響を与えません.この状態で描画命令が呼ばれると行列スタックの一番上にある平行移動行列を含めた三つ全ての行列の掛け算が頂点の座標変換に使われます.もし必要がなくなれば glPopMatrix を呼ぶと,そのまま一番上にあった平行移動行列が破棄され,glPushMatrix を呼ぶ前の状態に戻ります.

実際のプログラムでも見てみましょう.先ほどの例であれば,

glPushMatrix();
glTranslated(1.0, 0.0, 0.0);
glutSolidSphere(1.0, 64, 32);
glPopMatrix();

glPushMatrix();
glTranslated(-1.0, 0.0, 0.0);
glutSolidSphere(1.0, 64, 32);
glPopMatrix();

のようにすることで,二度呼び出されている glTranslated が互いに鑑賞することなく,二つの球が正しくX方向に+1, X方向に-1だけそれぞれ動いた描画結果を得ることができます.

課題3 (配点: 3点)

上記のサンプルプログラムを改良して下の示した動画のようにティーポットの周りを球が回るようなプログラムを作成せよ(以下の動画では球が12個ある).この際 glPushMatrix ならびに glPopMatrix を使用すれば短いコードでこれを実現できるが,同様の画像が出るならば必ずしも実現方法は問わない.

実行結果の例

課題4 (配点: 3点)

上記のサンプルプログラムを改良して,「微分方程式」で作成した減衰振動の様子を可視化せよ.上方向がY軸が正の方向となるように座標系を設定し,Y軸に沿って球を動かせば良い (球は glutSolidSphere で描画して良い).

実行結果の例