Rampage

Detail Object Map - 지형 위에 풀숲 (2)

J'Heel; 2023. 7. 29. 16:16

이전 포스트에서 디테일 오브젝트를 위한 클래스를 선언했다. 이제 정의를 작성해야하는데, 해야할 코딩은 렌더링 부분과 디테일 맵과 텍스쳐를 읽어 리소스를 준비하는 것이 되겠다.

 

 

우선 바이너리로 저장한 디테일 맵을 읽는 것과 텍스쳐를 로드하는 것은 생성자에서 하기로 한다.

생성자의 시그니쳐는 다음과 같다.

 

CDetailObject(ID3D12Device* pd3dDevice, ID3D12GraphicsCommandList* pd3dCommandList, std::shared_ptr<CShader> pShader, void* pContext)

 

생성자는 다이렉트12 디바이스와 커맨드 리스트, 그리고 셰이더에 대한 포인터와 void* 형의 pContext를 받는다.

여기서 pContext로는 터레인의 포인터를 넘겨줄 것이다. 터레인 위에 그려질 디테일 정보들은 터레인 포면에 붙어서 존재해야 하므로 터레인으로부터 각 위치에서의 높이 정보를 알 수 있어야 한다. 이를 생성자의 인수로 넘겨받아 세팅할 계획이다.

 

 

우선 텍스쳐를 로드하고 이를 저장하자. 코드는 다음과 같다.

이 두 코드는 풀 텍스쳐를 로드한 뒤 생성자로부터 넘겨받은 pShader로 다시 텍스쳐를 넘겨서 저장하고 동시에 셰이더 리소스 뷰를 만들고 있다. 그리고 마지막엔 SetShader라는 함수를 호출한다. 이렇게 되면 디테일 오브젝트는 셰이더에 대한 포인터를 저장하고 있게 되며 셰이더는 렌더링에 사용할 텍스쳐를 가지는 구조가 된다.

 

 

텍스쳐 로드가 완료되었으니 다음으로 할 일은 디테일 맵을 읽어 해당 정보를 활용해 버텍스 버퍼를 만들어야 한다. 버텍스 버퍼의 구조는 다음과 같다.

번호 시맨틱 포맷
0 POSITION R32G32B32_FLOAT
1 SIZE R32G32B32_FLOAT
2 COLOR R32G32B32_FLOAT
3 NORMAL R32G32B32_FLOAT

 

이렇게 하여 버텍스 버퍼는 4개의 구성 요소를 가진다. 여기서 SIZE 의 경우 float 형 실수를 3개를 가지는데, 이는 잘못된 건 아니고 일부러 이렇게 구성했다. 왜 이렇게 했는지는 추후 설명.

 

 

그러면 구조는 결정됐으니 버퍼를 만들면 되는데, 또 중요한 게 하나 더 있다. 다음으로 정해야하는 건 버텍스 버퍼를 구성할 때 몇 개의 버퍼를 이용해 구성할 건지에 따라 구현도 달라진다. 내 경우 버텍스 버퍼를 위한 컨테이너를 4개를 만들어 드로우 콜을 할 때 적절한 순서로 Set 할 계획이다.

 

알 사람은 알겠지만 반대로 버텍스 버퍼를 하나의 버퍼로만 구성하여 Set해주는 것도 가능한데, 그렇게 하고자 한다면 이를 위한 구조체를 선언하여 메모리를 할당해줘야할 것이다. 대충 예시를 들자면

struct DetailVertex
{
	XMFLOAT3 xmf3Position;
    XMFLOAT3 xmf3Size;
    XMFLOAT3 xmf3Color;
    XMFLOAT3 xmf3Normal;
}

이런 식으로 구성하여 원하는 갯수만큼 메모리를 할당하면 된다.

 

 

하지만 나는 4개의 정점 버퍼를 만들어 각각 따로 연결할 것이므로 버퍼들을 다음과 같이 선언한다.

정점 버퍼들

