.normalize()를 왜 쓰는 걸까 — Three.js와 블렌더로 이해하는 벡터 정규화
벡터의 길이를 1로 만드는 정규화의 본질을 게임 이동 로직과 블렌더의 Apply Scale 사례를 통해 분석했습니다.
벡터의 길이를 1로 만드는 정규화의 본질을 게임 이동 로직과 블렌더의 Apply Scale 사례를 통해 분석했습니다.
입력한 비밀번호는 비밀 댓글 열람, 수정, 삭제에 사용됩니다.
Three.js를 공부하다 보면 벡터 뒤에 거의 공식처럼 붙어 있는 .normalize()를 자주 보게 됩니다. 강의에서는 "유용하다"는 말만 남기고 넘어갔는데, 오히려 그게 더 궁금해졌습니다.
왜 멀쩡한 벡터를 굳이 길이 1로 만들까요? 언제 꼭 필요하고, 언제는 굳이 안 해도 되는 걸까요?
생각해 보니 이건 처음 겪는 개념이 아니었습니다. 블렌더를 자주 쓰다 보면 스케일을 조정한 뒤에는 항상 Apply Scale을 해주라는 말을 듣게 됩니다. 이유는 늘 "나중에 깨질 수 있어서"였지만, 왜 깨지는지는 제대로 이해하지 못한 채 습관처럼 눌러왔습니다. Three.js의 정규화를 파고들다 보니, 블렌더에서 하던 그 행동이 사실은 같은 문제를 다른 방식으로 해결하고 있었다는 게 보이기 시작했습니다.
정규화(normalize)는 벡터의 입니다. "얼마나 멀리 가는지"는 버리고 "어느 방향을 가리키는지"만 남기는 것입니다. 그래서 정규화된 벡터는 주로 방향(direction), 시선(view direction), 노멀(normal), 이동 입력 벡터처럼 크기보다 방향이 중요한 정보를 표현할 때 쓰입니다.
수식으로는 벡터 v를 그 크기 |v|로 나눈 값입니다. (3, 4, 0) 같은 벡터는 길이가 5이므로 정규화하면 (0.6, 0.8, 0)이 됩니다. 길이는 1이 되고 방향은 그대로입니다.
체스, 포켓몬 구작, 턴제 전략 시뮬레이션 같은 격자 기반 게임에서는 정규화가 필요하지 않습니다. 이런 게임의 세계는 사실상 연속 공간이 아닙니다. 왼쪽 한 칸, 오른쪽 한 칸, 대각선 한 칸은 수학적으로는 각각 다른 거리지만 게임 규칙상으로는 모두 "한 번 이동"입니다. 대각선이 수학적으로 √2 ≈ 1.41이라도, 규칙이 "한 칸"이라고 정의하면 그게 기준입니다. 중요한 건 위치의 정확한 거리 값이 아니라 규칙이기 때문에 "대각선이 더 빠르지 않나?" 같은 문제가 애초에 발생하지 않습니다.
Three.js에서 다루는 공간은 완전히 다릅니다. 좌표는 (x, y, z), 값은 전부 실수(Float)이고, 이동은 "칸"이 아니라 거리 계산입니다. 여기서 정규화를 하지 않으면 문제가 바로 드러납니다.
W + D 키를 동시에 누른다고 가정해 봅니다. 입력 벡터는 (1, 1)이고 이 벡터의 길이는 √2 ≈ 1.41입니다. 즉, 같은 프레임 동안 직선 이동은 1만큼 이동하고 대각선 이동은 1.41만큼 이동합니다. 이건 단순한 체감 속도 문제가 아니라 실제 위치 값이 더 많이 바뀌는 것입니다.
이 차이는 생각보다 심각한 결과를 낳습니다. 얇은 벽이나 코너를 대각선으로 이동하면 한 프레임에 벽을 통과해버리는 충돌 판정 버그가 생기고, 같은 목적지인데 대각선으로 이동하는 캐릭터가 항상 먼저 도착하는 현상도 발생합니다. 속도 설정이 잘못된 게 아니라 거리 이득이 생기는 것입니다.
자유 이동 게임에서는 보통 이런 흐름을 따릅니다. 먼저 입력을 방향 의도로 해석합니다. (1, 1)은 "대각선으로 가고 싶다"는 의미입니다. 여기에 .normalize()를 적용하면 (0.707, 0.707)이 됩니다. 그리고 여기에 속도(speed)를 곱합니다.
결과적으로 오른쪽 이동 (1, 0), 위쪽 이동 (0, 1), 대각선 이동 (1, 1) 모두 정규화 후에는 길이가 1이 됩니다. 눈에는 대각선으로 이동하지만 위치 값 기준으로는 항상 같은 거리만 이동합니다.
여기서 블렌더가 다시 연결됩니다. 블렌더의 노멀 벡터(Normal Vector) 는 조명 계산에서 "면이 바라보는 방향"을 나타냅니다. 그리고 이 노멀은 항상 길이 1이라는 전제로 계산됩니다.
스케일을 늘리고 Apply Scale을 하지 않으면 내부 벡터 길이도 함께 늘어납니다. 노멀이 더 이상 "방향만 가진 값"이 아니게 되는 것입니다.
조명 계산은 내적(Dot Product)을 씁니다. 면 노멀과 빛 방향 벡터가 모두 길이 1일 때 내적 결과는 -1 ~ 1 사이 값이 나오고, 이것이 우리가 기대하는 밝기로 매핑됩니다. 그런데 면 노멀 길이가 100이고 빛 방향 길이가 50이라면 내적 결과가 5000이 나옵니다. 이 값은 화면이 하얗게 타버리거나, 색이 이상하게 튀거나, 셰이딩이 깨진 것처럼 보이게 만듭니다. Apply Scale은 결국 "이 벡터들을 다시 길이 1 기준으로 맞춰라"라는 의미였습니다.
.normalize()는 원본 벡터를 직접 바꿉니다.
위치나 힘의 크기를 유지해야 한다면 반드시 복사해서 써야 합니다.
const direction = v.처음에 이걸 모르고 position 벡터에 바로 .normalize()를 호출해서 오브젝트가 원점 근처로 날아가는 경험을 하는 경우가 많다고 합니다.
모든 곳에 붙일 필요는 없습니다. 하지만 "방향"이라는 단어가 떠오르는 순간에는 거의 무조건 사용하게 됩니다.
OrbitControls 같은 컨트롤러를 쓰면 내부에서 알아서 처리되기 때문에 처음엔 .normalize()를 쓸 일이 거의 없다고 느끼기 쉽습니다. 그런데 키보드 입력이나 클릭 좌표를 받아 직접 이동 로직을 만든다면 이야기가 달라집니다. mesh.position.x += 1 같은 단순한 좌표 이동은 "얼마만큼 이동"이 명확하기 때문에 정규화가 필요 없습니다. 하지만 특정 방향으로 속도를 주는 이동, 카메라 방향이나 입력 벡터 기반 이동에서는 반드시 써야 합니다.
여기서 정규화를 하지 않으면 speed 값이 거리마다 다르게 적용됩니다. 목표 지점이 가까울수록 느리게, 멀수록 빠르게 이동하는 이상한 결과가 나옵니다.
Three.js로 3D 웹 인터랙션을 만들다 보면 거의 반드시 Raycaster를 쓰게 됩니다. 마우스 위치에서 화면 안쪽으로 쏘는 보이지 않는 레이저가 바로 방향 벡터입니다. raycaster.setFromCamera(mouse, camera) 내부에서는 정규화된 방향 벡터가 들어올 것을 전제로 계산합니다. 정규화되지 않은 벡터를 직접 넣으면 클릭 판정이 엉뚱한 위치에서 발생하거나 레이가 필요 이상으로 길어져 성능에 영향을 줄 수 있습니다.
MeshStandardMaterial, MeshPhongMaterial 같은 내장 재질을 사용할 때는 Three.js가 내부에서 노멀과 방향 벡터를 전부 정규화해줍니다. 하지만 처럼 스케일을 강하게 왜곡하거나 을 직접 작성하는 순간부터는 직접 챙겨야 합니다.
셰이더에서는 숫자가 곧바로 화면 결과로 튑니다. 중간에 정규화를 빠뜨리면 빛이 과도하게 밝아지거나 색이 깨지거나 프레임마다 결과가 흔들립니다.
정규화는 이동에서는 공정한 거리 기준을, 조명에서는 밝기의 기준을 유지하게 해줍니다. 방향과 크기를 의도적으로 분리해, "이 값은 오직 방향만을 나타낸다"고 명시하는 것이기도 합니다.
결국 .normalize()는 값의 크기를 통제해서 계산의 기준을 일정하게 맞추는 역할을 합니다. 방향만 필요한 상황이라면 자연스럽게 따라붙는 처리라고 이해하면 될 것 같습니다.
const direction = inputVector.clone().normalize();
mesh.position.add(direction.multiplyScalar(speed));const v = new THREE.Vector3(3, 4, 0);
v.normalize();
console.log(v); // Vector3 { x: 0.6, y: 0.8, z: 0 }const direction = target.clone().sub(mesh.position).normalize();
mesh.position.add(direction.multiplyScalar(speed));mesh.scale.set(10, 1, 1)ShaderMaterialvec3 lightDir = normalize(lightPosition - vPosition);
float diffuse = dot(normal, lightDir);온보딩 시스템 개선부터 인프라 모니터링, 실시간 소켓 통신, 그리고 3D 캐릭터 최적화까지 7주간의 그룹 프로젝트 완주 기록
아이디어 선정부터 시니어 피드백을 거쳐 MVP 구현까지, 그룹 프로젝트의 기틀을 다지며 겪은 시행착오와 기술적 도전의 기록
벡터의 길이를 1로 만드는 정규화의 본질을 게임 이동 로직과 블렌더의 Apply Scale 사례를 통해 분석했습니다.
6주간의 그룹 스프린트를 돌아보며 경험한 설계의 시행착오, 협업의 기술, 그리고 시니어 피드백을 통한 성장을 정리한 기록
10주 동안 진행된 부스트캠프 멤버십 학습 스프린트를 돌아보며 기술 학습, 설계 고민, 번아웃, 그리고 AI 활용까지 정리한 기록
네이버 부스트캠프 웹·모바일 10기 챌린지 과정 - 데일리 미션, CS 공부, 피어 피드백, AI 활용 방안
함수형 프로그래밍과 객체 지향 프로그래밍의 장단점, 리액트에서 함수형을 선택한 이유에 대한 탐구에 대한 기록
네이버 부스트캠프 웹·모바일 10기 베이직 과정 지원 준비부터 수료, 문제 해결력 테스트 후기까지 정리한 기록
OSCCA 마스터 기간 동안 Githru 프로젝트에서 진행한 UI 개선, 이슈 해결, Pull Request 경험을 정리한 기록
Open Source Contribution Academy(OSCCA) 챌린지 기간 동안 Githru 프로젝트에 참여하며 이슈 제기와 첫 Pull Request를 경험한 기록