PointerLockControls는 기본적으로 시점 변경만 지원합니다.
실제 게임과 같은 이동 시스템을 구현하기 위해서는 추가적인 개발이 필요합니다.
class MovementSystem {
private moveForward: boolean = false;
private moveBackward: boolean = false;
private moveLeft: boolean = false;
private moveRight: boolean = false;
private canJump: boolean = false;
private velocity: THREE.Vector3;
private direction: THREE.Vector3;
constructor(
private camera: THREE.Camera,
private controls: PointerLockControls,
private domElement: HTMLElement
) {
this.velocity = new THREE.Vector3();
this.direction = new THREE.Vector3();
// 키보드 이벤트 리스너 설정
document.addEventListener('keydown', this.onKeyDown.bind(this));
document.addEventListener('keyup', this.onKeyUp.bind(this));
}
private onKeyDown(event: KeyboardEvent): void {
switch (event.code) {
case 'ArrowUp':
case 'KeyW':
this.moveForward = true;
break;
case 'ArrowDown':
case 'KeyS':
this.moveBackward = true;
break;
case 'ArrowLeft':
case 'KeyA':
this.moveLeft = true;
break;
case 'ArrowRight':
case 'KeyD':
this.moveRight = true;
break;
case 'Space':
if (this.canJump) {
this.velocity.y += 350;
this.canJump = false;
}
break;
}
}
private onKeyUp(event: KeyboardEvent): void {
switch (event.code) {
case 'ArrowUp':
case 'KeyW':
this.moveForward = false;
break;
case 'ArrowDown':
case 'KeyS':
this.moveBackward = false;
break;
case 'ArrowLeft':
case 'KeyA':
this.moveLeft = false;
break;
case 'ArrowRight':
case 'KeyD':
this.moveRight = false;
break;
}
}
public update(delta: number): void {
if (this.controls.isLocked) {
// 중력 적용
this.velocity.y -= 9.8 * 100.0 * delta;
this.direction.z = Number(this.moveForward) - Number(this.moveBackward);
this.direction.x = Number(this.moveRight) - Number(this.moveLeft);
this.direction.normalize();
// 이동 속도 설정
const speed = 400.0 * delta;
if (this.moveForward || this.moveBackward) {
this.velocity.z -= this.direction.z * speed;
}
if (this.moveLeft || this.moveRight) {
this.velocity.x -= this.direction.x * speed;
}
// 카메라 방향으로 이동
this.controls.moveRight(-this.velocity.x * delta);
this.controls.moveForward(-this.velocity.z * delta);
// Y축 이동 (점프)
this.camera.position.y += this.velocity.y * delta;
// 바닥 충돌 체크
if (this.camera.position.y < 10) {
this.velocity.y = 0;
this.camera.position.y = 10;
this.canJump = true;
}
}
}
}
실제 게임에서는 단순히 이동하는 것뿐만 아니라, 오브젝트들과의 충돌도 고려해야 합니다.
class CollisionSystem {
private raycaster: THREE.Raycaster;
private directions: THREE.Vector3[];
constructor(private scene: THREE.Scene) {
this.raycaster = new THREE.Raycaster();
// 충돌 체크할 방향들 설정
this.directions = [
new THREE.Vector3(1, 0, 0), // 오른쪽
new THREE.Vector3(-1, 0, 0), // 왼쪽
new THREE.Vector3(0, 0, 1), // 앞
new THREE.Vector3(0, 0, -1), // 뒤
];
}
public checkCollision(position: THREE.Vector3, radius: number = 2): boolean {
for (const direction of this.directions) {
this.raycaster.set(position, direction);
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
if (intersects.length > 0 && intersects[0].distance < radius) {
return true;
}
}
return false;
}
}
기존 코드에서 더 나아가, LOD(Level of Detail)를 적용하여 성능을 최적화할 수 있습니다.
class CityGenerator {
private static readonly BUILDING_TYPES = {
SKYSCRAPER: { minHeight: 50, maxHeight: 200, probability: 0.2 },
OFFICE: { minHeight: 20, maxHeight: 50, probability: 0.5 },
HOUSE: { minHeight: 5, maxHeight: 15, probability: 0.3 }
};
constructor(private scene: THREE.Scene) {}
public generateCity(): void {
const geometriesByDistance: Map<string, THREE.BufferGeometry[]> = new Map();
geometriesByDistance.set('near', []);
geometriesByDistance.set('medium', []);
geometriesByDistance.set('far', []);
// 1000개의 건물 생성
for (let i = 0; i < 1000; i++) {
const position = this.getRandomPosition();
const distance = position.length();
// 거리에 따른 LOD 결정
const detail = this.getDetailLevel(distance);
const building = this.generateBuilding(position, detail);
geometriesByDistance.get(detail)?.push(building);
}
// LOD별로 건물 병합 및 메쉬 생성
for (const [detail, geometries] of geometriesByDistance) {
if (geometries.length === 0) continue;
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
const material = this.getMaterialByDetail(detail);
const mesh = new THREE.Mesh(mergedGeometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
this.scene.add(mesh);
}
}
private getRandomPosition(): THREE.Vector3 {
const radius = Math.random() * 500;
const angle = Math.random() * Math.PI * 2;
return new THREE.Vector3(
Math.cos(angle) * radius,
0,
Math.sin(angle) * radius
);
}
private getDetailLevel(distance: number): string {
if (distance < 100) return 'near';
if (distance < 300) return 'medium';
return 'far';
}
private generateBuilding(position: THREE.Vector3, detail: string): THREE.BufferGeometry {
const type = this.getRandomBuildingType();
const height = Math.random() *
(type.maxHeight - type.minHeight) + type.minHeight;
let geometry: THREE.BufferGeometry;
// LOD에 따른 지오메트리 복잡도 조절
switch (detail) {
case 'near':
geometry = this.generateDetailedBuilding(height);
break;
case 'medium':
geometry = this.generateSimpleBuilding(height);
break;
case 'far':
geometry = this.generateBasicBuilding(height);
break;
default:
geometry = this.generateBasicBuilding(height);
}
geometry.translate(position.x, height / 2, position.z);
return geometry;
}
private getMaterialByDetail(detail: string): THREE.Material {
switch (detail) {
case 'near':
return new THREE.MeshStandardMaterial({
roughness: 0.12,
metalness: 0.9,
vertexColors: true
});
case 'medium':
return new THREE.MeshPhongMaterial({
color: 0x808080,
specular: 0x111111
});
case 'far':
return new THREE.MeshLambertMaterial({
color: 0x808080
});
}
return new THREE.MeshBasicMaterial();
}
// ... 기타 헬퍼 메서드들
}
성능 최적화를 위해 렌더링 상태를 모니터링하는 시스템을 구현할 수 있습니다.
class PerformanceMonitor {
private stats: {
fps: number;
renderCalls: number;
triangles: number;
geometries: number;
textures: number;
};
private domElement: HTMLElement;
constructor(
private renderer: THREE.WebGLRenderer,
private scene: THREE.Scene
) {
this.stats = {
fps: 0,
renderCalls: 0,
triangles: 0,
geometries: 0,
textures: 0
};
this.domElement = this.createStatsPanel();
document.body.appendChild(this.domElement);
// 60프레임마다 통계 업데이트
let frameCount = 0;
let lastTime = performance.now();
const updateStats = () => {
frameCount++;
const currentTime = performance.now();
if (currentTime >= lastTime + 1000) {
this.stats.fps = Math.round((frameCount * 1000) / (currentTime - lastTime));
this.stats.renderCalls = renderer.info.render.calls;
this.stats.triangles = renderer.info.render.triangles;
this.stats.geometries = renderer.info.memory.geometries;
this.stats.textures = renderer.info.memory.textures;
this.updateStatsDisplay();
frameCount = 0;
lastTime = currentTime;
}
requestAnimationFrame(updateStats);
};
updateStats();
}
private createStatsPanel(): HTMLElement {
const div = document.createElement('div');
div.style.position = 'fixed';
div.style.top = '0';
div.style.right = '0';
div.style.background = 'rgba(0, 0, 0, 0.8)';
div.style.color = 'white';
div.style.padding = '10px';
div.style.fontFamily = 'monospace';
div.style.fontSize = '12px';
div.style.zIndex = '1000';
return div;
}
private updateStatsDisplay(): void {
this.domElement.innerHTML = `
FPS: ${this.stats.fps}<br>
Render Calls: ${this.stats.renderCalls}<br>
Triangles: ${this.stats.triangles}<br>
Geometries: ${this.stats.geometries}<br>
Textures: ${this.stats.textures}
`;
}
public getRenderCalls(): number {
return this.stats.renderCalls;
}
}
위의 모든 시스템을 통합하여 사용하는 예시입니다.
class Game {
private scene: THREE.Scene;
private camera: THREE.PerspectiveCamera;
private renderer: THREE.WebGLRenderer;
private controls: PointerLockControls;
private movementSystem: MovementSystem;
private collisionSystem: CollisionSystem;
private cityGenerator: CityGenerator;
private performanceMonitor: PerformanceMonitor;
constructor() {
// 기본 three.js 설정
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(this.renderer.domElement);
// 컨트롤 시스템 초기화
this.controls = new PointerLockControls(
this.camera,
this.renderer.domElement
);
this.movementSystem = new MovementSystem(
this.camera,
this.controls,
this.renderer.domElement
);
this.collisionSystem = new CollisionSystem(this.scene);
// 도시 생성
this.cityGenerator = new CityGenerator(this.scene);
this.cityGenerator.generateCity();
// 성능 모니터링
this.performanceMonitor = new PerformanceMonitor(
this.renderer,
this.scene
);
// 이벤트 리스너 설정
window.addEventListener('resize', this.onWindowResize.bind(this));
// 애니메이션 루프 시작
this.animate();
}
private onWindowResize(): void {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
private animate(): void {
requestAnimationFrame(this.animate.bind(this));
const delta = clock.getDelta();
// 이동 시스템 업데이트
this.movementSystem.update(delta);
// 충돌 검사
const playerPosition = this.camera.position.clone();
if (this.collisionSystem.checkCollision(playerPosition)) {
// 충돌 시 처리 로직
console.log('Collision detected!');
}
this.renderer.render(this.scene, this.camera);
}
}
// 게임 초기화
const game = new Game();
시야 범위 밖의 오브젝트를 렌더링하지 않도록 최적화하는 기법입니다.
class FrustumCullingOptimizer {
private frustum: THREE.Frustum;
private frustumMatrix: THREE.Matrix4;
constructor() {
this.frustum = new THREE.Frustum();
this.frustumMatrix = new THREE.Matrix4();
}
public updateFrustum(camera: THREE.Camera): void {
this.frustumMatrix.multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
);
this.frustum.setFromProjectionMatrix(this.frustumMatrix);
}
public isInView(object: THREE.Object3D): boolean {
const bbox = new THREE.Box3().setFromObject(object);
return this.frustum.intersectsBox(bbox);
}
}
// 사용 예시
class OptimizedRenderer {
private frustumOptimizer: FrustumCullingOptimizer;
constructor(
private scene: THREE.Scene,
private camera: THREE.Camera,
private renderer: THREE.WebGLRenderer
) {
this.frustumOptimizer = new FrustumCullingOptimizer();
}
public render(): void {
this.frustumOptimizer.updateFrustum(this.camera);
// 시야 내 오브젝트만 visible 설정
this.scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.visible = this.frustumOptimizer.isInView(object);
}
});
this.renderer.render(this.scene, this.camera);
}
}
동일한 지오메트리를 여러 번 렌더링할 때 성능을 최적화하는 방법입니다.
class InstancedMeshGenerator {
public static createInstancedBuildings(
count: number,
baseGeometry: THREE.BufferGeometry,
material: THREE.Material
): THREE.InstancedMesh {
const mesh = new THREE.InstancedMesh(
baseGeometry,
material,
count
);
const matrix = new THREE.Matrix4();
const position = new THREE.Vector3();
const rotation = new THREE.Euler();
const scale = new THREE.Vector3();
for (let i = 0; i < count; i++) {
position.x = Math.random() * 1000 - 500;
position.z = Math.random() * 1000 - 500;
position.y = 0;
rotation.y = Math.random() * Math.PI;
const buildingHeight = Math.random() * 30 + 10;
scale.set(1, buildingHeight, 1);
matrix.compose(position, new THREE.Quaternion().setFromEuler(rotation), scale);
mesh.setMatrixAt(i, matrix);
}
return mesh;
}
}
// 사용 예시
const baseGeometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshPhongMaterial({ color: 0x808080 });
const instancedBuildings = InstancedMeshGenerator.createInstancedBuildings(1000, baseGeometry, material);
scene.add(instancedBuildings);
커스텀 셰이더를 사용하여 렌더링 성능을 최적화할 수 있습니다.
class BuildingShader {
public static vertexShader = `
attribute float height;
varying vec3 vNormal;
varying float vHeight;
void main() {
vNormal = normalize(normalMatrix * normal);
vHeight = height;
vec3 pos = position;
pos.y *= height;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`;
public static fragmentShader = `
varying vec3 vNormal;
varying float vHeight;
void main() {
float light = dot(vNormal, vec3(0.5, 0.5, 0.5));
vec3 color = mix(
vec3(0.2, 0.2, 0.2),
vec3(0.8, 0.8, 0.8),
vHeight / 50.0
);
gl_FragColor = vec4(color * light, 1.0);
}
`;
public static createMaterial(): THREE.ShaderMaterial {
return new THREE.ShaderMaterial({
vertexShader: this.vertexShader,
fragmentShader: this.fragmentShader,
uniforms: {},
vertexColors: true
});
}
}
renderer.info.render.calls는 Three.js에서 GPU에 보내는 드로우 콜의 수를 나타냅니다.
이는 성능에 직접적인 영향을 미치므로, 다음과 같은 전략으로 최적화할 수 있습니다.
class GeometryMerger {
public static mergeByMaterial(
objects: THREE.Mesh[]
): Map<THREE.Material, THREE.BufferGeometry> {
const geometriesByMaterial = new Map<THREE.Material, THREE.BufferGeometry[]>();
// 머티리얼별로 지오메트리 그룹화
objects.forEach(mesh => {
if (!geometriesByMaterial.has(mesh.material)) {
geometriesByMaterial.set(mesh.material, []);
}
geometriesByMaterial.get(mesh.material)?.push(mesh.geometry);
});
// 각 그룹별로 지오메트리 병합
const mergedGeometries = new Map<THREE.Material, THREE.BufferGeometry>();
geometriesByMaterial.forEach((geometries, material) => {
mergedGeometries.set(
material,
BufferGeometryUtils.mergeGeometries(geometries)
);
});
return mergedGeometries;
}
}
이러한 최적화 기법들을 적절히 조합하여 사용하면, 대규모 3D 씬에서도 60fps의 부드러운 렌더링을 유지할 수 있습니다.