地下鉄な3Dモデルをつくりたい
なんだか唐突になってしまいましたが。某夏アニメを見ていて、地下鉄テロのシーンがあったので、
暴走各駅停車なシーンでドンパチやれたら楽しそう、、と思って作らずにはいられなかったのですね。
去年のAdventCalender2021で曲線関係やっているし、カメラワーク関数もあるのでなんとかなるのではないかと。
Mapleを改良したい
Mapleというのは、旧Siv3Dで作っていた3Dモデル(glTF)のブロックを並べてマインクラフト風の3次元のマップをCSVファイルで作れるものです。AdventCalender2020
ひっぱりだして作り始めましたが、割としんどくて無理...が。
まず、第一に地下鉄というと、数キロ~数十キロに及ぶ建造物なので結構大きいです、1㍍のブロック並べて、、、
、、、死にます。
しかも、CSVでってどんだけ広大なんだろう?こんなのエディタ必須であろう!いや、そんなもん作ったらそもそも目的が変わってしまうに決まってる。(いつものパターンです)
第二に、ブロック一個あたり、1ファイル(.glb)の作業効率の悪さ。Blender開いて作ってgltTFエクスポートして閉じて、、次のモデルを、、って、いや、無いですわ現実的に。
100ブロックあったら100個ファイル開いて編集やプログラムのローダーも書かなければならないだろうし。
第三に、線路は基本、曲線です。おそらくグリッドマップじゃできない、、と思ったら、マインクラフトの鉄道MODにRealTrainModというのがありまして。
スゴいですね。よくぞブロック構成のマップであそこまで滑らかな曲線にジョイントの接続まで対応しますか!
じゃあ僕もやってみようかと。
とりあえずMap用ブロックを作る
Blenderを3Dモデルの作成ツールとして使います。ファイルフォーマットは引き続きglTF(glb)形式です。
glbはシーン記述ができるファイル形式なので、1ファイルに複数のモデル、メッシュなどを格納できます。
今までは、1ファイル=1モデルの扱いで使ってきましたが、、、
例えばこんな感じで、複数のモデルを1つのglbファイルに格納できるのですね。
描画コードの方で、選択部分のモデル毎のローカル座標変換を無視してやれば個別のメッシュとして使えます。
無造作に並べているようですが、注意点としては、
・大きさをぴったり合わせる事
今回は、地下鉄がテーマなので4立方㍍のブロックを基本単位にしています。エスカレータなどははみ出てますが、要は位置決めを確実にする意味で寸法は重要です。
・すべてのモデルでローカル座標はZ+方向が前(BlenderだとY-で編集エキスポート時にY-Up)である事。VRM1.0に合わせてますが、それぞれバラバラだとプログラム側で対応必要になります。重要。
・メッシュの名前の付け方を工夫する事
メッシュも増えてくると収集つかなくなってくるので、<名称><タグ名>の形で作っておきます。
プログラムの側でタグ名とIDコードを割り当てるために、Entityという構造体を用意しました。
struct Entity
{
std::string meshtag; //meshTAG、略称。glTFのmeshのnameに対応 StationA1など
int meshid; //meshID、glTFのmeshのmesh番号に対応
Float3 pos{ 0,0,0 };
Float3 scale{ 1,1,1 };
Quaternion qrotate = Quaternion::Identity();
};
//タグ定義
enum {
TRAIN_HEAD = 29, TRAIN_MID = 28, TRAIN_TAIL = 27,
COLLIDER_HEAD = 32, COLLIDER_MID = 31, COLLIDER_TAIL = 30,
ARROW = 33, CAMERA = 34,
};
//
Array<Entity> entMaple = {
//地下鉄駅
Entity{ "A1", 0}, Entity{ "D6", 1 }, Entity{ "A2", 2 }, Entity{ "A3", 3 },
Entity{ "B1", 4}, Entity{ "B2", 5 }, Entity{ "B3", 6 }, Entity{ "C1", 7 },
Entity{ "C2", 8}, Entity{ "C3", 9 }, Entity{ "C4", 10}, Entity{ "C5", 11},
Entity{ "C6", 12}, Entity{ "D1", 13}, Entity{ "D2", 14}, Entity{ "D3", 19},
Entity{ "D4", 15}, Entity{ "D5", 16}, Entity{ "ES", 17},
//地下鉄線路 地下鉄列車
Entity{ "SW", 18 },
Entity{ "R1", 20 }, Entity{ "R2", 21}, Entity{ "R3", 22},
Entity{ "S1", 23 }, Entity{ "S2", 24}, Entity{ "S3", 25},
//ドローン 列車(後尾車両) 列車(車両) 列車(先頭車両)
Entity{ "DR", 26}, Entity{ "TR1", TRAIN_TAIL },Entity{ "TR2", TRAIN_MID },Entity{ "TR3", TRAIN_HEAD },
//コライダー 後尾車両 先頭 車両
Entity{ "CL1", COLLIDER_TAIL}, Entity{ "CL2", COLLIDER_MID}, Entity{ "CL3", COLLIDER_HEAD},
//矢印 カメラ
Entity{ "AR", ARROW}, Entity{ "CAM", CAMERA},
};
メッシュに文字列タグを割り当てるのは、文字列でマップを定義したいからです。
半角文字1文字の横幅が1㍍、高さ2㍍とすると、例えばこんな風にマップを定義します。
旧SivのMapleは、CSVで特殊な構文を作るのが面白かったですが、実用性を考えたら、単なる文字列が扱いやすくかんたんでしたね。
※Siv3DはString型だとLinuxと挙動が異なるのと、メモリ効率と速度面から、定義にはstd::stringを使います。
//列車オブジェクトの配置(1両24m x3両)
std::string mapTrain =
{//0m 4m 8m 12m 16m 20m 24m
"TR1.:...:...:...:...:...TR2.:...:...:...:...:...TR3.:...:...:...:...:...\n"
};
//地下鉄駅A
std::string mapStaA =
{// 0m 4m 8m 12m 16m 20m 24m 28m 32m 36m 40m 44m
":...:...:...:...:...:...:...:...:...:...:...\n"
"A1..A2..A2..A2..A2..A2..A2..A2..A2..A2..A3..\n"
":...:...:...:...:...:...:...:...:...:...:...\n"
"B1..B2..B2..B2..B2..B2..B2..B2..B2..C2..B3..\n"
":...:...:...:...:...:...:...:...:...:...:...\n"
":...ES..C5..C5..C5..C5..C5..C5..C5..C6..:...\n" };
//地下鉄駅B
std::string mapStaB =
{// 0m 4m 8m 12m 16m 20m 24m 28m 32m 36m 40m 44m
":...:...:...:...:...:...:...:...:...:...:...\n"
"SW..SW..A1..A2..A2..A2..A2..A2..A2..A3..SW..\n"
":...:...:...:...:...:...:...:...:...:...:...\n"
":...:...B1..B2..B2..D1..D2..D2..D3..B3..:...\n"
":...:...:...:...:...:...:...:...:...:...:...\n"
":...:...:...C4..C5..D4..D5..D5..D6..:...:...\n" };
文字列=メッシュとすることで、std::string::findで3Dモデルの位置取得できたり、
まだ作ってないですが、Replaceや箱型置換処理を作って、一般的な文字列操作関数で動的に3Dマップ編集をかんたんにできる事が狙いです。
// 取得座標XY 検索開始位置 マップ 検索タグ マップ幅
static size_t GetPoint(Point& point, size_t start, std::string& mapstr, std::string& tag, uint32 width)
{
size_t index = mapstr.find(tag, start);
if (index == std::string::npos) return std::string::npos; //検索タグない
point.x = index % width;
point.y = index / width;
return index;
}
直交曲線座標系(もどき)
折角、文字列でかんたんにマップ作れたとしても、見た目がブロックの集合ではちょっと残念感あります。線路は曲線なので曲線をつかいます。直交曲線座標系といってもY方向しか曲線になってなくて速度重視で簡単に。
LookAt関数で得られるFront/Right/Upベクトルで、曲線を構成する線分毎に角度を変えていけば良さそうです。
単位ベクトルを4㍍として上記で作成した、文字列のマップをFrontベクトルに沿って並べていきます。
横方向はRightベクトルを使います。
ブロック単位だと隙間が大量に発生します。おそらく正しいやり方は、進行方向に沿ってウェイトを設定してLERPでスキニング、、、ですが、現状自前のglTFのスキニング処理はソフト処理なので厳しいのかと思います。
来年に期待ですかね。
処理はというと、サンプルでは円周上の頂点を乱数でばらつかせて、CatmullRomの3次元スプラインで曲線化して、全長距離を等分してLERPで等速化しています。
#if 1
constexpr double RADIUS = 1000;
constexpr double VOLATILITY = 500;
LineString3D trackway; // 環状線計算
for (int r = 0; r < 10; r++)
{
constexpr Vec2 CENTER{ 0,0 };
const Float2 pos2 = CENTER + Circular(RADIUS, ToRadians(r * 36));
Float3 pos = Float3{ pos2.x + VOLATILITY * (Random() - 0.5),
VOLATILITY * Random(),
pos2.y + VOLATILITY * (Random() - 0.5) };
trackway.emplace_back(pos);
}
#endif
LineString3D curvedway{ trackway.catmullRomClosed(80) }; //曲線化
double len = curvedway.updateLengthList();
LineString3D railway{ curvedway.getEquatePoints( len/4 ) }; //等速化
railway.updateLengthList();
線路に列車を走らせる
線路ができたので列車を走らせますが、曲線に沿って列車を走らせるのは、等速化ができているので割と簡単です。
距離は長いですが、開始点が0.0、終点1.0なので刻みを細かくして曲線上の座標を取って列車を、そこに描画します。
5両編成で以下のような感じですか。
void updateTrain( LineString3D &railway, const double progressT, const double spacing,
Array<Float3>& carPos, Array<Quaternion>& carRotQ)
{
carPos[0] = railway.getCurvedPoint(progressT + spacing * 2);
carPos[1] = railway.getCurvedPoint(progressT + spacing * 3);
carPos[2] = railway.getCurvedPoint(progressT + spacing * 4);
carPos[3] = railway.getCurvedPoint(progressT + spacing * 5);
carPos[4] = railway.getCurvedPoint(progressT + spacing * 6);
carPos[5] = railway.getCurvedPoint(progressT + spacing * 7);
carRotQ[CAR0] = PixieCamera::getQLookAt(carPos[CAR0], carPos[CAR1]);
carRotQ[CAR1] = PixieCamera::getQLookAt(carPos[CAR1], carPos[CAR2]);
carRotQ[CAR2] = PixieCamera::getQLookAt(carPos[CAR2], carPos[CAR3]);
carRotQ[CAR3] = PixieCamera::getQLookAt(carPos[CAR3], carPos[CAR4]);
carRotQ[CAR4] = PixieCamera::getQLookAt(carPos[CAR4], carPos[CAR5]);
}
文字列でコライダー
Siv3DはMeshを登録すると、自動的にBoundingBoxも用意してくれています。
ただ自動故に、頂点座標のMin/Maxなんですよね。。
細かく考えるとコライダーもモデルにあった形状で座標合わせないと、、と考えると結構手間がかかってしまいます。
glTFもコライダーなんて保存できる項目はないですが、1つのglbファイルにメッシュを沢山入れられるのだから、メッシュでコライダー作っておけば良いですね。Blenderなら見ながら編集できますし。
と!
言っておいてなんですが、もっと良い方法がありました。文字列でグリッド作って、Siv3Dが自動で作ってくれるBoundingBoxを入れ物にして内側でスケール合わせて用意すれば計算コストほとんどかからず見かけヨシでできますね例えば、こんな風に。
//列車室内のコライダー
std::string mapInterior =
{// 0m 1m 2m 3m
"####:####\n"
"####:####\n"
"##..:..##\n"
"##..:..##\n"
"#...:...#\n"
"#...:...#\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"#...:...#\n"
"#...:...#\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"#...:...#\n"
"#...:...#\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"##..:..##\n"
"#...:...#\n"
"#...:...#\n"
"##..:..##\n"
"##..:..##\n"
"####:####\n"
"####:####\n"
};
ちょっと解像度低すぎですが、解像度上げてMeshとの合わせこみすれば低処理コストで色々使えます。例えば、これだと床が滑るんだとか、風が吹いてて煽られるんだみたいな属性マップにも対応できたり、いろいろ妄想が楽しい感があります。
3Dなんだから回ったり傾いたらどうするのというと、前述の「ローカル座標はZ+方向が前」て仕組みが効いてきます。
要は回転前に、衝突判定やってしまえば良いですね。
FPSのキー操作と、衝突判定を書くとこんな風になります。
X軸とZ軸(Y軸)をあえて分けているのは、壁にぶつかった後、壁沿いに少しでも動ける方向に動くためです。
char GetChar(const Float2& pos, const OrientedBox& ob) const
{
Float3 gridpos = {};
return GetChar(pos, ob, gridpos);
}
char GetChar(const Float2& newpos, const OrientedBox& ob, Float3& gridpos) const
{
if (size == Size{ 0,0 }) assert(1);
Float2 unit{ newpos.x / ob.w , newpos.y / ob.d };
gridpos.x = (int)Clamp((float)size.x * unit.x, 0.0f, (float)size.x - 1);
gridpos.y = (int)Clamp((float)size.y * unit.y, 0.0f, (float)size.y - 1);
gridpos.z = gridpos.y * size.x + gridpos.x;
return grid.at(gridpos.z);
}
void updateMainCamera( PixieCamera& camera, const Float3& orgpos, const Maple& maple, const OrientedBox& ob )
{
Float3 oldeyepos = camera.getEyePos();
Float3 oldfocuspos = camera.getFocusPos();
float speed = camera.getBasicSpeed() / 10000;
if (KeyW.pressed()) camera.dolly(+speed);
if (KeyS.pressed()) camera.dolly(-speed);
if (KeyA.pressed()) camera.trackX(+speed);
if (KeyD.pressed()) camera.trackX(-speed);
if ('#' == maple.GetChar(Float2{ camera.getEyePos().x + ob.w/2, oldeyepos.z }, ob))
camera.setEyeX( oldeyepos.x ).setFocusX( oldfocuspos.x );
if ('#' == maple.GetChar(Float2{ oldeyepos.x + ob.w/2, camera.getEyePos().z }, ob))
camera.setEyeZ( oldeyepos.z ).setFocusZ( oldfocuspos.z );
}
乗れない列車
線路もできたし列車もできたので、列車に乗ってどこまでも、ん!!
この列車、、、乗るのが難しそう。。。
曲線座標系のレールの上を、常に傾く列車座標系が連なって移動している中を、人物カメラが列車座標系を乗り移れる必要があります。
3Dは、描画の座標系だけでできる事しかやってなかったのですが、そんな制限はもともとなくて、自分に都合良くオレオレ座標系を移動用に活用して、なにか面倒そうに見える動きもさらっとやれるようになったらいいですね。
void changeCar( int car, int meshid, PixieMesh& meshMaple, const Array<Float3>& carPos,
const Array<Quaternion>& carRotQ)
{
// 乗車列車コライダーを取得
OrientedBox ob = meshMaple.getCollider(meshid);
ob = Geometry3D::TransformBoundingOrientedBox(ob, meshMaple.getMat());
if (ob.contains(cameraMain.getEyeWorld().xyz()))
{
//列車変更検知
if (atCar != car)
{
//視点座標を相対で保持
Float3 dir = cameraMain.getFocusPos() - cameraMain.getEyePos();
//逆行列で回転戻して新列車のローカル座標を算出
Float3 eyepos = Mat4x4(carRotQ[car]).inverse()
.transformPoint(cameraMain.getEyeWorld().xyz() - carPos[car]);
cameraMain.setEyePos(eyepos);
cameraMain.setFocusPos(eyepos + dir);
atCar = car;
}
}
if (atCar == car) meshMapleSub = meshMaple;
}
void drawTrain(PixieMesh& meshMaple, const Array<Float3>& carPos, const Array<Quaternion>& carRotQ)
{
const Array<int> car{ TRAIN_TAIL, TRAIN_MID, TRAIN_MID, TRAIN_MID, TRAIN_HEAD };
//5両分の列車を描画、乗り換え処理
for (int i = 0; i < car.size(); i++)
{
meshMaple.setMove(carPos[i]).setRotateQ(carRotQ[i]).drawMesh( car[i] );
changeCar(i, car[i], meshMaple, carPos, carRotQ);
}
//列車位置を曲線座標から更新
cameraMain.setOrgPos( carPos[atCar] );
}
デモ
操作は、WASDキーで視点の平行移動です。
視点の方向はマウスのMボタンドラッグでPanXYです。
右ボタンドラッグするとコライダー無視で左右移動、ホイールもコライダー無視で前後移動になります。
画面右の方にスロットルを追加したので速度調整ができます。
てきとうに乱数で路線を作っているため、ところどころ絶壁のような坂を登っている場合があります。
UPベクトルに近づくとPanXYの挙動がおかしくなるかもしれません。
https://github.com/kestrel-90r/testTrain2022/blob/main/testTrain2022.m4v?raw=true
ソースコード
OpenSiv3D 0.6.52を使っているので、Appフォルダを追加するか、
新しいプロジェクトを作成して、C++ソースコードと3rdフォルダ、Assetフォルダをコピーすればビルドできるかと思います。
まだまだ、バグなどたくさんあるようなものですが、もしも指摘いただけるとありがたく考えております。