TECHARTNOMAD | TECHARTFLOWIO.COM

TECH.ART.FLOW.IO

[번역/교정] Unity에서 ComputeShader를 이용한 View Frustum Culling

jplee 2025. 1. 31. 16:49
역자 주
2025년 구정 연휴를 맞이하여 컨설팅 고객사를 위해 컬링 적용 지침서를 작성하던 중에 눈에 띄는 왕장롱 선생의 기사가 있어서 공유 해 봅니다. 유니티가 6.0에 이르렀지만 여전히 GPU 절두체 컬링등은 외부 라이브러리나 또는 직접 구현해서 사용해야 합니다. GPU 상주드로어나 정말 10년이 다 된 HI-Z 컬링이 2025년에 유니티 빌트인으로 통합되는걸 보면 유니티는 일을 더 열심히 할 필요가 있지않나 싶죠. 나스닥 상장사의 면모를 보자면 정말 너무나 너무나 렌더링 쪽 개발에 투자를 안하고 있다고 생각이 들 만큼 말이죠. 아무튼 2020년 부터 중국 게임사에서는 대부분 직접 개발을 해서 사용해 왔던 것이기도 하고 하여 GPU 절두체 컬링과 관계 된 내용들도 공유를 더 해 볼 심산입니다.

저자 : 왕 장롱

프러스텀 컬링은 왜 하는가?

많은 오픈월드 게임에서는 장면에 많은 양의 초목과 건물이 있는 경우가 많지만, 시점이 제한되어 있기 때문에 맵의 모든 오브젝트가 화면에 표시되지 않고 뷰 프러스텀 안에 있는 오브젝트만 표시되는 경우가 많습니다.아래 그림에서 볼 수 있듯이 씬에 작은 나무가 많이 있지만 뷰 콘에 있는 작은 나무만 화면에 표시됩니다.

이러한 처리를 전혀 하지 않으면 씬의 모든 트리 데이터(정점, 트라이앵글)는 DrawCall을 제출을 위해 CPU를 통해 GPU로 전달되고 컬링 작업이 수행되기 전에 버텍스 셰이더와 지오메트리 셰이더가 있는 경우 계산에 참여하게 됩니다.즉, 실제로 볼 수 없는 많은 오브젝트가 렌더링 파이프라인에 남아 있어 많은 연산과 불필요한 소비를 유발합니다.

그리고 GPU는 단일 물체의 개념이 없고 모두 정점과 면이기 때문에 제거(컬링) 효율이 높지 않습니다.예를 들어 수만 개의 정점과 면이 있는 캐릭터 모델이 있다면 CPU는 모든 정점과 면을 계산하여 컬링할 때 어떤 것을 남기고 어떤 것을 제외할지 확인해야 합니다.하지만 모델을 래퍼로 감쌀 수 있다면 래퍼에서 몇 개의 정점만 계산하여 모델을 컬링할지 여부를 결정하면 됩니다.

위의 문제에 대해 우리는 프러스텀 컬링을 사용하여 최적화할 수 있습니다.물론, 이 외에도 차단 제거(예: Hi-z) 등의 방안이 있어 더욱 최적화할 수 있습니다.

이 글을 읽다가 그만둘 수도 있으니 렌더링부터 시작하겠습니다.간단한 뷰 콘 컬링의 효과는 다음과 같습니다:

 
Demo 깃허브 주소:

GitHub - luckyWjr/ComputeShaderDemo

Contribute to luckyWjr/ComputeShaderDemo development by creating an account on GitHub.

github.com

GPU 프러스텀 컬링 원리

앞서 말했듯이 보이지 않는 모든 물체, 즉 시야각 밖에 있는 물체는 계산을 위해 GPU로 전달됩니다.CPU 단계에서 이러한 시야각 밖의 물체를 버릴 수 있다면 GPU로 전달되는 데이터의 양을 크게 줄일 수 있습니다.

