아웃바운드의 클립 셰이더 접근 방식: 실시간 환경을 위한 정밀한 잎사귀 버리기

어떻게 하면 오픈 월드 밴 라이프 게임에서 잔디가 바닥을 뚫고 나오는 것을 막을 수 있을까요? 이 게스트 포스트에서 Square Glade Games 프로그래머인 Tony Fial과 Michiel Procé는 Outbound의 문제를 해결하기 위해 접근한 방법을 자세히 설명합니다.
우리는 Tony Fial과 Michiel Procé로, Square Glade Games 팀의 일원이며, 현재 스튜디오의 최신 제목인 Outbound에 대해 작업하고 있습니다. 이 게임은 유토피아적 근미래를 배경으로 한 오픈 월드 탐험 게임입니다. 플레이어는 빈 캠퍼 밴으로 시작하여 원하는 대로 완벽한 모바일 홈으로 변형할 수 있습니다.
차량은 게임의 큰 초점이며, 자연 속에서 운전하는 것도 마찬가지입니다. Outbound의 세계는 손으로 제작되었으며, 풍부하고 키가 크고 많은 잔디와 식물이 포함되어 있습니다. 이 자산으로 아름다운 세계를 만들 수 있었지만, 그러한 환경을 통과하는 차량과 결합하면서 몇 가지 시각적 문제가 발생했습니다.
문제
플레이어는 기본적으로 모든 열린 지역을 통해 캠퍼 밴을 운전할 수 있습니다. 덤불과 잔디는 이를 방해하지 않습니다. 밴이 지면에 매우 가까워서, 종종 지형의 잔디가 차량의 바닥이나 측면을 뚫고 나오는 결과가 발생했습니다.
또한 밴이 꽃과 덤불과 같은 더 높은 식물에 도달할 수 있는 장소도 있습니다. 문제를 보여주기 위해 아래 스크린샷은 잔디와 덤불이 차량에 심하게 뚫고 있는 경우를 보여줍니다. 이는 시각적으로 매력적이지 않을 뿐만 아니라, 상호작용이나 중요한 정보를 시각적으로 차단하는 등 다양한 게임 플레이 문제를 일으킵니다.

