[Unity]トップビュー向けの自由形状な地形を作るテクニック

**[グレンジ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/grenge)** 5日目担当の、flankidsです。
普段は操作、アニメーション、カメラワークなどなどで遊び心地を作ることを主にやっています。楽しいものを作るのが好きです！

今回は、直近の開発で作った地形のデザイン自動反映の仕組みについて書きました。
**もし楽しんでいただけたら「いいね」を押してもらえると、とても励みになります！**

# 作ったもの

ピンボール系のゲーム開発でSpriteShapeでプランナーさんに地形のレベルデザインをしてもらったものに、動的にデザインを反映する仕組みを作りました。

|||||
|:---:|:---:|:---:|:---:|
|![stage_base.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/c7b7c6fd-6b71-7329-356c-69ac2e5ea9f1.jpeg)|![stage_a.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/c44d6909-4e03-6090-8891-407a76f50e26.jpeg)|![stage_b.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/7fd81b76-e342-dfff-0f27-aa147b73e012.jpeg)|![stage_c.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/cf67487f-f18b-0c19-d69d-91f69c388ef3.jpeg)|

上図の一番左のように、ステージの地形の判定をSpriteShapeで作りながらボールの流れが意図した通りに確認しながら調整をして、作業が完了したらデザインセットを指定すると、草原や砂漠、怪しげな森のデザインが反映されるようにしています。

https://x.com/flankids/status/1731647966660993444

こんな感じで、簡単で自由な地形を作りながら判定のテストをできるので便利です。

Sprite Shapeのおかげでこの仕組みは作ることができたのですが、Sprite Shapeどちらかというと横スクロールの地形を作ることを前提にしているなーと感じることが多く、上のような**トップビューの地形のデザインを作ろうとした時に困る**ことがありました。

本記事ではその問題の備忘録と、対策になるちょっとしたアイデアをまとめてみました。



# Sprite Shape

