굉장히 다양한 기하들을 볼 수 있다. 박스 모양, 공 모양 등..
우선 대표적인 Box Geometry 를 보자.
const boxGeometry = new THREE.BoxGeometry()
const material = new THREE.MeshNormalMaterial({
wireframe: true,
})
const cube = new THREE.Mesh(boxGeometry, material)
scene.add(cube)
위와 같은 코드를 작성한 후 로그를 찍어보면 parameters 에 BoxGeometry 의 생성자 속성값들을 설정할 수 있다.
또한 attributes 의 position 에 들어가보면 굉장히 많은 값들을 볼 수 있는데 이는 xyz 씩 하여 BoxGeometry의 각 점의 위치를 나타내고 있다.
그리고 여기서부터 중요한거
const cubeFolder = gui.addFolder('Cube')
const cubeScaleFolder = cubeFolder.addFolder('Scale')
cubeScaleFolder.add(cube.scale, 'x', -5, 5, 0.1).onFinishChange(function() { console.log(cube.geometry) })
cubeScaleFolder.add(cube.scale, 'y', -5, 5, 0.1)
cubeScaleFolder.add(cube.scale, 'z', -5, 5, 0.1)
GUI에 Scale 을 변환한 후에 log 를 찍도록 코드를 수정한 후 로그를 보면 놀랍게도 모든 position 값들이 그대로 존재한다. 왜일까?
cube.scale.x를 변경하면, Mesh의 변환 매트릭스(matrix)가 업데이트된다.
Three.js는 Mesh의 스케일을 변경하더라도, 기본 지오메트리의 정점 데이터(attributes.position)에는 영향을 미치지 않는다.
반면 스케일 변환은 렌더링 시점에 적용된다. 즉, BoxGeometry의 정점 데이터는 그대로 유지되고, 변환 매트릭스를 통해 스케일링 효과를 계산해 화면에 출력한다.
개념 | 기본 지오메트리의 정점 데이터 | Object3D의 matrix 속성 |
---|---|---|
주요 역할 | 3D 모델의 형태를 정의하는 정점 위치 및 속성. | 객체의 위치, 회전, 스케일을 표현하는 변환 행렬. |
구성 요소 | 정점의 위치, 법선, 텍스처 좌표 등. | 4x4 행렬의 위치, 회전, 스케일 구성 요소. |
변환 적용 | 정점 데이터는 지역 좌표계에서 정의되며, 변환을 적용하기 위해 matrix와 곱셈이 필요함. | 정점 데이터를 지역 좌표계에서 월드 좌표계로 변환하는 데 사용됨. |
자동 업데이트 | 정점 데이터는 수동으로 수정해야 함. | 객체의 위치, 회전, 스케일 변경 시 자동으로 업데이트됨. |
계산 | 정점 간의 관계 및 형상을 정의하는 데 사용됨. | 객체의 변환을 계산하고 자식 객체에 영향을 미침. |
geometry.attributes.position은 정점 데이터를 저장하는 버퍼로, 초기화 시 한 번 생성되고 이후에는 직접 수정되지 않는다.
Mesh의 스케일을 변경해도 Three.js는 기본 지오메트리 데이터(BoxGeometry)를 업데이트하지 않는다.
대신 변환 매트릭스(Matrix4)를 통해 변형된 결과를 렌더링할 뿐이다.
따라서, 로그를 출력할 때 geometry.attributes.position의 값은 변경되지 않고 초기 상태를 유지하는 것이다.
스케일링이 적용된 정점 데이터를 확인하려면, 메시의 변환 매트릭스를 정점 데이터에 직접 적용해야 한다.
cubeScaleFolder.add(cube.scale, 'x', -5, 5, 0.1).onFinishChange(() => {
// 원본 geometry를 복사하여 변환된 값을 계산
const transformedPositions = cube.geometry.attributes.position.array.slice();
const positionCount = cube.geometry.attributes.position.count;
const matrix = cube.matrixWorld; // 메시의 월드 변환 매트릭스
const vector = new THREE.Vector3();
for (let i = 0; i < positionCount; i++) {
vector.set(
transformedPositions[i * 3],
transformedPositions[i * 3 + 1],
transformedPositions[i * 3 + 2]
);
vector.applyMatrix4(matrix); // 변환 매트릭스를 정점에 적용
console.log(`Transformed Vertex ${i}:`, vector.toArray());
}
});
그럼 왜 기본 데이터를 그대로 유지할까?
Three.js는 성능 최적화를 위해 원본 데이터를 변경하지 않는다.
왜냐하면 변경할 경우, GPU 메모리와 CPU 사이의 데이터를 다시 동기화해야 하며, 이는 성능 저하를 유발하기 때문이다.
따라서 Sclae, Rocation, Position 등은 모두 매트릭스 연산을 사용하여 효율적으로 처리되고, 정점 데이터(attributes.position)에는 영향을 미치지 않는다.
따라서 위 Geometries 중 3가지를 골라서 작업하면 전체 코드는 아래와 같다.
import './style.css'
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import Stats from 'three/addons/libs/stats.module.js'
import { GUI } from 'dat.gui'
const scene = new THREE.Scene()
scene.add(new THREE.AxesHelper(5))
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
camera.position.set(-2, 4, 5)
const renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
new OrbitControls(camera, renderer.domElement)
const boxGeometry = new THREE.BoxGeometry()
const sphereGeometry = new THREE.SphereGeometry()
const icosahedronGeometry = new THREE.IcosahedronGeometry()
const material = new THREE.MeshNormalMaterial({
wireframe: true,
})
const cube = new THREE.Mesh(boxGeometry, material)
cube.position.x = -4
scene.add(cube)
const sphere = new THREE.Mesh(sphereGeometry, material)
sphere.position.x = 0
scene.add(sphere)
const icosahedron = new THREE.Mesh(icosahedronGeometry, material)
scene.add(icosahedron)
sphere.position.x = 4
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
const stats = new Stats()
document.body.appendChild(stats.dom)
const gui = new GUI()
const cubeData = {
width: 1,
height: 1,
depth: 1,
widthSegments: 1,
heightSegments: 1,
depthSegments: 1
}
const cubeFolder = gui.addFolder('Cube')
const cubeRotationFolder = cubeFolder.addFolder('Rotation')
cubeRotationFolder.add(cube.rotation, 'x', 0, Math.PI * 2, 0.01)
cubeRotationFolder.add(cube.rotation, 'y', 0, Math.PI * 2, 0.01)
cubeRotationFolder.add(cube.rotation, 'z', 0, Math.PI * 2, 0.01)
const cubePositionFolder = cubeFolder.addFolder('Position')
cubePositionFolder.add(cube.position, 'x', -10, 10)
cubePositionFolder.add(cube.position, 'y', -10, 10)
cubePositionFolder.add(cube.position, 'z', -10, 10)
const cubeScaleFolder = cubeFolder.addFolder('Scale')
cubeScaleFolder.add(cube.scale, 'x', -5, 5, 0.1).onFinishChange(function() { console.log(cube.geometry) })
cubeScaleFolder.add(cube.scale, 'y', -5, 5, 0.1)
cubeScaleFolder.add(cube.scale, 'z', -5, 5, 0.1)
cubeFolder
.add(cubeData, 'width', 1, 30)
.onChange(regenerateBoxGeometry)
.onFinishChange(function() { console.log(cube.geometry) })
cubeFolder.add(cubeData, 'height', 1, 30).onChange(regenerateBoxGeometry)
cubeFolder.add(cubeData, 'depth', 1, 30).onChange(regenerateBoxGeometry)
cubeFolder.add(cubeData, 'widthSegments', 1, 30).onChange(regenerateBoxGeometry)
cubeFolder.add(cubeData, 'heightSegments', 1, 30).onChange(regenerateBoxGeometry)
cubeFolder.add(cubeData, 'depthSegments', 1, 30).onChange(regenerateBoxGeometry)
cubeFolder.open()
function regenerateBoxGeometry() {
const newGeometry = new THREE.BoxGeometry(
cubeData.width,
cubeData.height,
cubeData.depth,
cubeData.widthSegments,
cubeData.heightSegments,
cubeData.depthSegments
)
cube.geometry.dispose()
cube.geometry = newGeometry
}
const sphereData = {
radius: 1,
widthSegments: 8,
heightSegments: 6,
phiStart: 0,
phiLength: Math.PI * 2,
thetaStart: 0,
thetaLength: Math.PI
}
const sphereFolder = gui.addFolder('Sphere')
sphereFolder.add(sphereData, 'radius', 0.1, 30).onChange(regenerateSphereGeometry)
sphereFolder.add(sphereData, 'widthSegments', 1, 32).onChange(regenerateSphereGeometry)
sphereFolder.add(sphereData, 'heightSegments', 1, 16).onChange(regenerateSphereGeometry)
sphereFolder.add(sphereData, 'phiStart', 0, Math.PI * 2).onChange(regenerateSphereGeometry)
sphereFolder.add(sphereData, 'phiLength', 0, Math.PI * 2).onChange(regenerateSphereGeometry)
sphereFolder.add(sphereData, 'thetaStart', 0, Math.PI).onChange(regenerateSphereGeometry)
sphereFolder.add(sphereData, 'thetaLength', 0, Math.PI).onChange(regenerateSphereGeometry)
sphereFolder.open()
function regenerateSphereGeometry() {
const newGeometry = new THREE.SphereGeometry(
sphereData.radius,
sphereData.widthSegments,
sphereData.heightSegments,
sphereData.phiStart,
sphereData.phiLength,
sphereData.thetaStart,
sphereData.thetaLength
)
sphere.geometry.dispose()
sphere.geometry = newGeometry
}
const icosahedronData = {
radius: 1,
detail: 0
}
const icosahedronFolder = gui.addFolder('Icosahedron')
icosahedronFolder.add(icosahedronData, 'radius', 0.1, 10).onChange(regenerateIcosahedronGeometry)
icosahedronFolder.add(icosahedronData, 'detail', 0, 5).step(1).onChange(regenerateIcosahedronGeometry)
icosahedronFolder.open()
function regenerateIcosahedronGeometry() {
const newGeometry = new THREE.IcosahedronGeometry(icosahedronData.radius, icosahedronData.detail)
icosahedron.geometry.dispose()
icosahedron.geometry = newGeometry
}
const debug = document.getElementById('debug') as HTMLDivElement
function animate() {
requestAnimationFrame(animate)
renderer.render(scene, camera)
debug.innerText = 'Matrix\n' + cube.matrix.elements.toString().replace(/,/g, '\n')
stats.update()
}
animate()
Geometry 는 deprecate 되었고 그 자리를 Buffered Geometry 가 대신한다.
이 버퍼 지오메트리는 데이터를 typed arrays 로 저장하기 때문에 Mesh 를 보다 효율적으로 표현할 수 있다.
하지만 코드는 굉장히 복잡한 거 같다. 일단 기존 Geometry 와 버퍼지오메트리의 차이점을 먼저 살펴보자.
특징 | THREE.Geometry | THREE.BufferGeometry |
---|---|---|
데이터 구조 | 고수준 구조: 정점 배열(vertices)과 면 배열(faces)로 구성됨. | 저수준 구조: Float32Array로 직접 버퍼에 저장. |
성능 | 느림: JavaScript 레벨에서 정점을 처리하며 GPU로 보내기 전에 변환 필요. | 빠름: 데이터가 GPU 친화적인 형식으로 바로 저장됨. |
유연성 | 직관적이고 사용하기 쉬움. | 초기 생성은 복잡하지만 성능 및 확장성이 뛰어남. |
메모리 사용 | 비교적 비효율적: 메모리를 더 많이 소모. | 효율적: 메모리 사용량이 적음. |
유지보수 상태 | 더 이상 적극적으로 유지보수되지 않음. | Three.js의 기본 표준으로 권장됨. |
사용 예시 | 프로토타이핑에 적합. | 게임, 대규모 애플리케이션, 성능 중요한 작업에 적합. |
그럼 이제 코드로 보면, 예를 들어 어떤 지오메트리를 전체적으로 2만큼 욺직이겠다고 한다면 다음과 같이 코드를 작성해야한다.
const vertices = (geometry as THREE.Geometry).vertices
for (let i = 0; i < vertices.length; i++) {
vertices[i].multiplyScalar(2)
}
;(geometry as THREE.Geometry).verticesNeedUpdate = true
const positions = (geometry.attributes.position as THREE.BufferAttribute).array as Array<number>
for (let i = 0; i < positions.length; i += 3) {
const v = new THREE.Vector3(positions[i], positions[i + 1], positions[i + 2]).multiplyScalar(2)
positions[i] = v.x
positions[i + 1] = v.y
positions[i + 2] = v.z
}
;(geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true
const material = new THREE.MeshNormalMaterial()
let geometry = new THREE.Geometry()
geometry.vertices.push(
new THREE.Vector3(1, 1, 1), // a
new THREE.Vector3(-1, -1, 1), // b
new THREE.Vector3(-1, 1, -1), // c
new THREE.Vector3(1, -1, -1) // d
)
geometry.faces.push(
new THREE.Face3(2, 1, 0),
new THREE.Face3(0, 3, 2),
new THREE.Face3(1, 3, 0),
new THREE.Face3(2, 3, 1)
)
geometry.computeFlatVertexNormals()
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
const material = new THREE.MeshNormalMaterial()
let geometry = new THREE.BufferGeometry()
const points = [
new THREE.Vector3(-1, 1, -1), // c
new THREE.Vector3(-1, -1, 1), // b
new THREE.Vector3(1, 1, 1), // a
new THREE.Vector3(1, 1, 1), // a
new THREE.Vector3(1, -1, -1), // d
new THREE.Vector3(-1, 1, -1), // c
new THREE.Vector3(-1, -1, 1), // b
new THREE.Vector3(1, -1, -1), // d
new THREE.Vector3(1, 1, 1), // a
new THREE.Vector3(-1, 1, -1), // c
new THREE.Vector3(1, -1, -1), // d
new THREE.Vector3(-1, -1, 1), // b
]
geometry.setFromPoints(points)
geometry.computeVertexNormals()
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
코드는 훨씬 복잡해 졌지만 WebGL 이 사용하는 GPU 친화적, 더 빠르고 메모리 효율적으로 작성되고 복잡하고 대규모 기하학 처리에 적합하기 때문에 우리는 이제 BufferGeometry
를 사용하면 된다.