Unreal_애님 블루프린트 최적화 관련 몇가지 코드

2023. 4. 18. 19:43unreal/UnrealCode

애니매이션 및 애님 블루프린트를 최적화 할때는 언리얼 공식 가이드를 따르는것이 가장 우선입니다.

공식 가이드

https://docs.unrealengine.com/4.26/ko/AnimatingObjects/SkeletalMeshAnimation/Optimization/

 

애니메이션 최적화

애니메이션 블루프린트의 퍼포먼스를 높이는 최적화 기법에 대한 설명입니다.

docs.unrealengine.com

 

게임 장르상 애니매이션 블루프린트를 공유한다 하면 subaniminstance 를 적극 활용하고

애니매이션 블루프린트를 공유하지 않는다 하면 최대한 기본 로직을 공유해 c++을 통해 구현을 하는것이 최 우선입니다.

 

 

위 공식 가이드를 보면 가장 중요하고 가장 많은 양을 할당하는것이 빠른경로애니매이션인것을 알 수 있습니다.

또한 공식적으로 가장 많은 퍼포먼스를 소비하는것은 버추얼 머신(이벤트 그래프 및 빠른경로가 아닌 노드)입니다.

또한 위 두 경우처럼 중요하게 이야기 하지 않고 있지만 공간변환(컴퍼넌트 스페이스 > 로컬스페이스)또한 많은 퍼포먼스를 사용하기에 최대한 줄여서 사용해야 합니다.

 

애님 블루프린트를 공유한다는 것은 1개의 애님 블루프린트로 모든 케릭터를 제어하는 상황이기에 로직등이 크게 달라지지 않아 큰문제 없이 subaniminstance사용으로 대부분의 최적화가 이루어지고  몇몇개의 변수만 사용하면 빠른경로 설정이 가능합니다.

애님 블루프린트를 공유하지 않는다는 전제하에 추가 내용입니다.

 

빠른경로

대부분의 경우에는 문제 없이 빠른경로로 설정이 가능합니다

 

가장 빠른경로로 사용하기 어려운 케이스가

문제는 다중조건식과 애님 커브사용이 있습니다.

 

이러한 다중 조건 식은 빠른경로를 사용하기 어렵습니다.

이런경우 케이스를 추적해 일일히 조건식을 만들어주기보다는

공통된 케이스

예를들면 2/1 이상의 케릭터 혹은 어느정도는 자주 사용하는 다중조건은 코드로 변수를 만들어주는 것이 좋습니다.

예를들면 이런식으로

bool bIsD = (!bIsAir && bIsC) || (bIsAir && bIsF);

 

다음은 별도의 다중케이스 입니다.

특정 상황이나 몇몇 특정 케릭터만 사용하는 다중 조건식이 존재할 경우 사실 빠른 실행이 불가능합니다.(가능은 하지만 퍼모먼스의 이득은 없는 상황이죠)

위이미지 같은경우는 애니매이션 이벤트 노드로 옮겨서 한번에 처리해주는게 조금이나마 최적화에 도움이 되고 있습니다.

 

 

커브사용

애니매이션 시퀀스나 몽타주 등에서 애님 커브 그래프는 상당히 자주 사용되는 기능힙니다.

디자이너가 직관적으로 값을 조절해 적용할 수 있고 애니매이션에 종속된 상태표현도 쉽게 가능하기 때문이죠

상태등을 애니매이션 커브를 사용해서 만들어 내고 있습니다.

위 이미지처럼 1개의 커브가 아닌 다양한 커브의가 추가될 수 있고 그에따른 값을 가져와 스테이트나 애니매이션 변환에 사용하기위해서는 배열을 사용하는것이 가장 좋습니다.

 

배열을 3개 준비 합니다.

하나는 사용할 커브의 이름을 적어 넣습니다. 위 이미지에서는 mask_geo, WeaponScale 이 되겟죠

그리고 c++ 에서 현재 업데이트 중인 커브를 찾아 위에 적어둔 이름과 동일한 커브가 있다면 값을 표현하면 됩니다.

저같은경우는 값을 float 배열과 bool 배열 두종류로 저장하였습니다.

