3Dグラフィックスで遊ぼう – PICO-8ゲーム開発入門(5)


PICO-8でプログラミングを1から学ぶ連載、第5回です。前回はこちら。2016年最後の今回は、いつものプログラミング未経験者向けの初心者コースから少し外れて、PICO-8用の3Dエンジンを使って3Dグラフィックスに挑戦してみます。

 

3Dグラフィックスとは

立体の表面は三角形や四角形でできています。
立体の表面は三角形や四角形でできています。

私たちが生きるこの現実世界は3次元空間ですが、私たちの目には2次元の映像が映っています。絵画、写真、テレビモニター上の映像などはすべて、3次元空間のあるひとつの側面を2次元平面に投影したものです。それと同様に、架空の3次元空間上の立体物を2次元平面に投影して作るのが、3Dコンピューター・グラフィックスです。

3次元立体を表現する方法はいくつかありますが、立体物の表面を「ポリゴン」すなわち多角形(主に三角形)の組み合わせで構成するのが一般的です。昨今のゲームや映画の高精細な3D映像も、基本的には同じ仕組みのはずです。FF15の主人公たちもシン・ゴジラも、大量の微細な多角形によって構成されているのです。

 

PICO-8の3D事情

PICO-8には、2Dグラフィックスを描く機能があらかじめ備わっています。第2回で説明した図形描画の関数や、第3回で説明したスプライトの関数などがそれにあたります。しかし、3Dグラフィックスの描画を簡単に行う方法はありません。

Twitterのハッシュタグ#pico8を眺めていると、PICO-8上で3Dグラフィックスをグリグリ動かしているGIFアニメが見つかることがあります。それらは、独自に3次元描画処理を実装してしまう猛者たちの作なのです。

カーレースゲームのデモ

 
Alone in the Darkのディメイク”Alone in Pico”

 
テクスチャーマッピングまで実装されている3Dシューター“Hyperspace”

 

Gryphon 3D Engine Library

Gryphon 3D Engine Library」は、そんな猛者の一人であるelectricgryphonさん作の3Dグラフィックス・エンジンです。これは、3Dシューティング・ゲーム『Pico Fox』のために作られた3D描画処理部分を、ほかのユーザーも使えるように切り出したものです。

このエンジンは、3次元の立体物(3Dモデル)2次元の画面に投影して描画する処理を行ってくれます。エンジン利用者は、3Dモデルのデータを「登録」するだけで、3Dグラフィックスを表示することができます。

なお、このエンジンには、整理されたドキュメントなどは用意されていません。エンジン利用者は、作者が用意してくれたメモ、ソースコードおよびコメントを読んで、使い方を推し量るしかありません。というわけで、以下は筆者が理解した範疇での説明であることをご了承の上、お楽しみください。

 

Gryphon 3D Engine Libraryの導入

「Gryphon 3D Engine Library」ページを開き、カートリッジの画面の下の「Code」をクリックしましょう。すると、カートリッジ内のソースコードがすべて表示されます。その中から「BEGIN CUT HERE」と書かれた行から「END COPY」と書かれた行までをコピーして、PICO-8のまっさらな状態のコードエディターにペーストしましょう。ここが3Dエンジン部分です。

このコードにはおなじみの_INIT()、_UPDATE()、_DRAW()が含まれないので、このままでは動きません。元の「Gryphon 3D〜」のカートリッジを参考に、下記を追加してみました。

(Gryphon 3D Engine Library 部分略)

FUNCTION _INIT()
 INIT_3D() -- プレイヤー、カメラ、照明を初期化する
END

FUNCTION _UPDATE()
 HANDLE_BUTTONS() -- ボタン入力処理
 UPDATE_PLAYER() -- プレイヤーを更新する
 UPDATE_CAMERA() -- プレイヤーの位置・方向に応じてカメラを更新する
 UPDATE_PARTICLES() -- 3Dパーティクルがあれば更新する
 UPDATE_3D() -- _UPDATE()の最後でこれを呼ぶ必要がある
END

FUNCTION _DRAW()
 RECTFILL(0,0,127,127,7) -- 背景色塗り。お好きな色でどうぞ。
 DRAW_3D() -- 3D立体を描画する
END

