[UE5 | Audio Tracing] 다양한 에디터 조작에 대응하는 Sound Material 컴포넌트 개발

2025. 12. 1. 22:59·게임테크랩 1기/Audio Tracing

서론

이전 글에 이어서 음향 재질을 위한 컴포넌트인 Sound Material Component 구현 과정을 다루겠다.

https://mstone8370.tistory.com/55

 

[Audio Tracing] 음향 재질을 위한 에셋 구현 과정

서론이전 글에서 음향 재질을 위한 데이터 파이프라인 구축 과정을 작성했다. https://mstone8370.tistory.com/53 [Audio Tracing] 음향 재질을 위한 Custom Primitive Data 획득서론소리의 전파는 충돌한 표면에 따

mstone8370.tistory.com

 

컴포넌트 제작 과정에서 고려할 부분이 많았기 때문에 글의 내용 또한 길다.


 

문제 정의

음향 재질은 프리미티브의 Custom Primitive Data(CPD)를 통해 전달하니, 각각의 프리미티브에 따라 설정이 가능해야 한다.

그리고 음향 재질 정보를 설정해야 하는 프리미티브들은 대부분 스태틱 메시 컴포넌트 또는 스텔레탈 메시 컴포넌트일 것이다.

플러그인 입장에서 이 프리미티브들의 CPD를 설정할 수 있게 하고, 사용하기 쉬운 방식이 무엇인지를 결정해야 한다.

 

이때 중요하게 생각한 것은, 이 플러그인을 어떠한 프로젝트의 중간 과정에 적용한다 하더라도 기존의 프로젝트에 최대한 영향을 주지 않으면서도 이 플러그인을 문제없이 적용 가능하게 만드는 것이었다.

 

따라서, 스태틱 또는 스켈레탈 메시 컴포넌트를 상속받아서 음향 재질을 설정할 수 있게 하는 방식은, 기존의 컴포넌트를 대체해야 하는 문제가 생기므로 올바르지 못한 방향이라고 판단했다.


 

해결 방안

이러한 목적을 위해서, 음향 재질을 설정하는 기능이 있는 컴포넌트를 새로 생성하기로 했다.

음향 재질을 설정할 프리미티브 컴포넌트에 이 컴포넌트를 어태치 해서 해당 프리미티브 컴포넌트의 CPD를 설정하는 방식이다.

이렇게 하면 액터에 여러 컴포넌트가 있더라도, 각각의 프리미티브에 원하는 음향 재질을 설정할 수 있다.

 


 

한 가지 아쉬운 점이라면, 하나의 프리미티브라 하더라도 여러 머티리얼을 가질 수 있다는 점이다.

이 각각의 머티리얼마다 서로 다른 음향 재질을 설정할 수 있다면 더 좋을 텐데, 이 방법을 위해서는 기존의 CPD를 활용한 데이터 파이프라인을 완전히 바꿔야 하는 문제가 있다.

하지만, 인라인 레이 트레이싱으로 머티리얼 정보를 가져오는 방법을 모르기도 하고, 당시 개발 기간이 촉박했기 때문에, 아쉽더라도 프리미티브 단위로 음향 재질을 설정하는 방향으로 정했다.


 

구현

컴포넌트 구현 과정에서는 많은 것을 고려해야 한다.

에디터에서 이 컴포넌트를 다루게 되므로, 에디터 조작을 통해 발생할 수 있는 다양한 경우를 고려해야 한다.

 

구현 과정에서 고려한 것들은 다음과 같다.

  • 컴포넌트에 음향 재질 에셋 지정
  • 음향 재질 에셋의 값 변경
  • 컴포넌트의 음향 재질 값 변경
  • Undo 또는 Redo
  • 드래그를 통해 부모 컴포넌트 변경
  • 음향 재질을 설정한 상태에서 컴포넌트 삭제
  • 음향 재질 에셋을 컴포넌트에 지정한 상태에서 에셋 삭제
  • 프로젝트 설정을 통해 CPD가 저장될 인덱스 변경
  • PIE 또는 게임 플레이 상황
  • 게임 플레이 중 음향 재질 변경

각각의 상황에 따른 구현 내용을 하나씩 적기에는 어렵지만, 최대한 자세히 다루겠다.

 


 

기본 설계

먼저 이 컴포넌트의 기능을 먼저 확실히 정하고 시작해야 한다.

 

이 컴포넌트는 어태치 된 부모가 프리미티브 컴포넌트면, CPD의 특정 인덱스에 음향 재질 값을 설정한다.

이때 음향 재질 값은 Sound Material 에셋을 기반으로 할 수도 있고, 컴포넌트의 자체적인 값을 기반으로 할 수도 있다.

 

PIE 또는 게임 플레이 중에도 음향 재질을 바꾸는 기능은 열어두었다.

