🏎️

Babylon.js で物理演算(havok):カートレース

に公開

この記事のスナップショット


プレイ画像(2倍速)

https://playground.babylonjs.com/?inspectorv2=true?BabylonToolkit#07233Z

(上記のURLにおいて、ツールバーの歯車マークから「EDITOR」のチェックを外せばウィンドウいっぱいに、歯車マークから「FULLSCREEN」を選べば画面いっぱいになります。)

ソース

https://github.com/fnamuoo/webgl/blob/main/116

ローカルで動かす場合、上記ソースに加え、別途 git 内の 104/js を ./js として配置してください。

はじめに

Babylon.js の基本操作(Mesh / Camera / Physics)の知見を有していればより理解が深まりますが、そんな知識がなくてもディープな説明はないので大丈夫です。
初めての方でもデモプレイ(上記リンク)を遊んでいただいて興味を持っていただけると幸いです。

概要

実際のカートコースを模したレースゲームを作成しました。作成時のポイントを解説しますが、過去の記事で紹介した部分は割愛し、目新しい部分のみに焦点を当てています。

検索から有名どころと思われるカートコースをピックアップしてコースを作成してみました。コースマップ(図)からコースをおこしているので結果、似て非なるものに仕上がってますが、雰囲気は味わえるかと思います。順路がわからないものは適当に決めてます。知っている方がいたら教えてほしいです。検索しているときにドリフトのコースも見つけてしまったので、おまけとして追加しました。

車モデルは Babylon.js で物理演算(havok):車モデル で使ったものを利用します。パラメータを調整して、ドリフト仕様とグリップ仕様を用意しました。

コースの作り方はおおむね、こちらの手順 Babylon.js:画像からコース作り(1/2) のとおりですが、最初の画像処理(コースのセンターライン上に一定間隔で点を配置)を生成AIにやらせてます。

コースに交差や高低差がある場合は、部分的にバンクを付けます。バンクのつけ方は Babylon.js で物理演算(havok):コースに部分的バンクをつけて試走 のノウハウを流用します。

今回は、更に次の作り込みを行ってます。

  • コースにテクスチャを張ってリアルっぽさを出す
  • ライバルカー(COMカー)との並走
  • 画面右上にワイプ画面(バードビュー)を配置
  • ラップタイムを計測・表示
  • ライバルカー(COMカー)の難度の自動調整
  • ゲームパッド対応

やったこと

  • コース作り
  • コースにテクスチャを張る
  • CNS によるライバルカー(COMカー)との並走
  • ワイプ画面
    • ワイプ表示のGUI操作
    • ワイプのカメラ
  • ラップタイムを計測
  • ライバルカー(COMカー)の難度の自動調整
  • ステージクリアの追加
  • ゲームパッド対応

コース作り

  • コース画像を用意します

    • 末尾のリンクにある画像を参考にさせていただきました
  • コース画像のコースライン(センターライン)上に一定間隔で点を打ちます

    • ここを生成AIで実施します。上手く点が配置できたらなそのまま利用し、できてなくてもセンターライン情報を利用して、点を打ち直します。

      // プロンプト
      添付画像のサイズを変更せずに次の操作を上書きして描画してください。
      操作1.太い黒い線にそって、中心部分に線、センターラインを引いて。センターラインは1ピクセルの白色で示して。黒い線が交差するときは直進して。
      操作2.センターライン上に、30ピクセルごとの等間隔に、サイズ2ピクセルの緑色の点を配置して。
      上記の操作について、レビューと修正を3回繰り返して。
      3回目の画像を表示。
      
  • 点を打った画像から、点の座標をぬきだします。

    • python script で画像から座標位置を抜き出し JavaScriptの配列形式で出力。
    • CourseData3.js に追記。
  • Babylon/extrudeでコースを表示して確認

    • 順路が崩れていたら、点を打ち直して再度コース情報を取得
    • コースに「重なり」ができていれば、点の位置をずらしたり、追加したり、削ったりでRが緩くなるよう調整したり、コース幅(断面形状)を小さくしたり、コース全体のスケールを大きくしたりします。

下記では、断面を変更したときのコース形状を示しています。


幅広の断面で「重なり」が発生


中間の幅の断面でも「重なり」が発生


幅狭の断面で「重なり」を解消

コースにテクスチャを張る

コースにテクスチャを張ってリアルっぽさを出します。
そのまま貼り付けていしまうと間延びしてしまいますが、縦方向に数百並べることでそれっぽく見えます。

テクスチャを並べて表示する手順
mesh.material.diffuseTexture = new BABYLON.Texture(floorPath2, scene);
mesh.material.diffuseTexture.uScale = 1;
mesh.material.diffuseTexture.vScale = 200;
mesh.material.specularColor = new BABYLON.Color4(0, 0, 0);


