13Dモデルのコンバート
今回は3Dレースゲームを作ります。 完成画面は図1-1の通りです。

まずは、ゲームで使う車の3Dモデルをお好みのツールで作り、「.fbx」形式で出力しておきます。 今回はサンプルとして「samples/free_resources/obj_car.fbx」を使います。
「.fbx」形式は3Dモデルの一般的な形式ですが、プログラムで読み込むには効率が悪いため、効率の良いKuinのバイナリ形式「.knobj」にコンバートして使います。
Kuinエディタを起動して、メニューから「ツール」「3Dモデルコンバータ...」を選び、「...」からfbxファイルを選んで「コンバート」ボタンを押すと、しばらくしてからfbxファイルと同じフォルダ内にknobjファイルが出力されます(図1-2)。

注意点として、fbxファイルはすべてのポリゴンが三角形になっている必要があります。 三角分割をしてからfbxを保存してください。
また今回は使いませんが、3Dモデルコンバータのチェックボックスは表1-1の通りです。
オプション | 説明 |
---|---|
タンジェントとバイノーマルを書き込む | UVからタンジェントとバイノーマルを計算し、knobjに書き込みます。 法線マップが使えるようになりますが、データが大きくなり、UVが縮退していると描画が崩れます。 法線マップを使わない場合はオフにしてください。 |
ジョイントを書き込む | ボーンとボーンアニメーションをknobjに書き込みます。 各頂点が影響を受けるボーンの数は最大で2個です。 ボーンの総数は最大で256個です。 ボーンを動かさない場合はオフにしてください。 |
knobjファイルが出力されたら、「3Dモデルコンバータ」ダイアログを閉じ、「Ctrl+S」を押してメインのknファイルを保存し、「Ctrl+Shift+R」を押して表示される「res」フォルダ以下にknobjファイルを「obj_car.knobj」の名前で移動させます。
また、3Dモデルに貼るためのテクスチャもresフォルダ以下に「tex_car_albedo.png」の名前で用意します。 今回はサンプルとして「samples/free_resources/tex_car_albedo.png」をコピーして配置します。
ついでに、今回使うファイルをsamples/free_resourcesフォルダからコピーしましょう。 「tex_field_albedo.jpg」「tex_sky_albedo.png」「tex_wall_albedo.jpg」「map_sample_racing.txt」もコピーして配置します(図1-3)。

2車の描画
それでは早速3Dモデルを描画しましょう。 第2回のようにスニペットから「mainとdrawコントロール」のコードを挿入し、図2-1のコードを追加します。
- var wndMain: wnd@Wnd
- var drawMain: wnd@Draw
- var objCar: draw@Obj
- var texCarAlbedo: draw@Tex
-
- func main()
- do @wndMain :: wnd@makeWnd(null, %aspect, 1600, 900, "Title")
- do @drawMain :: wnd@makeDraw(@wndMain, 0, 0, 1600, 900, %scale, %scale, false)
-
- do @objCar :: draw@makeObj("res/obj_car.knobj")
- do @texCarAlbedo :: draw@makeTex("res/tex_car_albedo.png")
- do draw@depth(true, true)
-
- while(wnd@act())
- do @objCar.draw(0, 0.0, @texCarAlbedo, null, null)
- do draw@render(60)
- end while
- end func
3、4行目で3Dモデルとテクスチャ画像の変数を宣言し、10、11行目でファイルからロードしています。
12行目は、奥行きの前後関係を解決するためのZバッファの設定で、draw@depth関数の括弧内は順に「Zテストが有効か」「Z書き込みが有効か」です。 通常は、3Dを描画するときは有効化して「draw@depth(true,true)」とし、2Dを最前面に描画するときは「draw@depth(false,false)」とします。
15行目で、テクスチャを表面に貼った3Dモデルを画面に描画しています。 drawメソッドの括弧内は順に「メッシュのインデックス」「アニメーションのフレーム」「ディフューズマップテクスチャ(色の反射)」「スペキュラマップテクスチャ(光沢)」「法線マップテクスチャ(凹凸)」です。
カメラやライトは何も設定しなくても、ある程度使いやすい設定になっています。 実行すると、車が表示されます(図2-2)。

