2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

第26回ぷちコン参加振り返り

2
Posted at

はじめに

historia様主催の第26回UE5ぷちコン(テーマ「だん」)に参加したので振り返りです。
応募した作品の動画は以下になります。

使用バージョン:UE5.8.2

注意
記事内のソースコードは、AIから提案されたコードを元にゲームの実装に合わせて手を加えています。
間違いや不要な個所が含まれている可能性があるので、参考程度に見ていただければと思います。

テーマの検討

マインドマップ
今回のテーマは「だん」。
ゲームのネタとしては困らないテーマでした。
色々ネタを考える中で、ノリで思いついた彫刻のゲームで進めることにしました。

今回やったこと

切断と削りを検証

テーマが「だん」なので、「石を切断して大まかに形を整えて削りで細かな調整をする」流れを想定しました。
メッシュの切断と削りができないと、彫刻ゲームとして成立しないので、まずはそこから検証しました。

進め方として、AIにC++のサンプルコードを出してもらい、それを元に実装を進めるようにしました。
UEでC++をあまり触っていなかったので、C++での開発の勉強を兼ねてMCPは使いませんでした。

実際に検証を進めていくと以下の問題が出てきました。

問題 対応
1 ブーリアンなので削りとは程遠い見た目で不自然なほど凸凹が目立つ 許容
2 削り切れなかった部分が細かく残る 適切な関数を使用
3 メッシュが複雑になってくると処理が終わるまで10msほどかかる(そのあと12msを超えたので更に増大が懸念) 非同期対応

問題と対応

見た目が不自然

見た目の不自然さについては最後まで考えましたが、割り切って許容することにしました。
20260909_ブーリアンでの削り.png

ブーリアンに使用するメッシュを細かくすればもう少し見た目が改善できたかもしれませんが、メッシュの頂点が著しく増大しそうなので避けました。
頂点の状態を考慮して処理すれば、もう少し彫刻っぽい見た目にできた可能性があったかもしれませんが、期間的にそこまで手が回らなかったので踏み込みませんでした。

細かい破片が残るのを何とかしたい

削りは単純なブーリアンで実装しているだけなので、メッシュのつながりが切れてもその場に残り続けてしまいます。
20260910_細かい破片が残る.png

とりあえず、解決方法が無いかAIに聞いてみるとあっさり解決。

UGeometryScriptLibrary_MeshRepairFunctions::RemoveSmallComponents()を使うことで、ある程度の大きさやポリゴン数のメッシュアイランドを除去できるというものです。
独立したメッシュにのみ有効なので不自然な形状が残りますが、細かいカスが浮いていることが無くなったので、何となく許容できる感じになりました。

実際に提案された関数が以下です。

void AMyActor::CleanupSmallIslands()
{
    UDynamicMeshComponent* MeshComp = ...; // 対象のDynamicMeshComponent
    UDynamicMesh* DynMesh = MeshComp->GetDynamicMesh();

    FGeometryScriptRemoveSmallComponentOptions Options;
    Options.MinTriangleCount = 10;   // 三角形数がこれ未満の島を削除
    Options.MinVolume = 0.0;         // 体積基準(不要ならデフォルトのまま)
    Options.MinArea = 0.0;           // 面積基準(不要ならデフォルトのまま)

    UGeometryScriptLibrary_MeshRepairFunctions::RemoveSmallComponents(
        DynMesh,
        Options
    );

    MeshComp->NotifyMeshUpdated(); // 変更をレンダリングに反映
}

処理時間の懸念

メッシュの加工のように時間のかかる処理である以上、3の処理時間増大に関しては避けられない問題です。
画面の動きがガタガタになるのは困るので、メッシュの加工は非同期処理をしたいところ。
こちらもAIに質問したら、ワーカースレッドで処理を行うサンプルを出してくれたので、手を加えて適用しています。

