7. 자체엔진 애니메이션 블렌드 스페이스

JellyPower·2026년 3월 24일

애니메이션 블렌딩

  • 3D게임에서 특히 게임의 퀄리티가 높아질 수록 애니메이션 퀄리는 매우 중요해집니다.
  • 그리고 그러한 애니메이션의 퀄리티를 프로그래밍적으로 높일 수 있는 가장 핵심적인 방법이 바로 애니메이션 블렌딩 입니다.

  • 위 사진을 보면 3명의 캐릭터가 다른 포즈를 취하고 있습니다.
  • 제일 왼쪽 캐릭터는 가만히 서있고 제일 오른쪽 캐릭터는 열심히 달리고 있습니다. 중간의 캐릭터는 느리게 걷고있죠.
  • 근데 실제로 사용된 애니메이션 에셋은 서있기, 뛰기 단 두 개 뿐입니다.
  • 가만히 서있는 애니메이션과 달리는 애니메이션을 섞어줌으로서 느리게 걷는다는 애니메이션을 만들어 준 것이죠.
  • 이러한 방법론은 다양하게 적용될 수 있습니다.
  • 걷고있던 캐릭터가 공격을 시작할 때 공격 모션의 시간에 따라서 더욱 높은 가중치로 블렌딩 된다거나 캐릭터가 움직이는 방향이 30도라고 한다면 0도방향으로 움직이는 애니메이션과 45도 방향으로 움직이는 애니메이션을 블렌딩함으로서 새로이 움직이는 방향 또한 만들어 줄 수 있겠죠.
  • 결국 언리얼의 애니메이션 몽타주나 유니티 애니메이션 트리거를 통해서 적용되는 애니메이션들도 유저가 사용하기 쉽게 추상화 돼있을 뿐 결국은 애니메이션 에셋들을 블렌딩 해준 결과라고 볼 수 있죠.

블렌드 스페이스

  • 그리고 많은 게임엔진들은 이러한 애니메이션 블렌딩을 유저가 손쉽게 진행할 수 있도록 속칭 블렌드 스페이스와 같은 기능을 제공합니다.
  • 위의 두 엔진들의 사용법을 간단하게 설명해 보겠습니다.
    • 임의의 2D 공간에 유저가 특정 좌표에 어떤 애니메이션 에셋을 사용할지 정해줍니다.
    • 보통은 유저가 캐릭터가 움직이는 방향 기준으로 애니메이션 에셋들의 포인트를 지정해 줍니다.
    • 그러면 언리얼 기준으로 x표시, 유니티 기준으로 빨간 점을 캐릭터 이동 방향에 위치시키면 해당 위치에 알맞게 애니메이션을 블렌딩 해줍니다.

풀어야 할 문제

  • 위 설명처럼 쉽게 “와 블렌딩 완료~” 라고 생각하고 넘어가면 편하겠지만 실상은 문제가 조금 더 복잡합니다.
  • 바로 내가 지정한 x지점(혹은 빨간 점) 기준으로 애니메이션을 블렌딩하기 위해서 어떤 애니메이션들을 어느 가중치만큼 블렌딩 해야할지를 풀어내는 과정이 필요하기 때문입니다.
  • 해당 문제를 해결하기 위해선…
  • 첫번째론 위 사진에서 나온 것처럼 각 애니메이션 클립에 대응되는 임의의 점들을 3개씩 모아 삼각형 형태로 집합들을 만들어 줘야합니다.
  • 두번째론 각 집합의 삼각형 내에 위치한 지점에서의 가중치를 구해 어느만큼 블렌딩할지 계산해야 합니다.
  • 첫 번째 문제는 보통 Triangulation 문제라고 하며 문제를 풀기 위해 일반적으로 보이어 왓슨이라는 알고리즘을 사용합니다. 하지만, 현재 제 엔진은 편의를 위해 하드코딩으로 공간을 나눠놓은 상황이니 일단 넘어가도록 합시다.
  • 두 번째 문제는 바리센트릭 좌표계를 활용하여 해결이 가능합니다. 이에 대해 좀 더 자세히 알아보도록 하죠.

