본문으로 바로가기
반응형

https://www.3dgep.com/forward-plus

 

Forward+ Rendering

Comparing Forward, Deferred, and Forward+ rendering algorithms using DirectX 11.

www.3dgep.com



Forward+

Forward+는 어떤 조명이 스크린 공간의 어느 영역과 겹치는지 먼저 결정하여 일반 포워드 렌더링을 개선하게 된다. 셰이딩 단계에서는 현재 픽셀(fragment)과 잠재적으로 겹칠 수 있는 조명만 고려하게 된다. "잠재적(potentially)"이라는 용어를 사용한 이유는 나중에 설명하겠지만 겹치는 조명을 결정하는 데 사용되는 기술이 완전히 정확하지 않기 때문이다.

Forward+ 기술은 주로 다음 세 가지 패스로 구성된다.

  Lighting Culling
  Opaque pass
  Transparent pass

Light culling 패스에서 씬의 각 라이트는 화면공간의 타일로 정렬된다.

Opaque(불투명) 패스에서 라이트 컬링 패스에서 생성된 라이트 목록을 사용하여 opaque 지오메트리에 대한 조명을 계산하게 된다. 이 단계에서 조명을 위해 모든 조명을 고려할 필요는 없으며 조명을 계산할 때 이전에 현재 프래그먼트 화면 공간 타일로 정렬된 조명만 고려하면 된다. 투명 패스는 조명 계산에 사용되는 라이트 목록이 약간 다르다는 점을 제외하면 opaque 패스와 유사하다. 다음 섹션에서 불투명 패스와 투명 패스에 대한 라이트 목록의 차이점에 대해 설명한다.

 

Grid Frustums

라이트 컬링이 발생하기 전에 조명을 화면 공간 타일로 컬링하는데 사용할 컬링 절두체(culling frustums)를 계산해야 한다. 컬링 절두체는 뷰공간에서 표현되기 때문에 그리드의 차원이 변경되거나(예: 화면 크기가 조정되는 경우) 타일 크기가 변경되는 경우에만 다시 계산하면 된다. 다음은 타일에 대한 절두체 평면이 어떻게 정의되는지에 대한 기초를 설명한다.

화면은 여러 개의 정사각형 타일로 나뉘게 되며, 모든 화면 타일을 라이트 그리드라고 부르게 된다. 각 타일의 크기를 지정해야 하며, 단일 타일의 크기는 세로 및 가로 크기를 모두 정의 해야 한다. 타일 크기는 임의로 선택해서는 안 되며 DirectX 컴퓨트 셰이더[각주:1]에서 각 타일이 단일 스레드 그룹에 의해 계산될 수 있도록 선택해야 한다. 스레드 그룹의 스레드 수는 64의 배수여야 하며(최신 GPU에서 사용할 수 있는 이중 워프 스케줄러를 활용하기 위해) 스레드 그룹당 스레드 수는 1024개를 초과할 수 없다. 스레드 그룹 차원의 가능한 후보는 다음과 같다.

  • 8×8 (64 threads per thread group)
  • 16×16 (256 threads per thread group)
  • 32×32 (1024 threads per thread group)

지금은 스레드 그룹의 차원이 16×16 스레드라고 가정할때, 이 경우 라이트 그리드의 각 타일은 16×16 스크린 픽셀의 치수를 가진다.

위의 이미지는 16×16 스레드 그룹의 부분 그리드를 보여준다. 각 스레드 그룹은 두꺼운 검은색 선으로 구분되며 스레드 그룹 내의 스레드는 가는 검은색 선으로 구분된다. 라이트 컬링에 사용되는 타일도 같은 방식으로 분할되며, 비스듬한 각도에서 타일을 본다면 계산해야 하는 컬링 절두체를 시각화할 수 있다.

위의 이미지는 카메라의 위치(눈)가 절두체의 원점이며 타일의 꼭지점은 절두체 모서리를 나타낸다. 이 정보로 타일 절두체의 평면을 계산할 수 있다. 뷰 절두체는 6개의 평면으로 구성되지만 라이트 컬링을 수행하기 위해 절두체에 대한 4개의 측면 평면을 미리 계산해야 한다. 근거리 및 원거리 절두체 평면의 계산은 라이트 컬링 단계까지 연기된다. 왼쪽, 오른쪽, 위쪽 및 아래쪽 절두체 평면을 계산하기 위해 다음 알고리즘을 사용한다.

  1.  화면 공간에서 현재 타일의 네 꼭지점을 계산한다.
  2. 화면 공간 모서리 점을 보기 공간에서 먼 클리핑 평면으로 변환한다.
  3. 눈 위치와 두 개의 다른 꼭지점에서 절두체 평면을 만든다.
  4. 계산된 절두체를 RWStructuredBuffer에 저장한다.

평면에 있는 세 점을 알면 평면을 계산할 수 있다[각주:2]. 위의 이미지에 표시된 것처럼 타일의 꼭지점에 번호를 매기면 눈 위치와 뷰 공간의 다른 두 꼭지점을 사용하여 절두체 평면을 계산할 수 있다. 예를 들어 시계 반대 방향 감기 순서를 가정하여 절두체 평면을 계산하기 위해 다음 점을 사용할 수 있다.

  • Left Plane: Eye, Bottom-Left (2), Top-Left (0)
  • Right Plane: Eye, Top-Right (1), Bottom-Right (3)
  • Top Plane: Eye, Top-Left (0), Top-Right (1)
  • Bottom Plane: Eye, Bottom-Right (3), Bottom-Left (2)

평면에 있는 세 개의 비동일선상 점 ABC를 알고 있으면(위 이미지에 표시됨) 평면 n에 대한 법선을 계산할 수 있다.

n = (B A) × (C A)

 

n이 정규화되면 평면에 있는 주어진 점 P를 사용하여 원점에서 평면까지 부호 있는 거리를 계산할 수 있다.