3地面と影の描画
次に、地面と影を描画します。
地面は単なる平面にテクスチャを貼って作ります。 平面のような単純な3Dモデルを作るだけなら「.knobj」ファイルを用意する必要はなく、関数から作れます。
影を描画するには、いったんdraw@Shadowクラスのインスタンスを作って、そこに影を落とす3Dモデルを登録しておき、先ほどのdrawメソッドの代わりにdrawWithShadowメソッドを使用します(図3-1)。
- var wndMain: wnd@Wnd
- var drawMain: wnd@Draw
- var objCar: draw@Obj
- var texCarAlbedo: draw@Tex
- var objField: draw@Obj
- var texFieldAlbedo: draw@Tex
- var shadow: draw@Shadow
-
- func main()
- do @wndMain :: wnd@makeWnd(null, %aspect, 1600, 900, "Title")
- do @drawMain :: wnd@makeDraw(@wndMain, 0, 0, 1600, 900, %scale, %scale, false)
-
- do @objCar :: draw@makeObj("res/obj_car.knobj")
- do @texCarAlbedo :: draw@makeTex("res/tex_car_albedo.png")
- do draw@depth(true, true)
- do @objField :: draw@makePlane()
- do @objField.pos(80.0, 1.0, 80.0, 0.0, 0.0, 0.0, 38.0, 0.0, 38.0)
- do @texFieldAlbedo :: draw@makeTex("res/tex_field_albedo.jpg")
- do @shadow :: draw@makeShadow(1024, 1024)
-
- while(wnd@act())
- do @shadow.beginRecord(0.0, 0.0, 0.0, 5.0)
- do @shadow.add(@objCar, 0, 0.0)
- do @shadow.endRecord()
-
- do @objField.drawWithShadow(0, 0.0, @texFieldAlbedo, null, null, @shadow)
- do @objCar.drawWithShadow(0, 0.0, @texCarAlbedo, null, null, @shadow)
- do draw@render(60)
- end while
- end func
27行目も忘れず変更してください。
16行目で地面を表す平面モデルを作成し、17行目で位置と大きさを調整しています。 posメソッドの括弧内は順に「拡縮X」「拡縮Y」「拡縮Z」「回転X」「回転Y」「回転Z」「位置X」「位置Y」「位置Z」です。
19行目で影のバッファを作成し、22から24行目で影を落とすモデルを登録しています。 このように、beginRecordとendRecordで囲んだ中で、モデルをaddメソッドで追加します。
22行目のbeginRecordの括弧内は、影を計算する範囲を球で指定します。 順に「球の位置X」「位置Y」「位置Z」「球の半径」です。 球を大きくしすぎると影が粗くなり、小さくしすぎると球の外の影は描画されません。
実行すると、図3-2のようになります。

