우리가 만든 goo에는 물리 시뮬레이션이 없습니다. 대신 네 가지 단순한 아이디어가 겹쳐 있어요 — ① 모양을 거리로 표현하고(SDF), ② 거리들을 부드럽게 섞어 합쳐지게 하고(smooth-min = 끈적임), ③ 그걸 광선으로 그리고(레이마칭), ④ 작은 물리로 움직입니다. 아래 데모들의 슬라이더를 직접 만지면서 각 원리가 화면을 어떻게 바꾸는지 확인해 보세요.
관련: ▶ 실제 프로토타입 열기 · 기획서
각 블록은 따로 보면 아주 단순합니다. 합쳐졌을 때 "살아있는 끈적한 덩어리"처럼 보일 뿐이에요.
"이 점에서 표면까지 거리"를 알려주는 함수. 구는 거리 = 중심까지거리 − 반지름. 모양을 점·선이 아니라 수식으로 들고 있습니다.
두 거리값을 부드럽게 최소화하면, 가까워진 두 덩어리 사이에 "목(neck)"이 생기며 녹아 붙습니다. 멀어지면 끊어집니다. 이게 메타볼의 핵심.
삼각형 메시가 없으니, 픽셀마다 광선을 쏘고 "현재 표면까지 거리만큼" 한 발씩 전진해 표면을 찾습니다. 매끈한 곡면·광택을 메시 없이 그립니다.
덩어리 중심마다 중력·바닥 반발·킥 임펄스를 주는 ~40줄짜리 코드. 모양은 ②가, 움직임은 ④가 담당. 둘은 분리되어 있습니다.
"진짜 유체 시뮬"은 무겁고 통제가 어렵습니다. 우리는 모양(②)과 움직임(④)을 분리해서, 합쳐짐은 공짜로 얻고(수식) 움직임은 손으로 연출합니다(작은 물리). 그래서 가볍고, 비트에 맞춰 마음대로 조종할 수 있어요.
화면의 한 점 p가 있을 때, "표면까지 얼마나 떨어졌나"를 숫자로 돌려주는 함수를 SDF라고 합니다. 구(공)는 세상에서 가장 쉬운 SDF예요.
d > 0 = 표면 바깥, d = 0 = 표면 위(껍질), d < 0 = 안쪽. 그래서 "Signed(부호 있는)" 거리. 표면은 항상 d = 0인 지점들의 모임입니다.
마우스를 캔버스 위에 올려보세요. 커서에서 표면까지의 거리(흰 선)와 부호를 실시간으로 보여줍니다. 동심원은 같은 거리값(등거리선)이에요.
두 모양을 합치려면 두 거리 중 더 가까운 것(최소값)을 고르면 됩니다. 그런데 그냥 min을 쓰면 만나는 곳에 날카로운 주름이 생겨요. 부드럽게 최소화하면 — 거기서 끈적임이 태어납니다.
두 표면이 거리 k 안으로 들어오면, smin이 표면을 바깥으로 살짝 끌어내 둘 사이에 다리(목)를 놓습니다 → 합쳐져 보임. 다시 k보다 멀어지면 다리가 얇아지다 끊어집니다 → 분리. 그래서 k 하나가 "얼마나 끈적한가"를 정합니다. k가 곧 점성 손잡이.
두 원을 드래그해 가까이/멀리 옮겨보세요. 끈적임 k를 올리면 더 멀리서도 다리가 생기고, 목이 두꺼워집니다. 흰 윤곽선이 실제 goo 표면(d=0)이에요.
두 거리값이 교차하는 지점을, 딱딱한 min(주황, 각짐)과 smin(녹색, 둥글게)으로 비교합니다. k를 키울수록 모서리가 더 둥글게 깎여요 — 이 "깎인 부분"이 화면에선 목(neck)으로 보입니다.
프로토타입의 scene() 안 루프가 모든 덩어리를 d = smin(d, 구거리, uSmoothK)로 차곡차곡 합칩니다. GUI의 "끈적임 K" 슬라이더가 바로 이 k = uSmoothK예요.
우리에겐 삼각형이 없습니다. 거리 함수만 있죠. 그래서 픽셀마다 카메라에서 광선을 쏘고, "지금 위치에서 표면까지 거리"만큼 안전하게 한 발씩 전진합니다. 거리가 0에 가까워지면 = 표면에 닿음.
현재 점에서 가장 가까운 표면이 거리 d만큼 떨어져 있다면, d만큼 전진해도 절대 표면을 뚫고 지나치지 않습니다(반지름 d인 빈 공이 보장됨). 그래서 빈 공간은 성큼성큼, 표면 근처는 촘촘히 — 효율적이에요. 단, 스텝 수가 모자라면 멀리 있는 표면에 닿기 전에 포기 → 잘리거나 띠가 보입니다.
왼쪽 눈에서 광선이 출발합니다. 각 단계의 빈 원이 "그 지점에서 가장 가까운 표면까지 거리"예요. 스텝 수를 줄이면 표면에 못 닿는 걸, 안전계수를 1 이상으로 올리면 표면을 뚫는(과전진) 걸 볼 수 있습니다.
표면에 닿으면, 그 점이 어느 방향을 보는지(노멀)를 구해 빛을 입힙니다. 노멀은 거리장의 "기울기"로 공짜로 얻어요.
광택을 올리면 젖은 하이라이트가 또렷해지고, 프레넬을 올리면 가장자리가 녹색으로 빛납니다. Hue로 색을, 글로우로 번짐을 조절. 프로토타입의 "룩·광택" 슬라이더와 같은 값이에요.
덩어리 중심마다 아주 단순한 물리를 매 프레임 적용합니다. 모양(메타볼)과 완전히 분리된, 수십 줄짜리 코드예요.
왼쪽은 공, 오른쪽은 시간에 따른 높이 그래프입니다. 중력↑=빨리 떨어짐, 반발↑=더 오래 통통, 점성↑(감쇠↓)=금세 멈춤. KICK으로 발사해 곡선을 보세요.
실제 프로토타입에선 비트 = 킥 임펄스, 저음 = 점성·크기↑로 연결됩니다. 즉 위 슬라이더들을 음악이 자동으로 움직이는 셈이에요.
레이어는 "서로 섞이지 않는 별개의 goo 그룹"입니다. 같은 레이어끼리만 smin으로 끈적이고, 다른 레이어와는 그냥 겹쳐 보일 뿐이에요.
덩어리 데이터는 vec4(x, y, z, w) 네 칸뿐. 그래서 마지막 칸에 반지름 + 레이어×8을 함께 넣고, 셰이더에서 레이어=floor(w/8), 반지름=w−레이어×8로 풀어 씁니다(반지름은 항상 8보다 작으니 안 겹침). 덕분에 추가 데이터 배열 없이 레이어를 표현해요. 각 레이어엔 다른 색(Hue 오프셋)과 깊이를 줘서 "두 겹"으로 읽히게 합니다.
goo의 비용은 거의 하나의 곱으로 설명됩니다. 이 식만 이해하면 미디어월(4K+)에서 60fps를 맞추는 법이 보여요.
픽셀 수는 가로·세로 양쪽에 곱해지므로 내부 해상도를 0.7로 낮추면 비용은 0.7² ≈ 0.49배 — 거의 절반! goo는 경계가 부드러워서 살짝 흐려져도 티가 잘 안 납니다. 그래서 4K 월에선 0.5~0.75로 렌더하고 화면에서 확대하는 게 정석이에요.
곡선 = 내부 해상도에 따른 비용(제곱 곡선). 막대 = 스텝/덩어리의 선형 영향. ● = 현재 설정. fps는 M1 Max 대략 보정값 기준 추정치(실제는 프로토타입에서 확인).
| 손잡이 | 비용 영향 | 시각 효과 | 미디어월 팁 |
|---|---|---|---|
| 내부 해상도 | 제곱 (가장 큼) | 선명도 | 0.5~0.75로 — 비용 절반, 차이 미미 |
| 레이 스텝 | 선형 | 먼 표면·깊이 정확도 | 16~32로 충분(goo는 단순). 띠 보이면 + |
| 덩어리 수 | 선형 | 풍성함·디테일 | 16~48 권장. 64는 강한 GPU에서만 |
| 끈적임 K | 거의 없음 | 융합 정도 | 마음껏. 단 너무 크면 레이마칭 띠 → 스텝↑ |
| 글로우/광택 | 미미 | 발광·젖은감 | LED에서 강하게 빛남 — 과하지 않게 |
| 출력 해상도 | 제곱 | 월 크기 | 월 실제 픽셀로 고정 + 내부해상도로 보정 |
픽셀수 × 스텝. 4K 풀스크린 레이마칭이 여기서 막힙니다. → 내부 해상도↓ + 스텝↓로 해결.
매 스텝마다 모든 덩어리의 거리+smin. 덩어리 수에 선형 비례. → 덩어리 수 제한, 필요하면 공간 격자 가속.
표면에 닿은 픽셀은 노멀 때문에 map을 6번 더 호출. 표면이 화면을 많이 덮을수록 가중. → 보통 무시 가능.
프로토타입의 각 손잡이가 어떤 수식/코드를 건드리는지, 올리면/내리면 어떻게 보이는지.
| 슬라이더 | 수식·코드 | 올리면 (↑) | 내리면 (↓) |
|---|---|---|---|
| 끈적임 K | smin의 k (uSmoothK) | 더 멀리서 융합, 목 두꺼움, 한 덩어리화 | 또렷이 분리, 개별 공처럼 |
| 덩어리 수 | uBlobCount, 루프 횟수 | 풍성·복잡 (비용↑) | 단순·깔끔 (비용↓) |
| 레이어 수 | d₀/d₁/d₂ 분리 | 여러 겹 동시에 | 한 덩어리 군 |
| 레이어 색차 | Hue 오프셋 | 레이어 색 대비 강함 | 색이 비슷 |
| 덩어리 크기 | sizeBase × r | 전체적으로 큰 덩어리 | 작은 알갱이 |
| 크기 다양성 | r = base·(1+(seed−.5)·2·var) | 크고 작은 게 섞여 형태 다채 | 균일한 크기 |
| 크기→튐 차이 | vs = (base/r)p | 작은 건 높이, 큰 건 둔하게 | 크기 무관 동일하게 튐 |
| 중력 | v_y −= g·Δt | 빨리 떨어짐, 묵직 | 둥둥, 천천히 |
| 바닥 반발 | v_y ← −v_y·e | 오래 통통 튐 | 바닥에 철퍽 정착 |
| 점성·감쇠 | v ← v·damping | 관성 유지, 출렁출렁 | 금세 멈춤, 끈적·무거움 |
| 킥 발사력 | v_y += impulse | 높이 솟구침 | 살짝 들썩 |
| 저음→점성 | bass→damping·r 보정 | 저음에서 크게 뭉침·느려짐 | 음악 영향 적음 |
| 광택 / 프레넬 / 글로우 | spec / fresnel / 누적 halo | 젖은 광·발광 강 | 매트·차분 |
| 레이 스텝 / 내부 해상도 | 성능 손잡이 | 정확·선명 (비용↑) | 빠름 (띠·흐림 위험) |
// 1) 한 덩어리까지의 거리 (SDF) float sdSphere(p, c, r) return length(p - c) - r; // 2) 부드러운 최소 = 끈적임 (merge/split) float smin(a, b, k){ h = clamp(0.5 + 0.5*(b-a)/k, 0, 1); return mix(b, a, h) - k*h*(1-h); } // 3) 모든 덩어리를 레이어별로 합침 float scene(p){ for (덩어리 i) d[layer] = smin(d[layer], sdSphere(p, c_i, r_i), uSmoothK); return min(d0, d1, d2); // 레이어끼리는 안 섞임 } // 4) 레이마칭 — 표면 찾기 t = 0; for (i < uSteps){ d = scene(o + dir*t); if(d < 0.001) 닿음; t += d*0.85; } // 5) 노멀 + 빛 (젖은 녹색) n = normalize(차분(scene)); diffuse = n·l; fresnel = (1-n·v)^3; spec = (n·h)^64; // 6) 물리 (JS, 매 프레임) — 모양과 분리 v.y -= g*dt; p += v*dt; if(바닥) v.y = -v.y*e; v *= damping; // 킥: v.y += impulse
① 모양은 거리(SDF)로, 합쳐짐은 smooth-min으로 — k가 끈적임. ② 그리기는 레이마칭 — 거리만큼 점프, 스텝·내부해상도가 성능. ③ 움직임은 별도의 작은 물리 — 중력·반발·킥, 음악이 이 손잡이를 돌린다.