문제의 핵심은 물체가 프러스텀 안쪽인지 바깥쪽인지 판단하는 방법입니다.앞서 말했듯이 오브젝트는 수천 개의 꼭지점과 면으로 매우 복잡할 수 있으므로 각 오브젝트에 대해 둘러싸는 상자 또는 둘러싸는 구를 정의하는 경우가 많으며, 이를 통해 둘러싸는 상자 또는 둘러싸는 구와 프러스텀 의 내부 및 외부 사이의 관계를 판단하는 방법으로 문제를 단순화할 수 있습니다.

둘러싸는 상자가 프러스텀 내부에 있는지 판단하는 방법을 2D 도식화하면 다음과 같습니다:

세 개의 객체 ABD의 데이터를 GPU에 제출하고 C를 제거해야 한다는 것은 분명합니다.그렇다면 이 판단 로직은 어디에서 나온 것일까요?우선, 상자의 각 꼭지점과 프러스텀의 관계를 판단하여 전체 상자와 프러스텀의 관계를 판단할 수 있습니다.이 경우 프러스텀을 여섯 면의 집합으로 생각할 수 있으며, 어떤 점이 여섯 면의 뒷면에 모두 있다면 그 점은 프러스텀 안에 있는 것입니다.그러나 둘러싸는 상자의 꼭지점 중 하나라도 프러스텀에 있으면 프러스텀에 속하는 것처럼 취급하므로 오브젝트 D(예: 벽)에 대한 AABB는 분명히 적용되지 않으며 모든 꼭지점이 프러스텀에 있지 않지만 여전히 렌더링해야 합니다.거꾸로 생각해야 합니다. 즉, 둘러싸는 상자의 모든 꼭지점이 뷰 프러스텀의 면 중 하나 밖에 있는 경우 이 오브젝트를 제거해야 한다고 가정합니다.예를 들어 C의 모든 꼭지점이 오른쪽 면 밖에 있으면 제거해야 하고, D의 모든 꼭지점이 면 밖에 있지 않으면 유지해야 합니다.


원리를 이해 한 후 다음으로해야 할 일은 코드에서 구현하는 방법입니다. 과거에는이 부분의 로직이 CPU에서 수행되고 장면을 나누기 위해 옥트리 형식, 예를 들어 내 노드가 뷰 프러스텀 외부의 둘러싸는 상자에 해당하면 노드 아래의 모든 하위 노드가 뷰 프러스텀 외부에 있어야하므로 많은 계산을 절약 할 수 있습니다.CPU에서 수행되는 컬링 작업( 뷰 프러스텀 컬링, 오클루전 컬링 등)을 CPU 컬링이라고 합니다.

그러나 오늘날의 GPGPU에서는 컴퓨트 셰이더를 사용하여 GPU 컬링이라고 하는 오브젝트 레벨 컬링을 수행할 수 있으며, 이 글에서는 컴퓨트 셰이더를 사용하여 뷰 프러스텀 컬링을 수행하는 방법 중 하나인 단순히 cs를 사용하여 GPU 측에서 오브젝트의 둘러싸는 박스 및 뷰 프러스텀 케이싱을 결정하는 방법에 대해 소개합니다.이는 단순히 cs를 사용하여 오브젝트의 둘러싸는 상자와 GPU 측의 뷰 프러스텀 사이의 관계를 결정하는 것입니다.

컴퓨트쉐이더의 기본 사항은 이전 문서를 참조하세요:

컴퓨트 셰이더 - Unity 매뉴얼

컴퓨트 셰이더는 일반 렌더링 파이프라인과 별도로 그래픽 카드에서 실행되는 프로그램입니다. 컴퓨트 셰이더는 대량 병렬 GPGPU 알고리즘 또는 게임 렌더링의 일부를 가속시키기 위해 사용할

docs.unity3d.com

 

Graphics.DrawMeshInstancedIndirect