d = n P

 

이를 평면의 상수-수직 형태라고 하며 다음과 같이 표현할 수도 있다.

ax + by + cz d = 0

 

 X가 평면에 있는 점일때 n = (a, b, c) 및 X = (x, y, z) 이다

HLSL 셰이더에서 평면을 단위 법선 n과 원점까지의 거리 d로 정의할 수 있다.

CommonInclude.hlsl
struct Plane
{
    float3 N;   // Plane normal.
    float  d;   // Distance to origin.
};

평면에 있는 반시계 방향이 아닌 3개의 점을 고려하면 HLSL의 ComputePlane 함수를 사용하여 평면을 계산할 수 있다.

CommonInclude.hlsl
// Compute a plane from 3 noncollinear points that form a triangle.
// This equation assumes a right-handed (counter-clockwise winding order) 
// coordinate system to determine the direction of the plane normal.

Plane ComputePlane( float3 p0, float3 p1, float3 p2 )
{
    Plane plane;
 
    float3 v0 = p1 - p0;
    float3 v2 = p2 - p0;
 
    plane.N = normalize( cross( v0, v2 ) );
 
    // Compute the distance to the origin using p0.
    plane.d = dot( plane.N, p0 );
 
    return plane;
}

그리고 절두체는 4면의 구조로 정의된다.

CommonInclude.hlsl
// Four planes of a view frustum (in view space).
// The planes are:
//  * Left,
//  * Right,
//  * Top,
//  * Bottom.
// The back and/or front planes can be computed from depth values in the 
// light culling compute shader.
struct Frustum
{
    Plane planes[4];   // left, right, top, bottom frustum planes.
};

그리드 절두체를 미리 계산하려면 그리드의 각 타일에 대해 계산 셰이더 커널을 호출해야 한다. 예를 들어 화면 해상도가 1280×720이고 라이트 그리드가 16×16 타일로 분할된 경우 80×45(3,600) 절두체를 계산해게 된다. 스레드 그룹이 16×16(256) 스레드를 포함하는 경우 모든 절두체를 계산하기 위해 5×2.8125 스레드 그룹을 디스패치해야한다. 물론 부분적인 스레드 그룹을 디스패치할 수 없으므로 컴퓨팅 셰이더를 디스패치할 때 가장 가까운 정수로 반올림해야 한다. 이 경우 각각 16×16(256) 스레드가 있는 5×3(15) 스레드 그룹을 디스패치하고 컴퓨트 셰이더에서 화면 경계를 벗어난 스레드를 무시하도록 해야 한다.

위의 이미지는 16×16 스레드 그룹을 가정하여 타일 절두체를 생성하기 위해 호출될 스레드 그룹을 보여준다. 검은색 굵은 선은 스레드 그룹 경계를 나타내고 가는 검은색 선은 스레드 그룹의 스레드를 나타내게 된다. 파란색 스레드는 타일 절두체를 계산하는 데 사용되는 스레드를 나타내고 빨간색 스레드는 화면 크기를 넘어 확장되기 때문에 절두체 타일 계산을 건너뛰어야 한다.

다음 공식을 사용하여 디스패치의 크기를 결정할 수 있다.

여기서 g는 디스패치 될 총 스레드 수, w는 화면 너비(픽셀), h는 화면 높이(픽셀), B는 스레드 그룹의 크기(이 예에서는 16), G는 실행할 스레드 그룹의 수다.

 

이 정보를 사용하여 그리드 절두체를 미리 계산하는데 사용할 컴퓨팅 셰이더를 디스패치할 수 있다.

 

Grid Frustums Compute Shader

기본적으로 컴퓨트 셰이더의 스레드 그룹 크기는 16×16 스레드이지만 응용 프로그램은 셰이더 컴파일 중에 다른 블록 크기를 정의할 수 있다.

ForwardPlusRendering.hlsl
#ifndef BLOCK_SIZE
#pragma message( "BLOCK_SIZE undefined. Default to 16.")
#define BLOCK_SIZE 16 // should be defined by the application.
#endif

그리고 공통 컴퓨팅 셰이더 입력 변수를 저장하기 위한 공통 구조를 정의한다.

ForwardPlusRendering.hlsl
struct ComputeShaderInput
{
    uint3 groupID           : SV_GroupID;           // 3D index of the thread group in the dispatch.
    uint3 groupThreadID     : SV_GroupThreadID;     // 3D index of local thread ID in a thread group.
    uint3 dispatchThreadID  : SV_DispatchThreadID;  // 3D index of global thread ID in the dispatch.
    uint  groupIndex        : SV_GroupIndex;        // Flattened local index of the thread within a thread group.
};

컴퓨트 셰이더에 대한 입력으로 사용할 수 있는 시스템 값 시맨틱 목록은 다음[각주:3]을 참조.
HLSL에서 제공하는 시스템 값 외에도 현재 디스패치의 총 스레드 수와 총 스레드 그룹 수를 알아야 한다. 안타깝게도 HLSL은 이러한 속성에 대한 시스템 값 의미 체계를 제공하지 않는다. DispatchParams라는 상수 버퍼에 필요한 값을 저장한다.

ForwardPlusRendering.hlsl
// Global variables
cbuffer DispatchParams : register( b4 )
{
    // Number of groups dispatched. (This parameter is not available as an HLSL system value!)
    uint3   numThreadGroups;
    // uint padding // implicit padding to 16 bytes.
 
    // Total number of threads dispatched. (Also not available as an HLSL system value!)
    // Note: This value may be less than the actual number of threads executed 
    // if the screen size is not evenly divisible by the block size.
    uint3   numThreads;
    // uint padding // implicit padding to 16 bytes.
}

