LuaJITでお手軽3Dプログラミング (3) (2013/06/23)

3000円程度で購入できる超小型PCボードの Raspberry Pi の上で、LuaJIT という非常に高速なスクリプト言語を使って、3Dプログラミングを手軽に楽しもうというシリーズの3回目です。前回は、LjES を使って20行ほどで 3D アニメーションのプログラムを作成できることを紹介しました。 今回から LjES をもう少し詳しく見ていくことにします。

Raspberry Pi の公式ディスクイメージの Raspbian には最初から LuaJIT が入っているため、新たに何もインストールする必要もなくプログラミングを始められます。 サンプルプログラムを試すには 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

3D アニメーションとは

3D アニメーションは、3次元コンピュータグラフィックスで作成した画像を少しずつ位置や角度を変えながら高速に表示することでなめらかに動いているように見せる技術です。 3次元コンピュータグラフィックスは、3次元の座標で表した物体を「写真を撮影するように」2次元の画像に透視投影して作成します。

LjES を使ったプログラムでは、多くの X座標、Y座標、Z座標で指定された頂点を用意し、それらの頂点の中の3つの頂点を使って3角形を定義する (頂点間に面を貼る) ことで3次元の物体を表します (Shapeクラスが担当)。 次に空間内の特定の位置に視点 (Nodeクラス) を置いて、そこから見える空間内 (Spaceクラスが担当) に存在する物体の位置や角度 (Nodeクラスが担当) を計算して求めて、視点から見える風景の画像を作成(レンダリング)します。 なめらかにアニメーションさせるためには1秒間に数十回の速さで画像を計算して作成、表示(Screenクラスが担当)する必要があります。

LjES の構成

下の図は LjES のおおまかな構成を示していて、下のほうがより低レベルな機能を実現しています。現在のLjES (ljes-1.00) は 24のソースファイルで構成されています。



以下のファイル名が大文字で始まるファイル(図の黄色の部分)は、LjESでコアとなるクラスを定義しています。 OpenGL ES2で必要となる行列演算、GPUへの転送、シェーダプログラミングといった機能や物体の形状や動作の指定、画面への文字表示といった部分を担当しています。LjES を使ってプログラミングをするにはこれらのクラスを必要に応じて使うことになります。

ファイル名 クラス名 機能
Space.lua Space 物体が存在する空間
Node.lua Node 物体の位置と姿勢、階層構造を保持
Screen.lua Screen GPUの初期化と表示領域の設定
Shape.lua Shape 物体の形状(頂点と面)、色などの属性を保持
Text.lua Text スクリーンに表示するテキストを管理
TexText.lua TexText Textと同じく表示テキストを管理するがフォントを画像で指定
Texture.lua Texture テクスチャ用のpng画像を管理
Phong.lua Phong Phongシェイディングモデルのシェーダ
Font.lua Font テキスト表示用のシェーダ
Shader.lua Shader シェーダの基本クラス
Matrix.lua Matrix 行列演算
Quat.lua Quat クォータニオンの演算
Message.lua Message 文字表示クラス
Background.lua Background 背景用のシェーダ
FrameBufferObject.lua FrameBufferObject フレームバッファオブジェクト
Object.lua Object すべてのクラスの祖先

以下のファイル(図のグリーンの部分)は、RaspberryPi のハードウェアに近い、低レベルなライブラリ関数(共有ライブラリ、図中の赤い部分)を LuaJIT の ffi を使って、LuaJIT から使用できる形式に変換しています。

ファイル名 機能
bcm.lua 画面の初期化など Raspberry Pi専用の関数群。
egl.lua EGLをLuaJITから利用するための関数群。
gles2.lua OpenGL ES2をLuaJITから利用するための関数群。関数名はWebGLと互換にしています。
png.lua libpngをLuaJITから利用するための関数群。
termios.lua キー入力のためコンソールの設定変更用の関数。
util.lua 時間取得やスリープなどのユーティリティ関数。

以下の2つのファイル(図の青い部分)は、LjESのクラスを使ったフレームワークのサンプルです。デモ用にある程度定型的な処理をまとめています。小さなプログラムではこのフレームワークを使うと楽できます。コピーして自分用に改造して使って下さい。

ファイル名 機能
demo.lua LjESのサンプル用のフレームワーク
demo2.lua demo.luaと同じ機能だが文字表示にTexTextを使うもの

