OMRViewModelBoneAnchor.cs
using Sandbox;
/// <summary>
/// Keeps a helper GameObject attached to a named bone on a first-person weapon model.
///
/// Runtime viewmodels have a special ordering constraint: animation bones are settled
/// before the camera modifier chain, while BaseCombatWeapon.PlaceViewModel moves the
/// viewmodel root during that camera chain. Reading a cached world-space bone transform
/// after the root has moved can therefore be one root-transform behind.
///
/// To avoid that, OneMoreRoundWeapon captures each helper as a ROOT-RELATIVE pose before
/// BaseCombatWeapon repositions the viewmodel, then reapplies that same pose after final
/// viewmodel placement. The bone animation remains current, but camera/root movement can
/// no longer create a one-frame feedback loop.
///
/// In editor scenes and as a fallback, this component still follows the final world-space
/// bone transform directly so offsets remain easy to author visually.
/// </summary>
[Title( "OMR Viewmodel Bone Anchor" )]
public sealed class OMRViewModelBoneAnchor : Component, Component.ExecuteInEditor
{
public readonly struct RootRelativePose
{
public readonly Vector3 Position;
public readonly Rotation Rotation;
public RootRelativePose( Vector3 position, Rotation rotation )
{
Position = position;
Rotation = rotation;
}
}
[Property, Group( "Reference" )]
public SkinnedModelRenderer Renderer { get; set; }
[Property, Group( "Reference" )]
public string BoneName { get; set; } = "weapon_root";
[Property, Group( "Offset" )]
[Description( "Position relative to the selected bone, in model/local units." )]
public Vector3 PositionOffset { get; set; } = Vector3.Zero;
[Property, Group( "Offset" )]
[Description( "Rotation relative to the selected bone." )]
public Angles RotationOffset { get; set; } = Angles.Zero;
[Property, Group( "Following" )]
public bool FollowPosition { get; set; } = true;
[Property, Group( "Following" )]
public bool FollowRotation { get; set; } = true;
[Property, Group( "Diagnostics" )]
public bool LogMissingBone { get; set; } = false;
private bool _loggedMissingBone;
private bool _externallyDrivenThisFrame;
protected override void OnStart()
{
ResolveRenderer();
// Runtime active viewmodels are driven from OneMoreRoundWeapon.PlaceViewModel.
// Editor authoring still wants an immediate visual placement.
if ( Game.IsEditor )
UpdateAnchorNow();
}
protected override void OnUpdate()
{
if ( Game.IsEditor )
UpdateAnchorNow();
}
protected override void OnPreRender()
{
// The active weapon already supplied a current root-relative transform during
// its PlaceViewModel stage. Do not overwrite it with a cached world-space bone
// read later in the same frame.
if ( _externallyDrivenThisFrame )
{
_externallyDrivenThisFrame = false;
return;
}
UpdateAnchorNow();
}
/// <summary>
/// Capture this helper's current animated pose relative to the supplied viewmodel root.
/// Call this BEFORE BaseCombatWeapon.PlaceViewModel changes that root for the frame.
/// The old world root cancels out, leaving only the current animated bone pose + authored
/// helper offset.
/// </summary>
public bool TryCaptureRootRelative(
GameObject viewModelRoot,
out RootRelativePose pose
)
{
pose = default;
ResolveRenderer();
if (
viewModelRoot is null ||
!viewModelRoot.IsValid ||
Renderer is null ||
!Renderer.IsValid ||
string.IsNullOrWhiteSpace( BoneName )
)
{
return false;
}
if ( !Renderer.TryGetBoneTransform( BoneName, out Transform boneTransform ) )
{
LogMissingBoneOnce();
return false;
}
_loggedMissingBone = false;
Vector3 anchorWorldPosition = boneTransform.PointToWorld( PositionOffset );
Rotation anchorWorldRotation = boneTransform.RotationToWorld( RotationOffset.ToRotation() );
Transform rootWorld = viewModelRoot.WorldTransform;
Vector3 rootLocalPosition = rootWorld.PointToLocal( anchorWorldPosition );
Rotation rootLocalRotation = rootWorld.RotationToLocal( anchorWorldRotation );
pose = new RootRelativePose(
rootLocalPosition,
rootLocalRotation
);
return true;
}
/// <summary>
/// Reapply a captured animated pose after the viewmodel root reaches its final camera
/// transform for the frame. Marks the helper as externally driven so its fallback
/// OnPreRender path cannot overwrite the result with stale world-space bone data.
/// </summary>
public void ApplyRootRelative(
GameObject viewModelRoot,
in RootRelativePose pose
)
{
if ( viewModelRoot is null || !viewModelRoot.IsValid )
return;
Transform rootWorld = viewModelRoot.WorldTransform;
if ( FollowPosition )
GameObject.WorldPosition = rootWorld.PointToWorld( pose.Position );
if ( FollowRotation )
GameObject.WorldRotation = rootWorld.RotationToWorld( pose.Rotation );
_externallyDrivenThisFrame = true;
}
/// <summary>
/// Direct world-space fallback used for editor authoring and non-weapon contexts.
/// Runtime active OMR viewmodels normally use the root-relative capture/apply path.
/// </summary>
public bool UpdateAnchorNow()
{
ResolveRenderer();
if (
Renderer is null ||
!Renderer.IsValid ||
string.IsNullOrWhiteSpace( BoneName )
)
{
return false;
}
if ( !Renderer.TryGetBoneTransform( BoneName, out Transform boneTransform ) )
{
LogMissingBoneOnce();
return false;
}
_loggedMissingBone = false;
Vector3 worldPosition = boneTransform.PointToWorld( PositionOffset );
Rotation worldRotation = boneTransform.RotationToWorld( RotationOffset.ToRotation() );
if ( FollowPosition )
GameObject.WorldPosition = worldPosition;
if ( FollowRotation )
GameObject.WorldRotation = worldRotation;
return true;
}
private void ResolveRenderer()
{
if ( Renderer is not null && Renderer.IsValid )
return;
Renderer = GameObject.Components.Get<SkinnedModelRenderer>(
FindMode.InAncestors |
FindMode.InSelf |
FindMode.InDescendants
);
}
private void LogMissingBoneOnce()
{
if ( !LogMissingBone || _loggedMissingBone )
return;
_loggedMissingBone = true;
Log.Warning(
$"[OMR VM ANCHOR] Bone '{BoneName}' was not found on '{Renderer?.GameObject?.Name ?? "UNKNOWN"}'."
);
}
}