실제 촉감을 XPBD 소프트바디로 구현해보았습니다.
반죽은 놓은 자리에 남아야 합니다
레퍼런스로 삼은 건 3D 공을 돌려가며 눌러 부수는 미니앱이었지만, 만들고 싶었던 건 정반대였습니다. 부서지는 공이 아니라, 손가락으로 잡아 늘리면 그 자리에 그대로 남는 밀가루 반죽이었죠. 기획서 맨 앞에 물성 원칙을 못 박아 두었는데 그 첫 줄이 "스냅백 없음"이었습니다. 잡아 늘리고 놓았을 때 고무줄처럼 튕겨 돌아가면 그 시점에서 이미 실패라는 뜻입니다.
문제는 웹에 도는 소프트바디 예제 대부분이 정확히 그 스냅백을 기본 동작으로 삼는다는 점이었습니다. XPBD든 스프링-매스든, 정점마다 "원래 있어야 할 자리(rest position)"와 엣지마다 "원래 길이(rest length)"를 정해 두고 그쪽으로 되돌리는 힘을 매 프레임 넣는 게 보통이죠. 되돌리는 힘이 곧 탄성이고, 그게 있는 한 놓으면 돌아갑니다. 탱탱볼은 그래야 맞지만 반죽은 그러면 안 됐습니다.
해법은 "되돌릴 목표 자체를 현재 모양으로 계속 갱신"하는 것이었습니다. SlimePhysics.ts에는 소성(plasticity)이 두 겹으로 들어가 있습니다.
- 첫째, 엣지의 rest length가 현재 길이를 실시간으로 따라갑니다.
updatePlasticity에서 변형률(strain)이PLASTIC_YIELD(0.015)를 넘으면 restLen을 현재 길이 쪽으로PLASTIC_RATE=40/s로 끌어옵니다. 늘어난 엣지는 그 길이를 "원래 길이"로 받아들이니 되돌릴 이유가 사라지죠. - 둘째, 형상 기억의 목표(rest shape)마저 현재 모양을 τ≈0.08초(
MEMORY_PLASTIC_RATE=12/s)로 추종합니다. 잠깐의 출렁임은 rest shape로 되돌려 잔진동을 죽이되, 잡고 있는 변형은 곧 새로운 "원래 모양"이 됩니다.
이 두 겹이 있어도 마지막 한 방이 필요했습니다. 손가락을 떼는 순간에 남는 잔여 탄성이죠. 그래서 마지막 그랩이 끝나는 찰나에 현재 상태를 원래 상태로 확정하는 commit을 넣었습니다.
endGrab(pointerId: number) {
if (!this.grabs.delete(pointerId)) return;
// 마지막 손가락을 놓는 순간 잔여 탄성을 전부 소성으로 확정
if (this.grabs.size === 0) this.commitPlasticState();
}
private commitPlasticState() {
const p = this.pos;
for (let i = 0; i < this.restLen.length; i++) {
// ... 모든 엣지의 restLen을 현재 길이로 확정
}
this.rest.set(p); // rest shape = 현재 모양
for (let i = 0; i < this.pos.length; i++) this.vel[i] = 0; // 속도 0
}
restLen과 rest shape를 현재로 확정하고 속도를 0으로 지우면, 놓는 순간 되돌아갈 목표도 관성도 남지 않습니다. 스냅백을 "약하게 만든" 게 아니라 원리적으로 없앤 셈이죠. 더블탭으로 푹 눌러 자국을 낼 때도 눌러 들어가는 동작이 끝나면 같은 commit을 호출해, 팬 자국이 그대로 굳습니다.