Unity의 GPU 인스턴싱 기술을 사용하면 씬의 식생과 같이 적은 수의 드로 콜을 사용하여 동일한 머티리얼로 많은 수의 오브젝트를 그릴 수 있으므로 작은 나무를 많이 그리면서도 좋은 프레임 속도를 유지할 수 있습니다.

컬링에 CS를 사용하려면 수천 개의 오브젝트의 바운딩 박스 정보를 CS에 전달하고, CS는 컬링되지 않은 오브젝트를 다시 CPU에 전달하여 그려야 합니다.CPU와 GPU 간에 많은 양의 데이터를 전송하면 성능에 큰 문제가 발생하며, 특히 휴대폰의 전송 대역폭이 제한되어 있는 경우 견딜 수 없는 수준입니다.

DrawMeshInstancedIndirect 메서드를 사용하면 이 문제를 해결할 수 있으며, 공식적인 설명은 다음과 같습니다:

GPU에서 모든 인스턴스 데이터를 채우고 싶지만 CPU가 그릴 인스턴스 수를 모르는 경우(예: GPU 컬링 수행 시)에 유용합니다.

즉, 이 방법을 사용하면 CPU에서 데이터를 전송하는 대신 메모리에 있는 데이터를 렌더링 파이프라인으로 직접 끌어올 수 있습니다.즉, CS 처리 결과를 CPU로 전달하지 않고 렌더링 파이프라인에 직접 넣을 수 있습니다.

이 글의 예시도 공식 문서에서 제공한 코드를 기반으로 합니다(안타깝게도 공식 문서에서는 제대로 된 컬링 작업을 제공하지 않았습니다):
https://docs.unity3d.com/ScriptReference/Graphics.DrawMeshInstancedIndirect.html

Unity - Scripting API: Graphics.DrawMeshInstancedIndirect

This function only works on platforms that support compute shaders. Similar to Graphics.DrawMeshInstanced, this function draws many instances of the same mesh, but unlike that method, the arguments for how many instances to draw come from bufferWithArgs. U

docs.unity3d.com

문서의 코드를 프로젝트에 복사하고 원하는 메시를 선택하면 글의 시작 부분에 있는 이미지와 동일한 효과를 얻을 수 있습니다.다음으로 해야 할 일은 간단한 원뿔 컬링 효과를 얻기 위해 벽돌과 박격포를 몇 개 더 추가하는 것입니다. 원뿔의 여섯 면의 정의
프러스텀 컬링의 핵심은 둘러싸는 상자와 원뿔 사이의 관계를 판단하는 방법이며, 둘러싸는 상자의 모든 꼭지점이 프러스텀의 면 밖에 있으면 이 개체를 제거해야 한다고 판단하는 방법이라고 말씀드린 바 있습니다.그런 다음 먼저 뷰 프러스텀의 여섯 면을 정의해야 합니다.

평면 방정식은 다음과 같습니다:

Ax+By+Cz+D=0

여기서 xyz는 평면의 한 점을 나타내고, ABC는 평면의 법선이며, D의 값은 나중에 설명합니다.즉, 평면을 표현하기 위해 4차원 벡터 Vector4=(A,B,C,D)를 사용할 수 있습니다.

예를 들어, xy 평면에 평행하고 위를 향한 평면이 있다고 가정하면 법선은 (0,1,0)이므로 A=0, B=1, C=0입니다. 평면이 점 (0,5,0)을 통과하면 x=0, y=5, z=0이 되며, D=-5로 풀 수 있습니다. 따라서 (0,5,0) 점을 통과하는 법선이 (0,1,0)인 평면의 방정식은 벡터 측면에서 0x+1y+0z-5=0이 됩니다.벡터 (0,1,0,-5)로 표현됩니다.

D=-(Ax+By+Cz)이며, Ax+By+Cz의 값은 정확히 (A,B,C)와 (x,y,z)의 점 곱의 결과이므로 D의 값은 평면의 법선과 평면의 모든 점의 점 곱의 결과이며 음수임을 알 수 있습니다.

