虎の穴ラボ技術ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

レンガ 🧱の積み方をbabylon.js と 物理エンジン(Havok)で シミュレーション!

皆さん夏をいかがお過ごしですか?、おっくんです。

本記事は2023 夏のブログ連載企画16日目の記事になります。

7月14日は、y.fさんの「【ChatGPT】フレームワークの移行を試してみた!〜コード変換の実践〜」でした。 toranoana-lab.hatenablog.com

明日は、後藤さんの「画像から取得したImageData オブジェクトで花火を描画してみた」になります。ご期待ください!


最近「レンガの積み方」について調べていたのですが、レンガには「フランス積み」や「イギリス積み」など名前が付いた様々な積み方がある事を知りました。

こうなると興味が湧いてくるのはそれぞれの積み方による強度です。 いくつか見比べたウェブサイト中で、イギリス積みの強度が高いという記述を見つけました。 が、書いてあると試してみたくなるのが人の性。

しかし、別にリアルでレンガを積みたいわけでもないので、今回は babylon.js で 物理エンジンを使用して、レンガの積み方による壁の強度をシミュレーションしてみます。

最終的には次のようなものが出来上がります。

目標

以下4つのレンガの積み方で、シミュレーション空間内にレンガ積みした壁を作成し、物体をぶつけることで外力を加え破損状況を比較します。

  • イギリス積み
  • フランス積み
  • ドイツ積み
  • 長手積み

それぞれの積み方の詳細は、「 3 シミュレーション比較」で紹介します。

注意1: 積み方と名称の正確性について

以下記事では、各種名称が付いた積み方を例として記載しますが、分類の正確性については保証いたしません。積み方と破損具合を比較しながらお楽しみください。

注意2: 目地材の考え方

「レンガの積み方」にフォーカスをしていくに当たり、取り扱いが難しいと感じたのは「目地材」です。
レンガを積むときに間に入れていくものですが、こちらの表現に困りました。

今回は、摩擦係数を高めに設定し「横滑りしにくくする」ことで表現することにします。
摩擦係数による挙動の違いは後述する確認の中で比較します。
ただし、あくまで滑りの抑制なので「垂直方向に結合する」という要素は除かれています。

babylon.js

試していくにあたり、物理シミュレーションができる環境が必要でした。 ゲーム開発環境を使う方法なども検討できますが、今回は前々から試してみたかった babylon.js を使用します。

babylon.js は、オープンソースの Web レンダリングエンジンです。
2023年4月にリリースされた babylon.js 6.0 では Havok Physics という物理エンジンをプラグインで導入できるようになりました。

本記事では、この Havok Physics を導入した環境でシミュレーションを行います。

参考

動作環境

  • vite 4.3.9
  • babylon.js 6.10.0
  • @babylonjs/havok 1.0.1

1 動作確認

1.1 Babylon.js で物理シミュレーション

第一段階として、シンプルな構成で動作確認を行います。 次の環境を作ります。

  • 質量1の立方体がある
  • 質量1の球体がある
  • 起動から3秒経つと球体に横方向の力が加わり、立方体に衝突する

[sample1-1.ts]

import * as BABYLON from "babylonjs";
import HavokPhysics from "@babylonjs/havok";
import havokWasmUrl from "../assets/HavokPhysics.wasm?url";

const canvas = document.getElementById("renderCanvas");

const havok = await HavokPhysics({
  locateFile: () => havokWasmUrl,
});

const engine = new BABYLON.Engine(canvas, true, {
  preserveDrawingBuffer: true,
  stencil: true,
});

// シーンの作成
const scene = new BABYLON.Scene(engine);

// 物理エンジンの有効化
scene.enablePhysics(
  new BABYLON.Vector3(0, -9.8, 0),
  new BABYLON.HavokPlugin(true, havok)
);


//カメラの作成
const camera = new BABYLON.ArcRotateCamera(
  "Camera",
  (Math.PI * 60) / 180,
  (Math.PI * 120) / 180,
  -30,
  BABYLON.Vector3.Zero(),
  scene
);
camera.setTarget(BABYLON.Vector3.Zero());
camera.attachControl(canvas, true);