우리의 핵심 문제를 요약하자면, 캠퍼 밴을 뚫고 나오는 다양한 종류의 식물과 잔디가 있어, 이는 시각적 및 게임 플레이 관점에서 원하지 않는 것입니다.
이제 해결책으로 넘어가 볼까요?
가능한 해결책 브레인스토밍
Square Glade Games에서는 해결책 작업을 시작하기 전에 최적의 요구 사항 목록을 작성하는 것이 유용하다고 생각합니다.
이 특정 경우에 우리는 우리의 해결책이 다음과 같아야 했습니다:
• 뛰어난 성능을 발휘해야 합니다. 아웃바운드에는 풀이 많이 자생하고 있어, 최적화되지 않은 솔루션은 풀이 더 많고 식물이 많은 지역에서 매우 비쌀 수 있습니다.
• 원래 스타일을 유지하세요. 현재 우리는 아웃바운드의 주요 요소의 모양을 변경할 수 없는 개발 상태에 있으므로, 이상적으로는 솔루션이 가능한 한 많은 원래의 식생을 활용해야 합니다.
• 크로스 플랫폼 호환성을 가져야 합니다.제목이 여러 플랫폼에서 출시될 예정이므로, 솔루션은 Windows, Nintendo Switch™, Xbox 및 PlayStation®에서 작동해야 합니다.
• 사용하기 직관적이어야 합니다.솔루션은 팀의 디자이너와 프로그래머 모두에게 직관적이어야 합니다.
• 여러 형태에 적용되어야 합니다. 이상적으로는 차량의 정확한 형태로 식생을 잘라내고, 여러 형태를 사용할 수 있습니다.
이제 이 요구 사항 목록을 충족할 수 있는 솔루션에 대해 생각해 보겠습니다. 우리의 첫 번째 생각은 모든 풀잎이 공유하는 요소인... 셰이더였습니다.
아웃바운드의 거의 모든 식물은 지형 도구를 사용하여 Unity 지형에 배치됩니다. 이 중 상당 부분은 기본 풀 셰이더를 사용하는 풀입니다. 이 셰이더는 GPU를 사용하여 풀 평면을 매우 효율적으로 배치하고 광고합니다. 위 스크린샷에 표시된 더 큰 덤불과 같은 다른 요소들은 세부 메시로 배치되며, 자신에게 할당된 재료와 셰이더를 사용합니다.
이는 제안된 솔루션이 여러 완전히 다른 셰이더에서 동시에 동일한 방식으로 작동할 수 있어야 한다는 또 다른 중요한 세부 사항을 제시했습니다.
제안된 솔루션
아래의 모든 제안된 솔루션은 하나의 주요 '입력'을 공유합니다: 캠퍼밴의 위치, 또는 더 정확히 말하자면, 식생이 잘려야 할 영역입니다.
명시된 요구 사항을 살펴보면, 우리는 솔루션이 Square Glade 팀의 나머지 구성원들이 사용하기 직관적이기를 원했습니다. 우리의 경험에 따르면, 편집 도구는 팀원들이 직관적이고 쉽게 사용할 수 있을 때만 사용됩니다. 이를 염두에 두고, 우리는 차량의 몸체를 적절히 잘라내고 조정할 수 있는 시각적 3D 큐브를 만들기로 결정했습니다 just right. 큐브 내의 모든 식물은 잘려 나가고, 큐브 외부의 모든 것은 동일하게 보일 것입니다.
스텐실 셰이더
우리가 시도한 첫 번째 것은 '스텐실 버퍼'라는 셰이더 요소를 사용하는 것이었습니다.
셰이더 프로그래밍의 이 부분은 매우 매력적이지만, 이해하기에는 약간 어려운 부분이 있습니다. 우리의 목적을 위해 요약하자면, 우리는 '클리핑 요소'인 큐브에게 렌더링된 프레임의 스텐실 버퍼에 정보를 기록하라고 지시합니다. 즉, 큐브가 있는 화면의 모든 곳에 값 1을 기록합니다. '잘린' 객체(우리의 경우, 풀)는 그 버퍼에서 읽고 값이 정확히 1로 설정된 픽셀을 버립니다.
셰이더 코드에서는 다음과 같이 보일 것입니다:
Clipping object 'Cube'
Stencil
{
Ref 1
Comp always
Pass replace
}
Clipped object 'Grass'
Stencil
{
Ref 1
Comp equal
}클리핑 객체는 버퍼에 값 1을 기록하며, 이는 Ref 1에 의해 명시되고, 항상 이렇게 할 것입니다. 나중에 렌더링된 스텐실 값이 일치하거나 통과하면, 이 셰이더의 정보로 대체됩니다.풀은 유사한 구현을 가지고 있습니다: 그것은 또한 Ref 1의 값을 찾고, 비교가 그 참조 값에 같을 때만 검사를 통과합니다.
이 구현은 풀을 잘라내는 데 효과적이었고, 렌더링된 프레임의 픽셀에서 작동하므로 주어진 장면의 풀의 양에 영향을 받지 않았습니다. 그러나 이 솔루션에는 치명적인 결함이 있었습니다. 이 구현은 깊이에 대한 감각이 없기 때문에 큐브 뒤에 있는 모든 것도 잘라냅니다. 실제로 이것은 플레이어가 차량 내부에 앉아 1인칭 시점에서 볼 때, 전체 화면이 '잘린' 것으로 표시되어 플레이어가 어디에서도 풀을 볼 수 없다는 것을 의미했습니다. 이 때문에 우리는 플레이어 카메라가 '클리퍼' 객체 내부에 있을 때도 작동할 수 있는 다른 방법을 시도해야 했습니다.
수동 클리핑
우리가 간단히 논의한 해결책은 차량의 위치에서 잔디를 수동으로 제거하여 지형 자체에서 멀리하는 것이었습니다. 우리는 이미 게임의 다른 부분에 대해 그렇게 했으며, Unity가 제공하는 'TerrainData.SetDetailLayer' 함수를 사용했습니다. 이것은 밴 바로 아래의 픽셀에서 디테일 레이어의 그레이스케일 색상을 0으로 설정하여 지형에 해당 위치의 디테일 메시나 잔디를 제거하도록 지시합니다.
아웃바운드의 맵은 상당히 크기 때문에 디테일 레이어의 해상도가 낮은 편이어서 약간 '톱니 모양'이 됩니다. 이것은 잔디와 다른 메시의 일반적인 디테일 배치에는 완벽하게 괜찮지만, 부품을 수동으로 클리핑할 때 낮은 해상도는 밴의 크기에 충분히 가까운 형태가 아닌 너무 작거나 너무 큰 형태를 초래합니다.
이 해결책은 차량이 두 개의 지형 디테일 픽셀의 경계에 있을 때 디테일이 깜박이는 결과를 초래할 것입니다. 이러한 이유로 우리는 이 해결책을 구현하지 않기로 했습니다. 우리의 여정은 계속됩니다!
클립 셰이더
스텐실 버퍼 셰이더를 사용하여 우리는 필요할 때 픽셀을 보이지 않게 렌더링하면서 밴의 외부 몸체의 정밀도로 거의 다 왔다고 생각했습니다. 만약 큐브의 깊이를 실제로 사용하면서 그렇게 할 수 있는 다른 방법이 있다면 좋았을 텐데, 해결책은 기본적으로 경계 상자 내부의 픽셀만 클리핑해야 한다는 것을 알고 있습니다.
결과적으로, 바로 그렇게 하는 방법이 있습니다! HLSL 셰이더는 단순히 지정된 값이 0보다 작으면 픽셀을 버리는 겸손한 clip() 함수를 제공합니다. 당신은 아마도 알파 클리핑에 자주 사용되는 무작위 셰이더에서 이것을 본 적이 있을 것입니다.
예를 들어, 아웃바운드의 잔디는 실제 잔디 다발처럼 보이며, 잔디 이미지가 있는 정사각형 쿼드처럼 보이지 않습니다. 왜냐하면 우리는 잔디 텍스처의 알파 채널이 검은색인 곳을 '클립'하기 때문입니다.
우리가 이 해결책에 대한 빠른 첫 프로토타입/체크를 했을 때, 우리는 이 구현이 작동할 수 있을 것이라는 높은 기대를 가졌습니다. 왜냐하면 우리는 특정 월드 위치 위에서 픽셀을 보이지 않게 렌더링할 수 있었기 때문입니다. 의사 코드에서 함수는 다음과 같이 보였습니다:
// Return -1 when the Y position is above 0, and return 1 when it is not.
clip( worldPos.y > 0 ? -1 : 1 );해결책: 클립 셰이더
이 시점에서 우리는 클립 셰이더를 사용하는 유망한 솔루션을 보여주는 간단한 예제를 가지고 있었습니다. 다음 단계는 셰이더에 우리가 원하는 정확한 클립 위치에 대한 정보를 제공하는 함수를 만드는 것이었습니다. 여기에는 두 가지 부분이 포함되었습니다:
• 본질적으로 '형태'를 계산하는 부분, 여기에는 그 치수와 변환이 포함되며, 이 데이터를 셰이더에 제공합니다.
• 셰이더가 이 데이터를 사용하고, 주어진 점이 형태 내에 있는지 확인하며, 필요한 경우 픽셀을 버리는 부분입니다.
우리 솔루션의 첫 번째 단계로, 우리는 씬의 객체에 부착할 수 있는 MonoBehaviour인 'GrassClipperShape' 스크립트를 만들었습니다. 이 스크립트는 클리핑 영역이 어디에 있을지를 결정합니다. 아래에 이 예가 표시되어 있으며, 에디터 뷰에서 OnDrawGizmos를 사용하여 형태의 영역이 표시됩니다.

