three.js 회전체로 만든 도자기 물레 — 웹에서 점토 빚기
물레는 계속 돈다. 손은 한 점만 누른다. 그런데 그릇 전체가 변한다. 도자기의 이 오래된 규칙이, 웹에서 3D 형태를 만드는 가장 값싼 방법이기도 하다는 게 이 실험의 출발점이었다. 위 무대에서 바로 빚어볼 수 있고, 전체화면으로 빚기를 누르면 화면 전체로 커진다.
먼저 결론
- 회전체는 3D가 아니라 1차원 배열이다. 높이별 반지름
Float32Array(96)하나가 형태 전부를 담는다. 매 프레임 다시 계산하는 건 이 96개 숫자뿐이다. - 물레가 돌기 때문에 한 점만 눌러도 된다. 각도 방향 입력이 필요 없으니, 마우스 좌표 두 개로 3D 형태가 만들어진다.
- 정점 12,384개를 매 프레임 갱신하면서 60fps.
computeVertexNormals()를 버리고 법선을 해석적으로 구한 게 결정적이었다. - 폴백 4종. WebGL 미지원 · 웹캠 거부 ·
prefers-reduced-motion· three.js CDN 차단까지 각각 다른 경로로 살려 뒀다.
왜 회전체였나 — 마우스 좌표 두 개로 3D를 만드는 법
브라우저에서 3D 모델링을 흉내 내는 데모는 대개 두 갈래다. 이미 만들어진 모델을 불러와 돌려 보게 하거나, 정점 하나하나를 끌어당기는 조각 도구를 흉내 내거나. 앞쪽은 만드는 재미가 없고, 뒤쪽은 마우스 하나로 3차원을 다루려다 대부분 실패한다. 커서는 평면 위에 있고 형태는 공간에 있으니, 그 간극을 메우려면 카메라를 돌리고 축을 고정하고 브러시 크기를 조절하는 UI가 줄줄이 따라붙는다. 데모는 무거워지고 사람은 3초 만에 떠난다.
도자기 물레는 이 문제를 물리적으로 해결해 둔 도구다. 흙이 축을 중심으로 돌기 때문에, 손은 한 지점에만 닿아 있어도 그 높이의 둘레 전체가 깎인다. 즉 사람이 입력해야 하는 값이 (높이, 반지름) 두 개뿐이다. 마우스 좌표가 정확히 두 개라는 사실과 딱 맞는다. 웹에서 3D를 만지게 하려면 이보다 나은 은유가 없다고 판단했다.
three.js LatheGeometry 대신 BufferGeometry를 직접 쓴 이유
three.js에는 회전체를 만드는 LatheGeometry가 이미 있다. 단면 점들을 넘기면 알아서 돌려준다. 문제는 이게 생성 시점에 한 번 계산되는 지오메트리라는 점이다. 형태를 바꾸려면 매번 새로 만들어야 하고, 초당 60번 새 지오메트리를 만들면 GC가 계속 끼어든다.
그래서 BufferGeometry를 직접 만들고, 정점 배열과 인덱스는 딱 한 번만 할당한 뒤 매 프레임 position 값만 덮어썼다. 형태를 저장하는 건 결국 배열 하나다.
const RINGS = 96, SEG = 128, H = 2.1;
const prof = new Float32Array(RINGS); // 형태 전부가 여기에 있다
// 처음 상태 = 물레에 올린 점토 덩어리
function baseRadius(t){
const body = 0.46 + 0.10 * Math.sin(t * Math.PI);
const topCap = t > 0.88 ? Math.sqrt(Math.max(0, 1 - ((t-0.88)/0.12)**2)) : 1;
const botCap = t < 0.04 ? Math.sqrt(Math.max(0, 1 - ((0.04-t)/0.04)**2)) : 1;
return body * topCap * botCap;
}
정점은 96 × 129 격자, 총 12,384개다. 갱신할 때는 geo.attributes.position.needsUpdate = true만 세워주면 된다. 지오메트리 재생성이 아예 없으니 프레임이 균일해진다.
computeVertexNormals()가 느릴 때 — 회전체 법선은 직접 구하는 게 빠르다
첫 버전은 정직하게 geometry.computeVertexNormals()를 매 프레임 호출했다. 정점 12,384개, 삼각형 24,320개를 훑으면서 면 법선을 누적하고 다시 정규화하는 작업이다. 노트북에서는 넘어갔지만 모바일에서 프레임이 눈에 띄게 끊겼다. 프로파일러를 보니 프레임당 예산의 상당 부분이 거기서 사라지고 있었다.
해결은 계산을 지우는 쪽이었다. 회전체의 법선은 이웃 삼각형을 뒤질 필요가 없다. 단면 곡선의 기울기 dr/dy만 알면 자오선 평면에서의 법선이 나오고, 그걸 각도만큼 돌리면 끝이다. 삼각형을 한 번도 보지 않고, 정점 루프 안에서 나눗셈 한 번으로 해결된다.
for (let i = 0; i < RINGS; i++){
const y = (i / (RINGS - 1)) * H, r = prof[i];
// 자오선 기울기 dr/dy 로 법선을 바로 구한다
const dr = prof[Math.min(i+1,RINGS-1)] - prof[Math.max(i-1,0)];
const dy = H * 2 / (RINGS - 1);
const nl = Math.hypot(dy, dr) || 1;
for (let s = 0; s <= SEG; s++){
const a = (s / SEG) * Math.PI * 2, c = Math.cos(a), sn = Math.sin(a);
pos[p++] = r*c; pos[p++] = y; pos[p++] = r*sn;
nor[q++] = (dy/nl)*c; nor[q++] = -dr/nl; nor[q++] = (dy/nl)*sn;
}
}
“도자기는 손이 형태를 만드는 게 아니라, 손이 만든 자국을 회전이 형태로 바꾸는 것이다.”
이 문장은 구현에도 그대로 적용됐다. 손이 하는 일은 prof[i] 몇 칸을 건드리는 것뿐이고, 그것이 그릇이 되는 건 회전 대칭 덕분이다. 회전 대칭이라는 제약을 받아들이면 계산이 통째로 사라진다.
점토가 찢어지는 문제 — 기울기 상한과 이완
커서를 빠르게 움직이면 인접한 링의 반지름 차이가 급격히 벌어져서, 형태가 뾰족한 이빨처럼 튀었다. 흙이라기보다 종잇장이 찢어진 모습이었다. 실제 점토에는 없는 현상이니 물리적으로 막아야 했다. 두 가지를 넣었다.
// 1) 이웃 링과의 기울기 상한 — 흙은 이만큼밖에 못 늘어난다
const MAX_STEP = 0.055;
for (let i = 1; i < RINGS; i++){
const d = prof[i] - prof[i-1];
if (Math.abs(d) > MAX_STEP) prof[i] = prof[i-1] + Math.sign(d) * MAX_STEP;
}
// 2) 누르는 동안만 아주 약한 이완 — 손이 떠나면 굳는다
if (pressing) for (let i = 1; i < RINGS-1; i++)
prof[i] += ((prof[i-1] + prof[i+1]) * 0.5 - prof[i]) * 0.06;
이완(relaxation)을 상시로 돌리면 시간이 지날수록 모든 형태가 원기둥으로 되돌아간다. 그래서 pressing일 때만 적용했다. 손을 떼면 그 순간의 형태가 그대로 굳는다 — 실제 물레보다 관대하지만, 화면에서는 이쪽이 훨씬 기분 좋다.
MediaPipe Hand Landmarker로 웹캠 손 인식 붙이기
원래 아이디어는 웹캠으로 손 관절을 추적해 점토를 빚는 것이었다. MediaPipe의 Hand Landmarker는 손 하나당 21개 랜드마크를 돌려주고, 그중 검지 끝(8번)과 엄지 끝(4번)만 쓰면 충분했다. 두 점의 화면상 중점이 누르는 위치, 두 점 사이 거리가 누르는 세기다. 손가락을 오므릴수록 깊이 파인다.
const { FilesetResolver, HandLandmarker } =
await import("https://cdn.jsdelivr.net/npm/@mediapipe/[email protected]/vision_bundle.mjs");
const tip = lm[8], thumb = lm[4];
const pinch = Math.hypot(tip.x - thumb.x, tip.y - thumb.y); // 0.02 ~ 0.30
const power = clamp((0.22 - pinch) / 0.18, 0, 1); // 오므릴수록 1
S.px = 1 - (tip.x + thumb.x) / 2; // 거울 보정
S.py = (tip.y + thumb.y) / 2;
다만 이걸 기본 모드로 두지는 않았다. 페이지를 열자마자 카메라 권한을 묻는 창이 뜨는 건 방문자 입장에서 무례하다. 그래서 첫 화면은 커서로 바로 빚어지는 상태이고, 웹캠은 오른쪽 위 버튼을 눌러야 켜진다. 켜기 전까지는 WASM도 모델 파일(약 8MB)도 내려받지 않는다.
웹캠이 안 켜질 때 — getUserMedia는 HTTPS나 localhost에서만 된다
손 추적을 붙이고 나서 가장 많이 헤맨 건 알고리즘이 아니라 이것이었다. navigator.mediaDevices.getUserMedia()는 보안 컨텍스트에서만 동작한다. 파일을 더블클릭해 file://로 열면 navigator.mediaDevices 자체가 undefined가 되기도 하고, 사내망이나 폰에서 http://192.168.0.x:8000 같은 LAN 주소로 접속하면 권한 창조차 뜨지 않는다.
| 주소 | 웹캠 | 비고 |
|---|---|---|
https://… | 동작 | 배포된 사이트는 문제없음 |
http://localhost:8000 | 동작 | localhost는 보안 컨텍스트 예외 |
http://192.168.0.x:8000 | 안 됨 | LAN IP는 예외가 아니다 |
file:///… | 안 됨 | 모듈 import도 함께 막힌다 |
그래서 로컬에서 확인할 때는 파일이 있는 폴더에서 python3 -m http.server 8000을 띄우고 http://localhost:8000으로 여는 게 가장 빠르다. 폰에서 실제로 만져보려면 결국 HTTPS로 올리거나 터널을 하나 뚫어야 한다.
setPointerCapture 때문에 버튼 클릭이 안 먹던 문제
배포 직전에 발견한 버그다. 드래그가 캔버스 밖으로 나가도 끊기지 않게 하려고 pointerdown에서 stage.setPointerCapture()를 걸어 뒀는데, 무대 위에 얹은 버튼들이 전부 눌리지 않았다. 버튼에서 시작한 포인터까지 무대가 가로채는 바람에, pointerup이 버튼이 아니라 무대로 가고 click 이벤트가 아예 생성되지 않았던 것이다. 화면상으로는 버튼이 멀쩡히 보이니 눈으로는 절대 안 잡힌다.
// 버튼 위에서 시작한 포인터까지 캡처하면 click 이 안 뜬다 — 먼저 걸러낸다
function onControl(e){
return !!(e.target && e.target.closest && e.target.closest('.tools, .note, #cam'));
}
stage.addEventListener('pointerdown', function(e){
if (S.hand || onControl(e)) return;
stage.setPointerCapture(e.pointerId);
...
});
헤드리스 브라우저로 버튼을 실제로 클릭해 보는 테스트를 돌리다 걸렸다. 렌더링 결과만 스크린샷으로 확인했다면 그대로 나갔을 버그다.
three.js 폴백 네 가지 — WebGL 미지원, CDN 차단, 모션 최소화
| 상황 | 대응 | 결과 |
|---|---|---|
| WebGL 컨텍스트 실패 | 같은 prof 배열을 2D 캔버스에 좌우 대칭 실루엣으로 그림 | 입체감은 없지만 빚는 조작은 그대로 작동 |
| 웹캠 거부 · 미지원 | 안내 후 커서 모드 복귀, 버튼 상태 해제 | 기능 손실 없음 |
| prefers-reduced-motion | 자동 회전 정지, 좌우 드래그로 수동 회전 | 어지럼 유발 요소 제거 |
| three.js CDN 차단 | 6초 안에 모듈이 실행되지 않으면 2D 경로로 자동 전환 | 빈 화면이 남지 않음 |
네 번째는 만들면서 알게 된 것이다. 처음에는 폴백 분기를 모듈 스크립트 안에 넣어 뒀는데, CDN 자체가 막힌 환경에서 시험해 보니 모듈이 아예 실행되지 않아 폴백까지 같이 죽었다. 화면에는 빈 사각형만 남았다. 그래서 2D 경로를 일반 스크립트로 끌어내고, 타이머로 감시하게 바꿨다.
// 모듈이 끝내 실행되지 않으면 2D 로 내려간다
setTimeout(function(){ window.startClay2D('lib'); }, 6000);
폴백은 폴백이 실행될 환경에서 시험해야 한다는 뻔한 교훈인데, 실제로 그 환경에 들어가 보기 전까지는 알 수 없었다. 세 번째 항목도 중요했다. 이 데모의 핵심 장치가 끊임없는 회전이기 때문에, 모션 최소화 설정을 켠 사람에게는 가장 불친절한 종류의 페이지가 된다. 자동 회전을 끄고 드래그로 직접 돌리게 하면 조작은 한 단계 늘지만, 회전 대칭이라는 원리는 그대로 체험된다. 폴백은 기능을 빼는 게 아니라 같은 원리를 다른 통로로 전달하는 일이라고 본다.
모바일에서 가로 스크롤이 생기지 않게 하는 법
3D 캔버스를 넣은 페이지가 모바일에서 옆으로 밀리는 원인은 거의 정해져 있다. 캔버스의 CSS 크기가 아니라 드로잉 버퍼 크기가 레이아웃에 새어 나가는 경우다. canvas{display:block;width:100%;height:100%}를 명시하고, 리사이즈 때 renderer.setSize(w, h, false)의 세 번째 인자를 false로 주면 three.js가 인라인 스타일을 건드리지 않는다. 여기에 html,body{max-width:100%;overflow-x:hidden}과 touch-action:none을 더하면 끝난다.
의외의 범인이 하나 더 있었다. 본문 안의 인라인 코드였다. canvas{display:block;width:100%;height:100%} 같은 긴 토큰은 줄바꿈이 안 돼서 390px 화면을 436px까지 밀어냈다. code{overflow-wrap:anywhere} 한 줄로 해결됐다. 긴 코드 블록은 반대로 pre{overflow-x:auto}로 자기 안에서만 스크롤되게 두는 편이 읽기 좋다.
픽셀 비율도 Math.min(devicePixelRatio, 2)로 잘랐다. 요즘 기기는 3배까지 올라가는데, 매 프레임 정점을 다시 쓰는 이 데모에서는 해상도보다 프레임이 훨씬 중요하다.
전체화면으로 직접 빚어보기
720px 상자 안에서는 아무래도 작다. 그래서 무대 오른쪽 위에 전체화면으로 빚기 버튼을 넣었다. 누르면 화면 전체로 커지고, Esc나 ‘닫기’로 돌아온다. 구현은 두 겹이다 — 먼저 requestFullscreen()을 시도하고, 거부되거나 지원되지 않으면(iOS Safari는 임의의 요소에 대한 전체화면을 오래 막아 뒀다) position:fixed; inset:0 오버레이로 같은 화면을 만든다.
function enterFs(){
const req = stage.requestFullscreen || stage.webkitRequestFullscreen;
if (req){
try{
const p = req.call(stage);
if (p && p.catch) p.catch(cssFs); // 거부되면 CSS 로
setTimeout(() => { if (!nativeOn()) cssFs(); }, 350);
}catch(e){ cssFs(); }
} else cssFs();
setTimeout(relayout, 60); // 렌더러에 새 크기를 알린다
}
여기서 한 가지 주의할 점 — CSS 오버레이로 커질 때는 window의 resize 이벤트가 발생하지 않는다. 캔버스만 커지고 렌더러는 옛날 크기 그대로라 화면이 뭉개진다. 그래서 ResizeObserver를 무대에 붙이고, 전환 직후에 렌더러의 resize()를 직접 한 번 더 호출한다.
남은 것 — 흙의 총량 보존
실제 물레라면 흙의 총량이 보존된다. 벽을 얇게 늘리면 그릇이 높아지고, 눌러 내리면 두꺼워진다. 지금 구현은 그 보존을 하지 않아서, 계속 누르면 흙이 사라진다. 단면적 적분을 유지하도록 높이를 재분배하면 훨씬 물성이 살 텐데, 그 순간 조작 난이도도 같이 올라간다. 다음 버전에서 시험해 볼 항목으로 남겨 뒀다. 표면에 남는 물레 자국(throwing line)도 지금은 텍스처로 흉내 내고 있을 뿐, 손이 지나간 경로를 실제로 기록하지는 않는다.
이런 실험들은 랩 목록에 하나씩 쌓고 있고, 만들면서 정리한 생각은 블로그에 따로 적는다. 이런 인터랙션을 실제 웹사이트에 얹는 작업을 맡기고 싶다면 홈에서 이야기할 수 있다.