[Three.js] Shadow Map, Camera 설정부터 최적화 그림자 효과

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

1. 동적 그림자 캐싱 시스템

대규모 3D 씬에서 그림자 계산은 성능에 큰 영향을 미칩니다.
동적 그림자 캐싱을 통해 이를 최적화해보겠습니다.

class ShadowCacheSystem {
    constructor(scene) {
        this.scene = scene;
        this.shadowCache = new Map();
        this.staticObjects = new Set();
        this.dynamicObjects = new Set();
        this.lastUpdateTime = {};
        
        // 그림자 맵 해상도 설정
        this.shadowMapSize = this.calculateOptimalShadowMapSize();
    }

    calculateOptimalShadowMapSize() {
        // 디바이스 성능에 따른 최적 그림자 맵 크기 계산
        const gpu = renderer.capabilities;
        const maxSize = gpu.maxTextureSize;
        const memory = navigator.deviceMemory || 4; // 기본값 4GB

        if (memory >= 8 && maxSize >= 4096) return 2048;
        if (memory >= 4 && maxSize >= 2048) return 1024;
        return 512;
    }

    addStaticObject(object) {
        object.castShadow = true;
        object.receiveShadow = true;
        this.staticObjects.add(object);
        
        // 정적 오브젝트의 그림자 맵 미리 계산
        this.precalculateShadows(object);
    }

    precalculateShadows(object) {
        const shadowTexture = new THREE.WebGLRenderTarget(
            this.shadowMapSize,
            this.shadowMapSize,
            {
                format: THREE.RGBAFormat,
                type: THREE.FloatType
            }
        );

        // 그림자 맵 생성
        const shadowCamera = new THREE.OrthographicCamera(-10, 10, 10, -10, 0.5, 500);
        this.scene.lights.forEach(light => {
            this.renderShadowMap(object, light, shadowCamera, shadowTexture);
        });

        this.shadowCache.set(object.uuid, shadowTexture);
    }

    renderShadowMap(object, light, camera, target) {
        const currentXform = object.matrixWorld.clone();
        const lightDirection = light.position.clone().sub(object.position).normalize();
        
        camera.position.copy(object.position).add(lightDirection.multiplyScalar(20));
        camera.lookAt(object.position);
        
        renderer.setRenderTarget(target);
        renderer.render(this.scene, camera);
        renderer.setRenderTarget(null);
        
        object.matrixWorld.copy(currentXform);
    }

    update(deltaTime) {
        // 동적 오브젝트 그림자 업데이트
        this.dynamicObjects.forEach(object => {
            if (this.shouldUpdateShadow(object, deltaTime)) {
                this.updateDynamicShadow(object);
            }
        });
    }

    shouldUpdateShadow(object, deltaTime) {
        const lastUpdate = this.lastUpdateTime[object.uuid] || 0;
        const timeSinceUpdate = deltaTime - lastUpdate;
        
        // 이동 거리에 따른 업데이트 필요성 확인
        const movement = object.position.distanceTo(object.userData.lastPosition || object.position);
        const shouldUpdate = movement > 0.1 || timeSinceUpdate > 1000;
        
        if (shouldUpdate) {
            object.userData.lastPosition = object.position.clone();
            this.lastUpdateTime[object.uuid] = deltaTime;
        }
        
        return shouldUpdate;
    }
}

2. 그림자 퀄리티 자동 조정 시스템

사용자의 디바이스 성능과 씬의 복잡도에 따라 그림자 품질을 자동으로 조정하는 시스템입니다.

class ShadowQualityManager {
    constructor(renderer, scene) {
        this.renderer = renderer;
        this.scene = scene;
        this.fpsHistory = [];
        this.qualityLevels = {
            high: {
                type: THREE.PCFSoftShadowMap,
                mapSize: 2048,
                bias: -0.0001,
                radius: 4
            },
            medium: {
                type: THREE.PCFShadowMap,
                mapSize: 1024,
                bias: -0.001,
                radius: 2
            },
            low: {
                type: THREE.BasicShadowMap,
                mapSize: 512,
                bias: -0.002,
                radius: 1
            }
        };
        
        this.currentQuality = 'medium';
        this.setupInitialQuality();
    }

