본문 바로가기

Projects/zig game engine project

2024-10-08 UPDATE #1 vulkan 관련 오류 해결 + 멀티 스레드, 그래픽 구현 부분 리펙토링 등

안녕하세요, 일기 형식으로 써봤습니다.

 

zig game engine project를 개발하면서 이제 2D 그래픽 기능은 어느정도 완성된 상태에서 일지를 처음 쓰는데 이름은 그냥 우연히 zig를 한 몇달전 처음 접하게 되었는데 게임 엔진에 사용할 언어를 zig로 정해서 그렇게 지었습니다.

 

뭐 그래서 zig가 아니어도 될수도 있지만 현재로써는 아쉬운점이 살짝 있어도 zig가 만족스럽네요.

 

암튼 이번에 수정한 내용은 제가 한 2~3일동안 계속 고민하다가 제대로 된 커밋도 못하고 그래서 지금 제 머리속에서도 잘 정리가 안돼 있는데..ㅎㅎ 크게 나눠보면

 

1. Vulkan 관련 디바이스 로스트 오류 해결

2. 멀티 스레드 구현, 안정화

3. 윈도우 사이즈 조절할때 동작 구현

4. 안드로이드 환경에서 화면 회전할 때 동작 구현

5. 그래픽 구현 부분(Shape, Image) 표시 부분을 리펙토링

 

등등이.. 있습니다. 좀 많긴한데 차례차례 설명해볼게요.

 

1. Vulkan 관련 디바이스 로스트 오류 해결

이거는 제가 제일 머리 싸매고 오래 고민한건데 생각보다 쉽게(어이없게..) 해결 했습니다. 

원래 제 코드 렌더링 구조가 오브젝트(위에 말했던 Shape,Image 등)를 만들고 그거에 대한 한번 커멘드 버퍼(이하 커멘드)를 제출한 후에 사용자가 업데이트 하거나 화면 크기를 조절할때 (-> 화면 크기가 조절되면 스왑체인/프레임 버퍼를 재생성해야 되서 커멘드도 다시 제출해야 합니다.) 마다 제출해주는데.

 

그러니까 간단하게 말해서 커멘드 버퍼를 한번 제출한 뒤에 가능한한 재사용하는 목적을 두고 있는 겁니다. 그래서 오브젝트도 새로 추가 제거 되지 않는 한 오브젝트 내의 좌표(Uniform Buffer) 나 리소스(Vertex, index, Image 등) 내용이 바뀌어도 따로 커멘드를 업데이트하지 않아도 됩니다. 다만 Push Constants는 커멘드에 제출할때는 제출한 값 그대로 쓰이기 때문에 이때는 값이 바뀌면 반영되지 않게 됩니다.

 

여튼 제가 왜 이 얘기를 하나면 저는 화면 크기를 조절해서 스왑체인을 다시 생성했을때 커멘드를 다시 제출해야하니까 다음과 같이 코드를 짰었습니다.

if (@atomicLoad(bool, &graphics.render_cmd.?.*.__refesh, .monotonic)) {//사용자가 새로고침했을때
    @atomicStore(bool, &graphics.render_cmd.?.*.__refesh, false, .monotonic);
    recordCommandBuffer(graphics.render_cmd.?);//다시 제출
} else if (graphics.render_cmd.?.*.__refresh_framebuffer != vk_swapchain_frame_buffers[0]) {//현재 스왑체인하고 커멘드에 저장된 스왑체인하고 다르면
    graphics.render_cmd.?.*.__refresh_framebuffer = vk_swapchain_frame_buffers[0];//재설정
    recordCommandBuffer(graphics.render_cmd.?);//다시 제출
}

여기서 else if 부분이 문제인데 vk_swapchain_frame_buffers[0]값이 스왑체인을 다시 생성하면 바뀌(일수 있기...)기 때문에 바뀐걸 확인하고 다시 새로 고침할수 있습니다.

라고.. 생각했지만 이게 아무래도 다시 생성해도 vk_swapchain_frame_buffers[0]값이 그대로일수도 있는거 같습니다.

왜냐하면 vk_swapchain_frame_buffers[0] 값 내용은 실체가 아닌 주소일 뿐이고 결국 Vulkan이 맘대로 저한테 주소를 주는 거기 때문에 같은 주소를 줄수도 있다는 거겠죠.

 

그래서 지금보면 참 무모하고 위험한 코드지만 그때 당시엔 그냥 대충짜고 넘어갔언지어라 이게 또 문제인게 이렇게 짜도 바로 문제가 안나오고 일정확률로, 심지어 Release모드에서만 정체를 알수 없는 디바이스 로스트가 나오면서 꺼지니 당시에는 귀신이 곡할 노릇이었네요..ㅋㅋ

 

어찌됬든 원인을 겨우 파악하고 다음과 같이 전역 변수(리스트)를 만들어서 커멘드를 생성할때마다 리스트에 추가하고 사이즈가 변경되었을때 한꺼번에 새로고침할수 있게 개선했습니다.

 

//else if 부분 제거 __refresh_framebuffer 사용안함
if (@atomicLoad(bool, &graphics.render_cmd.?.*.__refesh, .monotonic)) {
   @atomicStore(bool, &graphics.render_cmd.?.*.__refesh, false, .monotonic);
   recordCommandBuffer(graphics.render_cmd.?);
}
//사이즈 변경될때 마다 호출
pub fn refresh_all() void {
    mutex.lock();//render_cmd_list에 접근시 뮤텍스로 보호
    for (render_cmd_list.items) |cmd| {//render_cmd_list는 전역 리스트
        cmd.*.refresh();
    }
    mutex.unlock();
}
//커멘드 객체 생성
...
__render_command.mutex.lock();
__render_command.render_cmd_list.append(self) catch system.handle_error_msg2(" render_cmd_list.append(&self)");
__render_command.mutex.unlock();
...
//커멘드 객체 제거
...
var i: usize = 0;
__render_command.mutex.lock();
 while (i < __render_command.render_cmd_list.items.len) : (i += 1) {
     if (__render_command.render_cmd_list.items[i] == self) {
         _ = __render_command.render_cmd_list.orderedRemove(i);
         break;
     }
 }
 __render_command.mutex.unlock();