바리센트릭 좌표계

  • 바리센트릭 좌표계는 쉽게 말해 삼각형 내의 공간에 임의의 점을 집었을 때, 해당 점이 각 삼각형을 이루는 세 정점에 얼마나 가까이 있는지를 나타낸다고 보시면 됩니다.
  • 위 사진처럼 임의의 점을 기준으로 큰 삼각형을 나누는 작은 삼각형들의 너비를 계산해 이 비율을 통해 정점의 가중치를 구할 수 있습니다.
  • 너비의 비율 계산하면 되기 때문에 보통 외적 벡터의 길이를 구해 비율을 구하는 방식이 제일 쉽고 빠릅니다.

실제 구현(애니메이션 선택)

  • 제 엔진의 경우 편의를 위해 하드코딩으로 애니메이션 블렌딩 공간을 8개로 나눠놨습니다.
  • 이를 도식화 한다면 위의 이미지와 같이 분할된 공간일 것입니다.
enum class E8Dir : int32
{
	None,

	U,
	UR,
	R,
	DR,
	D,
	DL,
	L,
	UL,

	Count
};
// 블렌딩을 위한 각 지점을 Enum을 통해 지정해 줍니다.
		constexpr int32 TEMP_COUNT = (int32)E8Dir::Count;
		static const Vector4f BlendPosByDir[TEMP_COUNT] =
		{
			{0, 0, 0 ,0},		// None,
			{0, 1, 0, 0},		// U,
			{0.71, 0.71, 0, 0},		// UR,
			{1, 0, 0, 0},		// R,
			{0.71, -0.71, 0, 0},		// RD,
			{0, -1, 0, 0},		// D,
			{-0.71, -0.71, 0, 0},		// DL,
			{-1, 0, 0, 0},		// L,
			{-0.71, 0.71, 0, 0}		// LU,
		};
// 각 지점과 좌표를 매칭시킵니다.

		constexpr E8Dir Dir0 = E8Dir::None;
		E8Dir Dir1;
		E8Dir Dir2;
		
		// !! 1. 유저가 지정한 방향을 좌표로 바꿔 어느 애니메이션 에셋을 블렌드할지 결정합니다 !!
		if (AngleInRad < XM_PIDIV4) // 0~45
		{
			Dir1 = E8Dir::UR;
			Dir2 = E8Dir::R;
		}
		else if (AngleInRad < XM_PIDIV4 * 2) // 45~90
		{
			Dir1 = E8Dir::U;
			Dir2 = E8Dir::UR;
		}
		else if (AngleInRad < XM_PIDIV4 * 3) // 90~135
		{
			Dir1 = E8Dir::UL;
			Dir2 = E8Dir::U;
		}
		else if (AngleInRad < XM_PIDIV4 * 4) // 135~180
		{
			Dir1 = E8Dir::L;
			Dir2 = E8Dir::UL;
		}
		else if (AngleInRad < XM_PIDIV4 * 5) // 180~225
		{
			Dir1 = E8Dir::DL;
			Dir2 = E8Dir::L;
		}
		else if (AngleInRad < XM_PIDIV4 * 6) // 225~270
		{
			Dir1 = E8Dir::D;
			Dir2 = E8Dir::DL;
		}
		else if (AngleInRad < XM_PIDIV4 * 7) // 270~315
		{
			Dir1 = E8Dir::DR;
			Dir2 = E8Dir::D;
		}
		else // 315 ~ 360
		{
			Dir1 = E8Dir::R;
			Dir2 = E8Dir::DR;
		}

		// !! 2. 각 지점의 좌표값을 얻어옵니다. !!
		const Vector4f& v0 = BlendPosByDir[(int32)Dir0];
		const Vector4f& v1 = BlendPosByDir[(int32)Dir1];
		const Vector4f& v2 = BlendPosByDir[(int32)Dir2];

		Vector4f BlendPoint4f = { _BlendPoint.X, _BlendPoint.Y, 0, 0 };

		// !! 3. 각 지점의 좌표값을 기준으로 블렌드 Wegith를 계산합니다. !!
		Vector4f Weights = SS::CalcBarycentricWeight(BlendPoint4f, v0, v1, v2);
		
		// 블렌드 값은 각각 Vector4f.x, Vector4f.y, Vector4f.z에 담깁니다.		
		float w0 = Weights.X;
		float w1 = Weights.Y;
		float w2 = Weights.Z;
		
		
		// !! 4. 블렌드할 애니메이션의 실제 데이터를 들고옵니다 !!
		IRenderAnimAsset* FoundRenderAnimAsset0 =
			AssetManager->FindAssetByName<IRenderAnimAsset>(_RenderAnimAssetNames[static_cast<int32>(Dir0)]);
		const RenderAnimRawData* AnimRawData0 = FoundRenderAnimAsset0->GetKeyFrameAnimData();

		IRenderAnimAsset* FoundRenderAnimAsset1 =
			AssetManager->FindAssetByName<IRenderAnimAsset>(_RenderAnimAssetNames[static_cast<int32>(Dir1)]);
		const RenderAnimRawData* AnimRawData1 = FoundRenderAnimAsset1->GetKeyFrameAnimData();

		IRenderAnimAsset* FoundRenderAnimAsset2 =
			AssetManager->FindAssetByName<IRenderAnimAsset>(_RenderAnimAssetNames[static_cast<int32>(Dir2)]);
		const RenderAnimRawData* AnimRawData2 = FoundRenderAnimAsset2->GetKeyFrameAnimData();

