feat(inspector): новые слайдеры света — заливка теней, экспозиция, контраст, насыщенность

В Свет и атмосфера добавлено:
- Заливка теней (scene.ambientColor) — позволяет окрасить тени в
  сером тоне без пересвета diffuse материалов.
- Экспозиция (ipc.exposure 0.3-2) — общая яркость через
  imageProcessingConfiguration.
- Контраст (ipc.contrast 0.5-2)
- Насыщенность (colorCurves.globalSaturation -100..+100)

Юзер крутит слайдеры до момента когда импортированная Roblox-карта
выглядит как оригинал. Дефолты: ambient 0.3, exposure 1.0, contrast
1.0, saturation 1.0.

Также убрал mat.ambientColor=цвет — теперь default (0,0,0). Освещение
управляется глобально через панель.

Состояние пока не сохраняется в проект (только сессия). Persistence
добавим в следующем шаге.
This commit is contained in:
min 2026-06-08 18:54:00 +03:00
parent 67851820a9
commit 19f47b2d75
4 changed files with 103 additions and 5 deletions

View File

@ -526,11 +526,73 @@ const InspectorPanel = ({
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</div> </div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Заливка теней</span>
<span style={{ opacity: 0.6 }}>{(selection.sceneAmbient ?? 0.3).toFixed(2)}</span>
</div>
<input
type="range" min="0" max="1" step="0.05"
value={selection.sceneAmbient ?? 0.3}
onChange={(e) => props.onSetLightingProps?.({ sceneAmbient: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 2 }}>
Подсветка теней цвет в затенённых гранях. 0 = чёрные тени, 1 = плоско.
</div>
</div>
<div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 4 }}> <div style={{ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic', marginTop: 4 }}>
<Icon name="sparkle" size={11} /> Цвет окружающего света подбирается автоматически по времени суток. <Icon name="sparkle" size={11} /> Цвет окружающего света подбирается автоматически по времени суток.
</div> </div>
</div> </div>
{/* Цветокоррекция */}
<div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="sparkle" size={12} /> Цветокоррекция</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Экспозиция</span>
<span style={{ opacity: 0.6 }}>{(selection.exposure ?? 1.0).toFixed(2)}</span>
</div>
<input
type="range" min="0.3" max="2" step="0.05"
value={selection.exposure ?? 1.0}
onChange={(e) => props.onSetLightingProps?.({ exposure: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 2 }}>
Общая яркость. &lt;1 = темнее, &gt;1 = светлее.
</div>
</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Контраст</span>
<span style={{ opacity: 0.6 }}>{(selection.contrast ?? 1.0).toFixed(2)}</span>
</div>
<input
type="range" min="0.5" max="2" step="0.05"
value={selection.contrast ?? 1.0}
onChange={(e) => props.onSetLightingProps?.({ contrast: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
</div>
<div style={{ padding: '4px 0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
<span>Насыщенность</span>
<span style={{ opacity: 0.6 }}>{(selection.saturation ?? 1.0).toFixed(2)}</span>
</div>
<input
type="range" min="0" max="2" step="0.05"
value={selection.saturation ?? 1.0}
onChange={(e) => props.onSetLightingProps?.({ saturation: parseFloat(e.target.value) })}
style={{ width: '100%' }}
/>
<div style={{ fontSize: 10, color: 'var(--text-dim)', marginTop: 2 }}>
0 = чёрно-белое, 1 = норма, 2 = очень сочно.
</div>
</div>
</div>
{/* Туман */} {/* Туман */}
<div className={cl.section}> <div className={cl.section}>
<div className={cl.sectionTitle}><Icon name="fog" size={12} /> Туман</div> <div className={cl.sectionTitle}><Icon name="fog" size={12} /> Туман</div>

View File

@ -37,6 +37,7 @@ import {
Ray, Ray,
PointerEventTypes, PointerEventTypes,
Tools as BabylonTools, Tools as BabylonTools,
ColorCurves,
} from '@babylonjs/core'; } from '@babylonjs/core';
import { PlacementManager } from './PlacementManager'; import { PlacementManager } from './PlacementManager';
import { ShopInventoryUi } from './ShopInventoryUi'; import { ShopInventoryUi } from './ShopInventoryUi';
@ -1885,9 +1886,41 @@ export class BabylonScene {
} }
if (typeof patch.sunIntensity === 'number' && this._sunLight) { if (typeof patch.sunIntensity === 'number' && this._sunLight) {
this._sunLight.intensity = Math.max(0, patch.sunIntensity); this._sunLight.intensity = Math.max(0, patch.sunIntensity);
this._sunIntensity = patch.sunIntensity;
} }
if (typeof patch.hemiIntensity === 'number' && this._hemiLight) { if (typeof patch.hemiIntensity === 'number' && this._hemiLight) {
this._hemiLight.intensity = Math.max(0, patch.hemiIntensity); this._hemiLight.intensity = Math.max(0, patch.hemiIntensity);
this._hemiIntensity = patch.hemiIntensity;
}
// Окружающий свет (scene.ambientColor) — отдельный множитель.
// Применяется ко всем материалам через ambient*ambient.
if (typeof patch.sceneAmbient === 'number') {
const v = Math.max(0, Math.min(1, patch.sceneAmbient));
this.scene.ambientColor = new Color3(v, v, v);
this._sceneAmbient = v;
}
// Цветокоррекция — экспозиция, контраст, насыщенность через
// imageProcessingConfiguration (включает HDR pipeline).
if (typeof patch.exposure === 'number' || typeof patch.contrast === 'number'
|| typeof patch.saturation === 'number') {
const ipc = this.scene.imageProcessingConfiguration;
ipc.isEnabled = true;
if (typeof patch.exposure === 'number') {
ipc.exposure = Math.max(0.1, Math.min(3, patch.exposure));
this._exposure = ipc.exposure;
}
if (typeof patch.contrast === 'number') {
ipc.contrast = Math.max(0.5, Math.min(2.5, patch.contrast));
this._contrast = ipc.contrast;
}
if (typeof patch.saturation === 'number') {
// colorCurves для saturation (стандартный Babylon приём)
if (!ipc.colorCurves) ipc.colorCurves = new ColorCurves();
const s = Math.max(-100, Math.min(100, (patch.saturation - 1) * 100));
ipc.colorCurves.globalSaturation = s;
ipc.colorCurvesEnabled = true;
this._saturation = patch.saturation;
}
} }
if (this.environment && typeof this.environment.setFog === 'function') { if (this.environment && typeof this.environment.setFog === 'function') {
// Текущие значения берём из Environment, поверх накладываем patch // Текущие значения берём из Environment, поверх накладываем patch

View File

@ -506,11 +506,9 @@ export class PrimitiveManager {
_applyMaterial(mesh, typeDef, color, material, textureUrl) { _applyMaterial(mesh, typeDef, color, material, textureUrl) {
const matName = `${mesh.name}_mat`; const matName = `${mesh.name}_mat`;
const mat = new StandardMaterial(matName, this.scene); const mat = new StandardMaterial(matName, this.scene);
const dc = Color3.FromHexString(color || '#888888'); mat.diffuseColor = Color3.FromHexString(color || '#888888');
mat.diffuseColor = dc; // ambient = default (0,0,0). Освещение настраивается через
// ambient = 40% от цвета. Roblox-look: тень окрашена, но не // глобальную панель «Свет и атмосфера» (sun/hemi/saturation).
// суммируется с прямым светом в пересвет. Белые остаются белыми.
mat.ambientColor = new Color3(dc.r * 0.4, dc.g * 0.4, dc.b * 0.4);
// Если задан textureUrl — подгружаем PNG как diffuseTexture. Это // Если задан textureUrl — подгружаем PNG как diffuseTexture. Это
// используется для GD-скинов куба (например /gd/skins/cube_smile.png). // используется для GD-скинов куба (например /gd/skins/cube_smile.png).

View File

@ -282,6 +282,11 @@ export class SelectionManager {
fogColor: env ? `#${[env.fogColor?.[0] ?? 0.7, env.fogColor?.[1] ?? 0.8, env.fogColor?.[2] ?? 0.9].map(c => Math.round(Math.max(0, Math.min(1, c)) * 255).toString(16).padStart(2, '0')).join('')}` : '#b0c8e6', fogColor: env ? `#${[env.fogColor?.[0] ?? 0.7, env.fogColor?.[1] ?? 0.8, env.fogColor?.[2] ?? 0.9].map(c => Math.round(Math.max(0, Math.min(1, c)) * 255).toString(16).padStart(2, '0')).join('')}` : '#b0c8e6',
shadowQuality: this._scene3d.getShadowQuality?.() || 'soft', shadowQuality: this._scene3d.getShadowQuality?.() || 'soft',
ssaoEnabled: this._scene3d.getSsaoEnabled?.() || false, ssaoEnabled: this._scene3d.getSsaoEnabled?.() || false,
// Новые: глобальный ambient + image processing
sceneAmbient: this._scene3d._sceneAmbient ?? 0.3,
exposure: this._scene3d._exposure ?? 1.0,
contrast: this._scene3d._contrast ?? 1.0,
saturation: this._scene3d._saturation ?? 1.0,
}; };
this._notifyChange(); this._notifyChange();
} }