void UAsyncMeshProcessorComponent::CarveMeshAsync(const FTransform& MeshTransform, UDynamicMeshComponent* ToolMeshComponent, const FTransform& ToolTransform)
{
	if (!TargetMeshComponent || !ToolMeshComponent || bIsProcessing)
	{
		UE_LOG(LogTemp, Log, TEXT("処理中なので終了"));
		return;
	}

	bIsProcessing = true;
	bCancelRequested = false;

	double StartTime = FPlatformTime::Seconds();

	// 別スレッド実行前のGameスレッドでメッシュをコピー
	TSharedPtr<UE::Geometry::FDynamicMesh3> WorkingMesh = MakeShared<UE::Geometry::FDynamicMesh3>();
	TargetMeshComponent->GetDynamicMesh()->ProcessMesh(
		[&](const UE::Geometry::FDynamicMesh3& Mesh)
		{
			*WorkingMesh = Mesh;
		}
	);

	double EndTime = FPlatformTime::Seconds();

	UE_LOG(LogTemp, Log, TEXT("メッシュコピー[%f]"), EndTime-StartTime);

	// ツールで使用するメッシュを複製して参照する
	TSharedPtr<UE::Geometry::FDynamicMesh3> WorkingToolMesh = MakeShared<UE::Geometry::FDynamicMesh3>();
	ToolMeshComponent->GetDynamicMesh()->ProcessMesh(
		[&](const UE::Geometry::FDynamicMesh3& Mesh)
		{
			*WorkingToolMesh = Mesh;
		}
	);

	UE::Geometry::FTransformSRT3d MeshSRT = MeshTransform;
	UE::Geometry::FTransformSRT3d ToolSRT = ToolTransform;

	// ワーカースレッドでメッシュを加工
	TWeakObjectPtr<UAsyncMeshProcessorComponent> WeakThis(this);
	Async(EAsyncExecution::ThreadPool, [WeakThis, WorkingMesh, WorkingToolMesh, MeshSRT, ToolSRT]()
	{
		if (!WeakThis.IsValid()) return;

		double StartTime = FPlatformTime::Seconds();

		TSharedPtr<UE::Geometry::FDynamicMesh3> ResultMesh = MakeShared<UE::Geometry::FDynamicMesh3, ESPMode::ThreadSafe>();
		UE::Geometry::FMeshBoolean MeshBoolean(
			WorkingMesh.Get(), MeshSRT,
			WorkingToolMesh.Get(), ToolSRT,
			ResultMesh.Get(),
			UE::Geometry::FMeshBoolean::EBooleanOp::Difference
		);

		MeshBoolean.bSimplifyAlongNewEdges = true;
		MeshBoolean.bWeldSharedEdges = true;

		bool bSuccess = MeshBoolean.Compute();
		UE_LOG(LogTemp, Log, TEXT("メッシュ加工結果[%d]"), bSuccess);

		// 小さいメッシュを取り除く
		//poolを使うとアクセス例外が出るので外しておく
		//UDynamicMesh* TempDynamicMesh = WeakThis->MeshPool->RequestMesh();
		UDynamicMesh* TempDynamicMesh = NewObject<UDynamicMesh>();
		TempDynamicMesh->SetMesh(MoveTemp(*ResultMesh.Get()));

		FGeometryScriptRemoveSmallComponentOptions Options;
		//Options.MinTriangleCount = 10;
		Options.MinVolume = 2000.0; // 体積基準
		//Options.MinArea = 0.0; // 面積基準
		UGeometryScriptLibrary_MeshRepairFunctions::RemoveSmallComponents(TempDynamicMesh, Options);
		TempDynamicMesh->EditMesh([&](FDynamicMesh3& Mesh) {
			*ResultMesh = MoveTemp(Mesh);
		}, EDynamicMeshChangeType::GeneralEdit, EDynamicMeshAttributeChangeFlags::Unknown);

		//WeakThis->MeshPool->ReturnMesh(TempDynamicMesh);

		// UVを更新
		WeakThis->UpdateUV(*ResultMesh.Get());

		UE_LOG(LogTemp, Log, TEXT("加工&UV更新[%f]"), FPlatformTime::Seconds() - StartTime);

		if (WeakThis->bCancelRequested)
		{
			WeakThis->bIsProcessing = false;
			return;
		}

		// Gameスレッドで結果を戻す
		AsyncTask(ENamedThreads::GameThread, [WeakThis, ResultMesh]()
		{
			if (WeakThis.IsValid())
			{
				WeakThis->ApplyResultOnGameThread(ResultMesh);
			}
		});

	});
}

DynamicMeshの処理は別スレッドでも呼び出せるので、元のメッシュをコピーしてワーカースレッドで処理しています。結果をコンポーネントへ反映する必要があるため、Gameスレッドで戻しています。

その他の対応

切断

切断に関しては、指定されたメッシュを2つに切断するだけなので、大きな問題もなく割とあっさりと実装できました。
分割されたメッシュの扱いに手間取りましたが、AIに確認しつつ問題なく進めました。
むしろ切断を示すUI表示や操作が大変でした。

20260909_切断.png

パースがかかってまっすぐ切断しにくいのが難点ですが、だいたいなので…

UVの再生成

切断、削りによってメッシュの形状が変わるため、頂点のUVの再計算が必要でした。
石なので多少のずれは考慮せず、1枚のテクスチャでシンプルに0-1でマッピングしています。
法線から、XYZどの方向から投影するかを判断してUVを生成しています。
これもAIにサンプルを出してもらいつつ組み込んでいきました。

