NodeJS C++ addon 바이너리 데이터 넘기기 삽질기

C++ | 2014. 7. 8. 15:33
Posted by 클라리넷

목표 : NodeJS C++ addon을 붙여서 네트워크 통신은 NodeJS가 담당하고 게임 로직은 C++이 담당하는 것 
통신 방식 : ProtoBuf + socket.io


삽질기 

0. 자동화 작업 
.proto 파일을 통한 Auto generated 코드를 만들어서 통신하는 게 프로토버프의 기본. 
근데 이 .proto 파일 하나를 C++/NodeJS/브라우저 세 곳에서 써야되기에, 
파일을 한 군데서 작성하고, 나머지 위치에 복사해주는 방식을 택했다. 

모든 빌드의 중심은 Visual Studio. 
Visual Studio에서 prebuild, postbuild event로 스크립트를 돌리고, 
거기서 protobuf C++ 코드 자동 생성도 하고, node 확장자 생성도 하고, .proto 파일 복사도 하고, 다 한다. 

1. 클라 -> 서버 
ProtoBuf.js를 이용해서 NodeJS/브라우저에서 .proto 파일을 파싱하고, 
그 파싱한 정보로 decode/encode를 한다. 
여기서 가장 효율이 좋은 것은 ArrayBuffer 통한 바이너리 형태의 데이터. 
근데 문제는 socket.io는 바이너리 데이터의 전송을 지원하지 않는다. 
문자열 온리...... 

찾아보면 socket.io로 바이너리 데이터를 보내고 받는 플러그인도 있으나, 
귀찮아서(..) 그냥 hex 데이터를 보내고 받기로 했다. 
ProtoBuf.js에서 base64, hex, AB(이게 뭐지), binary 네 가지 형태의 암호화 방식을 지원하고 있어서 가능한 방법이다. 

어쨌든 브라우저는 패킷을 encodeHex로 보내고, 
NodeJS는 그걸 받아서 decodeHex를 한 후, 
그걸 다시 binary로 encode해서 C++ addon에 넘긴다. 
C++ protobuf에서는 binary 데이터가 아니면 읽을 수 없기에..... 
비효율적이긴 하지만, 나중에 브라우저 - NodeJS 패킷 통신을 바이너리로 직접 하게 바꾸면 해결되는 문제라 그냥 냅뒀다. 

C++에 넘겨줄 때 패킷의 enum 값을 같이 넘겨주고, 그 enum 값에 따라 적절한 protobuf message 클래스를 이용해서 
직렬화하면 완성! 나머지는 로직의 영역이다. 

2. 서버 -> 클라 
간단할줄 알았으나 내 코딩 시간을 며칠이나 잡아먹은 원흉 되시겠다. 
일단 며칠 간의 삽질을 단 5분 만에 끝내도록 도와주신 최모군(28세, 백수)님에게 감사의 말을 올립니다. 

C++ addon에서 NodeJS로 바이너리와 패킷 enum 값을 보내면 
NodeJS에서는 그걸 encodeHex 해서 브라우저로 보내는 방식이다. 
브라우저는 decodeHex 하면 되고. 

문제가 두 가지가 있었다. 
첫 번째는 NodeJS에서 C++로 넘겨준 자바스크립트 콜백 함수가 계속 증발하는 문제, 
두 번째는 바이너리 데이터를 C++에서 NodeJS로 넘기는 문제였다. 

첫 번째 문제가 며칠을 잡아먹은 문제다. 
일단 C++ addon에서 SetSendFunction을 통해 콜백을 받고, 
그 콜백을 멤버 변수로 저정해둔다. 
그리고 나중에 Send를 할 때 그 콜백에 인자를 적절하게 넘기고 호출한다. 

여기서 발생한 현상이, SetSendFunction으로 콜백을 저장하면 
SetSendFunction 내부에서는 callback->IsFunction() 이 true로 나오지만, 
나중에 Send 할 때 확인해보면 false가 나온다. 

