계단식 그림자 맵


원문


계층형 그림자 맵 (Cascaded Shadow Maps)

Rouslan Dimitrov
NVIDIA Corporation

계층형 그림자 맵

문서 변경 이력

버전날짜책임자변경 사유
1.0Rouslan Dimitrov최초 릴리스
1.1Miguel Sainz그림 및 사소한 편집 수정

계층형 그림자 맵

그림자 맵은 게임 엔진에서 사실적인 그림자를 얻기 위해 매우 인기 있는 기법입니다. 넓은 공간에 이를 사용하려고 할 때, 그림자 맵은 조정하기 더 어려워지고 표면 여드름(surface acne)과 앨리어싱을 나타낼 가능성이 더 높아집니다. 계층형 그림자 맵(CSM)은 뷰어 근처에서는 깊이 텍스처의 더 높은 해상도를 제공하고 멀리 있는 곳에서는 더 낮은 해상도를 제공함으로써 앨리어싱 문제를 해결하는 데 도움이 되는 알려진 접근법입니다. 이는 카메라 뷰 절두체를 분할하고 화면 오류를 일정하게 만들기 위한 시도로 각 파티션에 대해 별도의 깊이 맵을 생성함으로써 수행됩니다.

CSM은 일반적으로 넓은 지형에 대해 태양이 드리우는 그림자에 사용됩니다. 모든 것을 단일 그림자 맵에 캡처하려면 매우 높고 비현실적인 해상도가 필요할 것입니다. 따라서 여러 개의 그림자 맵이 사용됩니다 - 가까운 객체만을 커버하여 각각이 상세한 그림자를 드리우는 그림자 맵; 거친 해상도로 멀리 있는 모든 것을 캡처하는 또 다른 그림자 맵과 선택적으로 그 사이에 몇 가지 더 많은 그림자 맵들. 이러한 분할은 합리적인데, 멀리 있는 객체들이 드리우는 그림자는 화면 공간에서 단지 몇 픽셀만을 차지하고 가까이 있는 객체들은 화면의 상당 부분을 차지하는 그림자를 드리울 수 있기 때문입니다.

그림 1-1은 평행 분할 CSM의 개략도를 보여주는데, 여기서 분할은 근거리 평면과 원거리 평면에 평행한 평면이며 각 슬라이스는 그 자체로 절두체입니다. 태양은 방향성 광원이므로 연관된 라이트 절두체들은 박스입니다(빨간색과 파란색으로 표시).

알고리즘은 다음과 같이 진행됩니다:
• 모든 라이트의 절두체에 대해, 라이트의 관점에서 장면 깊이를 렌더링합니다.
• 카메라의 관점에서 장면을 렌더링합니다. 프래그먼트의 z 값에 따라 조회할 적절한 그림자 맵을 선택합니다.

그림 1-1. 가장 오른쪽 나무는 근거리 그림자 맵에 캡처되고 다른 두 개는 왼쪽에 있습니다. 측면의 뷰어가 볼 때 왼쪽 그림자는 블록 모양이지만, 카메라는 세 그림자 모두를 대략 동일한 앨리어싱으로 인식할 것입니다.


화면 공간 앨리어싱 오류를 개선하려는 여러 가지 다른 인기 있는 접근법들이 있습니다. 여기에 언급된 것들은 전통적으로 전체 뷰 절두체에 대해 작동합니다. 이러한 기법들을 CSM의 모든 절두체 슬라이스에 적용할 수는 있지만, 이는 시각적 품질을 크게 향상시키지 않으며 훨씬 더 큰 알고리즘 복잡성의 비용이 따릅니다. 사실, CSM은 원근 그림자 맵의 이산화로 생각할 수 있습니다.

원근 그림자 맵 (Perspective Shadow Maps, PSM) [2] – 그림 1-1은 라이트 절두체의 일부가 잠재적 차폐물을 포함하지 않고 카메라 뷰 절두체 밖에 있으며 이 그림자 맵의 부분이 낭비됨을 보여줍니다. PSM의 핵심 아이디어는 라이트 절두체를 정확히 뷰 절두체와 일치하도록 래핑하는 것입니다. 대략적으로, 이는 현재 카메라의 후-원근 공간에서 표준 그림자 매핑을 적용함으로써 달성됩니다. 이 방법의 단점은 광원의 위치와 유형이 직관적이지 않게 변경되므로 이 방법이 컴퓨터 게임에서 그다지 일반적이지 않다는 것입니다.

