OpenSiv3Dでクレーンゲームにソフトボディを実装する

## はじめに

クレーンゲームの魅力を増すためには、リアルな物理シミュレーションが不可欠です。今回、OpenSiv3Dを用いてクレーンゲームにこのようなソフトボディの挙動を実装する過程を紹介します。

![ezgif.com-optimize (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34295/15f10058-edbf-536e-b98b-676357c2c9d2.gif)
(NT東京で展示した「らいとんクレーンゲーム」)

## OpenSiv3DとBox2D

OpenSiv3Dは、デフォルトでBox2Dの機能を提供しており、これを使ってソフトボディの実装に取り組むことにしました。


![ezgif.com-optimize.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34295/fdeb787a-0595-2feb-2faa-2c34b37fba96.gif)


```cpp
# include <Siv3D.hpp> // OpenSiv3D v0.6.11


Array<Vec2> ChaikinSmooth(const Array<Vec2>& inputVertices, int iterations = 1)
{
	// Error handling for insufficient vertex count
	if (inputVertices.size() < 3)
	{
		throw std::invalid_argument("Input vertices must form a polygon (at least 3 vertices).");
	}

	// Error handling for invalid iteration count
	if (iterations <= 0)
	{
		throw std::invalid_argument("Iterations must be a positive integer.");
	}

	Array<Vec2> currentVertices = inputVertices;

	for (int k = 0; k < iterations; ++k)
	{
		Array<Vec2> newVertices;

		const size_t vertexCount = currentVertices.size();
		for (size_t i = 0; i < vertexCount; ++i)
		{
			// Get current vertex and next vertex (with wrap-around)
			const Vec2& p0 = currentVertices[i];
			const Vec2& p1 = currentVertices[(i + 1) % vertexCount];

			// Compute new vertices
			const Vec2 q = p0 * 0.75 + p1 * 0.25;
			const Vec2 r = p0 * 0.25 + p1 * 0.75;

			// Add new vertices to the list
			newVertices << q << r;
		}

		// Update current vertices for next iteration (if any)
		currentVertices = newVertices;
	}

	return currentVertices;
}


struct SoftBody
{
	Array<P2Body> aroundBodies;
	P2Body centerBody;
	Array<P2DistanceJoint> softbodyJoints;
	double centerBodyRadius;
	double outerBodyRadius;
};

SoftBody CreateSoftBody(P2World& world, const Vec2& pos, double centerBodyRadius = 50.0, double outerBodyRadius = 30.0)
{
	SoftBody softbody;
	P2Material material;
	material.friction = 0.5;

	softbody.centerBodyRadius = centerBodyRadius;
	softbody.outerBodyRadius = outerBodyRadius;
	softbody.centerBody = world.createCircle(P2Dynamic, pos, Circle(centerBodyRadius), material);

	int32 num = 12;
	const double diffAngle = Math::TwoPi / num;

	for (int32 i = 0; i < num; ++i)
	{
		softbody.aroundBodies << world.createCircle(P2Dynamic, pos + Vec2::Right().rotated(diffAngle * i) * (outerBodyRadius + centerBodyRadius), Circle(outerBodyRadius), material);
	}

	for (int32 i = 0; i < num; ++i)
	{
		const Vec2 jointPosA = softbody.centerBody.getPos() + (softbody.aroundBodies[i].getPos() - softbody.centerBody.getPos()) * 0.0;
		const Vec2 jointPosB = softbody.aroundBodies[i].getPos();

		softbody.softbodyJoints << world.createDistanceJoint(softbody.centerBody, jointPosA, softbody.aroundBodies[i], jointPosB, (jointPosB - jointPosA).length()).setLinearStiffness(3.0, 0.5).setMinLength(0.0);
	}

	for (int32 i = 0; i < num; ++i)
	{
		softbody.softbodyJoints << world.createDistanceJoint(softbody.aroundBodies[i], softbody.aroundBodies[i].getPos(), softbody.aroundBodies[(i + 1) % num], softbody.aroundBodies[(i + 1) % num].getPos(), (softbody.centerBody.getPos() - softbody.aroundBodies[i].getPos()).length()).setLinearStiffness(1.0, 0.5).setMinLength(0.0);
	}

	return softbody;
}

void Main()
{
	Window::Resize(1280, 720);

	// 2D 物理演算のシミュレーションステップ（秒）
	constexpr double StepTime = (1.0 / 200.0);

	// 2D 物理演算のシミュレーション蓄積時間（秒）
	double accumulatedTime = 0.0;

	P2World world;
	Array<SoftBody> softbodies;

	// 地面
	Array<P2Body> grounds;
	grounds << world.createRect(P2Static, Vec2{ 0, -200 }, SizeF{ 600, 20 });
	grounds << world.createLine(P2Static, Vec2{ 0, 0 }, Line{ -500, -150, -300, -50 });
	grounds << world.createLineString(P2Static, Vec2{ 0, 0 }, LineString{ Vec2{ 100, -50 }, Vec2{ 200, -50 }, Vec2{ 600, -150 } });


	// 2D カメラ（中心座標 (0, -300), 拡大率 1.0）
	Camera2D camera{ Vec2{ 0, -300 }, 1.0 };


	while (System::Update())
	{
		for (accumulatedTime += Scene::DeltaTime(); StepTime <= accumulatedTime; accumulatedTime -= StepTime)
		{
			// 2D 物理演算のワールドを StepTime 秒進める
			world.update(StepTime);

			// 地面の下に 500 cm 以上落下した物体を削除する
			softbodies.remove_if([](const SoftBody& softbody) { return (500 < softbody.centerBody.getPos().y); });
		}


		// 2D カメラを更新する
		camera.update();
		{
			// 2D カメラから Transformer2D を作成する
			const auto t = camera.createTransformer();

			// すべての地面を描画する
			for (const auto& ground : grounds)
			{
				ground.draw(Palette::Gray);
			}

			for (const auto& softbody : softbodies)
			{
				Array<Vec2> vertices;

				for (const auto& body : softbody.aroundBodies)
				{
					vertices << body.getPos();
				}

				Polygon(ChaikinSmooth(vertices, 2)).calculateBuffer(softbody.outerBodyRadius).draw(Palette::Blue).drawFrame(5.0, Palette::White);

			}
		}

		// 2D カメラの操作を描画する
		camera.draw(Palette::Orange);

		if (SimpleGUI::Button(U"Add", Vec2{ 40, 80 }, 120))
		{
			softbodies << CreateSoftBody(world, Vec2(0.0, -400.0));
		}
	}
}


```

## ソフトボディの物理

ソフトボディとは、外部からの力に応じて形状が変化し、力が取り除かれると元の形に戻る物体のことです。これにより、よりリアルな物理シミュレーションが可能になります。LiquidFunなどの粒子ベースのアプローチを採らず、Box2Dの`DistanceJoint`を使って多角形を作成し、ソフトボディの挙動をシミュレートすることにしました。

![ezgif.com-optimize (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34295/9271271d-2ba9-2954-065b-54791ce931ef.gif)


```cpp

	for (int32 i = 0; i < num; ++i)
	{
		softbody.aroundBodies << world.createCircle(P2Dynamic, pos + Vec2::Right().rotated(diffAngle * i) * (outerBodyRadius + centerBodyRadius), Circle(outerBodyRadius), material);
	}

	for (int32 i = 0; i < num; ++i)
	{
		const Vec2 jointPosA = softbody.centerBody.getPos() + (softbody.aroundBodies[i].getPos() - softbody.centerBody.getPos()) * 0.0;
		const Vec2 jointPosB = softbody.aroundBodies[i].getPos();

		softbody.softbodyJoints << world.createDistanceJoint(softbody.centerBody, jointPosA, softbody.aroundBodies[i], jointPosB, (jointPosB - jointPosA).length()).setLinearStiffness(3.0, 0.5).setMinLength(0.0);
	}

	for (int32 i = 0; i < num; ++i)
	{
		softbody.softbodyJoints << world.createDistanceJoint(softbody.aroundBodies[i], softbody.aroundBodies[i].getPos(), softbody.aroundBodies[(i + 1) % num], softbody.aroundBodies[(i + 1) % num].getPos(), (softbody.centerBody.getPos() - softbody.aroundBodies[i].getPos()).length()).setLinearStiffness(1.0, 0.5).setMinLength(0.0);
	}

```

## コードの解説

`CreateSoftBody`関数では、中心の`centerBody`と周囲の`aroundBodies`を結ぶことで、ソフトボディを形成しています。各`DistanceJoint`の`setLinearStiffness`により、ソフトボディの弾性と減衰を制御しています。

## 問題点と解決策

実装したソフトボディは、`aroundBodies`の数が少ないと角ばった外観になってしまいます。この視覚的な問題を解決するために、Chaikinのコーナーカットアルゴリズムを適用しました。

```cpp
Polygon(ChaikinSmooth(vertices, 2)).calculateBuffer(softbody.outerBodyRadius).draw(Palette::Blue).drawFrame(5.0, Palette::White);
```

詳しくはこちらで記事にしました。
https://qiita.com/hamukun8686/items/9888d3f18dd33d56a7f6

あと、結構パラメータがシビアです。どんなパラメータをしてしても破綻しないようにうまく設計できればいいのですが、今はそうなっていません。

## まとめ

この記事では、OpenSiv3DとBox2Dを用いてクレーンゲームにソフトボディを実装する方法を紹介しました。ソフトボディのリアルな挙動は、ゲーム体験を格段に向上させることができます。