이 클리퍼를 여러 개 사용하고 싶기 때문에, 모든 사용 가능한 클리퍼를 처리할 수 있는 포괄적인 스크립트(즉, "매니저")가 필요합니다. 각 클리퍼는 'GrassClipperManager'라는 이 포괄적인 스크립트에 다음 속성을 제공합니다:
• 형태: 형태의 유형, 우리는 이 버전이 큐브와 구 모두에서 작동하기를 원했으므로, 이는 '큐브' 또는 '구'로 설정된 간단한 열거형입니다.
• Vector3:씬에서 객체의 크기
• Matrix4x4: 월드 공간에서 계산된 회전된 객체
GrassClipperManager는 씬에 항상 하나만 존재하며, 매 프레임마다 클리퍼로부터 이 정보를 가져와 셰이더에 다음과 같이 전송합니다:
Shader.SetGlobalInteger("_ShapeCount", count);
Shader.SetGlobalMatrixArray("_ShapeInvMatrix", inv);
Shader.SetGlobalVectorArray("_ShapeParams", size);
Shader.SetGlobalFloatArray("_ShapeType", type);위의 줄은 전역 셰이더 값을 설정합니다. 간단히 설명하자면, 이는 이러한 정확한 이름과 유형의 셰이더 값을 사용할 수 있으며, 이를 모든 셰이더에서 사용할 수 있음을 의미합니다.
여러 다른 셰이더에서 클리핑이 발생하도록 하려는 이유로, 우리는 클리퍼의 영향을 받아야 하는 셰이더에 포함될 별도의 HLSL 스크립트를 만들었습니다. 이 스크립트는 'ApplyClipVolumeSDF'라는 사용자 정의 함수를 노출합니다. 이 함수는 이제 채워진 전역 셰이더 값의 정보를 사용하여 픽셀이 어떤 경계 내에 있는지 계산합니다.
inline void ApplyClipVolumeSDF(float3 worldPos)
{
float clipVal = GetClipFade(worldPos);
if (clipVal <= 0.0)
clip(-1);
}위에서 볼 수 있듯이, 픽셀이 버려져야 하는 경우 'clip(-1)' 함수를 호출하여 버려진 픽셀을 반환합니다. 그렇지 않으면 나머지 셰이더를 정상적으로 진행합니다.
클립 셰이더 구현
클리핑 함수가 이제 생성되고 필요한 데이터가 제공되었으므로, 이를 우리의 셰이더에 구현할 시간입니다.
먼저, 원본의 복사본을 만들고 편집할 수 있는 디테일 메시를 위해 이를 수행하는 방법에 대해 논의해 보겠습니다. 셰이더의 가장 상단에서 사용자 정의 스크립트를 다음과 같이 참조해야 합니다:
#include "Assets/Shaders/ClipVolume.hlsl"그리고 실제로 함수를 사용하고 싶을 때, 셰이더의 프래그먼트 부분 내에서 다음과 같이 호출합니다:
float3 worldPos = mul(unity_ObjectToWorld, float4(input.positionOS, 1.0)).xyz;
ApplyClipVolumeSDF(worldPos);우리의 경우, 이 기능을 포함해야 하는 셰이더는 두 개뿐이며, 기본적으로 Unity 풀에서 사용하는 셰이더와 모든 다른 잎사귀를 디테일 메시로 렌더링하는 데 사용되는 사용자 정의 셰이더입니다. 이제 이것이 있으므로, 필요할 경우 다른 셰이더에 쉽게 구현할 수 있습니다.
하지만 우리의 여정은 끝나지 않았습니다 - 마지막 장애물이 나타났습니다. 이제 기본 풀 셰이더에 대해 변경 사항을 어떻게 편집하고 실제로 유지할 수 있을까요? Unity는 풀을 렌더링하기 위해 특정 내장 셰이더를 사용하며, 우리의 경우 'WavingGrassBillboard.shader'입니다. 이 셰이더는 모든 풀에 자동으로 적용되며, 사용자 정의 변형을 제공할 옵션이 없습니다. 이것은 우리의 솔루션이 작동하는 데 매우 중요했습니다. 왜냐하면 사용자 정의 'ApplyClip' 함수를 호출하고 원하지 않는 픽셀을 버리기 위해 그 셰이더에 연결해야 했기 때문입니다.
몇 가지 솔루션을 시도한 후, 동료 팀원인 미키엘 프로세가 기본 풀 셰이더를 신뢰성 있게 편집하고 실제로 변경 사항을 유지하는 방법을 알아냈습니다. 다음 코드를 빌드 및 편집기에서 실행함으로써, 우리의 사용자 정의 셰이더가 기본 URP 셰이더를 대체합니다:
string replacementShaderName = "Hidden/TerrainEngine/Details/UniversalPipeline/BillboardWavingDoublePass_Clipped";
if (GraphicsSettings.TryGetRenderPipelineSettings<UniversalRenderPipelineRuntimeShaders>(out var shadersResources))
{
if (shadersResources.terrainDetailGrassBillboardShader.name != replacementShaderName)
{
Shader replacementShader = Shader.Find(replacementShaderName);
shadersResources.terrainDetailGrassBillboardShader = replacementShader;
}
}이것은 WavingGrassBillboard 셰이더만 대체하지만, 다른 셰이더에 대해 이를 구현하는 것은 유사할 것입니다.
정리 및 결론
클립 셰이더를 사용하는 우리의 최종 솔루션은 우리의 목적에 잘 맞고, 제공하는 결과에 매우 만족합니다. 아래 스크린샷에서 솔루션의 시각화를 확인하세요. 여기서 직사각형 큐브가 풀을 잘라냅니다. 상자는 위에서 보이며, 잘린 내용을 최적의 시각으로 보기 위해 지형을 통과하여 배치됩니다.