...

 

2. 멀티 스레드 구현, 안정화

멀티스레드라고 해봐야 아직 렌더링을 스레드 별로 나눈건 아니고 사용자가 다른 스레드에서 업데이트를 할때(예 : 버퍼의 값 변경) 충돌되지 않게 구현하고 테스트해봤습니다. 커멘드같은경우 현재는 렌더링시 렌더링 스레드에서만 필요에 따라 제출하도록 짜여 있는데 사용자가 별도의 스레드에서 커멘드를 제출할수도 있도록 개선할수도 있을것 같습니다.

 

그리고 멀티스레드를 사용할때 서로 다른 스레드에서 같은 메모리 객체 VkDeviceMemory를(당연히 그 VkDeviceMemory를 통해 바인딩된 리소스) 접근할수 없기(안되기) 때문에 저는 스레드마다 다른 VkDeviceMemory 할당자(__vulkan_allocator.zig)를 사용하는 방식으로 구현했습니다. 수정: 그냥 이렇게 하지 말고 하나의 스레드에서만 최종적으로 할당 함수가 호출되게 비동기로 구현하는게 낫습니다. 추후에 더 자세히 정리하겠습니다. 

 

3. 윈도우 사이즈 조절할때 동작 구현

윈도우즈 프로그램 특성이 윈도우 사이즈 크기를 조절하는 동안 메세지 루프에서 상주하기 때문에 렌더링 코드가 실행되지 못해서 화면 멈춤 현상이 나타납니다.

그래서 저는 렌더링하는 스레드를 따로 둬 가지고 어느 정도 해결하긴 했습니다. 다만 윈도우 크기를 조절할때 화면이 멈추지 않지만 끊기는 현상이 발생하는데 이게 윈도우 사이즈를 조절한다고 Vulkan쪽이 바로바로 그 크기를 실시간으로 아는게 아니라서 바로 알려주게 하고 싶으면 WM_SIZE 메세지가 발생했을때 그 변경한 크기에 맞추어 스왑체인을 다시 생성하면 되긴 합니다.

그렇지만 아무래도 스레드 끼리 통신하는 방식으로 구현해서 그런지 그렇게 하면 크기 조절하는게 렉이 걸리는 현상이 발생해서 일단은 Vulkan이 알아서 감지하도록 했네요.. 그래도 창을 끌어서 옮기는 동안 게임이 멈추지 않기 때문에 나름 만족스럽습니다.ㅎㅎ

 

4. 안드로이드 환경에서 화면 회전할 때 동작 구현

이거는 안드로이드 레퍼런스 페이지 여기 Vulkan 사전 회전으로 기기 방향 처리  |  Android game development  |  Android Developers  에 잘 설명되어 있어서 편하게 하긴 했는데 표시되는 모든 객체를 화면 방향에 맞추어 회전해야 되기 때문에 화면 회전 행렬을 따로 만들어서 셰이더 좌표 계산시 같이 사용하는것이 편합니다.

 

5. 그래픽 구현 부분(Shape, Image) 표시 부분을 리펙토링

원래는 Image하고 Shape 객체를 두 객체의 함수들의 포인터(함수 포인터)를 받는 iobject 인터페이스 객체를 만들어 사용했는데 굳이 그렇게 안하고 union(enum) 형식으로 iobject 내에서 Image, Shape 를 선택하여 사용하는것이 훨씬 간단한거 같아 그렇게 바꿨습니다. 그리고 공통적인 함수를 iobject에서 선언하여 다음과 같이 사용할 수 있는데,

pub const iobject = union(enum) {
    const Self = @This();
    _shape: shape,
    _image: image,

    pub inline fn deinit(self: *Self) void {
        switch (self.*) {
            inline else => |*case| case.*.deinit(),//모든 self.*의 그밖에(지금 경우는 모두) 현재 객체에 대한 deinit 호출
        }
    }
    pub inline fn build(self: *Self) void {
        switch (self.*) {
            inline else => |*case| case.*.build(),
        }
    }
    pub inline fn update(self: *Self) void {
        switch (self.*) {
            inline else => |*case| case.*.update(),
        }
    }
    pub inline fn __draw(self: *Self, cmd: vk.VkCommandBuffer) void {
        switch (self.*) {
            inline else => |*case| case.*.__draw(cmd),
        }
    }
};

보다시피 switch 문 내에 inline else 라는 신기한 문법이 있는데 저도 아직은 저거에 대해 잘 모르지만 위의 예제 경우는 iobject가 _shape 면  shape.deinit();, _image면 image.deinit();를 선택하여 호출하는 코드 입니다. ( _shape ,_image 두개 밖에 없으니) 그러니 shape, image에 모두 deinit가 구현이 되 있어야합니다.

 

그래서 저 iobject를 사용하려면 var text_shape = .{ ._shape = .{} };식으로 초기화해서 text_shape._shape... 식으로 접근해서 편리하게 사용할 수 있는것이 좋았습니다.

 

 

이것들 말고 한것들이 더 있긴 하지만 글이 너무 길어지고 쓰는 시간도 오래 걸려서.. 일단은 여기서 줄이겠습니다. 감사합니다.