std::vector<XMFLOAT3> 형으로 4개의 변수를 멤버 변수로 선언했다. 각 버퍼에 적절한 값들을 넣어 리소스를 생성하고, m_pd3dVertexBufferViews에 정점 버퍼 뷰를 생성하여 넣어줄 것이다.

 

 

우선 유니티에서 얻어낸 디테일 맵을 읽어 버퍼에 값을 채워준다.

위와 같이 바이너리 파일을 열고, 파일 로드를 위한 메모리도 할당한다. 미리 생성자 인수로 받아온 pContext를 CSplatTerrrain*로 변환해 기억해두고, 터레인의 크기도 미리 기억해줬다.

 

그리고 read()를 이용해 바이너리 파일에 저장된 데이터를 한 번에 읽어들였고, 이를 순차탐색을 하면서 총 디테일 개수를 계산했다.

 

 

데이터를 읽었으니 이제 이 데이터를 이용해 버퍼를 생성해준다.

앞에서 계산한 디테일 갯수인 nDetails만큼 버퍼들의 크기를 재할당해주고 루프를 돌면서 data[i]의 값이 0보다 큰 경우, 즉 해당 위치에 디테일 오브젝트가 존재하는 경우 버퍼에 값을 기록한다. 이 때 터레인의 전체 크기를 고려하여 적절한 위치에 오브젝트가 위치할 수 있도록 약간의 계산을 했다.

 

각 정점은 4개의 구성요소를 가지도록 했다고 했는데, 그 중에서 색상과 노멀, 그리고 크기 뒤에 WindOffset이라는 부분은 난수를 생성하여 넣어줬다. 이는 색상을 다르게 하여 보다 자연적인 풀숲의 모습을 낼 수 있도록 하고, 노멀의 경우 빌보드를 쓰지 않고 각자 다른 방향을 바라보도록 하게 만들기 위함이다. WindOffset는 나중에 자세히 설명하겠지만 바람에 대한 값이다.

 

여기까지 한 뒤 이제 리소스를 생성하고 뷰를 만들어주면 끝난다.

 

 

오브젝트 생성은 완료가 되었으니 다음으로 볼 건 Render() 함수다. Render 함수는 길이가 길진 않다.

Render()

Render() 에서는 위와 같이 셰이더의 Render를 호출하여 셰이더를 Set해주고, UpdateShaderVariables()를 호출하여 렌더링에 필요한 리소스를 셋해준다. 이때 아까 로드한 Grass 텍스쳐가 셋된다. 현재 게임오브젝트 구조에선 오브젝트가 머터리얼을 가지고, 머터리얼이 셰이더와 텍스쳐를 가지기 때문에 위와 같이 첫번째 머터리얼에 접근하여 필요한 함수를 호출하는 것을 볼 수 있다.

 

[0] 이런 방식으로 임의로 인덱스에 접근하는 것은 추후 메모리 액세스 오류를 발생시킬 여지가 있으므로 사실 좋은 코드는 아니기에 검사하는 코드를 넣어서 사용하도록 하자. 나같은 경우 일부러 위험한 방식으로 접근하게 놔뒀는데, 머터리얼이 제대로 생성되었는지 빠르게 확인하기 위해 이렇게 했다.

 

만일 머터리얼이 정상적으로 생성되지 않아 메모리 액세스 오류가 뜬다면 비주얼 스튜디오가 해당 위치에서 오류가 났다는 것을 바로 잡아줄 것이기 때문에 Release 모드 환경을 자주 사용하는 환경에서 버그가 있을 것으로 예상되는 지점을 확인하기에 썩 나쁘지 않은 방법이라 생각한다. 다만... 확인하고 고치기까지 했으면 이렇게 남겨두지 말고 안전하게 수정해두자..

 

 

아무튼 이렇게 셰이더를 설정하고 프리미티브 토플리지와 버텍스 버퍼를 설정해준 뒤 DrawInstanced를 호출하여 렌더링을 하면 된다. 나는 하나의 정점을 입력으로 넘기고 이후 기하세이더에서 평면 메쉬를 생성할 것이므로 입력 조립기의 프리미티브 토플로지를 포인트 리스트로 설정해줬다.

 

 

 