// 地面の作成
const ground = BABYLON.MeshBuilder.CreateGround(
  "Ground",
  { width: 30, height: 30 },
  scene
);
const groundAggregate = new BABYLON.PhysicsAggregate(
  ground,
  BABYLON.PhysicsShapeType.BOX,
  { mass: 0, friction: 10 },
  scene
);

// 照明の作成
const light = new BABYLON.HemisphericLight(
  "light",
  new BABYLON.Vector3(0, 100, 0),
  scene
);

// ボールの作成
const bollMaterial = new BABYLON.StandardMaterial("BollMaterial");
bollMaterial.diffuseColor = new BABYLON.Color3(0.8, 0.8, 0.8);
const bollMesh = BABYLON.MeshBuilder.CreateSphere("boll", { diameter: 1.0 }, scene);
bollMesh.position = new BABYLON.Vector3(-5, 3, 0);
bollMesh.material = bollMaterial;
const bollPhysics = new BABYLON.PhysicsAggregate(
  bollMesh,
  BABYLON.PhysicsShapeType.SPHERE,
  { mass: 1, friction: 10 },
  scene
);

// 箱の作成
const boxMaterial = new BABYLON.StandardMaterial("BoxMaterial");
boxMaterial.diffuseTexture = new BABYLON.Texture("../assets/brick_wall.png");
const boxMesh = BABYLON.MeshBuilder.CreateBox(
  "box",
  { height: 1, width: 1, depth: 1 },
  scene
);
boxMesh.position = new BABYLON.Vector3(0, 3, 0);
boxMesh.material = boxMaterial;
const boxPhysics = new BABYLON.PhysicsAggregate(
  boxMesh,
  BABYLON.PhysicsShapeType.BOX,
  { mass: 1, friction: 10 },
  scene
);

engine.runRenderLoop(function () {
  scene.render();
});

setTimeout(() => {
  bollPhysics.body.applyImpulse(new BABYLON.Vector3(30, 0, 0), bollMesh.position);
}, 3000);

動作させると次のようになります。

立方体があまり滑らずに、衝突によって回転して飛んでいく様子が見えます。

1.2 Babylon.js で物理シミュレーション 摩擦係数を下げる

1.1 の例から摩擦係数だけ低くします。

[sample1-1.ts(改変抜粋)]

const boxPhysics = new BABYLON.PhysicsAggregate(
  boxMesh,
  BABYLON.PhysicsShapeType.BOX,
  { mass: 1, friction: 0.1 },  // <= 10 から 0.1 に変更
  scene
);

動かすと次の通りです。

衝突した立方体が滑っているのが一目でわかるはずです。

2 レンガを積む

2.1 レンガの大きさ

レンガの大きさの規格を調べると、「長辺 x 短い辺 x 高さ」は「21 x 10 x 6 cm」 だそうです。
間に目地材が入る前提があるからだと思うのですが、上の面の『 短い辺 x 2 = 長辺 』は成立しないようです。

今回は目地材はありませんので、隙間なく詰めて積むことを前提に次のサイズとします。
babylon.js の長さに単位は無いので、「3.0 x 1.5 x 1.0」 としました。

また、重さは 2.25kg が標準だそうなので、設定する質量は 2.0 とします。
半分に切ったものも用意しますが、これらは質量は 1.0 です。
質量もbabylon.js に単位はありません。

テクスチャも貼りましたが、レンガに見えるでしょうか?

2.2 レンガの壁を作る

レンガの壁を複数作るに当たり、個別のプログラムを書いておくのはあまりに面倒なので、外部から設定値を渡して、並ぶようにします。

[sample2-2.ts]

import * as BABYLON from "babylonjs";
import HavokPhysics from "@babylonjs/havok";
import havokWasmUrl from "../assets/HavokPhysics.wasm?url";
import brickSetup from "./brick_setup.json" assert { type: "json" };

const canvas = document.getElementById("renderCanvas");

