Cosmetic in Lyra

Jangmanbo·2025년 7월 1일

라이라의 코스메틱 시스템을 알아보기 전에 Modular Gameplay Actor 먼저

Modular Gameplay Actor

B_SimpleHeroPawn은 메시가 직접 부착되어있다.

그런데 B_Hero_ShooterMannequin을 보면 스태틱 메시가 없다.
라이라에서는 직접 메시를 설정하는게 아니라, GameFeature의 AddComponent 기능을 이용해서 메시를 컴포넌트로 부착한다.
이때 AddComponent 하려는 대상은 Receiver로 등록해야 한다
그런 액터들에서 직접 다 AddReceiver를 호출하지는 않고.. ModularGameplayActor를 사용한다

ModularGameplayActors 플러그인에 있는 클래스들

  • AModularCharacter
  • AModularPlayerController

ModularCharacter

void AModularCharacter::PreInitializeComponents()
{
	Super::PreInitializeComponents();

	UGameFrameworkComponentManager::AddGameFrameworkComponentReceiver(this);
}

초기화할 때 UGameFrameworkComponentManager에 리시버로 등록한다

void AModularCharacter::BeginPlay()
{
	UGameFrameworkComponentManager::SendGameFrameworkComponentExtensionEvent(this, UGameFrameworkComponentManager::NAME_GameActorReady);

	Super::BeginPlay();
}

다른 곳에서 BeginPlay 시점에 특정 동작을 수행할 수 있도록 이벤트를 보낸다.

void AModularCharacter::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	UGameFrameworkComponentManager::RemoveGameFrameworkComponentReceiver(this);

	Super::EndPlay(EndPlayReason);
}

액터가 사라질 때 리시버 해제해준다.



ALyraCharacter, ALyraPlayerController는 각각 AModularCharacter, AModularPlayerController를 상속받는다
UGameFrameworkComponentManager에 ComponentReceiver로 등록된다.


Cosmetic System

LyraControllerComponent_CharacterParts

  • 컨트롤러에 부착된다
  • TSubClass로 어떤 캐릭터를 부착할 수 있는지에 대한 메타데이터를 가지고 있어 가벼움 (ex. 전사, 마법사, ..)
  • 그래서 어떤 캐릭터인지는 LyraPawnComponent_CharacterParts에 알린다
UCLASS(meta = (BlueprintSpawnableComponent))
class ULyraControllerComponent_CharacterParts : public UControllerComponent
{
    // ...
protected:
	UPROPERTY(EditAnywhere, Category=Cosmetics)
	TArray<FLyraControllerCharacterPartEntry> CharacterParts;
};

FLyraControllerCharacterPartEntry 어레이를 들고 있다

// A character part requested on a controller component
USTRUCT()
struct FLyraControllerCharacterPartEntry
{
	// ...
public:
    // 캐릭터 파츠에 대한 정의 (메타데이터)
	UPROPERTY(EditAnywhere, meta=(ShowOnlyInnerProperties))
	FLyraCharacterPart Part;

    // 컨트롤러가 보고있는 pawn에서 생성한 캐릭터 파츠 인스턴스
    // 이 파츠를 생성하지 않았으면 없을 것..
	FLyraCharacterPartHandle Handle;
};

FLyraControllerCharacterPartEntry는 각 캐릭터 파츠에 대한 메타데이터와 캐릭터 파츠를 생성했다면 그 인스턴스를 들고 있다

USTRUCT(BlueprintType)
struct FLyraCharacterPart
{
	GENERATED_BODY()

	// 생성할 액터 클래스
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	TSubclassOf<AActor> PartClass;

	// 액터 부착할 소켓 정보. 없으면 Root에 부착
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FName SocketName;

	// ...
};

// A handle created by adding a character part entry, can be used to remove it later
USTRUCT(BlueprintType)
struct FLyraCharacterPartHandle
{
	// ...
private:
	UPROPERTY()
	int32 PartHandle = INDEX_NONE;
};

LyraPawnComponent_CharacterParts

  • Pawn에 부착된다
  • LyraControllerComponent_CharacterParts로부터 메타데이터 받아서 메시를 생성 또는 교체
