3D 장면 위에 HTML 라벨을 얹어 본 적이 있다면 한 번쯤 겪는다. three.js 에서 인물 머리 위 점을 Vector3.project(camera) 로 화면 좌표로 바꿔 이름표를 붙였는데, 카메라가 빨리 움직이는 구간에서 이름표가 한 박자 늦게 따라오거나 아예 화면 밖으로 튀어 나간다(three.js HTML label lag). 또는 매 프레임 카메라 자리를 다시 계산하는 트래킹 카메라에 OrbitControls 를 붙였더니 휠 줌이 안 먹는다(트래킹 카메라 휠 줌 안됨, OrbitControls zoom not working). 둘 다 오류는 없다. 값이 틀렸을 뿐이다.
왜 라이브러리의 값은 읽는 순간에 따라 다른가
라이브러리는 내부 상태를 당신 코드의 순서가 아니라 자기 순서로 갱신하고 지운다. 그 값에 기대는 계산을 갱신 전에 두면, 문법상 맞는 코드가 지난 프레임의 값이나 이미 지워진 값을 읽는다.
- three.js 의
Vector3.project는 카메라의matrixWorldInverse를 쓴다. 그런데 이 행렬을 새로 고치는 것은 렌더러이고, 그것도renderer.render()안에서 한다. 렌더 전에 투영하면 지난 프레임의 카메라 자세로 계산한다 OrbitControls는 휠 이벤트 핸들러 안에서 바로update()를 부르고 내부 줌 배율을 1 로 되돌린다. 렌더 루프에서update()앞뒤를 비교해 사용자의 줌을 읽으려 하면 언제나 배율 1 이 나온다. 거기에 트래킹 카메라가 자리를 덮어쓰면 줌은 흔적도 없이 지워진다
평소엔 이 차이가 한 프레임이라 안 보인다. 카메라 자세가 프레임마다 크게 바뀌는 구간(물러나며 시선을 눕히는 카메라, 빠른 전환)에서만 화면 몇 배짜리 오차로 드러난다.

파생값은 어디서 읽어야 하나
그 값을 고치는 라이브러리 호출이 끝난 뒤에 읽는다. 순서는 이렇다.
- 상태를 바꾼다(카메라 이동, 대상 이동)
- 라이브러리가 상태를 굳힌다(
renderer.render(), 레이아웃 계산, 상태 저장소의 커밋) - 그 뒤에 파생값을 읽는다(화면 좌표 투영, 요소 크기, 선택값)
렌더 전에 꼭 투영해야 한다면 camera.updateMatrixWorld() 를 직접 불러 행렬을 먼저 굳힌다. 같은 원리가 3D 밖에도 있다. 차트 라이브러리의 툴팁 자리를 그리기 전에 읽거나, 폼 라이브러리가 제출 핸들러 안에서 이미 초기화한 「수정됨」 표시를 핸들러 뒤에 읽는 것도 같은 꼴이다.
사용자 입력은 어떻게 읽어야 하나
라이브러리가 입력을 받자마자 소화하고 지운다면, 입력을 라이브러리의 내부 값이 아니라 프레임 사이의 차로 읽는다.
- 지난 프레임 끝(스프링 암·바닥 처리까지 다 한 뒤)의 카메라 거리를 적어 둔다
- 이번 프레임 첫 줄에서 거리를 다시 잰다. 둘의 비가 그 사이 사용자가 민 줌이다
- 비교 기준은 그때 쓴 타깃과 거리로 고정한다. 타깃이 움직인 만큼이 줌으로 새지 않게
minDistance·maxDistance 같은 한계값을 아무리 바꿔도 이 문제는 안 고쳐진다. 값이 아니라 읽는 자리가 틀렸기 때문이다.
확인 목록
- 라이브러리 값(행렬·크기·선택값)을 읽는 줄마다: 그 값을 갱신하는 호출이 이 줄보다 앞에 있나?
- 렌더 전에 화면 좌표가 필요하면 camera.updateMatrixWorld() 를 먼저 부른다.
- 입력이 "가끔만 먹는다"면 라이브러리가 이벤트 핸들러 안에서 상태를 지우는지 소스를 연다.
- 사용자 입력은 내부 배율 대신 "지난 프레임 끝 ↔ 이번 프레임 첫 줄" 의 차로 읽는다.
- 검증은 카메라가 가장 빨리 움직이는 구간에서 한다 — 느린 구간에서는 한 프레임 지연이 안 보인다.
우리가 겪은 일
- 화면 밖으로 사라진 표식: 물러날수록 시선을 눕히는 카메라에서 상단 머리 위 점을 렌더 전에 투영했더니, 정규화 좌표 y 가 6.1 로 나왔다(화면 안은 −1~1). 표식이 화면 아래로 사라졌다. 투영을 같은 프레임의 렌더 뒤로 옮기자 맞았다.
- 안 먹는 휠 줌: 트래킹 카메라에서
update()앞뒤의 배율로 줌을 읽으려 했는데 늘 1 이었다.OrbitControls소스를 열어 보고서야 휠 핸들러 안에서 이미 소화하고 되돌린다는 것을 알았다. 지난 프레임 끝과 이번 프레임 첫 줄의 거리 차로 읽게 바꿔 고쳤다.
2026-10 기준, three.js r186. 내부 갱신 시점은 라이브러리 버전마다 바뀔 수 있다 — 기대는 값이 있으면 그 값을 갱신하는 소스 줄을 한 번 찾아 둔다.
이전 글에이전트의 도구는 사람의 터미널이 아니다 — 오래 도는 프로세스와 긴 입력은 도구의 한도 밖으로 빼고, 멈춤은 시간으로 끊는다