const havok = await HavokPhysics({
  locateFile: () => havokWasmUrl,
});

const engine = new BABYLON.Engine(canvas, true, {
  preserveDrawingBuffer: true,
  stencil: true,
});

// シーンの作成
const scene = new BABYLON.Scene(engine);

// 物理エンジンの有効化
scene.enablePhysics(
  new BABYLON.Vector3(0, -9.8, 0),
  new BABYLON.HavokPlugin(true, havok)
);

//カメラの作成
const camera = new BABYLON.ArcRotateCamera(
  "Camera",
  (Math.PI * 60) / 180,
  (Math.PI * 120) / 180,
  -30,
  BABYLON.Vector3.Zero(),
  scene
);
camera.setTarget(BABYLON.Vector3.Zero());
camera.attachControl(canvas, true);

// 地面の作成
const ground = BABYLON.MeshBuilder.CreateGround(
  "Ground",
  { width: 30, height: 30 },
  scene
);
const groundAggregate = new BABYLON.PhysicsAggregate(
  ground,
  BABYLON.PhysicsShapeType.BOX,
  { mass: 0, friction: 10 },
  scene
);

// 照明の作成
const light = new BABYLON.HemisphericLight(
  "light",
  new BABYLON.Vector3(0, 100, 0),
  scene
);

// ボールの作成
const bollMaterial = new BABYLON.StandardMaterial("BollMaterial");
bollMaterial.diffuseColor = new BABYLON.Color3(0.8, 0.8, 0.8);
const bollMesh = BABYLON.MeshBuilder.CreateSphere(
  "boll",
  { diameter: 1.0 },
  scene
);
bollMesh.position = new BABYLON.Vector3(-5, 3, 0);
bollMesh.material = bollMaterial;
const bollPhysics = new BABYLON.PhysicsAggregate(
  bollMesh,
  BABYLON.PhysicsShapeType.SPHERE,
  { mass: 1, friction: 10 },
  scene
);

// レンガの作成
const boxMaterial = new BABYLON.StandardMaterial("BoxMaterial");
boxMaterial.diffuseTexture = new BABYLON.Texture("../assets/brick_wall.png");

const definitionBrick = {
  // type 1: 1x1.5x3
  1: {
    size: { height: 1, width: 1.5, depth: 3 },
    physics: { mass: 2, friction: 10 },
  },
  // type 2: 1x1.5x1.5
  2: {
    size: { height: 1, width: 1.5, depth: 1.5 },
    physics: { mass: 1, friction: 10 },
  },
};

interface BrickSetupCell {
  type: 1 | 2;
  x: number;
  y: number;
  z: number;
  roty: number;
}

interface BrickSetup {
  bricks: BrickSetupCell[];
}

function setupBricks(
  scene: BABYLON.Scene,
  boxMaterial: BABYLON.Material,
  brickSetup: BrickSetup
) {
  brickSetup.bricks.forEach((brick, i) => {
    const boxMesh = BABYLON.MeshBuilder.CreateBox(
      `box-${i}`,
      definitionBrick[brick.type].size,
      scene
    );
    boxMesh.position = new BABYLON.Vector3(brick.x, brick.y, brick.z);
    boxMesh.rotation.y = Math.PI * brick.rotY

    boxMesh.material = boxMaterial;
    new BABYLON.PhysicsAggregate(
      boxMesh,
      BABYLON.PhysicsShapeType.BOX,
      definitionBrick[brick.type].physics,
      scene
    );
  });
}

setupBricks(scene, boxMaterial, brickSetup as BrickSetup);

engine.runRenderLoop(function () {
  scene.render();
});

setTimeout(() => {
  bollPhysics.body.applyImpulse(
    new BABYLON.Vector3(30, 0, 0),
    bollMesh.position
  );
}, 3000);

設置位置を定義した、brick_setup.json には次のように記述します。

[brick_setup.json]