// A component that handles spawning cosmetic actors attached to the owner pawn on all clients
UCLASS(meta=(BlueprintSpawnableComponent))
class ULyraPawnComponent_CharacterParts : public UPawnComponent
{
	// ...
private:
	// 캐릭터 파츠 인스턴스
	UPROPERTY(Replicated)
	FLyraCharacterPartList CharacterPartList;

	// 애니메이션 적용을 위한 메시와의 연결고리
	UPROPERTY(EditAnywhere, Category=Cosmetics)
	FLyraAnimBodyStyleSelectionSet BodyMeshes;
};

FLyraCharacterPartList

// Replicated list of applied character parts
USTRUCT(BlueprintType)
struct FLyraCharacterPartList : public FFastArraySerializer
{
	// ...
private:
	// 현재 적용되어있는 캐릭터 파츠 정보들
	UPROPERTY()
	TArray<FLyraAppliedCharacterPartEntry> Entries;

	// 이 캐릭터 파츠 인스턴스를 가지고 있는 PawnComponent
	UPROPERTY(NotReplicated)
	TObjectPtr<ULyraPawnComponent_CharacterParts> OwnerComponent;

	// Upcounter for handles
	int32 PartHandleCounter = 0;
};

FLyraAppliedCharacterPartEntry

생성되어있는 캐릭터 파츠 인스턴스 정보

USTRUCT()
struct FLyraAppliedCharacterPartEntry : public FFastArraySerializerItem
{
	// ...
private:
	// 생성한 캐릭터 파츠 액터 정보 (메타데이터)
	UPROPERTY()
	FLyraCharacterPart Part;

	// LyraControllerComponent_CharacterParts의 핸들과 맞춤
	UPROPERTY(NotReplicated)
	int32 PartHandle = INDEX_NONE;

	// 부착한 액터의 스폰 정보
    // 액터에 액터를 부착할 때는 ChildActorComponent를 사용해야 함
	UPROPERTY(NotReplicated)
	TObjectPtr<UChildActorComponent> SpawnedComponent = nullptr;
};

참고로.. FLyraCharacterPartList, FLyraAppliedCharacterPartEntry와 같이 이런식으로 struct가 나뉘어진 이유는
네트워크 데이터 전송 시 변경된 데이터 정보만 보내기 위해서다
(그래서 부모가 각각 FFastArraySerializer, FFastArraySerializerItem)

FLyraAnimBodyStyleSelectionSet

USTRUCT(BlueprintType)
struct FLyraAnimBodyStyleSelectionSet
{
	GENERATED_BODY()
	
    // 애니메이션과 메시 간의 규칙. 애니메이션을 어느 메시에 적용할 지에 대한? 태그 기반
	UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(TitleProperty=Mesh))
	TArray<FLyraAnimBodyStyleSelectionEntry> MeshRules;

	// MeshRules로 애니메이션을 적용할 메시를 찾지 못했을 때 디폴트로 적용할 메시
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	TObjectPtr<USkeletalMesh> DefaultMesh = nullptr;
	
    // 애니메이션은 여러 개여도 PhisicsAsset은 하나로 통일
	UPROPERTY(EditAnywhere)
	TObjectPtr<UPhysicsAsset> ForcedPhysicsAsset = nullptr;

	USkeletalMesh* SelectBestBodyStyle(const FGameplayTagContainer& CosmeticTags) const;
};

FLyraAnimBodyStyleSelectionEntry

USTRUCT(BlueprintType)
struct FLyraAnimBodyStyleSelectionEntry
{
	GENERATED_BODY()

	// AnimLayer 적용할 스켈레탈 메시
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	TObjectPtr<USkeletalMesh> Mesh = nullptr;

	// 어떤 태그일 때 이 애니메이션을 적용할지
	UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(Categories="Cosmetic"))
	FGameplayTagContainer RequiredTags;
};

정리하자면..

