LuaJITでお手軽3Dプログラミング (2) (2013/04/14)
LjESについて
Raspberry Pi を使って、簡単に3Dプログラミングを始める事ができるように LjES というライブラリを作成しました。
LuaJIT という非常に高速なスクリプト言語を使って、 3Dアニメーションのプログラミング経験がなくても3Dプログラミングを手軽に楽しめることを目的としています。 Raspberry Pi の公式ディスクイメージの Raspbian には最初から LuaJIT が入っているため、新たに何もインストールする必要もなくプログラミングを始められます。 サンプルプログラムを試すには LjESを解凍したディレクトリでデモのスクリプトを実行するだけです。
この動画のサンプルプログラムでは、描画に最大2万ポリゴンを使っていますが、十分に高速に動作していると思います。 Raspberry Pi の CPU は 700MHz の ARM という今となっては高速とはいえない CPU ですが、シェーダプログラムを GPU (VideoCore4) 上で使い、LuaJITという JIT コンパイルを行う Lua 処理系の実装を使うことで、スクリプト言語でもリアルタイムの 3D アニメーションを実行できます。 Raspberry Pi で動作する 3D ゲームがたくさん公開されると楽しいでしょうね。
LjES のダウンロードと使い方
LjES はいくつかの画像ファイルとテキストだけが含まれています。テキストの多くはLua言語のソースプログラムで、Raspberry Piでそのまま実行できます。
GitHub (https://github.com/jun-mizutani/ljes.git )
適当なディレクトリで次のコマンドを実行すると、LjESのダウンロードと展開、デモの実行まで行います。
curl -O https://www.mztn.org/rpi/ljes-1.00.tar.gz tar zxf ljes-1.00.tar.gz cd ljes-1.00 cd examples luajit demo_spheres.lua
examples ディレクトリのサンプルは [q] キーを押すか、2分程経過すると終了します。
LjES の構成
LjES は大体下の図に示すような構成になっています。 図の青い部分の demo.lua はLjESの全体を使うための簡単なフレームワークになっています。demo2.lua は文字表示に画像ファイルを使う demo.lua の別バージョンです。使い方はどちらも同じです。 グリーンの部分のbcm.lua、egl.lua、 png.lua、 gles.lua、 termios.lua は、どんな Linux のディストリビューションでも持っているシステム用の共有ライブラリ(図中の赤い部分)を LuaJIT から使うための低レベルなコードです。残りの黄色の部分は LjES のクラスライブラリ群です。 OpenGL ES2で必要となる行列演算、GPUへの転送、シェーダプログラミングといった機能や物体の形状や動作の指定、画面への文字表示といった部分を担当しています。
20 行のサンプルプログラム
次のコードは LjESを使った、3次元でアニメーションを行うほぼ最小のプログラムです。 20行で回転するドーナッツを表示するプログラムができます。赤字の部分を変更すると物体の形状や動作を変更できます。 それ以外の部分はあまり変更する必要のない部分です。 この例では OpenGL ES2のAPIも行列計算も出てきません。 コードの意味は後から詳細に解説しますが、まずは「難しくなさそう」と思ってもらえるでしょうか?
package.path = "../LjES/?.lua;" .. package.path local demo = require("demo") demo.screen(0, 0) local aSpace = demo.getSpace() local eye = aSpace:addNode(nil, "eye") eye:setPosition(0, 0, 30) demo.backgroundColor(0.2, 0.2, 0.4) local shape = Shape:new() shape:donut(8, 3, 16, 16) shape:endShape() shape:shaderParameter("color", {0.5, 0.3, 0.0, 1.0}) local node = aSpace:addNode(nil, "node1") node:setPosition(0, 0, 0) node:addShape(shape) function draw() node:rotateX(0.1) aSpace:draw(eye) end demo.loop(draw, true) demo.exit()
「shape:donut(8, 3, 16, 16)」の部分を変更して、それぞれ実行すると以下のような画面が全画面で表示されます。[q] キーを押すか、5000回表示(約1分20秒)されると終了します。[p] キーを押すとスクリーンショットが ss日付_時間.png というファイル名でカレントディレクトリに保存されます。
donut(8, 3, 16, 16) | sphere(8, 16, 16) |
---|---|
double_cone(8, 8, 16) | truncated_cone(8, 2, 8, 16) |
サンプルコードの解説
上のソースと全く同じですが、行ごとの動作を説明しやすいように行番号を付けて、色分けしています。
01: package.path = "../LjES/?.lua;" .. package.path -- (A) 02: local demo = require("demo") -- (B) 03: 04: demo.screen(0, 0) -- (C) 05: demo.backgroundColor(0.2, 0.2, 0.4) -- (D) 06: 07: local aSpace = demo.getSpace() -- (E) 08: 09: local eye = aSpace:addNode(nil, "eye") -- (F) 10: eye:setPosition(0, 0, 30) -- (G) 11: 12: local shape = Shape:new() -- (H) 13: shape:donut(8, 3, 16, 16) -- (I) 14: shape:endShape() -- (J) 15: shape:shaderParameter("color", {0.5, 0.3, 0.0, 1.0}) -- (K) 16: 17: local node = aSpace:addNode(nil, "node1") -- (L) 18: node:setPosition(0, 0, 0) -- (M) 19: node:addShape(shape) -- (N) 20: 21: function draw() -- (O) 22: node:rotateX(0.1) -- (P) 23: aSpace:draw(eye) -- (Q) 24: end 25: 26: demo.loop(draw, true) -- (R) 27: demo.exit() -- (S)
先頭の記号は、コードのコメントの (A) から (S) に対応しています。
初期化
- (A) モジュールの検索パスの設定
-
LjES のコードが格納されているパスを、モジュールの検索パスの先頭に追加しています。 「../LjES/?.lua;」という文字列を package.path の先頭部分に追加することで、「1つ上のディレクトリの下の LjES ディレクトリにある拡張子が .lua というファイル」をまずモジュールとして探すという意味になります。 この場合は下のように example ディレクトリにあるコードが LjES ディレクトリ以下のファイルをモジュールとして読み込むことができるようになります。
--+-- LjES/ -- ?.lua | +-- example/
実際の検索パスの一覧../LjES/?.lua; ./?.lua; /usr/share/luajit-2.0.0-beta11/?.lua; /usr/local/share/lua/5.1/?.lua; /usr/local/share/lua/5.1/?/init.lua; /usr/share/lua/5.1/?.lua; /usr/share/lua/5.1/?/init.lua
- (B) モジュールの読み込み
-
4行目にある「local demo = require("demo")」では、require("demo")から、変数と関数が登録されたテーブル(オブジェクト)が返ります。 これをローカル変数の demo に代入しているため、demo.screen() といった形式で demo モジュールの関数を実行できます。
また、demo モジュールがロードしているモジュールのうちいくつかは、クラスオブジェクトを定義しています。 そのため demo モジュールを読み込むとクラスとなる Font、Matrix、Node、Object、Phong、Quat、Screen、Shader、Shape、Space、Text がグローバル変数として登録されます。 demo モジュールを読み込んだプログラム内で使用できます。 LjES では 大文字から始まるファイル名の場合は、同じ名前のクラスがグローバル名前空間に登録されます。 - (C) 表示領域の設定
- グラフィックを表示するスクリーンの領域を指定します。「demo.screen(幅, 高さ)」という形式で指定します。
- (D) 表示領域の背景色の指定
- 物体やカメラが動く度に画面全体を書き直しますが、前の画面を消去するのに使う色を R(赤)、G(緑)、B(青)について 0.0 から 1.0 の範囲で指定します。demo.backgroundColor(0.2, 0.2, 0.4) としているので、背景色は暗い青色になります。
- (E) 空間の取得
- demo モジュール内部で Spaceクラスのインスタンスが作成されます。物体を登録するために必要なSpaceクラスのインスタンスを demo.getSpace() で取得して ローカル変数に保存しています。
視点の設定
- (F) カメラの作成
- 物体を表示するため、それを見ている目(カメラ)の位置や角度を指定する必要があります。LjESでは、カメラも物体も空間(Space)内に存在する Node クラスのインスタンスとして管理します。 物体もカメラも区別はなく、すべての「ノード」が視点になることができます。ここでは Node クラスのインスタンスを空間に一つ追加して eye という変数に格納しています。
- (G) カメラ位置の指定
- eye というノードの setPosition(X座標値, Y座標値, Z座標値) メソッドで視点の位置を指定します。 X座標、Y座標、Z座標の向きは OpenGL の座標系で下の図のようになります。
見ている方向は初期値としてZ軸のマイナスの方向(奥に向かって)を見ています。 Z座標に30を指定することで、原点からそのまま後ろに下がって原点方向を見ているように設定しています。 物体の大きさと視点の位置の関係は、視野角の大きさに依存しますが、初期設定では物体と視点との距離と、物体の水平方向の大きさが同じ場合に画面いっぱいに表示されます。 大きなものを表示するには離れる必要があり、小さなものは近づけば見えるという、ごく普通の関係です。
物体の形状に関する設定
- (H) 形状用のインスタンスの作成
- 物体を表示するためには、物体の形状を頂点座標と頂点に囲まれた面として登録する必要があります。 この頂点や面の情報を格納するために Shape クラスを使います。
- (I) ドーナッツ型の作成
- いくつかの形状はサイズを指定して簡単に作成できるようになっています。Shape クラスのインスタンスである shape の donut メソッドを実行して、ドーナッツの形状を登録しています。 donut メソッドの最初の引数はリングの半径、2番めの引数はリングの太さの半径、3番めはリングの円周方向の分割数、4番目の引数はリングの太さ方向の分割数です。 分割数が多いほどなめらかになります。
- (J) 形状設定の終了
- LjES にとって endShape メソッドは非常に重要な役目があります。 頂点や面を順次登録した後に実行することで、頂点の法線ベクトルやテクスチャ座標の計算を行い、頂点や面、テクスチャ座標、法線ベクトルの情報をGPUに転送します。 endShape メソッドの実行が形状定義の最後になります。
- (K) 物体の色を指定
- 物体の表面の色や滑らかさのような性質はシェーダプログラムで決められます。こういった性質をシェーダに対して設定する必要があります。Shape:shaderParameter メソッドはこのシェーダへの設定に使用するメソッドです。 使用できるパラメータの名称はシェーダに依存します。LjES の Phong シェーダは "color"、"tex_unit"、"use_texture"、"light"、"emissive"、"ambient"、 "specular"、"power" をパラメータの名称として認識します。 後からシェーダを変更することを可能にするため、パラメータの名称を使って設定するようになっています。
物体の位置と姿勢に関する設定
- (L) ノードの作成
- 物体の位置と姿勢は Node クラスのインスタンスのメソッドを使ってコントロールします。形状を作成したドーナッツは、 Space に追加したノードに形状を割り当てることで位置と姿勢を制御できるようになります。 ノードを追加する時に、親ノードを指定すると、追加したノードは親ノードのローカル座標系に存在するようになります。もし親ノードを nil と指定した場合は、追加されたノードはワールド座標系に存在することになります。
- (M) 位置の設定
- Node:setPosition(0, 0, 0) メソッドで物体のノードを原点に配置しています。
- (N) ノードに形状を登録
- 作成した形状をノードに割り当てています。これでノードを動かすとドーナッツが動きます。
アニメーションの描画
- (O) アニメーション描画の更新用の関数の作成
- アニメーションを表示するプログラムは、定期的にスクリーンを描画するための関数が必要です。ここで作成する関数は1秒間に最大60回呼ばれます。
- (P) ノードの回転
- アニメーション描画関数の中でノードの移動や回転を実行すると、そのノードは連続して移動や回転をするようになります。
- (Q) 空間の表示
- 視点(カメラ)や物体のノードが含まれる空間 (Space) の draw メソッドを視点となるノードを引数に指定して呼び出すと視点ノードから見た風景が表示されます。
- (R) アニメーションループの開始
- demo モジュールの demo.loop 関数の最初の引数として、(O) で定義したアニメーション描画更新用の関数を指定して実行するとアニメーションが開始されます。 2番めの引数はFPS (frames per second) を表示するかどうかのフラグになります。 true を指定すると1秒間に書き換えた回数を表示するようになります。 [q] キーを押すか、5000回表示すると終了します。
終了処理
- (S) 終了処理
- コンソールのキーの再設定を行います。 起動時に実行中にキー入力を受け付けるため、キー入力待ち、押したキーの表示、改行を行わないように設定しています。 この設定を元に戻しています。もし実行中にエラーが発生すると、キー入力できない(キーを押しても表示されない)症状になる場合があります。キーが受け付けられないように見えても、「./term.lua[エンター]」と入力すれば元に戻ります。