본문 바로가기
셰이더 (Shader)/셰이더 프로그래밍 입문 - Pope Kim (완)

[HLSL] 챕터10 - 그림자매핑

by Minkyu Lee 2023. 3. 20.

 

새로 등장하는 그림자 기법 대부분은 그림자매핑 개량판이다.

 

// 원리

(a), (d), (f)는 가로막는 물체 X, 햇빛 O

(b), (c), (e) 가로막는 물체 O, 햇빛 X, 그림자 O

 

즉 그림자란 빛과 어떤 물체 사이에 다른 물체가 있을 때,

입사광선을 가로막아서 생기는 현상이다.

 

1, 2, 3 점선 화살표를 보라.

1번은 가로막는 물체 0개, 2번은 1개, 3번은 2개다.

2번이나 3번이나 그림자 생기는 것은 똑같다.

따라서 빛을 가로막는 물체가 몇 개가 있던, 제일 처음 가로막는 물체만이 중요하다.

 

입사광선을 가로막는 첫번째 물체가 그림자를 드리운다.

이 정리를 이용한 것이 그림자 매핑이다.

 

빛 사이에 물체가 있다는 것을 어떻게 알 수 있을까?

두 단계에 걸쳐 기법을 구현한다.

 

// 1단계 : 그림자 생성

빛을 가로막는 첫번째 물체의 깊이를 저장한다.

 

빛의 위치에서 아래를 바라보면서 각 물체의 깊이를 저장하는 것이다.

렌더링하는 원리와 동일하다.

카메라 위치에서 어느 한방향으로 색상 그리기

빛의 위치에서 어느 한방향으로 깊이 그리기

 

이를 텍스쳐에 담는다.

앞으로 그리는 위치를 텍스처 위에 렌더링하라고 명령한다.

이렇게 렌더링 결과 담는 텍스쳐를 렌더타깃이라고 한다. (render target)

텍스처에 저장해두면 픽셀셰이더에서 이 값 읽어와 그림자 계산에 사용한다.

 

그림자 생성단계에서 만든 이 텍스쳐를 그림자맵이라고 한다. (shadow map)

 

// 2단계 : 그림자 적용

점선 2를 보라. 물체 B와 점선2가 만나는 지점의 픽셀을 생각해보자.

해당 지점의 깊이는 0.5이다.

 

이 픽셀이 그림자 안에 존재하는지 판별해보자.

그림자 맵에서 이 위치를 샘플링해본다.

빛을 가로막은 첫번째 물체의 깊이값을 가져오는 것이다.

A가 가로막은 지점이므로 0.2이다.

 

따라서 현재 깊이 > 그림자 깊이로 내 앞에 가로막았음을 알 수 있다.

그림자를 드리운다.

 

그림자 기법은 개념적으로 복잡하지 않다.

설정과 디테일이 골치 아픈 기법이다.

 

// 정리

그림자 생성 단계와 적용 단계로 구분할 수 있다.

 

- 생성

렌더타깃을 정해둔다.

물체를 그린다.

깊이를 반환한다.

 

- 적용

빛으로부터 현재 픽셀까지의 깊이를 비교한다.

현재 깊이가 그림자맵의 깊이보다 크면 그림자를 드리운다.


  • 그림자 만들기 셰이더

정점셰이더

float4x4 gWorldMatrix;
float4x4 gLightViewMatrix; // Start 213p
float4x4 gLightProjectionMatrix;

float4 gWorldLightPosition; // gLightViewMatrix

struct VS_INPUT
{
   float4 mPosition: POSITION;
};

struct VS_OUTPUT
{
   float4 mPosition: POSITION;
   float4 mClipPosition : TEXCOORD1;
};


VS_OUTPUT vs_main( VS_INPUT Input )
{
   float4x4 lightViewMatrix = gLightViewMatrix;
   float3 dirZ = -normalize(gWorldLightPosition.xyz);
   float3 up = float3(0,1,0);
   float3 dirX = cross(up, dirZ);
   float3 dirY = cross(dirZ, dirX);
   
   lightViewMatrix = float4x4(
      float4(dirX, -dot(gWorldLightPosition.xyz, dirX)),
      float4(dirY, -dot(gWorldLightPosition.xyz, dirY)),
      float4(dirZ, -dot(gWorldLightPosition.xyz, dirZ)),
      float4(0, 0, 0, 1));
   lightViewMatrix = transpose(lightViewMatrix);
   

   VS_OUTPUT Output;
   Output.mPosition = mul(Input.mPosition, gWorldMatrix);
   Output.mPosition = mul(Output.mPosition, lightViewMatrix);
   Output.mPosition = mul(Output.mPosition, gLightProjectionMatrix);
   Output.mClipPosition = Output.mPosition;
   return Output;
}

// 위치값 전달

픽셀셰이더에서 위치값이 필요하다.

하지만 픽셀셰이더 안에서 POSITION 시맨틱 변수에 액세스할 수 없다.

