[유니티] 유니티에서 메타볼 구현. 허허허허 😉😉 메타몽: 나는 메타볼이다!!! 🖤💦

profile image 02BBQ 2025. 10. 15. 11:45
이제 옛날 옛적에 만들던 개망한 엔진 팀플이 있다....
그 당시 나는 메타볼을 구현하고 싶었다.

 

메타몽: 메타볼이 뭐야?? 

 

내가 아냐 ㅂㄹㅇ


오늘 구현해 볼 것은 '메타볼'이다.

위에 화난 이유는 걍 개 심심했다. 허허허허허 <<퍽 퍼벅 시그마 시그마 보이

이제 이런게 메타볼이다... 물방울 합쳐지는 것 같은 그런 효과를 말한ㄷㅏㅏㅏㅏ.

사전적 의미:  In computer graphics, metaballs, also known as blobby objects,[1][2] are organic-looking n-dimensional isosurfaces, characterised by their ability to meld together when in close proximity to create single, contiguous objects. 이다.

 

근데 이제 예전에 만들었던 이제 그 정자 졸작게임이 있는데 사실 그 당시에도 메타볼을 만드려 했었다. 일단 그 게임의 움직임은 이랬고... 대충 꼬리들이 총알을 쏘고 그 반동으로 이동하며 진행하는 게임이였다. 

 

https://02bbq.tistory.com/17

근데 꼬리들이 이렇게 원형으로 대충 같이 이동하기만 하니 연결된 느낌이 아니다. 그래서 이걸 고쳐보자.

 

일단 이건 옛날에 만든 것이다.

근데 이제 이거 보면 걍 개각짐. 그래서 만지면 찔리고.. 그리고 이제 최적화가....

metaball on
metaball off

그냥 개망했다. ㅋㅋㅋㅋㅋㅋㅋ

망한 사진(난 이렇게 되지 말아야지)

이게 느린 이유가 알고리즘이 헤비 한 데다가 그냥 CPU에서 무지성으로 돌렸다....

그리고 좀 알고리즘 지식도 부족했다. 엄청 부족하진 않았는데 CPU가 감당하긴 힘든 알고리즘이였다.

 


뭐 아무튼 그래서 어케 구현하는가.

고민하는 나

여러가지 방법을 생각했지만 제일 좋은 퀄리티를 보여줄 것은 당연히 수학일 것이다.

 

 사실 메타볼은 “암시적 표면(implicit surface)” 개념을 활용해서 정의되는 형태다.

이게 메타볼. 근데 저 계산 기준이 되는 원? 구?들도 메타볼. 걍 다 메타볼.

즉, 정점(vertex)이나 면(face)으로 직접 정의되는 메시(mesh)이런게 아니고, 어떤 공간 함수(field)를 이용해서 “이 함수 값이 특정 기준값(threshold) 이상인 영역”임. 그냥 저 영역임.

 

여러 개의 메타볼이 있을 때 전체 필드 함수 F(x,y) 또는 F(x,y,z) 이렇게 정의함. (x,y랑 x,y,z는 2 3차원 차이)

그 다음 이 함수를 써서 임계값 보다 큰 곳과 작은곳을 구분하는 거임.

 

그래픽에서 구현할 때에는 일단 화면을 격자로 나눔.

그 다음 각 격자점에서 F(p)를 쓰는 거임.

그 후 임계값과 비교하여 안과 밖은 구분함.

https://jamie-wong.com/2014/08/19/metaballs-and-marching-squares/

이거는 내 게임사진.

빨갈 수록 그 그리드의 밀도장이 높은 것. (더 뭉쳐있음)

그럼 이런식으로 표현됨. 근데 보면은 이제 미친 각진 뭐 도트 그래픽이 되버리기 때문에 이걸 해결해야함.

 

무엇을 해야하나??

그리드의 각 셀에서 경계선을 추출해야함.

그 다음 선을 이어 붙이면 됨.

=> Marching Squares 알고리즘.

 

와우 어려워 보인다. 사실 그건 아니고 쉬운 것이다.

저 격자점들을 2x2 네개씩 계산하여 선을 그으면 된다. 이건 공식에 기반.

격자마다 검사하여 네개의 점씩 묶어서 그 격자의 선을 그어버리면 된다!!

 

그럼 이런식으로 묶일거다. 어 잘 안보이면 클릭해 어

근데 사실 이거까지 해도? 약간 각짐. 뭔 다 45도로 막 이루어진거같노;;

 

해결법: Density 수치에 따라서 선을 보간하자!

그럼 이제 저 녹색 점은 하나여도 여러가지 형태 구현 가능!!!!

 