**라이트 공간 원근 그림자 맵 (Light Space Perspective Shadow Maps, LiPSM) [4]**은 광원의 방향을 변경하지 않는 방식으로 카메라 절두체를 래핑합니다. 라이트 방향에 수직인 시선 광선을 가진 새로운 라이트 절두체가 구축됩니다(그림자 맵에 평행). 절두체는 카메라 절두체와 잠재적 그림자 캐스터를 포함하도록 적절하게 크기가 조정됩니다. PSM과 비교하여 LiPSM은 특수한 경우가 많지 않지만 그림자 맵 텍스처를 완전히 사용하지 못합니다.

**사다리꼴 그림자 맵 (Trapezoidal Shadow Maps, TSM) [5]**은 라이트에서 볼 때 카메라 절두체의 경계 사다리꼴(LiPSM의 절두체 대신)을 구축합니다. 알고리즘은 다른 접근법과 유사하게 진행됩니다.


상세 개요 (Detailed Overview)

다음 논의는 OpenGL SDK의 계층형 그림자 맵 데모를 기반으로 하며 수행되는 단계들을 자세히 설명할 것입니다.

그림자 맵들은 각 레이어가 별도의 그림자 맵을 보유하는 텍스처 배열에 저장되는 것이 가장 좋습니다. 이는 픽셀 셰이더에서 효율적인 주소 지정을 가능하게 하며 모든 레이어가 본질적으로 동일한 방식으로 처리되므로 합리적입니다.

그림자 맵 생성 (Shadow-map generation)

그림 1-1을 보면, 현재 라이트 절두체(박스) 밖의 모든 것은 렌더링되지 않아야 한다는 것을 알 수 있는데, 이는 모든 그림자 캐스터와 카메라 절두체 슬라이스가 그 안에 포함되어 있다는 조건 하에서입니다. 어떤 면에서, 라이트의 절두체는 카메라 절두체 슬라이스의 경계 상자이며, 근거리 측면이 모든 가능한 차폐물을 캡처할 수 있을 만큼 충분히 확장되어 있습니다. 만약 나무 위에 차폐물 B(예를 들어, 새)가 있었다면, 박스는 적절하게 확장되어야 하며, 그렇지 않으면 B는 그림자를 드리우지 않을 것입니다.

그림 2-1. 카메라 절두체 분할

알고리즘의 첫 번째 단계는 카메라 눈 공간에서 뷰 절두체 분할의 z 값들을 계산하는 것입니다. 그림자 맵의 픽셀이 측면 길이 ds를 가진다고 가정합시다. 그것이 드리우는 그림자는 그림자가 드리워지는 객체의 법선과 위치에 따라 화면의 일부 dp를 차지합니다. 다이어그램 2-1을 참조하면,

dp = (n/z) × ds × (cosφ/cosθ)

여기서 n은 뷰 절두체의 근거리 거리입니다.

이론적으로, 화면에서 정확히 동일한 오류를 제공하려면 dp/ds가 일정해야 합니다. 또한, 우리는 원근 오류만을 최소화하고 투영 오류를 담당하므로 코사인 의존 요소도 상수로 취급할 수 있습니다. 따라서,

dz = ρ × z × ds, ρ = ln(f/n)

여기서 ρ의 값은 s ∈ [0;1] 제약 조건에 의해 강제됩니다.

위 방정식을 z에 대해 풀고 이산화하면 (분할 수 N이 크다고 가정), 분할 지점은 지수적으로 분포되어야 하며:

z_i = n × (f/n)^(i/N)

여기서 N은 총 분할 수입니다. 더 자세한 유도는 [1]을 참조하시기 바랍니다.

그림 2-2. 뷰어 바로 위의 라이트로부터의 라이트 절두체

그러나 일반적으로 N이 1과 4 사이이므로, 방정식은 그림자 해상도가 급격하게 변경되어 분할 지점을 가시적으로 만듭니다. 그림 2-2는 불일치의 이유를 보여줍니다: 뷰 절두체 밖에 있지만 라이트 절두체 내부에 있는 영역은 보이지 않기 때문에 낭비됩니다; 그러나 N → ∞일 때 이 영역은 0으로 갑니다.

이 효과에 대응하기 위해 i에 대한 선형 항이 추가되고 차이는 거의 보이지 않게 됩니다:

z_i = λ × (n × (f/n)^(i/N)) + (1-λ) × (n + (i/N) × (f-n))

여기서 λ는 보정의 강도를 제어합니다.

