{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[Unreal Engine] Water Plugin 및 이를 이용한 연출들

Water Plugin

  언리얼 엔진에는 Water Plugin을 이용하여 바다나 호수 등을 연출할 수 있습니다. 이에는 물과 관련한 고품질의 머티리얼 등을 제공하므로 들여다보는 것이 꽤 가치가 있을 것입니다. 특히, Phil in the Mirror에서는 공간 전체가 물로 차오르는 것을 연출할 필요가 있었고, Water Plugin을 사용하는 것이 적절하다고 판단되었습니다. 이는 실험 단계 플러그인이지만, 5.5 버전 기준으로 패키징에 문제가 없었고, 일부를 수정하여 게임의 요구사항에 맞게 사용할 수 있는 형태로 개선할 수 있었습니다.


머티리얼 살펴보기

  Water Plugin은 5.5 기준으로 수위의 높이를 런타임에 조절할 수 있는 기능을 명시적으로 제공하지 않습니다. 그러나 수위를 조절하는 것은 기술적으로 당연히 가능할 것이라고 생각했고, 전반적인 머티리얼을 파악하기로 했습니다. 의심가는 머티리얼 파라미터를 우선 추려냈었는데, UseFixedZ와 FixedZ 파라미터였습니다.



  Water Plugin은 기본적으로 Ocean이나 Lake, River 등을 만들 수 있게 편의성을 제공하고 있었고, 그들은 각자 머티리얼을 이용해서 물을 렌더링하고 있었습니다. 공통적으로 사용되는 M_WaterPlugin_Water를 확인해볼 수 있었고, 그 하위에는 WaterHeightMappingVS 함수, 그 안에는 WaterBodyData라는 함수를 살펴볼 수 있었습니다. 솔직히 이들을 노드로 일일히 봐가면서 해석하는 것은 어렵고 귀찮은 일이므로, Water Height Z를 결정하는 요소만을 찾아봤었던 것 같습니다. 결론적으로는 UseFixedZ가 true이면 FixedZ의 값이 주어진 높이로 고정되는 로직이 존재함을 확인했습니다.

  따라서 이를 이용한다면 물의 높이를 변경시킬 수 있다는 것이었습니다. 오래 전 일이라 기억이 잘 나지 않는데, 5.5 기준으로 실험 단계에 있는 플러그인이라 그랬던지, UseFixedZ가 제대로 동작하지 않았던 것 같은 기억이 있습니다. 그래서 머티리얼의 일부 노드를 수정했었고, 위와 같은 형태를 만들어서 해결했었던 것 같습니다.

  또한 카메라가 물 경계 밑으로 잠겼을 때 Underwater Post Process가 함께 이루어져야 하는데, 이는 FixedZ만을 올린다고 해서 단순히 해결되는 문제는 아니었습니다. 정확하지는 않지만, 내부적으로 Underwater Post Process Material의 경우 물의 바깥에 있는 경우 굳이 Post Process 파이프라인을 거칠 필요가 없으므로, 물의 경계에 관한 바운딩 박스 정보(혹은 파도의 높이 등)를 기반으로 렌더링 여부를 파악하기 때문인 것으로 보입니다. 다음 코드를 참고해보세요:

for (const FWaterBodyPostProcessQuery& Query : WaterBodyQueriesToProcess)
{
	float LocalDepthUnderwater = 0.0f;

	// Underwater is fudged a bit for post process so its possible to get a true return here but depth underwater is < 0
	// Post process should appear under any part of the water that clips the camera but underwater audio sounds should only play if the camera is actualy under water (i.e LocalDepthUnderwater > 0)
	bUnderwaterForPostProcess = 
		GetWaterBodyDepthUnderwater(Query, LocalDepthUnderwater);
	if (bUnderwaterForPostProcess)
	{
		CachedDepthUnderwater = 
			FMath::Max(LocalDepthUnderwater, CachedDepthUnderwater);
		UnderwaterPostProcessVolume.PostProcessProperties = 
			Query.WaterBodyComponent.GetPostProcessProperties();

#if !(UE_BUILD_SHIPPING || UE_BUILD_TEST)
		UnderwaterPostProcessDebugInfo.ActiveWaterBodyComponent = 
			&Query.WaterBodyComponent;
		UnderwaterPostProcessDebugInfo.ActiveWaterBodyQueryResult = 
			Query.QueryResult;
#endif // !(UE_BUILD_SHIPPING || UE_BUILD_TEST)					
		break;
	}
}

SceneView->UnderwaterDepth = CachedDepthUnderwater;

if (!bUnderwaterForPostProcess ||
	!IsUnderwaterPostProcessEnabled() || 
	SceneView->Family->EngineShowFlags.PathTracing)
{
	UnderwaterPostProcessVolume.PostProcessProperties.bIsEnabled = false;
	UnderwaterPostProcessVolume.PostProcessProperties.Settings = nullptr;
}


  이를 해결하기 위해 조절해주어야 했던 값은 MaxWaveHeightOffset과 CollisionHeightOffset이었습니다. FixedZ가 도달할 수 있는 값을 기준으로 적당히 높은 값을 주어서 해결하였습니다.


발소리 및 부력 연출

  또한 Phil in the Mirror에는 차오르는 물을 이용해서 배수관 안에 끼어있던 카드를 획득하여 퍼즐을 진행하는 구간이 존재합니다. 따라서 카드가 부력을 받아 물의 표면으로 올라오는 것을 연출해야 했는데요. 원래라면 BuoyancyComponent를 활용하면 됐겠지만, 이는 제가 FixedZ를 이용해서 우회적으로 수위 조절을 연출한 만큼 별도의 구현으로 해결해야 했습니다.

  마침 물이 차오를 때의 발소리 연출을 위해서 이미 WaterHeight 값을 전역적으로 관리하고 있었는데요. 얕은 물에서의 찰바닥거리는 느낌부터, 물을 헤집고 나아가는 첨벙거리는 느낌까지 수위에 따라 블렌딩된 발소리를 연출하고 싶었기 때문입니다. 

MetaSound의 구현 일부. NormalizedWaterHeight을 받아서 Wade(첨벙) 소리의 크기를 조절해주는 것을 볼 수 있습니다.

  Phil in the Mirror에서 부력 연출은 그냥 단순히 물의 경계에 붙어있기만 하면 됐으므로, 이 역시 단순하게 해결할 수 있었습니다. 다만 물 위에 떠다니는 느낌은 주었어야 하므로, 물의 파형에 따라 흔들리는 느낌을 주면서 물의 경계에 붙어있도록 했습니다. 부끄럽지만 약간의 하드코딩도 볼 수 있습니다:

void UFloor8BuoyancyComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
	if (!GetWorld())
	{
		return;
	}

	// 파동 위상 계산
	const float Time = GetWorld()->GetTimeSeconds();
	const float W = 2.f * PI / OscillationPeriod;
	float Phase = W * Time + OscillationPhase;

	// 흔들림 회전 계산 (Pitch·Roll)
	float Pitch = FMath::Sin(Phase) * OscillationAmplitude;
	float Roll  = FMath::Cos(Phase) * OscillationAmplitude;
	FQuat Qpitch = FQuat(FVector::RightVector, FMath::DegreesToRadians(Pitch));
	FQuat Qroll  = FQuat(FVector::ForwardVector, FMath::DegreesToRadians(Roll));
	FQuat OscQuat = Qpitch * Qroll;
	FQuat NewQuat = OscQuat * BaseAlignedQuat;
	GetOwner()->SetActorRotation(NewQuat);

	// 기울기 방향(수평 성분) 벡터
	FVector UpVec = NewQuat.RotateVector(FVector::UpVector);
	FVector HorizontalDir = FVector(UpVec.X, UpVec.Y, 0.f);
	if (HorizontalDir.SizeSquared() > KINDA_SMALL_NUMBER)
	{
		HorizontalDir.Normalize();
	}
	// 파동형 이동 오프셋
	float MoveAmount = TranslationAmplitude * FMath::Sin(Phase);
	FVector TranslationOffset = HorizontalDir * MoveAmount;
	
	// 물 표면 고정
	if (UGlobalVariableSubsystem* GVSubsystem = GetWorld()->GetSubsystem<UGlobalVariableSubsystem>())
	{
		FVector Loc = GetOwner()->GetActorLocation();
		//if (Loc.Z < GVSubsystem->GetFloatData("WaterHeight"))
		{
			Loc.Z = GVSubsystem->GetFloatData("WaterHeight");
			GetOwner()->SetActorLocation(Loc + TranslationOffset);
		}
	}
}