このエンジンにはプレイヤー(player)というオブジェクトが定義されています。このプレイヤーはカメラとほぼ同義であり、FPSのプレイヤー・キャラクターと考えればよいでしょう。プレイヤーの位置と方向が、3次元空間での視点となります。

HANDLE_BUTTONS()のデフォルトの実装では、ボタン入力で視点と角度を変えられるようになっています。左右キーで水平方向(X方向)に回転し、上下キーで前後進(Z方向移動)します。DOOMのプレイヤー移動のような感じです。この関数の中身は、作りたいものによって書き換えるべきでしょう。

 

3Dモデルを登録する

この状態ではなにも起きないので、3Dモデルを表示しましょう。3Dモデル=オブジェクトを登録する関数は、以下になります。

LOAD_OBJECT(OBJECT_VERTICES, OBJECT_FACES, X, Y, Z, AX, AY, AZ, OBSTACLE, COLOR_MODE, COLOR)

  • OBJECT_VERTICES: オブジェクトの頂点リスト
  • OBJECT_FACES: オブジェクトの面リスト
  • X, Y, Z: オブジェクトの中心の位置
  • AX, AY, AZ: 各軸に対する回転
  • OBSTACLE: この値がTRUEのとき、この物体はプレイヤーとの衝突判定対象となる?
  • COLOR_MODE:
    • K_COLORIZE_STATIC(=1): 1つの色を元に影をつける
    • K_COLORIZE_DYNAMIC(=2): 1つの色を元に動的に影をつける(処理遅い)
    • K_MULTI_COLOR_STATIC(=3): 面リストに定義された複数の色を元に影をつける
    • K_MULTI_COLOR_DYNAMIC(=4): 面リストに定義された複数の色を元に動的に影をつける(処理遅い)
    • K_PRESET_COLOR(=5): 面リストに定義された色だけを使い、照明効果は適用しない
  • COLOR: K_COLORIZE_STATIC or K_COLORIZE_DYNAMICの場合の色(オプション引数)
  • 返り値: 登録したオブジェクト

この関数こそがこのエンジンの肝です。引数が多いですが、理解すればどうということはありません。ひとつひとつ見ていきましょう。頂点リストと面リストについては、次項で説明します。

X, Y, Z: オブジェクトの中心の位置
本エンジンの座標系は、左方向が+x、上方向が+y、手前方向が+zです。また、初期状態でのプレイヤーの位置は、(0, 8, 15)です(関数INIT_PLAYER()中で定義)。初期状態で(0, 0, 0)にオブジェクトを置いたとすると、以下のような位置関係になります。

pico-8-for-beginners-vol4-002

AX, AY, AZ: 各軸に対する回転

回転の度合いの値は、「度」でも「ラジアン」でもなく、1.0で一回転になる数値です(1.0=360°)。これは、PICO-8のSIN()やCOS()で使う値と同じです。

OBSTACLE
obstacleとは障害物という意味で、この値がTRUEのとき、プレイヤーはこの物体にぶつかって通過できなくなります。『Pico Fox』でこの機能が使われているようです。

COLOR_MODE
このエンジンではシェーディング、すなわち照明効果によって立体物に影を落とすことができます。この引数では、シェーディングモードを設定します。

K_COLORIZE_STATIC か K_COLORIZE_DYNAMICの場合は、オブジェクト全体を1つの色に設定してシェーディングします。色の設定は、最後の引数で行います。それ以外のモードでは、面リスト中で面ごとに色を設定します。

STATICがつくモードの場合は、シェーディング処理が一回しか行われません。そのため、オブジェクトを回転させても、各面の色が変わることはありません。DYNAMICの場合は、毎フレームごとにシェーディングが行われるため、リアリティが増しますが処理速度は遅くなります。

K_COLORIZE_STATIC
K_COLORIZE_STATIC
K_COLORIZE_DYNAMIC
K_COLORIZE_DYNAMIC

K_MULTI_COLOR_STATICとK_MULTI_COLOR_DYNAMICは、面ごとの色を使ってシェーディングをする、とソースコード中のコメントにはありますが、筆者が動かした限り、実際の挙動はそうなっていないように見受けられます。

K_PRESET_COLORの場合は、シェーディング処理がまったく行われず、面ごとに決まった色で塗られるだけになります。

K_PRESET_COLOR
K_PRESET_COLOR

 

