Intro
2021년 12월에 Image & Form과 Thunderful에서 The Gunk라는 게임을 출시했습니다. 처음에는 Xbox Series S/X와 Xbox One에 출시되었고, 그 다음 해 봄에 PC용 Steam에 출시되었습니다. The Gunk는 외계 행성을 탐험하고 Gunk라는 부패한 끈적끈적한 물질을 정화하는 게임입니다. 플레이어는 Rani라는 우주 탐험가를 조종합니다. 온갖 잔해물을 빨아들일 수 있는 거대한 기계 장갑을 장착한 그녀는 행성에서 Gunk와 부패를 청소하는 임무를 맡고, 이를 통해 행성의 무성한 초목도 회복합니다.
이 프로젝트에서 저는 코드 리더로 일했고 또한 끈적끈적한 슬라임과 그것이 세상에 미치는 파괴적인 시각적 효과에 대한 렌더링 기술을 개발하는 일을 맡았습니다.
이 게임에서 우리는 플레이어가 청소할 수 있도록 세상을 오물로 어지럽히고 싶었습니다. 오물 자체는 팀에서 쉽게 배치할 수 있어야 했고, 플레이어는 오물에 움푹 들어간 곳과 구멍을 만들고 완전히 제거할 수 있어야 했습니다. 게임은 렌더링을 위한 GPU뿐만 아니라 게임플레이 이벤트와 오디오를 구동하기 위한 CPU에서도 청소 진행 상황과 오물이 있는 위치를 추적할 수 있어야 했습니다. 또한 플레이어가 발견하기 어려웠을 아주 작은 오물 조각을 자동으로 제거할 수 있어야 했고 매우 관대한 것이어야 한다는 것을 발견했습니다.
원하는 느낌을 얻기 위해 볼륨메트릭을 사용하여 접근하기로 결정하고 그것이 적합한지 확인했습니다. 볼륨메트릭은 구름과 안개를 렌더링하는 데 사용할 수 있지만 불투명한 표면도 사용할 수 있습니다. 이러한 종류의 표면을 렌더링할 때는 고려해야 할 여러 가지 방법이 있습니다. Marching Cubes와 같은 폴리곤 기반 마칭 방법은 한 가지 방법이고, splat 기반 표면은 또 다른 방법이며, Raymarching도 또 다른 방법입니다. 후자는 Voxel 기반 Signed Distance Fields와 결합하여 탐구하기로 결정했습니다. Raymarching은 빠르게 실행되고 노이즈 레이어를 적용하면 꽤 높은 해상도를 쉽게 얻을 수 있을 것이라고 내기를 걸었습니다.
그리고 폴리곤을 사용하지 않고 무언가를 렌더링하는 것은 탐구하는 것이 매우 흥미로웠습니다.
이 글에서는 컴퓨트 셰이더와 기존 UE 머티리얼 파이프라인을 조합하여 언리얼 엔진 4에서 엉터리 작업을 하고 렌더링하기 위해 만든 기술을 분석해보겠습니다. 여기에는 비동기 컴퓨트를 최적화하기 위해 엔진에 적용한 변경 사항도 포함됩니다.
제가 여기에 설명하고 구현한 솔루션은 거인의 어깨 위에 서 있는 셈입니다. 저는 정말 인상적인 프로젝트에서 많은 영감을 얻었습니다. 특히 Sebastian Aaltonen이 Claybook에서 Conetracing을 사용하여 한 작업, Alex Evans와 Media Molecule이 Dreams에서 SDF 렌더링에 접근한 다양한 방법, 그리고 Inigo Quilez가 SDF에 대해 수행하고 가르친 환상적인 작업 등이 그렇습니다.
이 기술에 대한 작업은 지금까지 제가 추진한 작업 중 가장 흥미로운 작업이었고, 여전히 많은 잠재력을 가지고 있으므로 읽어보는 것이 흥미로울 것이라고 바래봅니다.
Game loop
게임에서 정크(역자 주 gunk : 오물투성이, 엉망인채로 쌓여진형태. 본문에서 계속 언급되므로 가능하면 원문 그대로 정크로 번역)는 다음과 같이 작동합니다.
- 플레이어는 정크가 들끓는 지역을 찾습니다. 정크 주변 지역은 오염되고 회색이며 적이 생성되고 경로가 차단될 수 있습니다.
- 플레이어가 정크로 이동하면 체력을 잃습니다.
- 플레이어는 정크를 흡수하여 터널을 파고 조금씩 제거할 수 있습니다. 높고 낮은 곳과 모서리 주변의 정크를 찾기 위해 주변을 조사해야 합니다.
- 모든 정크가 없어지면 해당 지역은 다시 살아납니다. 식물이 자라기 시작하고 해당 지역은 다시 무성해집니다. 초목과 지형의 회색 오염 효과가 사라집니다. 새로운 경로가 열릴 수 있습니다.
Overview
먼저, 언리얼 에디터에서 어떻게 엉뚱한 요소를 작성하는지, 그리고 플레이어가 상호작용할 수 있는 데이터를 생성하는 파이프라인은 어떻게 되는지, 그리고 렌더러에서 그 데이터를 사용하는지에 대한 간략한 개요부터 설명하겠습니다.
게임 레벨이 시작되기 전에 구 모양의 Unreal 액터 세트가 로드되어 정크가 있어야 할 위치를 정의합니다. 이들은 Unreal Engine의 다른 액터와 마찬가지로 레벨 디자인 팀에서 레벨에 배치합니다. 정크를 배치하는 데 일반 Unreal 액터를 사용하면 무료로 비파괴적 워크플로(non-destructive workflow)를 얻을 수 있습니다. 액터는 개별적으로 또는 그룹으로 이동, 제거, 실행 취소 및 크기 조정이 가능합니다. 여러 액터가 엉킴 영역 모양을 형성합니다. 디자이너는 레벨을 편집하는 동안 최종 엉킴 모양을 시각화할 수도 있습니다.
게임 시작 시, 정크스피어(gunk sphere) 액터들은 정크 영역으로 복셀화됩니다. 이는 게임에서 정크가 얼마나 남았는지, 어디에 남았는지 확인하고 정크에 대한 충돌을 처리하는 데 사용됩니다. 정크 영역은 또한 표면을 레이마칭하는 데 사용하는 데이터인 Signed Distance Field(SDF) 생성의 기초 역할을 합니다. 이러한 단계에서는 많은 일이 발생하며, 잠시 후에 이에 대해 자세히 알아보겠습니다! 필드를 레이마칭하는 것만으로도 위 이미지의 대략적인 모양이 됩니다.
낮은 chunky 해상도를 해결하기 위해 다양한 크기의 타일 가능한 노이즈를 적용할 수 있습니다. 약간 조정하면 낮은 해상도를 꽤 효과적으로 숨길 수 있습니다. 이는 게임에서 많은 2D 효과에 공통적인 트릭입니다. 노이즈를 쉽게 애니메이션화하여 끈적끈적하고 거품이 많은 외계인 끈적끈적한 모습을 만들 수도 있습니다. 하지만 노이즈는 SDF에 고유한 골치 아픈 문제와 문제점을 안겨줍니다. 한 가지 큰 문제는 노이즈 볼륨과 기본 덩어리 볼륨을 레이마칭하는 데 프레임당 시간이 더 많이 걸린다는 것입니다. 하지만 이를 통해 높은 지각 충실도를 위해 매우 거친 3D 그리드를 사용할 수 있어 우리에게는 좋았습니다.
표면이 완전히 렌더링되면 per-pixel depth 오프셋이 있는 전체 화면 쿼드를 사용하여 화면에 결과를 렌더링하고, 이에 따라 일반적인 Unreal 머티리얼 그래프를 적용할 수 있으며, 이는 모든 폴리곤 오브젝트처럼 씬에 적용되고 라이팅에 반응합니다. 이는 나중에 살펴보겠지만 UE의 기본 픽셀 뎁스 오프셋 계산을 약간만 조정하면 가능합니다.
각주: 정크 주위에 보이는 회색 손상은 별도의 효과와 시스템으로, 워크플로가 비슷한 방식으로 처리됩니다. 디자이너들은 액터를 배치하여 정크 주위에 손상되어야 할 부분을 지정할 수 있습니다. 그런 다음 손상과 정크는 게임 로직에서 결합되므로 손상된 영역의 모든 정크가 지워지면 손상이 사라집니다. 손상 효과는 머티리얼과 블루프린트에 의해 작동하며 실제로는 포스트 프로세싱이 아닙니다. 아마도 이에 관련된 솔루션과 최적화에 대한 별도의 기사를 작성해야 할 것입니다.
정크를 추가하는 방법과 게임 플레이 중에 발생하는 일에 대한 대략적인 개요입니다. 다음 장에서는 각 단계를 구동하는 기술을 더 자세히 살펴보겠습니다!
언리얼에서 일반 언리얼 액터 오브젝트를 사용하여 어떻게 정크와 주변 손상이 레벨에 추가되는지에 대한 샘플입니다. 우리는 모든 커스텀 셰이더를 에디터에서도 렌더링할 수 있도록 했습니다. SDF는 각 수정마다 에디터에서 재생성되므로 시리얼라이즈 할 필요가 없습니다.
CPU : From spheres to voxels
CPU 측에서 우리는 구형 액터를 게임 플레이 중에 추적할 수 있는 복셀 볼륨으로 처리합니다. 또한 플레이어가 상호 작용하는 대상이기도 합니다. 우리가 최종적으로 렌더링하는 것보다 훨씬 거칠지만 해상도는 이 게임에서 사용하는 종류의 상호 작용과 충돌 감지에 충분합니다.
Gunk voxels
우리는 구형 액터를 실제 게임플레이 영역에서 그룹화합니다. 우리는 이러한 그룹을 사용하여 게임플레이 이벤트를 구동하고 그룹별로 distance culling을 처리할 수 있으며 또한 이를 사용하여 덩어리의 볼륨을 생성할 수 있습니다. 여기서 첫 번째 단계는 구체를 복셀화하는 것입니다. 그룹은 구체에 따라 복셀을 추가하는 캔버스라고 생각합니다. 그룹은 차례로 남아 있는 활성 덩어리 복셀의 양을 추적합니다.
복셀은 3D로 밀도를 표현하는 편리한 방법입니다. 본질적으로 3D 그리드의 큐브입니다.
흡수되는 정크 위에 시각화된 복셀. 중앙에 사각형으로 표시된 복셀 "HP", 더 클수록 더 많음.
복셀의 그리드는 거칠고 복셀은 큽니다. 우리는 가능한 한 그리드를 거칠게 유지하여 많은 복셀과 많은 메모리가 필요하지 않도록 하려고 합니다. 하지만 복셀은 여전히 충분히 작아야 하므로 너무 블록형이거나 뾰족한 덩어리 모양이 되지 않습니다.
덩어리의 복셀은 복셀의 "Health Point"를 나타내는 단일 정밀도 부동 소수점으로 표현됩니다. 이것은 원하는 흡수 속도를 조정하는 데 사용되지만 SDF 생성시 볼류메트릭 앤티 앨리어싱을 돕는 데에도 사용됩니다. 정크의 얼룩은 기본 HP는 바깥쪽 가장자리에서 더 낮습니다.
Mesh cut-away
Gunk는 바닥과 벽과 같은 레벨 지오메트리 내부에 생성되어서는 안 됩니다. 그러나 구형 액터를 사용하여 Gunk 덩어리를 정의할 때 레벨 지오메트리와 겹칠 수 있다는 점을 고려해야 합니다. 지오메트리 내부에서 Gunk를 제거하기 위해 선택한 솔루션은 Gunk 영역 내부의 지오메트리를 복셀화하고 이를 사용하여 Gunk 복셀을 빼는 것입니다. 이 시스템은 메시를 사용하여 Gunk를 배치하는 데 역으로도 활용할 수 있지만, 시도해 본 적은 없습니다.
레벨은 위에서 아래로 복셀 열의 2차원 그리드를 레이캐스팅하여 복셀화하고 선을 따라 모든 진입점과 출구점을 찾습니다.
이 작업은 레벨 로드당 한 번만 수행됩니다.
게임의 많은 곳에서 사용되는 랜드스케이프 액터의 경우, 우리는 시스템이 그것들을 땅 아래 무한한 두께로 간주하도록 결정했습니다. 우리는 overlapping 랜드스케이프나 랜드스케이프 위의 동굴을 사용하지 않았기 때문에 숏컷을 사용할 수 있었습니다.
Player interaction
Absorbing
플레이어는 정크를 흡수할 수 있습니다. 이는 본질적으로 플레이어가 정크 스피어(구체)를 뺄 수 있다는 것을 의미합니다. 이러한 구는 대략 원뿔 모양에 맞춰 정렬되며, 이에 따라 세그먼트 크기를 변경할 수 있습니다. 원뿔 방향을 따라 오클루전 검사를 수행하고 정크 내부의 세그먼트를 더 작게 만들어 정크 표면만 제거되도록 합니다. 이 오클루전 검사는 CPU에서 정크 복셀을 쿼리하여 각 원뿔 세그먼트 부분이 정크 내부에 있는지 여부를 확인하고, 이는 각 세그먼트 제거 강도에 영향을 미칩니다. 이를 사용하여 흡수 속도와 거리 및 밀도 효과를 조정했습니다.
복셀이 흡수된 곳에서 우리는 정크 덩어리에서 플레이어의 흡수 장갑 노즐로 이동하는 입자를 생성합니다. 이러한 입자는 또한 먼지로 렌더링되며, 나중에 자세히 설명합니다.
Overlap detection
모든 gunk 충돌 검사는 복셀의 겹침을 검사(overlapping detection)하여 수행됩니다. 플레이어는 gunk와 접촉하면 데미지를 입습니다. CPU에서 거리 필드를 생성하는 실험을 했고, GPU에서 리드백을 사용하여 초기 실험도 했습니다(게임이 2D 기반일 때). 하지만 우리 게임에서는 복셀 데이터만 검사해도 충분하므로 결국 겹침 감지 시스템을 매우 단순하게 유지할 수 있었습니다. 겹침 감지가 더 매끄러운 표면에서 수행되는 것처럼 느껴지도록 하기 위해 가장 가까운 복셀의 trilinear 보간을 수행합니다. 여기서 복셀의 HP의 앤티앨리어싱 특성은 약간 더 매끄럽게 느껴지도록 하는 데 도움이 됩니다(Gunk 복셀 참조).
삼선형 보간(Trilinear interpolation)은 3차원 데이터셋에서 보간된 값을 얻는 방법입니다. 본질적으로 두 개의 쌍선형(Bilinear-2D) 보간을 보간한 것입니다. 점 주변의 8개 이웃을 샘플링하고 이웃과 관련하여 해당 점의 정규화된 좌표의 분수 값을 사용하여 이웃 사이를 선형 보간합니다. 먼저 이웃의 상단 및 하단 평면에서 두 개의 평행 축을 보간합니다(1차원, 선형 보간-Linear interpolation). 그런 다음 해당 선을 연결하는 축을 사용하여 결과 사이를 보간합니다(2차원, 쌍선형 보간). 그런 다음 두 평면을 연결하는 축에서 해당 결과 사이를 보간합니다(3차원, 삼선형 보간). 위의 이미지에서 라니(Rani) 위에 그려진 선 시각화를 볼 수 있습니다.
Sound ambience
CPU에 복셀 데이터를 보관하면 플레이어가 정크 소리 근처에 있을 때 사운드 앰비언스를 생성하는 데 도움이 됩니다. 그리고 이 앰비언스와 재생 위치와 방법은 플레이어가 정크 소리를 흡수할 때 엉터리 소리의 모양이 어떻게 바뀌는지에 따라 달라집니다. 제 동료인 Magnus Martinsson은 복셀 데이터를 입력으로 받아서 FMOD 사운드 엔진에 유용한 무언가로 변환하는 시스템을 구현했습니다.
Cellular automata
우리가 초기에 마주친 문제는 플레이어가 청소할 때 오물 부분을 놓친다는 것이었습니다. 정크가 가산/감산(additive/subtractive) 노이즈로 애니메이션화된다는 사실 때문에 더 악화되었습니다(따라서 복셀이 잠시 애니메이션화될 수 있음). 우리는 노이즈를 원래 정크 모양에 최대한 가깝게 만들려고 노력했지만, 그것이 제공하는 충실도를 너무 많이 바꿀 수는 없었습니다. 이를 해결하기 위해 우리는 정크 필드에 셀룰러 오토마타를 실행합니다. 그 규칙에 따르면 이웃이 없는 단일 정크 복셀이나 낮은 집단 HP를 가진 정크 복셀 클러스터는 줄어들고 사라져야 합니다.
우리는 복셀에 HP가 있으므로 점진적이고 개별적으로 줄일 수 있습니다. 이 시스템을 많이 조정하면 플레이어가 놓칠 수 있는 작은 정크 부분을 조용하고 은밀하게 정리하는 데 도움이 되었습니다.
셀룰러 오토마타는 개발 중에 여러 번 탐구한 것입니다. 어느 시점에서 우리는 이것을 사용하여 정크를 자동으로 재생성하여 기존 정크에서 흘러나와 한때 흡수되었던 부분을 채우도록 했습니다.
Gunk particles
흡수할 때 생성되는 정크 흐름은 파티클 기반이며, 파티클을 제어하는 C++로 작성된 커스텀(그리고 매우 간단한) 파티클 시스템을 사용하여 구축됩니다.
파티클은 각 프레임에서 업데이트하는 작은 위치+속도 구조체입니다. 입자는 라니의 장갑과 흡수하는 콘의 중심선을 향해 당겨집니다. 이렇게 하면 깔끔한 소용돌이 효과가 생깁니다. 파티클이 충분히 가까이 있으면 이전 속도에 관계없이 라니의 장갑 위치로 부드럽게 보간하여 노즐을 놓치지 않도록 합니다.
파티클 디버그. 각 파란색 점은 일시적 렌더 대상에 SDF를 그립니다. 노즐 근처의 선은 대상을 향해 간단한 선형 보간으로 전환할 만큼 가까운 파티클을 시각화합니다(따라서 결코 빗나가지 않습니다).
그런 다음 각 파티클은 구에 대한 SDF를 3D 렌더 타겟으로 렌더링합니다. 우리는 정크 영역과 같은 방식으로 CPU의 볼륨을 추적할 필요가 없기 때문에 이에 대해 복셀화를 수행하지 않습니다.
Pipeline
GPU: Generating the SDFs and clipmap
Voxels to distance field using Jump Flood Algorithm (JFA)