2400rpm은 장식이 아니어야 했다
2026.08.31
18.44m가 0.4초에 사라진다. 글로 읽으면 그런가 싶은데, 그 자리에 앉아 보면 말이 안 되는 속도다.
그래서 포수 눈높이에 카메라를 놓고 KBO 투수가 던지는 공을 받아보게 만들었다. 판정 게임이 아니다. 스트라이크·볼을 맞히게 하지도, 반응 시간을 재지도 않는다. 보는 것이 전부다.
한 공마다 실시간으로 한 번, 0.15배속 리플레이로 한 번. 그리고 궤적이 자국으로 남아서, 공이 쌓이면 직구와 슬라이더가 어디서 갈라지는지가 한 장에 보인다.

넷이 릴리스에서는 한 점이다. 갈라지는 건 거의 다 마지막 몇 미터에서 일어난다. 타자가 공을 보고 판단할 시간이 없다는 말이 이 그림이다.
곡선을 손으로 깎지 않았다
제일 쉬운 길은 구종마다 예쁜 베지어를 하나씩 깎아두는 것이다. 슬라이더는 이렇게 휘고 커브는 저렇게 떨어지고.
그러면 화면에 띄우는 회전수와 회전축이 장식이 된다. 「2400rpm」이라 써 놓고 궤적은 그 숫자와 아무 상관 없는 곡선이라면, 이 페이지가 하겠다는 말 자체가 거짓이 된다.
그래서 중력·항력·마그누스 세 힘을 넣고 적분했다. 입력은 릴리스 좌표·초속·회전수·회전축 네 개뿐이고, 휘는 방향과 크기는 전부 결과로 따라 나온다. 회전축을 바꾸면 궤적이 바뀌고, 화면에서 도는 실밥 방향도 같이 바뀐다.
물리 라이브러리는 안 썼다. 필요한 게 상태 여섯 개짜리 상미분방정식 하나라서, 라이브러리를 얹으면 코드가 줄지도 않으면서 왜 되는지만 사라진다.
회전축을 시계로 적었다
마그누스 힘의 방향을 정하는 관습은 회전축 벡터를 적고 외적을 취하는 것이다. 그리고 그 길은 부호를 세 번 헷갈리게 만든다.
그래서 「공이 밀리는 방향」을 포수가 보는 시계 문자판으로 직접 받았다. 12시는 위, 6시는 아래, 3시는 포수 기준 오른쪽.
이게 데이터에 적힌 그대로이자 화면에서 눈에 보이는 그대로다. 우투수 포심은 11시, 슬라이더는 4시.
좌투수는 h → 12 - h로 좌우만 뒤집는다. 프리셋에 좌투를 둘 넣은 건 취향이 아니라 필요였다. 좌투의 슬라이더는 포수가 볼 때 반대로 휘니까, 그 반전이 눈에 보여야 회전축이 진짜로 물리에 들어갔다는 게 증명된다.
항 하나를 더 써서 테스트를 얻었다
적분은 속도만 쓰는 semi-implicit Euler로 시작했다. 그런데 중력만 있는 경우에도 도착 높이가 2mm 빗나갔다.
위치 갱신에 ½a·dt² 항을 넣었다. 그러면 등가속 구간에서 해석해와 정확히 같아진다.
공기를 끄면 포물선 공식과 일치한다
이 문장을 오차 허용치 없이 시험할 수 있게 된 것이 그 항을 넣은 이유다. 2mm가 문제여서가 아니라, 2mm가 있으면 「얼마까지 봐줄 것인가」를 정해야 하고 그 숫자에는 근거가 없다.
RK4는 필요 없었다. 회전 감쇠도 무시했다 — 0.4초 동안 rpm은 2%도 안 줄어든다.
처박혀야 어디로 겨눌지 안다
존 안으로 넣으려면 조준을 보정해야 한다. 빗나간 만큼 밀어 올리는 걸 여섯 번 돌린다.
여기서 걸린 게 있다. 낙차 큰 커브는 첫 시도에서 반드시 땅에 처박힌다. 그걸 실패로 보고 보정을 멈추면 커브는 영영 존에 안 들어온다.
바운스 지점이 「더 위로 겨눠라」는 신호다. 처박힌 것은 실패가 아니라 그 회차의 답이었다.
숫자가 이상한지 던져 보고 알았다
커브와 체인지업의 유효 회전 비율을 처음엔 0.80·0.85로 적었다. 실제로 던져 보니 커브 낙차가 무회전 대비 54cm까지 나왔다. 공개된 낙차 범위보다 한참 컸다.
내렸다. 지금은 커브가 −43cm, 체인지업이 +17cm로 들어온다.
프리셋 여섯 명 전원의 전 구종을 실제로 던져 보는 테스트를 뒀다. 값 하나를 찍어보는 테스트로는 이런 게 안 걸린다.
없는 데이터를 있는 척하지 않는다
이 페이지에서 제일 조심한 곳이다.
구속과 구종 배합은 공개된 기록이다. 회전수와 회전축은 추정치다.
KBO에는 MLB Statcast 같은 공개 트래킹 데이터가 없다. 구단 안에는 있어도 밖으로 안 나온다. 그래서 회전수·회전축·유효 비율은 구종별 통상값에서 가져와 선수별로 조금씩 조정한 값이고, 화면 구석에 그렇게 적어 뒀다.
숨기고 숫자만 띄우면 없는 데이터를 있는 척하는 게 된다.
구장 치수도 같은 원칙으로 적었다.
어느 쪽이 실측이고 어느 쪽이 어림인지를 field.ts에 적어 뒀다. 나중에 실측을 찾으면 어디를 고쳐야 하는지 알 수 있어야 한다.
어두운 게 아니라 비어 있었다

