[유니티] 유니티에서 메타볼 구현. 허허허허 😉😉 메타몽: 나는 메타볼이다!!! 🖤💦
이제 옛날 옛적에 만들던 개망한 엔진 팀플이 있다....
그 당시 나는 메타볼을 구현하고 싶었다.

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

내가 아냐 ㅅㅂㄹㅇ
오늘 구현해 볼 것은 '메타볼'이다.
위에 화난 이유는 걍 개 심심했다. 허허허허허 <<퍽 퍼벅 시그마 시그마 보이


이제 이런게 메타볼이다... 물방울 합쳐지는 것 같은 그런 효과를 말한ㄷㅏㅏㅏㅏ.
사전적 의미: 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. 이다.
근데 이제 예전에 만들었던 이제 그 정자 졸작게임이 있는데 사실 그 당시에도 메타볼을 만드려 했었다. 일단 그 게임의 움직임은 이랬고... 대충 꼬리들이 총알을 쏘고 그 반동으로 이동하며 진행하는 게임이였다.

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

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


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

이게 느린 이유가 알고리즘이 헤비 한 데다가 그냥 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)를 쓰는 거임.
그 후 임계값과 비교하여 안과 밖은 구분함.


이거는 내 게임사진.
빨갈 수록 그 그리드의 밀도장이 높은 것. (더 뭉쳐있음)
그럼 이런식으로 표현됨. 근데 보면은 이제 미친 각진 뭐 도트 그래픽이 되버리기 때문에 이걸 해결해야함.
무엇을 해야하나??
그리드의 각 셀에서 경계선을 추출해야함.
그 다음 선을 이어 붙이면 됨.
=> Marching Squares 알고리즘.
와우 어려워 보인다. 사실 그건 아니고 쉬운 것이다.
저 격자점들을 2x2 네개씩 계산하여 선을 그으면 된다. 이건 공식에 기반.

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

근데 사실 이거까지 해도? 약간 각짐. 뭔 다 45도로 막 이루어진거같노;;
해결법: Density 수치에 따라서 선을 보간하자!

그럼 이제 저 녹색 점은 하나여도 여러가지 형태 구현 가능!!!!
이건 걍 공식 가져다가 박으면 된다....
엣지 교차점 보간(Linear Interpolation)
마칭 스퀘어즈에서 경계선은 셀 엣지를 통과한다. 엣지 양 끝점 A,B에서의 스칼라값을 각각 vA, vB 라 하고, 임계값을 라 하면, 가 되는 지점 P는


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

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

그냥 위에 있는 순서대로 구현하였다.
근데 이제 이걸 이전에는 다 CPU에게 맡겨서 역대급 프레임이 표현되어버렸다...

보간도 대강했는데도 아주 프레임이 미쳤다 그냥.
metaball 미사용 시 280 FPS정도 나온다.

자 그래서 해결책: GPU와 역할을 분담하자!!! -> Compute Shader을 이용고고
자 근데 이게 뭐 코드만 보낸다고 뭐 이해가 잘 되진 않기에 일단 플로우차트로 설명간다. 근데 그리진 않아서 말로함 ㅠㅠ
자 일단
- CPU에서는 커널을 얻고 컴퓨트 버퍼를 생성해야함.
- 이제 매프레임 출발
- 일단 메타볼을 계산할 그 오브젝트(꼬리)가 존재하는지 파악을 하고.. 존재하면
- 카메라 위치와 메타볼의 데이터를 캐싱해놓고 만약 변경되면 그 데이터를 컴퓨트 셰이더로 Dispatch함.
- 계산 후 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 알고리즘이 샘플링된 데이터를 연산한다.

(이렇게 해줌.)
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 정도로 하락한 상황.. 우리는 더 큰 최적화가 필요하다.

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를 변경할 수 있다.
참고로 프레임 기반으로 자동 조절도 만들어 놓긴 함.
그리드가 촘촘하면 당연하게도 퀄이 점점 높아진다!!

진짜 끝.

출처 & 참고
https://jamie-wong.com/2014/08/19/metaballs-and-marching-squares/
'졸작 > 엔진팀플' 카테고리의 다른 글
| [엔진팀플] 우리의 엔진팀플로 세상을 정복하다!! 🗣️🔥🔥 (4) | 2024.12.16 |
|---|---|
| [엔진팀플] 지구멸망3초전,엔진팀플끝내는법! 💦 (1) | 2024.11.06 |
| [엔진팀플] 게으른 팀원을 팀장의 흉악 몽둥이로 참교육...❤️🩹 (5) | 2024.11.01 |