LjES の使い方

色々な初期化といった定型的な部分を除くと、LjES を使った 3Dアニメーションのプログラミングは、「Shapeクラスで形を決めて、Nodeクラスで位置と姿勢を指定して物体を動かす。」ということができます。

Shapeクラスの変数 (以後 Shape オブジェクトと呼ぶことにします) は物体の大きさや形状、色といった頂点や面の情報を持っていて、Nodeクラスの変数 (以後 Node オブジェクトと呼ぶことにします) は物体の位置や姿勢の情報を持っています。LjES を使ったプログラムは、画面などの初期化のあとにShapeクラスを使った物体の定義が続きます。 物体の形状が定義された Shape オブジェクトを、位置と姿勢を指定するためのNodeオブジェクトに登録することで個々の物体の定義が完了します。あとは Node オブジェクトの移動や回転のメソッドを使って物体を動かします。

空間 (Space) には色々な物体 (Node) が存在し、物体はいくつかの形状 (Shape) で構成されます。 図のように Space オブジェクトは複数の Node オブジェクトを保持することができます。 同様にNode オブジェクトは複数のShapeオブジェクトを持つことができます。 Shape オブジェクトは Shader オブジェクトを持ちます。 Shape オブジェクトはデフォルトではShader クラスの派生クラスである Phong クラスのオブジェクトが登録されています。 Shaderの種類を変更すると、例えばすべての物体をモノクロに変更するなど、物体の質感を変更できますが、普通は変更する必要はありません。Node オブジェクトは 親のNodeオブジェクトを指定することができ、親のNodeオブジェクトの座標系に所属させることで簡単に階層構造とすることができます。関節を持つ物体や、太陽、地球、月のような従属関係を構成することができます。

Spaceクラスのメソッド

Spaceクラスは「3次元空間」を表していて、物体を Nodeオブジェクトとして保持します。addNode メソッドでNodeオブジェクトを生成し、draw メソッドでシーン(3次元空間)をレンダリング(描画)します。

メソッド 機能
new() Spaceクラスのインスタンスを返す。
addNode(parent_node, name) Nodeクラスのインスタンスを生成して返す。
delNode(name) ノードの名称を指定して、ノードを削除する。
findNode(name) ノードの名称を指定して、ノードを返す。
listNode() ノードの一覧をコンソールに表示する。
now() 現在時間を秒で返す(マイクロ秒の精度)。
timerStart() タイマーを開始する。
uptime() タイマーを開始してからの時間を秒で返す(マイクロ秒の精度)。
deltaTime() 前回表示してからの経過時間を秒で返す(マイクロ秒の精度)。
count() 表示回数を返す。
draw(eye_node) 指定した視点ノードから見える空間を表示する。

Nodeクラスのメソッド

物体の位置と姿勢を管理します。Shapeオブジェクトを追加することで物体の形状を付加します。Shapeオブジェクトを追加しなければ視点のように形状が無く位置や角度だけをもつ仮想的な物体を表現できます。


メソッド 機能
addShape(shape) Shapeクラスのインスタンスの登録。
delShape() Shapeクラスのインスタンスの削除。
getShape(n) Shapeクラスのインスタンスの取得。
setAttitude(head, pitch, bank) 姿勢(角度)の指定。ヘッド(左右)、ピッチ(上下)、バンク([反]時計回り)を度を単位とした小数点数で与える。
getWorldAttitude() ワールド座標系における姿勢(角度)の取得。ヘッド(左右)、ピッチ(上下)、バンク([反]時計回り)の順で度を単位とした小数点数で3つの値が返る。
getLocalAttitude() ローカル座標系における姿勢(角度)の取得。。ヘッド(左右)、ピッチ(上下)、バンク([反]時計回り)の順で度を単位とした小数点数で3つの値が返る。
setPosition(x, y, z) ワールド座標系における位置の設定。
setPositionX(x) ワールド座標系における位置(X座標)の設定。
setPositionY(y) ワールド座標系における位置(Y座標)の設定。
setPositionZ(z) ワールド座標系における位置(Z座標)の設定。
rotateX(degree) ローカル座標系のX軸周りの回転。度を単位とした小数点数で与える。
rotateY(degree) ローカル座標系のY軸周りの回転。度を単位とした小数点数で与える。
rotateZ(degree) ローカル座標系のZ軸周りの回転。度を単位とした小数点数で与える。
rotate(head, pitch, bank)ローカル座標系のZ軸周りの回転。度を単位とした小数点数で与える。
move(x, y, z) 位置の変更(ローカル座標系で指定)。
detach() 親座標系から離れて、親座標系がワールド座標系となる。
attach(parent_node) 指定した親座標系に所属させる。
distance(node) 指定したNodeとの距離を返す。
getWorldPosition() ワールド座標系の位置を返す。