따라서 다른 시맨틱으로 위치를 전달한다.

 

하지만 POSITION도 전달해줘야한다.

그렇지 않으면 래스터라이저가 픽셀 찾지 못한다.

 

// 변환

광원에서 바라본 뷰공간으로 변환한다.

광원-뷰공간 gLightViewMatrix이다.

 

이후 광원 투영공간으로 변환해야 물체를 그릴 수 있다.

(광원의 위치에서 바라본 투시 적용된 시점. 광원을 마치 카메라처럼 사용한다.)

광원-투영공간 gLightProjectionMatrix이다.

 

gLightViewMatrix는 변수 시맨틱 중에 사용할게 없다.

절대 옳은 방법이 아니지만 사용할 값이 없어서 정점셰이더 안에서 직접 제작한다.

직접 만드려면 광원의 위치와 빛의 바라보는 곳의 위치가 필요하다.

 

이후 제작 방법은 이해하지 않아도 된다.

 

위치를 물체공간 -> 월드공간 -> 광원뷰공간 -> 광원투영공간 으로 변환한다.

픽셀셰이더에서 깊이값을 반환할 때 사용할 값은 mClipPosition 멤버변수에 대입해준다.

 

픽셀셰이더

struct PS_INPUT
{
   float4 mClipPosition: TEXCOORD1;
};

float4 ps_main(PS_INPUT Input) : COLOR0
{   
   float depth = Input.mClipPosition.z / Input.mClipPosition.w;
   return float4(depth.xxx, 1);
}


float4x4 gWorldMatrix;
float4x4 gLightViewMatrix;
float4x4 gLightProjectionMatrix;

float4 gWorldLightPosition;

// 깊이 값을 반환

mClipPosition.z를 w를 나누어 반환해야한다.

 

왜 나누어야하는가?

3차원 좌표에서 위치벡터는 x,y,z,1로 표현한다.

방향벡터는 x,y,z,0으로 표현한다.

 

이는 방향벡터에 4x4 행렬을 곱했을 때 마지막 성분이 0이 되어 평행 이동의 영향을 받지 않도록 하기 위함이다.

따라서 0아니면 1이다.

그런데 위치벡터에 원근투영행렬을 곱하면 w가 1이 아닌 다른 값이 된다.

이런 좌표값을 동차좌표라고 한다.

동차좌표 값을 화면상의 좌표 값으로 사용혀려면 w값을 1로 바꿔줘야한다.

따라서 x/w, y/w ,z/w, w/w

즉, x/w, y/w, z/w 1이 된다.

 

골아프면 다음과 같이 이해하라.

원근투시법 사용시 멀리에 있는 물체 조그맣게 보인다.

2D 이미지 위 그려지는 물체들의 XY 좌표 값 잘못되므로 올바른 XY 구하기 위해 w로 나눈다.

 

// w로 나눠서 얻는 다른 효과

X,Y의 범위가 -1~1이 된다.

Z의 범위가 0~1이 된다.

 

// 렌더타깃 설정 

렌더몽키에서 add texture - add renderable texture를 만들고 기타 옵션을 설정해줘서 만든다.

그림자가 없는 부분은 1.0 값을 가져야함에 주의하라.

따라서 그림자 텍스쳐의 기본값은 1,1,1이다.

 

이렇게하여 모든 깊이 정보가 렌더 타깃에 들어간다.


  • 그림자입히기 셰이더 - 원환체 (토러스)

물체를 그리고 그림자 입힐 단계이다.

현재 픽셀의 깊이를 그림자맵에서 가져온 깊이와 비교한다.

 

정점셰이더

struct VS_INPUT
{
   float4 mPosition: POSITION;
   float3 mNormal: NORMAL;
};

struct VS_OUTPUT
{
   float4 mPosition: POSITION;
   float4 mClipPosition: TEXCOORD1;
   float mDiffuse: TEXCOORD2;
};

float4x4 gWorldMatrix;
float4x4 gLightViewMatrix;
float4x4 gLightProjectionMatrix;
float4 gWorldLightPosition;
float4x4 gViewProjectionMatrix;

