OMRScopePhysicalLens.cs
using Sandbox;
using System;
using System.Linq;
/// <summary>
/// Bridges the existing OMR scope PiP render target to a physical model-space
/// image surface.
///
/// Phase 6C2 moves the procedural reticle onto that physical surface and
/// suppresses the legacy WorldPanel while the physical image is active.
///
/// Phase 6C3.1 reworks the lens into two deliberately separate layers:
/// ScopeGlass handles Facepunch-inspired refractive/PBR glass, while the
/// view-dependent eye-box/scope-shadow mask is applied to ScopeImagePlane.
/// This keeps the glass clear and dynamic instead of turning the whole lens
/// into an opaque grey overlay. Phase 6C3.2 adds scope-local lateral chromatic
/// aberration to the PiP image, strongest toward the lens edge and independently
/// tunable for hipfire and ADS. The reticle remains unsplit/gameplay-authoritative.
/// Phase 6C4 moves E19 lens-size measurement to the physical ScopeImagePlane
/// renderer. The legacy WorldPanel remains optional only as a compatibility
/// fallback for older optics and is no longer required by a fully migrated M700.
/// </summary>
[Title( "OMR Scope Physical Lens" )]
public sealed class OMRScopePhysicalLens : Component
{
private const float DefaultReticleReferenceResolution = 512f;
[Property, Group( "References" )]
public OMRScopePiPCamera PiPCamera { get; set; }
[Property, Group( "References" )]
public ModelRenderer ImageRenderer { get; set; }
[Property, Group( "References" )]
[Description( "Optional physical convex glass renderer placed in front of ScopeImagePlane. If empty, ScopeGlass is resolved by name." )]
public ModelRenderer GlassRenderer { get; set; }
[Property, Group( "References" )]
[Description( "Legacy model-space WorldPanel retained temporarily for E19 automatic lens-size measurement. It is hidden from render while the physical lens is active." )]
public Sandbox.WorldPanel LegacyWorldPanel { get; set; }
[Property, Group( "References" )]
[Description( "Fallback child name used when ImageRenderer has not been assigned manually." )]
public string ImageObjectName { get; set; } = "ScopeImagePlane";
[Property, Group( "References" )]
[Description( "Fallback child name used when GlassRenderer has not been assigned manually." )]
public string GlassObjectName { get; set; } = "ScopeGlass";
[Property, Group( "Physical Image" )]
[Description( "Show the PiP render target on the physical ScopeImagePlane." )]
public bool EnablePhysicalImage { get; set; } = false;
[Property, Group( "Physical Image" )]
public string ImageShaderPath { get; set; } = "shaders/omr_scope_image.shader";
[Property, Group( "Physical Image" )]
[Description( "Hide an optional legacy WorldPanel while the physical image is visible. Fully migrated Phase 6C4 optics can remove the WorldPanel entirely." )]
public bool HideLegacyWorldPanelWhenPhysicalImage { get; set; } = true;
[Property, Group( "Physical Reticle" )]
[Description( "Render the optic profile's procedural reticle directly in the physical lens shader. ShowSimpleReticle and the authored reticle opacity still control final visibility." )]
public bool EnablePhysicalReticle { get; set; } = true;
[Property, Group( "Physical Reticle" ), Range( 128f, 2048f )]
[Description( "Reference canvas used to convert the existing pixel-authored reticle dimensions to normalized lens UVs. 512 preserves the legacy WorldPanel tuning exactly." )]
public float ReticleReferenceResolution { get; set; } = DefaultReticleReferenceResolution;
[Property, Group( "Chromatic Aberration" )]
[Description( "Enable scope-local lateral chromatic aberration on the PiP image. The effect grows toward the edge of the lens and is applied before the reticle, so aiming remains crisp." )]
public bool EnableChromaticAberration { get; set; } = true;
[Property, Group( "Chromatic Aberration" ), Range( 0f, 8f )]
[Description( "Approximate red/blue channel separation in reference pixels while hipfiring. Keep this low so the off-eye preview does not become a rainbow smear." )]
public float ChromaticAberrationHipPixels { get; set; } = 0.35f;
[Property, Group( "Chromatic Aberration" ), Range( 0f, 8f )]
[Description( "Approximate red/blue channel separation in reference pixels at full ADS. 2-3 px is clearly visible without overwhelming the sight picture." )]
public float ChromaticAberrationAdsPixels { get; set; } = 2.25f;
[Property, Group( "Chromatic Aberration" ), Range( 128f, 2048f )]
[Description( "Reference resolution used to convert the authored pixel separation into normalized lens UVs. 512 keeps the effect stable across PiP render-target quality changes." )]
public float ChromaticAberrationReferenceResolution { get; set; } = 512f;
[Property, Group( "Chromatic Aberration" ), Range( 0f, 0.95f )]
[Description( "Normalized radial position where lateral color separation begins. 0 is the lens center and 1 is the edge." )]
public float ChromaticAberrationStartRadius { get; set; } = 0.18f;
[Property, Group( "Chromatic Aberration" ), Range( 0.25f, 4f )]
[Description( "Controls how quickly chromatic separation ramps from the protected center toward the lens edge. Higher values keep the center cleaner." )]
public float ChromaticAberrationEdgePower { get; set; } = 1.35f;
[Property, Group( "Physical Glass" )]
[Description( "Enable the separate convex ScopeGlass renderer. The glass is presentation-only and never changes the PiP camera, reticle or ballistic aim." )]
public bool EnablePhysicalGlass { get; set; } = false;
[Property, Group( "Physical Glass" )]
public string GlassShaderPath { get; set; } = "shaders/omr_scope_glass.shader";
[Property, Group( "Physical Glass" ), Range( 1f, 1.05f )]
[Description( "Index-of-refraction style strength used by the framebuffer refraction path. Facepunch sight glass commonly stays very close to 1.0; start subtle." )]
public float GlassRefractionStrength { get; set; } = 1.003f;
[Property, Group( "Physical Glass" ), Range( 0f, 1f )]
[Description( "PBR roughness of the physical glass. Lower values produce a cleaner, sharper coating/reflection." )]
public float GlassRoughness { get; set; } = 0.10f;
[Property, Group( "Physical Glass" ), Range( 0f, 1f )]
[Description( "Additional framebuffer blur used by the refracted view. It is multiplied by roughness and grazing angle in the shader." )]
public float GlassBlurAmount { get; set; } = 0.18f;
[Property, Group( "Physical Glass" ), Range( 0f, 1000f )]
[Description( "Thin-film interference wavelength/thickness control, following Facepunch's glass_scope shader convention." )]
public float GlassIridescence { get; set; } = 600f;
[Property, Group( "Physical Glass" ), Range( 0f, 2f )]
[Description( "Strength of the thin-film coating coloration. OMR defaults much lower than Facepunch's RMR material because the M700 scope should stay visually clean." )]
public float GlassIridescenceScale { get; set; } = 0.08f;
[Property, Group( "Physical Glass" ), Range( 0f, 2f )]
[Description( "Multiplier applied to Lens Hip/ADS Reflection Opacity for the physical glass coating." )]
public float GlassReflectionScale { get; set; } = 1f;
[Property, Group( "Physical Glass" ), Range( 0f, 2f )]
[Description( "Multiplier applied to Lens Hip/ADS Tint Opacity on the physical glass." )]
public float GlassTintScale { get; set; } = 1f;
[Property, Group( "Scope Shadow" ), Range( 0f, 2f )]
[Description( "Multiplier applied to the profile's normal lens edge darkening. The mask is now applied to ScopeImagePlane rather than painted onto the glass." )]
public float GlassEdgeDarkeningScale { get; set; } = 0.75f;
[Property, Group( "Scope Shadow" ), Range( 0f, 2f )]
[Description( "Multiplier for E19 eye-box shadow strength. The shadow darkens the PiP itself instead of making ScopeGlass opaque." )]
public float GlassEyeBoxShadowScale { get; set; } = 1f;
[Property, Group( "Scope Shadow" ), Range( 0f, 2f )]
[Description( "View-dependent parallax amount used to move the exit-pupil/scope-shadow mask from the actual camera-to-lens angle. This is based on the same projection idea used by Facepunch for sight parallax." )]
public float ScopeShadowParallaxScale { get; set; } = 0.65f;
[Property, Group( "Scope Shadow" ), Range( 0f, 1.5f )]
[Description( "Normalized radius where the dynamic eye-box shadow begins." )]
public float ScopeShadowInnerRadius { get; set; } = 0.58f;
[Property, Group( "Scope Shadow" ), Range( 0.05f, 1.5f )]
[Description( "Normalized radius where the dynamic eye-box shadow becomes fully opaque before its authored strength multiplier." )]
public float ScopeShadowOuterRadius { get; set; } = 0.98f;
[Property, Group( "Diagnostics" )]
public bool LogSetup { get; set; } = false;
[Property, Group( "Diagnostics" )]
public bool LogStateChanges { get; set; } = false;
private Material _imageMaterial;
private string _loadedShaderPath;
private Material _glassMaterial;
private string _loadedGlassShaderPath;
private bool _lastVisible;
private bool _lastGlassVisible;
private bool _loggedMissingRenderer;
private bool _loggedMissingGlassRenderer;
private bool _loggedMissingPiP;
private OMRVisualFoundation _visualFoundation;
private bool _legacyRenderStateCaptured;
private bool _legacyEnabled;
private bool _legacyWasSuppressed;
protected override void OnStart()
{
ResolveReferences();
EnsureImageMaterial();
EnsureGlassMaterial();
ApplyPhysicalImageState();
if ( LogSetup )
{
Log.Info(
$"[OMR SCOPE PHYSICAL] Ready | Image:{ImageRenderer?.GameObject?.Name ?? "NONE"} | " +
$"Glass:{GlassRenderer?.GameObject?.Name ?? "NONE"} | PiP:{PiPCamera?.GameObject?.Name ?? "NONE"} | " +
$"LegacyPanel:{LegacyWorldPanel?.GameObject?.Name ?? "NONE"} | " +
$"ImageShader:{ImageShaderPath} | GlassShader:{GlassShaderPath}"
);
}
}
protected override void OnUpdate()
{
ResolveReferences();
EnsureImageMaterial();
EnsureGlassMaterial();
ApplyPhysicalImageState();
}
protected override void OnDisabled()
{
DisablePhysicalPresentation();
base.OnDisabled();
}
protected override void OnDestroy()
{
DisablePhysicalPresentation();
base.OnDestroy();
}
private void ResolveReferences()
{
if ( ImageRenderer is null || !ImageRenderer.IsValid )
{
GameObject imageObject = GameObject
.GetAllObjects( true )
.FirstOrDefault(
x =>
x is not null &&
x.IsValid &&
string.Equals( x.Name, ImageObjectName, StringComparison.OrdinalIgnoreCase )
);
ImageRenderer = imageObject?.Components.Get<ModelRenderer>();
}
if ( GlassRenderer is null || !GlassRenderer.IsValid )
{
GameObject glassObject = GameObject
.GetAllObjects( true )
.FirstOrDefault(
x =>
x is not null &&
x.IsValid &&
string.Equals( x.Name, GlassObjectName, StringComparison.OrdinalIgnoreCase )
);
GlassRenderer = glassObject?.Components.Get<ModelRenderer>();
}
if ( PiPCamera is null || !PiPCamera.IsValid )
{
PiPCamera = Scene?
.GetAllComponents<OMRScopePiPCamera>()
.FirstOrDefault( x => x is not null && x.IsValid );
}
if ( _visualFoundation is null || !_visualFoundation.IsValid )
{
_visualFoundation =
PiPCamera?.SourceCamera?.GameObject?
.Components
.Get<OMRVisualFoundation>();
_visualFoundation ??= Scene?
.GetAllComponents<OMRVisualFoundation>()
.FirstOrDefault( x => x is not null && x.IsValid );
}
if ( LegacyWorldPanel is null || !LegacyWorldPanel.IsValid )
{
LegacyWorldPanel = GameObject.GetComponent<Sandbox.WorldPanel>( true );
_legacyRenderStateCaptured = false;
_legacyWasSuppressed = false;
}
CaptureLegacyWorldPanelRenderState();
if ( ImageRenderer is null || !ImageRenderer.IsValid )
{
if ( LogSetup && !_loggedMissingRenderer )
{
_loggedMissingRenderer = true;
Log.Warning(
$"[OMR SCOPE PHYSICAL] No ModelRenderer found for '{ImageObjectName}'."
);
}
}
else
{
_loggedMissingRenderer = false;
}
if ( GlassRenderer is null || !GlassRenderer.IsValid )
{
if ( LogSetup && EnablePhysicalGlass && !_loggedMissingGlassRenderer )
{
_loggedMissingGlassRenderer = true;
Log.Warning(
$"[OMR SCOPE PHYSICAL] No ModelRenderer found for '{GlassObjectName}'."
);
}
}
else
{
_loggedMissingGlassRenderer = false;
}
if ( PiPCamera is null || !PiPCamera.IsValid )
{
if ( LogSetup && !_loggedMissingPiP )
{
_loggedMissingPiP = true;
Log.Warning( "[OMR SCOPE PHYSICAL] No OMRScopePiPCamera found in the scene." );
}
}
else
{
_loggedMissingPiP = false;
}
}
private void EnsureImageMaterial()
{
if ( string.IsNullOrWhiteSpace( ImageShaderPath ) )
return;
if (
_imageMaterial is not null &&
_imageMaterial.IsValid &&
string.Equals( _loadedShaderPath, ImageShaderPath, StringComparison.OrdinalIgnoreCase )
)
{
return;
}
_imageMaterial = Material.FromShader( ImageShaderPath );
_loadedShaderPath = ImageShaderPath;
}
private void EnsureGlassMaterial()
{
if ( string.IsNullOrWhiteSpace( GlassShaderPath ) )
return;
if (
_glassMaterial is not null &&
_glassMaterial.IsValid &&
string.Equals( _loadedGlassShaderPath, GlassShaderPath, StringComparison.OrdinalIgnoreCase )
)
{
return;
}
_glassMaterial = Material.FromShader( GlassShaderPath );
_loadedGlassShaderPath = GlassShaderPath;
}
private void ApplyPhysicalImageState()
{
if ( ImageRenderer is null || !ImageRenderer.IsValid )
{
SetPhysicalGlassVisible( false );
RestoreLegacyWorldPanelRenderState();
return;
}
Texture texture = PiPCamera?.OutputTexture;
OMROpticProfile profile = PiPCamera?.ActiveProfile;
IWeaponOpticState optic = PiPCamera?.ActiveOpticState;
bool shouldShow =
EnablePhysicalImage &&
PiPCamera is not null &&
PiPCamera.IsValid &&
PiPCamera.IsRendering &&
texture is not null &&
texture.IsValid &&
profile is not null &&
profile.IsValid &&
profile.PresentationMode == OMROpticPresentationMode.ModelLensPictureInPicture &&
_imageMaterial is not null &&
_imageMaterial.IsValid;
ImageRenderer.Enabled = shouldShow;
ApplyLegacyWorldPanelVisibility( shouldShow );
ApplyPhysicalGlassState( shouldShow, profile, optic );
if ( shouldShow != _lastVisible )
{
_lastVisible = shouldShow;
if ( LogStateChanges )
{
Log.Info(
$"[OMR SCOPE PHYSICAL] Visible:{shouldShow} | " +
$"Texture:{(texture is not null && texture.IsValid ? texture.Width : 0)} | " +
$"Aim:{optic?.AimFraction ?? 0f:0.00}"
);
}
}
if ( !shouldShow )
return;
// Re-apply the override after enable-state changes. Renderer attributes are
// runtime-only, so they are intentionally refreshed every frame below.
if ( ImageRenderer.MaterialOverride != _imageMaterial )
ImageRenderer.MaterialOverride = _imageMaterial;
float aimFraction = optic?.AimFraction ?? 0f;
float erectBlend = ResolveBlend(
aimFraction,
profile.LensErectStart,
profile.LensErectFull,
profile.HipPreviewOrientation != profile.AdsPreviewOrientation
);
Vector2 hipFlip = OrientationToFlip( profile.HipPreviewOrientation );
Vector2 adsFlip = OrientationToFlip( profile.AdsPreviewOrientation );
float midpoint = 4f * erectBlend * (1f - erectBlend);
float transitionDarkening = midpoint * MathX.Clamp(
profile.LensErectTransitionDarkening,
0f,
0.75f
);
ImageRenderer.Attributes.Set( "ScopeTexture", texture );
ImageRenderer.Attributes.Set( "ScopeHipFlip", hipFlip );
ImageRenderer.Attributes.Set( "ScopeAdsFlip", adsFlip );
ImageRenderer.Attributes.Set( "ScopeErectBlend", erectBlend );
ImageRenderer.Attributes.Set( "ScopeTransitionDarkening", transitionDarkening );
ApplyWorldLookAttributes( texture );
ApplyChromaticAberrationAttributes( profile, aimFraction );
ApplyPhysicalReticleAttributes( profile, aimFraction );
ApplyPhysicalOpticalMaskAttributes( profile, aimFraction );
}
private void ApplyWorldLookAttributes( Texture texture )
{
if ( ImageRenderer is null || !ImageRenderer.IsValid )
return;
// OMRVisualFoundation is now the single source of truth for scope
// inheritance and independent scope treatment. Do not gate it again
// here; the foundation's ScopeInheritWorldLook setting decides whether
// the PiP mirrors the world or uses its own profile.
OMRVisualFoundation visual =
_visualFoundation is not null && _visualFoundation.IsValid
? _visualFoundation
: null;
float blend =
visual?.EffectiveScopeLookBlend ?? 0f;
ImageRenderer.Attributes.Set(
"ScopeWorldLookBlend",
blend
);
ImageRenderer.Attributes.Set(
"ScopeWorldLookPixelateScale",
visual?.EffectiveScopePixelateScale ?? 0f
);
ImageRenderer.Attributes.Set(
"ScopeWorldLookColorEnabled",
visual?.EffectiveScopeColorAdjustmentsEnabled == true ? 1f : 0f
);
ImageRenderer.Attributes.Set(
"ScopeWorldLookBrightness",
visual?.EffectiveScopeBrightness ?? 1f
);
ImageRenderer.Attributes.Set(
"ScopeWorldLookContrast",
visual?.EffectiveScopeContrast ?? 1f
);
ImageRenderer.Attributes.Set(
"ScopeWorldLookSaturation",
visual?.EffectiveScopeSaturation ?? 1f
);
ImageRenderer.Attributes.Set(
"ScopeWorldLookHueRotate",
visual?.EffectiveScopeHueRotate ?? 0f
);
ImageRenderer.Attributes.Set(
"ScopeWorldLookGrainIntensity",
visual?.EffectiveScopeFilmGrainIntensity ?? 0f
);
ImageRenderer.Attributes.Set(
"ScopeWorldLookGrainResponse",
visual?.EffectiveScopeFilmGrainResponse ?? 0.5f
);
float resolution =
PiPCamera is not null && PiPCamera.IsValid && PiPCamera.OutputResolution > 0
? PiPCamera.OutputResolution
: texture?.Width ?? 512;
ImageRenderer.Attributes.Set(
"ScopeWorldLookResolution",
MathF.Max( resolution, 1f )
);
}
private void ApplyChromaticAberrationAttributes(
OMROpticProfile profile,
float aimFraction
)
{
if ( ImageRenderer is null || !ImageRenderer.IsValid )
return;
float strengthUv = 0f;
if ( EnableChromaticAberration )
{
float adsBlend = ResolveBlend(
aimFraction,
profile.LensClarityStart,
profile.LensClarityFull,
true
);
float separationPixels = MathX.Lerp(
MathF.Max( ChromaticAberrationHipPixels, 0f ),
MathF.Max( ChromaticAberrationAdsPixels, 0f ),
adsBlend
);
float reference = MathF.Max( ChromaticAberrationReferenceResolution, 1f );
strengthUv = separationPixels / reference;
}
ImageRenderer.Attributes.Set(
"ScopeChromaticAberrationStrength",
MathF.Max( strengthUv, 0f )
);
ImageRenderer.Attributes.Set(
"ScopeChromaticAberrationStartRadius",
MathX.Clamp( ChromaticAberrationStartRadius, 0f, 0.95f )
);
ImageRenderer.Attributes.Set(
"ScopeChromaticAberrationEdgePower",
MathX.Clamp( ChromaticAberrationEdgePower, 0.25f, 4f )
);
}
private void ApplyPhysicalGlassState(
bool physicalImageVisible,
OMROpticProfile profile,
IWeaponOpticState optic
)
{
bool shouldShow =
EnablePhysicalGlass &&
physicalImageVisible &&
GlassRenderer is not null &&
GlassRenderer.IsValid &&
_glassMaterial is not null &&
_glassMaterial.IsValid;
SetPhysicalGlassVisible( shouldShow );
if ( !shouldShow )
return;
if ( GlassRenderer.MaterialOverride != _glassMaterial )
GlassRenderer.MaterialOverride = _glassMaterial;
float aimFraction = optic?.AimFraction ?? 0f;
float clarityBlend = ResolveBlend(
aimFraction,
profile.LensClarityStart,
profile.LensClarityFull,
profile.EnableLensSimulation
);
float tintOpacity = 0f;
float reflectionOpacity = 0f;
if ( profile.EnableLensSimulation )
{
tintOpacity = MathX.Lerp(
MathX.Clamp( profile.LensHipTintOpacity, 0f, 1f ),
MathX.Clamp( profile.LensAdsTintOpacity, 0f, 1f ),
clarityBlend
) * MathF.Max( GlassTintScale, 0f );
reflectionOpacity = MathX.Lerp(
MathX.Clamp( profile.LensHipReflectionOpacity, 0f, 1f ),
MathX.Clamp( profile.LensAdsReflectionOpacity, 0f, 1f ),
clarityBlend
) * MathF.Max( GlassReflectionScale, 0f );
}
Color tint = profile.LensTintColor;
GlassRenderer.Attributes.Set(
"ScopeGlassTint",
new Vector4( tint.r, tint.g, tint.b, tint.a )
);
GlassRenderer.Attributes.Set( "ScopeGlassTintOpacity", MathX.Clamp( tintOpacity, 0f, 1f ) );
GlassRenderer.Attributes.Set( "ScopeGlassReflectionOpacity", MathX.Clamp( reflectionOpacity, 0f, 1f ) );
GlassRenderer.Attributes.Set( "ScopeGlassRefractionStrength", MathX.Clamp( GlassRefractionStrength, 1f, 1.05f ) );
GlassRenderer.Attributes.Set( "ScopeGlassRoughness", MathX.Clamp( GlassRoughness, 0f, 1f ) );
GlassRenderer.Attributes.Set( "ScopeGlassBlurAmount", MathX.Clamp( GlassBlurAmount, 0f, 1f ) );
GlassRenderer.Attributes.Set( "ScopeGlassIridescence", MathX.Clamp( GlassIridescence, 0f, 1000f ) );
GlassRenderer.Attributes.Set( "ScopeGlassIridescenceScale", MathF.Max( GlassIridescenceScale, 0f ) );
}
private void ApplyPhysicalOpticalMaskAttributes(
OMROpticProfile profile,
float aimFraction
)
{
if ( ImageRenderer is null || !ImageRenderer.IsValid )
return;
float clarityBlend = ResolveBlend(
aimFraction,
profile.LensClarityStart,
profile.LensClarityFull,
profile.EnableLensSimulation
);
float edgeDarkening = 0f;
if ( profile.EnableLensSimulation )
{
edgeDarkening = MathX.Lerp(
MathX.Clamp( profile.LensHipEdgeDarkening, 0f, 1f ),
MathX.Clamp( profile.LensAdsEdgeDarkening, 0f, 1f ),
clarityBlend
) * MathF.Max( GlassEdgeDarkeningScale, 0f );
}
float eyeBoxShadow = 0f;
Vector2 eyeBoxOffset = Vector2.Zero;
if ( profile.EnableEyeBoxSimulation && PiPCamera is not null && PiPCamera.IsValid )
{
float misalignment =
1f - MathX.Clamp( PiPCamera.CurrentEyeBoxAlignment, 0f, 1f );
eyeBoxShadow =
MathX.Clamp( profile.EyeBoxShadowStrength, 0f, 1f ) *
misalignment *
MathF.Max( GlassEyeBoxShadowScale, 0f );
float shift =
MathX.Clamp( profile.EyeBoxGradientShiftPercent, 0f, 35f ) / 100f;
eyeBoxOffset = PiPCamera.CurrentEyeBoxOffset * shift;
}
float innerRadius = MathX.Clamp( ScopeShadowInnerRadius, 0f, 1.5f );
float outerRadius = MathX.Clamp(
ScopeShadowOuterRadius,
innerRadius + 0.001f,
1.5f
);
ImageRenderer.Attributes.Set(
"ScopeLensEdgeDarkening",
MathX.Clamp( edgeDarkening, 0f, 1f )
);
ImageRenderer.Attributes.Set(
"ScopeEyeBoxShadow",
MathX.Clamp( eyeBoxShadow, 0f, 1f )
);
ImageRenderer.Attributes.Set( "ScopeEyeBoxOffset", eyeBoxOffset );
ImageRenderer.Attributes.Set(
"ScopeEyeBoxParallaxScale",
MathF.Max( ScopeShadowParallaxScale, 0f )
);
ImageRenderer.Attributes.Set( "ScopeEyeBoxInnerRadius", innerRadius );
ImageRenderer.Attributes.Set( "ScopeEyeBoxOuterRadius", outerRadius );
}
private void SetPhysicalGlassVisible( bool visible )
{
if ( GlassRenderer is null || !GlassRenderer.IsValid )
{
_lastGlassVisible = false;
return;
}
GlassRenderer.Enabled = visible;
if ( visible == _lastGlassVisible )
return;
_lastGlassVisible = visible;
if ( LogStateChanges )
{
Log.Info( $"[OMR SCOPE PHYSICAL] GlassVisible:{visible}" );
}
}
private void ApplyPhysicalReticleAttributes( OMROpticProfile profile, float aimFraction )
{
float opacity = 0f;
float outlineOpacity = 0f;
float thickness = MathF.Max( profile.SimpleReticleThickness, 0.5f );
float gap = MathF.Max( profile.SimpleReticleGap, 0f );
float lineLength = MathF.Max( profile.SimpleReticleLineLength, 1f );
float dotSize = MathF.Max( profile.SimpleReticleDotSize, 0f );
float outlineWidth = MathF.Max( profile.SimpleReticleOutlineWidth, 0f );
float dotEnabled = profile.SimpleReticleStyle == OMRScopeReticleStyle.DotCross ? 1f : 0f;
if ( profile.SimpleReticleStyle == OMRScopeReticleStyle.Duplex )
{
// Preserve the exact visual convention used by the legacy
// OMRScopeReticlePanel so migration does not retune the optic.
thickness *= 1.75f;
gap *= 1.5f;
}
if ( EnablePhysicalReticle && profile.ShowSimpleReticle )
{
float clarityBlend = ResolveBlend(
aimFraction,
profile.LensClarityStart,
profile.LensClarityFull,
profile.EnableLensSimulation
);
opacity = MathX.Lerp(
MathX.Clamp( profile.ReticleHipOpacity, 0f, 1f ),
MathX.Clamp( profile.ReticleAdsOpacity, 0f, 1f ),
clarityBlend
);
outlineOpacity = MathX.Clamp(
profile.SimpleReticleOutlineOpacity,
0f,
1f
) * opacity;
}
float reference = MathF.Max( ReticleReferenceResolution, 1f );
Color reticleColor = profile.SimpleReticleColor;
ImageRenderer.Attributes.Set(
"ScopeReticleColor",
new Vector4( reticleColor.r, reticleColor.g, reticleColor.b, reticleColor.a )
);
ImageRenderer.Attributes.Set( "ScopeReticleOpacity", opacity );
ImageRenderer.Attributes.Set( "ScopeReticleThickness", thickness / reference );
ImageRenderer.Attributes.Set( "ScopeReticleGap", gap / reference );
ImageRenderer.Attributes.Set( "ScopeReticleLineLength", lineLength / reference );
ImageRenderer.Attributes.Set( "ScopeReticleDotSize", dotSize / reference );
ImageRenderer.Attributes.Set( "ScopeReticleDotEnabled", dotEnabled );
ImageRenderer.Attributes.Set( "ScopeReticleOutlineWidth", outlineWidth / reference );
ImageRenderer.Attributes.Set( "ScopeReticleOutlineOpacity", outlineOpacity );
}
private void CaptureLegacyWorldPanelRenderState()
{
if (
_legacyRenderStateCaptured ||
LegacyWorldPanel is null ||
!LegacyWorldPanel.IsValid
)
{
return;
}
// WorldPanel inherits Component. E19 only reads the component's authored
// PanelSize and transform, so it can remain a valid measurement proxy while
// disabled. Disabling the renderer is also the most reliable way to prevent
// coplanar z-fighting with ScopeImagePlane across current S&box builds.
_legacyEnabled = LegacyWorldPanel.Enabled;
_legacyRenderStateCaptured = true;
}
private void ApplyLegacyWorldPanelVisibility( bool physicalImageVisible )
{
if (
LegacyWorldPanel is null ||
!LegacyWorldPanel.IsValid ||
!_legacyRenderStateCaptured
)
{
return;
}
bool shouldSuppress = HideLegacyWorldPanelWhenPhysicalImage && physicalImageVisible;
if ( shouldSuppress == _legacyWasSuppressed )
return;
_legacyWasSuppressed = shouldSuppress;
LegacyWorldPanel.Enabled = shouldSuppress ? false : _legacyEnabled;
}
private void RestoreLegacyWorldPanelRenderState()
{
if (
LegacyWorldPanel is null ||
!LegacyWorldPanel.IsValid ||
!_legacyRenderStateCaptured ||
!_legacyWasSuppressed
)
{
return;
}
_legacyWasSuppressed = false;
LegacyWorldPanel.Enabled = _legacyEnabled;
}
private void DisablePhysicalPresentation()
{
if ( ImageRenderer is not null && ImageRenderer.IsValid )
ImageRenderer.Enabled = false;
SetPhysicalGlassVisible( false );
_lastVisible = false;
RestoreLegacyWorldPanelRenderState();
}
private static float ResolveBlend(
float value,
float startFraction,
float fullFraction,
bool usesTransition
)
{
if ( !usesTransition )
return 1f;
float start = MathX.Clamp( startFraction, 0f, 1f );
float full = MathX.Clamp( fullFraction, start, 1f );
float clampedValue = MathX.Clamp( value, 0f, 1f );
if ( full <= start + 0.0001f )
return clampedValue >= full ? 1f : 0f;
return MathX.Clamp( (clampedValue - start) / (full - start), 0f, 1f );
}
private static Vector2 OrientationToFlip( OMRScopePreviewOrientation orientation )
{
return orientation switch
{
OMRScopePreviewOrientation.Rotate180 => new Vector2( 1f, 1f ),
OMRScopePreviewOrientation.FlipHorizontal => new Vector2( 1f, 0f ),
OMRScopePreviewOrientation.FlipVertical => new Vector2( 0f, 1f ),
_ => Vector2.Zero
};
}
}