虚幻引擎编程基础(二)

虚幻引擎编程基础(二)

文章目录

  • 虚幻引擎编程基础(二)
    • 一、前言
    • 二、多线程
      • 2.1 FRunnable & FRunnableThread
      • 2.2 使用线程池的AsyncTask
      • 2.3 TaskGraph
    • 三、垃圾回收
    • 四、常用代码块
      • 3.1 蓝图和C++交互
      • 3.2 Json文件的读写
      • 3.3 动态创建材质、控制后处理
      • 3.4 对象内存常驻的四种方式
      • 3.5 其他
    • 参考文章

一、前言

在 虚幻引擎编程基础(一) 中整理一些基础的数据结构用法。

本文主要会继续简单地整理一些相关的基础内容,如多线程,垃圾回收,以及一些常用的代码块。

以下是笔者的一些笔记。如有错误,还请见谅。

二、多线程

线程是操作系统能够进行运行调度的最小单位。

一个进程中可以并发多个线程,每条线程并行执行不同的任务。

游戏引擎中,多线程用的一点也不少,比如渲染模块、物理模块、网络通信、音频系统、IO等。

UE4使用多线程的方式非常丰富,可以分为下面三类:

  • 标准多线程实现FRunnable;
  • 使用线程池的AsyncTask;
  • TaskGraph;

2.1 FRunnable & FRunnableThread

UE4是跨平台的引擎,对各个平台线程实现进行了封装,抽象出了 FRunnable,类似与Java中的多线程方式。

主要需要认识的类有:

FRunnable
FRunnableThread

其中:

FRunnable是需要继承实现的线程执行体。其接口如下:

class CORE_API FRunnable
{
public:

	/**
	 * Initializes the runnable object.
	 *
	 * This method is called in the context of the thread object that aggregates this, not the
	 * thread that passes this runnable to a new thread.
	 *
	 * @return True if initialization was successful, false otherwise
	 * @see Run, Stop, Exit
	 */
	virtual bool Init()
	{
		return true;
	}

	/**
	 * Runs the runnable object.
	 *
	 * This is where all per object thread work is done. This is only called if the initialization was successful.
	 *
	 * @return The exit code of the runnable object
	 * @see Init, Stop, Exit
	 */
	virtual uint32 Run() = 0;

	/**
	 * Stops the runnable object.
	 *
	 * This is called if a thread is requested to terminate early.
	 * @see Init, Run, Exit
	 */
	virtual void Stop() { }

	/**
	 * Exits the runnable object.
	 *
	 * Called in the context of the aggregating thread to perform any cleanup.
	 * @see Init, Run, Stop
	 */
	virtual void Exit() { }

	/**
	 * Gets single thread interface pointer used for ticking this runnable when multi-threading is disabled.
	 * If the interface is not implemented, this runnable will not be ticked when FPlatformProcess::SupportsMultithreading() is false.
	 *
	 * @return Pointer to the single thread interface or nullptr if not implemented.
	 */
	virtual class FSingleThreadRunnable* GetSingleThreadInterface( )
	{
		return nullptr;
	}

	/** Virtual destructor */
	virtual ~FRunnable() { }
};

FRunnableThreadc,才是真正负责创建的多线程,它持有FRunnable的实例。

  • 通过FRunnableThread::Create创建相应平台的线程
FRunnableThread* FRunnableThread::Create(
	class FRunnable* InRunnable, 
	const TCHAR* ThreadName,
	uint32 InStackSize,
	EThreadPriority InThreadPri, 
	uint64 InThreadAffinityMask,
	EThreadCreateFlags InCreateFlags)
{
	bool bCreateRealThread = FPlatformProcess::SupportsMultithreading();

	FRunnableThread* NewThread = nullptr;

	if (bCreateRealThread)
	{
		check(InRunnable);
		// Create a new thread object
		NewThread = FPlatformProcess::CreateRunnableThread();
	}
	else if (InRunnable->GetSingleThreadInterface())
	{
		// Create a fake thread when multithreading is disabled.
		NewThread = new FFakeThread();
	}

	if (NewThread)
	{
		SetupCreatedThread(NewThread, InRunnable, ThreadName, InStackSize, InThreadPri, InThreadAffinityMask, InCreateFlags);
	}

	return NewThread;
}