Shapeクラスのメソッド

頂点座標や面の情報を持って物体の形状を管理します。頂点のテクスチャ座標の計算、法線ベクトルの計算も可能な限り自動的に行われます。頂点や面を登録した後に endShape メソッドを実行すると頂点バッファオブジェクト、インデックスバッファオブジェクトを作成してGPU側に転送することで、その後の移動や回転を伴う描画もGPU側だけで効率的に行われます。球体、円柱、円錐、直方体、任意の回転体を生成するメソッドも用意されています。

メソッド 機能
new()Shapeクラスのインスタンスを生成して返す。
referShape(shape)別のShapeクラスのインスタンスを指定して、そこで定義された形状を参照(形状をそのまま使う)する。
getVertexCount()この形状に含まれる頂点数を返す。
getTriangleCount()面(3角形)の数を返す。多角形は三角形に分割されてカウントされる。
shaderParameter(key, value)シェーダのパラメータを設定する。
setShader(shader)形状を表示するシェーダ(シェーダクラスのインスタンス)を設定する。
setTexture(texture)テクスチャクラスのインスタンスを設定する。
setTextureMappingMode(mode)テクスチャマッピングのモード(0:球面、1:投影)を指定する。
setTextureMappingAxis(axis)テクスチャの貼り付け軸を指定する。軸の指定には整数を指定し、球面マッピングの場合は、0, 1, -1 : Y軸周り、 2, -2 : X軸周り、 3, -3 : Z軸周りとなる。投影マッピングの場合は、0, 1, -1 : XY面(Z軸に直角)、 2, -2 : YZ面(X軸に直角)、 3, -3 : XZ面(Y軸に直角)となる。
setTextureScale(scale_u, scale_v)テクスチャの拡大率を設定する。値が大きいと貼られたテクスチャは拡大して見える。テクスチャのサイズは(1.0, 1.0)で形状のサイズが1より大きいと複数枚くり返し貼られる。
endShape()形状定義を終了してシェーダに転送する。頂点や面を登録後に必ず呼び出す必要がある。
addVertex(x, y, z)座標を指定して頂点を追加する。
addVertexUV(x, y, z, u, v)座標とテクスチャ座標を指定して頂点を追加する。
addVertexPosUV(pos, uv)座標とテクスチャ座標をテーブルで指定して頂点を追加する。
setVertNormal(vn, x, y, z)頂点の法線ベクトルを設定する。
getVertNormal(vn)頂点の法線ベクトルを返す。
getVertPosition(vn)頂点の座標(X, Y, Z)を返す。
addTriangle(p0, p1, p2)頂点番号を指定して面(三角形)を登録する。
addPlane(indices)頂点番号の配列(Lua のテーブル)でポリゴンを登録する。
revolution(latitude, longitude, verts, spherical)回転体を生成する。
縦方向の面の数(分割数)、水平方向の分割数、断面の形状を指定する頂点(XY面)の配列、テクスチャのモード(0:球面、1:投影)を与える。
sphere(radius, latitude, longitude)球を生成する。後から endShape() を呼ぶ必要がある。半径、縦方向の面の数(分割数)、水平方向の分割数を引数として与える。
donut(radius, radiusTube, latitude, longitude)ドーナッツ型を生成する。後から endShape() を呼ぶ必要がある。半径、太さ、縦方向の面の数(分割数)、水平方向の分割数を引数として与える。
cone(height, radius, n)形状として角錐(円錐)を生成する。後から endShape() を呼ぶ必要がある。半径、太さ、縦方向の面の数(分割数)、水平方向の分割数を引数として与える。
truncated_cone(height, radiusTop, radiusBottom, n)角錐台(円錐台)を生成する。後から endShape() を呼ぶ必要がある。高さ、上端の半径、下端の半径、水平方向の分割数を引数として与える。
double_cone(height, radius, n)角錐を張り合わせた形状を生成する。後から endShape() を呼ぶ必要がある。高さ、半径、水平方向の分割数を引数として与える。
prism(height, radius, n)形状として角柱(円柱)を生成する。後から endShape() を呼ぶ必要がある。高さ、半径、水平方向の分割数を引数として与える。
arrow(length, head, width, n)形状として矢印型を生成する。後から endShape() を呼ぶ必要がある。
cuboid(size_x, size_y, size_z)形状として直方体を生成する。後から endShape() を呼ぶ必要がある。X方向の大きさ、Y方向の大きさ、Z方向の大きさを引数として与える。
mapCuboid(size_x, size_y, size_z)形状として6面のテクスチャを貼る直方体を生成する。後から endShape() を呼ぶ必要がある。X方向の大きさ、Y方向の大きさ、Z方向の大きさを引数として与える。
cube(size)形状として立方体を生成する。後から endShape() を呼ぶ必要がある。1辺の大きさを引数として与える。
mapCube(size)形状として6面のテクスチャを貼る立方体を生成する。後から endShape() を呼ぶ必要がある。1辺の大きさを引数として与える。