numThreads 변수의 값은 앞에서 설명한 것처럼 화면 범위를 벗어난 경우 디스패치의 스레드가 사용되지 않도록 하는 데 사용할 수 있다.
계산된 그리드 절두체의 결과를 저장하려면 타일당 하나의 절두체를 저장할 수 있을 만큼 충분히 큰 구조화된 버퍼도 생성해야 한다. 이 버퍼는 균일 액세스 보기를 사용하여 out_Frustrum RWStructuredBuffer 변수에 바인딩되게 된다.

ForwardPlusRendering.hlsl
// View space frustums for the grid cells.
RWStructuredBuffer<Frustum> out_Frustums : register( u0 );

 

TILE CORNERS IN SCREEN SPACE

컴퓨트 셰이더에서 가장 먼저 해야 할 일은 디스패치에서 현재 스레드의 전역 ID를 사용하여 타일 절두체 모서리의 화면 공간 포인트를 결정하는 것이다.

ForwardPlusRendering.hlsl
// A kernel to compute frustums for the grid
// This kernel is executed once per grid cell. Each thread
// computes a frustum for a grid cell.
[numthreads( BLOCK_SIZE, BLOCK_SIZE, 1 )]
void CS_ComputeFrustums( ComputeShaderInput IN )
{
    // View space eye position is always at the origin.
    const float3 eyePos = float3( 0, 0, 0 );
 
    // Compute the 4 corner points on the far clipping plane to use as the 
    // frustum vertices.
    float4 screenSpace[4];
    // Top left point
    screenSpace[0] = float4( IN.dispatchThreadID.xy * BLOCK_SIZE, -1.0f, 1.0f );
    // Top right point
    screenSpace[1] = float4( float2( IN.dispatchThreadID.x + 1, IN.dispatchThreadID.y ) * BLOCK_SIZE, -1.0f, 1.0f );
    // Bottom left point
    screenSpace[2] = float4( float2( IN.dispatchThreadID.x, IN.dispatchThreadID.y + 1 ) * BLOCK_SIZE, -1.0f, 1.0f );
    // Bottom right point
    screenSpace[3] = float4( float2( IN.dispatchThreadID.x + 1, IN.dispatchThreadID.y + 1 ) * BLOCK_SIZE, -1.0f, 1.0f );

전역 스레드 ID를 화면 공간 위치로 변환하려면 조명 그리드의 타일 크기를 곱하기만 하면 된다. 화면 공간 위치의 z 구성 요소는 카메라가 뷰 공간에서 -z축을 바라보는 오른손 좌표계를 사용하고 있기 때문에 -1이다. 왼손 좌표계를 사용하는 경우 z 구성 요소에 1을 사용해야 한다. 이것은 먼 클리핑 평면에서 타일 모서리의 화면 공간 위치를 제공하게 된다.

 

TILE CORNERS IN VIEW SPACE

다음으로 디퍼드 렌더링 픽셀 셰이더에 대한 섹션에서 설명한 ScreenToView 함수를 사용하여 screen space position을 뷰공간으로 변환해야 한다.

ForwardPlusRendering.hlsl
float3 viewSpace[4];
    // Now convert the screen space points to view space
    for ( int i = 0; i < 4; i++ )
    {
        viewSpace[i] = ScreenToView( screenSpace[i] ).xyz;
    }

 

COMPUTE FRUSTUM PLANES

타일 모서리의 뷰 공간 위치를 사용하여 절두체 평면을 만들 수 있다.

ForwardPlusRendering.hlsl
// Now build the frustum planes from the view space points
    Frustum frustum;
 
    // Left plane
    frustum.planes[0] = ComputePlane( eyePos, viewSpace[2], viewSpace[0] );
    // Right plane
    frustum.planes[1] = ComputePlane( eyePos, viewSpace[1], viewSpace[3] );
    // Top plane
    frustum.planes[2] = ComputePlane( eyePos, viewSpace[0], viewSpace[1] );
    // Bottom plane
    frustum.planes[3] = ComputePlane( eyePos, viewSpace[3], viewSpace[2] );

 

STORE GRID FRUSTUMS

마지막으로 절두체를 전역 메모리에 기록해야 한다. 할당된 절두체 버퍼의 범위를 벗어난 배열 요소에 액세스하지 않도록 주의.

ForwardPlusRendering.hlsl
// Store the computed frustum in global memory (if our thread ID is in bounds of the grid).
    if ( IN.dispatchThreadID.x < numThreads.x && IN.dispatchThreadID.y < numThreads.y )
    {
        uint index = IN.dispatchThreadID.x + ( IN.dispatchThreadID.y * numThreads.x );
        out_Frustums[index] = frustum;
    }
}

이제 미리 계산된 그리드 프러스텀이 있으므로 이를 라이트 컬링 컴퓨팅 셰이더에서 사용할 수 있다.

 

Lighting Culling

Forward+ 렌더링 기술의 다음 단계는 이전 섹션에서 계산된 그리드 절두체를 사용하여 조명을 컬링하는 것이다. 그리드 절두체의 계산은 애플리케이션 시작시 또는 화면 크기 또는 타일 크기가 변경되지만 카메라가 이동하거나 조명의 위치가 이동하는 모든 프레임에서 라이트 컬링 단계가 발생해야 하는 경우 한 번만 수행하면 된다. 혹은 Depth 버퍼의 내용에 영향을 주는 장면의 개체가 변경된다. 이러한 이벤트 중 하나가 발생할 수 있으므로 일반적으로 매 프레임마다 라이트 컬링을 수행하는 것이 안전하다.

라이트 컬링을 수행하기 위한 기본 알고리즘은 다음과 같다.

  1. 타일의 뷰 공간에서 최소 및 최대 깊이 값을 계산.
  2. 조명을 선별하고 조명을 조명 인덱스 목록에 기록
  3. 라이트 인덱스 목록을 글로벌 메모리에 복사

COMPUTE MIN/MAX DEPTH VALUES

알고리즘의 첫 번째 단계는 라이트 그리드의 타일당 최소 및 최대 깊이 값을 계산하는 것이다. 최소 및 최대 깊이 값은 컬링 절두체의 근거리 및 원거리 평면을 계산하는 데 사용.