하지만 이때에는, 에셋의 값을 수정하는 것은 금지했다.

게임 플레이 중 에셋을 수정하면, 이 에셋을 사용하는 모든 컴포넌트에 영향을 끼치게 되고, 배포된 게임인 경우에는 게임 데이터의 수정을 시도하는 등의 다양한 문제가 발생한다.

따라서, 게임 플레이 중에서는 에셋의 값을 활용할 뿐, 에셋 자체를 다루지는 않는 방향으로 진행한다.

이때, 위에서 언급했던 컴포넌트의 자체적인 값을 활용한다.

 

아래의 코드는 예시로 음향 재질 속성 중 하나인 Scattering Factor만 넣었다.

BeginPlay의 구현과 다른 함수에서 HasBegunPlay를 통해 분기를 나눠둔 부분이 핵심이다.

// Components/ATSoundMaterialComponent.h

UCLASS( ClassGroup=(AudioTracing), meta=(BlueprintSpawnableComponent, DisplayName = "Audio Tracing Sound Material") )
class AUDIOTRACING_API UATSoundMaterialComponent : public USceneComponent
{
protected:
    virtual void BeginPlay() override;
    
public:
    UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Audio Tracing|Sound Material Component")
    float GetScatteringFactor() const;

    UFUNCTION(BlueprintCallable, Category = "Audio Tracing|Sound Material Component")
    void SetScatteringFactor(float NewScatteringFactor);
    
protected:
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Audio Tracing|Override")
    bool bOverrideSoundMaterial;
    
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Audio Tracing|Override", meta = (
        EditCondition = "bOverrideSoundMaterial", ClampMin = "0.0", ClampMax = "1.0", UIMin = "0.0", UIMax = "1.0"))
    float ScatteringFactor;
};



// Components/ATSoundMaterialComponent.cpp

void UATSoundMaterialComponent::BeginPlay()
{
    Super::BeginPlay();

    if (!bOverrideSoundMaterial && SoundMaterial)
    {
        ScatteringFactor = SoundMaterial->GetScatteringFactor();
        // 다른 음향 재질 속성 값도 설정
    }

    SetSoundMaterialCustomPrimitiveData();
}

float UATSoundMaterialComponent::GetScatteringFactor() const
{
    if (HasBegunPlay())
    {
        return ScatteringFactor;
    }
    
    if (bOverrideSoundMaterial)
    {
        return ScatteringFactor;
    }
    if (SoundMaterial)
    {
        return SoundMaterial->GetScatteringFactor();
    }
    return USoundMaterial::DefaultScatteringFactor;
}

void UATSoundMaterialComponent::SetScatteringFactor(float NewScatteringFactor)
{
    if (HasBegunPlay())
    {
        ScatteringFactor = FMath::Clamp(NewScatteringFactor, 0.f, 1.f);
        SetSoundMaterialCustomPrimitiveData();
    }
}

 


 

주요 로직

가장 중요한 기능은 부모 프리미티브에 CPD 값을 지정하는 것이다.

CPD 값은 UPrimitiveComponent::SetCustomPrimitiveData[타입] 함수를 통해 지정 가능하다.

 

문제는 이렇게 설정된 CPD 값을 사용할 때 발생한다.

 

CPD 값은 기본적으로 0.0f으로 초기화된다.

그리고 음향 재질 값을 별도로 설정하지 않으면 기본 음향 재질 값을 사용해야 한다.

하지만 GPU에서 읽은 음향 재질 값이 모두 0.0f라면, 이걸 사용자가 의도해서 설정한 값인지, 아니면 별도로 설정하지 않은 것인지 구분할 수 없다.

 

이 문제를 해결하는 가장 쉬운 방법은 flag를 추가하는 것이다.

하지만 CPD에 담기는 값의 개수는 제한되어 있기 때문에(float4 타입 9개 = 36개의 float), flag를 위해 새로운 변수를 사용하는 것은 공간 낭비라고 느껴졌다.

 

이를 위해, flag를 음향 재질 값과 같이 packing 하는 방법을 생각했다.

음향 재질을 사용자가 설정했다는 의미로, 음향 재질 값에 1.0f를 더해서 오프셋을 주는 방식을 사용했다.

따라서 0.0f라는 값을 보면, 아무 값도 설정되지 않았음을 확실히 알 수 있다.

// Components/ATSoundMaterialComponent.h

class AUDIOTRACING_API UATSoundMaterialComponent : public USceneComponent
{
public:
    void SetSoundMaterialCustomPrimitiveData() const;
};



// Components/ATSoundMaterialComponent.cpp