이후 배열의 get 노드중 get(사본)노드를 이용해 사용하면 빠른 실행을 적용할 수 있습니다.(get참조는 빠른실행이 적용되지 않습니다)

 

코드로 표현하면 이렇게 되겠습니다.

 

애님인스턴스.h

 

class 내게임_API 애님인스턴스 : public UCharacterAnimInstance2
{
	GENERATED_UCLASS_BODY()
    ....
    public:
    
	//값을 얻어올 커브의 이름 각 커브의 값은 "UpdateCurveValue"에 같은 인덱스에 저장됩니다.
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AnimBPUpdate_Curvedata")
		TArray<FName> UpdateCurveName;
	//커브의 값을 불변수로 사용할지 입니다..
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AnimBPUpdate_Curvedata")
		bool  UseValueasBool = false;
	//커브의 현재 값입니다 인덱스틑 이름에 등록된 순서와 동일합니다.
	UPROPERTY(Transient, BlueprintReadOnly, Category = "AnimBPUpdate_Curvedata")
		TArray<float> UpdateCurveValue;
	//커브의 값을 불변수로 변환한 값입니다.
	UPROPERTY(Transient, BlueprintReadOnly, Category = "AnimBPUpdate_Curvedata")
		TArray<bool> UpdateCurveToBool;
        
        void GetCurvedataFromName();
        ...
        }

 

애님인스턴스.cpp

