OMRScopePiPCamera.cs
using Sandbox;
using Sandbox.Rendering;
using System;
using System.Linq;
/// <summary>
/// Local-only picture-in-picture scope camera.
///
/// Supports both the screen-space debug/fallback lens and the final model-space
/// lens path. In model-space mode the secondary camera may take its position from
/// an authored helper inside the active viewmodel while keeping rotation tied to
/// the authoritative gameplay aim. This gives us a physically located scope
/// camera without allowing cosmetic viewmodel sway to make the reticle lie.
/// </summary>
[Title( "OMR Scope PiP Camera" )]
public sealed class OMRScopePiPCamera : Component
{
private const string DefaultViewModelTag = "viewmodel";
[Property, Group( "References" )]
public CameraComponent SourceCamera { get; set; }
[Property, Group( "Filtering" )]
public string ViewModelTag { get; set; } = DefaultViewModelTag;
[Property, Group( "Filtering" )]
[Description( "Tag used by the local first-person body. Excluded so the scope does not render viewer-only local geometry." )]
public string ViewerTag { get; set; } = "viewer";
[Property, Group( "Rendering" ), Range( 0, 8 )]
[Description( "Render-order offset relative to the gameplay camera. Clamped to S&box's supported camera priority range." )]
public int PriorityOffset { get; set; } = 2;
[Property, Group( "Diagnostics" )]
public bool LogSetup { get; set; } = false;
[Property, Group( "Diagnostics" )]
public bool LogStateChanges { get; set; } = false;
[Property, Group( "Diagnostics" )]
public bool LogMissingAnchor { get; set; } = false;
public CameraComponent ScopeCamera => _scopeCamera;
public Texture OutputTexture => _outputTexture;
public int OutputResolution => _outputResolution;
/// <summary>Optical target FOV implied by the optic profile.</summary>
public float CurrentScopeFieldOfView { get; private set; }
/// <summary>FOV actually submitted to RenderToTexture this frame.</summary>
public float CurrentRenderFieldOfView { get; private set; }
/// <summary>Gameplay FOV before PiP peripheral presentation scaling, used as the fixed optical reference.</summary>
public float CurrentOpticalReferenceFieldOfView { get; private set; }
/// <summary>FOV of the camera that actually renders the physical viewmodel/lens mesh.</summary>
public float CurrentLensProjectionFieldOfView { get; private set; }
/// <summary>Requested/effective angular magnification of the optic.</summary>
public float CurrentScopeMagnification { get; private set; } = 1f;
/// <summary>
/// Legacy presentation scale. Phase 5E15 performs deterministic magnification
/// inside RenderToTexture via ViewSetup.ClipSpaceBounds, so model-lens optics
/// normally leave this at 1.
/// </summary>
public float PresentationZoomScale { get; private set; } = 1f;
/// <summary>Centered clip-space sub-rectangle submitted to the manual scope render.</summary>
public Vector4? CurrentClipSpaceBounds { get; private set; }
/// <summary>World-space separation between the gameplay eye and the final PiP render-camera position.</summary>
public float CurrentEyeToScopeCameraDistance { get; private set; }
/// <summary>World-space separation between the gameplay eye and the physical authored optic anchor before any ADS camera-position blend.</summary>
public float CurrentEyeToPhysicalOpticDistance { get; private set; }
/// <summary>0 = PiP camera remains at the physical optic anchor, 1 = PiP camera has blended to the gameplay eye.</summary>
public float CurrentPiPPositionBlend { get; private set; }
/// <summary>Angular eye-to-optic-axis error in degrees, measured from the gameplay view basis.</summary>
public float CurrentEyeBoxAngularError { get; private set; }
/// <summary>1 = eye centered in the optic's authored eye box, 0 = at or beyond the configured full-shadow angle.</summary>
public float CurrentEyeBoxAlignment { get; private set; } = 1f;
/// <summary>Normalized right/up displacement of the optic relative to the eye at the configured full-shadow angle.</summary>
public Vector2 CurrentEyeBoxOffset { get; private set; } = Vector2.Zero;
/// <summary>Estimated inner-lens diameter as a fraction of screen width for the current ADS pose/FOV.</summary>
public float CurrentLensScreenFraction { get; private set; } = 1f;
/// <summary>True when CurrentLensScreenFraction came from live model-space geometry rather than the authored fallback values.</summary>
public bool IsLensScreenFractionMeasured { get; private set; }
/// <summary>Identifies which geometry source produced the current automatic lens-size measurement.</summary>
public string CurrentLensMeasurementSource { get; private set; } = "AuthoredFallback";
/// <summary>Unsmoothed raw screen fraction from the current model-space lens projection. Useful for diagnostics/tuning.</summary>
public float CurrentRawMeasuredLensScreenFraction { get; private set; }
/// <summary>Magnification we want the player to perceive inside the on-screen lens.</summary>
public float CurrentTargetApparentMagnification { get; private set; } = 1f;
/// <summary>Clip-space magnification actually submitted to RenderToTexture.</summary>
public float CurrentRenderCropMagnification { get; private set; } = 1f;
/// <summary>Expected visible magnification after accounting for the lens's on-screen size.</summary>
public float CurrentExpectedApparentMagnification { get; private set; } = 1f;
public bool LastManualRenderSucceeded { get; private set; }
public GameObject ResolvedCameraAnchor => _resolvedCameraAnchor;
public bool IsRendering =>
_renderRequested &&
_scopeCamera is not null &&
_scopeCamera.IsValid &&
_outputTexture is not null &&
_outputTexture.IsValid;
public BaseInventoryComponent LocalInventory => ResolveLocalInventory();
public OneMoreRoundWeapon ActiveWeapon => LocalInventory?.ActiveItem as OneMoreRoundWeapon;
public IWeaponOpticState ActiveOpticState =>
LocalInventory?.ActiveItem as IWeaponOpticState;
public OMROpticProfile ActiveProfile =>
ActiveOpticState?.ActiveOpticProfile;
private GameObject _scopeCameraObject;
private CameraComponent _scopeCamera;
private Texture _outputTexture;
private int _outputResolution;
private PlayerState _localPlayer;
private BaseInventoryComponent _localInventory;
private bool _renderRequested;
private bool _lastRenderingState;
private float _nextHipfirePreviewAt;
private GameObject _anchorViewModel;
private string _anchorName;
private GameObject _resolvedCameraAnchor;
private bool _loggedMissingAnchor;
private GameObject _lensMeasureViewModel;
private string _lensMeasureObjectName;
private GameObject _resolvedLensMeasureObject;
private ModelRenderer _resolvedLensImageRenderer;
private Sandbox.WorldPanel _resolvedLensWorldPanel;
private float _smoothedMeasuredLensScreenFraction = -1f;
protected override void OnStart()
{
ResolveSourceCamera();
if ( SourceCamera is null || !SourceCamera.IsValid )
{
Log.Warning( "[OMR SCOPE PIP] No valid source CameraComponent was found." );
Enabled = false;
return;
}
CreateScopeCamera();
SetRenderingState( false );
if ( LogSetup )
{
Log.Info(
$"[OMR SCOPE PIP] Ready | Source:{SourceCamera.GameObject.Name} | " +
$"WorldPriority:{SourceCamera.Priority}"
);
}
}
protected override void OnUpdate()
{
ResolveSourceCamera();
if ( SourceCamera is null || !SourceCamera.IsValid )
{
SetRenderingState( false );
return;
}
if ( _scopeCamera is null || !_scopeCamera.IsValid )
CreateScopeCamera();
if ( _scopeCamera is null || !_scopeCamera.IsValid )
{
SetRenderingState( false );
return;
}
IWeaponOpticState optic = ActiveOpticState;
OMROpticProfile profile = optic?.ActiveOpticProfile;
bool usesPiP =
profile is not null &&
(
profile.PresentationMode == OMROpticPresentationMode.PictureInPicture ||
profile.PresentationMode == OMROpticPresentationMode.ModelLensPictureInPicture
);
bool shouldRender =
optic is not null &&
profile is not null &&
profile.IsValid &&
profile.Mode == OMROpticMode.MagnifiedScope &&
usesPiP &&
(
profile.KeepPiPWarmWhileEquipped ||
optic.IsScopePresentationActive
);
if ( shouldRender )
{
EnsureRenderTarget( profile );
ConfigureScopeCameraStatic( profile );
}
else
{
ClearResolvedAnchor();
}
SetRenderingState(
shouldRender &&
_outputTexture is not null &&
_outputTexture.IsValid
);
}
/// <summary>
/// CameraComponent.View is finalized after Update and before PreRender. This
/// lets us mirror the fully composed gameplay aim and projection here.
///
/// For a physical model-space scope we can replace only the camera position
/// with an authored viewmodel anchor. Rotation remains gameplay-authoritative
/// by default so cosmetic gun sway cannot desynchronize the reticle from the
/// actual shot direction.
/// </summary>
protected override void OnPreRender()
{
if ( !IsRendering )
return;
IWeaponOpticState optic = ActiveOpticState;
OMROpticProfile profile = optic?.ActiveOpticProfile;
if (
optic is null ||
profile is null ||
!profile.IsValid ||
SourceCamera is null ||
!SourceCamera.IsValid ||
_scopeCamera is null ||
!_scopeCamera.IsValid ||
_scopeCameraObject is null ||
!_scopeCameraObject.IsValid
)
{
return;
}
int previewRate = Math.Clamp( OMRClientPerformance.ScopePreviewFrameRate, 0, 240 );
bool hipfireOnly = !optic.IsAiming && optic.AimFraction <= 0.001f;
if ( hipfireOnly && previewRate > 0 && LastManualRenderSucceeded && RealTime.Now < _nextHipfirePreviewAt ) return;
_nextHipfirePreviewAt = hipfireOnly && previewRate > 0 ? RealTime.Now + 1f / previewRate : 0f;
CameraView source = SourceCamera.View;
Vector3 sourcePosition = source.FieldOfView > 1f
? source.Position
: SourceCamera.WorldPosition;
Rotation sourceRotation = source.FieldOfView > 1f
? source.Rotation
: SourceCamera.WorldRotation;
float sourceFov = source.FieldOfView > 1f
? source.FieldOfView
: SourceCamera.FieldOfView;
// PeripheralAimFovScale is a presentation-only zoom on the ordinary world
// camera. Recover the pre-peripheral FOV before deriving the physical optic
// projection so a "4x" scope remains authored as the same 4x optic even if
// the surrounding world receives a subtle focus zoom.
float peripheralScale = MathX.Clamp(
optic.CurrentPeripheralAimFovScale,
0.5f,
1.5f
);
float opticalReferenceFov = MathX.Clamp(
sourceFov / MathF.Max( peripheralScale, 0.0001f ),
1f,
179f
);
OMRViewModelCamera viewModelCamera =
SourceCamera.GameObject.Components.Get<OMRViewModelCamera>();
float lensProjectionFov =
viewModelCamera is not null &&
viewModelCamera.IsValid
? viewModelCamera.ResolveViewModelFieldOfView( sourceFov, optic )
: sourceFov;
CurrentOpticalReferenceFieldOfView = opticalReferenceFov;
CurrentLensProjectionFieldOfView = lensProjectionFov;
float sourceZNear = source.ZNear > 0f
? source.ZNear
: SourceCamera.ZNear;
float sourceZFar = source.ZFar > sourceZNear
? source.ZFar
: SourceCamera.ZFar;
ResolveCameraPose(
profile,
optic.AimFraction,
sourcePosition,
sourceRotation,
out Vector3 scopePosition,
out Rotation scopeRotation,
out Vector3 physicalOpticPosition
);
UpdateEyeBoxState(
profile,
sourcePosition,
sourceRotation,
physicalOpticPosition
);
// Keep the proven Phase 5E8 / Facepunch CameraWeapon lifecycle: the
// render-target camera receives its final raw transform and projection in
// PreRender, after the gameplay camera has composed and after the M700's
// viewmodel anchors have been published for this frame. CameraComponent's
// current contract explicitly allows a camera changed after composition to
// render from that raw state. This avoids letting unrelated camera modifiers
// participate in the off-screen optic camera.
CurrentScopeFieldOfView = OMROpticMath.ResolveRenderedScopeFieldOfView(
opticalReferenceFov,
profile,
optic.AimFraction
);
CurrentScopeMagnification = OMROpticMath.CalculateAngularMagnification(
opticalReferenceFov,
CurrentScopeFieldOfView
);
// Phase 5E14 proved that scaling the custom Painter-backed panel is not a
// reliable way to magnify the live RT. S&box exposes ClipSpaceBounds on
// ViewSetup specifically to render a smaller centered sub-rectangle of the
// frustum (i.e. zoom into clip space). Do the crop inside RenderToTexture so
// the physical lens consumer receives pixels that are already magnified.
bool renderClipCrop = profile.UsePresentationCropForMagnification;
CurrentRenderFieldOfView = renderClipCrop
? opticalReferenceFov
: CurrentScopeFieldOfView;
PresentationZoomScale = 1f;
CurrentClipSpaceBounds = null;
CurrentLensScreenFraction = 1f;
IsLensScreenFractionMeasured = false;
CurrentLensMeasurementSource = "AuthoredFallback";
CurrentRawMeasuredLensScreenFraction = 0f;
CurrentTargetApparentMagnification = CurrentScopeMagnification;
CurrentRenderCropMagnification = CurrentScopeMagnification;
CurrentExpectedApparentMagnification = CurrentScopeMagnification;
if ( renderClipCrop )
{
float cropMagnification = MathF.Max( CurrentScopeMagnification, 1f );
if ( profile.CompensateForModelLensScreenSize )
{
float apparentBlend = ResolveApparentMagnificationBlend(
optic.AimFraction,
profile.ApparentMagnificationBlendStart,
profile.ApparentMagnificationBlendFull
);
float currentHorizontalFov = ResolveHorizontalFieldOfView(
lensProjectionFov,
SourceCamera.FovAxis
);
// Always calculate the authored fallback first. Besides giving us a
// safe fallback, it provides a sanity envelope for live measurement so
// a malformed panel transform cannot suddenly turn a working 4x optic
// into a 40x microscope.
float referenceLensFraction = MathX.Lerp(
MathX.Clamp( profile.HipLensScreenFraction, 0.02f, 0.8f ),
MathX.Clamp( profile.AdsLensScreenFraction, 0.02f, 0.8f ),
apparentBlend
);
float referenceHorizontalFov = MathX.Clamp(
profile.LensFractionReferenceFov,
30f,
140f
);
float referenceTan = MathF.Tan( referenceHorizontalFov * (MathF.PI / 360f) );
float currentTan = MathF.Tan( currentHorizontalFov * (MathF.PI / 360f) );
float fovSizeScale = MathF.Abs( currentTan ) > 0.00001f
? referenceTan / currentTan
: 1f;
float authoredCurrentLensFraction = MathX.Clamp(
referenceLensFraction * fovSizeScale,
0.02f,
0.95f
);
float measuredFraction = 0f;
bool measuredLensFraction =
profile.AutoMeasureModelLensScreenFraction &&
TryMeasureModelLensScreenFraction(
profile,
sourcePosition,
sourceRotation,
currentHorizontalFov,
out measuredFraction
);
// The live value should stay in the same rough order of magnitude as
// the authored fallback. This guard makes the feature fail-safe while
// still allowing large intentional lens-size edits in the prefab.
if ( measuredLensFraction )
{
float minPlausible = authoredCurrentLensFraction * 0.4f;
float maxPlausible = authoredCurrentLensFraction * 2.5f;
measuredLensFraction =
measuredFraction >= minPlausible &&
measuredFraction <= maxPlausible;
}
if ( measuredLensFraction )
{
CurrentRawMeasuredLensScreenFraction = measuredFraction;
IsLensScreenFractionMeasured = true;
float targetMeasured = MathX.Clamp(
measuredFraction * MathX.Clamp( profile.AutoMeasuredLensFractionScale, 0.5f, 1.5f ),
0.02f,
0.95f
);
float smoothingSpeed = MathX.Clamp(
profile.AutoMeasuredLensFractionSmoothingSpeed,
0f,
40f
);
if ( _smoothedMeasuredLensScreenFraction < 0f || smoothingSpeed <= 0f )
{
_smoothedMeasuredLensScreenFraction = targetMeasured;
}
else
{
float alpha = 1f - MathF.Exp( -smoothingSpeed * MathF.Max( Time.Delta, 0f ) );
_smoothedMeasuredLensScreenFraction = MathX.Lerp(
_smoothedMeasuredLensScreenFraction,
targetMeasured,
MathX.Clamp( alpha, 0f, 1f )
);
}
CurrentLensScreenFraction = MathX.Clamp(
_smoothedMeasuredLensScreenFraction,
0.02f,
0.95f
);
}
else
{
CurrentLensMeasurementSource = "AuthoredFallback";
CurrentLensScreenFraction = authoredCurrentLensFraction;
}
CurrentTargetApparentMagnification = MathX.Lerp(
MathF.Max( profile.HipApparentMagnification, 0.5f ),
MathF.Max( profile.NominalMagnification, 1f ),
apparentBlend
);
// A PiP image that occupies only a fraction F of the monitor needs an
// additional 1/F projection crop to produce the requested apparent
// magnification on the player's screen. Example: a lens that is 20%
// of screen width needs about a 20x render crop to look 4x relative
// to the surrounding 1x world: 0.20 * 20 = 4.
cropMagnification = CurrentTargetApparentMagnification /
MathF.Max( CurrentLensScreenFraction, 0.02f );
CurrentExpectedApparentMagnification =
CurrentLensScreenFraction * cropMagnification;
}
CurrentRenderCropMagnification = MathX.Clamp( cropMagnification, 1f, 64f );
if ( profile.CompensateForModelLensScreenSize )
{
CurrentExpectedApparentMagnification =
CurrentLensScreenFraction * CurrentRenderCropMagnification;
}
if ( CurrentRenderCropMagnification > 1.0001f )
{
CurrentClipSpaceBounds = BuildCenteredClipSpaceBounds(
CurrentRenderCropMagnification
);
}
}
CurrentEyeToScopeCameraDistance = Vector3.DistanceBetween(
sourcePosition,
scopePosition
);
CurrentEyeToPhysicalOpticDistance = Vector3.DistanceBetween(
sourcePosition,
physicalOpticPosition
);
// Keep the component's raw values in sync for diagnostics, but do not let the
// scene scheduler render this camera automatically. The actual optic render
// below uses CameraComponent.RenderToTexture with an explicit ViewSetup.
_scopeCameraObject.WorldPosition = scopePosition;
_scopeCameraObject.WorldRotation = scopeRotation;
_scopeCamera.FieldOfView = CurrentRenderFieldOfView;
_scopeCamera.ZNear = sourceZNear;
_scopeCamera.ZFar = sourceZFar;
_scopeCamera.FovAxis = SourceCamera.FovAxis;
_scopeCamera.BackgroundColor = SourceCamera.BackgroundColor;
Transform renderTransform = new Transform( scopePosition )
.WithRotation( scopeRotation );
ViewSetup renderSetup = new()
{
Transform = renderTransform,
FieldOfView = CurrentRenderFieldOfView,
ZNear = sourceZNear,
ZFar = sourceZFar,
EnablePostprocessing = profile.PiPEnablePostProcessing,
ClipSpaceBounds = CurrentClipSpaceBounds,
FlipX = false,
FlipY = false
};
LastManualRenderSucceeded = _scopeCamera.RenderToTexture(
_outputTexture,
renderSetup
);
}
/// <summary>
/// Builds a centered ViewSetup clip-space crop for the requested optical
/// magnification. S&box stores ClipSpaceBounds as
/// (left, down/bottom, right, up/top). The engine's full-frustum default is
/// (-1, -1, +1, +1). Keeping min values below max values is essential; a
/// vertically reversed rectangle can leave the RT showing only its clear
/// color, which is exactly what the E15 regression looked like.
/// </summary>
private static Vector4 BuildCenteredClipSpaceBounds( float magnification )
{
float safeMagnification = MathX.Clamp( magnification, 1f, 64f );
float halfExtent = 1f / safeMagnification;
return new Vector4(
-halfExtent, // left
-halfExtent, // down / bottom
halfExtent, // right
halfExtent // up / top
);
}
private static float ResolveApparentMagnificationBlend(
float aimFraction,
float startFraction,
float fullFraction
)
{
float start = MathX.Clamp( startFraction, 0f, 1f );
float full = MathX.Clamp( fullFraction, start, 1f );
float aim = MathX.Clamp( aimFraction, 0f, 1f );
if ( full <= start + 0.0001f )
return aim >= full ? 1f : 0f;
float t = MathX.Clamp( (aim - start) / (full - start), 0f, 1f );
return t * t * (3f - 2f * t);
}
private static float ResolveHorizontalFieldOfView(
float authoredFov,
CameraComponent.Axis axis
)
{
float fov = MathX.Clamp( authoredFov, 1f, 179f );
if ( axis != CameraComponent.Axis.Vertical )
return fov;
return Screen.CreateVerticalFieldOfView(
fov,
MathF.Max( Screen.Aspect, 0.01f )
);
}
protected override void OnDisabled()
{
SetRenderingState( false );
ClearResolvedAnchor();
DestroyScopeCamera();
DisposeRenderTarget();
base.OnDisabled();
}
protected override void OnDestroy()
{
SetRenderingState( false );
ClearResolvedAnchor();
DestroyScopeCamera();
DisposeRenderTarget();
base.OnDestroy();
}
private void ResolveSourceCamera()
{
if ( SourceCamera is not null && SourceCamera.IsValid )
return;
SourceCamera = Components.Get<CameraComponent>();
SourceCamera ??= Scene?.Camera;
}
private BaseInventoryComponent ResolveLocalInventory()
{
if (
_localPlayer is not null &&
_localPlayer.IsValid &&
!_localPlayer.IsProxy &&
!_localPlayer.IsBot &&
_localInventory is not null &&
_localInventory.IsValid
)
{
return _localInventory;
}
_localPlayer = Scene?
.GetAllComponents<PlayerState>()
.FirstOrDefault(
x =>
x is not null &&
x.IsValid &&
!x.IsProxy &&
!x.IsBot
);
_localInventory = _localPlayer?
.Components
.Get<BaseInventoryComponent>();
return _localInventory;
}
private void CreateScopeCamera()
{
if ( _scopeCamera is not null && _scopeCamera.IsValid )
return;
if (
Scene is null ||
SourceCamera is null ||
!SourceCamera.IsValid
)
{
return;
}
_scopeCameraObject = Scene.CreateObject( true );
_scopeCameraObject.Name = "OMR Scope PiP Camera";
_scopeCameraObject.NetworkMode = NetworkMode.Never;
_scopeCameraObject.Flags |= GameObjectFlags.NotSaved;
_scopeCamera = _scopeCameraObject.Components.Create<CameraComponent>();
_scopeCamera.IsMainCamera = false;
_scopeCamera.Orthographic = false;
_scopeCamera.BackgroundColor = SourceCamera.BackgroundColor;
_scopeCamera.ClearFlags = ClearFlags.Color | ClearFlags.Depth | ClearFlags.Stencil;
_scopeCamera.Enabled = false;
}
private void ConfigureScopeCameraStatic( OMROpticProfile profile )
{
if (
_scopeCamera is null ||
!_scopeCamera.IsValid ||
SourceCamera is null ||
!SourceCamera.IsValid
)
{
return;
}
_scopeCamera.IsMainCamera = false;
_scopeCamera.Orthographic = false;
_scopeCamera.ClearFlags = ClearFlags.Color | ClearFlags.Depth | ClearFlags.Stencil;
_scopeCamera.BackgroundColor = SourceCamera.BackgroundColor;
_scopeCamera.EnablePostProcessing = profile.PiPEnablePostProcessing;
_scopeCamera.FovAxis = SourceCamera.FovAxis;
_scopeCamera.TargetEye = SourceCamera.TargetEye;
_scopeCamera.ZNear = SourceCamera.ZNear;
_scopeCamera.ZFar = SourceCamera.ZFar;
_scopeCamera.Priority = Math.Clamp(
SourceCamera.Priority + PriorityOffset,
1,
16
);
_scopeCamera.Viewport = new Vector4( 0f, 0f, 1f, 1f );
_scopeCamera.CustomSize = new Vector2( _outputResolution, _outputResolution );
_scopeCamera.RenderTarget = null;
_scopeCamera.Enabled = false;
AddExcludeTag( ViewModelTag );
AddExcludeTag( ViewerTag );
}
private void AddExcludeTag( string tag )
{
if (
_scopeCamera is null ||
!_scopeCamera.IsValid ||
string.IsNullOrWhiteSpace( tag ) ||
_scopeCamera.RenderExcludeTags.Has( tag )
)
{
return;
}
_scopeCamera.RenderExcludeTags.Add( tag );
}
private void EnsureRenderTarget( OMROpticProfile profile )
{
int resolution = ResolveDesiredResolution( profile );
if (
_outputTexture is not null &&
_outputTexture.IsValid &&
_outputResolution == resolution
)
{
return;
}
DisposeRenderTarget();
_outputTexture = Texture.CreateRenderTarget()
.WithSize( resolution, resolution )
.WithFormat( ImageFormat.RGBA8888 )
.Create();
_outputResolution = resolution;
_nextHipfirePreviewAt = 0f;
LastManualRenderSucceeded = false;
if ( _scopeCamera is not null && _scopeCamera.IsValid )
{
_scopeCamera.RenderTarget = null;
_scopeCamera.CustomSize = new Vector2( resolution, resolution );
}
}
private int ResolveDesiredResolution( OMROpticProfile profile )
{
IWeaponOpticState optic = ActiveOpticState;
if (
profile.KeepPiPWarmWhileEquipped &&
optic is not null &&
optic.AimFraction < MathX.Clamp( profile.PiPFullResolutionAimFraction, 0f, 1f )
)
{
return Math.Clamp(
profile.PiPPrewarmResolution,
128,
1024
);
}
if ( profile.PiPResolutionMode == OMRPiPResolutionMode.Fixed )
{
return Math.Clamp(
profile.PiPRenderResolution,
256,
2048
);
}
int minimum = Math.Clamp( profile.PiPMinimumResolution, 256, 4096 );
int maximum = Math.Clamp( profile.PiPMaximumResolution, minimum, 4096 );
int quantum = Math.Clamp( profile.PiPResolutionQuantum, 16, 256 );
float desired = MathF.Max( Screen.Height, 1 ) * MathX.Clamp(
profile.PiPScreenHeightScale,
0.15f,
1.5f
);
int quantized = (int)MathF.Ceiling( desired / quantum ) * quantum;
return Math.Clamp( quantized, minimum, maximum );
}
private void ResolveCameraPose(
OMROpticProfile profile,
float aimFraction,
Vector3 sourcePosition,
Rotation sourceRotation,
out Vector3 position,
out Rotation rotation,
out Vector3 physicalOpticPosition
)
{
position = sourcePosition;
rotation = sourceRotation;
physicalOpticPosition = sourcePosition;
CurrentPiPPositionBlend = 1f;
if ( profile.PiPCameraPoseMode == OMRPiPCameraPoseMode.MainCamera )
return;
GameObject anchor = ResolveViewModelCameraAnchor( profile );
if ( anchor is null || !anchor.IsValid )
return;
// The active weapon publishes bone helpers from its final PlaceViewModel root
// before PreRender. Do not force another world-space bone read here: the
// renderer's bone cache was produced before BaseCombatWeapon moved the root
// for this camera frame, so re-reading it can reintroduce a one-frame offset.
physicalOpticPosition = anchor.WorldPosition;
position = physicalOpticPosition;
CurrentPiPPositionBlend = 0f;
if ( profile.BlendPiPPositionToGameplayEye )
{
CurrentPiPPositionBlend = ResolveSmoothBlend(
aimFraction,
profile.PiPPositionBlendStart,
profile.PiPPositionBlendFull
);
position = Vector3.Lerp(
physicalOpticPosition,
sourcePosition,
CurrentPiPPositionBlend
);
}
if ( profile.PiPCameraPoseMode == OMRPiPCameraPoseMode.ViewModelAnchorFullTransform )
rotation = anchor.WorldRotation;
}
private void UpdateEyeBoxState(
OMROpticProfile profile,
Vector3 eyePosition,
Rotation eyeRotation,
Vector3 physicalOpticPosition
)
{
CurrentEyeBoxAngularError = 0f;
CurrentEyeBoxAlignment = 1f;
CurrentEyeBoxOffset = Vector2.Zero;
if ( !profile.EnableEyeBoxSimulation )
return;
Vector3 eyeToOptic = physicalOpticPosition - eyePosition;
float forward = Vector3.Dot( eyeToOptic, eyeRotation.Forward );
float right = Vector3.Dot( eyeToOptic, eyeRotation.Right );
float up = Vector3.Dot( eyeToOptic, eyeRotation.Up );
float depth = MathF.Max( MathF.Abs( forward ), 0.001f );
float rightAngle = MathF.Atan2( right, depth ) * (180f / MathF.PI);
float upAngle = MathF.Atan2( up, depth ) * (180f / MathF.PI);
CurrentEyeBoxAngularError = MathF.Sqrt(
rightAngle * rightAngle +
upAngle * upAngle
);
float perfect = MathX.Clamp(
profile.EyeBoxPerfectAngleDegrees,
0f,
45f
);
float fullShadow = MathX.Clamp(
profile.EyeBoxFullShadowAngleDegrees,
MathF.Max( perfect + 0.001f, 0.001f ),
89f
);
float shadowBlend = ResolveSmoothBlend(
CurrentEyeBoxAngularError,
perfect,
fullShadow
);
CurrentEyeBoxAlignment = 1f - shadowBlend;
float normalizedRight = MathX.Clamp( rightAngle / fullShadow, -1f, 1f );
float normalizedUp = MathX.Clamp( upAngle / fullShadow, -1f, 1f );
float length = MathF.Sqrt(
normalizedRight * normalizedRight +
normalizedUp * normalizedUp
);
if ( length > 1f )
{
normalizedRight /= length;
normalizedUp /= length;
}
CurrentEyeBoxOffset = new Vector2(
normalizedRight,
normalizedUp
);
}
/// <summary>
/// Measure the current physical lens diameter from the ScopeImagePlane renderer.
/// Phase 6C4 prefers the real mesh that actually presents the PiP image, so the
/// legacy WorldPanel can be removed from the prefab without changing apparent
/// magnification. A WorldPanel path remains as a compatibility fallback for any
/// older optic prefab that has not migrated yet.
///
/// Projection stays manual instead of using CameraComponent.PointToScreenPixels:
/// the viewmodel is positioned against the fully composed gameplay view after
/// camera feel/recoil modifiers, and these inputs are that settled view.
/// </summary>
private bool TryMeasureModelLensScreenFraction(
OMROpticProfile profile,
Vector3 eyePosition,
Rotation eyeRotation,
float horizontalFov,
out float fraction
)
{
fraction = 0f;
CurrentLensMeasurementSource = "AuthoredFallback";
ResolveLensMeasurementReferences( profile );
if (
_resolvedLensImageRenderer is not null &&
_resolvedLensImageRenderer.IsValid &&
TryMeasureRendererScreenFraction(
_resolvedLensImageRenderer,
eyePosition,
eyeRotation,
horizontalFov,
out fraction
)
)
{
CurrentLensMeasurementSource = "PhysicalMesh";
return true;
}
if (
_resolvedLensMeasureObject is not null &&
_resolvedLensMeasureObject.IsValid &&
_resolvedLensWorldPanel is not null &&
_resolvedLensWorldPanel.IsValid &&
TryMeasureLegacyWorldPanelScreenFraction(
_resolvedLensMeasureObject,
_resolvedLensWorldPanel,
eyePosition,
eyeRotation,
horizontalFov,
out fraction
)
)
{
CurrentLensMeasurementSource = "LegacyWorldPanel";
return true;
}
return false;
}
private bool TryMeasureRendererScreenFraction(
ModelRenderer renderer,
Vector3 eyePosition,
Rotation eyeRotation,
float horizontalFov,
out float fraction
)
{
fraction = 0f;
if ( renderer is null || !renderer.IsValid || renderer.GameObject is null || !renderer.GameObject.IsValid )
return false;
BBox localBounds = renderer.LocalBounds;
Vector3 localSize = localBounds.Size;
float maxDimension = MathF.Max(
MathF.Abs( localSize.x ),
MathF.Max( MathF.Abs( localSize.y ), MathF.Abs( localSize.z ) )
);
if ( maxDimension <= 0.0001f )
return false;
Transform worldTransform = renderer.GameObject.WorldTransform;
float minX = float.MaxValue;
float maxX = float.MinValue;
foreach ( Vector3 localCorner in localBounds.Corners )
{
Vector3 worldCorner = worldTransform.PointToWorld( localCorner );
if ( !TryProjectHorizontalNormalized(
worldCorner,
eyePosition,
eyeRotation,
horizontalFov,
out float x
) )
{
return false;
}
minX = MathF.Min( minX, x );
maxX = MathF.Max( maxX, x );
}
fraction = MathX.Clamp( maxX - minX, 0f, 1f );
return fraction > 0.001f;
}
private bool TryMeasureLegacyWorldPanelScreenFraction(
GameObject lensObject,
Sandbox.WorldPanel worldPanel,
Vector3 eyePosition,
Rotation eyeRotation,
float horizontalFov,
out float fraction
)
{
fraction = 0f;
float panelScale = Sandbox.UI.WorldPanel.ScreenToWorldScale;
if ( panelScale <= 0f )
return false;
Vector3 worldScale = lensObject.WorldScale;
float halfWidth = worldPanel.PanelSize.x * 0.5f * panelScale * MathF.Max( MathF.Abs( worldScale.y ), 0.0001f );
float halfHeight = worldPanel.PanelSize.y * 0.5f * panelScale * MathF.Max( MathF.Abs( worldScale.z ), 0.0001f );
Vector3 right = lensObject.WorldRotation.Right * halfWidth;
Vector3 up = lensObject.WorldRotation.Up * halfHeight;
Vector3 center = lensObject.WorldPosition;
Vector3[] corners =
{
center - right - up,
center + right - up,
center - right + up,
center + right + up
};
float minX = float.MaxValue;
float maxX = float.MinValue;
for ( int i = 0; i < corners.Length; i++ )
{
if ( !TryProjectHorizontalNormalized(
corners[i],
eyePosition,
eyeRotation,
horizontalFov,
out float x
) )
{
return false;
}
minX = MathF.Min( minX, x );
maxX = MathF.Max( maxX, x );
}
fraction = MathX.Clamp( maxX - minX, 0f, 1f );
return fraction > 0.001f;
}
private void ResolveLensMeasurementReferences( OMROpticProfile profile )
{
OneMoreRoundWeapon weapon = ActiveWeapon;
GameObject viewModel = weapon?.ViewModel;
string objectName = profile?.ViewModelAimAnchorName;
if ( viewModel is null || !viewModel.IsValid || string.IsNullOrWhiteSpace( objectName ) )
{
ClearLensMeasurementReferences();
return;
}
bool cacheStillValid =
_lensMeasureViewModel == viewModel &&
string.Equals( _lensMeasureObjectName, objectName, StringComparison.Ordinal ) &&
_resolvedLensMeasureObject is not null &&
_resolvedLensMeasureObject.IsValid;
if ( cacheStillValid )
{
// Renderer/component references may legitimately be removed while editing a
// prefab, so refresh those lightweight lookups even when the anchor cache is valid.
RefreshLensMeasurementComponents();
return;
}
_lensMeasureViewModel = viewModel;
_lensMeasureObjectName = objectName;
_resolvedLensMeasureObject = viewModel
.GetAllObjects( true )
.FirstOrDefault(
x => x is not null && x.IsValid && string.Equals( x.Name, objectName, StringComparison.OrdinalIgnoreCase )
);
_resolvedLensImageRenderer = null;
_resolvedLensWorldPanel = null;
_smoothedMeasuredLensScreenFraction = -1f;
RefreshLensMeasurementComponents();
}
private void RefreshLensMeasurementComponents()
{
if ( _resolvedLensMeasureObject is null || !_resolvedLensMeasureObject.IsValid )
{
_resolvedLensImageRenderer = null;
_resolvedLensWorldPanel = null;
return;
}
OMRScopePhysicalLens physicalLens = _resolvedLensMeasureObject
.GetComponent<OMRScopePhysicalLens>( true );
ModelRenderer physicalRenderer = physicalLens?.ImageRenderer;
if ( physicalRenderer is null || !physicalRenderer.IsValid )
{
GameObject imageObject = _resolvedLensMeasureObject
.GetAllObjects( true )
.FirstOrDefault(
x => x is not null && x.IsValid && string.Equals( x.Name, "ScopeImagePlane", StringComparison.OrdinalIgnoreCase )
);
physicalRenderer = imageObject?.Components.Get<ModelRenderer>();
}
_resolvedLensImageRenderer =
physicalRenderer is not null && physicalRenderer.IsValid
? physicalRenderer
: null;
_resolvedLensWorldPanel = _resolvedLensMeasureObject
.GetComponent<Sandbox.WorldPanel>( true );
}
private void ClearLensMeasurementReferences()
{
_lensMeasureViewModel = null;
_lensMeasureObjectName = null;
_resolvedLensMeasureObject = null;
_resolvedLensImageRenderer = null;
_resolvedLensWorldPanel = null;
_smoothedMeasuredLensScreenFraction = -1f;
}
private static bool TryProjectHorizontalNormalized(
Vector3 worldPoint,
Vector3 eyePosition,
Rotation eyeRotation,
float horizontalFov,
out float normalizedX
)
{
normalizedX = 0f;
Vector3 delta = worldPoint - eyePosition;
float depth = Vector3.Dot( delta, eyeRotation.Forward );
if ( depth <= 0.001f )
return false;
float lateral = Vector3.Dot( delta, eyeRotation.Right );
float tanHalfFov = MathF.Tan( MathX.Clamp( horizontalFov, 1f, 179f ) * (MathF.PI / 360f) );
if ( MathF.Abs( tanHalfFov ) <= 0.00001f )
return false;
float ndcX = lateral / (depth * tanHalfFov);
normalizedX = 0.5f + ndcX * 0.5f;
return true;
}
private static float ResolveSmoothBlend(
float value,
float startValue,
float fullValue
)
{
float start = MathF.Min( startValue, fullValue );
float full = MathF.Max( startValue, fullValue );
if ( full <= start + 0.0001f )
return value >= full ? 1f : 0f;
float t = MathX.Clamp(
(value - start) / (full - start),
0f,
1f
);
return t * t * (3f - 2f * t);
}
private GameObject ResolveViewModelCameraAnchor( OMROpticProfile profile )
{
OneMoreRoundWeapon weapon = ActiveWeapon;
GameObject viewModel = weapon?.ViewModel;
string anchorName = profile.PiPCameraAnchorName;
if (
viewModel is null ||
!viewModel.IsValid ||
string.IsNullOrWhiteSpace( anchorName )
)
{
ClearResolvedAnchor();
return null;
}
if (
_resolvedCameraAnchor is not null &&
_resolvedCameraAnchor.IsValid &&
_anchorViewModel == viewModel &&
string.Equals( _anchorName, anchorName, StringComparison.Ordinal )
)
{
return _resolvedCameraAnchor;
}
_anchorViewModel = viewModel;
_anchorName = anchorName;
_resolvedCameraAnchor = viewModel
.GetAllObjects( true )
.FirstOrDefault(
x =>
x is not null &&
x.IsValid &&
string.Equals( x.Name, anchorName, StringComparison.OrdinalIgnoreCase )
);
if ( _resolvedCameraAnchor is null || !_resolvedCameraAnchor.IsValid )
{
if ( LogMissingAnchor && !_loggedMissingAnchor )
{
_loggedMissingAnchor = true;
Log.Warning(
$"[OMR SCOPE PIP] Viewmodel '{viewModel.Name}' has no camera anchor named '{anchorName}'. Falling back to the main gameplay camera position."
);
}
return null;
}
_loggedMissingAnchor = false;
return _resolvedCameraAnchor;
}
private void ClearResolvedAnchor()
{
_anchorViewModel = null;
_anchorName = null;
_resolvedCameraAnchor = null;
_loggedMissingAnchor = false;
ClearLensMeasurementReferences();
IsLensScreenFractionMeasured = false;
CurrentLensMeasurementSource = "AuthoredFallback";
CurrentRawMeasuredLensScreenFraction = 0f;
}
private void DisposeRenderTarget()
{
if ( _scopeCamera is not null && _scopeCamera.IsValid )
_scopeCamera.RenderTarget = null;
if ( _outputTexture is not null && _outputTexture.IsValid )
_outputTexture.Dispose();
_outputTexture = null;
_outputResolution = 0;
CurrentScopeFieldOfView = 0f;
CurrentRenderFieldOfView = 0f;
CurrentOpticalReferenceFieldOfView = 0f;
CurrentLensProjectionFieldOfView = 0f;
CurrentScopeMagnification = 1f;
PresentationZoomScale = 1f;
CurrentClipSpaceBounds = null;
CurrentEyeToScopeCameraDistance = 0f;
CurrentEyeToPhysicalOpticDistance = 0f;
CurrentPiPPositionBlend = 0f;
CurrentEyeBoxAngularError = 0f;
CurrentEyeBoxAlignment = 1f;
CurrentEyeBoxOffset = Vector2.Zero;
CurrentLensScreenFraction = 1f;
CurrentLensMeasurementSource = "AuthoredFallback";
CurrentTargetApparentMagnification = 1f;
CurrentRenderCropMagnification = 1f;
CurrentExpectedApparentMagnification = 1f;
LastManualRenderSucceeded = false;
}
private void SetRenderingState( bool rendering )
{
_renderRequested = rendering;
if ( _scopeCamera is not null && _scopeCamera.IsValid )
{
// Manual RT cameras remain disabled in the scene scheduler. This matches
// current S&box RenderToTexture usage: a disabled CameraComponent can still
// be rendered explicitly without participating in normal camera composition.
_scopeCamera.Enabled = false;
_scopeCamera.RenderTarget = null;
}
if ( !rendering )
LastManualRenderSucceeded = false;
if ( rendering == _lastRenderingState )
return;
_lastRenderingState = rendering;
if ( LogStateChanges )
{
Log.Info(
$"[OMR SCOPE PIP] Rendering:{rendering} | " +
$"Profile:{ActiveProfile?.ResourceName ?? "NONE"} | " +
$"Resolution:{_outputResolution} | " +
$"Anchor:{ResolvedCameraAnchor?.Name ?? "MAIN_CAMERA"}"
);
}
}
private void DestroyScopeCamera()
{
if ( _scopeCameraObject is not null && _scopeCameraObject.IsValid )
_scopeCameraObject.Destroy();
_scopeCameraObject = null;
_scopeCamera = null;
}
}