OMRVisualFoundation.ColorGrading.cs
using Sandbox;
using Sandbox.Rendering;
using System;
/// <summary>
/// Phase 8F3F5 / Hotfix 1: color grading for OMR's two-camera presentation stack.
///
/// Editor preview uses the native Sandbox.ColorGrading component on the authored
/// gameplay camera. Runtime cannot rely on that late native pass because OMR's
/// higher-priority composite camera owns the final image. Instead we reuse the
/// engine ColorGrading shader in a command list on the final overlay camera,
/// after the world look but before the independent viewmodel composite.
///
/// Result:
/// - world color grading is visible in the final gameplay image;
/// - viewmodel/arms remain outside the grading pass;
/// - no need to enable generic post-processing on the composite camera;
/// - LUT / RGB / HSV curve controls stay compatible with S&box's native shader.
/// </summary>
public sealed partial class OMRVisualFoundation
{
private static readonly ProfilingSampler ColorGradingGpuScope = new( "OMR World Color Grading" );
private bool _runtimeGradingCommandsBuilt;
private int _runtimeGradingCommandsHash;
private const string RuntimeColorGradingShaderPath = "ColorGrading.shader";
private const int RuntimeColorGradingCommandOrder = 1500;
[Property, Group( "World Look/Color Grading" ), Title( "Enable Color Grading" )]
[Description( "World-only grading. In Play this is applied on OMR's final overlay camera before the viewmodel composite." )]
public bool EnableColorGrading { get; set; } = false;
[Property, Group( "World Look/Color Grading" ), Title( "Grading Method" )]
public Sandbox.ColorGrading.GradingType ColorGradingMethod { get; set; } =
Sandbox.ColorGrading.GradingType.TemperatureControl;
[Property, Group( "World Look/Color Grading" ), Range( 0f, 1f ), Title( "Blend Factor" )]
[Description( "Overall grading weight. World Look blend weight is applied on top of this." )]
public float ColorGradingBlendFactor { get; set; } = 0.15f;
[Property, Group( "World Look/Color Grading" ), Range( 0f, 4f ), Title( "Color Temperature" )]
[ShowIf( nameof( ColorGradingMethod ), Sandbox.ColorGrading.GradingType.TemperatureControl )]
[Description( "Artist-friendly temperature scale: 0 = very warm, 1 ≈ 3000 K, 2 = neutral 6500 K, 3 ≈ 9800 K, 4 = very cool. Internally converted to Kelvin for S&box." )]
public float ColorGradingTemperature { get; set; } = 2f;
// 8F3F5 compatibility: older scene revisions serialized the raw Kelvin
// value under this name. Keep it hidden long enough to migrate those scenes
// automatically, then clear it so the new 0-4 control becomes authoritative.
[Hide]
public float ColorGradingTemperatureK { get; set; } = 0f;
[Property, Group( "World Look/Color Grading" ), Title( "Lookup Texture" )]
[ShowIf( nameof( ColorGradingMethod ), Sandbox.ColorGrading.GradingType.LUT )]
[Description( "Optional LUT path for later experimentation." )]
public Texture ColorGradingLookupTexture { get; set; } = Texture.White;
[Property, Group( "World Look/Color Grading/Advanced Curves" ), Title( "Curve Color Space" )]
public Sandbox.ColorGrading.ColorSpaceEnum ColorGradingColorSpace { get; set; } =
Sandbox.ColorGrading.ColorSpaceEnum.None;
[Property, Group( "World Look/Color Grading/Advanced Curves" ), Title( "Red Curve" )]
[ShowIf( nameof( ColorGradingColorSpace ), Sandbox.ColorGrading.ColorSpaceEnum.RGB )]
public Curve ColorGradingRedCurve { get; set; } = CreateDefaultColorGradingCurve();
[Property, Group( "World Look/Color Grading/Advanced Curves" ), Title( "Green Curve" )]
[ShowIf( nameof( ColorGradingColorSpace ), Sandbox.ColorGrading.ColorSpaceEnum.RGB )]
public Curve ColorGradingGreenCurve { get; set; } = CreateDefaultColorGradingCurve();
[Property, Group( "World Look/Color Grading/Advanced Curves" ), Title( "Blue Curve" )]
[ShowIf( nameof( ColorGradingColorSpace ), Sandbox.ColorGrading.ColorSpaceEnum.RGB )]
public Curve ColorGradingBlueCurve { get; set; } = CreateDefaultColorGradingCurve();
[Property, Group( "World Look/Color Grading/Advanced Curves" ), Title( "Hue Curve" )]
[ShowIf( nameof( ColorGradingColorSpace ), Sandbox.ColorGrading.ColorSpaceEnum.HSV )]
public Curve ColorGradingHueCurve { get; set; } = CreateDefaultColorGradingCurve();
[Property, Group( "World Look/Color Grading/Advanced Curves" ), Title( "Saturation Curve" )]
[ShowIf( nameof( ColorGradingColorSpace ), Sandbox.ColorGrading.ColorSpaceEnum.HSV )]
public Curve ColorGradingSaturationCurve { get; set; } = CreateDefaultColorGradingCurve();
[Property, Group( "World Look/Color Grading/Advanced Curves" ), Title( "Value Curve" )]
[ShowIf( nameof( ColorGradingColorSpace ), Sandbox.ColorGrading.ColorSpaceEnum.HSV )]
public Curve ColorGradingValueCurve { get; set; } = CreateDefaultColorGradingCurve();
[Button, Group( "World Look/Color Grading/A-B" ), Title( "Neutral Temperature" )]
public void ApplyNeutralColorGradingPreset()
{
EnableColorGrading = true;
ColorGradingMethod = Sandbox.ColorGrading.GradingType.TemperatureControl;
ColorGradingBlendFactor = 1f;
ColorGradingTemperature = 2f;
ColorGradingColorSpace = Sandbox.ColorGrading.ColorSpaceEnum.None;
RebuildColorGrading();
}
[Button, Group( "World Look/Color Grading/A-B" ), Title( "Diagnostic Warm" )]
[Description( "Deliberately obvious runtime check: full blend at 3500 K. If this does not visibly warm the world, the pass is not reaching the final image." )]
public void ApplyDiagnosticWarmColorGradingPreset()
{
EnableColorGrading = true;
ColorGradingMethod = Sandbox.ColorGrading.GradingType.TemperatureControl;
ColorGradingBlendFactor = 1f;
ColorGradingTemperature = KelvinToTemperatureControl( 3500f );
ColorGradingColorSpace = Sandbox.ColorGrading.ColorSpaceEnum.None;
RebuildColorGrading();
}
[Button, Group( "World Look/Color Grading/A-B" ), Title( "Subtle Warm Standard" )]
public void ApplyWarmColorGradingPreset()
{
EnableColorGrading = true;
ColorGradingMethod = Sandbox.ColorGrading.GradingType.TemperatureControl;
ColorGradingBlendFactor = 0.16f;
ColorGradingTemperature = KelvinToTemperatureControl( 6000f );
ColorGradingColorSpace = Sandbox.ColorGrading.ColorSpaceEnum.None;
RebuildColorGrading();
}
[Button, Group( "World Look/Color Grading/A-B" ), Title( "Subtle Cool Standard" )]
public void ApplyCoolColorGradingPreset()
{
EnableColorGrading = true;
ColorGradingMethod = Sandbox.ColorGrading.GradingType.TemperatureControl;
ColorGradingBlendFactor = 0.14f;
ColorGradingTemperature = KelvinToTemperatureControl( 7200f );
ColorGradingColorSpace = Sandbox.ColorGrading.ColorSpaceEnum.None;
RebuildColorGrading();
}
[Button, Group( "World Look/Color Grading/A-B" ), Title( "Disable Color Grading" )]
public void DisableColorGrading()
{
EnableColorGrading = false;
RebuildColorGrading();
}
[Button, Group( "World Look/Color Grading/Advanced Curves" ), Title( "Reset Curves To Default" )]
public void ResetColorGradingCurves()
{
ColorGradingRedCurve = CreateDefaultColorGradingCurve();
ColorGradingGreenCurve = CreateDefaultColorGradingCurve();
ColorGradingBlueCurve = CreateDefaultColorGradingCurve();
ColorGradingHueCurve = CreateDefaultColorGradingCurve();
ColorGradingSaturationCurve = CreateDefaultColorGradingCurve();
ColorGradingValueCurve = CreateDefaultColorGradingCurve();
RebuildColorGrading();
}
[Button, Group( "World Look/Color Grading" ), Title( "Setup Color Grading Preview" )]
[Description( "Adds/reuses Sandbox.ColorGrading on the authored gameplay camera for editor preview. Runtime uses OMR's final-camera bridge instead." )]
public void SetupColorGradingPreview()
{
CameraComponent camera = GetComponent<CameraComponent>( true );
if ( camera is null || !camera.IsValid )
{
Log.Warning(
"[OMR VISUAL] Color Grading preview setup failed: OMRVisualFoundation must share a GameObject with the gameplay CameraComponent."
);
return;
}
ResolveColorGradingComponent();
if ( _colorGrading is null || !_colorGrading.IsValid )
{
_colorGrading = GameObject.AddComponent<Sandbox.ColorGrading>( false );
_colorGradingCreatedByFoundation = false;
}
ApplyNativeColorGradingSettings();
Log.Info(
$"[OMR VISUAL] Native Color Grading preview ready on '{GameObject.Name}' | " +
$"Enabled:{_colorGrading.Enabled} | Method:{_colorGrading.GradingMethod} | " +
$"Blend:{_colorGrading.BlendFactor:0.###} | ColorSpace:{_colorGrading.ColorSpace}"
);
}
private Sandbox.ColorGrading _colorGrading;
private bool _colorGradingCreatedByFoundation;
private bool _colorGradingReadyLogged;
private bool _colorGradingTemperatureMigrated;
private CommandList _runtimeColorGradingCommands;
private Material _runtimeColorGradingMaterial;
private RenderAttributes _runtimeColorGradingAttributes;
private bool _runtimeColorGradingCommandAttached;
private bool _runtimeColorGradingMaterialFailureLogged;
private float EffectiveColorGradingBlend =>
EnableWorldLook && EnableColorGrading
? MathX.Clamp( ColorGradingBlendFactor, 0f, 1f ) * MathX.Clamp( BlendWeight, 0f, 1f )
: 0f;
// Temperature UI is deliberately not linear in Kelvin. White balance is
// much more intuitive in reciprocal color temperature (mireds), so the
// 0-4 control gets useful resolution on both the warm and cool sides.
// Chosen anchors:
// 0 = 1950 K, 1 = 3000 K, 2 = 6500 K, 3 ≈ 9811 K, 4 = 20000 K.
private const float ColorTemperatureWarmKelvin = 1950f;
private const float ColorTemperatureNeutralKelvin = 6500f;
private const float ColorTemperatureCoolKelvin = 20000f;
private float EffectiveColorTemperatureKelvin
{
get
{
EnsureColorTemperatureMigration();
return TemperatureControlToKelvin( ColorGradingTemperature );
}
}
private void EnsureColorTemperatureMigration()
{
if ( _colorGradingTemperatureMigrated )
return;
_colorGradingTemperatureMigrated = true;
// Old builds stored Kelvin directly. Anything above the new 0-4 range
// is therefore unambiguously a legacy value. Preserve the authored look
// automatically instead of forcing the user to retune it.
if ( ColorGradingTemperatureK > 4.001f )
ColorGradingTemperature = KelvinToTemperatureControl( ColorGradingTemperatureK );
ColorGradingTemperatureK = 0f;
}
private static float TemperatureControlToKelvin( float control )
{
float value = MathX.Clamp( control, 0f, 4f );
float warmMired = 1000000f / ColorTemperatureWarmKelvin;
float neutralMired = 1000000f / ColorTemperatureNeutralKelvin;
float coolMired = 1000000f / ColorTemperatureCoolKelvin;
float mired;
if ( value <= 2f )
{
float t = value / 2f;
mired = MathX.Lerp( warmMired, neutralMired, t );
}
else
{
float t = (value - 2f) / 2f;
mired = MathX.Lerp( neutralMired, coolMired, t );
}
return 1000000f / MathF.Max( mired, 0.0001f );
}
private static float KelvinToTemperatureControl( float kelvin )
{
float k = MathX.Clamp( kelvin, ColorTemperatureWarmKelvin, ColorTemperatureCoolKelvin );
float mired = 1000000f / k;
float warmMired = 1000000f / ColorTemperatureWarmKelvin;
float neutralMired = 1000000f / ColorTemperatureNeutralKelvin;
float coolMired = 1000000f / ColorTemperatureCoolKelvin;
if ( k <= ColorTemperatureNeutralKelvin )
{
float denom = MathF.Max( warmMired - neutralMired, 0.0001f );
float t = MathX.Clamp( (warmMired - mired) / denom, 0f, 1f );
return t * 2f;
}
float coolDenom = MathF.Max( neutralMired - coolMired, 0.0001f );
float coolT = MathX.Clamp( (neutralMired - mired) / coolDenom, 0f, 1f );
return 2f + coolT * 2f;
}
private static Curve CreateDefaultColorGradingCurve() =>
new Curve( new Curve.Frame( 0f, 0.5f ), new Curve.Frame( 1f, 1f ) );
private void ResetColorGradingSettings()
{
EnableColorGrading = false;
ColorGradingMethod = Sandbox.ColorGrading.GradingType.TemperatureControl;
ColorGradingBlendFactor = 0.15f;
ColorGradingTemperature = 2f;
ColorGradingTemperatureK = 0f;
_colorGradingTemperatureMigrated = true;
ColorGradingLookupTexture = Texture.White;
ColorGradingColorSpace = Sandbox.ColorGrading.ColorSpaceEnum.None;
ColorGradingRedCurve = CreateDefaultColorGradingCurve();
ColorGradingGreenCurve = CreateDefaultColorGradingCurve();
ColorGradingBlueCurve = CreateDefaultColorGradingCurve();
ColorGradingHueCurve = CreateDefaultColorGradingCurve();
ColorGradingSaturationCurve = CreateDefaultColorGradingCurve();
ColorGradingValueCurve = CreateDefaultColorGradingCurve();
}
private void ResolveColorGradingComponent()
{
if ( _colorGrading is not null && _colorGrading.IsValid )
return;
_colorGrading = GetComponent<Sandbox.ColorGrading>( true );
_colorGradingCreatedByFoundation = false;
}
private void RebuildColorGrading()
{
ResolveColorGradingComponent();
// Editor preview: use S&box's native component exactly as authored.
if ( !Game.IsPlaying )
{
if ( _colorGrading is not null && _colorGrading.IsValid )
ApplyNativeColorGradingSettings();
return;
}
// Runtime: the higher-priority OMR composite camera owns the final
// framebuffer, so disable the source-camera native pass and apply the
// same engine shader through our final-camera command bridge instead.
if ( _colorGrading is not null && _colorGrading.IsValid )
_colorGrading.Enabled = false;
EnsureRuntimeColorGradingPass();
RebuildRuntimeColorGradingCommands();
}
private void ApplyNativeColorGradingSettings()
{
if ( _colorGrading is null || !_colorGrading.IsValid )
return;
_colorGrading.GradingMethod = ColorGradingMethod;
_colorGrading.BlendFactor = EffectiveColorGradingBlend;
_colorGrading.ColorTempK = EffectiveColorTemperatureKelvin;
_colorGrading.LookupTexture = ColorGradingLookupTexture ?? Texture.White;
_colorGrading.ColorSpace = ColorGradingColorSpace;
_colorGrading.RedCurve = ColorGradingRedCurve;
_colorGrading.GreenCurve = ColorGradingGreenCurve;
_colorGrading.BlueCurve = ColorGradingBlueCurve;
_colorGrading.HueCurve = ColorGradingHueCurve;
_colorGrading.SaturationCurve = ColorGradingSaturationCurve;
_colorGrading.ValueCurve = ColorGradingValueCurve;
_colorGrading.Enabled = EffectiveColorGradingBlend > 0.0001f;
}
private void EnsureRuntimeColorGradingPass()
{
if ( !Game.IsPlaying )
return;
if ( _runtimeOverlayCamera is null || !_runtimeOverlayCamera.IsValid )
return;
if ( _runtimeColorGradingMaterial is null || !_runtimeColorGradingMaterial.IsValid )
{
_runtimeColorGradingMaterial = Material.FromShader( RuntimeColorGradingShaderPath );
if ( _runtimeColorGradingMaterial is null || !_runtimeColorGradingMaterial.IsValid )
{
if ( !_runtimeColorGradingMaterialFailureLogged )
{
_runtimeColorGradingMaterialFailureLogged = true;
Log.Warning(
$"[OMR VISUAL] Could not create runtime Color Grading material from '{RuntimeColorGradingShaderPath}'."
);
}
return;
}
_runtimeColorGradingMaterialFailureLogged = false;
}
_runtimeColorGradingAttributes ??= new RenderAttributes();
_runtimeColorGradingCommands ??= new CommandList( "OMR Runtime World Color Grading" );
if ( !_runtimeColorGradingCommandAttached )
{
_runtimeOverlayCamera.AddCommandList(
_runtimeColorGradingCommands,
Stage.AfterDepthPrepass,
RuntimeColorGradingCommandOrder
);
_runtimeColorGradingCommandAttached = true;
}
}
private void RebuildRuntimeColorGradingCommands()
{
if ( _runtimeColorGradingCommands is null ||
_runtimeColorGradingMaterial is null ||
!_runtimeColorGradingMaterial.IsValid ||
_runtimeColorGradingAttributes is null )
return;
int signature = HashCode.Combine( _runtimeColorGradingMaterial, EffectiveColorGradingBlend,
EffectiveColorTemperatureKelvin, ColorGradingMethod, ColorGradingColorSpace, ColorGradingLookupTexture );
if ( ColorGradingColorSpace == Sandbox.ColorGrading.ColorSpaceEnum.None &&
_runtimeGradingCommandsBuilt && signature == _runtimeGradingCommandsHash ) return;
_runtimeGradingCommandsBuilt = true;
_runtimeGradingCommandsHash = signature;
_runtimeColorGradingCommands.Reset();
float blend = EffectiveColorGradingBlend;
_runtimeColorGradingCommands.Enabled = blend > 0.0001f;
if ( !_runtimeColorGradingCommands.Enabled )
{
_colorGradingReadyLogged = false;
return;
}
_runtimeColorGradingAttributes.Set( "BlendFactor", blend );
_runtimeColorGradingAttributes.Set( "ColorTempK", EffectiveColorTemperatureKelvin );
_runtimeColorGradingAttributes.SetComboEnum( "D_CGRAD_PASS", ColorGradingMethod );
_runtimeColorGradingAttributes.SetComboEnum( "D_COLORSPACE", ColorGradingColorSpace );
if ( ColorGradingMethod == Sandbox.ColorGrading.GradingType.LUT )
_runtimeColorGradingAttributes.Set( "LookupTexture", ColorGradingLookupTexture ?? Texture.White );
if ( ColorGradingColorSpace == Sandbox.ColorGrading.ColorSpaceEnum.RGB )
{
SetRuntimeColorGradingCurve( ColorGradingRedCurve, "R" );
SetRuntimeColorGradingCurve( ColorGradingGreenCurve, "G" );
SetRuntimeColorGradingCurve( ColorGradingBlueCurve, "B" );
}
else if ( ColorGradingColorSpace == Sandbox.ColorGrading.ColorSpaceEnum.HSV )
{
SetRuntimeColorGradingCurve( ColorGradingHueCurve, "H" );
SetRuntimeColorGradingCurve( ColorGradingSaturationCurve, "S" );
SetRuntimeColorGradingCurve( ColorGradingValueCurve, "V" );
}
using ( _runtimeColorGradingCommands.ProfileScope( ColorGradingGpuScope ) )
{
_runtimeColorGradingCommands.Attributes.GrabFrameTexture( "ColorBuffer" );
_runtimeColorGradingCommands.Blit( _runtimeColorGradingMaterial, _runtimeColorGradingAttributes );
}
if ( LogSetup && !_colorGradingReadyLogged )
{
_colorGradingReadyLogged = true;
Log.Info(
$"[OMR VISUAL] Runtime world Color Grading active | " +
$"OverlayCamera:{_runtimeOverlayCamera.GameObject.Name} | " +
$"Method:{ColorGradingMethod} | Blend:{blend:0.###} | " +
$"Temperature:{EffectiveColorTemperatureKelvin:0}K | ColorSpace:{ColorGradingColorSpace}"
);
}
}
private void SetRuntimeColorGradingCurve( Curve curve, string channelLetter )
{
if ( _runtimeColorGradingAttributes is null )
return;
int totalFrames = Math.Min( curve.Length, 4 );
_runtimeColorGradingAttributes.Set( "CurveFrames" + channelLetter, totalFrames );
if ( totalFrames <= 0 )
return;
float[] xArray = new float[totalFrames];
for ( int i = 0; i < totalFrames; ++i )
{
Curve.Frame frame = curve.Frames[i];
xArray[i] = frame.Time;
Vector4 localFrame = new Vector4(
frame.Time,
frame.Value,
frame.In,
frame.Out
);
_runtimeColorGradingAttributes.Set(
"CurveFrame" + channelLetter + i,
localFrame
);
}
Vector4 divisorVector = new Vector4( 0f );
if ( totalFrames >= 2 )
{
divisorVector.x = 1f / MathF.Max( xArray[1] - xArray[0], 0.00001f );
if ( totalFrames >= 3 )
{
divisorVector.y = 1f / MathF.Max( xArray[2] - xArray[1], 0.00001f );
if ( totalFrames >= 4 )
divisorVector.z = 1f / MathF.Max( xArray[3] - xArray[2], 0.00001f );
}
}
_runtimeColorGradingAttributes.Set(
"CurveDivisors" + channelLetter,
divisorVector
);
}
private void ReleaseRuntimeColorGradingPass()
{
_runtimeGradingCommandsBuilt = false;
if ( _runtimeColorGradingCommandAttached &&
_runtimeOverlayCamera is not null &&
_runtimeOverlayCamera.IsValid &&
_runtimeColorGradingCommands is not null )
{
_runtimeOverlayCamera.RemoveCommandList( _runtimeColorGradingCommands );
}
_runtimeColorGradingCommandAttached = false;
if ( _runtimeColorGradingCommands is not null )
{
_runtimeColorGradingCommands.Enabled = false;
_runtimeColorGradingCommands.Reset();
}
_runtimeColorGradingCommands = null;
_runtimeColorGradingAttributes = null;
_runtimeColorGradingMaterial = null;
_runtimeColorGradingMaterialFailureLogged = false;
_colorGradingReadyLogged = false;
}
private void ReleaseColorGrading()
{
if ( _colorGrading is not null && _colorGrading.IsValid )
{
_colorGrading.Enabled = false;
if ( _colorGradingCreatedByFoundation )
_colorGrading.Destroy();
}
_colorGrading = null;
_colorGradingCreatedByFoundation = false;
ReleaseRuntimeColorGradingPass();
}
}