여러 가지 파츠 타입(ex. 여자, 남자, ..) 있고 각 메시가 있을 때
FLyraAnimBodyStyleSelectionEntry에서 여자 메시, 여자 태그를 설정한다.

캐릭터 파츠 액터에 미리 설정된 태그에 따라서, MeshRules를 보고 어떤 메시에 애니메이션을 적용할지 결정한다.
(그러려면 캐릭터 파츠 클래스가 뒤에 나오는 ALyraTaggedActor여야 할 듯)


잠시 에셋 살펴보기..

LyraControllerComponent_CharacterParts

LyraExperienceDefinition에서 설정하는 Action 중 하나인 AddComponents
모든 Controller 클래스에 B_PickRandomCharacter를 부착한다.


LyraPawnComponent_CharacterParts

여기서 설정한 스켈레탈 메시는 충돌만 담당하고 머터리얼이 세팅되어있지 않다.

LyraControllerComponent_CharacterParts는 AddComponent로 컨트롤러에 부착했지만,
LyraPawnComponent_CharacterParts는 직접 캐릭터에 배치한다.

  • LyraControllerComponent_CharacterParts는 메타데이터만을 들고 있기 때문에 매우 가벼워서 모든 컨트롤러에 부착하더라도 부담이 없다
  • LyraPawnComponent_CharacterParts는 TObjectPtr로 에셋을 그대로 들고 있기 때문에(FLyraAnimBodyStyleSelection) 모든 캐릭터에 막 부착해서는 안된다
  • 따라서 모든 메시들을 하나의 LyraPawnComponent_CharacterParts에 모아두지 않고, 적절히 분류해서(ex. 인간, 괴물, ..) 각각 캐릭터와 LyraPawnComponent_CharacterParts 에셋을 만들어둔다

ALyraTaggedActor


ALyraTaggedActor에셋인 B_Manny
루트는 SkeletalMeshComponent
폰에 B_Manny가 캐릭터 파츠로서 부착되면 B_Manny는 태그가 없으니까 DefaultMesh인 SKM_Manny_Invis가 장착될 것이다
(동작은 이후 설명)


LyraControllerComponent_CharacterParts

void ULyraControllerComponent_CharacterParts::AddCharacterPartInternal(const FLyraCharacterPart& NewPart, ECharacterPartSource Source)
{
	FLyraControllerCharacterPartEntry& NewEntry = CharacterParts.AddDefaulted_GetRef();
	NewEntry.Part = NewPart;
	NewEntry.Source = Source;

	if (ULyraPawnComponent_CharacterParts* PawnCustomizer = GetPawnCustomizer())
	{
		if (NewEntry.Source != ECharacterPartSource::NaturalSuppressedViaCheat)
		{
			NewEntry.Handle = PawnCustomizer->AddCharacterPart(NewPart);
		}
	}

}
  • AddDefaulted_GetRef: 미리 필요한 만큼만 메모리 할당해서 주소값을 넘김. 불필요한 복사연산 줄일 수 있음
  • PawnCustomizer->AddCharacterPart: 폰에 캐릭터 파츠 적용. 원래는 컨트롤러의 AddCharacterPart와는 분리되어야 한다 (메타데이터 추가와 실제 적용은 다르니까)

LyraPawnComponent_CharacterParts

FLyraCharacterPartHandle ULyraPawnComponent_CharacterParts::AddCharacterPart(const FLyraCharacterPart& NewPart)
{
	return CharacterPartList.AddEntry(NewPart);
}
  • AddCharacterPart: 바로 CharacterPartList에 추가. 거의 FLyraCharacterPartList에서 관리한다.
