개요
Three.js의 Geometry는 정점(Position), 법선(Normal), UV, 인덱스(Index) 등으로 구성되며, 모델의 형태를 정의하는 핵심 데이터입니다. 실무에서는 GPU에 최적화된 BufferGeometry와 Mesh 객체를 조합해 효율적인 렌더링과 조작을 수행합니다.
- Geometry
Three.js의 Geometry는 다음과 같은 요소로 이루어져 있습니다:
- Position: 각 정점의 3D 위치. (x, y, z) 좌표값
- Normal: 정점 또는 면의 방향 벡터. 조명 연산에 사용
- UV: 텍스처 맵핑을 위한 2D 좌표. (u, v) 범위는 보통 0~1의 UV좌표 존재
- Index: 중복 정점의 재사용을 위한 인덱스 배열
이러한 정보는 BufferGeometry를 통해 효율적으로 GPU에 전달됩니다.
- Geometry와 Mesh 분리
Three.js에서는 Geometry 자체에는 회전, 이동, 스케일 등의 트랜스폼 기능이 존재하나, 실무에서는 주로 Mesh 객체를 통해 모델을 조작합니다:
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial();
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// 회전이나 이동은 Mesh에 적용
mesh.position.set(1, 2, 3);
mesh.rotation.x = Math.PI / 4;
Three.js에서 Geometry는 말 그대로 정점(Vertex), 면(Face), UV, Normal 등의 구조 데이터만을 담고 있는 순수한 기하 객체입니다. 반면 Mesh는 Geometry + Material을 묶고, Scene graph(장면 계층 구조)에 속할 수 있도록 하는 객체입니다.
- 그러면 왜 Geometry에도 .translate(), .rotateX(), .scale() 같은 함수가 존재할까?
geometry.translate(...)처럼 Geometry 자체를 변형하는 메서드도 존재하지만, 실상 거의 사용하지 않는데, 그 이유는 다음과 같습니다:
- Geometry는 직접 변형하면 정점 데이터 자체가 바뀌기 때문에 재사용성 하락
- 같은 Geometry를 여러 Mesh에서 사용하려 할 때, 데이터 일관성이 깨짐
- Scene graph의 transfrom 계층과 분리되므로, 애니메이션 시스템과 연동이 안될 수 있음
하지만, 이러한 단점이 있는데도 불구하고 geometry.translate()와 같은 함수들은 고급 커스텀 처리 상황에서 사용됩니다. 그 이유는 다음과 같습니다:
- 커스텀 셰이더(Vertex Shader)에서 정점 위치를 조작하는데 필요한 초기 오프셋을 적용할 때
- 절두체 클리핑, VFX 파티클 메시 분할, 스킨 바인딩용 사전 오프셋 처리 등에 활용
- 로딩된 모델의 좌표계를 변환해야 할 때(Z-up => Y-up)
또는 아예 BufferGeometry의 position 속성을 직접 수정하여 Vertex level에서 변형을 구현하기도 합니다.
const pos = geometry.attributes.position;
for (let i = 0; i < pos.count; i++) {
pos.setY(i, pos.getY(i) + Math.sin(pos.getX(i)));
}
pos.needsUpdate = true;
이 방식은 정점 데이터를 CPU에서 직접 조작하므로, 셰이더 없이도 정적 변형이나 메시 분할(조각화)에 적합합니다. 또는, 정점 데이터 전체에 일괄적으로 행렬(Matrix) 변환을 적용할 수도 있습니다:
const matrix = new THREE.Matrix4().makeTranslation(1, 0, 0);
geometry.applyMatrix4(matrix);
applyMatrix4()는 내부적으로 모든 정점(Position)과 법선(Normal)에 행렬 연산을 수행하여 위치를 변경합니다. 실제 내부적으로 geometry.translate(), geometry.rotateX() 등의 함수는 결국 applyMatrix4()를 호출하여 동일한 방식으로 정점을 처리합니다.
이러한 방식은 정점 정보를 GPU에 전달하기 전에 한 번만 고정으로 조작할 수 있기 때문에, 초기 위치 조정, 좌표계 보정, 프리셰이프 처리 등에 효과적으로 사용됩니다.
- BoxGeometry의 세그먼트 분할
BoxGeometry는 가장 대표적인 기본형 모델입니다. 이때 사용하는 파라미터는 다음과 같습니다.
new THREE.BoxGeometry(width, height, depth, widthSegments, heightSegments, depthSegments);
예를 들어:
const geometry = new THREE.BoxGeometry(1, 1, 1, 2, 2, 2);
const material = new THREE.MeshBasicMaterial({ color: 0xff0000, wireframe: true });
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
이렇게 widthSegments, heightSegments, depthSegments 값을 늘리면, 각각의 면이 더 많은 정점으로 나뉘게 되고, 그 결과 하나의 면이 더 많은 삼각형으로 분할됩니다.
예를 들어, widthSegments = 2, heightSegments = 2라면 해당 면은 2×2 = 4개의 사각형으로 나뉘고, 각각의 사각형은 2개의 삼각형으로 구성되므로 총 8개의 삼각형이 생성됩니다.
이러한 고분할 구조는 특히 정점 기반 변형(디포메이션, Deformation)에 필수적입니다. 예를 들어 캐릭터의 얼굴 표정 변화, 옷의 주름 표현, 지형의 울퉁불퉁한 굴곡 같은 섬세한 움직임은 세부적으로 나눠진 정점이 있을 때만 가능합니다.
3D 툴인 Blender에서는 이 과정을 Subdivision(세분화)이라 부르며, 기본 메시의 정점을 더 촘촘하게 만들어 부드러운 형태나 변형에 대응할 수 있게 해줍니다.
Three.js에서도 동일한 개념으로 세그먼트를 활용할 수 있으며, 특히 PlaneGeometry나 BoxGeometry에서 세그먼트를 늘려 Terrain(지형) 제작이나 Cloth(천) 시뮬레이션 등에도 응용됩니다.
단, 세그먼트 수가 커질수록 정점 수가 기하급수적으로 증가하게 되고, 이는 곧 GPU의 연산량 증가 → 렌더링 성능 저하로 이어집니다. 때문에 실무에서는 세분화는 최소화, 필요한 부분만 국지적으로 디테일을 추가하는 방식(Subdivision + LOD or Quadtree)으로 최적화를 병행합니다.
- BufferGeometry: GPU 친화적 저수준 구조
BufferGeometry는 Three.js에서 고성능 렌더링을 위해 사용하는 저수준 데이터 구조입니다. 기존 Geometry가 추상적인 정점 구조를 제공했다면, BufferGeometry는 GPU의 버퍼 레이아웃에 직접 대응되는 데이터 구조를 갖고 있습니다.
const positionsArray = new Float32Array([
0, 0, 0, // Vertex 1
0, 1, 0, // Vertex 2
1, 0, 0, // Vertex 3
]);
const positionsAttribute = new THREE.BufferAttribute(positionsArray, 3);
const geometry = new THREE.BufferGeometry()
geometry.setAttribute("position", positionsAttribute);
여기서 BufferAttribute의 3은 x, y, z의 3차원 정점을 의미합니다. 2D 속성(UV 등)을 다룰 경우에는 2를 인자로 전달합니다.
참고
const uvArray = new Float32Array([
0, 0,
1, 0,
0, 1
]);
geometry.setAttribute('uv', new THREE.BufferAttribute(uvArray, 2));
참고로 UV(텍스처 좌표)는 2차원이기 때문에 new BufferAttribute(uvArray, 2)처럼 2를 인자로 넘깁니다.
- Float32Array는 왜 1차원 배열인가?
Three.js는 1차원 배열로 정점을 저장합니다. 그 이유는 다음과 같습니다:
- GPU 버퍼 구조와 직접 대응됨 => 데이터 전송이 빠르고 효율적
- 캐시 히트율이 높음 => CPU/GPU 모두 순차 접근에 최적화
- stride를 이용한 레이아웃 정의가 용이 => WebGL에서 필수적
- 왜 float만을 받을까?
Float32Array가 쓰이는 이유는 다음과 같습니다:
- GPU는 대부분의 정점 데이터를 float로 해석합니다.
- 정수나 boolean은 버텍스 셰이데에서 직접적으로 사용할 수 없가나 변환 오버헤드가 큽니다.
- WebGL은 모든 attribute 버퍼를 정해진 stride(크기)로 처리하므로, 타입이 혼합되면 정렬 비용이 커집니다.
이러한 이유로 Three.js에서도 BufferAttribute에는 오직 Float32Array, Uint16Array, Uint32Array 등 고정 타입 배열만을 허용하며, 일반 객체나 불리언 타입은 사용할 수 없습니다.
- BoxGeometry, BoxBufferGeometry 관계
BoxGeometry는 내부적으로 BoxBufferGeometry로 구현되어 있으며, 정점(Vertex)와 면(Face)을 분해해 버퍼에 직접 담습니다. 정점(vertex)만 있는 게 아니라, 정점의 인덱스(index), UV, Normal, Tangent 등도 함께 버퍼로 구성됩니다. 이는 OpenGL, DirectX, Vulkan 등의 저수준 그래픽 API의 버퍼 구조와 유사합니다.
- 랜덤 정점으로 다수의 삼각형 만들기
BufferGeometry는 정점을 직접 정의하여 원하는 형태를 자유롭게 구성할 수 있습니다. 다음 코드는 임의의 위치에 50개의 삼각형을 랜덤으로 생성하는 예시입니다:
const geometry = new THREE.BufferGeometry();
const count = 50; // 삼각형 개수
const positionsArray = new Float32Array(count * 3 * 3);
// count 개수만큼의 삼각형 × 3개의 정점 × 정점당 xyz (3개 좌표)
for (let i = 0; i < positionsArray.length; i++) {
positionsArray[i] = (Math.random() - 0.5) * 2;
// -1 ~ 1 사이의 랜덤값으로 좌표를 설정
}
const positionAttribute = new THREE.BufferAttribute(positionsArray, 3);
geometry.setAttribute("position", positionAttribute);
count * 3 * 3은 삼각형 수 x 정점 수(3개) x 좌표(x, y, z)를 의미합니다.
BufferAttribute(positionsArray, 3)은 배열을 3개씩 끊어서 하나의 정점으로 인식하게 합니다.
geometry.settAttribute('position', ...)을 통해 BufferGeometry에 정점 데이터를 등록합니다.
이상하게 나오는 것 같지만 정상 작동합니다. 변수들을 한번 씩 바꿔가면서 테스트 해보세요.
- 정점 재사용과 Index의 중요성
3D 모델은 대부분 삼각형(triangle)들의 집합으로 구성됩니다. 하지만 이 삼각형들 사이에는 정점이 겹치는 경우가 많습니다. 예를 들어, 사각형을 두 개의 삼각형으로 나눈다고 생각해 봅시다.
큐브 와이어 프레임을 다시 참고해보세요.
삼각형들 사이에 겹치는 부분이 상당히 많다는 것을 알 수 있습니다. 이제 사각형을 삼각형 두개로 만들 때, 단순하게 정점 6개를 선언해도 되지만 그건 낭비입니다. 대신 이미 정의한 정점을 재사용하면 훨씬 효율적입니다:
geometry.setIndex([
0, 1, 2, // 첫 번째 삼각형
0, 2, 3 // 두 번째 삼각형 (0, 2 재사용)
]);
// 큐브 예시가 아니라 2차원 사각형 예시
- Index란?
setIndex() 함수는 정점 배열에서 어떤 정점들을 조합해 삼각형을 구성할지를 지정합니다. 중복되는 정점을 새로 작성하지 않고 인덱스로 참조하므로, 메모리 절약 + 연산 감소라는 이점을 가져옵니다.
- 왜 중요한가?
정점이 아주 많은 캐릭터, 배경 지형, 오브젝트 등 수만~수십만 개의 정점이 존재하는 복잡한 Scene에서는 중복 정점 제거가 성능에 치명적인 영향을 미칩니다.
실제로 게임 엔진, DCC툴(Blender 등), 실시간 렌더링 환경에서는
항상 Index 기반 구조를 통해 중복 연산을 줄이는 것이 기본 최적화 방식입니다.
결론
Geometry는 정점 수준에서의 세밀한 제어와 최적화를 가능하게 하며, 특히 BufferGeometry를 통해 고성능 렌더링이 가능합니다. 세그먼트 분할, 인덱스 활용, 직접 버퍼 수정 등은 3D 표현력과 퍼포먼스를 모두 고려한 필수 기법입니다.
'Three.js > Intro' 카테고리의 다른 글
[threejs-journey 1-9] Debug UI (0) | 2025.06.22 |
---|---|
[threejs-journey 1-7] Resizing & FullScreen (0) | 2025.06.12 |
[threejs-journey 1-6] Control (0) | 2025.06.10 |
[threejs-journey 1-6] Camera (3) | 2025.06.10 |
[threejs-journey 1-5] Animation (0) | 2025.06.03 |