画像が間延びした様子


画像を繰り返した様子(vScale=100)

更に画像に一工夫します。壁・縁石・グラベル部分に相当するエリアを色付けして、リアルっぽさを出します。


幅広の断面・壁のテクスチャ画像


幅広の断面・壁のコース


中間の幅の断面・縁石のテクスチャ画像


中間の幅の断面・縁石のコース


幅狭の断面・グラベルのテクスチャ画像


幅狭の断面・グラベルのコース

CNS によるライバルカー(COMカー)との並走

Crowd Navigation System(CNS)を使って、エージェントをライバルカーとして走らせます。

既に前回の記事 Babylon.js で物理演算(havok):扇風機/UFOでレース で紹介済みですが、ここでは改めて簡単に紹介します。

  • エージェントごとに速度調整を行う
    • エージェント作成時に、maxSpeed と maxAcceleration に程よい値を設定します。実際に何度か試走して、がんばれば追い抜ける程度に調整しておきます。
    • コースによってはこの固定値ではハードモードな場合もありますが、後述の「ライバルカー(COMカー)の難度の自動調整」で調整します。
  • エージェントにコースを周回させる
    • エージェントは目的地に向かって移動するだけの機能しかありません。
    • コースを巡回させるには、コース上に複数の目的地を配置して、最初の目的地に到達したら次の目的地を再度設定するということを繰り返します。
    • 目的地はゴールも含め3箇所あれば十分と思われますが、コース上の点座標の偏りを考慮して4箇所とします。
エージェントを巡回させる処理
// 目的地に到着したら次の目的地を設定する
crowd.onReachTargetObservable.add((agentInfos) => {
    let agID = agentInfos.agentIndex;
    let meshAgent = meshAgentList[agID];
    let dest = crowd._ag2dest[agID];
    if (dest == pQ.length-1) {
        dest = 0;
    } else {
        ++dest;
    }
    // 次の目的地を設定
    crowd._ag2dest[agID] = dest;
    crowd.agentGoto(agID, navigationPlugin.getClosestPoint(pQ[dest]));
});

ワイプ画面を配置

画面の右上にワイプ画面(小さなスクリーン)を作り、そこにコースを表示させます。


ワイプ画面・バードビュー(全体)

ここでは Layer Masks and Multi-Cam Textures の技術を使って、同じシーンに複数のメッシュを重ね、カメラ・メッシュのレイヤーを分けることで複数のビューを実現させます。

メインカメラにはレイヤーマスク(layerMask)に 0x10000000 を割り当て、
ワイプカメラには 0x20000000 を割り当ててます。

メインカメラのみに映すにはメッシュのレイヤーマスクに 0x10000000 を割り当て、
ワイプカメラのみならメッシュのレイヤーマスクに 0x20000000 を、
両方に映すなら 0x30000000 を割り当てます。

例えば、プレイヤーの場合、メインに映すシャーシ・ホイールに 0x10000000 を、ワイプ用のマーカーに 0x20000000 を割り当てます。

プレイヤーメッシュの設定
// プレイヤーのメッシュ
// シャーシ:本体のメッシュ
let chassisMesh = BABYLON.MeshBuilder.CreateBox("Chassis", {width:1, height:0.4, depth:2})
chassisMesh.layerMask = 0x10000000;

// ホイールのメッシュ
let wheelMesh = BABYLON.MeshBuilder.CreateCylinder("WheelMesh", {height:0.3, diameter:0.4})
wheelMeshes = [wheelMesh, wheelMesh.createInstance(1), wheelMesh.createInstance(2), wheelMesh.createInstance(3)]
wheelMeshes.forEach(mesh => {
    mesh.layerMask = 0x10000000;
})

// --------------------
// camera2用メッシュ
let meshMy4c2 = new BABYLON.MeshBuilder.CreateCapsule("capsule", {radius:0.1, height:7.5, radiusTop:2.5});
meshMy4c2.layerMask = 0x20000000;
meshMy4c2.parent = chassisMesh;

コースのメッシュは、メイン・ワイプの両方に同じものを表示させたいので 0x30000000 を割り当ててます。

mesh = BABYLON.MeshBuilder.ExtrudeShape("extrude", options, scene);
mesh.layerMask = 0x30000000;

地面(グランド)については、メイン用(草のテクスチャ)とワイプ用(白)の2つを配置してます。