비압축성 — 한쪽을 누르면 반대쪽이 부풉니다
반죽의 두 번째 감각은 비압축성입니다. 실제 반죽은 한쪽을 누르면 부피가 어디 사라지지 않고 반대쪽으로 밀려 불룩해지죠. 이걸 살리려면 부피를 지키는 제약이 따로 필요했습니다.
지오메트리는 IcosahedronGeometry(1, 8)을 mergeVertices로 정점 공유 형태로 병합한 것으로, 약 812개 정점과 2,430개 엣지, 1,620개 삼각형이 됩니다. PolyhedronGeometry는 정점을 공유하지 않는 형태로 나오는데, 병합하지 않으면 엣지 토폴로지를 만들 수 없어 생성자에서 indexed geometry required로 바로 예외를 던지게 해 두었습니다.
이 위에서 매 프레임 5번의 substep을 도는데, 그 순서가 촉감을 좌우했습니다.
const sdt = dt / SUBSTEPS; // SUBSTEPS = 5
for (let s = 0; s < SUBSTEPS; s++) {
this.integrate(sdt); // 감쇠 적분
if (this.grabs.size > 0) this.applyGrab();
if (this.pressActive) this.solvePress();
this.solveEdges(); // 엣지 거리 제약(탄성)
this.solveVolume(); // 전역 부피 보존(비압축성)
this.applyShapeMemory(sdt);
this.updateVelocities(sdt);
}
비압축성은 solveVolume이 담당합니다. 삼각형마다 원점과 이루는 사면체의 부호 있는 부피를 모두 더해 현재 전체 부피 V를 구하고, 최초 부피 V0와의 차 C = V − V0를 제약으로 삼습니다. 그리고 각 정점이 부피를 얼마나 바꾸는지를 gradient로 계산해 그 방향으로 정점들을 조금씩 밀어 C를 0으로 되돌리죠. 여기서 중요한 선택이 "전역" 부피 보존이었습니다. 사면체 하나하나의 부피를 따로 지키게 만들면 표면이 뻣뻣해져 반죽 느낌이 죽는데, 전체 부피 하나만 지키게 하니 한쪽을 누른 만큼 반대편이 자연스럽게 밀려 나왔습니다.
엣지 제약은 일부러 무르게 뒀습니다. EDGE_STIFFNESS는 0.6인데, 이 값이 낮을수록 탄성 수축이 느려져 소성이 이깁니다. 반죽은 탱탱볼과 반대로 "제약을 덜 푸는" 쪽이 정답이었던 셈이죠. 코드 주석에도 적어 두었지만, Gauss-Seidel 반복이 모자라서 생기는 "덜 풀린 늘어남"이 여기서는 버그가 아니라 반죽의 무름 그 자체가 됩니다.
NaN과 레이캐스트, 두 개의 함정
물성보다 저를 오래 붙잡은 건 오히려 잘 안 보이는 함정 두 개였습니다.
첫째는 NaN이었습니다. substep은 sdt = dt / SUBSTEPS로 나눈 시간 간격으로 도는데, 속도를 되계산하는 updateVelocities가 1 / sdt를 곱합니다. 어쩌다 dt가 0으로 들어오면(탭 전환 직후나 첫 프레임처럼 시간차가 0인 순간이 실제로 있었죠) 이 나눗셈이 Infinity가 되고, 다음 프레임엔 정점 좌표가 통째로 NaN으로 물들어 슬라임이 화면에서 사라집니다. 그래서 루프 진입점에서 dt를 아예 클램프했습니다.
const dt = Math.min(Math.max((now - lastTime) / 1000, 1 / 240), 1 / 30);
하한 1/240은 dt=0을 막는 방어이고, 상한 1/30은 프레임이 크게 밀렸을 때(예: 백그라운드에서 막 돌아왔을 때) 한 프레임에 물리가 폭주해 튀는 걸 막는 장치입니다.
둘째는 더 오래 헤맨 레이캐스트 함정이었습니다. [확인 필요: 슬라임을 크게 늘려 놓은 뒤 그 늘어난 끝을 다시 잡으려는데 손가락이 자꾸 허공을 짚어서, 처음엔 hit 좌표 계산이 틀린 줄 알고 한참 그쪽만 팠다는 경위] 원인은 엉뚱한 곳에 있었습니다. three.js의 raycaster는 정밀한 삼각형 교차 검사에 앞서 객체의 bounding sphere로 1차 컬링을 하는데, 크게 늘린 부분은 낡은 bounding sphere 밖으로 나가 있어 검사 자체가 조기 탈락했던 겁니다. 정점 위치는 매 프레임 바뀌는데 bounding sphere는 그대로였으니까요. 해결은 한 줄이었습니다. step 끝에서 매 프레임 this.geometry.computeBoundingSphere()를 불러 주는 것이죠.
그 밖에도 반죽 감각을 지키는 잔손질이 몇 개 더 붙었습니다.
- 라플라시안 스무딩: 주무를 때 종이 구겨지듯 각진 주름이 생기지 않도록, 각 정점을 이웃 평균 쪽으로 조금씩 당겨 표면을 둥글게 뭉갭니다.
- 최대 크기 한계: 반복해 당겨도 중심에서
MAX_RADIUS(2.75)를 넘지 못하게 부드럽게 되돌리고, 소성 엣지 길이도 원본의 3배에서 멈춥니다. 무한정 늘어나는 건 명시적으로 원치 않는 동작이었거든요. - 질량중심 원점 고정: 매 프레임
recenter로 무게중심을 원점에 붙입니다. 이게 없으면 당겼다 놓은 뒤 슬라임이 화면 중앙으로 미끄러져 돌아가는, 은근히 멀미 나는 움직임이 생깁니다.
결국 반죽의 촉감은 화려한 알고리즘 하나가 아니라, 스냅백을 지우는 commit과 전역 부피 보존, 소성 이중 구조 같은 작은 결정들이 겹쳐 만들어졌습니다. 개인적으로는 물리 엔진을 "정확하게" 만드는 것과 "그럴듯하게, 만지고 싶게" 만드는 건 꽤 다른 일이라는 걸 이 프로젝트에서 배웠어요. 반죽은 제약을 완벽히 푸는 게 아니라, 적당히 덜 푸는 데서 시작하니까요.