void UAsyncMeshProcessorComponent::UpdateUV(UE::Geometry::FDynamicMesh3& Mesh)
{
	using namespace UE::Geometry;
	
	int32 UVChannel = 0;
	FBox Bounds = LocalBounds;
	float UVScale = 1.0f;

	if (!Mesh.HasAttributes())
	{
		Mesh.EnableAttributes();
	}

	if (Mesh.Attributes()->NumUVLayers() <= UVChannel)
	{
		Mesh.Attributes()->SetNumUVLayers(UVChannel + 1);
	}

	FDynamicMeshUVOverlay* UVOverlay = Mesh.Attributes()->GetUVLayer(UVChannel);
	if (!UVOverlay) return;

	auto Normalize = [](double Value, double Min, double Size) -> float
		{
			return (float)((Value - Min) / Size);
		};

	auto SizeX = Bounds.Max.X - Bounds.Min.X;
	auto SizeY = Bounds.Max.Y - Bounds.Min.Y;
	auto SizeZ = Bounds.Max.Z - Bounds.Min.Z;

	for (int32 TriID : Mesh.TriangleIndicesItr())
	{
		auto Tri = Mesh.GetTriangle(TriID);
		auto V0 = Mesh.GetVertex(Tri.A);
		auto V1 = Mesh.GetVertex(Tri.B);
		auto V2 = Mesh.GetVertex(Tri.C);
		auto Centroid = (V0 + V1 + V2) / 3.0;

		auto Normal = Mesh.GetTriNormal(TriID);
		FVector3d AbsN(FMath::Abs(Normal.X), FMath::Abs(Normal.Y), FMath::Abs(Normal.Z));

		// 法線からXYZどの方向のUVを投影するかを決める
		int32 DominantAxiz;
		DominantAxiz = (AbsN.Y >= AbsN.X && AbsN.Y >= AbsN.Z)? 1 : (AbsN.Z >= AbsN.X && AbsN.Z >= AbsN.Y)? 2 : 0;
	
		auto ProjectVertex = [&](const FVector3d& P) -> FVector2f
			{
				switch (DominantAxiz)
				{
				case 0: // X面(U=Y,V=Z)
					return FVector2f(Normalize(P.Y, Bounds.Min.Y, SizeY) * UVScale, Normalize(P.Z, Bounds.Min.Z, SizeZ) * UVScale);
				case 1: // Y面(U=X,V=Z)
					return FVector2f(Normalize(P.X, Bounds.Min.X, SizeX) * UVScale, Normalize(P.Z, Bounds.Min.Z, SizeZ) * UVScale);
				default: // Z面(U=X,V=Y)
					return FVector2f(Normalize(P.X, Bounds.Min.X, SizeX) * UVScale, Normalize(P.Y, Bounds.Min.Y, SizeY) * UVScale);
				}
			};

		auto UV0 = ProjectVertex(V0);
		auto UV1 = ProjectVertex(V1);
		auto UV2 = ProjectVertex(V2);
	
		auto E0 = UVOverlay->AppendElement(UV0);
		auto E1 = UVOverlay->AppendElement(UV1);
		auto E2 = UVOverlay->AppendElement(UV2);
		UVOverlay->SetTriangle(TriID, FIndex3i(E0, E1, E2));
	}
}

点数の計算

ゲームを意識して制作しているため、最終的に目標にどれだけ近づけたかを示す指標が必要です。
今回は、結果と目標がどれだけずれているかで点数を出すことにしました。

目標と結果それぞれのメッシュのデプス値を6方向から正投影でキャプチャ、デプス値を参照してテクセル単位で差分を求めることで点数を計算しています。

精度はそこまで必要ではないので、256x256のR16Fのレンダーテクスチャを使用しました。
だいたいでやるのでしたら、128x128でも良かったかもしれません。

この判定方法は、どのカメラからも死角になる部分は採点できないという問題点があります。
そのため、目標は死角ができないよう形状をシンプルにしています。

20260905_採点用のキャプチャ.png

生成されるテクスチャは結果と目標でそれぞれ6枚なので計12枚。
それぞれの差分をDrawMaterialToRenderTargetノードを使って差分用のレンダーターゲットにレンダリングして集計を考えました。

BPでカウントするには処理速度的に無理なので、C++で実装しようと思いましたが、勉強も兼ねてコンピュートシェーダーを使ってみました。

ここでも、AIに確認してみました。
以下は、集計に使用したコンピュートシェーダーです。
AIからのサンプルを元にゲームに合うよう改造を加えています。