z에서의 분할이 알려진 후, 현재 절두체 슬라이스의 모서리 점들은 화면의 시야각과 종횡비로부터 계산됩니다. 자세한 내용은 [3]을 참조하십시오.

그림 2-3. 크롭 행렬과 z-경계 변경의 효과

한편, 라이트의 모델뷰 행렬 M은 라이트 방향을 바라보도록 설정되고 일반적인 직교 투영 행렬 P=I가 설정됩니다. 그런 다음 카메라 절두체 슬라이스의 각 모서리 점 p는 라이트의 동차 공간에서 p_h = PMp로 투영됩니다. 각 방향의 최소값 m_i와 최대값 M_i는 라이트 절두체(박스)와 정렬된 경계 상자를 형성하며, 이로부터 일반 라이트 절두체를 정확히 일치시키기 위한 스케일링과 오프셋을 결정합니다. 이는 실제로 z에서 최상의 정밀도를 얻고 x와 y에서 가능한 한 적게 손실되도록 보장하며 크롭 행렬 C를 구축함으로써 달성됩니다. 마지막으로, 라이트의 투영 행렬 P는 P=CP_z로 수정되며, P_z는 m_z와 M_z에 근거리 및 원거리 평면을 가진 직교 행렬이고:

       | S_x   0    0   O_x |
C =    | 0    S_y   0   O_y |
       | 0     0    1    0  |
       | 0     0    0    1  |

여기서:

  • S_x = 2 / (M_x - m_x)
  • S_y = 2 / (M_y - m_y)
  • O_x = -0.5 × (M_x + m_x) × S_x
  • O_y = -0.5 × (M_y + m_y) × S_y

라이트의 절두체를 절두체 슬라이스와 정확히 일치시킬 수 있지만, 이는 원근 그림자 맵에서처럼 라이트의 방향과 유형을 변경한다는 점에 유의하십시오.

장면은 또한 각 절두체 슬라이스 i에 대해 절두체 컬링되고 모든 것이 모델뷰 및 투영 행렬로 (CP_fM)_i를 사용하여 깊이 레이어로 렌더링되며 전체 절차는 모든 절두체 파티션에 대해 반복됩니다.

최종 장면 렌더링 (Final scene rendering)

이전 단계에서 그림자 맵 1…N이 생성되었으며 이제 객체가 그림자 안에 있는지 결정하는 데 사용됩니다. 렌더링된 모든 픽셀에 대해 그 z 값은 이전에 계산된 N개의 z 범위와 비교되어야 합니다. 다음을 위해, i번째 범위에 속한다고 가정합시다. 픽셀 셰이더는 이 값을 후-투영 공간에서 받는 반면, 원래는 눈 공간에서 계산되었다는 점에 유의하십시오.

그런 다음 프래그먼트의 위치는 카메라 역 모델뷰 행렬 M_c^(-1)을 사용하여 월드 공간으로 변환됩니다(완전한 역행렬일 필요는 없습니다 - 스케일링이 사용되지 않는다면 상위 3x3 부분만 전치될 수 있습니다). 그 후, 슬라이스 i에 대한 라이트의 행렬과 곱해집니다. 변환은 다음 합성 행렬에 캡처됩니다: (CP_fM)_i M_c^(-1).

마지막으로, 투영된 점은 [-1; 1]에서 [0; 1]로 선형적으로 스케일됩니다. 이 모든 변환 후, 프래그먼트의 (x,y) 위치는 실제로 i번째 깊이 맵의 텍스처 좌표이고 z 좌표는 라이트에서 파티클까지의 거리를 알려줍니다. 조회를 수행함으로써 우리는 같은 방향에서 라이트에서 가장 가까운 차폐물까지의 거리를 봅니다. 이 두 값을 비교하면 프래그먼트가 그림자 안에 있는지 알 수 있습니다.

그림 2-4. 여러 깊이 맵에서 그림자를 드리우는 삼각형


코드 개요 (Code Overview)

함께 제공되는 OpenGL SDK 샘플에는 다음과 같은 소스 파일들이 포함되어 있습니다:

terrain.cpp – 환경을 로드하고 렌더링하기 위한 함수 정의들을 포함합니다. 그림자 매핑 알고리즘에 필요한 유일한 메서드는 Draw()이며, Load()와 GetDim()은 초기화 중에 호출되어 월드의 경계 상자를 로드하고 적절하게 설정합니다.