使ってみる

もう一度前回のサンプル を示します。リストの赤い部分で Shapeクラスのオブジェクトを生成して、Shapeオブジェクトでドーナッツの形状と色を指定しています。 青い部分は Node クラスのオブジェクトで位置と角度を設定していますが、形状として先に作成したShapeオブジェクトをNodeオブジェクトに登録しています。このNodeオブジェクトを動かせば、ドーナッツがアニメーションします。連続して動かすために、この例では draw という関数のなかでドーナッツの Node をX軸周りに少し (0.1度) 回転させるメソッドを呼び出し(緑色の部分)、この関数を demo.loop 関数で繰返し呼び出します。こうしてドーナッツがアニメーションすることになります。

package.path = "../LjES/?.lua;" .. package.path      -- (A)
local demo = require("demo")                         -- (B)

demo.screen(0, 0)                                    -- (C)
demo.backgroundColor(0.2, 0.2, 0.4)                  -- (D)

local aSpace = demo.getSpace()                       -- (E)

local eye = aSpace:addNode(nil, "eye")               -- (F)
eye:setPosition(0, 0, 30)                            -- (G)

local shape = Shape:new()                            -- (H)
shape:donut(8, 3, 16, 16)                            -- (I)
shape:endShape()                                     -- (J)
shape:shaderParameter("color", {0.5, 0.3, 0.0, 1.0}) -- (K)

local node = aSpace:addNode(nil, "node1")            -- (L)
node:setPosition(0, 0, 0)                            -- (M)
node:addShape(shape)                                 -- (N)

function draw()                                      -- (O)
  node:rotateX(0.1)                                  -- (P)
  aSpace:draw(eye)                                   -- (Q)
end

demo.loop(draw, true)                                -- (R)
demo.exit()                                          -- (S)

赤い Shape クラス関連の行を書き換えると、物体の形状を変化させることができます。 青い Node クラスでは初期位置を指定しています。 描画ループから何度も呼び出される draw() 関数内の 緑色の行では0.1度ずつX軸周りに回転していますが、この部分を書き換えることで動きを変更することができるようになります。

実際に操作してみる

実際にプログラムを修正して、動かしてみましょう。すべてコンソールで作業します。Raspberry Pi が起動した画面でログインするか、ssh でリモートでログインするか、X のデスクトップ上で LXTerminal (ターミナル) を起動して作業して下さい。ssh でリモートから実行しても Raspberry Pi のディスプレイに表示されます。

X のデスクトップで LjESのサンプルを起動すると、X のデスクトップを隠すように表示されます。これは X のウィンドウとして表示されているのではなく、別のレイヤーとして上にかぶせて表示されています。 マウスで不用意にクリックすると、裏(下?)に表示されているウィンドウを操作することになってしまって、サンプルを起動したターミナルからフォーカスが外れてしまって、キー入力が LjES のサンプルに届かなくなってしまいます。その場合「q」キーでサンプルを終了することができなくなりますが、待っていると1〜2分でサンプルプログラムは終了してデスクトップが表示されます。