이렇게 하면 첫 번째 함수가 제공됩니다:

// 점과 법선 벡터가 평면을 결정합니다.
public static Vector4 GetPlane(Vector3 normal, Vector3 point)
{
    return new Vector4(normal.x, normal.y, normal.z, -Vector3.Dot(normal, point));
}

뷰 프러스텀의 여섯 면을 간단히 왼쪽, 오른쪽, 위, 아래, 근거리, 원거리라고 부르며 근거리와 원거리의 법선은 Camera.transform.forward로 구할 수 있지만 나머지 네 면의 법선은 어떻게 구할 수 있을까요?원근 카메라의 좌, 우, 상, 하 네 면은 카메라 자체 위에 있어야 하므로 Camera.transform.position은 네 면의 한 점이고 세 점으로 평면을 결정할 수 있으므로 원면(또는 근면)의 네 끝점만 찾으면 상하좌우 네 면의 노멀에 대해 원면의 두 점과 카메라 자체(총 세 점)를 사용하여 노멀을 구할 수 있습니다.점과 카메라 자체의 두 점(총 세 점)을 벡터의 교차 곱셈으로 구할 수 있습니다. 如果对点乘叉乘不잘 알고 있다면 좋은 참고 자료가 될 것입니다:

머신러닝을 위한 선형대수학: 벡터와 벡터 연산

(2) 선형대수학 기초 & 총 정리본 첫번째 목차 - 행렬과 벡터 연산 | 이전 포스팅 : https://brunch.co.kr/@jennyjang93/41 선형대수학: 벡터와 행렬 목차 사전 지식 익히기 벡터와 벡터 연산 (Vectors and vector op

brunch.co.kr

세 개의 점은 다음과 같이 평면을 결정합니다:
 

//세 개의 점이 평면을 정의합니다.
public static Vector4 GetPlane(Vector3 a, Vector3 b, Vector3 c)
{
    Vector3 normal = Vector3.Normalize(Vector3.Cross(b - a, c - a));
    return GetPlane(normal, a);
}

프러스텀의 원평면은 다음과 같이 계산됩니다:
 

//프러스텀의 먼 평면에서 네 점을 구합니다.
public static Vector3[] GetCameraFarClipPlanePoint(Camera camera)
{
    Vector3[] points = new Vector3[4];
    Transform transform = camera.transform;
    float distance = camera.farClipPlane;
    float halfFovRad = Mathf.Deg2Rad * camera.fieldOfView * 0.5f;
    float upLen = distance * Mathf.Tan(halfFovRad);
    float rightLen = upLen * camera.aspect;
    Vector3 farCenterPoint = transform.position + distance * transform.forward;
    Vector3 up = upLen * transform.up;
    Vector3 right = rightLen * transform.right;
    points[0] = farCenterPoint - up - right;//left-bottom
    points[1] = farCenterPoint - up + right;//right-bottom
    points[2] = farCenterPoint + up - right;//left-up
    points[3] = farCenterPoint + up + right;//right-up
    return points;
}

비교적 간단하므로 자세히 설명하지 않겠습니다. camera.aspect = 너비/높이, 뷰 프러스텀의 yz 단면은 아래와 같습니다:
 

FOV와 측면에 대해 잘 모르시는 분은 아래 링크의 글 끝부분을 참고하세요:

3.3 투영과 뷰잉

[앞 절](230991)에서는 객체 좌표에서 세계 좌표로 변환하는 모델링 변환에 대해 살펴보았다. 그러나 3D 컴퓨터 그래픽스에서는 여러 다른 좌표 시스템과 그들 간의 변환에 대…

wikidocs.net

위의 점의 좌표를 사용하면 다음 코드를 사용하여 뷰 원뿔의 모든 면을 얻을 수 있습니다:
 

