TECHARTNOMAD | TECHARTFLOWIO.COM

TECH.ART.FLOW.IO

[번역][연재물] 언리얼 엔진 개발 가이드 Part-2

jplee 2025. 4. 16. 23:01

역자의 글.
봄이 오는가 싶더니 여전히 춥기도 하고 이게 지금 봄이 되는건지 겨울이 되는건지 알 길이 없을만큼 참 다이나믹한 날씨가 계속 되고 있어요. 그런데 말이죠. 생각해보면 우리들의 인생은 예전부터 계속 이래왔던게 아닌가 싶습니다. ㅎㅎ 갑자기 좀 센치해져봤어요.
여전히 Italink 군의 멋진 정리글을 공유 해 봅니다. 


에셋 시스템

NewObject의 함수 원형은 다음과 같습니다:

template< class T >
T* NewObject(UObject* Outer = (UObject*)GetTransientPackage());

여기서 Outer는 부모 객체로 간단히 이해할 수 있으며, 주의할 점은 Outer가 UObject의 GC와는 관련이 없고, 에셋 저장 구조상의 관계를 지정하는 역할을 한다는 것입니다.
UE에서 에셋 객체는 물리적 파일과 동일하지 않습니다. 실제로 디스크상의 *.uasset과 *.umap 파일은 UPackage 객체에 대응되는 것으로 이해할 수 있습니다.
UE에서 에셋을 생성할 때는 먼저 실제 물리적 파일 매핑으로 UPackage를 생성하고, 그 다음 NewObject를 사용하여 실제 에셋을 생성합니다. 이때 에셋의 Outer를 Package로 지정하며, Package를 저장할 때 해당 Package가 보유한 모든 UObject를 물리적 파일로 직렬화합니다. 이 과정은 다음과 같이 보입니다:

// 创建一个新Package,/Game/CustomPackage为包路径,其中/Game代表工程的Content目录
UPackage* CustomPackage = CreatePackage(nullptr, TEXT("/Game/CustomPackage"));
// 创建一个新的资产,可以是任意UObject(这里使用的UObject是抽象类,会有警告)
UObject* CustomInstance = NewObject<UObject>(CustomPackage, UObject::StaticClass(),
                                             TEXT("CustomInstance"), 
                                             EObjectFlags::RF_Public | EObjectFlags::RF_Standalone);    
// 获取Package的本地磁盘路径
FPackagePath PackagePath = FPackagePath::FromPackageNameChecked(CustomPackage->GetName());
const FString PackageFileName = PackagePath.GetLocalFullPath();
// 将包存储到磁盘上
const bool bSuccess = UPackage::SavePackage(CustomPackage,
                                            CustomInstance, 
                                            RF_Public | RF_Standalone,
                                            *PackageFileName,
                                            GError, nullptr, false, true, SAVE_NoError);

이 작업은 프로젝트의 Content 디렉토리에 CustomPackage.uasset 파일을 생성합니다.

디스크에 있는 패키지와 에셋 객체는 다음 코드를 통해 불러올 수 있습니다:

UPackage* LoadedPackage = LoadPackage(nullptr, TEXT("/Game/CustomPackage"), LOAD_None);
UObject* LoadedAsset = LoadObject<UObject>(nullptr,TEXT("/Game/CustomPackage.CustomInstance"));

해당 에셋을 UE 에디터에서 생성하고 미리보기하려면 다음이 필요합니다:

  • UFactory를 상속받아 에셋 객체의 생성 팩토리를 정의
  • FAssetTypeActions_Base를 상속받아 에디터에서의 미리보기, 메뉴 등의 동작을 정의

에셋 관리에 대한 자세한 내용은 다음을 참조하세요:

게임플레이 프레임워크

여기 매우 상세한 글들이 있어 더 이상의 설명은 생략하겠습니다:

이러한 구조 체계를 이해하고 나면, 다음과 같은 과감한 코드도 작성해볼 수 있습니다. 다음은 정적 모델의 오프라인 렌더링 예제입니다:

void Render(UStaticMesh* StaticMesh, FString OutFileName, FTransform CameraTransform, int32 InResolution)
{
    EWorldType::Type WorldType = EWorldType::Editor;
    UWorld* World = UWorld::CreateWorld(WorldType, false, "RenderWorld");       // 手动创建UWorld
    UWorld* LastWorld = GWorld;
    GWorld = World;         
    FWorldContext& EditorContext = GEditor->GetEditorWorldContext();
    EditorContext.SetCurrentWorld(World);   // Sky Light 的更新依赖于 GEgnine 的 Tick,它需要保证 GWorld == EditorContext.World() 
    const FURL URL;
    World->InitializeActorsForPlay(URL);
    World->BeginPlay();                                                         // 手动执行UWorld的开始事件
    FakeEngineTick(World, 0.03f);
    AStaticMeshActor* Actor = World->SpawnActor<AStaticMeshActor>();
    UStaticMeshComponent* StaticMeshComp = Actor->GetStaticMeshComponent();
    ASkyAtmosphere* SkyAtmosphereActor = World->SpawnActor<ASkyAtmosphere>();
    AExponentialHeightFog* ExponentialHeightFogActor = World->SpawnActor<AExponentialHeightFog>();
    ADirectionalLight* DirectionalLightActor = World->SpawnActor<ADirectionalLight>();
    UDirectionalLightComponent* DirectionalLightComponent = DirectionalLightActor->GetComponent();
    DirectionalLightComponent->SetIntensity(12);
    ASkyLight* SkyLightActor = World->SpawnActor<ASkyLight>();
    USkyLightComponent* SkyLightComponent = SkyLightActor->GetLightComponent();
    SkyLightComponent->SetMobility(EComponentMobility::Movable);
    SkyLightComponent->bLowerHemisphereIsBlack = false;
    SkyLightComponent->SetIntensity(2);
    SkyLightComponent->RecaptureSky();                                          // 请求刷新天光
    FakeEngineTick(World, 0.03f);                                               // 执行等待渲染更新的Tick完成
    UTextureRenderTarget2D* RenderTarget = NewObject<UTextureRenderTarget2D>(); // 初始化RT的数据
    RenderTarget->RenderTargetFormat = ETextureRenderTargetFormat::RTF_RGBA16f;
    RenderTarget->InitAutoFormat(InResolution, InResolution);
    RenderTarget->UpdateResourceImmediate(true);
    FakeEngineTick(World, 0.03f);                                               // 等待RT的资源提交
    ETextureRenderTargetFormat RenderTargetFormat = RenderTarget->RenderTargetFormat;
    FTextureRenderTargetResource* RenderTargetResource = RenderTarget->GameThread_GetRenderTargetResource();
    ASceneCapture2D* SceneCaptureActor = World->SpawnActor<ASceneCapture2D>();
    USceneCaptureComponent2D* SceneCaptureComp = SceneCaptureActor->GetCaptureComponent2D();
    SceneCaptureComp->TextureTarget = RenderTarget;
    SceneCaptureComp->bCaptureEveryFrame = false;
    SceneCaptureComp->bOverride_CustomNearClippingPlane = true;
    SceneCaptureComp->CustomNearClippingPlane = 0.01f;
    SceneCaptureComp->CaptureSource = ESceneCaptureSource::SCS_FinalColorLDR;
    SceneCaptureComp->ShowFlagSettings.Add(FEngineShowFlagsSetting{ TEXT("DistanceFieldAO"), false });
    SceneCaptureComp->SetShowFlagSettings(SceneCaptureComp->ShowFlagSettings);
    FakeEngineTick(World);                                          // 等待SceneCapture的状态更新
    SceneCaptureActor->SetActorTransform(CameraTransform);          // 执行捕获
    SceneCaptureComp->CaptureScene();
    TArray<FLinearColor> Colors;
    FReadSurfaceDataFlags ReadPixelFlags(RCM_MinMax);
    FIntRect IntRegion(0, 0, RenderTarget->SizeX, RenderTarget->SizeY);
    RenderTargetResource->ReadLinearColorPixels(Colors, ReadPixelFlags, IntRegion);
    FImageView ImageView(Colors.GetData(), RenderTarget->SizeX, RenderTarget->SizeY);
    FImageUtils::SaveImageByExtension(*OutFileName, ImageView);     // 保存图像
    World->DestroyWorld(false);                                     // 还原状态
    GEngine->DestroyWorldContext(World);
    GWorld = LastWorld;
}
// 手动执行引擎的Tick更新
static void FakeEngineTick(UWorld* InWorld, float InDelta = 0.03f)    
    FApp::SetDeltaTime(InDelta);
    GEngine->EmitDynamicResolutionEvent(EDynamicResolutionStateEvent::EndFrame);
    GEngine->Tick(InDelta, false);                                  // 其中包含了当前World的更新
    FSlateApplication::Get().PumpMessages();
    FSlateApplication::Get().Tick();
    GFrameCounter++;
    bool bIsTicking = FSlateApplication::Get().IsTicking();
    if (!bIsTicking && GIsRHIInitialized) {
        if (FSceneInterface* Scene = InWorld->Scene) {
            ENQUEUE_RENDER_COMMAND(BeginFrame)([](FRHICommandListImmediate& RHICmdList) {
                GFrameNumberRenderThread++;
                GFrameCounterRenderThread++;
                FCoreDelegates::OnBeginFrameRT.Broadcast();
            });
            ENQUEUE_RENDER_COMMAND(EndFrame)([](FRHICommandListImmediate& RHICmdList) {
                FCoreDelegates::OnEndFrameRT.Broadcast();
                RHICmdList.EndFrame();
            });
            FlushRenderingCommands();
        }
        ENQUEUE_RENDER_COMMAND(VirtualTextureScalability_Release)([](FRHICommandList& RHICmdList) {
            GetRendererModule().ReleaseVirtualTexturePendingResources();
        });
    }
    FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread);
    FSlateApplication::Get().GetRenderer()->Sync();
    FThreadManager::Get().Tick();
    FTSTicker::GetCoreTicker().Tick(InDelta);
    GEngine->TickDeferredCommands();
    GEngine->EmitDynamicResolutionEvent(EDynamicResolutionStateEvent::EndFrame);
    FAssetCompilingManager::Get().FinishAllCompilation();
}

