본문 바로가기

GDC

Procedural Grass in 'Ghost of Tsushima'

https://youtu.be/Ibe1JBF5i5Y?si=bs_ttX0wL185WeUE

 

위 영상을 요약 정리한 글입니다. 마음대로 번역한 것이고 잘 못알아듣겠는 부분은 그냥 스킵했음

(말씀이 워낙 빠르셔서 저는 0.75배속으로 겨우 들었습니다)

 

문제점

전통적인 Grass Card 를 이용한 렌더링에는 크게 두 가지 이슈가 있었음

: 첫째로, Grass card 전체에 애니메이션을 적용하는 것이 제한되었음.

둘째로, 밀도가 높아질 경우 오버드로우 문제가 심각했음

장면 내의 10만개의 grass blade 중 83000개 렌더링 하는 데 2.5ms가 걸림.

grass에서 아티스트가 config 할 수 있는 것은 거의 없었음. 여러 필드에 같은 grass를 사용.

바람의 방향이 플레이어가 가야 할 방향을 알려주기 때문에, grass도 바람과 상호작용 해야 했음.

 

Compute Shader

월드를 타일로 나눈다.

 

타일에는 터레인의 높이(Height), 터레인을 렌더링 할 머터리얼에 대한 정보와 함께,

해당 위치에 어떤 종류의 풀이 어느 정도의 길이로 배치될 지에 대한 정보가 포함되어 있다.

 

 

이 타일들은 다시 우리가 실제 렌더링 할 더 작은 타일 단위로 나뉘어진다.

이 작은 타일들은 부모 타일들을 샘플링 한 텍스처로 구성된다. 이 텍스처는 512x512이다 - 약 39cm 마다 1개의 텍셀이 맵핑된다.

갹 렌더 타일마다 1개의 컴퓨트 셰이더를 수행한다 -

각 컴퓨트 셰이더에서는 각 Lane마다 다음과 같은 작업을 한다.

  • Lane ID를 Position값으로 해서 랜덤한 offset를 준다 (= Jitter) 이것이 blade 의 위치가 된다.
  • Distance Culling 과 Frustum Culling을 수행한다.
  • 앞에서 언급한 Texture를 샘플링해 Grass의 종류와 길이를 결정한다.
  • 해당 Lane의 Grass Type이 없거나 Height 가 0이면, 그 Lane은 드랍된다.
  • Occlusion Culling을 수행한다 -

각 Grass는 type을 가진다 - 이것은 아티스트가 만든 파라미터의 집합이다.

각 타일은 Grass Type에 맵핑된 512x512 텍스처를 가지는데,이 텍스처에 8 bit짜리 인덱스로 Grass Parameter의 배열 인덱스를 저장한다. 텍셀 주변의 4개 GrassType을 모은 뒤, 이 중 1개를 랜덤하게 골라 해당 위치의 Grass Type을 결정하도록 하였다 - 이렇게 하면, 단순히 Point Sample 을 수행하는 것 보다 경계면에서 더 부드럽고 자연스러운 트랜지션을 표현할 수 있었고, 저해상도 텍스처로부터 GrassType을 결정해도 문제가 되지 않았다.

 

 

그 다음, 16개의 float으로 구성된 각 Blade별 Instance Data를 채운다.

  • 3개의 float은 blade의 위치이다.
  • 그 다음 2개의 float은 blade가 바라보는 방향 벡터이다. 이 2D 벡터는 Blade가 향하고 있는 방향을 의미한다.
  • 그 다음 float은 해당 Blade의 위치의 바람 세기이다. 이것이 애니메이션의 강도를 결정한다.
  • Blade 별 위치 기반 Hash - 애니메이션을 포함해 각 blade별로 다른 값을 가지도록 함
  • Grass Type : 아티스트가 작성한 파라미터 셋트 중 어떤것을 사용할 것인지 결정
  • Clumping 정보 (2 floats) : 아래에서 더 자세히 설명할 것이다.
  • Clump Color
  • Height, Width, Tilt, Bend, Side Curve풀이 어떻게 상호작용 하는지는 전적으로 아티스트가 작성한 파라미터에 따라 달라지며, 각 Grass Type 마다 달라진다.
    → 이 값들이 위에서 언급한 Per-blade Hash, 어느 Clump 에 귀속되는지, 바람 방향, 터레인의 경사 방향, 풀 사이를 지나가는 오브젝트들과 그 방향, 카메라의 위치 등에 의해 결정된다.

