player/src/engine/GraphicsManager.js
min ccf76d539b feat(player): графика/эффекты (фича-парность со студией) + realtime на game.rublox.pro
GraphicsManager (постобработка/материалы/API game.graphics) — паритет со студией,
применяется при загрузке игры если автор настроил. Новые материалы chrome/water/
iridescent. Realtime-эндпоинт переведён на game.rublox.pro (S1 NPM прямо, без
hop через S2 — чинит разрывы WebSocket). MultiplayerSync улучшен.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:29:00 +03:00

329 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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