이게 도대체 왜 이러는지 알 수가 없어서, 구글링도 해보고, 
gc가 알지 못하고 걍 죽여버리는 게 원인인가 싶어서(결국 이게 맞긴 했지만..) 
NodeJS 쪽에서 로컬 변수로 함수를 만들고, 그 변수를 대입하는 방식도 써봤고, 
C++ nodeJS addon을 만들어본 친구가 하필 일본에 가서 좌절도 해봤고, 
진짜 온갖 꼼수 및 디버깅을 해봤지만 해결을 할 수 없었다. 

열심히 삽질을 하고 있는 와중에, 잉챗에서 열심히 불평을 하고 있으려니까 
최모군(28세, 백수)님이 보우하사, Local 대신 Persistent를 써보라고 말해주셨고, 
Local<T> ->Persistent<T>로 못 바꾸고 고생하고 있자 친히 구글링을 하셔셔 stackoverflow 문서도 찾아주셨다. 
그리고 해결이 되었다. 

검색을 해본 결과, Persistent로 사용하면 v8 gc 관리 대상에 들어가고, 
Local로 하면 안 들어간다고 한다. 
그래서 Local로 저장해두면 나중에 gc가 회수해버려서 사라져버렸던 것이다. 
어쨌든 Persistent로 콜백 저장 문제는 해결했다. 

두 번째 문제는, 자바스크립트에서 binary data를 받을 때는 typed array를 쓰는데, 
이걸 어떻게 C++에서 생성해야 하나... 라는 고민이었는데, 
다행히도 NodeJS C++ 코드에서 node::Buffer라는 클래스를 제공해준다. 
이걸 쓰면 raw data를 NodeJS 단으로 쉽게 넘길 수 있다. 
해결 완료. 

3. 결론 
hex 값 부분만 일단 떼놓고 보면, C++/NodeJS/브라우저 간 protobuf를 이용한 통신을 구축 완료하였다. 
적절한 자동화와 prebuild/postscript를 거치면 당신도 이제 C++과 Javascrip를 이용한 서버/클라이언트 환경을 구축할 수 있다. 
도전해보세요! 

사실 다른 거 다 필요없고 자바스크립트 변수(특히 콜백 함수)를 C++ 측에 저장할 때는 
Local이 아니라 Persistent를 써야한다는 것만 기억하면 된다... 제길.

'C++' 카테고리의 다른 글

람다  (0) 2013.06.13
 

람다

C++ | 2013. 6. 13. 01:37
Posted by 클라리넷

람다는 C++11에 새로 등장한 문법이다.

기존에 클래스를 이용해서 만들어 쓰던 함수 객체(Functor)를 문법에 추가한 것이다.

int a = 3;
int b = 4;

auto test = [&a, b] (int c) -> int
{
    a = a + b + c;

    return c;
}

std::cout<<test(3)<<std::endl;
std::cout<<a<<std::endl;

출력

3
10

문법

[] () {}

세 쌍의 괄호들로 이루어진다.


1. []

변수를 캡쳐하는 부분이다.

람다가 정의되는 부분에서 접근 가능한 변수들을 저장해놓는 역할을 한다.

람다는 std::function이나 템플릿 유추 등을 통해 함수 포인터로써 기능하는 경우가 많다.

[]을 통해서 변수를 캡쳐하게 되면, 캡쳐하는 순간의 그 변수의 정보를 람다 함수가 들고 있게 된다.


캡쳐를 한다는 것은, 람다 객체의 멤버 변수로 넣겠다는 말과 같다.

위에서 적었다시피, 람다는 Functor를 문법으로 만든 것이고,

원래 Functor는 클래스로 만들었다.

Functor를 쓸 때 외부 변수가 필요할 경우, 멤버 변수에 값을 넣어둔 다음에

그 멤버 변수의 값을 사용했다.

그 부분이 람다에서는 캡쳐이다.

외부의 변수를 람다 내부에서 사용할 수 있도록 하는 것이다.



캡쳐에는 2종류가 있다.

값을 캡쳐하는 것과 주소를 캡쳐하는 것.

int a = 3;

이런 변수가 있을 때,


[a] - a의 값을 캡쳐한다.

[&a] a의 주소를 캡쳐한다.


a의 값을 캡쳐하는 경우,