    setupInitialQuality() {
        // GPU 성능 체크
        const gpu = this.renderer.capabilities;
        const maxTexSize = gpu.maxTextureSize;
        
        if (maxTexSize >= 4096 && this.checkHighPerformance()) {
            this.setQuality('high');
        } else if (maxTexSize >= 2048) {
            this.setQuality('medium');
        } else {
            this.setQuality('low');
        }
    }

    checkHighPerformance() {
        // WebGL 확장 기능 체크
        const gl = this.renderer.getContext();
        const extensions = [
            'EXT_color_buffer_float',
            'OES_texture_float_linear'
        ];
        
        return extensions.every(ext => gl.getExtension(ext));
    }

    setQuality(level) {
        const quality = this.qualityLevels[level];
        
        this.renderer.shadowMap.type = quality.type;
        
        this.scene.traverse(object => {
            if (object.isLight && object.shadow) {
                object.shadow.mapSize.width = quality.mapSize;
                object.shadow.mapSize.height = quality.mapSize;
                object.shadow.bias = quality.bias;
                object.shadow.radius = quality.radius;
                
                // 그림자 맵 리셋
                object.shadow.map = null;
            }
        });
        
        this.currentQuality = level;
    }

    updateFPSHistory(fps) {
        this.fpsHistory.push(fps);
        if (this.fpsHistory.length > 60) {
            this.fpsHistory.shift();
        }
        
        const avgFPS = this.fpsHistory.reduce((a, b) => a + b) / this.fpsHistory.length;
        this.adjustQualityBasedOnFPS(avgFPS);
    }

    adjustQualityBasedOnFPS(avgFPS) {
        if (avgFPS < 30 && this.currentQuality !== 'low') {
            this.setQuality('low');
            console.log('그림자 품질 저하: 성능 최적화를 위해 품질을 낮춥니다.');
        } else if (avgFPS > 55 && this.currentQuality === 'low') {
            this.setQuality('medium');
            console.log('그림자 품질 향상: 성능이 충분하여 품질을 높입니다.');
        }
    }
}

3. 고급 그림자 블러 시스템

부드러운 그림자 효과를 위한 커스텀 후처리 시스템입니다.

class AdvancedShadowBlur {
    constructor(renderer, scene, camera) {
        this.renderer = renderer;
        this.scene = scene;
        this.camera = camera;

        this.setupRenderTargets();
        this.setupShaderMaterials();
    }

    setupRenderTargets() {
        const shadowSize = 1024;
        this.shadowRenderTarget = new THREE.WebGLRenderTarget(
            shadowSize, shadowSize, {
                format: THREE.RGBAFormat,
                type: THREE.FloatType
            }
        );
        
        this.blurTargets = [
            new THREE.WebGLRenderTarget(shadowSize, shadowSize),
            new THREE.WebGLRenderTarget(shadowSize, shadowSize)
        ];
    }

    setupShaderMaterials() {
        // 가우시안 블러 쉐이더
        this.blurMaterial = new THREE.ShaderMaterial({
            uniforms: {
                tShadow: { value: null },
                resolution: { value: new THREE.Vector2(1024, 1024) },
                blurSize: { value: 2.0 }
            },
            vertexShader: `
                varying vec2 vUv;
                void main() {
                    vUv = uv;
                    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
                }
            `,
            fragmentShader: `
                uniform sampler2D tShadow;
                uniform vec2 resolution;
                uniform float blurSize;
                varying vec2 vUv;

                void main() {
                    vec4 sum = vec4(0.0);
                    vec2 texelSize = vec2(1.0 / resolution.x, 1.0 / resolution.y);

                    // 9x9 가우시안 블러 커널
                    for(float x = -4.0; x <= 4.0; x++) {
                        for(float y = -4.0; y <= 4.0; y++) {
                            vec2 offset = vec2(x, y) * texelSize * blurSize;
                            float weight = exp(-(x*x + y*y) / (2.0 * 8.0)) / (2.0 * 3.14159 * 8.0);
                            sum += texture2D(tShadow, vUv + offset) * weight;
                        }
                    }

                    gl_FragColor = sum;
                }
            `
        });
    }

