Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

멀티스레드 사용하기

이 튜토리얼에서는 최소 컴퓨팅 셰이더를 생성하는 과정을 안내합니다. 하지만 먼저, 컴퓨팅 셰이더에 대한 약간의 배경 지식과 이것이 Godot와 어떻게 작동하는지 알아보겠습니다.

참고

이 튜토리얼에서는 사용자가 일반적으로 셰이더에 익숙하다고 가정합니다. 셰이더를 처음 사용하는 경우 이 튜토리얼을 진행하기 전에 셰이더 소개 및 :ref:`첫 번째 셰이더 <toc-your-first-셰이더>`을 읽어보세요.

컴퓨팅 셰이더는 범용 프로그래밍을 지향하는 특별한 유형의 셰이더 프로그램입니다. 즉, 고정된 목적(예: 정점 변환 또는 이미지에 색상 쓰기)이 없기 때문에 정점 셰이더 및 조각 셰이더보다 더 유연합니다. 조각 셰이더 및 꼭짓점 셰이더와 달리 컴퓨팅 셰이더는 백그라운드에서 진행되는 작업이 거의 없습니다. 여러분이 작성하는 코드는 GPU가 실행하는 코드일 뿐이며 그 외에는 거의 없습니다. 이는 무거운 계산을 GPU로 오프로드하는 데 매우 유용한 도구가 될 수 있습니다.

이제 짧은 컴퓨팅 셰이더를 만들어 시작해 보겠습니다.

먼저 선택한 외부 텍스트 편집기에서 프로젝트 폴더에 ``compute_example.glsl``라는 새 파일을 만듭니다. Godot에서 계산 셰이더를 작성할 때 GLSL에 직접 작성합니다. Godot 셰이더 언어는 GLSL을 기반으로 합니다. Godot의 일반 셰이더에 익숙하다면 아래 구문이 다소 익숙해 보일 것입니다.

참고

Compute 셰이더는 RenderingDevice 기반 렌더러(Forward+ 또는 모바일 렌더러)에서만 사용할 수 있습니다. 이 튜토리얼을 진행하려면 Forward+ 또는 모바일 렌더러를 사용하고 있는지 확인하세요. 편집기의 오른쪽 상단에 있는 설정입니다.

기술적으로 지원되더라도 모바일 장치에서는 일반적으로 컴퓨팅 셰이더 지원이 좋지 않습니다(드라이버 버그로 인해).

각각이 하는 일을 살펴보겠습니다:

#[compute]
#version 450

// Invocations in the (x, y, z) dimension
layout(local_size_x = 2, local_size_y = 1, local_size_z = 1) in;

// A binding to the buffer we create in our script
layout(set = 0, binding = 0, std430) restrict buffer MyDataBuffer {
    float data[];
}
my_data_buffer;

// The code we want to execute in each invocation
void main() {
    // gl_GlobalInvocationID.x uniquely identifies this invocation across all work groups
    my_data_buffer.data[gl_GlobalInvocationID.x] *= 2.0;
}

이 코드는 부동 소수점 배열을 가져와서 각 요소에 2를 곱하고 결과를 다시 버퍼 배열에 저장합니다. 이제 한 줄씩 살펴보겠습니다.

#[compute]
#version 450

이 두 줄은 두 가지를 전달합니다.

  1. The following code is a compute 셰이더. 이것은 편집자가 셰이더 파일을 올바르게 가져오기 위해 필요한 Godot 관련 힌트입니다.

  2. 코드는 GLSL 버전 450을 사용하고 있습니다.

사용자 지정 컴퓨팅 셰이더에 대해 이 두 줄을 변경할 필요가 없습니다.

// Invocations in the (x, y, z) dimension
layout(local_size_x = 2, local_size_y = 1, local_size_z = 1) in;

다음으로 각 작업 그룹에서 사용할 호출 수를 전달합니다. 호출은 동일한 작업 그룹 내에서 실행되는 셰이더의 인스턴스입니다. CPU에서 컴퓨팅 셰이더를 시작하면 실행할 작업 그룹 수를 알려줍니다. 작업 그룹은 서로 병렬로 실행됩니다. 하나의 작업그룹을 실행하는 동안 다른 작업그룹의 정보에 접근할 수 없습니다. 그러나 동일한 작업 그룹의 호출은 다른 호출에 대해 일부 제한적인 액세스를 가질 수 있습니다.

작업 그룹과 호출을 거대한 중첩 for 루프로 생각해보세요.

for (int x = 0; x < workgroup_size_x; x++) {
  for (int y = 0; y < workgroup_size_y; y++) {
     for (int z = 0; z < workgroup_size_z; z++) {
        // Each workgroup runs independently and in parallel.
        for (int local_x = 0; local_x < invocation_size_x; local_x++) {
           for (int local_y = 0; local_y < invocation_size_y; local_y++) {
              for (int local_z = 0; local_z < invocation_size_z; local_z++) {
                 // Compute shader runs here.
              }
           }
        }
     }
  }
}