頂点リストと面リスト

このエンジンでは、複数の三角形の組み合わせで立体物(の表面)を表現します。三次元空間上の三角形たちを表現するデータが、頂点リストと面リストです。頂点リストは三次元空間上の点のリストです。例えば、こんな感じ:

pico-8-for-beginners-vol4-006

Luaのコードにすれば、以下のとおりです。このデータは、本講座ではまだ扱っていない「テーブル」を使って表現しています。

VERTICES={
 {0, 0, 0},
 {0, 10, 0},
 {10, 0, 0},
 {-10, 5, 0}
}

さらに、これらの点がどのように三角形を構成しているか、を定義する面リストも必要です。面リストは、頂点リスト中の頂点の番号のリストになります。上記の頂点リストから、二つ三角形を定義すると、下記のようになります。Luaのテーブル要素の番号は1から始まることに注意してください。

FACES={
 {1,3,2},
 {1,2,4}
}

ここで、{1,2,3}ではなく{1,3,2}であることには重要な意味があります。このエンジンでは、視点から見て反時計回りに並んだ頂点が作る面は表面になり、時計回りの場合は裏面になります。

表面の場合のみ描画され、裏面から見た場合は何も描画されません。
表面の場合のみ描画され、裏面から見た場合は何も描画されません。
以上のように、頂点リストと面リストで三角形をいくつも定義して、立体物を構成します。
以上のように、頂点リストと面リストで三角形をいくつも定義して、立体物を構成します。

こうした3Dモデルデータを作るのに、頂点の数値をポチポチ手で入力していくのは大変です。専用ツールを使ったほうがいいですね。エンジン作者の残したソースコード中のコメントによると、作者はBlenderを使ってモデルを作り、OBJファイル(Wavefront .obj)に書き出し、そのファイル中のデータを手で加工して頂点/面リストを作ったそうです。コメントには「誰かがOBJファイルからの変換スクリプトを書いてくれたらいいな」とも書かれていました。誰か!

 

描いてみよう

長々とLOAD_OBJECT()関数の説明をしてきましたが、そろそろ実際にモデルを登録して、画面に描画してみましょう。立方体のモデルを作ってみました:

CUBE_V={
 { 1.0, -1.0, -1.0},
 { 1.0, -1.0,  1.0},
 {-1.0, -1.0,  1.0},
 {-1.0, -1.0, -1.0},
 { 1.0,  1.0, -1.0},
 { 1.0,  1.0,  1.0},
 {-1.0,  1.0,  1.0},
 {-1.0,  1.0, -1.0}
}

CUBE_F={
 {1, 2, 3, 8, 9},
 {3, 4, 1, 8, 9},
 {5, 8, 7, 8, 9},
 {7, 6, 5, 8, 9},
 {1, 5, 6, 8, 9},
 {6, 2, 1, 8, 9},
 {2, 6, 7, 8, 9},
 {7, 3, 2, 8, 9},
 {3, 7, 8, 8, 9},
 {8, 4, 3, 8, 9},
 {5, 1, 4, 8, 9},
 {4, 8, 5, 8, 9}
}

CUBE_Vが頂点リスト、CUBE_Fが面リストです。1つの面は三角形なので、3つの頂点があれば足りますが、このデータでは一つの面要素に数値が5つあります。4、5番目の数値は面の色の情報です。ここに別々の色番号を与えると、エンジンが2つの色の中間色を作って塗ってくれます。ただし、PICO-8は16色以上出せませんから、2つの色でボーダー柄を作る擬似的な中間色になります。

中間色のサンプル
中間色のサンプル

このモデルを、_INIT()で読み込んでみましょう。こんなコードです:

(エンジン部略)

CUBE_V={
 { 1.0, -1.0, -1.0},
 { 1.0, -1.0,  1.0},
 {-1.0, -1.0,  1.0},
 {-1.0, -1.0, -1.0},
 { 1.0,  1.0, -1.0},
 { 1.0,  1.0,  1.0},
 {-1.0,  1.0,  1.0},
 {-1.0,  1.0, -1.0}
}

