/** * GraphicsManager — система визуальных эффектов («шейдеры») для игр Рублокса. * * Управляет: * - постобработкой экрана через Babylon DefaultRenderingPipeline: * bloom (свечение), FXAA (сглаживание), виньетка, цветокоррекция * (контраст/насыщенность/экспозиция), тонмаппинг, глубина резкости (DoF); * - качеством теней (через scene3d.setShadowQuality); * - контактными тенями SSAO (через scene3d.setSsaoEnabled). * * Управляется И из настроек игры (вкладка «Графика»), И из скриптов * (game.graphics.*). По умолчанию ВСЁ ВЫКЛЮЧЕНО (preset 'off') — старые игры * не меняются, FPS не страдает. Автор включает осознанно. * * Mobile-safe: на слабых устройствах тяжёлые эффекты (DoF, SSAO, ultra-тени, * HDR-bloom) автоматически урезаются, даже если в пресете включены. * * Один и тот же класс используется в студии и плеере (фича-парность). * * Использование: * const gfx = new GraphicsManager(scene, camera, scene3d, { mobile }); * gfx.apply({ preset: 'cinematic' }); * gfx.apply({ bloom: { enabled: true, intensity: 0.7 } }); * gfx.dispose(); */ import { DefaultRenderingPipeline, Color4, ImageProcessingConfiguration, } from '@babylonjs/core'; /** * Именованные пресеты. Каждый — полный набор настроек. 'off' = чистая картинка * (pipeline не создаётся вовсе). Значения подобраны так, чтобы быть заметными, * но не «кислотными». */ export const GRAPHICS_PRESETS = { off: { bloom: { enabled: false }, fxaa: false, vignette: { enabled: false }, grading: { enabled: false }, dof: { enabled: false }, ssao: false, shadows: null, // null = не трогаем текущее качество теней }, // Лёгкий: только мягкое свечение + сглаживание. Дёшево, годится почти везде. low: { bloom: { enabled: true, intensity: 0.3, threshold: 0.9 }, fxaa: true, vignette: { enabled: false }, grading: { enabled: false }, dof: { enabled: false }, ssao: false, shadows: 'hard', }, // Средний: свечение + лёгкая виньетка + чуть насыщенности. medium: { bloom: { enabled: true, intensity: 0.45, threshold: 0.85 }, fxaa: true, vignette: { enabled: true, weight: 0.5 }, grading: { enabled: true, contrast: 1.05, saturation: 1.1, exposure: 1.0 }, dof: { enabled: false }, ssao: false, shadows: 'soft', }, // Высокий: всё кроме DoF, SSAO включён. high: { bloom: { enabled: true, intensity: 0.6, threshold: 0.82 }, fxaa: true, vignette: { enabled: true, weight: 0.6 }, grading: { enabled: true, contrast: 1.1, saturation: 1.2, exposure: 1.05 }, dof: { enabled: false }, ssao: true, shadows: 'soft', }, // Ультра: + глубина резкости + мягкие каскадные тени. ultra: { bloom: { enabled: true, intensity: 0.7, threshold: 0.8 }, fxaa: true, vignette: { enabled: true, weight: 0.65 }, grading: { enabled: true, contrast: 1.12, saturation: 1.25, exposure: 1.05 }, dof: { enabled: true, focusDistance: 18, focalLength: 50, aperture: 1.2 }, ssao: true, shadows: 'high', }, // === Стилевые пресеты (художественные) === cinematic: { bloom: { enabled: true, intensity: 0.55, threshold: 0.8 }, fxaa: true, vignette: { enabled: true, weight: 0.85 }, grading: { enabled: true, contrast: 1.18, saturation: 1.05, exposure: 1.0 }, dof: { enabled: true, focusDistance: 22, focalLength: 60, aperture: 1.0 }, ssao: true, shadows: 'soft', }, vivid: { bloom: { enabled: true, intensity: 0.65, threshold: 0.78 }, fxaa: true, vignette: { enabled: false }, grading: { enabled: true, contrast: 1.1, saturation: 1.5, exposure: 1.1 }, dof: { enabled: false }, ssao: false, shadows: 'soft', }, night: { bloom: { enabled: true, intensity: 0.8, threshold: 0.7 }, fxaa: true, vignette: { enabled: true, weight: 1.0 }, grading: { enabled: true, contrast: 1.2, saturation: 0.85, exposure: 0.8 }, dof: { enabled: false }, ssao: true, shadows: 'soft', }, retro: { bloom: { enabled: false }, fxaa: false, // намеренно «пиксельно» vignette: { enabled: true, weight: 1.2 }, grading: { enabled: true, contrast: 1.3, saturation: 0.7, exposure: 0.95 }, dof: { enabled: false }, ssao: false, shadows: 'hard', }, soft: { bloom: { enabled: true, intensity: 0.4, threshold: 0.88 }, fxaa: true, vignette: { enabled: true, weight: 0.4 }, grading: { enabled: true, contrast: 0.95, saturation: 1.05, exposure: 1.05 }, dof: { enabled: false }, ssao: false, shadows: 'soft', }, }; // Глубокое слияние пресета и пользовательских оверрайдов. function _mergeConfig(base, over) { const out = JSON.parse(JSON.stringify(base || {})); if (!over) return out; for (const k of Object.keys(over)) { const v = over[k]; if (v && typeof v === 'object' && !Array.isArray(v)) { out[k] = { ...(out[k] || {}), ...v }; } else { out[k] = v; } } return out; } export class GraphicsManager { /** * @param scene Babylon Scene * @param camera активная камера (для pipeline) * @param scene3d ссылка на BabylonScene (для setShadowQuality / setSsaoEnabled / света) * @param opts { mobile:boolean } */ constructor(scene, camera, scene3d, opts = {}) { this.scene = scene; this.camera = camera; this.scene3d = scene3d; this.mobile = !!opts.mobile; this._pipeline = null; // Текущая активная конфигурация (после merge + mobile-clamp). this.config = _mergeConfig(GRAPHICS_PRESETS.off, null); this.config.preset = 'off'; this.enabled = false; } /** Сменить камеру (например после смены режима камеры) — пересобрать pipeline. */ setCamera(camera) { if (camera === this.camera) return; this.camera = camera; if (this.enabled) this._rebuildPipeline(); } /** * Применить настройки графики. Принимает либо {preset}, либо отдельные * секции (bloom/vignette/grading/dof/ssao/fxaa/shadows), либо и то и другое * (оверрайды поверх пресета). Сохраняет состояние в this.config. */ apply(settings = {}) { let cfg; if (settings.preset && GRAPHICS_PRESETS[settings.preset]) { cfg = _mergeConfig(GRAPHICS_PRESETS[settings.preset], settings); cfg.preset = settings.preset; } else { // частичный апдейт поверх текущего cfg = _mergeConfig(this.config, settings); cfg.preset = settings.preset || this.config.preset || 'custom'; } this.config = this._clampForMobile(cfg); this._applyConfig(); return this.config; } /** Полностью выключить эффекты (как preset 'off'). */ disableAll() { return this.apply({ preset: 'off' }); } /** Текущая конфигурация (для serialize). */ serialize() { // Храним «как просили» (preset + явные оверрайды). Для простоты — весь cfg. return JSON.parse(JSON.stringify(this.config)); } // --- внутреннее --- /** На слабых устройствах гасим самое дорогое, что бы ни просили. */ _clampForMobile(cfg) { if (!this.mobile) return cfg; const c = JSON.parse(JSON.stringify(cfg)); if (c.dof) c.dof.enabled = false; // DoF дорогой c.ssao = false; // SSAO дорогой if (c.shadows === 'high' || c.shadows === 'medium') c.shadows = 'hard'; // bloom оставляем, но без HDR (решается в _rebuildPipeline) c._mobileClamped = true; return c; } _applyConfig() { const c = this.config; const anyPipelineFx = (c.bloom && c.bloom.enabled) || c.fxaa || (c.vignette && c.vignette.enabled) || (c.grading && c.grading.enabled) || (c.dof && c.dof.enabled); // Тени и SSAO — через scene3d (они вне pipeline). try { if (c.shadows && this.scene3d?.setShadowQuality) { this.scene3d.setShadowQuality(c.shadows); } } catch (e) { /* ignore */ } try { if (this.scene3d?.setSsaoEnabled) this.scene3d.setSsaoEnabled(!!c.ssao); } catch (e) { /* ignore */ } if (!anyPipelineFx) { this.enabled = false; this._disposePipeline(); return; } this.enabled = true; this._rebuildPipeline(); } _rebuildPipeline() { this._disposePipeline(); if (!this.scene || !this.camera) return; const c = this.config; const useHdr = (c.bloom && c.bloom.enabled) && !this.mobile; const p = new DefaultRenderingPipeline('rbx_graphics', useHdr, this.scene, [this.camera]); // Bloom p.bloomEnabled = !!(c.bloom && c.bloom.enabled); if (p.bloomEnabled) { p.bloomThreshold = c.bloom.threshold ?? 0.85; p.bloomWeight = c.bloom.intensity ?? 0.5; p.bloomKernel = this.mobile ? 32 : 64; p.bloomScale = 0.5; } // FXAA p.fxaaEnabled = !!c.fxaa; p.samples = this.mobile ? 1 : 4; // Image processing: виньетка + цветокоррекция + (опц.) тонмаппинг const ip = p.imageProcessing; if (ip) { p.imageProcessingEnabled = true; ip.toneMappingEnabled = false; // как в GdPostFx — иначе картинка темнеет // экспозиция/контраст из grading if (c.grading && c.grading.enabled) { ip.exposure = c.grading.exposure ?? 1.0; ip.contrast = c.grading.contrast ?? 1.0; ip.colorCurvesEnabled = true; try { const curves = ip.colorCurves; if (curves) { // saturation: 1.0 = норма → curves в диапазоне примерно -100..100 const sat = c.grading.saturation ?? 1.0; curves.globalSaturation = Math.round((sat - 1.0) * 60); } } catch (e) { /* ignore */ } } else { ip.exposure = 1.0; ip.contrast = 1.0; } // виньетка if (c.vignette && c.vignette.enabled) { ip.vignetteEnabled = true; ip.vignetteWeight = c.vignette.weight ?? 0.6; ip.vignetteColor = new Color4(0, 0, 0, 0); ip.vignetteStretch = 0.3; ip.vignetteCameraFov = 0.5; ip.vignetteBlendMode = ImageProcessingConfiguration.VIGNETTEMODE_MULTIPLY; } else { ip.vignetteEnabled = false; } } // Depth of Field (глубина резкости) — только desktop if (c.dof && c.dof.enabled && !this.mobile) { p.depthOfFieldEnabled = true; try { p.depthOfFieldBlurLevel = 1; // 0..2 p.depthOfField.focusDistance = (c.dof.focusDistance ?? 18) * 1000; // мм p.depthOfField.focalLength = c.dof.focalLength ?? 50; p.depthOfField.fStop = c.dof.aperture ?? 1.2; } catch (e) { /* ignore */ } } else { p.depthOfFieldEnabled = false; } this._pipeline = p; } _disposePipeline() { if (this._pipeline) { try { this._pipeline.dispose(); } catch (e) { /* ignore */ } this._pipeline = null; } } dispose() { this._disposePipeline(); this.scene = null; this.camera = null; this.scene3d = null; } }