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>
329 lines
13 KiB
JavaScript
329 lines
13 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|