위의 이미지는 예시 장면을 보여준다. 파란색 오브젝트는 장면의 불투명 오브젝트를 나타낸다. 노란색 오브젝트는 라이트를 나타내고 음영 처리된 회색 영역은 타일당 최소 및 최대 깊이 값에서 계산된 타일 절두체를 나타낸다. 녹색 선은 라이트 그리드의 타일 경계를 나타낸다. 타일은 위에서 아래로 1-7번, 불투명 오브젝트는 1-5번, 라이트는 1-4번이다.

 

첫 번째 타일의 최대 깊이 값은 1(투영된 클립 공간에서)이다. 불투명 지오메트리가 덮이지 않는 일부 픽셀이 있기 때문이다. 이 경우 컬링 절두체는 매우 크고 지오메트리에 영향을 미치지 않는 조명을 포함할 수 있다. 예를 들어, 조명 1은 타일 1에 포함되어 있지만 조명 1은 지오메트리에는 영향을 주지 않는다. 지오메트리 경계에서 클리핑 절두체는 잠재적으로 매우 클 수 있으며 어떤 지오메트리에도 영향을 미치지 않는 조명을 포함할 수 있다.

 

오브젝트 2가 카메라를 직접 향하고 전체 타일을 채우므로 타일 2의 최소 및 최대 깊이 값은 동일하다. 나중에 조명 볼륨의 실제 클리핑을 수행할 때 볼 수 있으므로 이것은 문제가 되지 않는다.

 

오브젝트 3은 빛 3을 완전히 차단하므로 픽셀을 음영 처리할때 고려되지 않는다.

 

위의 이미지는 불투명 지오메트리에 대한 타일당 최소 및 최대 깊이 값을 보여준다. 투명 지오메트리의 경우 최대 깊이 평면 뒤에 있는 조명 볼륨만 클립할 수 있지만 모든 불투명 지오메트리 앞에 있는 모든 조명을 고려해야 한다. 그 이유는 타일당 최소 및 최대 깊이를 결정하는 데 사용되는 뎁스텍스처를 생성하기 위해 Depth pre-pass 단계를 수행할 때 깊이 버퍼에 투명 지오메트리를 렌더링할 수 없기 때문이다. 만약 그렇게 했다면 투명한 지오메트리 뒤에 있는 불투명한 지오메트리를 제대로 비추지 못하게 된다. 이 문제에 대한 해결책은 Markus Billeter, Ola Olsson 및 Ulf Assarsson의 "Tiled Forward Shading"이라는 제목의 기사에 설명되어 있다[각주:4] 라이트 컬링 컴퓨팅 셰이더에서 두 개의 라이트 목록이 생성된다. 첫 번째 조명 목록에는 불투명 지오메트리에 영향을 주는 조명만 포함되게 되며, 두 번째 조명 목록에는 투명한 지오메트리에 영향을 줄 수 있는 조명만 포함되어 있다. 불투명 지오메트리에서 최종 셰이딩을 수행할 때 첫 번째 목록을 보내고 투명 지오메트리를 렌더링할 때 두 번째 목록을 픽셀 셰이더로 보낸다.

라이트 컬링 컴퓨트 셰이더에 대해 논의하기 전에 컴퓨트 셰이더에서 라이트 목록을 작성하는 데 사용되는 방법에 대해 이야기 해본다.

 

LIGHT LIST DATA STRUCTURE

타일별 조명 목록을 저장하는 데 사용되는 데이터 구조는 Ola Olsson 및 Ulf Assarsson의 "Tiled Shading"이라는 제목의 논문[각주:5]에 설명되어 있다. Ola와 Ulf는 두 부분으로 데이터 구조를 설명하는데, 첫 번째 부분은 Light index list에 저장된 값의 수와 오프셋을 저장하는 2D 그리드인 light grid이다. 이 기술은 버텍스 버퍼에서 버텍스의 인덱스를 참조하는 인덱스 버퍼와 유사하다.

라이트 그리드의 크기는 라이트 컬링에 사용되는 스크린 타일의 수를 기반으로 한다. 조명 인덱스 목록의 크기는 타일당 예상되는 평균 중첩 조명 수를 기반으로 한다. 예를 들어 화면 해상도가 1280×720이고 타일 크기가 16×16인 경우 80×45(3,600) 라이트 그리드가 생성된다. 타일당 평균 200개의 조명을 가정하면 720,000개 색인의 조명 색인 목록이 필요하게 된다. 각 라이트 인덱스 비용은 4바이트(부호 없는 32비트 정수의 경우-Unsigned integer 32bit)이므로 라이트 목록은 2.88MB의 GPU 메모리를 사용하게 된다. 투명 및 불투명 지오메트리에 대한 별도의 목록이 필요하므로 총 5.76MB를 사용한다. 200개의 조명은 타일당 평균 중첩 조명 수를 과대 평가할 수 있지만 스토리지 사용량은 터무니없지 않다.

 

라이트 그리드와 라이트 인덱스 목록을 생성하기 위해 먼저 컴퓨트 셰이더에서 그룹 공유 라이트 인덱스 목록이 생성된다. 전역 광원 인덱스 목록의 수는 전역 광원 인덱스 목록에 대한 현재 인덱스를 추적하는 데 사용된다. 전역 광원 인덱스 수는 두 개의 스레드 그룹이 전역 광원 인덱스 목록에서 동일한 범위를 사용할 수 없도록 증가하게 된다(atomically incremented). 스레드 그룹이 전역 조명 인덱스 목록에 "예약된" 공간이 있으면 그룹 공유 조명 인덱스 목록이 전역 조명 인덱스 목록에 복사된다.