void UATSoundMaterialComponent::SetSoundMaterialCustomPrimitiveData() const
{
    if (UPrimitiveComponent* ParentPrimitiveComp = Cast<UPrimitiveComponent>(GetAttachParent()))
    {
        const int32 StartFloat4Index = AudioTracing::GetCustomPrimitiveDataStartFloat4Index();
            
        if (0 <= StartFloat4Index && StartFloat4Index < FCustomPrimitiveData::NumCustomPrimitiveDataFloat4s)
        {
            const int32 StartIndex = StartFloat4Index * 4;

            /**
             * Packs the scattering factor by adding 1.0f before writing it to the Custom Primitive Data.
             * This allows the shader to distinguish between a user-set 0.0f and the default 0.0f of an unset slot.
             *
             * The shader will decode this value by checking if it's greater than zero, and if so,
             * subtracting 1.0f to restore the original value.
             */
            const float PackedScatteringFactor = GetScatteringFactor() + 1.0f;
        
            ParentPrimitiveComp->SetCustomPrimitiveDataFloat(StartIndex, PackedScatteringFactor);
            ParentPrimitiveComp->SetCustomPrimitiveDataFloat(StartIndex + 1, GetReflectionFactor());
            ParentPrimitiveComp->SetCustomPrimitiveDataFloat(StartIndex + 2, GetAbsorptionCoefficient());
        }
    }
    else
    {
        UE_LOG(LogAudioTracing, Warning,
            TEXT("UATSoundMaterialComponent::SetSoundMaterialCustomPrimitiveData: Parent component must be a PrimitiveComponent. Owner Actor: [%s]"), 
            *GetNameSafe(GetOwner())
        );
    }
}

 

위 코드에서 AudioTracing::GetCustomPrimitiveDataStartFloat4Index()라는 함수는 플러그인의 설정에서 사용자가 설정한 CPD 인덱스 값을 한번 처리한 후에 리턴해준다.

이 플러그인이 아닌 다른 곳에서도 CPD를 사용할 수 있기 때문에, 값들이 서로 겹쳐서 덮어씌우는 일이 없도록, 사용자가 인덱스를 설정할 수 있도록 했다.

이 음향 재질을 위해 담겨야 하는 값의 개수를 기준으로 유효한 인덱스인지를 검사하는 로직이 있지만, 자세한 구현 내용은 생략한다.

 


 

에셋과 연동

이 컴포넌트는 에셋을 통해 음향 재질 값을 설정할 수 있다.

프리미티브의 음향 재질을 설정하는 작업은 컴포넌트에서 진행하고 있기에, 에셋의 값이 변한다면 컴포넌트가 이를 감지해서 값을 다시 설정해야 한다.

 

이전 글에서 언급했듯이, 이를 위해 에셋에 델리게이트를 만들었다.

컴포넌트는 에셋이 설정되면, 이 에셋의 델리게이트에 함수를 바인딩해야 한다.

이 델리게이트가 broadcast 될 때마다 음향 재질 값을 다시 설정하면 된다.

// Components/ATSoundMaterialComponent.h

class AUDIOTRACING_API UATSoundMaterialComponent : public USceneComponent
{
public:
    UFUNCTION(BlueprintCallable, Category = "Audio Tracing|Sound Material Component")
    void SetSoundMaterial(USoundMaterial* NewMaterial);
    
protected:
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Audio Tracing")
    TObjectPtr<USoundMaterial> SoundMaterial;
    
private:
#if WITH_EDITOR
    // 에셋을 변경하는 등의 다양한 상황을 다루기 위해선, 델리게이트가 바인딩된 에셋을 별도로 참조해야 함.
    TWeakObjectPtr<USoundMaterial> BoundSoundMaterial;
    
    UFUNCTION()
    void OnSoundMaterialPropertyChanged();

    void UnbindAssetDelegate();
#endif
};



// Components/ATSoundMaterialComponent.cpp

void UATSoundMaterialComponent::SetSoundMaterial(USoundMaterial* NewMaterial)
{
    SoundMaterial = NewMaterial;

    if (HasAnyFlags(RF_ClassDefaultObject | RF_ArchetypeObject | RF_MirroredGarbage))
    {
        return;
    }

    if (HasBegunPlay())
    {
        // 위에서 언급했던 게임 플레이 중에는 에셋의 값만 활용하는 부분.
        ScatteringFactor = SoundMaterial ? SoundMaterial->GetScatteringFactor() : USoundMaterial::DefaultScatteringFactor;
        ReflectionFactor = SoundMaterial ? SoundMaterial->GetReflectionFactor() : USoundMaterial::DefaultReflectionFactor;
        AbsorptionCoefficient = SoundMaterial ? SoundMaterial->GetAbsorptionCoefficient() : USoundMaterial::DefaultAbsorptionCoefficient;
    }
#if WITH_EDITOR
    else
    {
        if (SoundMaterial)
        {
            BoundSoundMaterial = NewMaterial;
            if (BoundSoundMaterial.IsValid() && !BoundSoundMaterial->OnSoundMaterialPropertyChanged.IsBoundToObject(this))
            {
                BoundSoundMaterial->OnSoundMaterialPropertyChanged.AddUObject(this, &UATSoundMaterialComponent::OnSoundMaterialPropertyChanged);
            }
        }
    }
#endif
    
    SetSoundMaterialCustomPrimitiveData();
}