#include "/Engine/Public/Platform.ush"

Texture2D<float> TexGoal;
Texture2D<float> TexResult;

float ValidMaxDistance; // 判定に参照されるカメラからの最大距離
float ScoreDistanceThreshold; // スコア判定の閾値

// C++側の定義と合わせること
struct FScoreResult
{
    float Score;
    uint Count;
    // 確認用に返す
    uint TexSizeX;
    uint TexSizeY;
};

RWStructuredBuffer<FScoreResult> OutputResult;

#define THREADGROUP_SIZE_X 8
#define THREADGROUP_SIZE_Y 8
#define THREADGROUP_COUNT (THREADGROUP_SIZE_X * THREADGROUP_SIZE_Y)

groupshared float ResultScores[THREADGROUP_COUNT];
groupshared uint GoalCounts[THREADGROUP_COUNT];

// スレッドグループ内のスレッド数を設定
[numthreads(THREADGROUP_SIZE_X, THREADGROUP_SIZE_Y, 1)]

void MainCS(
    uint3 DispatchThreadID : SV_DispatchThreadID,
    uint3 GroupThreadID : SV_GroupThreadID,
    uint3 GroupID : SV_GroupID,
    uint GroupIndex : SV_GroupIndex
){
    const uint LocalIndex = GroupThreadID.y * THREADGROUP_SIZE_X + GroupThreadID.x;
    
    // ゴールと結果は同じサイズで指定する前提
    uint2 TexSize;
    TexGoal.GetDimensions(TexSize.x, TexSize.y);

    uint Count = 0;
    float Score = 0.0f;
    
    // 「DispatchThreadID」はテクスチャのピクセル座標
    if (DispatchThreadID.x < TexSize.x && DispatchThreadID.y < TexSize.y)
    {
        const int3 Pixel = int3(DispatchThreadID.xy, 0);

        float ValueGoal;
        ValueGoal = TexGoal.Load(Pixel);

        float ValueResult;
        ValueResult = TexResult.Load(Pixel);
    
        // デプス値がゴールに近いほど1になる
        float Diff = abs(ValueGoal - ValueResult);
        Score = (ScoreDistanceThreshold - clamp(Diff, 0, ScoreDistanceThreshold)) / ScoreDistanceThreshold;
        
        // 判定可能最大距離以下ならカウント(ゴールとしてみなせるピクセル)
        if (ValueGoal <= ValidMaxDistance)
        {
            Count = 1;
        }
        else
        {
            // 範囲外ならスコアは0として扱う
            Score = 0;
        }
    }

    ResultScores[GroupIndex] = Score;
    GoalCounts[GroupIndex] = Count;
    
    GroupMemoryBarrierWithGroupSync();

    for (uint Stride = THREADGROUP_COUNT/2; Stride > 0; Stride >>= 1)
    {
        if (GroupIndex < Stride)
        {
            ResultScores[GroupIndex] += ResultScores[GroupIndex + Stride];
            GoalCounts[GroupIndex] += GoalCounts[GroupIndex + Stride];
        }

        GroupMemoryBarrierWithGroupSync();
    }

    if (GroupIndex == 0)
    {
        const uint GroupsX = (TexSize.x + THREADGROUP_SIZE_X - 1) / THREADGROUP_SIZE_X;
        const uint GroupFlatIndex = GroupID.y * GroupsX + GroupID.x;
        
        FScoreResult ScoreResult;
        
        ScoreResult.Score = ResultScores[0];
        ScoreResult.Count = GoalCounts[0];
        ScoreResult.TexSizeX = TexSize.x;
        ScoreResult.TexSizeY = TexSize.y;

        OutputResult[GroupFlatIndex] = ScoreResult;
    }
}

C++とコンピュートシェーダーの連携部分などは何となく理解できましたが、数値を畳み込んで結果を合計する部分がいまいち理解できませんでした。

それでも、動くサンプルがあると理解しやすいです。

コンピュートシェーダーを使うことで、マテリアルとは違った扱いやすさを体験できました。
計算に特化するものは、コードで書くと親和性が良いです。

ゲームっぽさを出す

形から入ることでゲームのイメージがはっきりして、制作がより楽しくなります。

フォントもその1つ。いつもゲームの内容と雰囲気をイメージしたフォントを探しています。
自分のイメージに合うフォントを見つけました。

タイトルを想定してウィジェットにテキストを出すと、よりイメージがはっきりします。
(タイトルに人の名前を用いることに若干の懸念があったので、「工房」としています)

image.png

こうなるとテンションが上がって制作が加速します。
やりたいことも色々出てきます。