例如Windows平台:

  1. FRunnableThread::SetupCreatedThread函数中,会调用NewThread->CreateInternal,从而调用FRunnableThreadWin的CreateInternal,进行Windows的线程创建;
// Create the new thread
{
    LLM_SCOPE(ELLMTag::ThreadStack);
    LLM_PLATFORM_SCOPE(ELLMTag::ThreadStackPlatform);
    // add in the thread size, since it's allocated in a black box we can't track
    LLM(FLowLevelMemTracker::Get().OnLowLevelAlloc(ELLMTracker::Default, nullptr, InStackSize));
    LLM(FLowLevelMemTracker::Get().OnLowLevelAlloc(ELLMTracker::Platform, nullptr, InStackSize));

    // Create the thread as suspended, so we can ensure ThreadId is initialized and the thread manager knows about the thread before it runs.
    Thread = CreateThread(NULL, InStackSize, _ThreadProc, this, STACK_SIZE_PARAM_IS_A_RESERVATION | CREATE_SUSPENDED, (::DWORD *)&ThreadID);
}
  1. 在传入的_ThreadProc函数,会调用GuardedRun方法;
/**
	 * The thread entry point. Simply forwards the call on to the right
	 * thread main function
	 */
static ::DWORD STDCALL _ThreadProc( LPVOID pThis )
{
    check(pThis);
    auto* ThisThread = (FRunnableThreadWin*)pThis;
    FThreadManager::Get().AddThread(ThisThread->GetThreadID(), ThisThread);
    return ThisThread->GuardedRun();
}
  1. GuardedRun方法中调用Run,从而运行FRunnable::Run(线性执行体)。

uint32 FRunnableThreadWin::Run()
{
	// Assume we'll fail init
	uint32 ExitCode = 1;
	check(Runnable);

	// Initialize the runnable object
	if (Runnable->Init() == true)
	{
		// Initialization has completed, release the sync event
		ThreadInitSyncEvent->Trigger();

		// Setup TLS for this thread, used by FTlsAutoCleanup objects.
		SetTls();

		// Now run the task that needs to be done
		ExitCode = Runnable->Run();
		// Allow any allocated resources to be cleaned up
		Runnable->Exit();

#if STATS
		FThreadStats::Shutdown();
#endif
		FreeTls();
	}
	else
	{
		// Initialization has failed, release the sync event
		ThreadInitSyncEvent->Trigger();
	}

	return ExitCode;
}

以上就是Unreal自带最基础的多线程的实现方式。

渲染线程的创建就是使用的这种方法。

LauchEngineLoop.cppFEngineLoop::PreInitPreStartupScreen函数中会调用StartRenderingThread函数:

// Turn on the threaded rendering flag.
GIsThreadedRendering = true;

// Create the rendering thread.
// 创建渲染线程
GRenderingThreadRunnable = new FRenderingThread();

Trace::ThreadGroupBegin(TEXT("Render"));
PRAGMA_DISABLE_DEPRECATION_WARNINGS
    GRenderingThread = 
    PRAGMA_ENABLE_DEPRECATION_WARNINGS
    FRunnableThread::Create(GRenderingThreadRunnable, 
                            *BuildRenderingThreadName(ThreadCount), 0, 
                            FPlatformAffinity::GetRenderingThreadPriority(), 
                            FPlatformAffinity::GetRenderingThreadMask(), FPlatformAffinity::GetRenderingThreadFlags());

FRenderingThread的Run函数,实际调用了RenderingThreadMain。