그럼 이제 가장 중요한 셰이더를 만들어줘야한다.

CTerrainDetailShader 선언

CTerrainDetailShader의 선언이다. CBilboardObjectShader를 상속받아 만들었는데, CBilboardObjectShader에서 쓰던 것을 그대로 쓰는 건 얼마 없어서 굳이 이것도 설명할 필요는 없겠다.

 

중요하게 봐야할 부분은 CreateInputLayout() 부분이다.

CreateInputLayout() 정의

이 함수에서는 입력 조립기로 넘어갈 정점의 구조를 정의하여 D3D12_INPUT_LAYOUT_DESC 를 반환한다.

 

앞서 정점 버퍼의 구조를 4개의 버퍼를 따로 만들어 각각 연결할 것이라 했으므로 D3D12_INPUT_ELEMENT_DESC의 크기는 4개가 되어 각각 순서대로 POSITION, SIZE, COLOR, NORMAL이 들어가게 된다. 포맷은 위에 표에서 적힌 대로 DXGI_FORMAT_R32G32B32_FLOAT가 들어간다. 그리고 그 바로 뒤에 숫자가 0, 1, 2, 3이 들어가는 것을 볼 수 있는데, 여기는 슬롯 번호가 들어가는 부분이다. 나는 정점 버퍼를 따로 만들었기 때문에 각 구성요소는 서로 다른 슬롯으로 넘어가게 되므로 이렇게 슬롯 번호를 다르게 순서에 알맞게 넣어줬다.

 

그 바로 뒤엔 모두 숫자가 0으로 통일되어 있는데, 여기에는 AlignedByteOffset 이 지정되야 하는 부분으로 만일 정점 버퍼를 여러개로 나누는 것이 아니라 구조체를 만들어 하나의 버퍼에 모두 넣겠다고 한다면 이부분에 적절한 오프셋을 주어 어느 바이트에서 해당 구성요소가 시작되는지 명시해 줘야한다. 나는 슬롯이 다르고 각 슬롯의 맨 처음이 구성요소의 시작이 되므로 0으로 넣으면 된다.

 

 

CTerrainDetailShader 는 여기까지만 봐도 무방할 것 같으니 다음으로는 셰이더 코드를 봐야할 시간이다.

DetailVertexShader

정점 셰이더는 딱히 볼 게 없다. 그냥 input으로 넘어둔 데이터를 거의 그대로 넘기고 있다. 다만, gcbToLightSpaces 라는 것을 계산하고 있는데, 이는 조명 연산과 그림자 연산을 위해 조명 공간에서의 해당 정점의 위치를 계산하는 코드다. 이 위치를 UV로 이용하여 그림자 매핑에 사용한다.

DetailGeometryShader 上

기하 셰이더에서는 이제 정점 하나를 기준으로 4개의 정점을 계산하여 사각형 메쉬를 만들어 넘겨준다. 그렇기 때문에 maxvertexcount는 4로 설정했다.

 

이 부분에서 하는 일은 메쉬의 중심점과 UP 벡터와 Look 벡터를 먼저 계산한다. UP은 항상 위를 향하게, Look은 앞에서 버퍼를 만들때 normal값을 넣었다. normal를 난수를 이용하여 랜덤한 값으로 생성했으니 모든 디테일 오브젝트가 바라보는 방향은 다르게 나올 것이다.

 

그 다음으로는 vWorldOffsets 이라는 부분인데, 이것의 경우 각 디테일 오브젝트의 중심점으로부터 만들어지는 4개의 정점들이 중심에서 어느 방향으로 오프셋을 주어 펼쳐지면 Look을 향해 바라보는 사각형 메쉬가 만들어지는 지를 미리 결정해둔 부분이다.

 

그리고 WindDir은 바람의 방향과 세기를 계산하고 있다. 바람의 방향은 x축으로 고정되어 있으나 size.z 로 넘어온 값은 난수로 생성된 값이므로 오브젝트마다 미묘하게 다른 바람 세기를 가지게 된다.

 

DetailGeometryShader 下