#if WITH_EDITOR
void UATSoundMaterialComponent::OnSoundMaterialPropertyChanged()
{
    SetSoundMaterialCustomPrimitiveData();
}

void UATSoundMaterialComponent::UnbindAssetDelegate()
{
    if (BoundSoundMaterial.IsValid() && BoundSoundMaterial->OnSoundMaterialPropertyChanged.IsBoundToObject(this))
    {
        BoundSoundMaterial->OnSoundMaterialPropertyChanged.RemoveAll(this);
    }
    BoundSoundMaterial = nullptr;
}
#endif

 

에디터 기능은 모두 #if WITH_EDITOR 로 감싸놓았다.

 

그리고, 코드를 보면 정작 델리게이트의 바인딩을 지우는 함수는 호출되고 있지 않다.

이것과 관련된 내용은 아래에서 다룬다.

 


 

컴포넌트 변경 사항 감지

컴포넌트에는 변경 사항이 발생했을 때 호출되는 함수들이 있다.

멤버 변수의 값이 에디터에서 바뀌거나, 어태치 된 부모가 바뀌는 경우와 같이, 에디터와 게임 로직 상황에 변경을 감지하면 정해진 함수들이 호출된다.

이를 활용하여 바인딩했던 델리게이트를 제거하는 등의 작업을 진행하면 된다.

// Components/ATSoundMaterialComponent.h

class AUDIOTRACING_API UATSoundMaterialComponent : public USceneComponent
{
public:
    virtual void DestroyComponent(bool bPromoteChildren = false) override;
    
    virtual void OnRegister() override;

    virtual void OnUnregister() override;
    
#if WITH_EDITOR
    virtual void PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent) override;

    virtual void PreEditUndo() override;
    
    virtual void PostEditUndo() override;
#endif
}



// Components/ATSoundMaterialComponent.cpp

void UATSoundMaterialComponent::DestroyComponent(bool bPromoteChildren)
{
    ClearSoundMaterialCustomPrimitiveData();
    
    Super::DestroyComponent(bPromoteChildren);
}

void UATSoundMaterialComponent::OnRegister()
{
    Super::OnRegister();

    SetSoundMaterial(SoundMaterial);
}

void UATSoundMaterialComponent::OnUnregister()
{
#if WITH_EDITOR
    UnbindAssetDelegate();
#endif
    
    Super::OnUnregister();
}

#if WITH_EDITOR
void UATSoundMaterialComponent::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
    Super::PostEditChangeProperty(PropertyChangedEvent);

    if (PropertyChangedEvent.Property)
    {
        if (PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(UATSoundMaterialComponent, SoundMaterial))
        {
            SetSoundMaterial(SoundMaterial);
        }
        else
        {
            const bool bShouldUpdateCustomPrimitiveData = PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(UATSoundMaterialComponent, bOverrideSoundMaterial) ||
                PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(UATSoundMaterialComponent, ScatteringFactor) ||
                PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(UATSoundMaterialComponent, ReflectionFactor) ||
                PropertyChangedEvent.Property->GetFName() == GET_MEMBER_NAME_CHECKED(UATSoundMaterialComponent, AbsorptionCoefficient);

            if (bShouldUpdateCustomPrimitiveData)
            {
                SetSoundMaterialCustomPrimitiveData();
            }
        }
    }
}

void UATSoundMaterialComponent::PreEditUndo()
{
    Super::PreEditUndo();

    UnbindAssetDelegate();
}

void UATSoundMaterialComponent::PostEditUndo()
{
    Super::PostEditUndo();

    SetSoundMaterial(SoundMaterial);
}
#endif

UPROPERTY로 지정한 멤버 변수의 값이 에디터에서 바뀌었을 때에는 PreEditChange와 PostEditChangeProperty가 호출된다.

PreEditChange에서는 변경되기 전의 값을, PostEditChangeProperty에서는 변경된 후의 값에 접근할 수 있으니, 상황에 맞게 변경 사항을 처리하면 된다.

또한, PreEditUndo와 PostEditUndo 과정에서 델리게이트를 해제했다가 다시 등록하는 과정을 거치게 해서 안정성을 높였다.

위의 SetSoundMaterial 함수의 구현 코드에 델리게이트를 바인딩하는 코드를 확인할 수 있다.

 

한 가지 중요한 것은, 컴포넌트의 PreEditChange와 PostEditChangeProperty 과정에서는 OnRegister와 OnUnregister 함수가 호출된다는 점이다.