utility.cpp – 메인 코드를 더 읽기 쉽게 만들기 위한 많은 헬퍼 함수들을 포함합니다. 여기에는 셰이더 로더; 카메라 처리; 메뉴, 키보드 및 마우스 처리 등이 포함됩니다.

shadowmapping.cpp – 이 파일은 제시된 알고리즘의 주요 핵심 코드를 포함하며 그림자 맵과 최종 이미지를 화면에 생성하고 그리기 위한 모든 코드를 포함합니다.

대략적으로, terrain.cpp와 utility.cpp는 샘플을 실행하는 데 필요한 프레임워크를 제공하며, 실제 게임에서는 게임 엔진이 이를 제공합니다. 이러한 비유에서 display()는 렌더링 루프의 일부이며, 이 샘플에서는 makeShadowMap()과 renderScene()를 호출합니다.

리스트 3-1. makeShadowMap()에서 발췌 (약간 수정됨)

void makeShadowMap() 
{ 
    /* ... */ 
    // 라이트의 방향을 설정
    gluLookAt(0, 0, 0, 
        light_dir[0], light_dir[1], light_dir[2], 
        1.0f, 0.0f, 0.0f); 
    
    /* ... */
    // 카메라 공간에서 본 각 분할의 z 거리를 계산 
    updateSplitDist(f, 1.0f, FAR_DIST); 
    
    // 모든 그림자 맵에 대해: 
    for(int i=0; i<cur_num_splits; i++) 
    { 
        // 월드 공간에서 카메라 절두체 슬라이스 경계 점들을 계산
        updateFrustumPoints(f[i], cam_pos, cam_view); 
        
        // 카메라 절두체 슬라이스를 완전히 둘러싸도록
        // 라이트의 뷰 절두체를 조정
        applyCropMatrix(f[i]); 
        
        // 현재 깊이 맵을 렌더링 대상으로 만들기
        glFramebufferTextureLayerEXT(GL_FRAMEBUFFER_EXT, 
            GL_DEPTH_ATTACHMENT_EXT, depth_tex_ar, 0, i); 
        
        // 지난번의 깊이 텍스처를 지우기
        glClear(GL_DEPTH_BUFFER_BIT); 
        
        // 장면 그리기
        terrain->Draw(minZ); 
        
        /* ... */
    } 
    /* ... */
} 

renderScene()는 셰이더 유니폼들을 설정하고(리스트 3-2 참조) 그런 다음 CSM 없이 하는 것처럼 장면을 렌더링합니다. CSM에 중요한 코드 조각은 이 패스 동안 적용되는 픽셀 셰이더에 있습니다.

리스트 3-2. shadow_single_fragment.glsl (약간 수정됨)

uniform sampler2D tex;              // 지형 텍스처
uniform vec4 far_d;                 // 모든 분할의 원거리 거리들
varying vec4 vPos;                  // 뷰 공간에서 프래그먼트의 위치
uniform sampler2DArrayShadow stex;  // 깊이 텍스처들
 
float shadowCoef() 
{ 
    int index = 3; 
    
    // 이 프래그먼트의 깊이에 기반하여 
    // 조회할 적절한 깊이 맵을 찾기
    if(gl_FragCoord.z < far_d.x) 
        index = 0; 
    else if(gl_FragCoord.z < far_d.y) 
        index = 1; 
    else if(gl_FragCoord.z < far_d.z) 
        index = 2; 
    
    // 이 프래그먼트의 위치를 뷰 공간에서
    // 스케일된 라이트 클립 공간으로 변환하여
    // xy 좌표가 [0;1]에 놓이도록 한다.
    // 직교 광원에 대해서는 w로 나눌 필요가 없다는 점에 유의
    vec4 shadow_coord = gl_TextureMatrix[index]*vPos; 
    
    // 비교할 현재 깊이를 설정
    shadow_coord.w = shadow_coord.z; 
    
    // glsl에 어느 레이어에서 조회할지 알려주기
    shadow_coord.z = float(index); 
    
    // 하드웨어가 비교를 수행하도록 하기
    return shadow2DArray(stex, shadow_coord).x; 
} 
 
void main() 
{ 
    vec4 color_tex = texture2D(tex, gl_TexCoord[0].st); 
    float shadow_coef = shadowCoef(); 
    float fog_coef = clamp(gl_Fog.scale*(gl_Fog.end + vPos.z), 
        0.0, 1.0); 
    gl_FragColor = mix(gl_Fog.color, (0.9 * shadow_coef * 
        gl_Color * color_tex + 0.1), fog_coef); 
}