이건 걍 공식 가져다가 박으면 된다....

 

엣지 교차점 보간(Linear Interpolation)

마칭 스퀘어즈에서 경계선은 셀 엣지를 통과한다. 엣지 양 끝점 A,B에서의 스칼라값을 각각 vA, vB 라 하고, 임계값을 라 하면, 가 되는 지점 P는

아니 걍 특수기호 커버가 안돼서 어쩔 수 없이 걍 GPT 쓴거 찍는다.

보간 후엔 이런 멋있는 결과물이 된다 ㅎㅎ

 

 

어 이제 구현해보자 어 어 그렇다.

수학이 아트라는 명백한 증거들 (이 글과 관련 없음)

 그냥 위에 있는 순서대로 구현하였다.

 

근데 이제 이걸 이전에는 다 CPU에게 맡겨서 역대급 프레임이 표현되어버렸다...

보간도 대강했는데도 아주 프레임이 미쳤다 그냥.

metaball 미사용 시 280 FPS정도 나온다.

그 당시 내가 생각한 CPU 성능:

자 그래서 해결책: GPU와 역할을 분담하자!!! -> Compute Shader을 이용고고

 

자 근데 이게 뭐 코드만 보낸다고 뭐 이해가 잘 되진 않기에 일단 플로우차트로 설명간다. 근데 그리진 않아서 말로함 ㅠㅠ

 

자 일단 

  1. CPU에서는 커널을 얻고 컴퓨트 버퍼를 생성해야함.
  2. 이제 매프레임 출발
    1. 일단 메타볼을 계산할 그 오브젝트(꼬리)가 존재하는지 파악을 하고.. 존재하면
    2. 카메라 위치와 메타볼의 데이터를 캐싱해놓고 만약 변경되면 그 데이터를 컴퓨트 셰이더로 Dispatch함.
    3. 계산 후 Mesh에 넣어주기 ㅎㅎㅎ

그럼 GPU에서는

  • 밀도장(Density)를 계산하여 점들을 찍음.
  • 그 다음 그것을 MarchingSquares 알고리즘 + 선형 보간 알고리즘을 기반으로 삼각형들을 생성함. 
  • 그렇게 나온 도형을 CPU로 전전송~!!

음 어 일단 말은 쉬워~

어 근데 이거 쉬운데 이제 단계별로 어케 구현했는지 설명한다.


구현 단계.

 

자 일단 우리는 Compute Shader를 사용할 것이기 때문이 차근차근 설명 간다.

? ComputeBuffer란 무엇인가 ?