그 다음에는 4번 루프를 돌며 4개의 정점을 생성해 outputStream에 넣어준다. 여기서 앞에서 계산한 WorldOffset를 더해주는 것으로 적절한 위치에 4개의 정점이 위치하도록 만들어주고 있다. 그 외에 WindDir에 Sin() 값 등을 곱하여 더해주고 있는데, 이는 시간에 따라 바람에 의해 풀숲이 흔들리는 것을 표현하기 위함이다.

 

sin()은 -1 ~ 1의 값을 리턴하므로 여기에 인수로 현재 누적 시간을 건내줌으로 시간에 따라 변화하는 값을 반환하도록 하였다. 이로 인하여 각 정점에 바람의 방향에 따른 오프셋이 주어져 조금씩 움직이게 될 것이다. 거기에 value 값을 곱하여 그 정도를 조절했다.

 

그런데 이렇게만 하면 현재 4번의 루프를 돌며 모든 정점에 해당 연산이 들어가게 되어 풀숲이 바닥에서 미끄러지듯 왔다 갔다 하는 모양새가 된다. 바람에 의해 풀이 흔들리게 되면 보통 풀의 윗부분이 흔들거리고 밑둥은 가만히 있는 것이 이치에 맞다. 고로 정점의 UV 를 이용하여 이를 uv의 y 값이 0일 경우(위로 갈수록) 온전히 바람의 영향을 받도록하고, 1일 경우(아래로 갈수록) 바람의 영향을 덜 받도록하여 풀숲의 윗부분은 많이 흔들리고 아래 밑붕은 가만히 있을 수 있도록 만들었다.

DetailPixelShader

마지막으로 픽셀 셰이더에서는 텍스쳐 샘플링을 한 뒤, 알파값이 0에 가까운 픽셀은 discard를 하여 그리지 않도록 하고 있다. 그 다음에는 조명 연산을 적용한 뒤, lerp()를 하여 output으로 값을 넣고 있는데 이 lerp()부분은 uv의 y 좌표를 이용해 풀숲이 밑둥 부분은 기존 색상보다 더 어두운 색을 가질 수 있도록 만들어주는 부분이다. 그냥 풀숲의 모든 부분이 같은 색을 가지는 것보다 아래로 갈수록 색을 어둡게하여 좀 더 자연스러운 풀숲을 연출해보고자 했다.

 

결과

최종적인 결과물이 이렇다. 보이는 것처럼 풀숲들이 이리저리 나있고, 아래로 갈수록 어두욱 색을 가지도록 한 것을 볼 수 있으며 풀숲 자체의 색상도 어느정도 난수를 주어 만들었기 때문에 모두 같은 색을 가지지 않는 상태다. 그림자가 지는 부분은 어두워서 잘 보이진 않는 상태인데, 현재 자세히보면 풀숲 자체는 그림자를 가지지 않는 것을 볼 수 있다. 이는 의도된 부분으로 풀숲이 주변 사물에 가려질 경우 그림자가 지게 되도록하되 풀숲 자체는 그림자가 없도록 연출했다.

 

여기까지해서 디테일 오브젝트도 끝났다. 생각보다 분량이 많아진 것 같은데... 그렇다고 나눠쓰기도 애매한 것 같아서 한 번에 적었다.

 

아무튼 이젠 또 다른 주제로 포스팅을 하게 될 터인데, 이번에도 미리 스포를 좀 해보자면

빨간 동그라미를 주목

저 빨간색 동그라미 처진 부분이 되겠다.

 

저 부분을 자세히 보면 나무근처에 약간 빛줄기 같은 것이 나있는 것을 볼 수 있다. 저기 말고도 주변 다른 나무나 큰 바위들을 보게 되면 마찬가지로 빛줄기가 있는 것을 볼 수 있다.

 

이는 라이트 섀프트(Light Shaft) 혹은 갓쓰레이(God's Ray)라 불리는 효과로 태양 빛이 사물에 의해 가려지는 상황에서 빛이 산란되어 나타나는 효과다.

 

그래서 다음 포스트 주제는 Screen Space Light Shaft 구현이 되겠다. 그런고로 아무튼 오늘은 여기까지.