4車の移動とカメラ
いよいよ、車を移動させます。
横視点アクションゲームのときのように、車の位置を、game@Rectを継承したクラスで管理します(図4-1)。
- class Car(game@Rect)
- +var angle: float
- end class
-
- var wndMain: wnd@Wnd
- var drawMain: wnd@Draw
- var objCar: draw@Obj
- var texCarAlbedo: draw@Tex
- var objField: draw@Obj
- var texFieldAlbedo: draw@Tex
- var shadow: draw@Shadow
- var car: @Car
-
- func main()
- do @wndMain :: wnd@makeWnd(null, %aspect, 1600, 900, "Title")
- do @drawMain :: wnd@makeDraw(@wndMain, 0, 0, 1600, 900, %scale, %scale, false)
-
- do @objCar :: draw@makeObj("res/obj_car.knobj")
- do @texCarAlbedo :: draw@makeTex("res/tex_car_albedo.png")
- do draw@depth(true, true)
- do @objField :: draw@makePlane()
- do @objField.pos(80.0, 1.0, 80.0, 0.0, 0.0, 0.0, 38.0, 0.0, 38.0)
- do @texFieldAlbedo :: draw@makeTex("res/tex_field_albedo.jpg")
- do @shadow :: draw@makeShadow(1024, 1024)
-
- do @car :: #@Car
- do @car.x :: 4.0 * 2.0
- do @car.y :: 4.0 * 12.0
- do @car.width :: 1.5
- do @car.height :: 1.5
- do @car.angle :: 0.0
-
- while(wnd@act())
- if(input@pad(0, %a) >= 1)
- do @car.veloX :- lib@sin(@car.angle) * 0.01
- do @car.veloY :- lib@cos(@car.angle) * 0.01
- end if
- if(input@pad(0, %b) >= 1)
- do @car.veloX :+ lib@sin(@car.angle) * 0.01
- do @car.veloY :+ lib@cos(@car.angle) * 0.01
- end if
- if(input@pad(0, %left) >= 1)
- do @car.angle :+ lib@dist(@car.veloX, @car.veloY, 0.0, 0.0) * 0.15
- end if
- if(input@pad(0, %right) >= 1)
- do @car.angle :- lib@dist(@car.veloX, @car.veloY, 0.0, 0.0) * 0.15
- end if
- do @car.move(0.25)
- do @car.backFriction(0.003)
- do @car.update()
- do draw@camera(@car.x + lib@sin(@car.angle) * 20.0, 5.0, @car.y + lib@cos(@car.angle) * 20.0, @car.x, 1.0, @car.y, 0.0, 1.0, 0.0)
- do @objCar.pos(1.0, 1.0, 1.0, 0.0, @car.angle, 0.0, @car.x, 0.0, @car.y)
-
- do @shadow.beginRecord(@car.x, 0.0, @car.y, 5.0)
- do @shadow.add(@objCar, 0, 0.0)
- do @shadow.endRecord()
-
- do @objField.drawWithShadow(0, 0.0, @texFieldAlbedo, null, null, @shadow)
- do @objCar.drawWithShadow(0, 0.0, @texCarAlbedo, null, null, @shadow)
- do draw@render(60)
- end while
- end func
54行目も忘れず変更してください。
2行目の「angle」は、車が向く角度を表します。 26から31行目でCarクラスのインスタンスを作成し、34から50行目で車の移動処理を行っています。 横視点アクションゲームのときと同様です。
51行目でカメラを設定しています。 draw@cameraの括弧内は順に「カメラの位置X」「位置Y」「位置Z」「カメラの注視点X」「注視点Y」「注視点Z」「カメラの上部が向く方向X」「Y」「Z」です。 ここでは常に車の斜め後ろから車を映すようにカメラを設定しています。
52行目で車の位置を設定しています。 また54行目で、影の作成を車の周辺、つまりカメラによく映る範囲に限って行うようにしています。
実行すると、図4-2のようになります。