ComputeBuffer는 Unity에서 CPU(C#)과 GPU(Compute Shader) 가
서로 데이터를 주고받기 위해 사용하는 공유 메모리 영역이다.

“CPU가 배열 데이터를 GPU로 보낼 때 데이터 덩어리...”

GPU는 C#의 List, Array를 직접 읽을 수 없으니까,
ComputeBuffer를 통해서만 데이터를 전달받고 쓸 수 있기 때문에 이거 해줘야함.

 

커널

걍 GPU 에서 함수 엔트리 포인트임. 얘가 그 뭐 일 받으면 알아서 스레드 들한테 일 시켜줌.

private void InitializeBuffers()
{
    metaballBuffer = new ComputeBuffer(100, sizeof(float) * 3); // pos(2) + scale(1)
    densityBuffer = new ComputeBuffer(10000, sizeof(float));
    vertexBuffer = new ComputeBuffer(MAX_VERTICES, sizeof(float) * 4); // Changed to float4
    triangleBuffer = new ComputeBuffer(MAX_TRIANGLES * 3, sizeof(int));
    counterBuffer = new ComputeBuffer(2, sizeof(int)); // [0] = vertex count, [1] = triangle index count
}

내가 사용할 버퍼들이다.

 

버퍼이름 용도 데이터 구조 연결되는 커 걍 하는거
metaballBuffer 메타볼의 위치/크기 정보 전달 MetaballData
{float2 position;
float scaleFactor; }
CalculateDensity CPU가 매 프레임 TailController에서 채워 넣고 GPU로 전달함
densityBuffer 격자 각 점의 밀도값 저장 float × (gridWidth × gridHeight) CalculateDensity, MarchingSquares GPU가 계산한 결과를 다음 커널에서 다시 사용함 (GPU 내부 피드백 버퍼)
vertex
Buffer
마칭 스퀘어즈로 생성된 버텍스 저장 VertexData { float4 position; } MarchingSquares 나중에 CPU가 읽어서 mesh.vertices로 사용
triangleBuffer 인덱스(삼각형 연결 정보) 저장 int × (삼각형 개수 × 3) MarchingSquares GPU가 만든 인덱스 배열을 CPU가 그대로 Mesh에 전달
counterBuffer 현재까지 생성된 버텍스/삼각형 개수 카운트 int[2] → [0]=vertexCount, [1]=triangleCount MarchingSquares 원자 연산(InterlockedAdd)으로 GPU 여러 스레드가 동시에 안전하게 쓰기

대충 이런 버퍼들을 만들어 주고 음//

 

음 치기 붇치기 박치기 (심심해서 일어난 비트박스)

 

자 일단 CPU에서 매프레임마다 Metaball 관련 데이터를 세팅한다.

int tailCount = _tailController.Tails.Count;

// Resize cache if needed
if (metaballDataCache.Length < tailCount)
{
    metaballDataCache = new MetaballData[tailCount * 2]; // Double size to avoid frequent resizing
}

// Fill metaball data (reuse cached array)
for (int i = 0; i < tailCount; i++)
{
    var tail = _tailController.Tails[i].transform;
    metaballDataCache[i].position = tail.position;
    metaballDataCache[i].scaleFactor = tail.localScale.x * tail.localScale.x; // Avoid Mathf.Pow
}

// Reallocate metaball buffer if needed
if (metaballBuffer.count < tailCount)
{
    metaballBuffer.Release();
    metaballBuffer = new ComputeBuffer(Mathf.Max(tailCount * 2, 100), sizeof(float) * 3);
}

metaballBuffer.SetData(metaballDataCache, 0, 0, tailCount);

// Reset counters (reuse cached array)
counterBuffer.SetData(resetCounterCache);

그 다음 격자들의 밀도점들을 계산한다.

메타볼 버퍼와 밀도 버퍼를 보내준다. 

// gridChanged일 때만 큰 셋업 (빈번한 Set 호출 회피)
if (gridChanged) {
    computeShader.SetBuffer(densityKernel, "metaballs", metaballBuffer);
    computeShader.SetBuffer(densityKernel, "densityField", densityBuffer);
    computeShader.SetInt   ("gridWidth",  gridWidth);
    computeShader.SetInt   ("gridHeight", gridHeight);
    computeShader.SetFloat ("gridSpacing",gridSpacing);
    computeShader.SetVector("gridStartPos", gridStartPos);
}
// 매 프레임 바뀌는 값(메타볼 수)은 매번 업데이트
computeShader.SetInt("metaballCount", tailCount);

그 후엔 이제 marching Squares 연산에 필요한 데이터를 보낸다.

 if (gridChanged)
{
    computeShader.SetBuffer(marchingKernel, "densityField", densityBuffer);
    computeShader.SetBuffer(marchingKernel, "vertices", vertexBuffer);
    computeShader.SetBuffer(marchingKernel, "triangles", triangleBuffer);
    computeShader.SetBuffer(marchingKernel, "counters", counterBuffer);
    computeShader.SetFloat("threshold", threshold);
    computeShader.SetInt("gridWidth", gridWidth);
    computeShader.SetInt("gridHeight", gridHeight);
    computeShader.SetFloat("gridSpacing", gridSpacing);
    computeShader.SetVector("gridStartPos", gridStartPos);
}

 

그리고 각각 Dispatch해줌.

 

그럼 ComputeShader에서는 단순 노가다 형식으로....

밀도와 선형 보간을 적용된 Marching Squares 알고리즘이 샘플링된 데이터를 연산한다.

GPU가 이거 계산해 줌.

(이렇게 해줌.)

switch(caseIndex) {
        case 1:
            points[0] = float3(-1,0,3); points[1] = float3(0,-1,2); points[2] = float3(-1,-1,-1);
            break;
        case 2:
            points[0] = float3(0,-1,2); points[1] = float3(1,0,1); points[2] = float3(1,-1,-1);
            break;
        case 3:
            points[0] = float3(-1,0,3); points[1] = float3(1,0,1); points[2] = float3(-1,-1,-1); points[3] = float3(1,-1,-1);
            break;
        case 4:
            points[0] = float3(0,1,0); points[1] = float3(1,0,1); points[2] = float3(1,1,-1);
            break;
        case 5:
            points[0] = float3(-1,0,3); points[1] = float3(0,-1,2); points[2] = float3(-1,-1,-1);
            points[3] = float3(0,1,0); points[4] = float3(1,0,1); points[5] = float3(1,1,-1);
            break;
        case 6:
            points[0] = float3(0,1,0); points[1] = float3(0,-1,2); points[2] = float3(1,1,-1); points[3] = float3(1,-1,-1);
            break;
        case 7:
            points[0] = float3(-1,0,3); points[1] = float3(0,1,0); points[2] = float3(1,1,-1); points[3] = float3(1,-1,-1); points[4] = float3(-1,-1,-1);
            break;
        case 8:
            points[0] = float3(-1,0,3); points[1] = float3(0,1,0); points[2] = float3(-1,1,-1);
            break;
        case 9:
            points[0] = float3(0,1,0); points[1] = float3(0,-1,2); points[2] = float3(-1,1,-1); points[3] = float3(-1,-1,-1);
            break;
        case 10:
            points[0] = float3(0,-1,2); points[1] = float3(1,0,1); points[2] = float3(1,-1,-1);
            points[3] = float3(-1,0,3); points[4] = float3(0,1,0); points[5] = float3(-1,1,-1);
            break;
        case 11:
            points[0] = float3(0,1,0); points[1] = float3(1,0,1); points[2] = float3(-1,1,-1); points[3] = float3(-1,-1,-1); points[4] = float3(1,-1,-1);
            break;
        case 12:
            points[0] = float3(-1,0,3); points[1] = float3(1,0,1); points[2] = float3(-1,1,-1); points[3] = float3(1,1,-1);
            break;
        case 13:
            points[0] = float3(0,-1,2); points[1] = float3(1,0,1); points[2] = float3(-1,1,-1); points[3] = float3(1,1,-1); points[4] = float3(-1,-1,-1);
            break;
        case 14:
            points[0] = float3(-1,0,3); points[1] = float3(0,-1,2); points[2] = float3(-1,1,-1); points[3] = float3(1,1,-1); points[4] = float3(1,-1,-1);
            break;
        case 15:
            points[0] = float3(-1,1,-1); points[1] = float3(1,1,-1); points[2] = float3(1,-1,-1); points[3] = float3(-1,-1,-1);
            break;
    }

 

그 후 CPU는 이 연산이 진행되는 것을 기다린 후 GetData()를 통해 연산된 Vertex와 Triangle들을 통해 Mesh를 만들고 유니티에 적용한다!!

 

// Get counters (reuse cached array)
counterBuffer.GetData(counterCache);

int vertexCount = counterCache[0];
int triangleIndexCount = counterCache[1];

if (vertexCount == 0 || triangleIndexCount == 0)
{
    if (mesh.vertexCount > 0)
    {
        mesh.Clear();
    }
    return;
}

// Clamp to max
int actualVertexCount = Mathf.Min(vertexCount, MAX_VERTICES);
int actualTriangleIndexCount = Mathf.Min(triangleIndexCount, MAX_TRIANGLES * 3);

// Get vertices
VertexData[] vertexData = new VertexData[actualVertexCount];
vertexBuffer.GetData(vertexData, 0, 0, actualVertexCount);

Vector3[] vertices = new Vector3[actualVertexCount];
for (int i = 0; i < actualVertexCount; i++)
{
    vertices[i] = vertexData[i].position; // 2D라서 z는 자동으로 0
}

// Get triangles
int[] triangles = new int[actualTriangleIndexCount];
triangleBuffer.GetData(triangles, 0, 0, actualTriangleIndexCount);

// Update mesh
mesh.Clear();
mesh.vertices = vertices;
mesh.triangles = triangles;
// mesh.RecalculateNormals(); // 2D라서 안씀 - 조명 안쓰면 불필요
mesh.RecalculateBounds();

2중 국적이라 2중 언어로 주석 달았다 (진짜임)

 

이럼 끝!!

 

결과물:

이런식으로 여러 원들이 이어진것 처럼 구현된 것을 볼 수 있다!! 와우!!!

 

끝!!

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

이라고 할 줄 알았다면 개추 ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ

여전히 프레임이 280+ -> 150 정도로 하락한 상황.. 우리는 더 큰 최적화가 필요하다.

ㅠㅠ fuck

Mesh를 그리는 것은 세상에서 가장 큰 작업 중 하나이다.

 

그래서 앙 쉐이더로 변경하였따!!!

struct VertexData
{
    float4 position; // xyz = position, w unused
};

StructuredBuffer<VertexData> vertices;
StructuredBuffer<int> triangles;

CBUFFER_START(UnityPerMaterial)
    float4 _Color;
CBUFFER_END

struct v2f
{
    float4 pos : SV_POSITION;
    float3 worldPos : TEXCOORD0;
};

v2f vert (uint vertexID : SV_VertexID)
{
    v2f o;

    // Get triangle index from buffer
    int triangleIndex = triangles[vertexID];

    // Get vertex position from buffer
    float3 worldPos = vertices[triangleIndex].position.xyz;

    o.worldPos = worldPos;

    // Transform to clip space (world space -> clip space for 2D)
    o.pos = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0));

    return o;
}