Light Culling
function CullLights( L, C, G, I )
    Input: A set L of n lights.
    Input: A counter C of the current index into the global light index list.
    Input: A 2D grid G of index offset and count in the global light index list.
    Input: A list I of global light index list.
    Output: A 2D grid G with the current tiles offset and light count.
    Output: A list I with the current tiles overlapping light indices appended to it.
 
1.  let t be the index of the current tile  ; t is the 2D index of the tile.
2.  let i be a local light index list       ; i is a local light index list.
3.  let f <- Frustum(t)                     ; f is the frustum for the current tile.
 
4.  for l in L                      ; Iterate the lights in the light list.
5.      if Cull( l, f )             ; Cull the light against the tile frustum.
6.          AppendLight( l, i )     ; Append the light to the local light index list.
                    
7.  c <- AtomicInc( C, i.count )    ; Atomically increment the current index of the 
                                    ; global light index list by the number of lights
                                    ; overlapping the current tile and store the
                                    ; original index in c.
            
8.  G(t) <- ( c, i.count )          ; Store the offset and light count in the light grid.
        
9.  I(c) <- i                       ; Store the local light index list into the global 
                                    ; light index list.

처음 세 줄에서 그리드의 현재 타일 인덱스는 t로 정의된다. 로컬 라이트 인덱스 목록은 i로 정의되고 현재 타일에 대한 라이트 컬링을 수행하는 데 사용되는 타일 절두체는 f로 정의된다.

라인 4, 5 및 6은 전역 조명 목록을 반복하고 현재 타일의 컬링 프러스텀에 대해 조명을 컬링한다. 라이트가 절두체 내부에 있으면 라이트 인덱스가 로컬 라이트 인덱스 목록에 추가된다.

7행에서 전역 조명 색인 목록의 현재 인덱스는 로컬 조명 색인 목록에 포함된 조명의 수만큼 증가한다. 증분되기 전의 전역 라이트 인덱스 목록 카운터의 원래 값은 로컬 카운터 변수 c에 저장된다.

8행에서 라이트 그리드 G는 현재 타일의 오프셋으로 업데이트되고 전역 라이트 인덱스 목록에 포함된다.

마지막으로 9행에서 로컬 조명 인덱스 목록이 전역 조명 인덱스 목록에 복사된다.

라이트 그리드와 글로벌 라이트 인덱스 목록은 최종 셰이딩을 수행하기 위해 프래그먼트 셰이더에서 사용된다.

 

Frustum Culling

라이트 볼륨에서 절두체 컬링을 수행하기 위해 두 가지 절두체 컬링 방법이 제시된다.

  1. Frustum-Sphere culling for point lights
  2. Frustum-Cone culling for spot lights

구체에 대한 컬링 알고리즘은 매우 간단하지만, 원뿔(cone)에 대한 컬링 알고리즘은 약간 더 복잡하다. 먼저 Frustum-sphere 알고리즘을 설명한 다음 cone-culling 알고리즘을 설명한다.

 

FRUSTUM-SPHERE CULLING

우리는 Compute Grid Frustums라는 제목의 이전 섹션에서 컬링 절두체의 정의를 이미 보았다. 구(sphere)는 뷰공간의 중심점과 반지름으로 정의된다.

CommonInclude.hlsl
struct Sphere
{
    float3 c;   // Center point.
    float  r;   // Radius.
};

구는 평면의 음의 절반 공간에 완전히 포함된 경우 평면 "내부"에 있는 것으로 간주된다. 구가 절두체 평면 중 하나에 완전히 "내부"에 있으면 절두체 외부에 있는 것이다.

다음 공식을 사용하여 평면에서 구의 signed distance값을 결정할 수 있다.[각주:6]

l = (c n) d

여기서 l은 구에서 평면까지의 signed distance, c는 구의 중심점, n은 평면에 수직인 단위(normal), d는 평면에서 원점까지의 거리이다.

l이 -r보다 작으면 r은 구의 반지름이고 구가 평면의 음의 절반 공간에 완전히 포함되어 있음을 알 수 있다.

CommonInclude.hlsl
// Check to see if a sphere is fully behind (inside the negative halfspace of) a plane.
// Source: Real-time collision detection, Christer Ericson (2005)
bool SphereInsidePlane( Sphere sphere, Plane plane )
{
    return dot( plane.N, sphere.c ) - plane.d < -sphere.r;
}

그런 다음 SphereInsidePlane 함수를 반복적으로 적용하여 구가 컬링 절두체 내부에 포함되어 있는지 확인할 수 있다.

CommonInclude.hlsl
// Check to see of a light is partially contained within the frustum.
bool SphereInsideFrustum( Sphere sphere, Frustum frustum, float zNear, float zFar )
{
    bool result = true;
 
    // First check depth
    // Note: Here, the view vector points in the -Z axis so the 
    // far depth value will be approaching -infinity.
    if ( sphere.c.z - sphere.r > zNear || sphere.c.z + sphere.r < zFar )
    {
        result = false;
    }
 
    // Then check frustum planes
    for ( int i = 0; i < 4 && result; i++ )
    {
        if ( SphereInsidePlane( sphere, frustum.planes[i] ) )
        {
            result = false;
        }
    }
 
    return result;
}

구가 뷰 공간에 설명되어 있으므로 z 위치와 가까운 클리핑 평면과 먼 클리핑 평면까지의 거리를 기준으로 라이트를 컬링해야 하는지 여부를 신속하게 결정할 수 있다. 구가 근거리 클리핑 평면의 전면에 완전히 있거나 원거리 클리핑 평면의 완전히 뒤에 있으면 조명을 버릴 수 있다. 그렇지 않으면 조명이 컬링 절두체의 경계 내에 있는지 확인해야 한다.

SphereInsideFrustum은 카메라가 음의 z축을 향하고 있는 오른손 좌표계를 가정한다. 이 경우 멀리 있는 평면이 음의 무한대에 접근하고 있으므로 구가 멀리 떨어져 있는지(음의 방향보다 작은지) 확인해야 한다. 왼손잡이 좌표계의 경우 zNear 및 zFar 변수를 268행에서 교체해야 한다.

 