void ULyraPawnComponent_CharacterParts::BroadcastChanged()
{
	const bool bReinitPose = true;

	// Check to see if the body type has changed
	if (USkeletalMeshComponent* MeshComponent = GetParentMeshComponent())
	{
		// Determine the mesh to use based on cosmetic part tags
		const FGameplayTagContainer MergedTags = GetCombinedTags(FGameplayTag());
		USkeletalMesh* DesiredMesh = BodyMeshes.SelectBestBodyStyle(MergedTags);

		// Apply the desired mesh (this call is a no-op if the mesh hasn't changed)
		MeshComponent->SetSkeletalMesh(DesiredMesh, /*bReinitPose=*/ bReinitPose);

		// Apply the desired physics asset if there's a forced override independent of the one from the mesh
		if (UPhysicsAsset* PhysicsAsset = BodyMeshes.ForcedPhysicsAsset)
		{
			MeshComponent->SetPhysicsAsset(PhysicsAsset, /*bForceReInit=*/ bReinitPose);
		}
	}

	// Let observers know, e.g., if they need to apply team coloring or similar
	OnCharacterPartsChanged.Broadcast(this);
}
  • SelectBestBodyStyle: 해당 태그에 맞는 스켈레탈메시 리턴. 태그가 없을거라 DefaultMesh인 SKM_Manny_Invis를 리턴할 것

LyraCharacterPartList

FLyraCharacterPartHandle FLyraCharacterPartList::AddEntry(FLyraCharacterPart NewPart)
{
	FLyraCharacterPartHandle Result;
	Result.PartHandle = PartHandleCounter++;

	if (ensure(OwnerComponent && OwnerComponent->GetOwner() && OwnerComponent->GetOwner()->HasAuthority()))
	{
		FLyraAppliedCharacterPartEntry& NewEntry = Entries.AddDefaulted_GetRef();
		NewEntry.Part = NewPart;
		NewEntry.PartHandle = Result.PartHandle;
	
		if (SpawnActorForEntry(NewEntry))
		{
			OwnerComponent->BroadcastChanged();
		}

		MarkItemDirty(NewEntry);
	}

	return Result;
}
  • SpawnActorForEntry: 액터 스폰해서 부착
  • OwnerComponent->BroadcastChanged: LyraPawnComponent_CharacterParts에 알림..
bool FLyraCharacterPartList::SpawnActorForEntry(FLyraAppliedCharacterPartEntry& Entry)
{
	bool bCreatedAnyActors = false;

	if (!OwnerComponent->IsNetMode(NM_DedicatedServer))
	{
		if (Entry.Part.PartClass != nullptr)
		{
			UWorld* World = OwnerComponent->GetWorld();

			if (USceneComponent* ComponentToAttachTo = OwnerComponent->GetSceneComponentToAttachTo())
			{
				const FTransform SpawnTransform = ComponentToAttachTo->GetSocketTransform(Entry.Part.SocketName);

				UChildActorComponent* PartComponent = NewObject<UChildActorComponent>(OwnerComponent->GetOwner());

				PartComponent->SetupAttachment(ComponentToAttachTo, Entry.Part.SocketName);
				PartComponent->SetChildActorClass(Entry.Part.PartClass);
				PartComponent->RegisterComponent();

				if (AActor* SpawnedActor = PartComponent->GetChildActor())
				{
					if (USceneComponent* SpawnedRootComponent = SpawnedActor->GetRootComponent())
					{
						SpawnedRootComponent->AddTickPrerequisiteComponent(ComponentToAttachTo);
					}
				}

				Entry.SpawnedComponent = PartComponent;
				bCreatedAnyActors = true;
			}
		}
	}

	return bCreatedAnyActors;
}
  • OwnerComponent->GetSceneComponentToAttachTo: LyraPawnComponent_CharacterParts가 부착된 폰의 SceneComponent를 가져옴
  • PartComponent->SetupAttachment: 폰에 PartComponent 부착
  • PartComponent->SetChildActorClass: 를 하고 RegisterComponent 호출하면 스폰까지 알아서
  • PartComponent->GetChildActor: 방금 설정한 ChildActor 가져옴. 이게 B_Manny
  • SpawnedRootComponent->AddTickPrerequisiteComponent: 폰의 SceneComponent보다 먼저 tick되지 않도록 막기. 일반적으로 부모의 변경점을 자식에게 반영해야되니까


마찬가지로 RemoveCharacterPart도 있다

이제 B_PickRandomCharacter에서 AddCharacterPart로 B_Manny를 장착하면 폰에 B_Manny가 장착된다

0개의 댓글