fixed4 frag (v2f i) : SV_Target
{
    return _Color;
}

쉐이더 요약: 걍 GPU 버퍼에 VertexID들 넘겨 받으면 그거로 걍 그림 ㅇㅇ... 그리고 화면에 띄어줌

 

GPU에서 계산 후 이제 그리기 까지 함.

새로운 업데이트 순서:

bool gridChanged = false;
if (HasCameraMovedOrZoomed())
{
    CacheCameraValues();
    UpdateGridDimensions();
    ReallocateDensityBuffer();
    UpdateRenderBounds();
    gridChanged = true;
}

RunComputeShaders(gridChanged);
private void RenderMetaballs() //LateUpdate에서 콜함.
{
    if (_tailController == null || _tailController.Tails.Count == 0)
        return;

    if (proceduralMaterial == null)
    {
        Debug.LogWarning("Procedural Material is null!");
        return;
    }

    // Set buffers to material
    proceduralMaterial.SetBuffer("vertices", vertexBuffer);
    proceduralMaterial.SetBuffer("triangles", triangleBuffer);

    if (useIndirectDraw)
    {
        // ZERO CPU-GPU SYNC! GPU decides how many triangles to draw
        Graphics.DrawProceduralIndirect(proceduralMaterial, renderBounds, MeshTopology.Triangles, argsBuffer, 0,
            mainCamera, null, UnityEngine.Rendering.ShadowCastingMode.Off, false, gameObject.layer);
    }
    else
    {
        // Fallback: Read counter from GPU (has sync overhead)
        counterBuffer.GetData(counterCache);
        int triangleIndexCount = counterCache[1];

        if (triangleIndexCount == 0)
            return;

        int actualTriangleIndexCount = Mathf.Min(triangleIndexCount, MAX_TRIANGLES * 3);

        Graphics.DrawProcedural(proceduralMaterial, renderBounds, MeshTopology.Triangles, actualTriangleIndexCount, 1,
            mainCamera, null, UnityEngine.Rendering.ShadowCastingMode.Off, false, gameObject.layer);
    }
}