VS_OUTPUT vs_main(VS_INPUT Input)
{
   VS_OUTPUT Output;
   
   // for gLightViewMatrix. Don't understand this code!!!
   float4x4 lightViewMatrix = gLightViewMatrix;
   float3 dirZ = -normalize(gWorldLightPosition.xyz);
   float3 up = float3(0,1,0);
   float3 dirX = cross(up, dirZ);
   float3 dirY = cross(dirZ, dirX);
   
   lightViewMatrix = float4x4(
      float4(dirX, -dot(gWorldLightPosition.xyz, dirX)),
      float4(dirY, -dot(gWorldLightPosition.xyz, dirY)),
      float4(dirZ, -dot(gWorldLightPosition.xyz, dirZ)),
      float4(0, 0, 0, 1));
   lightViewMatrix = transpose(lightViewMatrix);
   
   // scene transform
   float4 worldPosition = mul(Input.mPosition, gWorldMatrix);
   Output.mPosition = mul(worldPosition, gViewProjectionMatrix);
   
   // depth transform
   Output.mClipPosition = mul(worldPosition, lightViewMatrix);
   Output.mClipPosition = mul(Output.mClipPosition, gLightProjectionMatrix);
   
   // diffuse
   float3 lightDir = normalize(worldPosition.xyz - gWorldLightPosition.xyz);
   float3 worldNormal = normalize(mul(Input.mNormal, (float3x3)gWorldMatrix));
   Output.mDiffuse = dot(-lightDir, worldNormal);
   return Output;
}

광원으로부터의 깊이가 필요하다.

그래야 깊이를 그림자맵과 비교할 수 있기 때문이다.

따라서 mClipPoisition 멤버변수가 필요하다.

 

// 전역변수

카메라에서 뷰행렬과 투영행렬 가져와야한다.

gViewProjectionMatrix가 그것이다.

 

// 변환

광원 뷰행렬을 구한다.

 

공간변환을 한다.

두 번 해야한다.

첫번째 공간변환은 물체를 그리기 위한 변환이다.

두번째 공간변환은 광원으로부터의 물체의 깊이를 구하기 위한 변환이다.

 

픽셀셰이더

sampler2D ShadowSampler; // get shadowmap
float4 gObjectColor;

struct PS_INPUT
{
   float4 mClipPosition: TEXCOORD1;
   float mDiffuse: TEXCOORD2;
};

float4 ps_main(PS_INPUT Input) : COLOR
{
   float3 rgb = saturate(Input.mDiffuse) * gObjectColor;
   float currentDepth = Input.mClipPosition.z / Input.mClipPosition.w;
   
   // get shadowmap uv coordinate
   float2 uv = Input.mClipPosition.xy / Input.mClipPosition.w; // -1 ~ 1
   uv.y = -uv.y; // flip
   uv = uv * 0.5 + 0.5; // xy -> uv
   
   float shadowDepth = tex2D(ShadowSampler, uv).r; // It is R32F Texture. so use only red.
   if(currentDepth > shadowDepth + 0.0000125f)
   {
      rgb *= 0.5;
   }
   return(float4(rgb, 1.0));
}

현재 깊이와 그림자맵의 깊이를 비교한다.

그에 따라 그림자를 그린다.

 

그림자맵을 사용할 것이다.

따라서 그림자 텍스쳐 샘플러를 선언한다.

 

작업공간 패널에서 add texture object로 쉐도우맵을 불러온다.

텍스쳐를 읽을 수 있게 된다.

 

// 그림자맵에서 빛을 가렸던 물체의 깊이를 가져오기

tex2D함수를 사용한다. 그런데 UV 좌표는 무엇을 사용하나?

투영공간 좌표계를 이해해야한다.

 

투영공간의 XY 좌표 범위는 -1,1 ~ 1,-1 이다.

변환한다.

 

- XY좌표 -> UV좌표

u = x / 2 + 0.5

v = -y / 2 + 0.5

 

- UV좌표 -> XY좌표

x = u * 2 - 1

y = -v * 2 - 1

 

이를 이용해 uv를 구한다.

그림자의 깊이를 가져온다.

깊이를 비교해, 현재 깊이가 더 깊으면 그림자를 덮어씌운다.

 

그림자맵의 깊이 + 매우 작은 값보다 큰 경우에 그림자를 입힌다.

매우 작은 값을 주는 이유는 부동소수점 에러 문제, 텍스쳐 저장시 생기는 오차 문제 등을 예방하기 위함이다.

매우 작은 값은 언제나 변할 수 있다.

 


  • 그림자 입히기 셰이더 - 평면

평면을 하나 더 추가한다.

셰이더 코드에는 변화가 없다.

다만 렌더몽키에서는 동일한 셰이더를 사용해 여러 물체 그리는 법이 없어서 패스 추가한다.

 

  • 그림자 매핑에서 발전한 고급기법

그림자 매핑의 품질을 향상시키는 여러 기법이 있다.

 

- 캐스케이드 그림자맵

해상도 문제점 해결을 위한 것이다.

영역별 여러개 그림자맵을 만든다.

카메라에서 가까운 픽셀들이 비교적 해상도가 높은 그림자맵 이용한다.

 

- 퍼센티지 클로저 필터링

그림자 외곽선 부드럽게 필터링한다.

텍스쳐 사용시 이중선형 필터와 개념 비슷하다.

 

- 베리언스 그림자맵

하드웨어 텍스쳐 필터링 가능하도록 깊이 값 저장한다.

깊이값 두 채널로 나눠 저장한다.

그림자가 겹칠 때 빛이 새어 들어오는 부작용이 종종 생긴다.

댓글