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次元描画処理を実装してしまう猛者たちの作なのです。
カーレースゲームのデモ
(buggy) 3D Collision finally in! #pico8 https://t.co/42TJUR0GIC
— Rhys Simpson (@riperiperi) May 26, 2015
Alone in the Darkのディメイク”Alone in Pico”
I just released "Alone in Pico", a "demake" of Alone in the Dark in #pico8 https://t.co/AfOzsskMjq pic.twitter.com/04nzPd1zTd
— Antoine Zanuttini (@NuSan_fx) July 8, 2016
テクスチャーマッピングまで実装されている3Dシューター“Hyperspace”
Finally found some time to finish my #pico8 3D shooter: Hyperspace. Come and try it! https://t.co/knCwbVyFXx pic.twitter.com/eWK4faIFEa
— Julien Fryer (@JulienFryer) November 20, 2015
Gryphon 3D Engine Library
「Gryphon 3D Engine Library」は、そんな猛者の一人であるelectricgryphonさん作の3Dグラフィックス・エンジンです。これは、3Dシューティング・ゲーム『Pico Fox』のために作られた3D描画処理部分を、ほかのユーザーも使えるように切り出したものです。
Gryphon3D by electricgryphon — a solid 3D engine for #pico8 in 4675 tokens. (Used to make Pico Fox) https://t.co/05hVX9DLdK pic.twitter.com/ElFa0QAey9
— zep.p8 (@lexaloffle) November 17, 2016
このエンジンは、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)にオブジェクトを置いたとすると、以下のような位置関係になります。
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_MULTI_COLOR_STATICとK_MULTI_COLOR_DYNAMICは、面ごとの色を使ってシェーディングをする、とソースコード中のコメントにはありますが、筆者が動かした限り、実際の挙動はそうなっていないように見受けられます。
K_PRESET_COLORの場合は、シェーディング処理がまったく行われず、面ごとに決まった色で塗られるだけになります。
頂点リストと面リスト
このエンジンでは、複数の三角形の組み合わせで立体物(の表面)を表現します。三次元空間上の三角形たちを表現するデータが、頂点リストと面リストです。頂点リストは三次元空間上の点のリストです。例えば、こんな感じ:
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番です(結局、今回は面リストの色情報は無視されます)。これを実行したのがこちら:
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年もよろしくお願いいたします。
- 第1回: PICO-8って何?
- 第2回: プログラムで絵を描こう
- 第3回: アニメーションを作ろう
- 第4回: コントローラーを使おう
- 第5回: 3Dグラフィックスで遊ぼう
- 第6回: 効果音を鳴らそう
- 第7回: 人と物のふれあい……衝突判定
- 第8回: 1、2、3…無限大……繰り返しとテーブル
- 第9回: ビーム、撃っちゃうね。……繰り返しとテーブルその2
- 第10回: シンギュラリティは近い……ゲームAIの初歩の初歩
- 第11回: 撃たれると痛い……衝突判定その2
- 第12回: 画面効果その1・パーティクル