/** The rendering thread main loop */
void RenderingThreadMain( FEvent* TaskGraphBoundSyncEvent )
{
	LLM_SCOPE(ELLMTag::RenderingThreadMemory);

	ENamedThreads::Type RenderThread = ENamedThreads::Type(ENamedThreads::ActualRenderingThread);

	ENamedThreads::SetRenderThread(RenderThread);
	ENamedThreads::SetRenderThread_Local(ENamedThreads::Type(ENamedThreads::ActualRenderingThread_Local));
	
    // 把当前线作为渲染线程挂接到TaskGraph
	FTaskGraphInterface::Get().AttachToThread(RenderThread);
	FPlatformMisc::MemoryBarrier();

	// Inform main thread that the render thread has been attached to the taskgraph and is ready to receive tasks
	if( TaskGraphBoundSyncEvent != NULL )
	{
		TaskGraphBoundSyncEvent->Trigger();
	}

	// set the thread back to real time mode
	FPlatformProcess::SetRealTimeMode();

#if STATS
	if (FThreadStats::WillEverCollectData())
	{
		FThreadStats::ExplicitFlush(); // flush the stats and set update the scope so we don't flush again until a frame update, this helps prevent fragmentation
	}
#endif

	FCoreDelegates::PostRenderingThreadCreated.Broadcast();
	check(GIsThreadedRendering);
    // 告诉TaskGraph系统,使用该线程一直处理渲染任务,直到请求退出
	FTaskGraphInterface::Get().ProcessThreadUntilRequestReturn(RenderThread);
	FPlatformMisc::MemoryBarrier();
	check(!GIsThreadedRendering);
	FCoreDelegates::PreRenderingThreadDestroyed.Broadcast();
	
#if STATS
	if (FThreadStats::WillEverCollectData())
	{
		FThreadStats::ExplicitFlush(); // Another explicit flush to clean up the ScopeCount established above for any stats lingering since the last frame
	}
#endif
	
	ENamedThreads::SetRenderThread(ENamedThreads::GameThread);
	ENamedThreads::SetRenderThread_Local(ENamedThreads::GameThread_Local);
	FPlatformMisc::MemoryBarrier();
}

其中,RenderingThreadMain会通过TaskGraph来一直执行渲染命令。

下面也将对TaskGraph进行介绍。

2.2 使用线程池的AsyncTask

线程过多会带来调度开销,进而影响缓存局部性和整体性能。频繁创建和销毁线程也会带来极大的开销。

通常我们更加关心的是任务可以并发执行,并不想管理线程的创建,销毁和调度。

通过将任务处理成队列,交由线程池统一执行,可以提升任务的执行效率。

UE4提供了对应的线程池来满足我们的需求。

AsyncTask系统是一套基于线程池的异步任务处理系统

可供我们直接使用的有:

  • FAsyncTask,异步任务,自动加入线程池;
  • FAutoDeleteAsyncTask,异步任务,任务完成后会自动销毁;

具体的细节在《Exploring in UE4》多线程机制详解原理分析有详细记录。

2.3 TaskGraph

由于基于任务的线程模式几乎必须要手动的进行同步和模式数据的管理,因此对于那些重量级别的常驻线程需要另一个更为强大的工具进行处理。

Task Graph 系统是UE4一套抽象的异步任务处理系统,可以创建多个多线程任务,指定各个任务之间的依赖关系,按照该关系来依次处理任务。

Task Graph中的线程为FWorkerThread。

  • FWorkerThread封装了FRunnableThread作为真正的线程体。
  • 而FTaskThreadBase是对FRunnable的又一个封装结构。
/** 
	*	FWorkerThread
	*	Helper structure to aggregate a few items related to the individual threads.
**/
struct FWorkerThread
{
	/** The actual FTaskThread that manager this task **/
	FTaskThreadBase*	TaskGraphWorker;
	/** For internal threads, the is non-NULL and holds the information about the runable thread that was created. **/
	FRunnableThread*	RunnableThread;
	/** For external threads, this determines if they have been "attached" yet. Attachment is mostly setting up TLS for this individual thread. **/
	bool				bAttached;

	/** Constructor to set reasonable defaults. **/
	FWorkerThread()
		: TaskGraphWorker(nullptr)
		, RunnableThread(nullptr)
		, bAttached(false)
	{
	}
};

FTaskThreadBase又分为FTaskThreadAnyThread和FNamedTaskThread,分别表示非指定名称的任意Task线程执行体和有名字的Task线程执行体。

在创建时调用Startup默认构建24个FWorkerThread工作线程(这里支持最大的线程数量也就是24),其中里面有5个是默认带名字的线程。

例如RHIThread,AudioThread,GameThread,ActualRenderingThread都是有名线程,其他是无名。

enum Type
{
    UnusedAnchor = -1,
#if STATS
    StatsThread,
#endif
    RHIThread,
    AudioThread,
    GameThread,
    // The render thread is sometimes the game thread and is sometimes the actual rendering thread
    ActualRenderingThread = GameThread + 1,
};

对于有名字的线程,我们是在外部进行创建他们的Runnable,而对于没有名字的线程,要在初始化的时候就创建他们的Runnable。

