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

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

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개의 댓글