기존에는 Mesh를 만들기 위해 GetData()까지 남용하였는데 이게 꽤 무거웠지만 이것도 그냥 가차없이 사라져버렸다.

물론 GPU와 CPU가 Sync된 버전도 쓸수 있게 해뒀지만 이건 AI가 걍 싼거고...

Graphics.DrawProceduralIndirect()를 대신 사용한다. 허허허허.

 

더보기

DrawProceduralIndirect가 무엇인가

Graphics.DrawProceduralIndirect(
    Material material,        // 사용할 셰이더(머티리얼)
    Bounds renderBounds,      // 프러스텀 컬링용 경계 박스(월드 좌표) (이 안만 렌더함 GPU가 내부적으로 프러스텀 컬링)
    MeshTopology topology,    // 프리미티브 타입 (Trianges/Lines 등)
    ComputeBuffer argsBuffer, // 간접 드로우 인수 버퍼(4 x uint)
    int argsOffset,           // argsBuffer 내 오프셋(바이트)
    Camera camera = null,     // 렌더 기준 카메라(생략 가능)
    MaterialPropertyBlock mpb = null, // 추가 머티리얼 파라미터 (이게 뭐지 음 뭐 그래)
    ShadowCastingMode castShadows = ShadowCastingMode.Off, // 그림자 캐스팅
    bool receiveShadows = false, // 그림자 수신 여부
    int layer = 0             // 렌더 레이어(카메라 CullingMask와 매칭)
);

 

그리고 기존에는 CPU에서 GPU의 완료를 대기해야 했으나 이제는 완전히 비동기이다.

와 그냥 메타볼 생성 전이랑 후랑 프레임 차이가 걍 없노 ㄷㄷ

아주 효율적이라고~!! 올롸잇!! 

 

아 참고로 이런식으로 런타임에 GridSpcing이나 Threshold를 변경할 수 있다.

참고로 프레임 기반으로 자동 조절도 만들어 놓긴 함.

그리드가 촘촘하면 당연하게도 퀄이 점점 높아진다!!

진짜 끝.

Easy Work

출처 & 참고

https://jamie-wong.com/2014/08/19/metaballs-and-marching-squares/

https://en.wikipedia.org/wiki/Metaballs