1.개요
이번엔 컴퓨터 그래픽스에 관한 내용은 아니지만 엔진을 만들어가는 과정중에 하나이기에 DX11이란 카테고리를 부여했습니다.
Lua라는 언어를 개발하던 게임엔진에 연동하는 작업을 하려고 합니다.
그렇다면 이미 C++로 잘 개발되던 엔진에 어째서 Lua가 필요한 것일까요?
오늘 글에서는 Lua스크립트의 필요성과 Lua를 쓰는 이유 그리고 연동과정과 예시를 보여드릴 생각입니다.
2. Lua 스크립트의 필요성
Lua스크립트를 굳이 연동하는 이유는 분명하게 존재합니다.
그 이유를 크게 4개로 분리했습니다.
게임 로직과 엔진 코드 분리
기본적으로 엔진이 돌아가는 로직은 지금까지 DX11과 C++을 이용해서 개발되어 왔습니다.
그러나 우리가 만드는 것은 결국 게임을 개발하는 에디터 입니다.
그러므로 에디터를 사용하는 사람들 입장에서는 엔진코드를 알아야할 필요가 없습니다.
Lua 스크립팅을 이용해서 게임을 개발하면 되는 것입니다.
예시를 들면 유니티로 개발을 한다면 우리 입장에서는 유니티가 내부적으로 돌아가는 코드는 알 필요가 없습니다.
그저 C#을 이용해서 게임을 개발하면 될 뿐이죠
프로그래머가 아닌 디자이너/기획자들의 접근성 향상
Lua라는 언어는 C++에 비해서는 사용하기 간편한 언어이기 때문에 다른 직군의 사람들이
보다 쉽게 접근해서 테스트를 하거나 사용하는 것이 가능합니다.
C++은 메모리 관리, 포인터, 컴파일 과정 등 복잡한 개념이 많지만
Lua는 이러한 부담 없이 간단한 문법으로 로직을 구현할 수 있어 프로그래밍 경험이 적은 팀원들도 빠르게 적응할 수 있습니다.
실제로 많은 게임 회사에서는 기획자들이 Lua를 통해 게임플레이 요소나 간단한 이벤트를 직접 구현하기도 합니다.
런타임에서의 코드 수정 및 반복 개발 시간 단축
Lua 스크립트는 컴파일 과정 없이 인터프리터 방식으로 실행되기 때문에
게임이 실행 중인 상태에서도 코드를 수정하고 즉시 결과를 확인할 수 있습니다.
C++ 코드를 수정할 경우 전체 프로젝트를 다시 컴파일하고 링크해야 하는데
이는 큰 프로젝트에서 수 분에서 수십 분까지 소요될 수 있습니다.
반면 Lua 스크립트는 몇 초 안에 변경사항을 적용하고 테스트할 수 있어
반복 개발 과정에서 시간을 크게 절약할 수 있습니다.
모듈화와 확장성 개선
Lua 스크립팅을 통해 게임 시스템을 모듈화하면 코드의 재사용성과 유지보수성이 크게 향상됩니다.
기능을 독립적인 스크립트로 분리함으로써 한 부분의 변경이 다른 부분에 영향을 미치지 않도록 설계할 수 있습니다. 또한 Lua의 유연한 테이블 구조와 함수형 프로그래밍 기능을 활용하면 플러그인 시스템이나 모드 지원과 같은 확장 기능을 쉽게 구현할 수 있습니다.
이는 게임 출시 후에도 새로운 콘텐츠나 기능을 엔진 코드를 수정하지 않고도 추가할 수 있게 해주어
게임의 수명과 커뮤니티 참여를 늘릴 수 있습니다.
특히 사용자 제작 콘텐츠(UGC)를 지원하는 게임에서는 이러한 모듈화된 스크립팅 시스템이 필수적인 요소가 됩니다.
대표적으로 메이플스토리 월드에서 콘텐츠를 제작할 때 Lua를 사용합니다.
https://maplestoryworlds-creators.nexon.com/ko/docs/?postId=822
MapleStory Worlds Creator Center
메이플스토리 월드의 크리에이터 센터입니다.
maplestoryworlds-creators.nexon.com
3. lua 라이브러리 적용
3-1. vcpkg를 이용해 lib, dll 다운하기
직접 다운받고 빌드하는 방법이 있지만
이 방법은 신경쓸 부분도 많고 실수할여지도 많기에
우리는 vcpkg를 이용해서 다운 받을 것입니다.
물론 이것을 위해서는 git이 있어야합니다.
우선 저장할 임의의 폴더를 만들어줍니다.
저는 Lua라는 이름으로 폴더를 만들었습니다.
그리고 주소창에서 cmd를 검색합니다.
그럼 해당 폴더를 경로로하는 cmd가 열립니다.
https://citylock77.tistory.com/15
윈도우 - 원하는 위치에서 CMD(커맨드) 윈도우 실행하기
윈도우를 사용하다보면 CMD 윈도우를 열어서 특정 폴더로 찾아가야한다. 그런데 CMD 명령어를 실행하게 되면, 항상 유저폴더에서 시작한다는 것이다. 그럼 cd C:\ 부터 시작해서 특정 폴더를 하나
citylock77.tistory.com
이해가 안가시면 여기 참고부탁드립니다.
그리고 아래의 명령어를 순서대로 치면 됩니다.
git clone https://github.com/microsoft/vcpkg
cd vcpkg
bootstrap-vcpkg.bat
vcpkg install lua
그렇게 전부 다운로드 하고나면 vcpkg > installed > x64-windows폴더 안에 dll와 lib이 전부 다운로드 됩니다.
bin폴더에 dll이 있고, lib폴더에 lib이 있습니다.
이것은 release모드에서 쓰일 파일들이고 debug폴더에 가면 debug모드용 파일들이 있습니다.
이제 이 dll와 lib파일을 우리의 엔진 프로젝트에 적용시켜주어야 합니다.
그리고 include폴더에 들어가보면 우리가 필요한 header파일들이 있습니다.
이 파일들 역시 우리의 프로젝트 안으로 옮겨주어야합니다.
3-2. visual studio에서 적용하기
폴더를 하나 만들어서 안에다가 Release와 Debug모드 폴더를 각각 넣어줍니다. (vcpkg통해서 만들어진것)
Release와 Debug안에는 각각 bin과 lib이 있습니다.
그리고 3-1에서 include폴더 역시 같이 옮겨 줍니다.
결론적으로는 이렇게 됩니다.
우선 lib파일부터 링킹해주겠습니다.
프로젝트 > 속성 > 링커 > 일반 > 추가 라이브러리 디렉터리
이곳에서 우리의 lib파일이 있는 폴더위치를 지정해 주어야합니다.
편집을 누르게 되면 경로를 설정할 수 있게 됩니다.
$(SolutionDir)$(ProjectName)을 앞에 붙혀주면 자동으로 현재 프로젝트의 경로가 지정됩니다.
이것을 이용해서 보다 수월하게 경로를 작성할 수 있습니다. (여러분의 폴더 구조에 맞게 적절히 사용하세요)
그리고 저는 lua라는 폴더 아래에 Debug와 Release폴더를 넣었습니다.
여기서 $(Configuration)을 쓰면 현재 모드에 따라서 Debug와 Release값이 자동으로 들어갑니다.
다시말하자면 현재 모드에 따라서 적절한 lib파일의 경로가 지정되는 것이지요
Configuration을 쓰고 싶으시면 Debug, Release 폴더명을 명확히 해주어야 합니다.
저 같은 경우엔 이렇게 지정하였습니다.
아직 끝이 아닙니다.
링커 > 입력 > 추가 종속성에 들어가셔서 lua.lib을 추가해주셔야 합니다.
좋습니다 이제 거의 다 왔습니다.
include파일도 연결을 해주어야 합니다.
프로젝트 > 속성 > C/C++ > 일반 > 추가 포함 디렉터리에 추가
이곳에서 경로를 lua/include로 해주어야 합니다.
이제 정말 마지막 입니다!
dll파일을 적절하게 복사해주어야 합니다.
우리가 프로젝트를 빌드해서 exe로 만들게 되면 해당 프로젝트가 적절하게 돌아가기 위해서는 exe파일이 있는 폴더에
개발에 사용된 dll파일이 있어야 합니다. 그런데 빌드할때마다 이것을 매번 넣어주는 것은 상당히 번거로운 일입니다.
다시 말하자면 별다른 지정을 해주지 않으면 dll파일을 자동으로 빌드후 만들어지는 폴더에 주지 않는 것입니다.
그러면 자동으로 복사시키는 법을 알아 봅시다.
프로젝트 > 속성 >빌드 이벤트 > 빌드 후 이벤트
명령줄에 새로운 명령어를 추가해 줍니다.
xcopy /y /d "dll파일까지가는 경로\lua\$(Configuration)\bin\lua.dll" "$(OutDir)"
어렵게 썼지만 간단하게 정리해 보자면
xcopy /y /d "복사하려는 대상" "복사하고싶은 위치"
이렇게 정리 됩니다.
그리고 /y /d같은 명령어에 대해서도 설명하자면
- /y: 기존 파일을 덮어쓸 때 확인 메시지를 표시하지 않습니다. 즉, 질문 없이 자동으로 덮어씁니다.
- /d[:MM-DD-YYYY]: 소스 파일이 대상 파일보다 새 경우(날짜가 더 최신인 경우)에만 파일을 복사합니다.
날짜를 지정하면 해당 날짜보다 새로운 파일만 복사합니다.
이 두 플래그를 함께 사용하면
- /y /d: 파일이 이미 존재하면 확인 없이 덮어쓰지만, 소스 파일이 대상 파일보다 더 최신일 때만 복사합니다.
이러한 의미입니다.
여기까지 했으면 lib파일도 제대로 연동이 되었고 dll파일도 실행폴더에 자동으로 복사됩니다!
평생 C#, Unity로만 개발을 하다보니 Visual Studio에 새로운 라이브러리를 추가하는 설정들에 많이 미숙해서
공부하는데 많은 시간이 걸렸습니다.
이 글을 보는 여러분은 보다 수월하게 적용하시길 바라겠습니다!
4. Sol2 연동
우리는 sol2라는 라이브러리를 사용할 것입니다.
sol2는 C++와 Lua 사이의 연동을 단순화하는 라이브러리입니다.
sol2는 Lua C API의 복잡성을 추상화하여 현대적인 C++ 인터페이스를 제공합니다.
물론 Sol2를 사용하지 않아도 개발 할수있지만 보다 편하고 쉽게 개발하기 위하여 사용합니다.
git clone https://github.com/ThePhD/sol2
내가 원하는 폴더에서 cmd를 열고 위처럼 입력합니다. (git 설치 필수)
그러면 sol2폴더가 생기게 되는데 해당 폴더를 우리 프로젝트로 옮김니다.
프로젝트 > 속성 > C/C++ > 일반 > 추가 포함 디렉터리에 추가 - sol2/include
sol2 앞의 경로는 적절히 입력하시길 바랍니다.
3번에서 했던 lua/include파일의 경로를 정해주는 것과 같습니다.
그리고 해당 해더파일을 추가하고 빌드를 했을 때 정상적으로 작동하면 성공입니다.
이제 우리는 lua와 sol2를 사용하기 위한 모든 준비를 끝냈습니다.
5. lua 연동 과정
우리는 lua의 가장 기본형태인 template.lua를 만들어 줄것입니다.
이 스크립트는 에디터상에서 개발자가 새로운 스크립트를 생성할 때 template.lua를 복사해서 만들어 줄것입니다.
스크립트를 자동으로 만들어주고 할당하는 부분은 생략합니다.
BeginPlay는 최초 1회 실행되고
EndPlay는 게임이 종료될때 실행되고
Tick은 매 프레임 실행됩니다.
기본 적으로 이 함수들은 전부 C++에서 호출 할 것입니다.
function BeginPlay()
end
function EndPlay()
end
function OnOverlap(OtherActor)
end
function Tick(dt)
end
아래와 같은 과정을 통해서 Lua 스크립트를 불러옵니다.
bool UScriptComponent::LoadScript(const FString& InScriptPath)
{
ScriptPath = InScriptPath;
bIsScriptLoaded = false;
LuaScriptEnv = sol::nil;
sol::state& Lua = *GLuaManager.GetState();
// 1. 개별 스크립트 환경 생성
LuaScriptEnv = sol::environment(Lua, sol::create, Lua.globals());
// 2. 환경에 'self' 변수 설정 - 현재 오브젝트 참조
LuaScriptEnv["self"] = GetOwner();
// 3. 환경 내에서 스크립트 파일 실행
sol::protected_function_result result = Lua.script_file(
*ScriptPath,
LuaScriptEnv,
// 오류 핸들러 (간략화)
[](lua_State*, sol::protected_function_result pfr) { return pfr; }
);
// 4. 성공 여부 설정 및 반환
bIsScriptLoaded = result.valid();
return bIsScriptLoaded;
}
위의 코드를 정리하자면 아래와 같습니다.
1. 독립된 스크립트 환경 생성
- 각 오브젝트에 연결된 스크립트는 자체 sol::environment를 갖습니다
- 이 환경은 전역 Lua 상태에 기반하지만 독립적인 실행 공간을 제공합니다
- LuaScriptEnv = sol::environment(Lua, sol::create, Lua.globals());
2. 자기 참조 설정
- 각 환경에 self 변수를 통해 소유 오브젝트 참조를 제공합니다
- LuaScriptEnv["self"] = GetOwner();
- 이를 통해 스크립트는 "자신이 누구인지" 알 수 있습니다 (Lua스크립트에서 self 키워드를 통해서)
3. 환경 내에서 스크립트 실행
- 스크립트 파일은 해당 환경 내에서만 실행됩니다
- Lua.script_file(*ScriptPath, LuaScriptEnv, ...);
- 이렇게 실행된 스크립트의 모든 변수와 함수는 해당 환경으로 범위가 제한됩니다
(이렇게 설정해주지 않았을때 스크립트끼리 개별적으로 동작하지 않는 문제가 발생합니다.)
그렇게 PIE모드로 들어갈 때 lua파일의 경로에 따라서 자동으로 파일을 불러와 준뒤
BeginPlay > Tick 순서로 실행됩니다.
void UScriptComponent::BeginPlay()
{
Super::BeginPlay();
if (bIsScriptLoaded)
{
CallScriptFunction("BeginPlay");
}
}
void UScriptComponent::TickComponent(const float DeltaTime)
{
Super::TickComponent(DeltaTime);
if (bIsScriptLoaded)
{
CallScriptFunction("Tick", DeltaTime);
}
}
엔진의 Tick이 돌때 이 함수들을 호출해서 DeltaTime을 넘겨주는 방식입니다.
void UScriptComponent::CallScriptFunction(const char* functionName)
{
if (!bIsScriptLoaded || !LuaScriptEnv.valid()) return;
try {
sol::protected_function func = LuaScriptEnv[functionName]; // 환경에서 함수 찾기
if (!func.valid()) {
std::cerr << "Lua function not found in env: " << functionName << std::endl;
return;
}
auto result = func();
if (!result.valid()) {
sol::error err = result;
std::cerr << "Lua function call error (" << functionName << "): " << err.what() << std::endl;
}
}
catch (const sol::error& e) {
std::cerr << "Lua function call exception (" << functionName << "): " << e.what() << std::endl;
}
}
void UScriptComponent::CallScriptFunction(const char* functionName, float value)
{
if (!bIsScriptLoaded || !LuaScriptEnv.valid()) return;
try {
sol::protected_function func = LuaScriptEnv[functionName]; // 환경에서 함수 찾기
if (!func.valid()) return;
auto result = func(value);
if (!result.valid()) {
sol::error err = result;
std::cerr << "Lua function call error (" << functionName << "): " << err.what() << std::endl;
}
}
catch (const sol::error& e) {
std::cerr << "Lua function call exception (" << functionName << "): " << e.what() << std::endl;
}
}
그러면 함수 이름과 인자값이 넘어가서 Lua에서 적절한 함수를 실행시켜줍니다.
6. 연동해서 함수 써보기
function BeginPlay()
end
function EndPlay()
end
function OnOverlap(OtherActor)
end
function Tick(dt)
-- 위치 이동
local currentPos = self:GetActorLocation()
local newPos = FVector.new(
currentPos.X + 3 * dt, -- X축으로 초당 3 단위 이동
currentPos.Y,
currentPos.Z
)
self:SetActorLocation(newPos)
-- 회전 추가
local currentRot = self:GetActorRotation()
local newRot = FRotator.new(
currentRot.Pitch,
currentRot.Yaw , -- Yaw(Z축 기준)으로 초당 30도 회전
currentRot.Roll+ 30 * dt
)
self:SetActorRotation(newRot)
end
위는 Lua에서 작성한 스크립트 입니다
이곳에서는 등록한 함수들을 이용해서 이동과 회전을 하는 오브젝트를 만들고있습니다.
void BindAActor(sol::state& lua)
{
lua.new_usertype<AActor>("AActor",
// 위치 관련 함수
"GetActorLocation", &AActor::GetActorLocation,
"SetActorLocation", &AActor::SetActorLocation,
// 회전 관련 함수
"GetActorRotation", &AActor::GetActorRotation,
"SetActorRotation", &AActor::SetActorRotation
);
}
void BindFRotator(sol::state& lua)
{
lua.new_usertype<FRotator>("FRotator",
sol::constructors
FRotator(), // 기본 생성자
FRotator(float, float, float) // Pitch, Yaw, Roll 지정 생성자
>(),
// 주요 속성들
"Pitch", &FRotator::Pitch, // X축 회전 (상하)
"Yaw", &FRotator::Yaw, // Z축 회전 (좌우)
"Roll", &FRotator::Roll // Y축 회전 (기울기)
);
}
이렇게 함수를 사용하기 위해서는 C++에서 다음과 같이 등록해주어야 합니다.
C++에서 만들어둔 Actor의 위치와 회전을 바꾸는 함수를 만들어두고 사용해야 합니다.
Rotator와 Pitch,Yaw,Roll모두 마찬가지 입니다.
결국 Lua에서는 C++에서 어떠한 코드가 있는지 모르는 상태이기 때문에 모두 바인딩작업을 통해서
Lua에게 알려주어야 합니다.
이러한 방식으로 Lua와의 연동작업은 방법만 한번 숙지하면 반복 노동에 가깝습니다.
그리고 그렇게 등록한 함수를 Lua스크립트에서 적절하게 사용해서 게임을 구현할 수 있습니다.
카메라 전환 효과, 코루틴(개발 했습니다), Sound를 적절히 사용하면 아래와 같이 개발이 가능합니다.
컷씬에 대한 코딩은 전부 Lua스크립트에서 구현하였습니다.
https://www.youtube.com/watch?v=2zj0dunpUPk
'DirectX11' 카테고리의 다른 글
[DirectX11] FBX, CPU Skinning (0) | 2025.05.09 |
---|---|
[DirectX11] FBX SDK적용, 컴파일 옵션 /Md /Mt (0) | 2025.05.07 |
[DX11] PCF (Percentage Closer Filtering) (0) | 2025.04.24 |
[DX11] Renderdoc 사용법 CubeMap 디버깅 (0) | 2025.04.23 |
[DX11] Point Light Shadow - Peter Panning, Shadow Acne (2) | 2025.04.23 |