[Three.js] 성능 최적화 및 고급 제어 테크닉으로 3D 렌더링 효율 극대화하기

궁금하면 500원·2024년 12월 16일
0

Three.js 성능 최적화와 고급 제어 테크닉

1. PointerLockControls의 고급 활용

1.1 커스텀 이동 시스템 구현

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;
            }
        }
    }
}

1.2 충돌 감지 시스템 추가

실제 게임에서는 단순히 이동하는 것뿐만 아니라, 오브젝트들과의 충돌도 고려해야 합니다.

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;
    }
}

2. 고성능 렌더링 최적화

2.1 Geometry 병합의 고급 테크닉

기존 코드에서 더 나아가, 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();
    }
    
    // ... 기타 헬퍼 메서드들
}

2.2 렌더링 성능 모니터링

성능 최적화를 위해 렌더링 상태를 모니터링하는 시스템을 구현할 수 있습니다.

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;
    }
}

3. 실제 사용 예시

위의 모든 시스템을 통합하여 사용하는 예시입니다.

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();

4. 추가 성능 최적화 기법

4.1 Frustum Culling 최적화

시야 범위 밖의 오브젝트를 렌더링하지 않도록 최적화하는 기법입니다.

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);
    }
}

4.2 GPU Instancing 활용

동일한 지오메트리를 여러 번 렌더링할 때 성능을 최적화하는 방법입니다.

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);

4.3 셰이더 최적화

커스텀 셰이더를 사용하여 렌더링 성능을 최적화할 수 있습니다.

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
        });
    }
}

5. render.calls 최적화 전략

renderer.info.render.calls는 Three.js에서 GPU에 보내는 드로우 콜의 수를 나타냅니다.
이는 성능에 직접적인 영향을 미치므로, 다음과 같은 전략으로 최적화할 수 있습니다.

1. 지오메트리 병합

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의 부드러운 렌더링을 유지할 수 있습니다.

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글