FRUSTUM-CONE CULLING

Frustum-cone culling을 수행하기 위해 Christer Ericson이 "Real-Time Collision Detection"[각주:7]이라는 저서에서 설명한 기술을 사용한다. 원뿔은 꼭지점 T, 정규화된 방향의 벡터 d, 원뿔의 높이 h 및 밑면의 반지름 r로 정의할 수 있다.

HLSL에서 콘은 아래와 같이 정의된다.

CommonInclude.hlsl
struct Cone
{
    float3 T;   // Cone tip.
    float  h;   // Height of the cone.
    float3 d;   // Direction of the cone.
    float  r;   // bottom radius of the cone.
};

원뿔이 평면의 음의 절반 공간에 완전히 포함되는지 테스트하려면 두 지점만 테스트하면 된다.

  1. 콘의 꼭지점 T
  2. n 방향으로 평면에서 가장 멀리 떨어진 원뿔 밑면에 있는 점 Q

이 두 점이 절두체 평면의 음의 절반 공간에 포함되어 있으면 원뿔을 추려낼 수 있다.

n의 방향으로 평면에서 가장 멀리 떨어진 점 Q를 결정하기 위해 우리는 평행하지만 n에 반대이고 d에 수직인 중간 벡터 m을 계산할 것이다.

m = (n × d) × d

Q는 거리 h에서 원뿔 축 d를 따라 꼭지점 T에서 스테핑한 다음 평면 -m의 양의 절반 공간에서 떨어진 원뿔의 밑면을 따라 r의 계수에서 얻는다.

Q = T + hd rm

n × d가 0이면 원뿔 축 d는 평면 법선 n에 평행하고 m은 제로 벡터가 된다. 이 특별한 경우는 특별히 처리할 필요가 없다. 이 경우 방정식이 다음과 같이 줄어들기 때문인데

Q = T + hd

그러면 테스트해야 할 올바른 지점이 생성된다.

점 T와 Q를 계산하면 두 점이 평면의 음의 절반 공간에 있는 경우 두 점을 모두 테스트할 수 있다. 그렇다면 빛을 추려낼 수 있다고 결론을 내릴 수 있는데, 점이 평면의 음의 절반 공간에 있는지 테스트하기 위해 다음 방정식을 사용할 수 있다.

l = (n X) d

여기서 l은 점에서 평면까지의 부호 있는 거리이고 X는 테스트할 점이다. l이 음수이면 점은 평면의 음수 절반 공간에 포함된다.

HLSL에서 PointInsidePlane 함수는 점이 평면의 음의 절반 공간 안에 있는지 테스트하는 데 사용된다.

CommonInclude.hlsl
// Check to see if a point is fully behind (inside the negative halfspace of) a plane.
bool PointInsidePlane( float3 p, Plane plane )
{
    return dot( plane.N, p ) - plane.d < 0;
}

그리고 ConeInsidePlane 함수는 원뿔이 평면의 음의 절반 공간에 완전히 포함되어 있는지 테스트하는 데 사용된다.

CommonInclude.hlsl
// Check to see if a cone if fully behind (inside the negative halfspace of) a plane.
// Source: Real-time collision detection, Christer Ericson (2005)
bool ConeInsidePlane( Cone cone, Plane plane )
{
    // Compute the farthest point on the end of the cone to the positive space of the plane.
    float3 m = cross( cross( plane.N, cone.d ), cone.d );
    float3 Q = cone.T + cone.d * cone.h - m * cone.r;
 
    // The cone is in the negative halfspace of the plane if both
    // the tip of the cone and the farthest point on the end of the cone to the 
    // positive halfspace of the plane are both inside the negative halfspace 
    // of the plane.
    return PointInsidePlane( cone.T, plane ) && PointInsidePlane( Q, plane );
}

ConeInsideFrustum 함수는 원뿔이 클리핑 절두체 내에 포함되어 있는지 테스트하는 데 사용된다. 이 함수는 원뿔이 절두체 내부에 있으면 true를 반환하고, 클리핑 평면의 음의 절반 공간에 완전히 포함되어 있으면 false를 반환한다.

CommonInclude.hlsl
bool ConeInsideFrustum( Cone cone, Frustum frustum, float zNear, float zFar )
{
    bool result = true;
 
    Plane nearPlane = { float3( 0, 0, -1 ), -zNear };
    Plane farPlane = { float3( 0, 0, 1 ), zFar };
 
    // First check the near and far clipping planes.
    if ( ConeInsidePlane( cone, nearPlane ) || ConeInsidePlane( cone, farPlane ) )
    {
        result = false;
    }
 
    // Then check frustum planes
    for ( int i = 0; i < 4 && result; i++ )
    {
        if ( ConeInsidePlane( cone, frustum.planes[i] ) )
        {
            result = false;
        }
    }
 
    return result;
}

먼저 원뿔이 근거리 또는 원거리 클리핑 평면에 의해 클리핑되는지 확인한다. 그렇지 않으면 컬링 절두체의 4개 평면을 확인해야 한다. 원뿔이 클리핑 평면의 음의 절반 공간에 있으면 함수는 false를 반환한다.

이제 이것을 합쳐서 라이트 컬링 컴퓨트 셰이더를 정의할 수 있다.

 

Light Culling Compute Shader

라이트 컬링 컴퓨트 셰이더의 목적은 프래그먼트 셰이더에 필요한 전역 라이트 인덱스 목록과 라이트 그리드를 업데이트하는 것이다. 프레임당 다음 두 개의 목록을 업데이트해야 한다.

  1. Light index list for opaque geometry
  2. Light index list for transparent geometry

HLSL 컴퓨트 셰이더의 두 목록을 구별하기 위해 불투명 목록을 나타내는 접두사 "o_"와 투명 목록을 나타내는 접두사 "t_"를 사용한다. 두 목록 모두 라이트 컬링 컴퓨트 셰이더에서 업데이트 되게 된다.