    render() {
        // 원본 그림자 렌더링
        this.renderer.setRenderTarget(this.shadowRenderTarget);
        this.renderer.render(this.scene, this.camera);

        // 수평 블러
        this.blurMaterial.uniforms.tShadow.value = this.shadowRenderTarget.texture;
        this.blurMaterial.uniforms.resolution.value.set(1024, 1024);
        this.renderBlurPass(this.blurTargets[0]);

        // 수직 블러
        this.blurMaterial.uniforms.tShadow.value = this.blurTargets[0].texture;
        this.renderBlurPass(this.blurTargets[1]);

        // 최종 결과를 화면에 렌더링
        this.renderer.setRenderTarget(null);
        this.renderer.render(this.scene, this.camera);
    }

    renderBlurPass(target) {
        this.renderer.setRenderTarget(target);
        this.renderer.render(this.scene, this.camera);
    }
}

4. 실시간 그림자 LOD (Level of Detail) 시스템

거리에 따라 그림자 품질을 자동으로 조절하는 시스템입니다.

class ShadowLODSystem {
    constructor(camera) {
        this.camera = camera;
        this.lodLevels = new Map();
        this.distanceThresholds = [10, 30, 50]; // 거리 기준점
    }

    addObject(object, shadowConfigs) {
        this.lodLevels.set(object, {
            high: {
                mapSize: new THREE.Vector2(2048, 2048),
                bias: -0.0001,
                radius: 4,
                ...shadowConfigs?.high
            },
            medium: {
                mapSize: new THREE.Vector2(1024, 1024),
                bias: -0.001,
                radius: 2,
                ...shadowConfigs?.medium
            },
            low: {
                mapSize: new THREE.Vector2(512, 512),
                bias: -0.002,
                radius: 1,
                ...shadowConfigs?.low
            }
        });
    }

    update() {
        this.lodLevels.forEach((configs, object) => {
            const distance = this.camera.position.distanceTo(object.position);
            const level = this.calculateLODLevel(distance);
            this.applyShadowConfig(object, configs[level]);
        });
    }

    calculateLODLevel(distance) {
        if (distance < this.distanceThresholds[0]) return 'high';
        if (distance < this.distanceThresholds[1]) return 'medium';
        return 'low';
    }

    applyShadowConfig(object, config) {
        if (object.shadow) {
            object.shadow.mapSize.copy(config.mapSize);
            object.shadow.bias = config.bias;
            object.shadow.radius = config.radius;
            
            // 변경사항이 있을 때만 그림자 맵 업데이트
            if (object.shadow.needsUpdate) {
                object.shadow.map = null;
                object.shadow.needsUpdate = false;
            }
        }
    }
}

사용 예시

이러한 시스템들을 통합하여 사용하는 방법입니다.

class ShadowDebugger {
    constructor(renderer, scene) {
        this.renderer = renderer;
        this.scene = scene;
        this.debugModes = {
            NONE: 'none',
            SHADOW_MAP: 'shadowMap',
            SHADOW_CASCADE: 'shadowCascade',
            PERFORMANCE: 'performance'
        };
        this.currentMode = this.debugModes.NONE;
        this.stats = {
            drawCalls: 0,
            shadowMapTime: 0,
            totalTime: 0
        };
        this.setupDebugDisplay();
    }

    setupDebugDisplay() {
        this.debugContainer = document.createElement('div');
        Object.assign(this.debugContainer.style, {
            position: 'fixed',
            top: '10px',
            right: '10px',
            backgroundColor: 'rgba(0, 0, 0, 0.7)',
            color: 'white',
            padding: '10px',
            fontFamily: 'monospace',
            fontSize: '12px',
            zIndex: 1000
        });
        document.body.appendChild(this.debugContainer);
    }

    startMeasuring() {
        this.measurementStart = performance.now();
        return () => {
            const end = performance.now();
            return end - this.measurementStart;
        };
    }

    analyzeShadowMaps(lights) {
        const analysis = [];
        lights.forEach(light => {
            if (light.shadow && light.shadow.map) {
                const shadowMap = light.shadow.map;
                analysis.push({
                    type: light.type,
                    mapSize: shadowMap.width + 'x' + shadowMap.height,
                    memoryUsage: this.calculateTextureMemory(shadowMap),
                    renderTime: this.measureShadowMapRenderTime(light)
                });
            }
        });
        return analysis;
    }