UActorComponent의 PreEditChange와 PostEditChangeProperty 함수에서는 EditReregisterContexts를 통해 일정 기간 동안 컴포넌트를 등록 해제했다가 다시 등록한다.

이 로직은 FComponentReregisterContext 객체가 유효한 동안 컴포넌트를 등록 해제했다가, 객체가 소멸될 때 다시 등록하는 방식으로 작동한다.

 

OnRegister와 OnUnregister 함수는 컴포넌트가 월드에 등록되거나 등록 해제될 때 호출되는 함수로, 레벨을 로드하거나 컴포넌트가 삭제될 때도 호출된다.

즉, PreEditChange나 PostEditChangeProperty 보다는 범용성이 더 넓은 함수라고 볼 수 있다.

따라서, 음향 재질 설정 같은 매번 확인해야 하는 또는 레벨 로드 시에도 작동해야 하는 CPD 설정과 델리게이트 관리는 이 함수를 통해 진행했다.

DestroyComponent 함수에 델리게이트를 해제하는 코드가 없는 이유도 이와 같다.

 

FComponentReregisterContext와 관련하여 더 자세한 건 아래의 엔진 코드를 통해 확인할 수 있다.

// ComponentReregisterContext.h

/**
 * Unregisters a component for the lifetime of this class.
 *
 * Typically used by constructing the class on the stack:
 * {
 *        FComponentReregisterContext ReregisterContext(this);
 *        // The component is unregistered with the world here as ReregisterContext is constructed.
 *        ...
 * }    // The component is registered with the world here as ReregisterContext is destructed.
 */

class FComponentReregisterContext : public FComponentReregisterContextBase
{
private:
    /** Pointer to component we are unregistering */
    TWeakObjectPtr<UActorComponent> Component;
    /** Cache pointer to world from which we were removed */
    TWeakObjectPtr<UWorld> World;
public:
    FComponentReregisterContext(UActorComponent* InComponent, TSet<FSceneInterface*>* InScenesToUpdateAllPrimitiveSceneInfos = nullptr)
        : World(nullptr)
    {
        ScenesToUpdateAllPrimitiveSceneInfos = InScenesToUpdateAllPrimitiveSceneInfos;

        World = UnRegister(InComponent);
        // If we didn't get a scene back NULL the component so we dont try to
        // process it on destruction
        Component = World.IsValid() ? InComponent : nullptr;
    }

    ~FComponentReregisterContext()
    {
        if( Component.IsValid() && World.IsValid() )
        {
            ReRegister(Component.Get(), World.Get());
        }
    }
};



// Components/ActorComponent.cpp

void UActorComponent::PreEditChange(FProperty* PropertyThatWillChange)
{
    // ...

    if(IsRegistered())
    {
        // The component or its outer could be pending kill when calling PreEditChange when applying a transaction.
        // Don't do do a full recreate in this situation, and instead simply detach.
        if(IsValidChecked(this))
        {
            // ...
            
            EditReregisterContexts.Add(this,new FComponentReregisterContext(this)); // 이때 등록 해제
        }
        else
        {
            ExecuteUnregisterEvents();
            WorldPrivate = nullptr;
        }
    }

    // ...
}

void UActorComponent::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
    Super::PostEditChangeProperty(PropertyChangedEvent);

    ConsolidatedPostEditChange(PropertyChangedEvent);
}

void UActorComponent::ConsolidatedPostEditChange(const FPropertyChangedEvent& PropertyChangedEvent)
{
    // ...

    FComponentReregisterContext* ReregisterContext = nullptr;
    if(EditReregisterContexts.RemoveAndCopyValue(this, ReregisterContext))
    {
        delete ReregisterContext; // 이때 다시 등록
        
        // ...
    }

    // ...
}

 

 


 

부모 변경 처리

에디터에서는 배치된 씬 컴포넌트를 마우스로 드래그해서 어태치 할 부모를 변경할 수 있다.

이런 상황뿐만 아니라, 컴포넌트를 새로 생성하거나 잘라내어 붙여 넣는 경우에도 부모가 새로 지정되거나 바뀌게 된다.

이런 상황을 위해 OnAttachmentChanged 함수를 통해 어태치 된 부모가 바뀌었음을 확인할 수 있다.

 

이 Sound Material Component는 부모의 CPD 값을 지정해야 하기 때문에, 이 부모의 변경 사항을 감지하는 게 매우 중요하다.

부모가 변경되었다면, 이전의 부모에 설정해 놓은 CPD 값을 지우고, 새로운 부모의 CPD 값을 설정해야 한다.

이때, 이전의 부모를 참조하기 위해 멤버 변수를 통해 캐싱하는 방법을 사용했다.