기타

델리게이트

UE에서는 델리게이트(Delegate) 개념을 사용하여 객체 간 통신을 수행하며, 이를 통해 범용적이고 타입 안전한 방식으로 C++ 함수를 호출할 수 있습니다.

Qt에 익숙하다면, 델리게이트는 Qt의 시그널-슬롯(Signal-Slot) 중 시그널에 해당한다고 볼 수 있습니다. 다만 델리게이트가 시그널보다 더 강력하지만, 사용법은 더 복잡해졌죠.가장 아쉬운 점은 이름인데, 만약 Signal이라고 불렀다면 개발자들이 더 쉽게 연상하여 올바른 사용법을 떠올릴 수 있었을 것입니다.

다음은 델리게이트를 사용하는 예시입니다(Signal이라는 이름을 사용한 의사 코드):

class Student{
    Signal nameChanged(string OldName, string NewName);
public:
    void setName(string inName){
        string oldName = mName;
        mName = inName;
        nameChanged.Emit(oldName,mName);
    }
private:
    string mName;
};
class Tearcher{
public:
    void OnStudentNameChanged(string OldName, string NewName){
        // do something
    }
};
class Class{
protected:    
    void connectEveryone(){
         for(auto Student:mStudents){       
             Student.nameChanged.Connect(mTeacher,&Tearcher::OnStudentNameChanged);
         }
    }
private:
    Teacher mTeacher;
    TArray<Student> mStudents;
};

이 예시에서 connectEveryone 함수가 수행하는 기능은 다음과 같이 볼 수 있습니다: "선생님"이 교실에서 학생들에게 "이름을 바꾸면 저에게 알려주세요"라고 말하는 것입니다.
여기서는 Teacher와 Student의 구조를 직접 수정하지 않고, 제3자(교실)에서 두 객체의 관계를 연결함으로써 코드의 결합도를 낮추었습니다.
만약 델리게이트를 사용하지 않고 같은 기능을 구현한다면, 다음과 같은 지저분한 코드가 될 수 있습니다:

class Student{
public:
    Student(Class* InClass)     //需要传递上一级逻辑域的指针
        : mClass(InClass){}
    void setName(string inName){
        string oldName = mName;
        mName = inName;
        //不安全的链式调用,且上下文跨度较大的调用会让后续的维护和迭代变得困难
        mClass->mTeacher->OnStudentNameChanged(OldName,mName);  
    }
private:
    string mName;
    Class* mClass
};
class Tearcher{
public:
    void OnStudentNameChanged(string OldName, string NewName){
        // do something
    }
};
class Class{
    friend class Student;
private:
    Teacher mTeacher;
    TArray<Student> mStudents;
};

UE 델리게이트 사용법에 대한 자세한 내용은 다음을 참조하세요:


파트 3 에서 Slate 부터 다시 시작...