{
  "bricks": [
    { "type": 1, "x": 0.75, "y": 0.5,  "z": -3.75, "rotY": 0.5 },
    { "type": 1, "x": 0,      "y": 0.5,  "z": -1.5,    "rotY": 0 },
    { "type": 1, "x": 0,      "y": 0.5,  "z": 1.5,     "rotY": 0 },
    { "type": 1, "x": 0.75, "y": 0.5,  "z": 3.75,  "rotY": 0.5 },
    { "type": 1, "x": 0,      "y": 1.5,  "z": -3.0,   "rotY": 0 },
    { "type": 1, "x": 0,      "y": 1.5,  "z": 0.0,     "rotY": 0 },
    { "type": 1, "x": 0,      "y": 1.5,  "z": 3.0,     "rotY": 0 },
    { "type": 2, "x": 0,      "y": 2.5, "z": -3.75, "rotY": 0 },
    { "type": 1, "x": 0,      "y": 2.5, "z": -1.5,    "rotY": 0 },
    { "type": 1, "x": 0,      "y": 2.5, "z": 1.5,     "rotY": 0 },
    { "type": 2, "x": 0,      "y": 2.5, "z": 3.75,  "rotY": 0 },
    { "type": 1, "x": 0,      "y": 3.5, "z": -3.0,   "rotY": 0 },
    { "type": 1, "x": 0,      "y": 3.5, "z": 0.0,    "rotY": 0 },
    { "type": 1, "x": 0,      "y": 3.5, "z": 3.0,    "rotY": 0 }
  ]
}

動かすと次のようになります。

一列組んだだけでびくともしなくなりました。

ボールに加える力を上げてやると、崩れる様子が見えるようになります。

2.3 結果算出

レンガ積みの壁に、外から力を加えて強さを見るために、比較方法が必要です。 今回は、シミュレーション空間上にあるレンガの衝突前後の移動距離を差分としての計算し、スコアとします。
よってスコアが低いほど「動かなかった」ことになります。
併せて一個当たり最大で動いた距離、中央値なども算出していきます。

1秒毎に各レンガの移動距離を計算し、表示するように実装されています。

[sample3-2.ts]

import * as BABYLON from "babylonjs";
import HavokPhysics from "@babylonjs/havok";
import havokWasmUrl from "../assets/HavokPhysics.wasm?url";
import brickSetup from "./brick_setup.json" assert { type: "json" };

const canvas = document.getElementById("renderCanvas");

const havok = await HavokPhysics({
  locateFile: () => havokWasmUrl,
});

const engine = new BABYLON.Engine(canvas, true, {
  preserveDrawingBuffer: true,
  stencil: true,
});

// シーンの作成
const scene = new BABYLON.Scene(engine);

// 物理エンジンの有効化
scene.enablePhysics(
  new BABYLON.Vector3(0, -9.8, 0),
  new BABYLON.HavokPlugin(true, havok)
);

//カメラの作成
const camera = new BABYLON.ArcRotateCamera(
  "Camera",
  (Math.PI * 60) / 180,
  (Math.PI * 120) / 180,
  -30,
  BABYLON.Vector3.Zero(),
  scene
);
camera.setTarget(BABYLON.Vector3.Zero());
camera.attachControl(canvas, true);

// 地面の作成
const ground = BABYLON.MeshBuilder.CreateGround(
  "Ground",
  { width: 30, height: 30 },
  scene
);
const groundAggregate = new BABYLON.PhysicsAggregate(
  ground,
  BABYLON.PhysicsShapeType.BOX,
  { mass: 0, friction: 10 },
  scene
);

// 照明の作成
const light = new BABYLON.HemisphericLight(
  "light",
  new BABYLON.Vector3(0, 100, 0),
  scene
);

// ボールの作成
const bollMaterial = new BABYLON.StandardMaterial("BollMaterial");
bollMaterial.diffuseColor = new BABYLON.Color3(0.8, 0.8, 0.8);
const bollMesh = BABYLON.MeshBuilder.CreateSphere(
  "boll",
  { diameter: 1.0 },
  scene
);
bollMesh.position = new BABYLON.Vector3(-5, 3, 0);
bollMesh.material = bollMaterial;
const bollPhysics = new BABYLON.PhysicsAggregate(
  bollMesh,
  BABYLON.PhysicsShapeType.SPHERE,
  { mass: 1, friction: 10 },
  scene
);