// Components/ATSoundMaterialComponent.h

class AUDIOTRACING_API UATSoundMaterialComponent : public USceneComponent
{
public:
    virtual void OnAttachmentChanged() override;
    
private:
    TWeakObjectPtr<UPrimitiveComponent> CachedParent;
};



// Components/ATSoundMaterialComponent.cpp

void UATSoundMaterialComponent::OnAttachmentChanged()
{
    Super::OnAttachmentChanged();

    if (GetAttachParent())
    {
        SetSoundMaterialCustomPrimitiveData();
    }
    else
    {
        ClearSoundMaterialCustomPrimitiveData(CachedParent.Get());
    }

    CachedParent = Cast<UPrimitiveComponent>(GetAttachParent());
}

 

 

부모 변경 처리: 예상치 못한 예외 상황

여기에서, 예상치 못한 상황이 발생했다.

여러 메시 컴포넌트가 있는 액터를 만들어서 부모를 바꾸는 작업을 테스트했는데, 의도대로 작동하지 않는 문제가 발생했다.

이전 부모의 CPD는 올바르게 지웠지만, 새로운 부모의 CPD를 설정하지 못하는 문제가 발생했다.

 

디버깅을 통해 과정을 추적해 보니 마지막에 부모의 CPD를 설정하고 끝나지만, 최종적으로는 아무런 값이 지정되지 않는 결과로 이어진다.

 

자세한 상황은 다음과 같다.

다음과 같이 액터를 구성했다.

 

이 액터를 배치한 후에, 배치된 액터의 메시 컴포넌트에 이 Sound Material Component를 추가했다.

Sound Material Debug View

음향 재질 디버깅 뷰를 통해 확인하면 색상을 통해 음향 재질을 구분할 수 있다.

붉은색은 위의 이미지에 컴포넌트의 Override Sound Material을 통해 임의의 값을 지정한 것이고, 초록색은 기본값이다.

 

이후, 드래그를 통해 컴포넌트를 다른 메시에 어태치 한 결과는 다음과 같다.

이전 부모였던 Mesh1은 값이 올바르게 지워져서 기본값인 초록색으로 변했다.

하지만 새로운 부모인 Mesh2는 임의의 값인 붉은색이 아니라 여전히 기본값인 초록색이다.

 

중단점을 설정해서 과정을 추적해 봐도, 마지막에는 부모의 CPD 값을 설정하고 끝나기 때문에 과정을 제대로 추적해 봤다.

그 결과는 다음과 같다.

드래그, 드랍을 통해 Mesh2에서 Mesh1로 부모를 바꾸는 상황

1. 드래그 드랍 로직 디태치
콜 스택: OnAttachmentChanged -> ClearSoundMaterialCustomPrimitiveData
CachedParent: (Mesh2) 0x2ec20d58800 --[ 변경 ]--> nullptr

2. 드래그 드랍 로직 어태치
콜 스택: OnAttachmentChanged -> SetSoundMaterialCustomPrimitiveData
CachedParent: nullptr --[ 변경 ]--> (Mesh1) 0x2ec20d5d800

3. 액터 RerunConstructionScripts 호출. DetachFromComponent
콜 스택: OnAttachmentChanged -> ClearSoundMaterialCustomPrimitiveData
CachedParent: (Mesh1) 0x2ec20d5d800 --[ 변경 ]--> nullptr

4. 액터 RerunConstructionScripts 호출. AttachToComponent
콜 스택: OnAttachmentChanged -> SetSoundMaterialCustomPrimitiveData
CachedParent: nullptr --[ 변경 ]--> (Mesh1) 0x2ec248af800

5. 결과: 부모 이동 후 CPD 값을 설정하지만, CPD 값이 설정되지 않음.

이 과정에서 발견한 특이 사항으로는, 3번과 4번 과정에서 둘 다 Mesh1을 가리키지만, 주소가 서로 다르다는 점이다.

아마도 이 3번과 4번 과정이 진행되는 AActor::RerunConstructionScript 함수에서 액터를 다시 만들면서 이러한 증상이 나타나는 것으로 보였다.

 

그리고 이 증상은 4번 이후로도 발생할 수 있다고 판단했다.

물론 4번 이후에 OnAttachmentChanged 함수가 호출되지는 않았지만, 이 함수는 메모리의 값이 바뀜을 감지하고 호출되는 함수는 아니기에 충분히 가능성이 있어 보였다.

 

따라서, 여기에서 추적을 멈추지 않고, 중단점을 다시 걸고 컴포넌트의 값을 다시 바꿔서 CPD 값을 설정하게 했다.

드래그, 드랍을 통해 Mesh2에서 Mesh1로 부모를 바꾸는 상황