구현된 결과


기타 일어났던 문제들

1) ID 에러

중간에 World Partition Level로 변경하면서 WaterZone Actor에서 World 관련 레퍼런스가 제대로 업데이트되지 않아 id 관련 에러가 난 경우가 있었습니다. 내용은 다음과 같습니다:

PackagingResults: Error: Assertion failed: PathPIEInstanceID == INDEX_NONE || PathPIEInstanceID == PIEInstanceID [File:D:\build\++UE5\Sync\Engine\Source\Runtime\Engine\Private\WorldPartition\WorldPartitionLevelStreamingPolicy.cpp] [Line: 200] PackagingResults: Error: Unexpected PIEInstanceID 0 while converting editor to runtime path /Game/FirstPerson/Maps/UEDPIE_0_L_Floor8.L_Floor8:PersistentLevel.WaterZone_0 for world World /Game/FirstPerson/Maps/L_Floor8_WP.L_Floor8_WP with PIEInstanceID -1

그냥 다시 배치하니 해결됐습니다.


2) World Partition Level Streaming

  World Partition Level Streaming과도 여러 문제들이 겹쳤었는데, 부력 컴포넌트를 쓰는 액터가 Level Streaming이 되고, Water의 Collision Box 안에서 생성되었을 경우, Overlap Event의 타이밍이 맞지 않게 되면서 부력을 받지 않고 그대로 바닥으로 가라앉아버리는 문제가 있었습니다. 물론 저는 결론적으로 BuoyancyComponent를 사용하지는 않았지만, 실험적으로 사용해보는 단계에서 부딪혔던 문제입니다.

  이러한 문제는 World Paritition 피처를 사용하면서 항상 발생할 수 있었던 문제였고, Generate Overlap Events During Level Streaming flag를 활성화하여 해결할 수 있었습니다.

댓글

이 블로그의 인기 게시물

[코딩의탑] 5층: 포탈(Portal), 더 나아가기

[코딩의탑] 4층: 툰 쉐이딩

[코딩의탑] 3층: 바다 렌더링