실제 구현(애니메이션 블렌딩)

  • 위에서 값을 구했다면 이후부턴 단순히 블렌드를 진행해주면 끝입니다.
  • 사실은 EvaluatePose와 BlendThreePoses내부적으로 많은 일이 일어나긴 합니다.
  • 더불어 애니메이션을 에셋화하고 실제 Evaluate하여 포즈형태로 만들 수 있는 전반적인 시스템이 구성되기도 해야합니다.
  • 그런데 그러한 시스템을 전부 설명하기엔 너무 복잡하니 우선은 넘어가도록 합시다…
		// !! 5. 위에서 골라낸 애니메이션 에셋들을 기준으로 각 포즈를 구해줍니다. !!
		EvaluatePose(_BlendPose0, _BindingIdxByName, AnimRawData0, TimeRatio, _RootToIgnore);
		EvaluatePose(_BlendPose1, _BindingIdxByName, AnimRawData1, TimeRatio, _RootToIgnore);
		EvaluatePose(_BlendPose2, _BindingIdxByName, AnimRawData2, TimeRatio, _RootToIgnore);
		
		// !! 6. 위에서 계산한 가중치를 기준으로 각 포즈를 블렌딩 해줍니다. !!!
		BlendThreePoses(
			_ResultPose,
			_BlendPose0, w0,
			_BlendPose1, w1,
			_BlendPose2, w2
		);

결과

https://www.youtube.com/watch?v=J_K8yV4KCV8&embeds_referring_euri=https%3A%2F%2Fwww.notion.so%2F&source_ve_path=MjM4NTE

  • 만약 해당 과정을 전부 적용해낸다면 위 링크의 영상과 같은 결과를 확인할 수 있습니다.

레퍼런스

  • 실제 애니메이션 블렌딩을 테스트 해보고싶으시다면 아래 링크에서 확인해 볼 수 있습니다.
  • 제가 직접 만든 렌더러에 가속도 기반 캐릭터 무브먼트와 블렌드 스페이스를 적용한 샘플 프로젝트의 빌드입니다.

https://github.com/jellypower/SSGame.git

0개의 댓글