1. 드래그 드랍 로직 디태치
콜 스택: OnAttachmentChanged -> ClearSoundMaterialCustomPrimitiveData
CachedParent: (Mesh2) 0x2ec20d58800 --[ 변경 ]--> nullptr

2. 드래그 드랍 로직 어태치
콜 스택: OnAttachmentChanged -> SetSoundMaterialCustomPrimitiveData
CachedParent: nullptr --[ 변경 ]--> (Mesh1) 0x2ec20d5d800

3. 액터 RerunConstructionScripts 호출. DetachFromComponent
콜 스택: OnAttachmentChanged -> ClearSoundMaterialCustomPrimitiveData
CachedParent: (Mesh1) 0x2ec20d5d800 --[ 변경 ]--> nullptr

4. 액터 RerunConstructionScripts 호출. AttachToComponent
콜 스택: OnAttachmentChanged -> SetSoundMaterialCustomPrimitiveData
CachedParent: nullptr --[ 변경 ]--> (Mesh1) 0x2ec248af800

5. 결과: 부모 이동 후 CPD 값을 설정하지만, CPD 값이 설정되지 않음.

6. SoundMaterialCompnent 속성 변경을 통해 값 업데이트하여 부모 컴포넌트 다시 확인
콜 스택: PostEditChangeProperty -> AActor::RerunConstructionScripts -> USceneComponent::DetachFromComponent
CachedParent: (Mesh1) 0x2ec248af800 --[ 변경 ]--> nullptr

USceneComponent::DetachFromComponent 리턴

콜 스택: PostEditChangeProperty -> AActor::RerunConstructionScripts -> AActor::ExecuteConstruction -> USceneComponent::AttachToComponent
CachedParent: nullptr --[ 변경 ]--> (Mesh1) 0x2ec23520800

7. 결과: CPD 값이 올바르게 설정되었으나, 4번 과정 이후의 CachedParent와는 다른 부모임

결과를 확인해 보니 위에서 세운 가설이 맞았음을 확인했다.

6번 과정에서 GetAttachParent 함수를 통해 새로 받아온 부모는, 4번 과정 이후 CachedParent에 저장된 주소와는 달랐다.

둘 다 동일한 Mesh1을 가리키지만, 주소가 다르다.

 

이러한 증상이 나타나는 원인은, AActor::RerunConstructionScripts 함수 때문으로 보인다.

액터의 컴포넌트의 계층 구조가 바뀌면 완전히 새로운 컴포넌트들을 다시 생성하게 된다.

또한, 4번 과정은 이 함수에서 하는 수많은 작업 중의 일부 과정이므로, 이후에 또 다른 컴포넌트가 생성되어 최종적으로 이것이 사용될 가능성이 충분히 존재한다.

 

그러나, 위의 6번 과정을 자세히 보면 PostEditChangeProperty를 통해서도 AActor::RerunConstructionScripts가 호출되는 걸 확인할 수 있다.

이는 잠시 부모에서 떼었다가 변경 사항을 적용한 뒤, 기존의 부모에 다시 어태치 하므로, 여전히 CachedParent의 포인터는 유효한 것으로 보인다.

이유를 정확히 파악해보고 싶으나, AActor::RerunConstructionScripts의 코드가 약 550줄이기에 시간이 좀 걸릴 듯하다.

 

한 가지 다행인 점이라면, 이 컴포넌트는 여전히 동일한 메모리 주소의 동일한 객체라는 것이다.

따라서, 이 문제를 해결하기 위해, 다음 틱에 CPD 값을 설정하기로 했다.

FTimerManager에는 이런 상황을 위해 SetTimerForNextTick 함수가 존재하므로, 이를 활용했다.

// Components/ATSoundMaterialComponent.h

class AUDIOTRACING_API UATSoundMaterialComponent : public USceneComponent
{
private:
#if WITH_EDITOR
    /**
     * Re-applies sound material data to the parent component.
     * Intended to be called on the next tick to handle initialization race conditions (e.g., Construction Script).
     */
    void ApplySoundMaterialDataDelayed();
#endif
};



// Components/ATSoundMaterialComponent.cpp

#if WITH_EDITOR
void UATSoundMaterialComponent::ApplySoundMaterialDataDelayed()
{
    /**
     * Re-apply the value on the next tick to handle cases where the Actor's Construction Script regenerates
     * the parent component, potentially resetting Custom Primitive Data.
     */
    if (UWorld* World = GetWorld())
    {
        TWeakObjectPtr<UATSoundMaterialComponent> WeakThis(this);
            
        World->GetTimerManager().SetTimerForNextTick(
            [WeakThis]()
            {
                if (WeakThis.IsValid())
                {
                    if (WeakThis->GetAttachParent())
                    {
                        WeakThis->SetSoundMaterialCustomPrimitiveData();
                    }
                    WeakThis->CachedParent = Cast<UPrimitiveComponent>(WeakThis->GetAttachParent());
                }
            }
        );
    }
}
#endif