// レンガの作成
const boxMaterial = new BABYLON.StandardMaterial("BoxMaterial");
boxMaterial.diffuseTexture = new BABYLON.Texture("../assets/brick_wall.png");

const definitionBrick = {
  // type 1: 1x1.5x3
  1: {
    size: { height: 1, width: 1.5, depth: 3 },
    physics: { mass: 2, friction: 10 },
  },
  // type 2: 1x1.5x1.5
  2: {
    size: { height: 1, width: 1.5, depth: 1.5 },
    physics: { mass: 1, friction: 10 },
  },
  // type 3. 1x0.75x3
  3: {
    size: { height: 1, width: 0.75, depth: 3 },
    physics: { mass: 1, friction: 10 },
  }
};

interface BrickSetupCell {
  type: 1 | 2;
  x: number;
  y: number;
  z: number;
  roty: number;
}

interface BrickSetup {
  bricks: BrickSetupCell[];
}

type EntryBrickPositions = {
  initialPosition: BrickSetupCell;
  mesh: BABYLON.Position;
}[];

function setupBricks(
  scene: BABYLON.Scene,
  boxMaterial: BABYLON.Material,
  brickSetup: BrickSetup
): EntryBrickPositiopns {
  const tmp: EntryBrickPositiopns = [];

  brickSetup.bricks.forEach((brick, i) => {
    const boxMesh = BABYLON.MeshBuilder.CreateBox(
      `box-${i}`,
      definitionBrick[brick.type].size,
      scene
    );
    boxMesh.position = new BABYLON.Vector3(brick.x, brick.y, brick.z);
    boxMesh.rotation.y = Math.PI * brick.rotY;

    boxMesh.material = boxMaterial;
    new BABYLON.PhysicsAggregate(
      boxMesh,
      BABYLON.PhysicsShapeType.BOX,
      definitionBrick[brick.type].physics,
      scene
    );

    tmp.push({ initialPosition: brick, mesh: boxMesh.position });
  });

  return tmp;
}

interface aggregateBricksMoveResult {
  score: number;
  max: number;
  median: number;
}

// スコア計算処理
function aggregateBricksMove(
  entris: EntryBrickPositions
): aggregateBricksMoveResult {
  const tmp = entris
    .map((entry) => {
      const { initialPosition, mesh } = entry;
      const { x, y, z } = initialPosition;
      const { x: mx, y: my, z: mz } = mesh;

      return Math.sqrt(
        Math.abs(x - mx) ** 2 + Math.abs(y - my) ** 2 + Math.abs(z - mz) ** 2
      );
    })
    .sort((a, b) => a - b);

  const median = tmp[Math.round(tmp.length / 2)];

  return {
    score:
      Math.round(tmp.reduce((acc: number, cur: number) => acc + cur, 0) * 100) /
      100,
    max: Math.round(Math.max(...tmp) * 100) / 100,
    median: Math.round(median * 100) / 100,
  };
}

const setupedBricks = setupBricks(scene, boxMaterial, brickSetup as BrickSetup);

engine.runRenderLoop(function () {
  scene.render();
});

setTimeout(() => {
  bollPhysics.body.applyImpulse(
    new BABYLON.Vector3(130, 0, 0),
    bollMesh.position
  );
}, 3000);

setInterval(() => {
  console.log(JSON.stringify(setupedBricks));
  document.getElementById("result").innerText = JSON.stringify(
    aggregateBricksMove(setupedBricks)
  );
}, 1000);

動かすと次のようになります。

衝突の前の段階でスコアに0.02と記載されています。隙間なく詰めていても、どうしても誤差が出てきてしまいます。

衝突すると、数値が変化しているのもわかります。

ここまでで、レンガを積み、物体をぶつけ、衝突前後の移動状況を示す指標を用意しました。 準備が整ったので、シミュレーション比較を始めます。