먼저 라이트 컬링 컴퓨팅 셰이더에 필요한 리소스를 선언한다.

ForwardPlusRendering.hlsl
// The depth from the screen space texture.
Texture2D DepthTextureVS : register( t3 );
// Precomputed frustums for the grid.
StructuredBuffer<Frustum> in_Frustums : register( t9 );

뎁스 프리패스에서 생성된 뎁스 값을 읽으려면 뎁스 텍스처의 결과를 라이트 컬링 컴퓨팅 셰이더로 보내야 한다. DepthTextureVS 텍스처에는 뎁스 프리패스의 결과가 포함된다.

in_Frustums는 절두체가 컴퓨트 셰이더에서 계산된다. 이는 Grid Frustums Compute Shader에서 설명한 구조화 버퍼이다.

또한 글로벌 라이트 인덱스 목록에 대한 인덱스를 추적해야 한다.

ForwardPlusRendering.hlsl
// Global counter for current index into the light index list.
// "o_" prefix indicates light lists for opaque geometry while 
// "t_" prefix indicates light lists for transparent geometry.
RWStructuredBuffer<uint> o_LightIndexCounter : register( u1 );
RWStructuredBuffer<uint> t_LightIndexCounter : register( u2 );

라이트 인덱스 목록은 Unsigned integer의 1D array로 저장되지만 라이트 그리드는 각 "텍셀"이 2-component unsigned integer 벡터인 2D 텍스처로 저장됩니다. 라이트 그리드 텍스처는 R32G32_UINT 형식을 사용하여 생성된다.

타일당 최소 및 최대 깊이 값을 저장하려면 최소 및 최대 깊이 값을 저장하기 위해 일부 그룹 공유 변수를 선언해야 한다.  atomic increment[각주:8] 함수는 스레드 그룹의 한 스레드만 최소/최대 깊이 값을 변경할 수 있도록 하는 데 사용되지만 안타깝게도 셰이더 모델 5.0은 부동 소수점 값에 대한 atomic 함수를 제공하지 않는다. 이 제한을 피하기 위해 깊이 값은 그룹 공유 메모리에 unsigned integer로 저장되며 스레드별로 atomically로 비교 및 업데이트 된다.

ForwardPlusRendering.hlsl
groupshared uint uMinDepth;
groupshared uint uMaxDepth;

컬링을 수행하는 데 사용되는 절두체는 그룹의 모든 스레드에 대해 동일한 절두체가 되기 때문에 그룹의 모든 스레드에 대해 절두체의 복사본을 하나만 유지하는 것이 좋다. 그룹의 스레드 0만이 전역 메모리 버퍼에서 절두체를 복사해야 하며 스레드당 필요한 로컬 레지스터 메모리의 양도 줄이게 된다.

ForwardPlusRendering.hlsl
groupshared Frustum GroupFrustum;

또한 임시 조명 목록을 만들기 위해 그룹 공유 변수를 선언해야 한다. 불투명과 투명 지오메트리를 위한 각각 별도의 목록이 필요하다.

ForwardPlusRendering.hlsl
// Opaque geometry light lists.
groupshared uint o_LightCount;
groupshared uint o_LightIndexStartOffset;
groupshared uint o_LightList[1024];
 
// Transparent geometry light lists.
groupshared uint t_LightCount;
groupshared uint t_LightIndexStartOffset;
groupshared uint t_LightList[1024];

LightCount는 현재 타일 절두체와 교차하는 조명의 수를 추적한다.

LightIndexStartOffset은 전역 광원 인덱스 목록에 대한 오프셋이다. 이 인덱스는 라이트 그리드에 기록되며 로컬 라이트 인덱스 목록을 글로벌 라이트 인덱스 목록에 복사할 때 시작 오프셋으로 사용된다.

로컬 조명 인덱스 목록을 사용하면 단일 타일에 최대 1024개의 조명을 저장할 수 있다. 이 최대값은 거의 절대 도달하지 않는다(적어도 도달해서는 안된다!). 전역 조명 목록에 대한 스토리지를 할당할 때 타일당 평균 200개의 조명을 고려했다는 점을 염두해야한다. 200개 이상의 조명(1024개 이하)을 포함하는 일부 타일과 200개 미만의 조명을 포함하는 일부 타일이 있을 수 있지만 평균적으로 타일당 약 200개의 조명이 될 것으로 예상하게 된다. 이전에 언급했듯 타일당 평균 200개의 조명이라는 추정치는 아마도 과대 추정일 수 있지만 GPU 메모리는 이 프로젝트의 제한 제약이 아니므로 추정에 대해 자유로울 수 있다.

로컬 조명 카운터와 조명 목록을 업데이트하기 위해 AppendLight라는 도우미 함수를 정의한다. 불행히도 그룹 공유 변수를 함수의 인수로 전달하는 방법을 아직 파악하지 못했기 때문에 지금은 동일한 함수의 두 가지 버전을 정의할 것이다. 기능의 한 버전은 불투명 지오메트리에 대한 라이트 인덱스 목록을 업데이트하는 데 사용되고 다른 버전은 투명 지오메트리에 사용된다.

ForwardPlusRendering.hlsl
// Add the light to the visible light list for opaque geometry.
void o_AppendLight( uint lightIndex )
{
    uint index; // Index into the visible lights array.
    InterlockedAdd( o_LightCount, 1, index );
    if ( index < 1024 )
    {
        o_LightList[index] = lightIndex;
    }
}
 
// Add the light to the visible light list for transparent geometry.
void t_AppendLight( uint lightIndex )
{
    uint index; // Index into the visible lights array.
    InterlockedAdd( t_LightCount, 1, index );
    if ( index < 1024 )
    {
        t_LightList[index] = lightIndex;
    }
}