처음에는 땅 평면과 흙 원 두 개, 잔디 줄무늬가 전부였다. 야간 경기 하나로 두고 **「어두우면 공이 도드라진다」**고 적어 뒀다.
실제로 띄워 보니 어두운 게 아니라 빈 화면이었다.
그래서 포수 시점에서 눈에 먼저 들어오는 것들을 채웠다. 파울라인, 1·2·3루와 흙 베이스패스, 마운드, 담장, 야수 일곱.
마운드는 원기둥이 아니라 포물면이다. 실제 마운드는 가장자리에서 그라운드와 매끄럽게 만나는데, 원기둥으로 세우면 흙을 깐 단상이 된다. y = H(1 − r²/R²)를 광선식에 넣으면 t에 대한 이차식이라 따로 수치해를 구할 것도 없다.
담장도 처음엔 반경 하나짜리 원기둥이었다. 그러면 담장 윗변이 화면을 가로지르는 완전한 수평선이 되어 그냥 「벽」으로 보인다. 잠실 치수로 바꾸니 폴대 쪽이 중앙보다 25m 가까워서 양 끝이 올라오고, 그제야 구장이 나를 감싼 것처럼 보였다.
대신 반지름이 방위각에 따라 달라지면 광선 교차를 닫힌 형태로 못 푼다. 반지름을 가정해 원기둥으로 풀고, 맞은 자리의 방위각으로 고쳐 다시 푸는 것을 두 번 돌린다. 눈이 거의 원점에 있어서 한 번이면 수렴한다.
실제로 있는 노란 줄을 지웠다
외야 담장 윗변에는 노란 홈런 라인이 있다. 실제로 있으니까 그렸다.
그런데 100m 밖에서 화면을 통째로 가로지르는 노란 띠 하나가 시야에서 제일 밝은 것이 되어, 정작 봐야 할 공에서 눈을 빼앗았다.
지웠다.
파울폴은 남기되 반각 0.005rad — 100m에서 1m 두께 — 로 서 있던 것을 실제 굵기인 30cm로 줄이고 채도도 눌렀다.
실제에 있다고 다 그리면 안 된다. 화면은 실제를 베끼는 자리가 아니라 무엇을 보여줄지 고르는 자리다.
같은 이유로 안 보여주기로 한 것이 둘 더 있다.
- 통과 지점 원판은 공이 날아오는 동안에는 안 띄운다. 도착점을 미리 알려 주면 예고편이 된다.
- 자막은 리플레이에서만 띄우고, 화면 아래 한가운데는 비워 둔다 — 거기가 미트다.
어깨선을 긋고 나서야 사람이 됐다
선수를 실루엣 한 덩어리로 두면 사람이긴 해도 야구선수로는 안 읽힌다. 반대로 등번호나 핀스트라이프는 40m 밖에서 한 픽셀도 안 되니 넣어 봐야 얼룩이다.
그 사이에서 「야구선수」가 성립한 최소 조합이 여섯 색이었다. 흰 상의, 살짝 회색 바지, 남색 모자·스타킹·벨트, 맨살 팔뚝, 갈색 글러브, 검은 스파이크.
몸통을 캡슐 하나로 두니 어깨가 골반과 같은 폭이라 팔이 몸에 파묻혀 눈사람이 됐다. 어깨선을 따로 긋고 나서야 사람이 됐다.
투구 동작은 자세 다섯 벌로 만들었다 — 셋포지션·레그킥·스트라이드·릴리스·팔로스루. 릴리스 전에는 손에 공이 들려 있고, 릴리스 뒤에도 얼어붙지 않도록 팔로스루를 따로 뒀다.
그리고 여기서 하나 틀렸다. 사람을 그리는 판의 좌우 벡터가 오른손 규칙 때문에 화면과 뒤집혀 있었다. 코드를 봐서는 안 보였고, 우완 투수의 오른팔이 화면 오른쪽에 나오는 걸 보고 알았다.
공은 폴리곤이 아니다
공에는 지오메트리가 없다. 화면을 덮는 사각형 하나에 프래그먼트 셰이더로 레이-스피어 교차를 푼다. 어느 거리에서도 정확한 원이라, 마지막 순간에 각이 지지 않는다.
실밥은 시작할 때 지도를 한 장 만들어 둔다. 픽셀마다 실밥 곡선까지의 거리를 풀면, 공이 화면을 채우는 마지막 순간 — 이 페이지에서 제일 중요한 프레임 — 에 픽셀 하나당 수백 번을 돌게 된다. 거기서 끊기면 만든 의미가 없다.
궤적선에는 gl.LINES를 안 썼다. WebGL의 선 굵기는 대부분의 브라우저에서 1픽셀로 고정이라 궤적이 실오라기처럼 보인다. 사각형을 직접 이어 붙이면 굵기도 정하고 끝으로 갈수록 옅어지게도 만들 수 있다.
한 가지 더. 공은 기대만큼 커지지 않는다. 야구공은 1.2m 앞에서도 시야의 3.5° 정도다. 화면을 채우게 하려면 화각을 좁혀야 하는데 그러면 스트라이크존이 안 들어온다.
대신 릴리스에서 미트까지 지름이 열 배 넘게 늘고, 그 대부분이 마지막 0.1초에 몰린다. 밀려드는 느낌은 크기가 아니라 그 가속에서 온다.
2° 때문에 화각을 넓혔다
두 번 물렀다.
눈 위치. 처음에 z = -0.7로 잡았더니 존 아래 모서리가 시선에서 27.6° 아래로 내려가 반각을 넘었다. 스트라이크존 바닥이 잘려 나갔다. 홈플레이트는 앞 꼭짓점부터 43cm 깊이고 포수 머리는 거기서 다시 한 뼘 뒤라, z = -1.2가 맞았다.
화각. 55°로 시작했는데 16:9에서 가로 반각이 42.8°다. 45°에 있는 1루와 3루가 딱 2° 차이로 프레임 밖에 남았다 — 베이스도 베이스패스도 영영 안 보인다는 뜻이다.
60°로 넓히니 가로 반각이 45.8°가 되어 다이아몬드가 겨우 들어왔다. 대가는 공이 13% 작아지는 것이고, 그 교환은 받아들였다. 그리고 그 교환을 camera.test.ts가 못 박아 뒀다. 나중에 누가 화각을 되돌리면 테스트가 먼저 운다.
재미있는 건 아이맥스 1열 편에서는 정반대로 했다는 것이다. 거기서는 화각을 100°에서 66°로 좁혀서 스크린이 프레임을 넘치게 만들었다. 같은 질문에 반대로 답한 건 주인공이 달라서다. 거기서는 「다 안 보이는 것」이 요점이었고, 여기서는 구장이 보여야 거리가 산다.
남은 것
돌아보면 이 페이지에서 내린 결정은 거의 다 적어 놓은 것과 보이는 것을 맞추는 일이었다.
- 회전수를 적었으면 궤적이 그 숫자를 따라야 한다
- 추정치는 추정치라고 화면에 적는다
- 실측과 어림은 코드에 구분해 둔다
그리고 딱 한 번 반대로 갔다. 실제로 있는 노란 줄을 지웠다.
두 가지가 모순처럼 보이는데 아니다. 앞의 셋은 주장을 사실에 맞추는 일이고, 마지막 하나는 화면을 목적에 맞추는 일이다. 섞으면 둘 다 망가진다. 없는 데이터를 그럴듯하게 그리는 것도, 봐야 할 것을 가리면서 실제라고 우기는 것도.
18.44m는 여전히 0.4초 만에 사라진다. 포수 시점으로 공 받기