手書き風のフォントに合わせるため、ボタンなどのUIの枠も手書き風にしてみました。
以前、他の方の作品を見て、すごく良い雰囲気だったので参考にさせていただきました。

20260905_ボタンの枠と文字.gif

ただし、自分で手書きをすると良い雰囲気が出せず適当なだけになってしまうので、枠をノイズで歪ませて表現しています。

20260905_枠を動かすマテリアル.png

アイコンは使用数も少なくシンプルなので、Substance 3D Designerで作成しています。
やっぱり便利なツールですね。
image.png

MetaSoundの使用

MetaSound+AIで鋸の音を作れるのでは?

そう思ってAIに聞いてみたのですが、テキストで出力されたノード構成がさっぱり分かりませんでした。
頑張ってノードを構成してみたものの、正弦波が聞こえるだけ…

あまり時間もなかったので、最終的には鋸のSEを購入。
丁度良い再生時間のものは無かったのですが、時間指定で部分的に再生して間に合わせました。

image.png

イメージに近い音をMetaSoundで加工するのは有りかと思いました。

振り返りの段階で、「MCPでノードを使って作ってもらえばいけるのでは?」と思い、試しにMCPで「鋸の音を作って」とお願いしましたが、正弦波のノードが配置されただけで終わりました。

もう少し音の組み方など細かい要求を出さないと無理そうです。
音に関する知識があればもう少しまともに生成できたのかもしれません。

AsyncMessageSystemの使用

まだExperimentalですが、すごく便利なプラグインです。

image.png

以下の特徴があります。

  • タグでメッセージを管理
    • 管理、検索がしやすい
  • タグを設定してメッセージをブロードキャストできる
    • ペイロードでデータを送信可能
    • 対象のタグでメッセージを受け取ることができる
    • 特定のクラスに依存せずデータをやり取りできる
  • スレッドセーフ

前回のぷちコンから使い始めましたが、特定のクラスに依存しないのが一番の利点です。
他のクラスへの参照を保持する必要が無いので、実装をすっきりさせることもできます。

依存関係が無いクラスどうしでも、必要に応じてデータの受け渡しができるのも良いです。
例えばゲーム内の実績などを実装する場合、お互いのクラスを知らなくても必要なデータを受け渡しできます。実績の内容が追加されても、タグとペイロードの内容を用意すればスムーズに対応できます。

メッセージが増えてくると、どこで何が送られているのか分かりにくくなる場合もありますが、どんな時にどのタグでメッセージを送るのかをきちんと決めておくと、後から処理を追加する場合でも、メッセージだけを意識して処理を構築できます。
相手のクラスの仕様が変わっても、メッセージの内容が変わらない限りは影響を受けません。

メッセージを待つ
BP_Flow_Carving-EventGraph.png

メッセージをブロードキャストする
BP_ScreenController-OnFinishScoring.png

まとめ

  • AIを有効に使うことで、試行錯誤の手間を減らせる
    • ある程度の知識がある方が有効活用できる
    • 目指す方向を具体的に示さないと有効な活用は難しい
  • ゲームの雰囲気を作ることで制作が加速する
    • 可能な限り早い段階で、想定していたゲームの雰囲気を作り出す
      • それを元にやりたいことが明確になる
      • 後半だと時間が足りなくなるので並行して進める

おわりに

ぷちコンは可能な限りBlueprintのみでやる方針でしたが、今回はBlueprintだけでは実装が難しい個所もありそうだったのではC++を使いました。

ノードベースだとあまり使わなかったAIも、コードとの相性が凄く良かったことを改めて実感できました。

ただ、AIが出力したコードがこっそり間違っている場合も多く、出力即採用とはなりませんでしたが、試行錯誤の時間は圧倒的に少なくなることも実感できました。

制作へのAIの利用は今更な気もしますが、今回の制作を通して自分にとってもかなり勉強となりました。

ぷちコンは1か月半という期間が絶妙だと思います。
企画から制作まで、飽きずに集中して乗り切るにはちょうど良い期間です。
素晴らしいコンテストを開催してくださるヒストリアさんに感謝です。

使用したアセット

アセット 使用箇所
Stylized Workplace 背景
Dancing Lady - sculpture by Henryk Burzec 背景
Hug Sculpture photoscan 背景
The Twist Sculpture 背景
Hand Sculpture 背景
Pro Sound Collection SE全般
Stone Saw Cutting 石を切断する音
無心 ゲーム内フォント
コミカルで愉快なポップサウンド タイトルと目標選択画面のBGM
明るく愉快なシンセサイザーのポップス プレイ中のBGM
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?