위에서 언급한 Clump 에 관한 정보는, 각 지역마다 풀의 속성을 다르게 주기 위해 사용되었다.

지역이 가진 환경 특성 (일조량, 토양의 질 등) 에 따른 풀의 양상을 파라미터에 반영하기 위해, Clump 라는 단위로 풀들을 조직화하였다.

여기에 절차적인 Voronoi 알고리즘을 사용하였다.

 

  1. 2D 공간 안에 주어진 한 점 위치를 기준으로 가장 가까운 9개의 그리드 위의 점을 얻는다.
  2. 9개의 점 위치는 배리에이션을 위해 Jitter 된다 (= 랜덤한 벡터로 offset 된다)

 

이렇게 얻은 9개의 점을 샘플링 해서, 가장 가까운 포인트의 Clump 에 파라미터를 반영한다.

Compute shader에서의 처리과정을 정리해 보면, 아래와 같다.

 

 

먼저, 첫 번째 compute shader가 Instance data를 채우고, Blade 갯수를 누적한다.

첫 번째 셰이더가 끝난 직후에 시작되는 두 번째 compute shade가 앞에서 계산된 갯수를 현재 타일의 Draw Call 의 Indirect Draw Arguments에 옮긴다. 두 번째 과정은 아주 순식간에 이루어진다.

Indirect draw argment가 준비되면, 인스턴스 데이터에 기반해 버텍스 셰이더가 수행된다. 마지막으로 픽셀 셰이더가 셰이딩을 수행한다.

 

Data Pipeline

 

그러나 모든 타일을 동시에 처리하지는 않는다 - 메모리 코스트가 아주 높아질 것이기 때문이다. 대신,. 각 인스턴스 버퍼가 8개 타일의 인스턴스 데이터를 저장한다. 첫 번째 수행으로 4개의 타일에 대해 Compute shader를 수행하고, 버텍스, 픽셀 셰이더가 수행되는 동안 나머지 4개의 타일에 대한 컴Compute shader를 수행한다. 이렇게 버퍼 두 개를 쓰는 전략을 통해 GPU가 계속 바쁘게 움직일 수 있고, 메모리 버짓에 맞춰 동작할 수 있다.

Vertex Shader

모든 Grass Blade의 버텍스 셰이더는 Instanced index draw call로 수행된다 index와 instance id 만으로 아웃풋 데이터를 만든다. 각 타일이 1개의 드로우콜이며, High LOD인지, Low LOD인지만 다르다.

 

 

각 버텍스마다 blade의 length 상에서 어느 위치에 있는지 0 ~ 1 사이의 값, 그리고 오른쪽에 있는지? 왼쪽에 있는지도 알아야 했다. 풀의 커브 모양도 중요했기 때문에, 버텍스가 길이 방향으로 균등하게 배분되지 않도록 했다. (이 부분은 아티스트가 직접 버텍스가 어디에 위치해야 할 지 리스케일링할 수 있는 파라미터를 노출시켜 해결하였다)

두 가지 LOD 사이를 왔다갔다 하게 하는 것은 약간 어려웠다. 버텍스가 더 적은 LOD 로 전환될 때 튀는 현상이 있을 수 있었다 - High LOD 가 Low LOD로 전환될 때, 버텍스의 위치가 Low LOD 쪽으로 서서히 변하도록 하였다.

(이건 영상을 봐야 함.. 아래 상태에서 버텍스들이 이동해서 겹쳐지면서 위 이미지와 같은 모양으로 정렬됨)

 

Low LOD 의 타일 사이즈는 High LOD 타일 사이즈보다 두 배 더 크게, 하지만 같은 갯수의 Grass Blade를 가지도록 했다.

 

이것은 Grass blade의 간격은 두 배 더 넓다는 것을 의미한다.

LOD가 전환되기 전, High LOD Grass Plate 4개 중 1개를 숨겨 LOD 간 전환을 Seamless 하게 하였다.

 

