はじめに
今回の記事では,大学1~2年生に向けて記事の作成を行ってみました.線形代数とプログラミング(C言語)の知識があれば理解できる内容となっているので,読んでいただけると幸いです.
本記事で行う内容は,ターミナル上でキューブを回転させるだけの内容となっていますが,線形代数で習った回転行列を実際に利用したり,コンピュータビジョンの話も少し入ってくるので大学1~2年生の学生が挑戦するには適している内容になっていると思います.
初めて記事を書いたので,多少見ずらい部分があるかもしれないです...
関連動画
ターミナル上で面白い作品を作るのは,YouTubeやネットで検索すればたくさん出てくるので関連する動画やサイトを紹介したいと思います.
・ ドーナツサイト
・ 数学があると面白いことができると述べている動画
Joma Techさんが作成した動画となっています.プログラミングで数学を使えることの利点を面白く説明してくれています.
https://youtu.be/sW9npZVpiMI?si=FNwny8ns7Djqrlb_
事前知識
回転するキューブを作成する中で,必要となる知識をここでまとめたいと思います.各知識に関しては簡単な説明だけを行います.
回転行列
軸ごとに回転をさせてくれる行列のことです.
X軸回転
\begin{pmatrix} x' \\\ y' \\\ z' \end{pmatrix} = \begin{pmatrix} 1 & 0 & 0 \\\ 0 & \cosθ & -\sinθ \\\ 0 & \sinθ & \cosθ \end{pmatrix} \begin{pmatrix} x \\\ y \\\ z \end{pmatrix}
Y軸回転
\begin{pmatrix} x' \\\ y' \\\ z' \end{pmatrix} = \begin{pmatrix} \cosθ & 0 & \sinθ \\\ 0 & 1 & 0 \\\ -\sinθ & 0 & \cosθ \end{pmatrix} \begin{pmatrix} x \\\ y \\\ z \end{pmatrix}
Z軸回転
\begin{pmatrix} x' \\\ y' \\\ z' \end{pmatrix} = \begin{pmatrix} \cosθ & -\sinθ & 0 \\\ \sinθ & \cosθ & 0 \\\ 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} x \\\ y \\\ z \end{pmatrix}
映像で見た方がイメージできると思ったので,各軸で回転する立方体を下に示します.
Zバッファ
Zバッファ(深度バッファ)とは,カメラから見た各ピクセルにある描写される物体の奥行き情報を記録するためのバッファとなっています.
Zバッファの値を参考にすることで,カメラから近い物体だけを描写することが可能となります.
透視投影
3次元にある物体を,遠くのものを小さく,近くのものを大きく描くことで,2次元平面上に遠近感や立体感を表現する投影法です.
今回は,透視投影を行うための以下の式である透視変換を利用します.
\begin{pmatrix} x \\\ y \end{pmatrix} = \begin{pmatrix} f \frac{X}{Z} \\\ f\frac{Y}{Z} \end{pmatrix}
x, y:投影面上に移る点の座標
X, Y, Z:3次元上にある投影をしたい点の座標
f:焦点距離.視点(カメラ)から投影面までの距離.(スケールのような役割がある)
重要なポイント
奥行き情報であるZを利用してコード上では,ooz=1/Zとして管理しています.oozは値が小さければ遠くの位置にある点,大きければ近くにある点であることを表現してくれる値となっています.この情報は,zBufferで管理することで,前後関係を考慮した描写が可能となります.(描写される物体はz>0の位置にあることを仮定しています)
エスケープシーケンス
コンソールを制御するために利用します.以下のサイトでどのような制御が行えるかを調べて実際に実装してみると理解可能です.
https://www.mm2d.net/main/legacy/c/c-06.html
今回実装する上でのx, y, z軸の方向に関して
実装の都合上,次のようなx, y, z軸を考えて実装されています.これを頭の中に入れておくと内容の理解が簡単になる気がします..
仕組み
1. コンソール上に描写を行う準備
初めに,コンソール上に回転はしない立方体の描写を行っていきます.
描写の準備と一つの平面の描写
以下のコードを実装してみてください. コンソールに一つの面だけ描写されます.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
// コンソールで表示する範囲
#define WIDTH 100
#define HEIGHT 30
char buffer[WIDTH*HEIGHT]; // 描写する文字を入れる配列
char background = '-';
float incrementSpeed = 0.4;
// キューブの大きさ
float objectSize = 10;
// 3次元座標の物体を2次元に描写する.
// 完成品ではない.(Z軸の座標を考慮していない)
void surface(float objectX, float objectY, float objectZ, char ch){
/*
後で回転の操作を入れる
*/
int xp = (int)(WIDTH/2 + 2*objectX);
int yp = (int)(HEIGHT/2 + objectY);
int idx = xp + WIDTH*yp;
if(idx>=0 && idx < WIDTH * HEIGHT){
buffer[idx] = ch;
}
}
int main(){
printf("\x1b[2J"); // コンソール画面を綺麗にする
printf("\x1b[?25l"); // テキストカーソルを削除する
while(1){
// 初期化
memset(buffer, background, WIDTH*HEIGHT);
// キューブを描写(3次元)
// surface関数で3次元キューブを2次元に変換
for(float objectX = -objectSize; objectX < objectSize; objectX += incrementSpeed){
for(float objectY = -objectSize; objectY < objectSize; objectY += incrementSpeed){
surface(objectX, objectY, objectSize, '1'); // 奥の面
}
}
printf("\x1b[H"); // コンソールの入力位置を左上に持ってくる
for(int h=0; h<HEIGHT; h++){
for(int w=0; w<WIDTH; w++){
int i = w + h*WIDTH;
putchar(buffer[i]);
}
printf("\n");
}
}
printf("\x1b[?25h"); // テキストカーソルを復活させる
return 0;
}
必要となる説明は,以下の2点であると思うので紹介いたします.
surface関数(透視投影)(Zを考慮していない)
- surface関数は,3次元に描写されいる物体の座標(objectX, objectY, objectZ)を受け取ることで,その座標を2次元座標に変換します.
- 座標 (x, y) は、そのままだと画面の左上にいるので,画面の中心に来るようにwidth/2, height/2を足しています.
不思議に思われるのが以下のコードの2*と思われます.この理由として,コンソールでの横と縦の文字に対してのサイズが異なるからです.(わかりやすい文章が見つからなかったので,実際に2 *を取り除いて実装してみたらすぐわかります::)
int xp = (int)(WIDTH/2 + 2*objectX);
面の描写
- ここでは,z=cubeSizeの位置にあるキューブの一つの面を描写しています.また,その座標をsurface関数に渡すことで,透視投影(未完成)を行っています.
- incrementSppedは,キューブを描写するときの細かさに対応しています.(実際は,surface関数の2*が原因でキューブに間ができてしまうので,小数点で細かく描写するようにしています)
for(float objectX = -objectSize; objectX < objectSize; objectX += incrementSpeed){
for(float objectY = -objectSize; objectY < objectSize; objectY += incrementSpeed){
surface(objectX, objectY, objectSize, '1'); // 奥の面
}
}
2. 全ての面を描写
初めに2面
2つの面を描写するために,以下のコードのようにして変更をしてみてください.
for(float objectX = -objectSize; objectX < objectSize; objectX += incrementSpeed){
for(float objectY = -objectSize; objectY < objectSize; objectY += incrementSpeed){
surface(objectX, objectY, objectSize, '1'); // 奥の面
surface(-objectSize, objectY, objectX, '4'); // 1番の面をy軸90度回転
}
}
実行してみると,面の左側の一列が4になっていると思われます.これは,次の画像のように3次元上では2つの面が描写されている状態となっています.
重要な点として,4で描写される面は,1で描写される面をY軸90回転させたときの面となっています.計算は,以下のように行われています.
\begin{pmatrix} -z \\\ y \\\ x \end{pmatrix} = \begin{pmatrix} \cos90 & 0 & \sin90 \\\ 0 & 1 & 0 \\\ -\sin90 & 0 & \cos90 \end{pmatrix} \begin{pmatrix} x \\\ y \\\ z \end{pmatrix}
全ての面を描写
2面の描写を行った時と同様にして,全ての面を描写させていきます.以下のコードに変更すれば,3次元上で立方体が描写されていると考えることができます.
for(float objectX = -objectSize; objectX < objectSize; objectX += incrementSpeed){
for(float objectY = -objectSize; objectY < objectSize; objectY += incrementSpeed){
surface(objectX, objectY, objectSize, '1'); // 奥の面
surface(objectX, objectY, -objectSize, '2'); // 手前の面
surface(-objectSize, objectY, objectX, '4'); // 1番の面をy軸90度回転
surface(objectSize, objectY, -objectX, '3'); // 1番の面をy軸-90度回転
surface(objectX, objectSize, -objectY, '5'); // 1番の面をx軸90度回転
surface(objectX, -objectSize, objectY, '6'); // 1番の面をx軸-90度回転
}
}
3. キューブを回転させる
前回の実装から3次元上にキューブを描写することができました.次に行うことは,3次元上にあるキューブを回転させながら,2次元(コンソール上)に透視投影を行い描写します.
各軸に対しての回転行列関数
初めに,各軸で回転を行う関数を作成します.回転行列の計算は,計算ツールを用いた結果を用いて,3次元の座標を引数として入力することで回転させます.
行列計算は,次のような計算を各軸に対して行っています.
\begin{pmatrix} x \\\ ycos(θ) + zsin(θ) \\\ zcos(θ) - ysin(θ) \end{pmatrix} = \begin{pmatrix} \ 1 & 0 & 0 \\\ 0 & cosθ & -sinθ \\\ 0 & sinθ & cosθ \end{pmatrix} \begin{pmatrix} x \\\ y \\\ z \end{pmatrix}
float thetaA, thetaB, thetaC; // 回転する角度
float x, y, z; // 途中計算結果の保存用
void rotateX(float *objectX, float *objectY, float *objectZ){
x = (*objectX);
y = (*objectY) * cosf(thetaA) + (*objectZ) * sinf(thetaA);
z = (*objectZ) * cosf(thetaA) - (*objectY) * sinf(thetaA);
*objectX = x;
*objectY = y;
*objectZ = z;
}
void rotateY(float *objectX, float *objectY, float *objectZ){
x = (*objectX) * cosf(thetaB) - (*objectZ) * sinf(thetaB);
y = (*objectY);
z = (*objectX) * sinf(thetaB) + (*objectZ) * cosf(thetaB);
*objectX = x;
*objectY = y;
*objectZ = z;
}
void rotateZ(float *objectX, float *objectY, float *objectZ){
x = (*objectX) * cosf(thetaC) + (*objectY) * sinf(thetaC);
y = (*objectY) * cosf(thetaC) - (*objectX) * sinf(thetaC);
z = (*objectZ);
*objectX = x;
*objectY = y;
*objectZ = z;
}
合体
各軸の関数を一つの関数の中で呼び出して回転を行う関数を作ります.今回の場合は,x軸回転 → y軸回転 → z軸回転となるようになっています.
void rotate(float *objectX, float *objectY, float *objectZ){
rotateX(objectX, objectY, objectZ);
rotateY(objectX, objectY, objectZ);
rotateZ(objectX, objectY, objectZ);
}
実際に回転
実際にsurface関数で呼びだして回転を行い,透視投影の実装を行っていきます.surface関数を以下のよう変更してみてください.
// 透視投影で利用する変数
float f = 40; // 焦点距離
float ooz;
float zBuffer[WIDTH*HEIGHT];
float distanceFromCam = 60;
void surface(float objectX, float objectY, float objectZ, char ch){
rotate(&objectX, &objectY, &objectZ);
objectZ += distanceFromCam; // カメラの位置から離す
ooz = 1/(objectZ);
int xp = (int)(WIDTH/2 + f * ooz * (objectX) * 2);
int yp = (int)(HEIGHT/2 + f * ooz * (objectY));
int idx = xp + yp*WIDTH;
// 遠い物体はoozが小さい
// 近い物体はoozが大きい
// oozを利用して各ピクセルで一番近い物体を描写
if(idx >= 0 && idx < WIDTH * HEIGHT){
if(ooz > zBuffer[idx]){
zBuffer[idx] = ooz;
buffer[idx] = ch;
}
}
}
最後に,int main()の中にzBufferの初期化と,各thetaの回転を以下のようにして追加してみて下さい.コンソール上で回転が起こるはずです!!!
// 初期化
memset(buffer, background, WIDTH*HEIGHT);
memset(zBuffer, 0, WIDTH*HEIGHT*sizeof(float));
----------------------------------------------------------------------------------
for(int h=0; h<HEIGHT; h++){
for(int w=0; w<WIDTH; w++){
int i = w + h*WIDTH;
putchar(buffer[i]);
}
printf("\n");
}
thetaA += 0.4;
thetaB += 0.2;
回転に関しては,rotateを呼び出すだけですので,解説は省略いたします.重要なポイントは以下の2点であると思うので紹介いたします.
カメラと物体の位置とoozに関して
- カメラからオブジェクトを離すことにより,オブジェクトはz > 0 の位置の存在することになります.これにより,遠い位置にある物体はoozの値が小さく,近い位置にある物体は大きな値となります.
3次元から2次元への透視投影
透視投影を行う仕組みをそのまま実装しています.
int xp = (int)(WIDTH/2 + f * ooz * (objectX) * 2);
int yp = (int)(HEIGHT/2 + f * ooz * (objectY));
全体のコード
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#define WIDTH 100
#define HEIGHT 30
char buffer[WIDTH*HEIGHT];
float zBuffer[WIDTH*HEIGHT];
char background = ' ';
float distanceFromCam = 60;
float objectSize = 10;
float incrementSpeed = 0.4;
// 回転で利用する変数
float thetaA, thetaB, thetaC; // 回転する角度
float x, y, z; // 途中計算結果の保存用
// 透視投影で利用する変数
float f = 40; // 焦点距離
float ooz; //
void rotateX(float *objectX, float *objectY, float *objectZ){
x = (*objectX);
y = (*objectY) * cosf(thetaA) + (*objectZ) * sinf(thetaA);
z = (*objectZ) * cosf(thetaA) - (*objectY) * sinf(thetaA);
*objectX = x;
*objectY = y;
*objectZ = z;
}
void rotateY(float *objectX, float *objectY, float *objectZ){
x = (*objectX) * cosf(thetaB) - (*objectZ) * sinf(thetaB);
y = (*objectY);
z = (*objectX) * sinf(thetaB) + (*objectZ) * cosf(thetaB);
*objectX = x;
*objectY = y;
*objectZ = z;
}
void rotateZ(float *objectX, float *objectY, float *objectZ){
x = (*objectX) * cosf(thetaC) + (*objectY) * sinf(thetaC);
y = (*objectY) * cosf(thetaC) - (*objectX) * sinf(thetaC);
z = (*objectZ);
*objectX = x;
*objectY = y;
*objectZ = z;
}
void rotate(float *objectX, float *objectY, float *objectZ){
rotateX(objectX, objectY, objectZ);
rotateY(objectX, objectY, objectZ);
rotateZ(objectX, objectY, objectZ);
}
void surface(float objectX, float objectY, float objectZ, char ch){
rotate(&objectX, &objectY, &objectZ);
objectZ += distanceFromCam; // カメラの位置から離す
ooz = 1/(objectZ);
int xp = (int)(WIDTH/2 + f * ooz * (objectX) * 2);
int yp = (int)(HEIGHT/2 + f * ooz * (objectY));
int idx = xp + yp*WIDTH;
if(idx >= 0 && idx < WIDTH * HEIGHT){
if(ooz > zBuffer[idx]){
zBuffer[idx] = ooz;
buffer[idx] = ch;
}
}
}
int main() {
printf("\x1b[2J"); // コンソール画面を綺麗にする
printf("\x1b[?25l"); // マウスポインタを削除する
while(1){
// 初期化
memset(buffer, background, WIDTH*HEIGHT);
memset(zBuffer, 0, WIDTH*HEIGHT*sizeof(float));
for(float objectX = -objectSize; objectX < objectSize; objectX += incrementSpeed){
for(float objectY = -objectSize; objectY < objectSize; objectY += incrementSpeed){
surface(objectX, objectY, objectSize, '1'); // 奥の面
surface(objectX, objectY, -objectSize, '2'); // 手前の面
surface(-objectSize, objectY, objectX, '4'); // 1番の面をy軸90度回転
surface(objectSize, objectY, -objectX, '3'); // 1番の面をy軸-90度回転
surface(objectX, objectSize, -objectY, '5'); // 1番の面をx軸90度回転
surface(objectX, -objectSize, objectY, '6'); // 1番の面をx軸-90度回転
}
}
printf("\x1b[H"); // printする場所を指定する
for(int h=0; h<HEIGHT; h++){
for(int w=0; w<WIDTH; w++){
int i = w + h*WIDTH;
putchar(buffer[i]);
}
printf("\n");
}
thetaA += 0.01;
thetaB += 0.05;
thetaC += 0.03;
}
printf("\x1b[?25h"); // マウスポインを復活させる
return 0;
}
他の回転作品を作る
今回の実装において一番重要な点として,3次元上で物体の描写が完了すれば,その物体の座標を投げるだけで回転が簡単にできてしまう点です.
下のコードの場所を異なる物体が描写できるようになれば,ターミナル上で回転可能です!!