작업 그룹 및 호출은 고급 주제입니다. 지금은 작업 그룹당 두 번의 호출을 실행한다는 점을 기억하세요.

// A binding to the buffer we create in our script
layout(set = 0, binding = 0, std430) restrict buffer MyDataBuffer {
    float data[];
}
my_data_buffer;

여기에서는 컴퓨팅 셰이더가 액세스할 수 있는 메모리에 대한 정보를 제공합니다. layout 속성을 사용하면 버퍼를 찾을 위치를 셰이더에 알릴 수 있습니다. 나중에 CPU 측에서 이러한 setbinding 위치를 일치시켜야 합니다.

restrict 키워드는 이 버퍼가 이 셰이더의 한 위치에서만 액세스될 것임을 셰이더에 알려줍니다. 즉, 이 버퍼를 다른 set 또는 binding 인덱스에 바인딩하지 않습니다. 이는 셰이더 컴파일러가 셰이더 코드를 최적화할 수 있도록 해주기 때문에 중요합니다. 가능하면 항상 ``restrict``를 사용하세요.

이것은 크기가 지정되지 않은 버퍼입니다. 즉, 어떤 크기라도 될 수 있습니다. 따라서 버퍼 크기보다 큰 인덱스를 읽지 않도록 주의해야 합니다.

// The code we want to execute in each invocation
void main() {
    // gl_GlobalInvocationID.x uniquely identifies this invocation across all work groups
    my_data_buffer.data[gl_GlobalInvocationID.x] *= 2.0;
}

마지막으로 모든 논리가 발생하는 main 함수를 작성합니다. gl_GlobalInvocationID 내장 변수를 사용하여 스토리지 버퍼의 위치에 액세스합니다. ``gl_GlobalInvocationID``는 현재 호출에 대한 전역 고유 ID를 제공합니다.

계속하려면 새로 생성된 compute_example.glsl 파일에 위의 코드를 작성하세요.

만들기 로컬 RenderingDevice

컴퓨팅 셰이더와 상호 작용하고 실행하려면 스크립트가 필요합니다. 만들기 선택한 언어로 새 스크립트를 만들고 이를 씬의 노드에 연결합니다.

이제 셰이더를 실행하려면 :ref:`class_RenderingServer`를 사용하여 생성할 수 있는 로컬 :ref:`class_RenderingDevice`가 필요합니다.

# Create a local rendering device.
var rd := RenderingServer.create_local_rendering_device()

그런 다음 새로 생성된 셰이더 파일 ``compute_example.glsl``를 로드하고 다음을 사용하여 사전 컴파일된 버전을 생성할 수 있습니다.

# Load GLSL shader
var shader_file := load("res://compute_example.glsl")
var shader_spirv: RDShaderSPIRV = shader_file.get_spirv()
var shader := rd.shader_create_from_spirv(shader_spirv)

경고

로컬 RenderingDevice는 `RenderDoc <https://renderdoc.org/>`__과 같은 도구를 사용하여 디버깅할 수 없습니다.

입력 데이터 제공

기억하실 수 있듯이 입력 배열을 셰이더에 전달하고 각 요소에 2를 곱하여 결과를 얻으려고 합니다.

컴퓨팅 셰이더에 값을 전달하려면 버퍼를 만들어야 합니다. 우리는 부동 소수점 배열을 다루고 있으므로 이 예에서는 저장 버퍼를 사용합니다. 스토리지 버퍼는 바이트 배열을 사용하며 CPU가 GPU와 데이터를 주고받을 수 있도록 합니다.

이제 부동 소수점 배열을 초기화하고 저장 버퍼를 생성해 보겠습니다.

# Prepare our data. We use floats in the shader, so we need 32 bit.
var input := PackedFloat32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
var input_bytes := input.to_byte_array()

# Create a storage buffer that can hold our float values.
# Each float has 4 bytes (32 bit) so 10 x 4 = 40 bytes
var buffer := rd.storage_buffer_create(input_bytes.size(), input_bytes)

버퍼가 있으면 렌더링 장치에 이 버퍼를 사용하도록 지시해야 합니다. 이를 위해서는 유니폼(일반 셰이더와 같은)을 생성하고 이를 나중에 셰이더에 전달할 수 있는 유니폼 세트에 할당해야 합니다.

# Create a uniform to assign the buffer to the rendering device
var uniform := RDUniform.new()
uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
uniform.binding = 0 # this needs to match the "binding" in our shader file
uniform.add_id(buffer)
var uniform_set := rd.uniform_set_create([uniform], shader, 0) # the last parameter (the 0) needs to match the "set" in our shader file

컴퓨팅 파이프라인 정의

다음 단계는 GPU가 실행할 수 있는 명령 세트를 만드는 것입니다. 이를 위해서는 파이프라인과 컴퓨팅 목록이 필요합니다.