풀의 길이가 어느 정도 짧은 구간에서는, 같은 버텍스 갯수를 유지하되 blade를 두 개로 늘렸다. 이렇게 해서 더 빽빽하게 풀을 배치할 수 있었다. 둘 중 어느 쪽인지 구분하는 데는 Vertex ID를 사용할 수 있다.

버텍스가 월드 스페이스에서 어떻게 움직일지 결정해야 했다. 각 Grass blade의 셰입은 Cubic Besizer Curve(3차 베지에 커브) 이다. 이것을 이용하면,

  • 각 버텍스들의 위치를 간단히 계산할 수 있으며,
  • 파생 값(derivative)들도 쉽게 계산할 수 있다. (노멀 방향을 찾는 데 derivative를 사용하였다)
  • Control Point로 blade의 셰입을 쉽게 제어할 수 있다. 애니메이션을 줄 수 있을 뿐 아니라, 각 풀들의 모양도 컨트롤할 수 있다.

아래 그래프는 blade의 밑바닥으로부터의 버텍스까지의 상대적인 거리를 나타낸 것이다.

 

그래프의 맨 끝점은 tilt 파라미터에 의해 좌우된다.

midPoint는 Bend 파라미터에 의해 좌우된다. Bend 파라미터가 0일 경우, midpoint는 base 와 tip을 잇는 선분 위에 그대로 놓이게 된다. Bend 파라미터가 0보다 작을 경우, midPoint가 위로 밀려나고, 베이스와 팁 사이를 잇는 선으로부터 멀어진다. 위에서 본 2개로 나누어진 blade에서는 두 개의 blade를 서로 반대 방향으로 밀어내되, 같은 방향을 바라보게끔 유지하도록 했다.

 

 

베지에 커브를 계산하고, Facing 방향과 직교하는 방향으로 노멀을 구한 뒤, 각 버텍스들을 길이 방향으로 전진시키면서 버텍스 위치를 계산한다. 끝에 가까워질수록 폭이 줄어들게끔 한다.

어떻게 애니메이팅 할까? Wind System은 유저 파라미터에 의해 결정된 모양의 2D Perlin Noise로, 바람이 부는 방향으로 스크롤된다. CPU와 GPU 양쪽에서 모두 샘플링 될 수 있고 비교적 오버헤드가 적은 방법을 추구한 결과였다. 2D Noise가 해당 위치에서 바람 방향으로 밀어내는 값을 알려주고, 그것을 우리의 2D 방향 벡터와 결합해 바람에 반응하는 시스템을 만든다. 파티클 시스템이 속한 몇몇 Grass들은 더 복잡한 움직임을 위해, 추가적인 Noise 레이어를 사용하였다.

 

 

이렇게 완성된 Grass의 움직임을 보자.

Facing 방향은 이미 바람 방향에 영향을 받아 Compute shader에서 정해졌으므로, 위아래로의 움직임 (Bobbing & Popping) 만 구현하면 되었다.

 

 

이 Bobbing은 단순한 Sine 으로, 그 Phase 오프셋은 각 Blade별 Hash에 따라 달라지고, 또한 버텍스의 Blade 상의 위치에 따라서도 달라진다.

Blade별로 offset이 다르기 때문에, 각 Blade별로 움직이는 양상이 다르다.

Bezier 커브의 arclength가 계산하거나 제어하기 쉽지 않았다 - blade가 움직일 때 마다 arclength도 달라지기 때문이다. 그러나 애니메이션이 비교적 제한되도록 유지된다면, 이것은 그렇게 눈에 띄지 않았다.

 

 

Grass blade의 노멀을 약간 바깥쪽으로 기울였다 - 이렇게 하면 좀 더 자연스럽고 둥글려진(Rounded) 모습이 된다. 그리고 버텍스를 더 추가하는 것 보다 훨씬 싸다.

 

 

뿌려진 Grass들로 필드가 꽉 차 보이는 모습을 연출하기 위해 Blade가 향한 방향 벡터가 View 벡터와 직교할 경우, 카메라 방향으로 blade의 방향을 약간 움직였다. 이렇게 하면 유저 시야에서 풀을 미묘하게 두껍게 보이게 해, 아주 얇은 트라이앵글을 래스터라이징 하는 데 드는 시간을 줄일 수 있을 뿐 아니라, 필드를 더 풍부하게 보이게 한다.

 

 