void 애님인스턴스::NativeUpdateAnimation(float DeltaSeconds)
{
//업데이트커브 배열 필드가 비어있지 않은경우에만 함수를 실행합니다
if (UpdateCurveName.Num() != 0) { 애님인스턴스::GetCurvedataFromName(); 
}
void 애님인스턴스::GetCurvedataFromName()
{
	this->GetActiveCurveNames(EAnimCurveType::AttributeCurve, ActiveCurveName);
	// 현재 활성화된커브중 업데이트에서 사용중인 커브가 있는지 확인
	checkcontains = 0;//초기화
	
	for (int32 i = 0; i != ActiveCurveName.Num(); ++i)
	{
		checkcontains += ((UpdateCurveName.Contains(ActiveCurveName[i])) ? 1 : 0);
		if (checkcontains > 0)
			break;
	}
	//배열크기 설정
	if (UseValueasBool == true)
	{
		if (UpdateCurveName.Num() != (UpdateCurveValue.Num() && UpdateCurveToBool.Num()))
		{
			UpdateCurveValue.SetNum(UpdateCurveName.Num());
			UpdateCurveToBool.SetNum(UpdateCurveName.Num());
		}
	}
	else { if (UpdateCurveName.Num() != UpdateCurveValue.Num()) { UpdateCurveValue.SetNum(UpdateCurveName.Num()); } }
	//커브 값을 저장하는 바디
	if (checkcontains > 0)
	{
		for (int32 i = 0; i != UpdateCurveName.Num(); ++i)
		{
			FName CurrentCurveName = UpdateCurveName[i];
			float CurrentCurveValue = this->GetCurveValue(CurrentCurveName);
			UpdateCurveValue[i] = CurrentCurveValue;
			if (UseValueasBool == true)
			{
				bool bValueTob = (CurrentCurveValue < 0.015) ? false : true;
				UpdateCurveToBool[i] = bValueTob;
			}
		}
		loopend = false;
	}
	//일치하는 커브가 없을시 초기값(0 / false)로 변경
	else if (checkcontains == 0 && loopend == false)
	{
		//값 초기화
		for (int32 i = 0; i != UpdateCurveName.Num(); ++i)
		{
			
			UpdateCurveValue[i] = 0;
			if (UseValueasBool == true)
			{			
				UpdateCurveToBool[i] = false;
			}
		}
		loopend = true;
	}
}

부울 배열을 사용할경우는 bool 배열과 float 배열 둘다 출력되며 아니면 출력하지 않습니다

현재 얻고자하는 커브의 필드가 비어있다면 함수를 실행하지 않고

현재 활성화된 커브의 수가 0이라면 함수를 실행하지 않습니다.

애니매이션에 사본노드로 순서에맞춰서 커브에 대한 정보를 얻어올 수 있습니다.

위 이미지상에서는 get0> testcurve1의 값이 될것이고 get1>testcurve2 의 값이 될것입니다.

 

 

 

애니매이션 포즈 얻기

실제 애니매이션을 플레이 시키지 않고 애니매이션 시퀀스의 특정 구간에 월드 포지션을 얻어야 하는 상황이었습니다.

 

좀더 정확히는 서버에서는 실제 애니매이션을 플레이 하지 않고 특정 시간의 특정 본이나 소켓의 위치만 알면 되는 상황이었습니다.

그 타겟의 로케이션을 월드 포지션으로 얻어두고 테이블로 저장해두면(실제로는 컴포넌트 혹은 액터 포지션) 추후 게임에서 액터의 월드 포지션과 합쳐 실제 서버 리소스를 사용하지 않고 동기화에 사용할 수 있는 것이기이 비용+최적화에 필요하다고 판단되어 적용을 하게되었습니다.

 

주어진 정보는 몽타주(혹은 애님 시퀀스) 와 발동시간에 찍혀있는 애님 노티파이, 그리고 본 이름(소켓 이름) 이었습니다.

아주 단순히 발동시간이찍혀있는 노티파이에서 소켓 로케이션을 월드 포지션으로 얻는 코드나 노드 등을 넣은뒤

월드에 스폰해 해당 몽타주나 애님 시퀀스를 재생시킨다면 쉽게 할 수 있는 작업이었으나

실제 애니매이션을 재생시키면 바로 얻을수 있는 액터 로케이션 혹은 월드 로케이션입니다.

애니매이션이 상시로 변경될 수 있고 변경될 사항을 누락하면 이펙트등이 상이한 위치에서 나오기에

빠르게 구울 수 있는 방식을 찾았습니다.(액터를 월드(에디터나 게임월드)에 소환하지 않고 위치값을 얻어와야합니다)

 

일단 몽타주에서 애니매이션 시퀀스를 체크한뒤 노티파이가 붙어있는 시간에 주어진 본의 컴포넌트 트랜스폼을 얻는식으로 진행하였습니다.

 

//테이블상에서 주어진 정보 입니다.
float NotifyTimeAbs;
UAnimMontage InMontage;
FName boneName;
...

//몽타주에서 애님시퀀스를 받습니다 최초는 0번트랙의 0번 애니매이션입니다.
UAnimSequence* sequence = Cast<UAnimSequence>(InMontage->SlotAnimTracks[0].AnimTrack.AnimSegments[0].AnimReference);


//몽타주에 여러개의 애님시퀀스가 있을시 확인을 합니다.
if (1 != InMontage->SlotAnimTracks[0].AnimTrack.AnimSegments.Num())
{
    FAnimSegment CurrentSegment = InMontage->SlotAnimTracks[0].AnimTrack.AnimSegments[0];
    for (int32 i = 0; i < InMontage->SlotAnimTracks[0].AnimTrack.AnimSegments.Num(); ++i)
    {
        //구간만큼 루프를 돌며 노티파이 절대 시간이 포함된 애님 시퀀스를 찾습니다.
        CurrentSegment = InMontage->SlotAnimTracks[0].AnimTrack.AnimSegments[i];
        if (NotifyTimeAbs > CurrentSegment.StartPos && NotifyTimeAbs < CurrentSegment.StartPos + ((CurrentSegment.AnimEndTime - CurrentSegment.AnimStartTime)* CurrentSegment.AnimPlayRate))
        {
            sequence = Cast<UAnimSequence>(InMontage->SlotAnimTracks[0].AnimTrack.AnimSegments[i].AnimReference);
            break; }
    }
    //노티파이 절대시간을 주어진 시퀀스에 맞게 재 설정 합니다.

    NotifyTimeAbs = NotifyTimeAbs - CurrentSegment.StartPos;
}
else
{
    sequence = Cast<UAnimSequence>(InMontage->SlotAnimTracks[0].AnimTrack.AnimSegments[0].AnimReference);
}

                                    

if (sequence)
{
    //포즈를 넣기위한 스켈레톤이 필요합니다.

    USkeleton* skeleton = InMontage->GetSkeleton();
    int32 socketIndex = -1;
    //소켓인지 확인합니다 -1값이 변경이 된다면 소켓이고 유지된다면 타겟은 본입니다.

    USkeletalMeshSocket* socketInfo = skeleton->FindSocketAndIndex(boneName, socketIndex);

    if (skeleton)
    {
        //소켓인덱스가 -1이면 찾아야할 타겟이 소켓이 아닌 본입니다.
        if (socketIndex == -1)
        {
            socketIndex = skeleton->GetReferenceSkeleton().FindBoneIndex(boneName);
            
        }
        
        if (socketIndex != -1)
        {
            //트랜스폼을 계산할 스켈레톤 / 본컨테이너 / 본컨테이너인덱스를 넣을 배열을 준비합니다.
            FMemMark Mark(FMemStack::Get());
            FBoneContainer RequiredBones;
            TArray<FBoneIndexType> RequiredBoneIndexArray;
            RequiredBones.SetUseRAWData(true);
            
            //본배열의 크기를 스켈레톤과 맞춰줍니다(추후 포즈를 집어 넣을 위치 입니다.)
            RequiredBoneIndexArray.AddUninitialized(skeleton->GetReferenceSkeleton().GetNum());

            for (int32 BoneIndex = 0; BoneIndex < RequiredBoneIndexArray.Num(); ++BoneIndex)
            {
                RequiredBoneIndexArray[BoneIndex] = BoneIndex;
                
            }

            RequiredBones.InitializeTo(RequiredBoneIndexArray, FCurveEvaluationOption(true), *skeleton);
            
            //스켈레톤에 적용할 포즈를 뽑기위한 데이터를 준비합니다
            FAnimExtractContext ExtractContext;
            //Pose evaluation data
            FCompactPose Pose;
            Pose.SetBoneContainer(&RequiredBones);
            
            //포즈가 들어갈 구조체를 준비합니다
            FCSPose<FCompactPose> MeshPoses;
            FCSPose<FCompactPose> PredataMeshPoses;										
            FCSPose<FCompactPose> PredataMeshAdditivePoses;//어디티브계산용
            // first I need to convert to local pose
            FBlendedCurve Curve;
            FStackCustomAttributes InAttributes;
            Curve.InitFrom(RequiredBones);
            
            FAnimationPoseData PrePoseDataMontage(Pose, Curve, InAttributes);
            FAnimExtractContext Context; // CurrentTime = 0.0f by default
            
            Context.CurrentTime = NotifyTimeAbs;
            
            //시퀀스에서 지정된 위치의 포즈를 PrePoseDataMontage에 저장합니다.
            sequence->GetAnimationPose(PrePoseDataMontage, Context);		
            
            //PredataMeshPoses 포즈를 PrePoseDataMontage데이터를 사용해 초기화 해줍니다.
            PredataMeshPoses.InitPose(PrePoseDataMontage.GetPose());
            
            //이제 PredataMeshPoses의 트랜스폼은 노티파이가 적용된 시간의 로컬 트랜스폼이 되었습니다.

            //트랜스폼계산 위한 부분
            
                FCompactPoseBoneIndex GetCompactPoseIndexFromSkeletonIndex(socketIndex);
                FTransform boneTransform;

                if (socketInfo)
                {
                    int32 socketIndexforsockt = skeleton->GetReferenceSkeleton().FindBoneIndex(socketInfo->BoneName);
                    FCompactPoseBoneIndex PGetCompactPoseIndexFromSkeletonIndex(socketIndexforsockt);
                    //부모본의 컴퍼넌트 스페이스 로케이션 
                    FTransform ParentboneTransform = PredataMeshPoses.GetComponentSpaceTransform(PGetCompactPoseIndexFromSkeletonIndex);
                    boneTransform = socketInfo->GetSocketLocalTransform() * ParentboneTransform;
                }

                else
                {
                    boneTransform = PredataMeshPoses.GetComponentSpaceTransform(GetCompactPoseIndexFromSkeletonIndex);                                            
                }
            }
        }
    }

코드자체에 주석으로 각 문구의 기능에 대해 달아두었습니다.

이렇게 애니매이션 시퀀스에서 특정위치의 본의 로컬 정보를 본컨테이너에 담아 레퍼런스 스켈레톤에 적용하게되면 다시 그 스켈레톤의 정보를 토대로 컴퍼넌트 혹은 애터 기반의 위치를 확인 가능합니다.