CUBE_F={
 {1, 2, 3, 8, 9},
 {3, 4, 1, 8, 9},
 {5, 8, 7, 8, 9},
 {7, 6, 5, 8, 9},
 {1, 5, 6, 8, 9},
 {6, 2, 1, 8, 9},
 {2, 6, 7, 8, 9},
 {7, 3, 2, 8, 9},
 {3, 7, 8, 8, 9},
 {8, 4, 3, 8, 9},
 {5, 1, 4, 8, 9},
 {4, 8, 5, 8, 9}
}

FUNCTION _INIT()
 INIT_3D()
 LOAD_OBJECT(CUBE_V,CUBE_F,0,8,10,0.1,0.1,0,FALSE,K_COLORIZE_DYNAMIC,2)
END

FUNCTION _UPDATE()
 HANDLE_BUTTONS()
 UPDATE_PLAYER()
 UPDATE_CAMERA()
 UPDATE_PARTICLES()
 UPDATE_3D()
END

FUNCTION _DRAW()
 RECTFILL(0,0,127,127,7)
 DRAW_3D()
END

カメラ位置から見やすいように、中心点の座標を(0, 8, 10)としています。また、立体であることがわかるように、初期回転も(0.1, 0.1, 0)に設定しています。シェーディングモードはK_COLORIZE_STATICで、オブジェクトのベースの色は2番です(結局、今回は面リストの色情報は無視されます)。これを実行したのがこちら:

pico-8-for-beginners-vol4-010

 

3Dモデルの操作

LOAD_OBJECT()は、登録したオブジェクトを返り値として返します。この返り値のテーブルを操作することで、オブジェクトに変更を加えられます。_UPDATE()に、オブジェクトを移動したり回転させたりするコードを加えてみましょう。

(エンジン部、モデル定義略)

FUNCTION _INIT()
 PERIOD=48
 FRAME=0
 INIT_3D()
 OBJ=LOAD_OBJECT(CUBE_V,CUBE_F,0,8,10,0.1,0.1,0,FALSE,K_COLORIZE_DYNAMIC,2)
END

FUNCTION _UPDATE()
 HANDLE_BUTTONS()
 UPDATE_PLAYER()
 UPDATE_CAMERA()
 UPDATE_PARTICLES()
 
 FRAME+=1
 OBJ.Z=SIN((FRAME%PERIOD)/PERIOD)*10
 OBJ.AX+=1/PERIOD
 OBJ.AY+=1/PERIOD
 OBJ.AZ+=1/PERIOD

 UPDATE_3D()
END

FUNCTION _DRAW()
 RECTFILL(0,0,127,127,7)
 DRAW_3D()
END

これを実行すると:

.

各軸に対して回転させつつ、Z軸方向=奥行きの方向に行ったり来たりさせています。なお、LOAD_OBJECT()で登録したオブジェクトは、エンジンが管理するOBJECT_LISTというテーブルに保存されています。オブジェクトを削除するには、DELETE_OBJECT()関数が使えます。

DELETE_OBJECT(OBJECT)

  • OBJECT: 削除したいオブジェクト

 

3Dで遊ぼう

LOAD_OBJECT()で3Dモデルを登録して、返り値のオブジェクトを操作して動かす、というのが基本的な使い方です。モデルデータを作るのはちょっと大変ですが、立体物を動かして遊ぶのは楽しいですよ。シューティングゲームなどを作るには、立体と立体の衝突判定の処理が必要になりますが、それもこのエンジンに実装されています。

INTERSECT_BOUNDING_BOX(OBJECT_A, OBJECT_B)

  • OBJECT_A: 判定したいオブジェクトA
  • OBJECT_B: 判定したいオブジェクトB
  • 返り値: 2つのオブジェクトが衝突しているかどうか(TRUE/FALSE)

この関数を使って、簡単なデモを作ってみました。迫り来る立方体を避けるだけのゲームもどきです。

.

立方体とプレイヤーが動かせる宇宙船との間で、衝突判定を行っています。もう少し手を加えれば、シューティングゲームになりそうです。ぜひコードをご参考ください。

(エンジン部略)

CUBE_V={
 { 1.0, -1.0, -1.0},
 { 1.0, -1.0,  1.0},
 {-1.0, -1.0,  1.0},
 {-1.0, -1.0, -1.0},
 { 1.0,  1.0, -1.0},
 { 1.0,  1.0,  1.0},
 {-1.0,  1.0,  1.0},
 {-1.0,  1.0, -1.0}
}