地面メッシュの設定
// 地面のメッシュ
// 地面
let meshGrnd = BABYLON.MeshBuilder.CreateGround("ground", { width:grndW, height:grndH }, scene);
meshGrnd.position.y += -0.01;
meshGrnd.material = new BABYLON.StandardMaterial("mat", scene);
meshGrnd.material.diffuseTexture = new BABYLON.Texture(grndPath, scene);
meshGrnd.material.diffuseTexture.uScale = Math.ceil(grndW/4);
meshGrnd.material.diffuseTexture.vScale = Math.ceil(grndH/4);
meshGrnd.material.specularColor = new BABYLON.Color4(0, 0, 0);
meshGrnd.layerMask = 0x10000000;

// camera2用の地面
let meshGrnd2 = BABYLON.MeshBuilder.CreateGround("ground", { width:grndW*2, height:grndH*2 }, scene);
meshGrnd2.layerMask = 0x20000000;
meshGrnd2.material = new BABYLON.StandardMaterial("mat", scene);
meshGrnd2.material.emissiveColor = BABYLON.Color3.White();

尚、GUI(スピードやメッセージ、使い方ウィンドウ、ステージ選択ウィンドウ)については layer 属性のレイヤーマスクに設定します。

let advancedTexture = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI("UI");
advancedTexture.layer.layerMask = 0x10000000;

ワイプ表示のGUI操作

ステージ選択用のアイコン/GUIを用意しています。


ステージ選択用のアイコン(画面右上)



ステージ選択用のGUI

ワイプ表示を消していると普通に操作できるのですが、ワイプを表示している場合、なぜかメイン画面上のアイコンをクリックしても反応しません。しかしワイプ画面上でアイコン位置をクリックするとGUIを表示してくれます。何かしらの解決案がありそうなのですが、すぐには見つからなかったので、小細工をろうします。

アイコンは、ワイプと重ねて右上としておき、アイコン箇所をクリックしたら、ワイプを一時的に消してGUI(ステージ選択のウィンドウ)を表示、GUIを消すときはワイプの表示を戻します。アイコンとワイプを重ねているときは、ワイプ上でのアイコン範囲(小さくなった範囲)が反応する範囲になるので、ワイプを表示しているときはアイコンを大きなサイズにしています。

ワイプのカメラ

ワイプカメラも、メインカメラと同様に設定を変更することでトップビューやバードビューにすることができます。


ワイプ画面・トップビュー


ワイプ画面・バードビュー

ワイプでバードビューにする場合、メインカメラでの設定をそのまま使うと、視点が低く、広範囲を見渡せません。そこで、高い位置の視点の為に見えないメッシュ(alpha=0)を配置して、車のメッシュに紐づけ(parent指定)しておきます。

カメラの視点用のメッシュ
// カメラの視点用
let mesh4c2view = BABYLON.MeshBuilder.CreateBox("", {size:1});
mesh4c2view.position.y += 10;
mesh4c2view.material = new BABYLON.StandardMaterial("mat", scene);
mesh4c2view.alpha = 0;
mesh4c2view.parent = chassisMesh;


ワイプ画面・バードビュー(視線の方向が低い場合)

視線の方向が低い(車をlockedTargetにした)場合、自機の位置が中央になり、前方の視線(画面上部)が狭く感じます。視点用のメッシュを配置しそれに対してlockedTargetとすることで、カメラ内の自機の位置が下に下がり、前方(上部)の空間が広くなっていることがわかると思います。

ちなみに、ワイプカメラの切り替えは(v)キーで行えます。メインカメラの切り替えは(c)キーです。

ラップタイムを計測

ラップタイムを計測するには、基本、コントロールライン/フィニッシュラインに交差判定用のメッシュを配置して、時間計測を行うだけ十分ですが、今回は複数個所にチェックポイントを配置してラインの通過も確認します。

タイム計測/メッシュの交差判定のやりかたは Babylon.js で物理演算(havok):アルペンスキー と同じように次のチェックポイントと自機/ライバルカーとの交差判定を行います。この記事との違いは、チェックポイントごとに周回情報(何週目か)と時間計測(performance.now()の値)を持たせていることです。コントールラインのメッシュと交差したときに、前回とのタイム差でラップタイムを計算し、各チェックポイントが同じ周回情報を持っているかで有効なコースレコードか判断しています。

ちなみにチェックポイントのメッシュはコース幅よりやや大き目に作成し、透明(alpha=0)にしています。

ライバルカー(COMカー)の難度の自動調整

上述で、プレイヤーやライバルカーのラップタイムが取れるようになったことから、タイム差がわかります。このタイム差を使って、ライバルカーを遅くしたり、速くしたりすることで、ゲームの難度を自動的に調整します。ここでは、チェックポイントに接触したタイミングで、ある程度(5秒)の差があるときに、時間差に応じたスピード調整を行います。