ゲームパッドの「Aボタン」、もしくはキーボードの「Zキー」で発進します。 「Bボタン」もしくは「Xキー」で後退です。 走行中に左右ボタンもしくは左右キーを押すと、車は転回します。
5空と壁の描画
最後に、空と壁を描画します。 空も壁も、立方体で表現します。
壁は、横視点アクションゲームのときと同様、テキストファイルでマップを作り、game@Mapクラスで扱います。 衝突判定も横視点アクションゲームと同様です(図5-1)。
- class Car(game@Rect)
- +var angle: float
- end class
-
- var wndMain: wnd@Wnd
- var drawMain: wnd@Draw
- var objCar: draw@Obj
- var texCarAlbedo: draw@Tex
- var objField: draw@Obj
- var texFieldAlbedo: draw@Tex
- var shadow: draw@Shadow
- var car: @Car
- var objSky: draw@Obj
- var texSkyAlbedo: draw@Tex
- var objWall: draw@Obj
- var texWallAlbedo: draw@Tex
- var map: game@Map
-
- func main()
- do @wndMain :: wnd@makeWnd(null, %aspect, 1600, 900, "Title")
- do @drawMain :: wnd@makeDraw(@wndMain, 0, 0, 1600, 900, %scale, %scale, false)
-
- do @objCar :: draw@makeObj("res/obj_car.knobj")
- do @texCarAlbedo :: draw@makeTex("res/tex_car_albedo.png")
- do draw@depth(true, true)
- do @objField :: draw@makePlane()
- do @objField.pos(80.0, 1.0, 80.0, 0.0, 0.0, 0.0, 38.0, 0.0, 38.0)
- do @texFieldAlbedo :: draw@makeTex("res/tex_field_albedo.jpg")
- do @shadow :: draw@makeShadow(1024, 1024)
- do @objSky :: draw@makeBox()
- do @objSky.pos(320.0, 16.0, -320.0, 0.0, 0.0, 0.0, 40.0, 7.0, 40.0)
- do @texSkyAlbedo :: draw@makeTex("res/tex_sky_albedo.png")
- do @objWall :: draw@makeBox()
- do @texWallAlbedo :: draw@makeTex("res/tex_wall_albedo.jpg")
- do @map :: game@makeMap("res/map_sample_racing.txt", 4.0, 4.0)
-
- do @car :: #@Car
- do @car.x :: 4.0 * 2.0
- do @car.y :: 4.0 * 12.0
- do @car.width :: 1.5
- do @car.height :: 1.5
- do @car.angle :: 0.0
-
- while(wnd@act())
- if(input@pad(0, %a) >= 1)
- do @car.veloX :- lib@sin(@car.angle) * 0.01
- do @car.veloY :- lib@cos(@car.angle) * 0.01
- end if
- if(input@pad(0, %b) >= 1)
- do @car.veloX :+ lib@sin(@car.angle) * 0.01
- do @car.veloY :+ lib@cos(@car.angle) * 0.01
- end if
- if(input@pad(0, %left) >= 1)
- do @car.angle :+ lib@dist(@car.veloX, @car.veloY, 0.0, 0.0) * 0.15
- end if
- if(input@pad(0, %right) >= 1)
- do @car.angle :- lib@dist(@car.veloX, @car.veloY, 0.0, 0.0) * 0.15
- end if
- do @car.move(0.25)
- do @car.backFriction(0.003)
- do game@hitMapRect(@map, @car, getChipInfo, null)
- do @car.update()
- do draw@camera(@car.x + lib@sin(@car.angle) * 20.0, 5.0, @car.y + lib@cos(@car.angle) * 20.0, @car.x, 1.0, @car.y, 0.0, 1.0, 0.0)
- do @objCar.pos(1.0, 1.0, 1.0, 0.0, @car.angle, 0.0, @car.x, 0.0, @car.y)
-
- do @shadow.beginRecord(@car.x, 0.0, @car.y, 5.0)
- do @shadow.add(@objCar, 0, 0.0)
- do @shadow.endRecord()
-
- do @objField.drawWithShadow(0, 0.0, @texFieldAlbedo, null, null, @shadow)
- do @objCar.drawWithShadow(0, 0.0, @texCarAlbedo, null, null, @shadow)
- do @objSky.drawFlat(0, 0.0, @texSkyAlbedo)
-
- for z(0, 19)
- for x(0, 19)
- var chip: int :: @map.get(x, z)
- if(chip >= 0)
- do @objWall.pos(4.0, 2.0, 4.0, 0.0, 0.0, 0.0, x $ float * 4.0, 1.0, z $ float * 4.0)
- do @objWall.drawWithShadow(0, 0.0, @texWallAlbedo, null, null, @shadow)
- end if
- end for
- end for
- do draw@render(60)
- end while
-
- func getChipInfo(chip: int, info: game@ChipInfo)
- if(chip = 0)
- do info.shape :: %rect
- do info.solidFriction :: 0.5
- do info.repulsion :: 0.3
- else
- do info.shape :: %none
- end if
- end func
- end func
30行目、33行目では、「draw@makeBox」関数で立方体モデルを作っています。
空は、巨大な立方体で世界を包むようにして表現しています。 このため、立方体のポリゴンの表面が内側を向くようにする必要があり、31行目でZ軸の拡縮をマイナスにして裏返しています。
実行すると、図5-2のようになります。

ちゃんと壁に衝突します。 速く1周走れるように遊んだり、ステージやプログラムを改造してみると良いでしょう。
以上で、3Dレースゲームの完成です。 次回は、競技プログラミングに挑戦します。