CUBE_F={
 {1, 2, 3, 8,9},
 {3, 4, 1, 8,9},
 {5, 8, 7, 9,10},
 {7, 6, 5, 9,10},
 {1, 5, 6, 8,9},
 {6, 2, 1, 8,9},
 {2, 6, 7, 9,10},
 {7, 3, 2, 9,10},
 {3, 7, 8, 8,9},
 {8, 4, 3, 8,9},
 {5, 1, 4, 9,10},
 {4, 8, 5, 9,10}
}

SHIP_V={
 { 0.0, 0.5,-0.5},
 {-1.0, 0.0, 0.0},
 { 1.0, 0.0, 0.0},
 { 0.0, 0.0,-2.4},
}

SHIP_F={
 {2, 1, 4},
 {3, 4, 1},
 {4, 3, 2},
 {2, 3, 1}
}

–– 立方体を配置
FUNCTION NEW_CUBE()
 IF FRAME%PERIOD==1 THEN
  LOCAL X=4-RND(3)*4
  LOCAL COL=FLR(RND(8))+8
  LOCAL C=LOAD_OBJECT(CUBE_V,CUBE_F,X,4,-20,0,0,0,FALSE,K_COLORIZE_DYNAMIC,COL)
  C.VX=0.0
  C.VY=0.0
  C.VZ=0.5
 END
END

–– 立方体を更新
FUNCTION UPDATE_CUBE(C)
 –– 宇宙船は除外
 IF C==SHIP THEN
  RETURN
 END
 –– 視界から外れていったら削除する (残したままにしておくと、メモリがパンクします)
 IF C.Z>PLAYER.Z OR C.Y>10.0 THEN
  DELETE_OBJECT(C)
  RETURN
 END
 C.X+=C.VX
 C.Y+=C.VY
 C.Z+=C.VZ
 C.VZ+=0.05
 C.AX+=0.01
 C.AY+=0.01
 C.AZ+=0.01
 –– 衝突判定
 IF INTERSECT_BOUNDING_BOX(C,SHIP) THEN
  SFX(0) –– お好みの効果音をどうぞ
  CRASH+=8
  C.VZ-=1.5
  C.VX=RND(1.0)-0.5
  C.VY=1.0
 END
END

–– ボタン入力
FUNCTION INPUT()
 IF BTN(0) THEN SHIP.X+=0.2 END
 IF BTN(1) THEN SHIP.X-=0.2 END
 IF BTN(2) THEN SHIP.Z-=0.2 END
 IF BTN(3) THEN SHIP.Z+=0.2 END
END

–– ぶつかった時、画面を揺らす
FUNCTION TILT_SCREEN()
 IF CRASH>0 THEN
  CAMERA(RND(10)-5, RND(10)-5)
  CRASH-=1
 ELSE
  CAMERA(0,0)
 END
END

FUNCTION _INIT()
 PERIOD=24
 FRAME=0
 CRASH=0
 INIT_3D()
 SHIP=LOAD_OBJECT(SHIP_V,SHIP_F,0,4,11,0.0,0,0,FALSE,K_COLORIZE_DYNAMIC,13)
 –– 視界の初期状態をちょっと調整
 PLAYER.AX-=0.075
 PLAYER.Y=10
END

FUNCTION _UPDATE()
 FRAME+=1
 UPDATE_PLAYER()
 UPDATE_CAMERA()
 INPUT()
 NEW_CUBE()
 FOREACH(OBJECT_LIST, UPDATE_CUBE)
 TILT_SCREEN()
 UPDATE_3D()
END

FUNCTION _DRAW()
 RECTFILL(0,0,127,127,1)
 DRAW_3D()
END

また、筆者が本記事執筆のための調査を兼ねて作成した、ショートケーキに苺をのせるゲームも、あわせてご参考ください。

http://www.lexaloffle.com/bbs/?tid=28139

.

 

PICO-8シーン最新情報

AUTOMATONでも記事になっていましたが、PICO-8で制作されて人気を博した『Celeste』のPlayStation 4版の発売が発表されました!

次回からまた、PICO-8入門コースを再開します。年末年始はぜひ、PICO-8でポリゴンをグリグリしたりしてお過ごしください。2017年もよろしくお願いいたします。