먼 거리에서의 Specular 또한 문제였다. 아주 먼 거리에 있는 풀의 노멀은스크린 스페이스 상에서 아주 다양한 방향이었고, 특히 비가 오는 장면일 때 더 번들번들했는데, 풀이 애니메이팅 될 때 움직이면서 더 노이즈가 생겼다.

 

 

이 문제를 해결하기 위해, 카메라에서 멀어질수록 blade의 노멀 벡터를 clump의 common 노멀로 보간하였다. 이렇개 하면 필드의 모양은 유지하되, 노이즈를 감소시킬 수 있었다. 추가적으로 픽셀 셰이더에서 Gloss를 감소시키기도 했다. Gloss가 sub pixel 디테일에서의 표면 노멀이 어떻게 달라지는지의 표현인 점을 감안하면, 이것은 합당한 선택이다. 노멀 변화량 (variance) 이 증가하기 때문에, gloss를 줄인 것이다.

 

Pixel Shader

이렇게 Grass blade와 버텍스를 얻었다. 이제 이 트라이앵글을 셰이딩 할 차례이다. 머터리얼 데이터를 채우면 된다.

Gloss는 blade의 길이 방향으로 늘려서 반복시킨, 단순한 1D 텍스처이다.

Gloss Texture

 

Diffuse로는 두 가지 텍스처를 사용했는데, 첫 번째는 풀의 줄기를 나타내는 텍스처이다. 두 번째 텍스처는 실제 풀의 색상을 나타내는 2D 텍스처인데, V 방향은 풀의 길이 방향 색상의 변화를 나타낸다. 예를 들어, 풀의 아래쪽은 진한 색이고 끝 쪽으로 갈수록 더 밝은 색이 되도록 표현할 수 있었다. U 방향은 blade가 속한 clump 에 따라 달라졌는데, 실제 사용에서는 Clump 단위로 컬러가 달라지는 양을 아주 적게 사용하였다 - 이것은 보다 페인팅 같은 룩을 유지하기 위함이었다.

 

Translucency 에서는 상수값을 사용했는데, 풀의 길이에 따라 달라진다.

풀이 가장 두꺼운 아랫부분은 조금 낮고, 끝으로 갈수록 더 감소한다.

 

AO도 마찬가지이다. 아랫부분은 다른 풀들에 의해 빛이 보통 차폐되기 때문에 더 어둡고, 끝으로 갈수록 밝아진다.

 

왜 SSAO에 의존하지 않고, AO를 아웃풋에 포함시켰는지 궁금할 것이다.

풀은 Velocity 버퍼에 velocity를 기록하지 않게 되어 있다. 이전 프레임의 풀 버텍스 위치를 얻기 위해, 이전 프레임의 바람 데이터를 캐쉬하였다 - 바람의 속도나 방향이 달라지고 있기 때문이었다. 플레이어가 풀 사이를 걸어다니는 중일 수도 있었기 때문에, 마지막 프레임의 Displacement Buffer값도 가지고 있어야 했다. Compute Shader에서 이렇게 바람과 Displacement 관련된 부분들이 처리되고 있었기 때문에, 이렇게 처리된 blade 별 데이터들을 버텍스 셰이더에 저장했다가 사용할 필요가 있었다.

이것이 기술적으로는 가능했지만, 메모리 사용량과 퍼포먼스 때문에 실제로는 그렇게 할 수가 없었다.

(이 부분은 잘 안들려서 생략) 풀이 왔다갔다 하면서 서로 가까워졌다가 멀어졌다가 하기 때문에, SSAO가 계산되더라도 정상적이지 않았을 것이다. (결국 비용 때문에 사용하지 않았다는 뜻인 듯)

 

Miscellaneous

필드가 풀로만 구성되어 있지는 않았다 - 룩을 완성하기 위해 아티스트가 손수 작성한 에셋을 필드에 절차적으로 배치하였다. Pampas grass (이미지에 보이는 갈대)나 작은 꽃들이 그 예시이다.

 