比如说经常使用的ENQUEUE_RENDER_COMMAND,就是把Lamda表示式传送到渲染线程执行。

template
FORCEINLINE_DEBUGGABLE void EnqueueUniqueRenderCommand(LAMBDA&& Lambda)
{
	QUICK_SCOPE_CYCLE_COUNTER(STAT_EnqueueUniqueRenderCommand);
	typedef TEnqueueUniqueRenderCommandType EURCType;

#if 0 // UE_SERVER && UE_BUILD_DEBUG
	UE_LOG(LogRHI, Warning, TEXT("Render command '%s' is being executed on a dedicated server."), TSTR::TStr())
#endif
	if (IsInRenderingThread())
	{
		FRHICommandListImmediate& RHICmdList = GetImmediateCommandList_ForRenderCommand();
		Lambda(RHICmdList);
	}
	else
	{
		if (ShouldExecuteOnRenderThread())
		{
			CheckNotBlockedOnRenderThread();
			TGraphTask::CreateTask().ConstructAndDispatchWhenReady(Forward(Lambda));
		}
		else
		{
			EURCType TempCommand(Forward(Lambda));
			FScopeCycleCounter EURCMacro_Scope(TempCommand.GetStatId());
			TempCommand.DoTask(ENamedThreads::GameThread, FGraphEventRef());
		}
	}
}