スピード調整は maxSpeed および maxAcceleration を変更します。すべてのエージェントに対し、初期値で設定した値を等倍して変更します。少々雑な調整で、増加・減少が急だったり、遅延して反応している感じがあります。しかし細かくバランス調整しなくてもプレイヤーに合わせてゲーム難度を自動で調整してくれる点は非常に大助かりです。

自動判定処理
// チェックポイント側での判定・スピード調整の呼び出し
if (passCPMElist[imeshCPnextME] == passCPCOMlist[imeshCPnextME]) {
    // プレイヤー遅れ(COM先行)
    let tdiff = Math.floor(ctCOMlist[imeshCPnextME] - ctMElist[imeshCPnextME])/1000;
    if (tdiff < -5) {
        console.log("遅れ=", tdiff, "敵down");
        changeAgntSpeed(tdiff*0.01); // 10秒で0.1下げる割合
    } else {
        console.log("遅れ=", tdiff, "そのまま");
    }
}

// ライバルカー(エージェント)のスピード調整
let changeAgntSpeed = function (spdRateStep) {
    crowd._spdRate += spdRateStep;
    crowd._accRate += spdRateStep*0.2;
    console.log("changeAgntSpeed() spdRate=", crowd._spdRate);
    for (let meshAgent of meshAgentList) {
        let idx = meshAgent._idx;
        let agentParams = agentParamList[idx];
        agentParams.maxSpeed = meshAgent._spd * crowd._spdRate;
        agentParams.maxSpeed = Math.max(agentParams.maxSpeed, 8); // 下限を8とする
        agentParams.maxAcceleration = meshAgent._acc * crowd._accRate;
        agentParams.maxAcceleration = Math.max(agentParams.maxAcceleration, 4); // 下限を4とする
        crowd.updateAgentParameters(idx, agentParams);
    }
}

ステージクリアの追加

全ステージは最初から解放済みで、好きなステージを選べますが、ステージクリアで次のステージに切り替わるようにしています。

ステージクリアの条件は以下のとおりです。

  • ベストラップがライバルカーより 10秒以上速いこと。もしくは周回時に2回トップ通過(連続でなくてよい)すること

条件の秒数や周回数はパラメータにしているので、ここをいじればレベル調整できます。大きくすれば難しくなりますし、小さく(秒数はマイナス、周回は1に)すれば簡単にクリアできるようになります。

// 勝利条件1:周回でN回トップ通過
const winCond1 = 2;
// 勝利条件2:ベストラップでX秒以上速い
const winCond2 = 10;

ゲームパッド対応

キーボードでの操作のみでは少々つらいので、ゲームパッドでも動かせるようにしました。
処理方法は、Babylon.js:Navigation path をドローン操作でトレース+ゲームパッド/VirtualStick対応 と同様になります。この時はドローン操作でしたが、今回は車の操作にあわせてボタン・スティックの取得を変更しています。

既知の不具合

  • スタート時のカウントダウンはありません。突然始まります。
  • 対応していないゲームパッドがあるかもしれません。
  • ゲームパッドの操作では未対応の機能(位置リセットやジャンプ)があります。予めご承知おきください。
  • 利用しているマシン・通信状況によっては、十分に表示されない(バグって表示される)かもしれません。
    • 当方の開発環境は Win11, Intel Core 7, 16 GB-MEM になります。

まとめ・雑感

今持てる技術を総動員してみました。本格的なドライブシミュレータと比べるべくもありませんが、少しでも楽しめたら幸いです。

今までもレースゲームは作ってきましたが、年末の区切りにあわせて、今までの集大成+手を付けていなかった課題(テクスチャ張りやラップタイム、ゲーム難度の自動調整など)にもチャレンジしてみました。車のメッシュが矩形のままでダサいですが、自作するセンスは無いので、フリーで使える軽量でかっこいメッシュがあればと願うばかりです。

「コースを全て(壁や縁石ではなく)グラベルにすれば」と思う方がいるかもしれません。試しに試走してみると、コースが狭くなりライン取りが難しくなり、ライバルに抜かれまくりで止めました。(壁)から(縁石)はありですがコースアウトしやすくなるのでご注意です。(グラベル・縁石)から(壁)へはヘアピンのインサイドに壁ができるのでお勧めしません。

あと関係者の方が見ていたら申し訳ないです。名称は拝借していますが別物になっていると思います。NGの指摘をうけたら予告なく内容の変更・修正をいたします。

参考:コース情報のリンク

勝手にリンクを張らせていただいてます。問題がありましたらご連絡ください。
また閲覧するタイミングによってはリンク切れの恐れもあります。その際は公式サイトから辿ってください。

リンク

Discussion