Grass system이 실제 게임 오브젝트를 생성하는 것이 아닌, GPU Instanced draw 시스템을 사용하기 때문에, Grow 시스템은 효과적으로 최소한의 데이터 - 위치, 방향, Culling 정보 등 - 만을 스트림 하고, 이것으로부터 아주 많은 양의 에셋을 드로우했다. Graw 시스템에 대한 자세한 내용은 관련한 다른 프레젠테이션을 참고.

타일이 로드되면, Compute shader를 수행해 Graw system에서 사용하는 것과 같은, Grass field 에셋들에 필요한 데이터를 생성한다. 카메라에서 가장 가까운 3x3 타일의 정보를 메모리에 저장하고, 그 이외의 것은 버린다. 이렇게 하면 멀리 있는 에셋에 대한 데이터를 다 가지고 있지 않아도 된다. 배치(Placement) 알고리즘은 blade의 그것과 거의 비슷하다.

 

아주 먼 LOD 처리 - 요런 뷰에서..

 

→ 이런 먼 거리에서 개별 풀을 렌더링 하는 것은 무의미했으므로, 아티스트가 작성한 텍스처가 출력되도록 하였음.

 

인게임에서 플레이어가 풀에 숨어야 하는 경우가 있었는데, Grass에 대한 정보가 모두 GPU 친화적인 텍스처에 저장되어 있기 때문에, CPU에서 접근하기 어려웠다 - 타일을 로드했을 때 Compute shader에서 Fast GPU 텍스처를 복사해, CPU에서 접근하기 쉽도록 하였다. 이것을 바탕으로 Phys mesh를 생성해, 플레이어가 보이는 상태인지 아닌지를 판별하는 데 사용하였다. 숨을 수 있는 풀인지 아닌지 Grass type으로부터 판별하였다.

 

그림자에도 특별한 최적화 기법을 적용하였다. 그림자를 드리우는 라이트에 대해서도 완전한 Compute - Vertex - Pixel 셰이더를 사용할 수 있었지만, 이것은 아주 비쌌기 때문에 아주 제한적인 경우에만 사용하였다. 대신 아래에 깔린 Terrain의 임포스터 시스템에 의지했는다. 먼저 터레인의 버텍스 높이를 해당 위치의 Grass의 위치와 일치하도록 높이고, Shadow map 에 기록될 depth를 dither 된 패턴으로 offset 한다. 이것을 shadow field 터레인과 결합하면, 풀의 shadow density와 대략 일치하는 것을 얻게 된다.

 

Proxy mesh들은 하드 엣지를 가지고 있어 해결하기 쉽지 않았지만, 게임 내 대부분의 장면에서 결과는 괜찮았고, 퍼포먼스는 뛰어났다.

 

더 디테일한 표현을 위해서 Screen space 그림자를 사용했다. 스크린 스페이스에서는 풀의 thickness 를 인지할 수 없었지만, 풀은 어차피 아주 얇기 때문에 상관없었다. 스크린 스페이스에서는 아주 적은 범위만을 커버할 수 있었지만, 풀이 어차피 화면의 적은 부분만을 차지했다.

 

 

이것이 인게임 결과이다 .

더 발전할 수 있어 보이는 점

  1. 터레인 위에 배치된 에셋 위에도 아티스트가 플래그를 지정해 그 위로 풀이 배치될 수 있도록, 그래서 풀이 자라난 터레인과 그 위에 배치된 에셋 사이의 어색한 경계가 생기지 않고, 자연스럽게 연결될 수 있도록 개선했으면 한다.

 

2. Bezier 커브로 제어되는 버텍스는 빠르고 유연하지만, 절차적으로 생성되는 다른 타입들의 폴리지들이 있다 - 돌이나 다른 풀들이 그것이지만, 절차적으로 버텍스까 생성하기에는 어려울 것이다. 하지만 이 에셋들도 절차적으로 생성되게끔 하면 좋을 것 같다 - 예를 들어, 동물들 몸 표면의 Fur 카드들을 절차적으로 배치할 수도 있다.

 

3. LOD 전환 시 멀리 있는 Grass blade 들 중 3/4를 날리는 것…

이것은 퍼포먼스 상으로는 좋지만 유연하지 않은 방법임. 추후에는 타일 사이즈와 Loading간의 관계를 없애고 싶음.

'GDC' 카테고리의 다른 글

The Challenges of Rendering an Open World in Far Cry 5  (0) 2024.01.18