InterlockedAdd 기능은 그룹 공유 라이트 카운트 변수가 한 번에 단일 스레드에 의해서만 업데이트되도록 보장한다. 이렇게 하면 여러 스레드가 그룹 공유 조명 수를 동시에 증가시키려고 할 때 발생할 수 있는 경쟁 조건을 피할 수 있다.

증가하기 전의 빛 개수 값은 인덱스 로컬 변수에 저장되어 그룹 공유 빛 인덱스 목록에서 빛 인덱스를 업데이트하는 데 사용된다.

타일당 최소 및 최대 깊이 범위를 계산하는 방법은 2011년 Johan Andersson의 "DirectX 11 Rendering in Battlefield 3" 프레젠테이션[각주:9]과 Ola Olsson 및 Ulf Assarsson의 "Tiled Shading" 프레젠테이션[각주:10] 에서 가져왔다.

라이트 컬링 컴퓨트 셰이더에서 가장 먼저 할 일은 현재 스레드의 깊이 값을 읽는 것이다. 스레드 그룹의 각 스레드는 현재 스레드에 대해 깊이 버퍼를 한 번만 샘플링하므로 그룹의 모든 스레드는 단일 타일에 대한 모든 깊이 값을 샘플링하게 된다.

ForwardPlusRendering.hlsl
// Implementation of light culling compute shader is based on the presentation
// "DirectX 11 Rendering in Battlefield 3" (2011) by Johan Andersson, DICE.
// Retrieved from: http://www.slideshare.net/DICEStudio/directx-11-rendering-in-battlefield-3
// Retrieved: July 13, 2015
// And "Forward+: A Step Toward Film-Style Shading in Real Time", Takahiro Harada (2012)
// published in "GPU Pro 4", Chapter 5 (2013) Taylor & Francis Group, LLC.
[numthreads( BLOCK_SIZE, BLOCK_SIZE, 1 )]
void CS_main( ComputeShaderInput IN )
{
    // Calculate min & max depth in threadgroup / tile.
    int2 texCoord = IN.dispatchThreadID.xy;
    float fDepth = DepthTextureVS.Load( int3( texCoord, 0 ) ).r;
 
    uint uDepth = asuint( fDepth );

정수(integer)에 대해서만 원자 연산(Atomic operation)을 수행할 수 있기 때문에 100행에서 부동 소수점 깊이의 비트를 unsigned integer로 재해석한다. 우리는 깊이 맵의 모든 깊이 값이 [0… 부호 없는 정수 깊이 값에 대해 산술 연산을 수행하지 않는 한 올바른 최소값과 최대값을 얻어야 한다.

ForwardPlusRendering.hlsl
   if ( IN.groupIndex == 0 ) // Avoid contention by other threads in the group.
    {
        uMinDepth = 0xffffffff;
        uMaxDepth = 0;
        o_LightCount = 0;
        t_LightCount = 0;
        GroupFrustum = in_Frustums[IN.groupID.x + ( IN.groupID.y * numThreadGroups.x )];
    }
 
    GroupMemoryBarrierWithGroupSync();

그룹 공유 변수를 설정하고 있기 때문에 그룹의 한 스레드만 설정하면 된다. 실제로 HLSL 컴파일러는 이러한 변수의 쓰기를 그룹의 단일 스레드로 제한하지 않으면 경쟁 조건 오류를 생성합니다.

그룹의 모든 스레드가 컴퓨팅 셰이더의 동일한 지점에 도달했는지 확인하기 위해 GroupMemoryBarrierWithGroupSync 함수를 호출합니다. 이렇게 하면 그룹 공유 메모리에 대한 모든 쓰기가 완료되고 그룹의 모든 스레드에 대한 스레드 실행이 이 지점에 도달했는지 확인할 수 있습니다.

다음으로 현재 타일의 최소 및 최대 깊이 값을 결정합니다.

 

 

 

 

  1. Msdn.microsoft.com, ‘Compute Shader Overview (Windows)’, 2015. [Online]. Available: https://msdn.microsoft.com/en-us/library/windows/desktop/ff476331(v=vs.85).aspx. [Accessed: 04- Sep- 2015] [본문으로]
  2. C. Ericson, Real-time collision detection. Amsterdam: Elsevier, 2005. [본문으로]
  3. [10] Msdn.microsoft.com, ‘earlydepthstencil (Windows)’, 2015. [Online]. Available: https://msdn.microsoft.com/en-us/library/windows/desktop/ff471439(v=vs.85).aspx. [Accessed: 11- Aug- 2015]. [본문으로]
  4. M. Billeter, O. Olsson and U. Assarsson, ‘Tiled Forward Shading’, in GPU Pro 4, 1st ed., W. Engel, Ed. Boca Raton, Florida, USA: CRC Press, 2013, pp. 99-114. [본문으로]
  5. O. Olsson and U. Assarsson, ‘Tiled Shading’, Journal of Graphics, GPU, and Game Tools, vol. 15, no. 4, pp. 235-251, 2011. [본문으로]
  6. C. Ericson, Real-time collision detection. Amsterdam: Elsevier, 2005. [본문으로]
  7. C. Ericson, Real-time collision detection. Amsterdam: Elsevier, 2005. [본문으로]
  8. 중단되지 않는 증가. 원자성은 프로그래밍에서 중단되지 않는 연산을 이야기한다. int count -= 0 atomic operation/ count+++ non atomic operation https://mygumi.tistory.com/111 [본문으로]
  9. T. Harada, J. McKee and J. Yang, ‘Forward+: A Step Toward Film-Style Shading in Real Time’, in GPU Pro 4, 1st ed., W. Engel, Ed. Boca Raton, Florida, USA: CRC Press, 2013, pp. 115-135. [본문으로]
  10. O. Olsson and U. Assarsson, ‘Tiled Shading’, Journal of Graphics, GPU, and Game Tools, vol. 15, no. 4, pp. 235-251, 2011. [본문으로]
반응형