//뷰 프러스텀의 6개의 평면을 가져옵니다.
public static Vector4[] GetFrustumPlane(Camera camera)
{
    Vector4[] planes = new Vector4[6];
    Transform transform = camera.transform;
    Vector3 cameraPosition = transform.position;
    Vector3[] points = GetCameraFarClipPlanePoint(camera);
    //시계 방향
    planes[0] = GetPlane(cameraPosition, points[0], points[2]);//left
    planes[1] = GetPlane(cameraPosition, points[3], points[1]);//right
    planes[2] = GetPlane(cameraPosition, points[1], points[0]);//bottom
    planes[3] = GetPlane(cameraPosition, points[2], points[3]);//up
    planes[4] = GetPlane(-transform.forward, transform.position + transform.forward * camera.nearClipPlane);//near
    planes[5] = GetPlane(transform.forward, transform.position + transform.forward * camera.farClipPlane);//far
    return planes;
}

한 가지 주의해야 할 점은 정점의 순서로, Unity에서는 시계 방향이 앞면을 나타냅니다.

점과 면의 관계
이제 면이 생겼으니 점이 그 면의 앞면인지 뒷면인지 결정하는 것은 어떨까요?먼저 다음과 같은 2차원 도식을 살펴보겠습니다:

그림의 평면에 대한 법선이 (nx,ny,nz)이고 평면에 O(ox,oy,oz)가 있다고 가정하면 평면 방정식에서 D의 값은 다음과 같이 구할 수 있습니다: -(nx*ox+ny*oy+nz*oz).
이 평면 방정식에 A(ax,ay,az)를 대입하면 nx*ax+ny*ay+nz*az-(nx*ox+ny*oy+nz*oz)를 구할 수 있고, 이를 추출하면 nx*(ax-ox)+ny*(ay-oy)+nz*(az-oz)가 되는데 이는 정상 벡터 n과 벡터 OA의 도트 곱이 아닌 다른 층이 두 벡터의 모듈러스 곱이므로 도트 곱은 두 벡터의 모듈러스의 곱입니다.
함수는 두 벡터의 계수에 각도의 코사인을 곱한 값인데, 점이 평면의 앞면에 있는 경우 법선과의 각도가 0~90° 사이여야 하므로 해당 코사인은 0~1 사이여야 하며, 따라서 법선 벡터 n에 벡터 OA의 점을 곱한 결과는 0보다 커야 하기 때문이죠.

마찬가지로 평면(a,b,c,d)을 가정할 때 임의의 점(x,y,z)이 주어지면 다음과 같이 결론을 내릴 수 있습니다:

ax+by+cz+d>0이면 점을 평면 밖에서 찍음.
ax+by+cz+d=0이면 평면에 점을 찍음.
ax+by+cz+d<0이면 점이 평면 내에 있음.

코드는 이렇습니다:

bool IsOutsideThePlane(float4 plane, float3 pointPosition)
{
    if(dot(plane.xyz, pointPosition) + plane.w > 0)
        return true;
    return false;
}

참고: 이 부분의 판단은 때가 되면 CS로 이루어질 것이므로 더 이상 c# 코드가 아닙니다.


CPU와 GPU에서 전달되는 데이터
앞서 말했듯이 프러스텀 컬링 판단을 위해 모든 오브젝트의 모든 박스 정보를 CPU에서 cs로 전달해야 하지만, 오브젝트가 모두 같은 메시 안에 있지만 크기와 위치 및 회전이 동일하지 않을 수 있으므로 각 박스의 정점의 월드 좌표를 얻기 전에 CPU에서 일련의 계산을 거쳐야 합니다.이러한 연산을 CS에도 넣을 수 있을까요?
물론 가능합니다.

대부분의 경우 동일한 바운딩 박스를 가진 동일한 객체가 있으므로 객체 공간에서 이러한 객체는 동일한 바운딩 박스 정보를 갖습니다. 바운딩 박스의 중심이 객체의 중심인 두 점으로 바운딩 박스를 아래와 같이 설명할 수 있습니다.


원문

Unity中使用ComputeShader做视锥剔除(View Frustum Culling)