3 シミュレーション比較

3.1 イギリス積み

イギリス積みは、奇数段は壁に対して小口を並べます。

偶数段は、長手を前後でズラさずに並べます。

衝突する面から見ると次の様に見えます。

動作させた動画は次のようになります。

3.2 フランス積み

フランス積みは、長手と小口を前後させて並べます。

奇数段と偶数段は、長手の75%ズラして重ねます。

衝突する面から見ると次のように見えます。

動作させた動画は次のようになります。

3.3 ドイツ積み

ドイツ積みは、壁を作る面にすべて小口を向けます。

奇数段と偶数段は、小口の50%ズラして重ねます。

衝突する面から見ると次のように見えます。

動作させた動画は次のようになります。

3.4 長手積み

長手積みは、壁を作る面にすべて長手を向けます。

奇数段と偶数段は、長手の50%ズラして重ねます。

衝突する面から見ると次のように見えます。

動作させた動画は次のようになります。

3.5 結果発表

結果は次の通りとなりました。(各5回計測し、衝突前の値との差分との最大値最小値を除いた平均値です。)

積み方 スコア平均 1個当たりスコア最大値平均 一個当たりスコア中央値平均
イギリス積み 17.81 1.43 0.20
フランス積み 27.33 4.95 0.21
ドイツ積み 17.91 2.61 0.19
長手積み 103.19 8.81 0.26

衝突する方向に対して、奥行きがある積み方だけをするドイツ積みが一番スコアを抑えるかと思っていたのですが、イギリス積みが全体に優秀な成績を収めています。
この結果はちょっと意外でした。

全体の傾向として衝突する方向に対して、奥行きがある積み方は一度傾いても持ち直しますが、長手積みは大きく崩れていたのでスコアを伸ばしてしまいました。

ある一定の方向から力を加える実験でしたが、積み方1つで強さの違いを確かめることができました。 力を加える方向を変えたり、一面の壁ではなく建築物のようにもう少し大きくしたときの強さをシミュレーションをしてみると面白そうです。

3.6 追加実験

この記事のレビューをしてもらった同僚から、「フランス積みは衝突している箇所が小口になっているので長手にぶつけたらスコアを稼ぎそう」とコメントが有ったので、追加で試してみました。

偶数段と奇数段を入れ替えて、衝突するのが長手になるように実験したのが次の動画です。

積み方 スコア平均 1個当たりスコア最大値平均 一個当たりスコア中央値平均
フランス積み(長手に衝突) 63.05 8.18 0.21

どうしても落下しやすい最上段の端の細いレンガが落下によってスコアを稼いでいるようにも感じますが、先の比較よりも大きく崩れています。
「フランス積みは衝突している箇所が小口になっているので長手にぶつけたらスコアを稼ぎそう」という仮説が確認できました。

衝突するのが小口なのか長手なのかで差が出ましたが、壁を3列にし小口の奥にもレンガがあるという状況になれば、押し出される形で力が加わって別の結果も見えそうです。

まとめ

babylon.js でレンガを積んで、物体を衝突させその強さを比較してみました。
先の注意の通り、目地材を使った相互の接着はされておらず横滑りを防ぐための高い摩擦係数の設定により部分的な再現にとどまっていることは悔しい部分ではあるものの、物理エンジンを使いこれだけの違いをビジュアルで表現ができることは素晴らしいと感じます。

今年発売された「ゼルダの伝説 ティアーズ オブ ザ キングダム」も物理エンジンとして Havok が(そのまま同じではないのでしょうが)使われています。

物理エンジンをゲームで触れることはあっても「使う」になると急にハードルが高まるように感じますが、シチュエーションを限定すれば今回のような遊びが比較的容易にできる環境が babylon.js にはあります。

是非気になったことを実験してみてください。

採用情報

虎の穴では一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。
カジュアル面談やエンジニア向けイベントも随時開催中です。ぜひチェックしてみてください♪
yumenosora.co.jp