또한, OnAttachmentChanged 함수도 다음과 같이 수정했다.

void UATSoundMaterialComponent::OnAttachmentChanged()
{
    Super::OnAttachmentChanged();

    if (GetAttachParent())
    {
        SetSoundMaterialCustomPrimitiveData();
    }
    else
    {
        ClearSoundMaterialCustomPrimitiveData(CachedParent.Get());
    }

    CachedParent = Cast<UPrimitiveComponent>(GetAttachParent());
    
#if WITH_EDITOR
    ApplySoundMaterialDataDelayed();
#endif
}

 

이렇게 수정한 결과는 다음과 같다.

 


 

여기까지의 작업을 통해, 이 컴포넌트를 문제없이 사용하기 위해 중요하게 고려한 부분을 모두 다뤘다.


 

결과

먼저 게임 로직이 에셋에 영향을 주지 않게 설계한 결과다.

위와 같이 에셋을 통해 값을 설정하고, 게임 플레이 로직으로 음향 재질 값을 변경했다.

게임 플레이 로직이 에셋에 영향을 주지 않는 결과

마지막에 PIE를 종료한 에디터 화면을 통해, 에셋의 값은 변경되지 않은걸 색상으로 확인할 수 있다.

 


 

다음은 에셋의 델리게이트를 통해 컴포넌트에서 CPD 값을 업데이트하는 결과다.

에셋을 사용하는 여러 메시들이 동시에 반응하는 걸 확인할 수 있다.

 


 

Undo와 Redo를 사용할 때도 문제없이 반영된다.

부모를 바꾸는 과정과 섞여도 문제없이 반영된다.

 


 

에셋을 사용 중에 강제 삭제하는 건 권장되지 않지만, 이런 경우에도 올바르게 작동한다.

 


 

컴포넌트를 삭제하고 Undo와 Redo를 통해 복구하거나 되돌려도 CPD 값이 의도대로 반영된다.

 


 

이제 와서 적지만, 이 모든 걸 가능하게 해 준 건 이 디버깅 뷰 덕분인 듯하다.

이전의 레이 투과 로직도 그렇고, 테스트할 수 있는 환경이 준비되어 있어야 더 완성도 있는 결과물을 만들 수 있다고 생각한다.

 

 

'게임테크랩 1기 > Audio Tracing' 카테고리의 다른 글

[UE5 | Audio Tracing] 음향 재질을 위한 에셋 구현 과정  (0) 2025.11.29
[UE5 | Audio Tracing] 실시간 음향 정보 갱신을 위한 Readback 버퍼 풀과 데이터 캡처  (0) 2025.11.23
[UE5 | Audio Tracing] 음향 재질을 위한 Custom Primitive Data 획득  (2) 2025.10.27
[UE5 | Audio Tracing] TraceRayInline을 통한 월드 노멀 획득  (1) 2025.09.29
[UE5 | Audio Tracing] UE5 Lumen HWRT 활용하기  (0) 2025.09.29
'게임테크랩 1기/Audio Tracing' 카테고리의 다른 글
  • [UE5 | Audio Tracing] 음향 재질을 위한 에셋 구현 과정
  • [UE5 | Audio Tracing] 실시간 음향 정보 갱신을 위한 Readback 버퍼 풀과 데이터 캡처
  • [UE5 | Audio Tracing] 음향 재질을 위한 Custom Primitive Data 획득
  • [UE5 | Audio Tracing] TraceRayInline을 통한 월드 노멀 획득
mstone
mstone
프로젝트의 진행 상황과 공부하면서 기억해둬야 하는 내용을 정리해서 올립니다.
  • mstone
    // 주석
    mstone
  • 전체
    오늘
    어제
    • 분류 전체보기 (53)
      • 언리얼엔진 (35)
        • 노노그램 (16)
        • FPS 프로젝트 (10)
        • 그 외 (9)
      • 그래픽스 (2)
      • 게임테크랩 1기 (8)
        • Direct3D 11 엔진 (1)
        • Audio Tracing (7)
      • DirectX11 (7)
      • 취미 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 카테고리
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    캐릭터 무브먼트 컴포넌트
    게임플레이 태그
    언리얼 엔진
    노노그램
    Direct3D 11
    NetSerialize
    루트 모션
    애니메이션
    SCompundWidget
    애니메이션 블루프린트 링크
    그래픽스
    언리얼엔진
    Audio Tracing
    DirectX11
    FPS
    위젯
    Unreal Engine 5
    에디터 유틸리티
    Nonogram Solver
    슬레이트
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
mstone
[UE5 | Audio Tracing] 다양한 에디터 조작에 대응하는 Sound Material 컴포넌트 개발
상단으로

티스토리툴바