    calculateTextureMemory(shadowMap) {
        const pixelCount = shadowMap.width * shadowMap.height;
        // 깊이 텍스처는 일반적으로 32비트 float 사용
        return (pixelCount * 4) / (1024 * 1024); // MB 단위
    }

    measureShadowMapRenderTime(light) {
        const endMeasurement = this.startMeasuring();
        // 임시 렌더 타겟으로 그림자 맵 렌더링
        const currentRenderTarget = this.renderer.getRenderTarget();
        this.renderer.setRenderTarget(light.shadow.map);
        this.renderer.render(this.scene, light.shadow.camera);
        this.renderer.setRenderTarget(currentRenderTarget);
        return endMeasurement();
    }

    updateDebugDisplay() {
        if (this.currentMode === this.debugModes.NONE) {
            this.debugContainer.style.display = 'none';
            return;
        }

        this.debugContainer.style.display = 'block';
        let content = '';

        switch (this.currentMode) {
            case this.debugModes.SHADOW_MAP:
                content = this.formatShadowMapDebug();
                break;
            case this.debugModes.SHADOW_CASCADE:
                content = this.formatShadowCascadeDebug();
                break;
            case this.debugModes.PERFORMANCE:
                content = this.formatPerformanceDebug();
                break;
        }

        this.debugContainer.innerHTML = content;
    }

    formatShadowMapDebug() {
        const lights = this.scene.lights || [];
        const analysis = this.analyzeShadowMaps(lights);
        
        return `
            <h3>Shadow Map Debug</h3>
            ${analysis.map(info => `
                <div>
                    <p>Light Type: ${info.type}</p>
                    <p>Map Size: ${info.mapSize}</p>
                    <p>Memory Usage: ${info.memoryUsage.toFixed(2)} MB</p>
                    <p>Render Time: ${info.renderTime.toFixed(2)} ms</p>
                </div>
            `).join('<hr>')}
        `;
    }

    formatShadowCascadeDebug() {
        const directionalLights = this.scene.lights?.filter(light => 
            light.type === 'DirectionalLight' && light.shadow?.cascades
        ) || [];

        return `
            <h3>Shadow Cascade Debug</h3>
            ${directionalLights.map(light => `
                <div>
                    <p>Light Name: ${light.name || 'Unnamed'}</p>
                    <p>Cascade Count: ${light.shadow.cascades.length}</p>
                    ${light.shadow.cascades.map((cascade, i) => `
                        <div>
                            <p>Cascade ${i + 1}:</p>
                            <p>Range: ${cascade.near.toFixed(2)} - ${cascade.far.toFixed(2)}</p>
                            <p>Resolution: ${cascade.mapSize.width}x${cascade.mapSize.height}</p>
                        </div>
                    `).join('')}
                </div>
            `).join('<hr>')}
        `;
    }

    formatPerformanceDebug() {
        return `
            <h3>Shadow Performance</h3>
            <p>Draw Calls: ${this.stats.drawCalls}</p>
            <p>Shadow Map Time: ${this.stats.shadowMapTime.toFixed(2)} ms</p>
            <p>Total Render Time: ${this.stats.totalTime.toFixed(2)} ms</p>
            <p>Shadow/Total Ratio: ${((this.stats.shadowMapTime / this.stats.totalTime) * 100).toFixed(1)}%</p>
        `;
    }

    setDebugMode(mode) {
        if (this.debugModes[mode]) {
            this.currentMode = this.debugModes[mode];
            this.updateDebugDisplay();
        }
    }

    update() {
        if (this.currentMode === this.debugModes.NONE) return;

        // 성능 측정 업데이트
        const endMeasurement = this.startMeasuring();
        
        // 그림자 맵 렌더링 시간 측정
        const lights = this.scene.lights || [];
        let shadowTime = 0;
        lights.forEach(light => {
            if (light.shadow) {
                shadowTime += this.measureShadowMapRenderTime(light);
            }
        });

        this.stats.shadowMapTime = shadowTime;
        this.stats.totalTime = endMeasurement();
        this.stats.drawCalls = this.renderer.info?.render?.calls || 0;

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

0개의 댓글