엮인 글 함께 보기.
https://techartnomad.tistory.com/153
역자의 말.
절차적 모델링이나 절차적 배치 등등의 기법들은 2015년 제가 큰 관심을 갖을 때에는 업계에서 막 개념만 태동했던 거라면 이제는 실제 제작에 많이 도입 되고 있는 형태 입니다. 생각해 보면 8년이라는 시간이 넘게 지났군요. 이러한 제작 방식이 실무에 적용 되기까지 참 오래도 걸리는 것 같습니다. 이렇게 보면 게임개발진영은 꽤나 보수적인 듯 합니다. Kacper Szwajka 의 절차적 Placement 관련 토픽을 간략히 번역 해 봤습니다. 이미 UE5 에는 자체적인 기능이 통합 되었지만 유니티에서는 외부 플러그인등의 힘들 빌어서 작업하거나 직접 구현 해야합니다. 참고해서 구현 해 보는 것도 좋겠네요.
원문
이 글에서는 Unity 엔진을 사용하여 지형에 오브젝트를 런타임 GPU로 배치하는 방법을 설명합니다. 이 기술은 컴퓨트 셰이더를 지원하는 모든 엔진에 적용할 수 있습니다.
문제: 수 평방 킬로미터에 이르는 넓은 지형에 오브젝트를 수동으로 배치하는 것은 게임 개발 과정에서 어려운 작업이 될 수 있습니다. 잦은 반복과 조정이 필요한 경우 특히 더 어렵습니다. 또한 메모리 사용량도 고려해야 합니다. 2x2km 맵을 0.5m 밀도의 잔디로 채우면 1,600만 개의 인스턴스가 발생하며, 변환 데이터를 저장하는 데만 약 1024MB가 필요합니다. 바로 이 지점에서 절차적 배치가 중요해집니다.
절차적 배치: 솔루션
프로시저럴 배치는 카메라 범위 내의 렌더링 인스턴스에만 메모리를 할당하고 카메라가 움직일 때 동적으로 인스턴스를 흩어지게 하는 솔루션을 제공합니다. 이 접근 방식은 게릴라 게임즈에서 "Horizon: 제로 던'에서 사용한 기법에서 영감을 얻었으며, 프로젝트의 특정 요구 사항에 맞게 조정했습니다.
프로세스 개요
이 프로세스에는 카메라 주변에 '청크'를 나타내는 평평한 그리드를 만드는 작업이 포함됩니다. 이 그리드의 각 셀, 즉 청크에는 오브젝트 인스턴스가 될 포인터가 포함됩니다. 카메라가 움직일 때 뒤에서 앞으로 전환되는 청크만 업데이트합니다. 그런 다음 이러한 '더티' 청크의 포인터를 재구성하고 각 포인터에 프로토타입을 할당합니다. 마지막 단계는 선택한 프로토타입 매개변수를 기반으로 포인터 트랜스폼을 설정하는 것입니다.
이 시스템의 로직은 대부분 컴퓨트 셰이더를 사용하여 실행되며, 수많은 간접 디스패치를 통해 필요한 데이터만 업데이트되도록 합니다.
풋프린트 및 생태 유형
' 풋프린트 '과 '생태 유형'이 결합된 ' 풋프린트 ' 개념이 이 시스템의 핵심입니다. 풋프린트는 포인터 사이의 평균 거리를 정의하고 이러한 포인터를 배치할 때 알고리즘의 응답을 지시합니다. 또한 특정 배치 규칙을 가진 사용 가능한 프로토타입의 그룹인 에코타입 목록도 포함됩니다.
청크
청크 로직은 주로 컴퓨팅 셰이더를 통해 처리됩니다. 각 청크는 대략 512개의 포인터를 저장합니다. 이 숫자가 청크당 계산에 가장 효율적이라는 것을 발견했습니다. 이러한 청크의 그리드 크기는 에코타입으로부터의 최대 세부 수준(LOD) 거리에 따라 달라집니다. 다음 그림에서 볼 수 있듯이 한 청크가 다른 청크를 대체할 때 눈에 띄는 '팝핑'을 방지하는 것이 중요합니다:
[numthreads(GPUI_THREADS, 1, 1)]
void ChunkPositionUpdate (uint3 id : SV_DispatchThreadID) {
if (id.x >= maxInstances) return;
Chunk chunk = chunks[id.x];
int2 spatial = chunk.spatialPos;
if (spatial.x < newGridMin.x || spatial.x > newGridMax.x || spatial.y < newGridMin.y || spatial.y > newGridMax.y) {
chunk.spatialPos = newGridMax - (chunk.spatialPos - oldGridMin);
chunk.BBPosition = float3(chunk.spatialPos.x * chunkSize, 0, chunk.spatialPos.y * chunkSize) + (float)(chunkSize) * 0.5f;
chunks[id.x] = chunk;
chunksAppend.Append(chunk); // Mark as dirty for indirect dispatch
}
}
포인터
포인터는 미래의 프로토타입 위치를 나타냅니다. 각 청크에는 동일한 수의 포인터가 있으며 청크 인덱스에 따라 포인터 위치를 계산합니다.
다음은 포인터를 분산시키는 방법을 보여주는 예제 코드입니다. _SquareGrid 또는 _VoronoiGrid 는 포인터 분산에 대한 다른 설정 응답입니다.
float2 min = chunk.BBPosition.xz-chunk.BBSize.xz*0.5f;
float2 max = chunk.BBPosition.xz+chunk.BBSize.xz*0.5f;
float2 pos = 0;
#if _SquareGrid
pos = S_GridPos(id.x,uint2(gridSize,gridSize),min,max);
#elif _VoronoiGrid
pos = VoronoiPos(id.x,min,max);
#endif
// Apply jitter
uint rng = S_PosToHash(pos);
float angle = S_Random(rng)*K_TWO_PI;
float r = S_Random(rng);
float2 offset = float2(sin(angle),cos(angle))*jitter*r;
pos += offset;
난수
난수 생성은 포인터의 자연스러운 분포를 위해 매우 중요합니다. S_PosToHash() 함수는 주로 GPU Gems 3에 있는 "CUDA를 이용한 효율적인 난수 생성 및 적용"의 기술을 기반으로 합니다. 다음은 해싱 함수의 구현 예제입니다:
#define TAUS_STEP_1 ((z1 & 4294967294U) << 12) ^ (((z1 << 13) ^ z1) >> 19)
#define TAUS_STEP_2 ((z2 & 4294967288U) << 4) ^ (((z2 << 2) ^ z2) >> 25)
#define HYBRID_TAUS_N2 ((z1 ^ z2) & 268435455)
uint S_Hash(uint s)
{
s ^= 2747636419u;
s *= 2654435769u;
s ^= s >> 16;
s *= 2654435769u;
s ^= s >> 16;
s *= 2654435769u;
return s;
}
float S_Random(in out uint rngState) // 0-1
{
rngState = S_Hash(rngState);
return float(rngState) / 4294967295.0; // 2^32-1
}
uint S_PosToHash(float2 pos)
{
uint z1 = asuint(pos.x);
uint z2 = asuint(pos.y);
z1 = TAUS_STEP_1;
z2 = TAUS_STEP_2;
uint x = HYBRID_TAUS_N2;
return x;
}
프로토타입 선택하기
각 포인터에 적합한 프로토타입을 선택하는 것은 특히 컴퓨팅 셰이더를 사용할 때 복잡한 작업입니다. 난수를 생성하고 이를 프로토타입 목록에 매핑하는 것이 좋습니다. 여기에는 다양한 가중치를 가진 프로토타입을 고려하기 위한 버퍼를 생성하는 작업이 포함됩니다. 각 포인터에 대해 난수가 선택되고 이 버퍼의 인덱스에 매핑됩니다. 버퍼가 클수록 매핑이 더 정확해집니다.
대부분의 경우 공간을 고려한 난수를 고르기 위해 저는 풋프린트 크기와 거의 일치하도록 크기를 조정하는 블루 노이즈에 의존합니다.
블루 노이즈에 관해서는 이 기사를 적극 추천합니다:
특정 프로토타입의 덩어리를 피하는 것이 항상 그런 것은 아니며, 때로는 균일한 랜덤 또는 기타 노이즈 함수가 더 좋아 보일 수도 있습니다.
하나의 풋프린트에 많은 생태 유형이 있는 경우
하나의 풋프린트에 2개 이상의 생태형을 할당하는 경우 생태형 범위의 타겟팅을 상쇄하기만 하면 됩니다.
위의 그래프는 에코타입을 혼합할 수 있는 가능성 중 하나를 보여줍니다. 다른 하나는 새로운 밀도를 기준으로 이전 생태형을 덮어쓰는 것입니다. (저는 이 방법을 가장 많이 사용합니다.)
밀도
프로토타입의 밀도는 주로 프로토타입 선택 알고리즘에 의해 결정됩니다. 마스크 또는 노이즈 맵을 활용하여 여러 영역에 프로토타입을 배치하는 밀도를 제어할 수 있습니다. 이를 통해 지형 특징이나 플레이어의 동작에 따라 초목이나 기타 오브젝트를 동적으로 조정할 수 있습니다.
겹치는 인스턴스
절차적 배치의 맥락에서 겹치는 인스턴스를 관리하는 것은 미묘한 문제입니다. 이 문제는 주로 응답 거리와 프로토타입 밀도가 각각 다른 여러 개의 풋프린트를 함께 사용할 때 발생합니다. 예를 들어
풋프린트 A: 반응 거리가 0.5m로 주로 잔디를 흩뿌리는 용도로 설계되었습니다.
풋프린트 B: 반응 거리가 4.0m로, 나무의 산란에 사용됩니다.
이러한 시나리오에서는 풋프린트 A의 풀이 발자국 B의 나무와 같은 위치에 스폰될 수 있습니다. 이는 중복으로 보일 수 있지만, 자연스럽고 혼란스러운 환경의 느낌을 더하는 데 기여하는 경우가 많습니다. 자연 자체는 균일한 경우가 드물기 때문에 이러한 겹침이 의도치 않게 씬의 사실감을 더할 수 있습니다.
하지만 프로토타입 배치를 정밀하게 제어해야 하는 경우 정밀 밀도 마스크를 구현하여 이 문제를 해결할 수 있습니다. 이러한 마스크를 사용하면 특정 프로토타입을 배치할 수 있는 위치를 보다 정확하게 제어할 수 있으므로 원치 않는 겹침이 발생할 가능성을 효과적으로 줄일 수 있습니다. 이러한 마스크를 사용하면 나무가 있는 곳에 잔디가 자라지 않도록 하거나 그 반대의 경우도 방지하여 각 영역의 의도된 미적, 기능적 품질을 유지할 수 있습니다.
프로토타입 변형
프로토타입이 선택되면 변형 프로세스를 거칩니다. 여기에는 경사에 맞추기, 무작위 회전, 크기 조정 등이 포함됩니다. 이 변환 패스는 청크가 움직일 때와 업데이트가 필요한 포인터에 대해서만 실행됩니다.
애플리케이션/사용 사례 예시
동적 지형 생성: 서바이벌이나 탐험 장르와 같이 지형을 끊임없이 생성해야 하는 게임에서 사용합니다. 숲, 강, 산과 같은 지형 요소는 플레이어가 탐험하는 동안 동적으로 조정할 수 있습니다.
생물군계별 특징: 생물군계마다 고유한 생태 유형을 가질 수 있어 다양하고 사실적인 환경(예: 울창한 숲, 건조한 사막, 설산)을 구현할 수 있습니다.
실시간 환경 변경: 플레이어의 행동이나 이벤트에 따라 환경이 바뀌는 게임에 이상적입니다. 이 기능은 현재 개발 중인 게임에서 많이 필요한 기능입니다. 3인칭으로 플레이하면서 동시에 대규모 성을 건설할 수 있기 때문입니다. 성의 위치에서 식물을 효율적으로 제거할 수 있는 방법이 필요합니다.
시간에 따른 성장: 버려진 지역에서도 식물이 다시 자라나 세계 역학에 사실감을 더할 수 있습니다.
'TECH.ART.FLOW.IO' 카테고리의 다른 글
[요약번역]GDC 2023 - Real-time Sparse Distance Fields for Games 파트-2 알고리즘 (2) | 2024.02.24 |
---|---|
[번역]Unity-URP 자체 톤매핑(UE4 포팅) 및 기타 포스트 프로세싱 함수 수정하기 (0) | 2024.02.15 |
[번역]Efficient GPU Rendering for Dynamic Instances in Game Development (1) | 2024.02.05 |
[요약번역]GDC 2023 - Real-time Sparse Distance Fields for Games 파트-1 개요 (0) | 2024.01.21 |
[번역] 이미지공간에서 동적 전역조명 근사화 하기. 파트 1 (0) | 2024.01.15 |