a는 const 속성이 붙게 된다.

값을 변경할 수 없게 되는 것이다.

뒤에 적을 mutable 키워드를 붙이면 값의 변경은 가능하지만,

람다 안에서 a의 값을 바꾸더라도

람다 밖의 원본 a의 값은 전혀 변하지 않는다.

하지만 람다 안에서의 a는 바뀐다.

위에서 말했다시피, 람다에서 캡쳐는 멤버 변수의 역할을 한다.

외부 변수의 값을 가져온 다음 아무리 바꿔봤자 외부 변수는 바뀌지 않는 것과 같다.

int a = 3;

auto lambda = [a]() mutable
{
    ++a;
    std::cout<<a<<std::endl;
}

lambda();
lambda();

이 코드를 실행하면,

4
5

이렇게 출력이 된다.




a의 주소를 캡쳐하는 경우,

람다 안에서 a를 사용하는 것은 캡쳐된 변수 a의 레퍼런스를 사용하는 것이 된다.

이 때 주의해야 할 것은,

레퍼런스로 캡쳐할 경우, 람다가 실제로 수행될 때 그 원본 변수가 제대로 살아있는지 여부를 확인해야 한다.

Dangling pointer를 주의해야 하는 것이다.


예를 들어, 어떤 함수 안에서 선언된 변수를 레퍼런스로 캡쳐한 후,

그 함수를 빠져나간 다음에 람다가 실행되게 되면

람다는 잘못된 메모리에 접근하는 것이 된다.



캡쳐할 때, 기본 캡쳐 방식을 지정할 수 있다.

[=] 이렇게 쓰게 되면, 모든 외부 변수를 값으로 캡쳐하고,

[&] 이렇게 쓰게 되면, 모든 외부 변수를 주소로 캡쳐하게 된다.

기본 캡쳐 방식을 지정한 후에, 기본 캡쳐 방식과 다른 방식으로 캡쳐하고 싶은 변수들을 따로 캡쳐할 수 있다.

[=, &a, &b, &c] 이런 식이나,

[&, a, b, c] 이런 식으로.

기본 캡쳐 방식과 같은 방식의 추가 캡쳐는 못 한다.( [=, a] , [&, &b])



특수한 상황이 한 가지 있다.

클래스 안에서 선언된 람다의 경우,

멤버 변수는 캡쳐할 수 없다.

대신 this 포인터를 값으로 캡쳐해서 사용할 수 있다.

this는 주소로 캡쳐할 수 없다.


당연하지만, 아무것도 캡쳐하고 싶지 않을 경우

그냥 []를 쓰면 된다.



2. ()

일반적인 함수의 인자를 받는 것과 똑같다.

std::function<void(int)> print = [](int a)
{
    std::cout<<a;
});

print(4);


이러면 4를 출력한다.


<algorithm> 헤더에 있는 함수들 중

인자로 함수 포인터 또는 Functor를 받는 함수가 있다.

std::for_each 가 대표적인데,

첫 번째 인자와 두 번째 인자로 자료구조의 시작과 끝을 받고,

세 번째 인자로 함수 포인터나 Functor를 받는데,

그 함수의 인자는 첫 번째와 두 번째로 받은 자료구조의 자료형이어야 한다.

std::vector<int> container;

std::for_each(container.begin(), container.end(), [](int value)
{
    std::cout<<value;
}

3. {}

일반적인 함수에서의 역할과 같다.

람다 함수의 본문을 넣는 곳이다.


4. 리턴 타입

람다도 함수이니만큼, 리턴을 할 수 있다.

리턴은 두 가지 방식으로 한다.


1) 암시적 리턴

람다 본문 내부에서 return 키워드가 한 개만 쓰일 경우,

람다의 리턴 타입은 리턴하는 값/변수의 타입이 된다.

int 변수를 리턴할 경우 리턴 타입은 int이고,

Widget* 변수를 리턴할 경우 Widget*,

true를 리턴할 경우 bool이 되는 식이다.


2) 명시적 리턴

[] () -> 리턴 타입
{
    함수본문
}