결과를 계산하기 위해 수행해야 하는 단계는 다음과 같습니다.

  1. 새 파이프라인을 만듭니다.

  2. GPU가 실행할 명령 목록을 시작합니다.

  3. 컴퓨팅 목록을 파이프라인에 바인딩

  4. 버퍼 유니폼을 파이프라인에 바인딩

  5. 사용할 작업 그룹 수 지정

  6. 벡터 연산

# Create a compute pipeline
var pipeline := rd.compute_pipeline_create(shader)
var compute_list := rd.compute_list_begin()
rd.compute_list_bind_compute_pipeline(compute_list, pipeline)
rd.compute_list_bind_uniform_set(compute_list, uniform_set, 0)
rd.compute_list_dispatch(compute_list, 5, 1, 1)
rd.compute_list_end()

X축에 5개의 작업 그룹이 있고 나머지 하나에는 하나의 작업 그룹이 있는 컴퓨팅 셰이더를 디스패치하고 있습니다. X축에 2개의 로컬 호출이 있으므로(셰이더에 지정됨) 총 10개의 컴퓨팅 셰이더 호출이 시작됩니다. 버퍼 범위 밖의 인덱스를 읽거나 쓰는 경우 셰이더 제어 외부의 메모리 또는 일부 하드웨어에서 문제를 일으킬 수 있는 다른 변수의 일부에 액세스할 수 있습니다.

컴퓨팅 셰이더 실행

이 모든 작업이 거의 완료되었지만 여전히 파이프라인을 실행해야 합니다. 지금까지 우리는 GPU에 원하는 작업만 기록했습니다. 실제로 셰이더 프로그램을 실행하지 않았습니다.

컴퓨팅 셰이더를 실행하려면 파이프라인을 GPU에 제출하고 실행이 완료될 때까지 기다려야 합니다.

# Submit to GPU and wait for sync
rd.submit()
rd.sync()

이상적으로는 ``sync()``를 호출하여 RenderingDevice를 즉시 동기화하지 않는 것이 좋습니다. 이렇게 하면 CPU가 GPU 작업이 완료될 때까지 기다리게 되기 때문입니다. 이 예에서는 데이터를 즉시 읽을 수 있기를 원하기 때문에 즉시 동기화합니다. 일반적으로 GPU가 CPU와 병렬로 실행될 수 있도록 동기화하기 전에 최소 2~3프레임을 기다리는 것이 좋습니다.

경고

긴 계산으로 인해 Windows에 의해 트리거되는 :abbr:`TDR(시간 초과 감지 및 복구)`로 인해 Windows 그래픽 드라이버가 "충돌"할 수 있습니다. 이는 그래픽 드라이버에서 아무런 활동 없이 일정 시간(보통 5~10초)이 경과한 후 그래픽 드라이버를 다시 초기화하는 메커니즘입니다.

컴퓨팅 셰이더를 실행하는 데 걸리는 시간에 따라 각 디스패치에 소요되는 시간을 줄이고 TDR이 트리거될 가능성을 줄이기 위해 이를 여러 디스패치로 분할해야 할 수도 있습니다. TDR은 시간에 따라 달라지므로 주어진 컴퓨팅 셰이더를 실행할 때 더 빠른 GPU에 비해 느린 GPU가 TDR에 더 취약할 수 있습니다.

결과 테스트하기

셰이더 예제에서는 스토리지 버퍼의 내용을 수정했음을 알 수 있습니다. 즉, 셰이더는 배열에서 읽고 데이터를 동일한 배열에 다시 저장하므로 결과가 이미 거기에 있습니다. 데이터를 검색하고 결과를 콘솔에 인쇄해 보겠습니다.

# Read back the data from the buffer
var output_bytes := rd.buffer_get_data(buffer)
var output := output_bytes.to_float32_array()
print("Input: ", input)
print("Output: ", output)

리소스 해제(Free)하기

우리가 사용하고 있는 buffer, pipeline, uniform_set 변수는 각각 class_RID`입니다. RenderingDevice는 하위 수준 API이므로 RID가 자동으로 해제되지 않습니다. 즉, ``buffer` 또는 다른 RID 사용을 마친 후에는 RenderingDevice의 free_rid() 메서드를 사용하여 수동으로 메모리를 해제해야 합니다.

이로써 컴퓨팅 셰이더 작업을 시작하는 데 필요한 모든 것이 갖추어져 있습니다.

더 보기

데모 프로젝트 저장소에는 `Compute 셰이더 하이트맵 데모 <https://github.com/godotengine/godot-demo-projects/tree/master/compute/heightmap>`__가 포함되어 있습니다. 이 프로젝트는 CPU와 GPU에서 별도로 하이트맵 이미지 생성을 수행하므로 유사한 알고리즘이 두 가지 다른 방식으로 어떻게 구현될 수 있는지 비교할 수 있습니다(대부분의 경우 GPU 구현이 더 빠릅니다).