Unityには自由な形状でデザインを作る機能として、[SpriteShape](https://blog.unity.com/ja/engine-platform/intro-to-2d-worldbuilding-with-sprite-shape) があります。

![image7-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/25e75a95-d1c7-9a58-f471-7b912412186c.gif)
▲ベジエ曲線を使ってSpriteをグニャグニャ動かせる便利な仕組み

少ない画像で地形にバリエーションを作るという点でタイルマップと似た仕組みですが、より有機的なデザインが作れるため、アクションゲームに向いています。


## Sprite Shapeで出来なかったこと

今回、トップビューの地形作りに欲しかったデザインは以下のようなイメージでした。

|地形の判定|デザインの反映結果|
|:---:|:---:|
|![level_design.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/4eadf532-3b1a-7605-507a-ff70a291b0f9.jpeg)|![designed.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/e8030ff8-2b5f-39ef-6ea4-f8856e7ecf36.jpeg)|

- 地形の面（草地の部分）が判定範囲を埋める
- 左右や下のフチに厚みを表現するデザイン（崖の部分）が見える

しかし、Sprite Shapeでこれに近いデザインを作ろうとしてできたのが下記のようなものでした。

|Sprite Shapeの判定|デザインの反映結果|
|:---:|:---:|
|![level_design_shape.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/84a3baf5-d94b-d19b-8fcc-756035c29129.jpeg)|![designed_shape.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/8b9f19e9-d920-7c92-75e1-8b7637f93d52.jpeg)|

それっぽく見えなくもないですが、崖のデザインが切れてしまっているのが目立ちます。
Sprite Shape Profileの設定は以下の通り。

|![stage_meadow_floor_edge.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/51709188-ae1c-da09-317d-bc1346726a26.png)|![_test.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/c3c49f42-76aa-22a6-f827-a8d2752117cf.png)|
|:---:|:---:|
|![top.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/2cd28881-ac09-33c8-701d-8a8c5bf047b3.jpeg)|![bottom.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/4bde8c33-b1b6-137d-ee9b-36ea9aa258c4.jpeg)|

Angle Rangesで左右〜上の方は草のスプライト、下の方は崖付きの草スプライトを使っています。

Sprite Shapeでは曲線を作る各ノードのTangent Modeを`Linear`にすることで角用のスプライトを設定することもできるので、曲線状のデザインでなければそれを利用してもう少し自然にすることもできると思います。しかし、今回は自由な曲線が必要だったため、別な方法で解決する必要がありました。

# 対策方針

単純ですが、Sprite Shapeを下図のように重ねて左右と下方向に膨らませることで、厚みのデザインを作りました。
ついでにレイヤーを3層にすることで、落ち影の表現も加えています。

![layer.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/572c2ea2-c216-0fe1-7db1-7b64157df6f9.jpeg)

|Sprite Shapeの判定|デザインの反映結果|
|:---:|:---:|
|![level_design.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/506ba8f3-8ffa-d3b0-7e22-9777285d7fc6.jpeg)|![designed.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/8633aee3-55e1-3e58-2b72-4e9ecf9b0054.jpeg)|

それっぽくなったはず。

# 仕組み

まず、Sprite Shapeが作る図形の輪郭が持つ外向きのベクトル（3Dモデルでいうところの法線）を計算します。

![normal.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/f30fd461-f9ca-dc8f-1726-6dbeb0957a5f.jpeg)
▲各ノードの点から伸びる白い線が法線

ノードのLeft TangentへのベクトルからRight Tangentへのベクトルに向かって右回転させて中間地点となる角度を計算して、法線（Normal）として扱っています。

![normal_calc.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/def7b737-3846-b54f-aaae-03f6ea689aef.jpeg)

崖や影など、2枚目以降のレイヤーには
- 上方向のオフセット値
- 下方向のオフセット値
- 左右方向のオフセット値

を設定できるようにしています。

ベースとなるSpriteShapeを複製して、各ノードの法線に対して
- ベクトルのy要素が正数なら`上方向のオフセット値`を乗算
- ベクトルのy要素が負数なら`下方向のオフセット値`を乗算
- ベクトルのx要素に`左右方向のオフセット値`を乗算

をすることで、崖の厚みや影の面積を作れるようになっています。

# スクリプト

<details><summary><b>----------------------------------------<br> FieldDynamicDesign.cs（折りたたみ）<br> ------------------------------------------</b></summary><div>

```csharp:FieldDynamicDesign.cs
using System;
using UnityEngine;
using UnityEngine.U2D;

public class FieldDynamicDesign : MonoBehaviour {
	[Serializable]
	public class LayerInfo {
		public SpriteShape Profile;
		public float TopOffset;
		public float BottomOffset;
		public float SideOffset;
	}

	[SerializeField] private LayerInfo[] _layers;

	private SpriteShapeController[] _spriteShapes;

	private void Awake() {
		Execute();
	}

	[ContextMenu("Execute")]
	public void Execute() {
		// 子オブジェクトの全てのSpriteShapeControllerを取得
		_spriteShapes = GetComponentsInChildren<SpriteShapeController>();
		var layerCount = _layers.Length;
		
		foreach (var spriteShape in _spriteShapes) {
			// 各SpriteShapeControllerに_layersの1つめのProfileを設定
			spriteShape.spriteShape = _layers[0].Profile;
			// 基準となるsortingOrderを取得
			var sortingOrder = spriteShape.GetComponent<SpriteShapeRenderer>().sortingOrder;
			
			for (int i = 1; i < layerCount; i++) {
				var layerInfo = _layers[i];
				// 下に重ねるSpriteShapeControllerを作成
				var destShape = CreateSpriteShapeController(
					$"Layer ({i})",
					sortingOrder - i,
					spriteShape.transform,
					layerInfo.Profile
				);
				CopyShape(spriteShape, destShape, layerInfo);
			}
		}
	}

	/// <summary> 新規にSpriteShapeControllerを作成する </summary>
	private static SpriteShapeController CreateSpriteShapeController(string objName, int sortingOrder, Transform parent, SpriteShape profile) {
		var gameObj = new GameObject(objName);

		gameObj.transform.SetParent(parent, false);

		var renderer = gameObj.AddComponent<SpriteShapeRenderer>();
		renderer.sortingOrder = sortingOrder;
		var controller = gameObj.AddComponent<SpriteShapeController>();
		controller.spriteShape = profile;
		return controller;
	}

	private static void CopyShape(SpriteShapeController src, SpriteShapeController dst, LayerInfo offset) {
		dst.spline.Clear();
		for (int i = 0; i < src.spline.GetPointCount(); i++) {
			// ベジェ曲線を作る左右のタンジェントから、曲線の法線（外向きのベクトル）を計算する
			var leftTangent = src.spline.GetLeftTangent(i);
			var rightTangent = src.spline.GetRightTangent(i);
			var normal = CalcSplineNormal(leftTangent, rightTangent);

			// コピー先のSpriteShapeControllerの回転分を打ち消して、法線をワールド座標系に合わせる
			normal = Quaternion.Euler(0f, 0f, -dst.transform.eulerAngles.z) * normal;

			// コピー先のSpriteShapeControllerのスケールを打ち消して、法線の長さをワールド座標系に合わせる
			normal.x /= dst.transform.lossyScale.x;
			normal.y /= dst.transform.lossyScale.y;

			// オフセット値を法線に掛けて、コピー先の座標に足す 
			normal.x *= offset.SideOffset;
			normal.y *= normal.y >= 0 ? offset.TopOffset : offset.BottomOffset;

			// コピー先のSpriteShapeController座標系に合わせて法線を回転させる
			normal = Quaternion.Euler(0f, 0f, dst.transform.eulerAngles.z) * normal;
			dst.spline.InsertPointAt(i, src.spline.GetPosition(i) + normal);

			// その他のパラメータをコピー
			dst.spline.SetTangentMode(i, src.spline.GetTangentMode(i));
			dst.spline.SetLeftTangent(i, leftTangent);
			dst.spline.SetRightTangent(i, rightTangent);
			dst.spline.SetSpriteIndex(i, src.spline.GetSpriteIndex(i));
			dst.spline.SetCorner(i, src.spline.GetCorner(i));
		}
		dst.splineDetail = src.splineDetail;
	}

	/// <summary> ベジェ曲線を作る左右のタンジェントから、曲線の法線（外向きのベクトル）を計算する </summary>
	private static Vector3 CalcSplineNormal(Vector3 leftTangent, Vector3 rightTangent) {
		// 左右のタンジェントの角度を計算
		var leftAngle = CalcAngle(leftTangent);
		var rightAngle = CalcAngle(rightTangent);

		// 右回転で角度の中間を計算するため、右の角度が大きい場合は360度減算する
		if (leftAngle < rightAngle) {
			rightAngle -= 360f;
		}

		// 左右のタンジェントへの角度の中間を計算する
		var targetAngle = Mathf.Lerp(rightAngle, leftAngle, 0.5f);
		// 中間角度をSpriteShapeの外向きの角度（法線ベクトルの向き）として、ベクトルに変換する
		var normal = Quaternion.Euler(0f, 0f, targetAngle) * Vector3.up;
		return normal;
	}

	/// <summary> ベクトルの持つ角度を計算して0〜360度の範囲で返す </summary>
	private static float CalcAngle(Vector3 vector) {
		var axis = Vector3.Cross(Vector3.up, vector);
		var angle = Vector3.Angle(Vector3.up, vector);
		// 0〜360度の範囲に補正する
		if (axis.z < 0) {
			angle *= -1;
			angle += 360f;
		}
		return angle;
	}
}
```

</div></details>

このコンポーネントをアタッチしているオブジェクト以下にある`SpriteShapeController`に対して、Inspectorで設定した`Layers`の数だけデザインのレイヤーを作り、Sprite Shape Profileを反映→オフセット値を反映する仕組みです。

![Inspector.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/727730ec-ca0d-9f6b-4bf5-91cc4023045a.jpeg)

Topは負数にして潰して、Bottomは膨らませ、Sideもちょっと膨らませる、くらいの使い方が基本です。

仕上げに地形の上にディティールデザインを配置していけば華やかになるかなと思います。

# 所感

今回の実装はすごく単純なものですが、突き詰めて行くとプロシージャル生成的な考え方になっていくのかなと思います。
最近だと、CEDECで『星のカービィ ディスカバリー』のステージをプロシージャル生成しているという講演があり、非常にシステマチックにデザインを作っているなと感銘を受けました。

各所のゲームメディアさんから講演レポート記事も上がっていますが、↓CEDiLに講演資料が上がっていて詳細に内容を確認できるので必見です！

https://cedil.cesa.or.jp/cedil_sessions/view/2569

ユニークなデザインを作ることは重要ですが、デザインを仕組み化することで、開発の中でレベルデザインを試行錯誤しやすくしたり、コンテンツ量を増やすこともとても大事です。また、エンジニアとして貢献しやすいところだと思います。
上記講演のような高度なことはすぐに実践できずとも、この手の仕組みづくりは積極的に行っていきたいですね。