donut.lua をコピー

元のファイルを壊してしまわないように、別のディレクトリに移動してコピーを作って、それを修正します。すでに ljes-1.00.tar.gz を展開してあるものとします。もし指定なければ「 curl -O https://www.mztn.org/rpi/ljes-1.00.tar.gz 」でダウンロードして「 tar zxf ljes-1.00.tar.gz 」で展開して下さい。まず最初に、ljes-1.00ディレクトリに移動します。 ljes-1.00ディレクトリには何もファイルのない test ディレクトリがあるので、そこに移動します。次に examples ディレクトリの donut.lua を、test ディレクトリに sample01.lua という名前でコピーします。

  cd ljes-1.00
  cd test
  cp ../examples/donut.lua sample01.lua

ソースファイルの編集

コンソール (CUIの画面) でテキストファイルの編集するために nano エディタを使ってみます。UNIX系の OS に慣れている方は vim でも emacs でもかまいません。 vim や emacs は非常に高機能なエディタですが、慣れていないと使い方が全くわからないと思います。終了することすら難しいと思います。 nano の特徴は下の図のようにいつもコマンドの説明が下に表示されている (英語ですが) ので、「^X」がコントロールキーを押しながら X を押すことさえ知っていれば大丈夫です。「^G」がヘルプの表示、「^O」がファイルの保存で、「^X」が終了です。

  GNU nano 2.2.6              New Buffer                                      







^G Get Help ^O WriteOut ^R Read File^Y Prev Page^K Cut Text ^C Cur Pos
^X Exit     ^J Justify  ^W Where Is ^V Next Page^U UnCut Tex^T To Spell

さて、先ほどコピーして作成した sample01.lua を nano で開いてみましう。

  nano sample01.lua

次のように表示されます。カーソルキーでカーソルを移動させて、普通に編集できると思います。「^O」で修正したファイルを保存して、「^X」で終了します。

  GNU nano 2.2.6            File: sample01.lua                                
package.path = "../LjES/?.lua;" .. package.path
local demo = require("demo")

demo.screen(0, 0)
demo.backgroundColor(0.2, 0.2, 0.4)

local aSpace = demo.getSpace()

local eye = aSpace:addNode(nil, "eye")
eye:setPosition(0, 0, 60)

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)

local node2 = aSpace:addNode(node, "node2")
node2:setPosition(0, 0, 30)
node2:addShape(shape)

function draw()
  node:rotateX(0.1)
  node2:rotateZ(1.0)
  aSpace:draw(eye)
end

demo.loop(draw, true)
demo.exit()

^G Get Help ^O WriteOut ^R Read File^Y Prev Page^K Cut Text ^C Cur Pos
^X Exit     ^J Justify  ^W Where Is ^V Next Page^U UnCut Tex^T To Spell

赤い部分が変更、追加した行です。最初の「60」は視点を後に下げて広い範囲を見えるようにしています。次の3行はノードを追加しています。追加するノードの変数名と名前を 「node2」と指定して、親を元からある「node」にして階層構造にします。node2:setPosition(0, 0, 30) で追加したノードの位置を前(Z座標が大きい)にします。node2 の形状は前と同じ物を指定します。最後の「node2:rotateZ(1.0)」でZ軸周りに1度ずつ回転します。親座標系が node なので、真ん中のドーナッツの周りを回転しながら回ります。

中央部の3行は前の3行とほぼ同じなのでコピーしたくなります。 nano でコピーするには、コピーしたい領域の先頭で「^^」(コントロールキーを押しながら[^]を押す) でコピー領域の先頭を指定して、カーソルをコピーしたい領域の最終まで持って行って、「^K」でカット(内部のバッファーに取り込み)し、「^U」でその場にカットした部分をペーストして戻し、「^U」もう一度ペーストすることで複写できます。

「^O」でファイルを保存することを忘れないで下さい。

実行します。

$ luajit sample01.lua

サンプルを色々書き換えて試して下さい。


最近は日本でも Raspberry Pi 関連の書籍が発刊され始めましたが、Raspberry Pi のOS は普通の Linux です。 UNIX系の OS に慣れていない人は、ちゃんと Linux のコマンドが基本から解説されている書籍、例えば 新Linux/UNIX入門 第3版 などを1冊手元に持っているといいと思います。


続く...