이런 식으로 리턴 타입을 명시적으로 나타낼 수 있다.

[] () -> int { return 3.0f; }

이렇게 할 경우, 일반적인 함수에서와 같이

3.0f가 int로 형변환 되서 리턴이 된다.




리턴 문이 2개 이상일 경우,

그 어떤 경우에도 암시적 리턴을 쓸 수 없다.

반드시 명시적 리턴을 써야 한다.

if(조건)
    return false;
else
    return true;

이런 식으로 모든 리턴문의 리턴 타입을 같게 해주었더라도,

명시적 리턴을 사용해야 한다.


5. 키워드

함수에서 () 다음에 const, override 같은 키워드를 붙이듯이,

람다에서도 () 다음에 키워드를 붙일 수 있다.

mutable, exception, attribute 이렇게 세 개의 키워드를 쓸 수 있다.


mutable

값으로 캡쳐한 변수의 경우, 기본적으로 const로 취급이 된다.

mutable을 붙일 경우, const로 취급하지 않는다.


exception과 attribute는 각각 C++의 예외 처리 문법, 그리고 attribute 키워드에 대응한다.

이 쪽은 잘 모르는 데다가, C++ 기본 문법과 똑같은 역할을 한다고 하니 패스...



'C++' 카테고리의 다른 글

NodeJS C++ addon 바이너리 데이터 넘기기 삽질기  (0) 2014.07.08
 

모델링 & 텍스쳐 정보 로딩 시점?

게임 개발 | 2012. 12. 18. 01:04
Posted by 클라리넷

3D 게임이라면, 모델링과 텍스쳐를 사용할 것이다.

이건 당연한 건데...

요지는, 모델링과 텍스쳐를 불러와서 보여줄 때

어떻게 하면 효율적으로 할 수 있는가 이다.


1. 미리 불러오기

기본적으로, 그래픽 데이터들은

'로딩' 시점에 불러오는 방식이 제일 많이 쓰인다.


어떤 물체에 대해서 모델링과 텍스쳐 정보가 필요한 시점에서

그 데이터를 메모리에 올리기 시작한다면,

올린 시점~실제로 다 올라간 시점 사이에서는

물체는 보이지 않을 것이다.

물론 올리는 시간이 1프레임 이내로 끝난다면 상관없겠지만,

그건 현재 컴퓨터의 사양으로는 불가능하다.


그렇기 때문에, 어떤 모델링 또는 텍스쳐 정보가 필요할 거 같다 싶은 지점이 오기 전에

로딩 화면이 있고, 그 로딩화면에서 데이터를 메모리에 올리는 방식을 사용하는 게 일반적이다.

게임에 따라 다르지만

 맵 이동이 있는 게임은 맵 사이사이에서 로드할 것이고,

스테이지 단위로 되어있는 게임은 스테이지 시작할 때 로드할 것이고,

심리스 맵으로 되어 있는 구조고, 맵이 매우 크다면,

전체 맵을 적절하게 나눠서,

다음 구역으로 넘어가기 전에 해당 구역의 정보를 불러올 것이다.


적당한 시점을 선택해서,

그 시점 이후로 필요한 데이터를 불러오면 된다.


2. 실시간

온라인 3D MMORPG에서 다른 사람 캐릭터가 주변에 지나갈 때,

그 캐릭터의 모델 & 텍스쳐 정보를 불러와야 한다.


그런데 위에 써놨듯이, 옆에 지나가는 캐릭터를

그 순간 로딩해서 보여주는 건 불가능하다.

따라서, 일정 거리에 들어왔을 때 불러오기 시작하는 방법이 많이 쓰인다.

또는, 마비노기 영웅전에서 사용하는 후드 같은

적은 폴리곤 수/적은 메모리를 먹는 텍스쳐를 보여준 후,

실제 장비/외모 정보를 불러오기 시작하는 방법도 있다.

(마영전은 MMORPG는 아니지만...)

 

블로그 이미지

클라리넷

카테고리

Vie (12)
(6)
C++ (2)
게임 개발 (1)
게임하기 (0)
엔진 (0)
전자기기 (3)
Deep Learning (0)