#define ENQUEUE_RENDER_COMMAND(Type) \
	struct Type##Name \
	{  \
		static const char* CStr() { return #Type; } \
		static const TCHAR* TStr() { return TEXT(#Type); } \
	}; \
	EnqueueUniqueRenderCommand

EnqueueUniqueRenderCommand中会通过CreateTask,将Lambda表示创建为GraphTask。

TEnqueueUniqueRenderCommandType继承自FRenderCommand,其DoTask函数将调用外部传入的Lambda表示式,同时把RHICmdList作为参数传入。

void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
{
    TRACE_CPUPROFILER_EVENT_SCOPE_ON_CHANNEL_STR(TSTR::TStr(), RenderCommandsChannel);
    FRHICommandListImmediate& RHICmdList = GetImmediateCommandList_ForRenderCommand();
    // 执行外部传入的表达式
    Lambda(RHICmdList);
}

再回顾下前面所说的FRenderingThread。
其Run函数调用了的关键函数:RenderingThreadMain。
在这里有两个关键代码:

// 把当前线作为渲染线程挂接到TaskGraph
FTaskGraphInterface::Get().AttachToThread(ENamedThreads::RenderThread);

// 告诉TaskGraph系统,使用该线程一直处理渲染任务,直到请求退出
FTaskGraphInterface::Get().ProcessThreadUntilRequestReturn(ENamedThreads::RenderThread);

第一句作用为:

  • 为线程进行标记(有名线程),使得可以通过ENamedThreads::Type来操作对应的线程。

第二句ProcessThreadUntilRequestReturn:

virtual void ProcessThreadUntilRequestReturn(ENamedThreads::Type CurrentThread)  final override
{
    int32 QueueIndex = ENamedThreads::GetQueueIndex(CurrentThread);
    CurrentThread = ENamedThreads::GetThreadIndex(CurrentThread);
    check(CurrentThread >= 0 && CurrentThread < NumNamedThreads);
    check(CurrentThread == GetCurrentThread());
    Thread(CurrentThread).ProcessTasksUntilQuit(QueueIndex);
}

其中的ProcessTasksUntilQuit:

  • 是一个While循环。
virtual void ProcessTasksUntilQuit(int32 QueueIndex) override
{
    check(Queue(QueueIndex).StallRestartEvent); // make sure we are started  up
    Queue(QueueIndex).QuitForReturn = false;
    verify(++Queue(QueueIndex).RecursionGuard == 1);
    const bool bIsMultiThread = FTaskGraphInterface::IsMultithread();
    do
    {
        const bool bAllowStall = bIsMultiThread;
        // 具体处理逻辑
        ProcessTasksNamedThread(QueueIndex, bAllowStall);
    } while (!Queue(QueueIndex).QuitForReturn &&  !Queue(QueueIndex).QuitForShutdown && bIsMultiThread); // @Hack - quit now when  running with only one thread.
    verify(!--Queue(QueueIndex).RecursionGuard);
}

在ProcessTasksNamedThread中:

// 核心逻辑如下

while (!Queue(QueueIndex).QuitForReturn)
{
    //...

    // 从队伍中取出Task
    FBaseGraphTask* Task = Queue(QueueIndex).StallQueue.Pop(0, bStallQueueAllowStall);
    
        // 执行任务,会调用到DoTask
        Task->Execute(NewTasks, ENamedThreads::Type(ThreadId | (QueueIndex <<  ENamedThreads::QueueIndexShift)));
}

那么DoTask就会执行那些 通过ENQUEUE_RENDER_COMMAND宏,塞入的宏。

三、垃圾回收

在 虚幻引擎编程基础(一) 中的智能指針部分,提到了虚幻中的内存管理类一般分为两类:

  1. 自动垃圾回收(针对是所有继承UObject的类);
  2. 虚幻的智能指针功能(对于原生的C++类一般用智能指针);

垃圾回收(garbage collection, 缩写GC)是一种自动的内存管理机制。

当一个电脑上的动态内存不需要时,就应该予以释放,这种自动内存的资源管理,称为垃圾回收。垃圾回收可以减少程序员的负担,也能减少程序员犯错的机会。

以下仅仅记录 UE4垃圾回收 提到的结论。

分类:

分类一 引用计数 通过额外的计数来实时计算对单个对象的引用次数,当引用次数为0时回收对象。
引用计数的GC是实时的。像微软COM对象的加减引用值以及C++中的智能指针都是通过引用计数来实现GC的。
追踪式GC(UE4) 达到GC条件时(内存不够用、到达GC间隔时间或者强制GC)通过扫描系统中是否有对象的引用来判断对象是否存活,然后回收无用对象
分类二 保守式GC 并不能准备识别每一个无用的对象(比如在32位程序中的一个4字节的值,它是不能判断出它是一个对象指针或者是一个数字的),但是能保证在不会错误的回收存活的对象的情况下回收一部分无用对象。
保守式GC,不需要额外的数据来支持查找对象的引用,它将所有的内存数据假定为指针,通过一些条件来判定这个指针是否是一个合法的对象。
精确式GC(UE4) 是指在回收过程中能准确得识别和回收每一个无用对象的GC方式。
为了准确识别每一个对象的引用,通过需要一些额外的数据(比如虚幻中的属性UProperty)。
分类三 搬迁式GC 在GC过程中需要移动对象在内存中的位置。
当然移动对象位置后需要将所有引用到这个对象的地方更新到新位置(有的通过句柄来实现、而有的可能需要修改所有引用内存的指针)。
非搬迁式GC(UE4) 在GC过程中不需要移动对象的内存位置
分类四 实时GC 不需要停止用户执行的GC方式
非实时GC(UE4) 需要停止用户程序的执行(stop the world)
分类五 渐进式GC 不会在对象抛弃时立即回收占用的内存资源,而在GC达成一定条件时进行回收操作
非渐进式GC(UE4) 在对象抛弃时立即回收占用的内存资源

UE4采用追踪式、精确式、非搬迁式、非实时、非渐进式的标记清扫(Mark-Sweep)GC算法。

算法分为两个阶段:

  • 标记阶段(GC Mark)
  • 清扫阶段(GC Sweep)

只有被UPROPERTY宏修饰或在AddReferencedObjects函数被手动添加引用的UObject*成员变量,才能被GC识别和追踪。

GC通过这个机制,建立起引用链(Reference Chain)网络。

没有被UPROPERTY宏修饰或在AddReferencedObjects函数被没添加引用的UObject*成员变量,无法被虚幻引擎识别,这些对象不会进入引用链网络,不会影响GC系统工作。

垃圾回收器定时或某些阶段(如:LoadMap、内存较低等)从根节点Root对象开始搜索,从而追踪所有被引用的对象。

当UObject对象没有直接或间接被根节点Root对象引用或被设置为PendingKill状态,就被GC标记成垃圾,并最终被GC回收。

详细地流程可以查看 浅谈UE4的垃圾回收。

四、常用代码块

3.1 蓝图和C++交互

UFUNCTION

如何让C++的函数可以被蓝图调用:

  • BlueprintCallable 关键字
UFUNCTION(BlueprintCallable)
    void OnOverlap(AAcotr* OverlappedActor,	AActor* OtherActor);

如何在C++调用蓝图实现的函数:

  • BlueprintImplementableEvent 关键字
UFUNCTION(BlueprintImplementableEvent)
    void AddDynamicMaterial(UMaterialInstanceDynamic* Mat);

3.2 Json文件的读写

Json文件示例:

  • {…} 表示是一个数据对象
  • […] 表示是一个数组
{
    "Plugins":[
        {
            "name":"/Paper2D"  // "key" : "value"
        },
        {
            "name": "/SteamVR"
        },
        {
            "name": "/OculusVR"
        },
        {
            "name": "/DatasmithContent"
        },
        {
            "name": "/AnimationSharing"
        },
        {
            "name": "/MediaCompositing"
        }
    ]
}

步骤1:添加依赖模块JSon。

虚幻引擎编程基础(二)_第1张图片

步骤2:添加头文件

#include "Json.h"

步骤3:读取字符串转换为JsonObject。

虚幻引擎编程基础(二)_第2张图片

3.3 动态创建材质、控制后处理

动态创建材质:

UMaterialInstanceDynamic* MatInstance = UMaterialInstanceDynamic::Create(Mat, MeshComp);

运行时修改后处理材质:

APostProcessVolume* PPV = Cast(Actors[0]);
if (PPV)
{
    FPostProcessSettings& PostProcessSettings = PPV->Settings;

    if (TestMatIns)
    {
        TestMatInsDyna = UKismetMaterialLibrary::CreateDynamicMaterialInstance(this, TestMatIns);
        FWeightedBlendable WeightedBlendable;
        WeightedBlendable.Object = TestMatInsDyna;
        WeightedBlendable.Weight = 1;

        PostProcessSettings.WeightedBlendables.Array.Add(WeightedBlendable);
    }
}

3.4 对象内存常驻的四种方式

UE对象内存常驻的四种方式(防止GC的办法):

  1. 作为成员变量,并标记为UPROPERTY();

  2. 创建对象后 AddToRoot() ;(退出游戏时需要RemoveFromRoot)

  3. FStreamableManager Load资源时,bManageActiveHandle设置为true;

  4. FGCObjectScopeGuard 在指定代码区域内保持对象;

URPOPERTY()用法

URPOPERTY()
UObject* MyObj;

AddToRoot()用法

UMyObject* MyObj = NewObject();
MyObj.AddToRoot();

FStreamableManager 用法

FSoftObjectPath AssetPath(TEXT("/Game/Mannequin/Animations/ThirdPersonWalk.ThirdPersonWalk"));
FStreamableManager& AssetLoader = UAssetManager::GetStreamableManager();
//hold object in memory.
TSharedPtr Handle = AssetLoader.RequestSyncLoad(AssetPath, true);
UObject* Obj = Handle->GetLoadedAsset();
//free memory of object.
Handle->ReleaseHandle();

FGCObjectScopeGuard 用法

{
   FGCObjectScopeGuard(UObject* GladOS = NewObject<...>(...));
   GladOS->SpawnCell();
   RunGC();
   GladOS->IsStillAlive(); // Object will not be removed by GC
}

3.5 其他

遍历世界的Actor

for (TActorIterator It(GetEditorWorld(), AActor::StaticClass(), Flags); It; ++It)
{
    //...
}

遍历某个Actor上所有的Component

const TSet CompSet = Actor->GetComponents();
for(UActorComponent* UniqueComp : CompSet)
{
    // ...
}

TSubclassOf<> :提供UClass类型安全性的模板类。

Cast<>:类型转换。

参考文章

  • UE4 C++基础教程 - 多线程
  • 虚幻4笔记-渲染线程源码和TaskGraph多线程机制源码实现分析
  • 《Exploring in UE4》多线程机制详解原理分析
  • 虚幻4之TaskGraph:十分钟上手TaskGraph
  • 虚幻4之TaskGraph: 实现Fork-Join模型
  • UE4垃圾回收
  • 浅谈UE4的垃圾回收
  • 对象内存常驻的四种方式
  • UE4 C++读写Json文件
  • 读取Json文件
  • DynamicMaterialInstance
  • Modify Post Process Settings At Run-time

你可能感兴趣的:([UE编程],虚幻)