우리의 풀 클리핑 솔루션에 대한 요구 사항 목록을 되돌아보니, 모든 요구 사항을 준수하는 것을 보게 되어 기뻤습니다!
• 솔루션은 성능이 뛰어납니다, 클리핑을 계산하는 데 사용되는 함수가 매우 저렴하기 때문입니다. 픽셀을 완전히 버리기 때문에, 우리의 구현은 추가적인 불필요한 처리를 하지 않을 것입니다.
• 아웃바운드의 원래 스타일을 유지합니다 왜냐하면 우리가 이미 사용하고 있는 셰이더 위에 구축되었기 때문입니다.
• 구현은 플랫폼에 구애받지 않습니다, 왜냐하면 clip() 함수 자체가 그렇기 때문입니다.
• 솔루션은 팀의 나머지 구성원에게 사용하기 직관적입니다. 디자이너는 여러 형태를 만들고 사용할 수 있으며, 심지어 서로 교차할 수도 있습니다.
우리는 위와 같은 기능이 창의성의 차원뿐만 아니라 나중에 이상한 버그가 발생하는 것을 방지하기 위해서도 매우 중요하다고 믿습니다.
샘플 프로젝트
이 솔루션을 커뮤니티와 공유하기 위해, 위에 자세히 설명된 기술을 사용하여 샘플 프로젝트를 만들었습니다. 직접 시도해 보세요 – 여기에서 GitHub에서 확인하세요.
우리의 게스트 포스트를 읽어주셔서 감사합니다. 이것이 우리와 같은 문제에 직면한 많은 다른 개발자들에게 도움이 되기를 바랍니다!
아웃바운드는 현재 클로즈 베타 테스트 중입니다; Steam에서 게임을 팔로우하여 업데이트를 확인하세요. 우리의 Steam 큐레이터 페이지에서 Unity로 제작된 게임을 더 많이 살펴보고, 리소스 허브에서 Unity 개발자들의 더 많은 이야기를 확인하세요.
Nintendo Switch™는 Nintendo의 상표입니다.
