A viewmodel handler component for a weapon, responsible for positioning, rotating and animating the first-person weapon model and hands. It computes sway, visual recoil, aim/run/customize/jump animations, applies MW-style recoil effects, manages viewmodel FOV, and exposes console commands to tune a global viewmodel correction (position and rotation).
using SWB.Shared;
using System;
namespace SWB.Base;
public class ViewModelHandler : Component
{
public SkinnedModelRenderer ViewModelRenderer { get; set; }
public SkinnedModelRenderer ViewModelHandsRenderer { get; set; }
public Weapon Weapon { get; set; }
public CameraComponent Camera { get; set; }
public bool ShouldDraw { get; set; }
// Editor
public bool EditorMode { get; set; }
public AngPos EditorOffset { get; set; }
/// <summary>
/// A whole-viewmodel angular correction, in the handler's own rot units.
///
/// ⚠️ STATIC AND LIVE-TUNABLE ON PURPOSE. This is a hunt for one number; a
/// property on the weapon would need a prefab edit and a restart per attempt.
/// Once it is right it gets baked and this goes away.
/// </summary>
// ⛔ 90 IS THE DEFAULT, NOT ZERO. Every ARC9 BO1 viewmodel is authored a quarter
// turn from the axis SWB expects, so with no correction the weapon sits off
// screen entirely. This was being typed in by hand every session because the
// field is static and resets on restart — a correction the whole pack needs is
// a default, not a console command.
//
// ⚠️ If weapons face the wrong way, this is -90; it is the only value in play.
public static float VMYaw { get; set; } = 90f;
public static float VMPitch { get; set; }
public static float VMRoll { get; set; }
/// <summary>Positional half of the correction. x = right, y = forward,
/// z = up — the same axes the handler applies every other offset in.</summary>
public static float VMX { get; set; }
public static float VMY { get; set; }
public static float VMZ { get; set; }
/// <summary>Set the rotation: nz_vm_yaw <yaw> [pitch] [roll].</summary>
[ConCmd( "nz_vm_yaw" )]
public static void SetVMYaw( float yaw = 0f, float pitch = 0f, float roll = 0f )
{
VMYaw = yaw; VMPitch = pitch; VMRoll = roll;
Report();
}
/// <summary>Set the position: nz_vm_pos <x> <y> <z>
/// (right / forward / up).</summary>
[ConCmd( "nz_vm_pos" )]
public static void SetVMPos( float x = 0f, float y = 0f, float z = 0f )
{
VMX = x; VMY = y; VMZ = z;
Report();
}
/// <summary>
/// NUDGE all six: nz_vm_add <yaw> [pitch] [roll] [x] [y] [z].
///
/// ⚠️ The one you actually want while hunting a value — converging by typing
/// absolutes means recomputing the total in your head every attempt.
/// </summary>
[ConCmd( "nz_vm_add" )]
public static void AddVM( float yaw = 0f, float pitch = 0f, float roll = 0f,
float x = 0f, float y = 0f, float z = 0f )
{
VMYaw += yaw; VMPitch += pitch; VMRoll += roll;
VMX += x; VMY += y; VMZ += z;
Report();
}
/// <summary>Print the current correction, ready to be baked.</summary>
[ConCmd( "nz_vm" )]
public static void Report()
{
Log.Info( $"[swb] viewmodel correction: yaw {VMYaw:0.##} pitch {VMPitch:0.##}"
+ $" roll {VMRoll:0.##} x {VMX:0.##} y {VMY:0.##} z {VMZ:0.##}" );
Log.Info( $"[swb] nz_vm_yaw {VMYaw:0.##} {VMPitch:0.##} {VMRoll:0.##}"
+ $" · nz_vm_pos {VMX:0.##} {VMY:0.##} {VMZ:0.##}" );
}
/// <summary>Back to zero, for starting the hunt again.</summary>
[ConCmd( "nz_vm_reset" )]
public static void ResetVM()
{
VMYaw = VMPitch = VMRoll = VMX = VMY = VMZ = 0f;
Report();
}
public float EditorFOV { get; set; }
/// <summary>
/// The player this viewmodel belongs to — NULL-SAFE, because it is routinely orphaned.
///
/// ⛔ THIS THREW EVERY FRAME AND TOOK THE WHOLE BODY DOWN WITH IT. A player body is CLONED
/// (`NZPlayers.SpawnFor`), the clone carries a copy of the template's viewmodel object and this
/// handler, and then `Disarm` deletes the weapon — leaving a handler whose `Weapon` is null.
/// `Weapon.Owner` then threw in `OnUpdate` on every tick:
///
/// Exception when calling 'Update' on SWB.Base.ViewModelHandler
/// Exception when calling 'OnDestroy' on SWB.Base.ViewModelHandler
/// at SWB.Base.ViewModelHandler.get_player()
///
/// ⚠️ AND A THROWING `OnUpdate` DOES NOT FAIL QUIETLY — the rest of that object's update is
/// abandoned, `PlayerController` among it, so the body is never drawn. Measured, both ends of
/// one session: the body WITHOUT an orphaned viewmodel was visible, the body WITH one was
/// invisible while its stranded hands were not.
///
/// ⚠️ `?.` RATHER THAN REMOVING THE ORPHAN. Three attempts to delete these objects each made
/// bodies disappear; this cannot, because it removes nothing. It only stops an exception, and
/// an orphan with a null owner now simply does nothing — `IsFirstPerson` on a null player is
/// false, which is exactly the "not mine, do not draw" answer the handler already knows how to
/// act on.
/// </summary>
IPlayerBase player => Weapon?.Owner;
float animSpeed = 1;
float weaponFOVSpeed = 1;
// Target animation values
Vector3 targetVectorPos;
Vector3 targetVectorRot;
float targetWeaponFOV = -1;
// Finalized animation values
Vector3 finalVectorPos;
Rotation finalRot;
float finalWeaponFOV;
// Sway
Rotation lastEyeRot;
/// <summary>
/// How far the gun is swinging away from where the player is looking, this frame.
///
/// ⛔ EXPOSED SO NOTHING HAS TO RE-DERIVE IT. This is the whole of "how much a weapon swings
/// when you turn": the eye rotation lags behind the real one at `swayspeed`, and that lag,
/// scaled and clamped just below, is the swing. Any second copy of that calculation elsewhere is
/// free to disagree with the gun actually on screen -- so readers take these instead.
///
/// ⚠️ PITCH, YAW, ROLL in degrees, matching `MathUtil.ToRotation`'s (x, y, z) convention.
/// </summary>
public Vector3 SwayRotOffset { get; private set; }
/// <summary>
/// The gun's positional swing, in the handler's own (right, forward, up) order — the order
/// `WorldPosition +=` composes it in, NOT Source's (forward, left, up).
/// </summary>
public Vector3 SwayPosOffset { get; private set; }
/// <summary>
/// How far the lagged eye rotation is currently behind the real one, in degrees.
///
/// ⛔ THE INPUT TO THE WHOLE SWAY SYSTEM, and it tracks turn RATE, not turn DISTANCE. It grows
/// while `swayspeed` cannot keep up and settles at (degrees per second / swayspeed) — so a
/// steady turn holds a steady lag no matter how far round you go, and stopping collapses it.
/// </summary>
public Angles SwayLag { get; private set; }
/// <summary>The rate the lagged eye rotation chases the real one — 5 at the hip, 20 aiming.</summary>
public float SwaySpeed { get; private set; }
/// <summary>The rate the pose is slerped toward its target, `10 * Weapon.AnimSpeed`.</summary>
public float PoseSpeed => animSpeed;
/// <summary>The furthest the gun will ever tilt from sway, in degrees.</summary>
///
/// ⚠️ NAMED SO THE FULL RANGE CAN BE ASKED FOR. Anything expressing itself as a FRACTION of the
/// swing needs to know what the whole of it is, and a second copy of "4" written elsewhere would
/// quietly stop matching the moment this is tuned.
public const float SwayRotLimit = 4.0f;
/// <summary>The furthest the gun will ever shift from sway, in units.</summary>
public const float SwayPosLimit = 1.5f;
/// <summary>
/// Where the viewmodel would be standing this frame if it were not swaying.
///
/// ⛔ THE RAW SWAY NUMBERS ARE NOT WHAT YOU SEE, so anything that needs the gun's real on-screen
/// motion has to be given this instead of rebuilding it. `SwayRotOffset` is a TARGET: it is
/// clamped, then folded into a pose that is slerped toward at `animSpeed`, so what reaches the
/// screen is smaller than the target, lags it, and answers a fast turn on a completely different
/// curve than a slow one. A reader scaling the raw numbers cannot match that with any constant --
/// measured against the gun it needed 1200% sideways and still had the wrong shape.
///
/// ⚠️ VISUAL RECOIL IS DELIBERATELY EXCLUDED, so a kick shows up as motion against this rather
/// than cancelling out of it.
/// </summary>
public Transform SwayFreeTransform { get; private set; }
// ⚠️ A SECOND COPY OF THE DAMPED POSE, run with the sway subtracted from its target and stepped
// at the same `animSpeed` on the same frames. Anything cheaper -- a smoothed average of the real
// pose, a fixed reference -- drifts during a sustained turn, because a gun held at constant sway
// is indistinguishable from one at rest to a filter that only watches the output.
Rotation finalRotNoSway;
Vector3 finalVectorPosNoSway;
// FOV
float cachedFOV = -1f;
float cachedVerticalFOV;
// Jumping Animation
float jumpTime;
float landTime;
// Aim animation
float aimTime;
// Helpful values
Vector3 localVel;
protected override void OnDestroy()
{
// ⚠️ AN ORPHAN HAS NOBODY TO GIVE THE FIELD OF VIEW BACK TO. This is the exact line the
// `OnDestroy` exception in the logs came from.
if ( player is null || !player.IsFirstPerson ) return;
player.FieldOfView = Screen.CreateVerticalFieldOfView( Preferences.FieldOfView );
}
protected override void OnDisabled()
{
// Reinitialize all target values when enabled
targetWeaponFOV = -1;
}
Vector3 _visualRecoil; // pitch, yaw, roll — the TARGET the shot asked for, decaying
Vector3 _tiltShown; // what is actually drawn, chasing the target through a spring
Vector3 _tiltVel; // degrees per second, the spring's velocity
float _visualPunch; // units of backward drive still to settle
float _visualRecoilRecovery = 0.15f;
/// <summary>
/// Kick the MODEL. Called on each shot; the pose settles back on its own.
/// </summary>
/// <param name="replace">
/// Overwrite the outstanding kick instead of adding to it.
///
/// ⛔ ADDITIVE WAS THE ORIGINAL DESIGN AND IT IS WHAT MADE THE LEAN POSSIBLE AT ALL. Stacking
/// makes a burst climb in the hands, which reads well — but it also means the direction of
/// shot N is carried into shot N+1, so any bias in the sequence compounds instead of
/// cancelling. Two separate fixes tried to remove the bias from the SEQUENCE; replacing
/// removes the accumulation the bias needed.
///
/// ⚠️ IT COSTS THE CLIMB, DELIBERATELY. Requested as *"each shot going in a random direction,
/// and recenter before the next shot"* — a crisp per-shot snap rather than a rising sway.
/// `GlobalHandling.RecoilStability` switches back.
/// </param>
public void ApplyVisualRecoil( float up, float side, float roll, float punch, float recovery,
bool replace = false )
{
var kick = new Vector3( up, side, roll );
if ( replace )
{
_visualRecoil = kick;
_visualPunch = punch;
}
else
{
_visualRecoil += kick;
_visualPunch += punch;
}
_visualRecoilRecovery = recovery > 0 ? recovery : 0.15f;
}
/// <summary>
/// The MW Base's gun springs (`NZombies.MwRecoilFx`), kicked from `Weapon.Shoot` while the MW look owns the gun.
/// One per held gun, so a weapon swap starts at rest.
/// </summary>
public NZombies.MwGunRecoil MwGun { get; } = new();
/// <summary>
/// The MW Base's gun motion on this frame's pose: its springs (`MwGun`) and the part of the view punch the base
/// shows on the gun rather than on the camera.
///
/// ⚠️ ABOUT THE EYE, NOT ABOUT A PIVOT ON THE GUN. The base's viewmodel origin IS the eye and every turn it makes
/// (`RotateAroundAxis`) swings the gun about it, so a pitch reads as the gun rising on screen, not as the muzzle
/// tipping in place. Our own tilt pivots on the gun on purpose (*"it rotates around the player"*); this is the
/// other look, and only while it is asked for.
///
/// ⚠️ THE SCREEN RATTLE IS NOT HERE. It goes on this gun's CAMERA (`MwRecoilFx.GunCamera`, shaped by
/// `MwRecoilView`), so the gun and the world rattle as one picture, as they do in the base.
/// </summary>
void ApplyMwLook()
{
if ( !NZombies.MwRecoilFx.On ) return;
NZombies.MwRecoilFx.GunCamera = Camera;
var dt = MathF.Min( Time.Delta, 0.034f );
var aimed = Weapon.IsAiming && !Weapon.IsReloading ? 1f : 0f;
var aim = MathX.Lerp( NZombies.MwRecoilFx.Aim, aimed, MathF.Min( 1f, 18f * dt ) );
NZombies.MwRecoilFx.Aim = aim;
MwGun.Step( dt, aim );
MwGun.Output( aim, out var turn, out var move );
var eye = Camera.WorldPosition;
var cam = Camera.WorldRotation;
WorldPosition += cam.Right * move.x + cam.Forward * move.y + cam.Up * move.z;
// the base's (pitch up, yaw left, roll) is our (pitch down, yaw left, roll)
var local = Rotation.From( -turn.x, turn.y, turn.z ) * NZombies.MwRecoilFx.PunchOnGun( aim );
var q = cam * local * cam.Inverse;
WorldRotation = q * WorldRotation;
WorldPosition = eye + q * (WorldPosition - eye);
}
protected override void OnUpdate()
{
// ⛔ AN ORPHANED HANDLER STOPS HERE, AND STOPS QUIETLY. Everything below assumes a player;
// with none, the honest answer is "this viewmodel belongs to nobody, so draw nothing" —
// and, critically, RETURNING is what keeps the exception from killing the rest of this
// object's update, which is what was hiding the body.
if ( player is null )
{
if ( ViewModelRenderer.IsValid() ) ViewModelRenderer.Enabled = false;
if ( ViewModelHandsRenderer.IsValid() ) ViewModelHandsRenderer.Enabled = false;
return;
}
// ⛔ AND WITHOUT ITS CAMERA IT CANNOT POSITION ANYTHING EITHER. `nz_vm_clear` destroys
// viewmodel cameras deliberately, and the handler then threw on `Camera.WorldPosition`
// every frame — hundreds of times a second, measured:
//
// [115] Exception when calling 'Update' on SWB.Base.ViewModelHandler
// [224] Exception when calling 'Update' on SWB.Base.ViewModelHandler
//
// ⚠️ A DIAGNOSTIC COMMAND MUST NOT SET THE LOG ON FIRE. That flood buries the very lines
// the command was run to produce, which is the opposite of what a diagnostic is for.
if ( !Camera.IsValid() )
{
if ( ViewModelRenderer.IsValid() ) ViewModelRenderer.Enabled = false;
if ( ViewModelHandsRenderer.IsValid() ) ViewModelHandsRenderer.Enabled = false;
return;
}
var renderType = ShouldDraw ? ModelRenderer.ShadowRenderType.Off : ModelRenderer.ShadowRenderType.ShadowsOnly;
ViewModelRenderer.Enabled = player.IsFirstPerson;
ViewModelRenderer.RenderType = renderType;
if ( ViewModelHandsRenderer is not null )
{
ViewModelHandsRenderer.Enabled = player.IsFirstPerson;
ViewModelHandsRenderer.RenderType = renderType;
}
if ( !player.IsFirstPerson ) return;
// For particles & lighting
Camera.WorldPosition = Scene.Camera.WorldPosition;
Camera.WorldRotation = Scene.Camera.WorldRotation;
if ( targetWeaponFOV == -1 )
{
targetWeaponFOV = Weapon.ViewModelFOV;
finalWeaponFOV = Weapon.ViewModelFOV;
}
// Skinned modelrenderer has issues with following parent
WorldPosition = Camera.WorldPosition;
WorldRotation = Camera.WorldRotation;
// Pos + FOV
finalVectorPos = finalVectorPos.LerpTo( targetVectorPos, animSpeed * RealTime.SmoothDelta );
// ⚠️ SAME LINE, SAME animSpeed, SAME FRAME — the two only stay comparable while they are
// stepped identically. `animSpeed` is rewritten further down, so this cannot be deferred.
finalVectorPosNoSway = finalVectorPosNoSway.LerpTo( targetVectorPos - SwayPosOffset, animSpeed * RealTime.SmoothDelta );
finalWeaponFOV = MathX.LerpTo( finalWeaponFOV, targetWeaponFOV, weaponFOVSpeed * animSpeed * RealTime.SmoothDelta );
// Angles
//
// ⛔ THE CORRECTION IS COMPOSED AS A ROTATION, NOT SUMMED INTO THE EULER.
//
// I first added it to `targetVectorRot`, which is a Vector3 of
// (pitch, yaw, roll) awaiting conversion. **Euler composition is not
// commutative**: adding 90 to yaw before the conversion changes how the
// pitch and roll components are then interpreted, so every OTHER offset —
// aim, run, tuck — silently changed meaning. Reported exactly: *"when i
// run the weapon is supposed to turn to the left, but now its rotating
// counter clockwise"*. The run offset was fine; my correction had
// re-mapped its axes.
//
// Multiplied on the end instead, it is a fixed reorientation of the model
// in its own frame and leaves every authored offset meaning what it did.
var targetRot = MathUtil.ToRotation( targetVectorRot )
* Rotation.From( VMPitch, VMYaw, VMRoll );
finalRot = finalRot.SlerpTo( targetRot, animSpeed * RealTime.SmoothDelta );
// ⚠️ Euler subtraction, matching the Euler ADDITION the sway was applied with — undoing it
// the way it was done is what keeps the two poses differing by exactly the sway.
var targetRotNoSway = MathUtil.ToRotation( targetVectorRot - SwayRotOffset )
* Rotation.From( VMPitch, VMYaw, VMRoll );
finalRotNoSway = finalRotNoSway.SlerpTo( targetRotNoSway, animSpeed * RealTime.SmoothDelta );
// Reset Speed
animSpeed = 10 * Weapon.AnimSpeed;
// ── visual recoil ───────────────────────────────────────────────────
//
// ⚠️ Decayed BEFORE composing, and folded into finalRot/finalVectorPos
// rather than applied afterwards — the pose is built in the weapon's own
// space, so adding a world-space nudge after the fact would drift as you
// turn. This rides along with the pose instead.
// ⛔ NEVER WRITE VISUAL RECOIL INTO finalRot / finalVectorPos. Those are
// PERSISTENT FIELDS carried across frames and slerped toward the target
// pose — multiplying a kick into them compounds it into the weapon's base
// pose permanently, so the gun creeps further every shot and never comes
// back. That is a drift that no amount of decay can undo, because the
// decaying value was already baked into the state it decays from.
//
// ⚠️ Apply as LOCALS at composition time instead. The kick then genuinely
// is transient: it exists only for the frame it is drawn in.
var vrRot = finalRot;
var vrPos = finalVectorPos;
// ⛔ THE KICK'S ROTATION IS NO LONGER FOLDED INTO `vrRot`, AND THAT IS THE FIX FOR
// *"it rotates around the player."* `vrRot` is the frame the authored position offset is
// STEPPED ALONG, a few lines below — so a kick multiplied in here did not turn the gun,
// it turned the ruler the gun is measured with, and swung it on an arc centred on the eye.
// A rotation about the point you are looking FROM is very nearly pure screen-space
// translation: the weapon slid sideways and barely appeared to turn at all.
//
// ⚠️ IT IS HELD AND APPLIED AFTER THE POSITION INSTEAD, about a pivot on the weapon. The
// decay still happens here, because that is per-frame state and has to tick exactly once.
var recoilTilt = Rotation.Identity;
var hasTilt = false;
if ( _visualRecoil.LengthSquared > 0.0001f || MathF.Abs( _visualPunch ) > 0.001f )
{
var rate = (_visualRecoilRecovery > 0 ? RealTime.SmoothDelta / _visualRecoilRecovery : 1f).Clamp( 0f, 1f );
_visualRecoil = Vector3.Lerp( _visualRecoil, Vector3.Zero, rate );
_visualPunch = MathX.Lerp( _visualPunch, 0f, rate );
// ⚠️ THE PUNCH STAYS A POSITION TERM AND STAYS HERE, because it is a translation —
// sliding the gun back at the eye is the same motion wherever it is composed.
vrPos.y -= _visualPunch; // y = forward, so minus drives it back at the eye
}
// ── SOFTNESS: THE DRAWN TILT CHASES THE KICK INSTEAD OF BEING IT ──────────────
//
// ⛔ THE KICK USED TO ARRIVE IN ONE FRAME, WHICH IS INFINITE ACCELERATION. Only the
// return was smoothed, so the gun teleported to full tilt and then eased home — hard on,
// soft off. `_visualRecoil` is now the TARGET and `_tiltShown` is what gets drawn.
//
// ⚠️ A DAMPED SPRING, NOT A SECOND LERP, because a lerp has only one control and the
// request has two: *"how quickly it accelerates AND stops."* Stiffness sets the first,
// damping the second, and they are independent — which a single rate cannot be.
//
// ⚠️ SEMI-IMPLICIT EULER: velocity first, then position FROM THE NEW VELOCITY. The
// explicit form (position from the old velocity) feeds energy into a spring instead of
// taking it out, so a stiff setting would oscillate wider every frame until the gun span.
// One reordered line is the whole difference between stable and divergent.
//
// ⚠️ AND `dt` IS CAPPED. A hitch of a quarter second through a stiff spring is a step
// far past the target, which the next frame answers with a bigger step back. Clamping
// trades exactness during a stall — where nothing is being judged anyway — for never
// exploding.
var soften = NZombies.GlobalHandling.VisualTiltSoften;
if ( soften <= 0.001f )
{
_tiltShown = _visualRecoil;
_tiltVel = Vector3.Zero;
}
else if ( _visualRecoil.LengthSquared > 1e-8f
|| _tiltShown.LengthSquared > 1e-8f
|| _tiltVel.LengthSquared > 1e-8f )
{
// ⛔ THIS BLOCK MUST KEEP RUNNING AFTER THE TARGET REACHES ZERO, which is why it
// tests the spring's own state as well. Gating it on the target alone would freeze the
// model at whatever tilt it happened to be holding the moment the target expired —
// a gun stuck permanently askew, and only on the shots that ended mid-motion.
var dt = MathF.Min( RealTime.SmoothDelta, 0.05f );
var damp = MathF.Max( 0.05f, NZombies.GlobalHandling.VisualTiltDamp );
// ⚠️ 4/t, NOT 1/t, SO THE KNOB MEANS WHAT IT SAYS. A critically damped spring covers
// 90% of its distance in about 4/omega seconds, so omega = 1/t would have made "0.05"
// deliver a 0.2s rise — four times slower than the number claims, and slower than the
// 0.075s between shots at 800 rpm. Verified by simulating the integrator.
var omega = 4f / soften;
// ⛔ SUB-STEPPED, BECAUSE THIS INTEGRATOR HAS A STABILITY LIMIT AND THE KNOB DRIVES
// STRAIGHT THROUGH IT. Semi-implicit Euler on a spring needs roughly
// `2 * damp * omega * h < 2`; at soften 0.02 and 30 fps the real numbers were 3.3, and
// a simulation of this exact loop ran away to 4.9e5 degrees in a third of a second.
// Shipping a knob whose useful range explodes the gun is not a knob.
//
// ⚠️ SO THE STEP IS SPLIT UNTIL IT IS SAFE rather than the setting being refused.
// Sixteen is far more than the default needs (six at 60 fps) and costs a few float
// operations in the worst case.
var hMax = 0.5f / (omega * MathF.Max( 1f, 2f * damp ));
var steps = Math.Clamp( (int)MathF.Ceiling( dt / hMax ), 1, 16 );
var h = dt / steps;
// ⚠️ AND IF SIXTEEN STILL IS NOT ENOUGH, THE STIFFNESS IS CAPPED RATHER THAN THE
// SIMULATION ABANDONED. That only happens for an extreme setting at a bad frame rate,
// and the honest failure is "as sharp as this frame rate can carry" — not a weapon
// that spins off the screen on the one machine that dropped frames.
if ( h > hMax ) omega = 0.5f / (h * MathF.Max( 1f, 2f * damp ));
var stiffness = omega * omega;
var friction = 2f * damp * omega;
for ( var i = 0; i < steps; i++ )
{
_tiltVel += ((_visualRecoil - _tiltShown) * stiffness - _tiltVel * friction) * h;
_tiltShown += _tiltVel * h;
}
if ( _tiltShown.LengthSquared < 1e-8f && _tiltVel.LengthSquared < 1e-8f )
{
_tiltShown = Vector3.Zero;
_tiltVel = Vector3.Zero;
}
}
// ⚠️ REPORTED EVERY FRAME, INCLUDING THE ONE WHERE IT REACHES ZERO, which is what lets
// the probe print a peak and then stop. Gating this on a non-zero tilt would mean the
// summary line never fires.
NZombies.TiltProbe.Frame( _tiltShown );
if ( _tiltShown.LengthSquared > 1e-8f )
{
// The tilt as a player reads it: pitch raises the muzzle, yaw swings it across, roll
// turns it about the barrel. Plain VIEW space, nothing model-specific.
var viewTilt = Rotation.From( -_tiltShown.x, _tiltShown.y, _tiltShown.z );
// ⛔ REBASED OUT OF THE MODEL'S FRAME, AND WITHOUT THIS TWO OF THE THREE AXES ARE THE
// WRONG ONES. `vrRot` carries the VMYaw = 90 correction documented below, which maps the
// viewmodel's Right onto the CAMERA's Forward. Applied in that frame:
//
// pitch turns about the model's right = the camera's FORWARD -> you get a ROLL
// yaw turns about the model's up = the camera's up -> you get a yaw
// roll turns about the model's forward = the camera's LEFT -> you get a PITCH
//
// So pitch and roll were swapped and only yaw was ever right. Reported exactly that
// way: *"the vertical part does basically nothing, the side one is very good."* Side
// is yaw, and yaw is the one axis the correction leaves alone.
//
// ⚠️ A SIMILARITY TRANSFORM RATHER THAN SWAPPING THE COMPONENTS BY HAND. Both fix the
// axes; only this one also gets the SIGNS right without a derivation that has to be
// redone if the correction ever changes. `WorldRotation` is `camera * vrRot` by the
// time this is applied, so `vrRot.Inverse * viewTilt * vrRot` composes to
// `camera * viewTilt * vrRot` — the tilt in camera space, then the model correction,
// which is the order that was wanted all along.
//
// ⚠️ AND IT LEAVES A TUNED SIDE VALUE ALONE. Yaw maps to yaw under the transform, so
// the horizontal lean is bit-for-bit what it was; only the two broken axes move.
recoilTilt = vrRot.Inverse * viewTilt * vrRot;
hasTilt = true;
}
// Change the angles and positions of the viewmodel with the new vectors
//
// ⛔ THE AXES BELOW ARE THE VIEWMODEL'S, NOT THE CAMERA'S, AND READING THEM LITERALLY GIVES
// THE WRONG ANSWER FOR EVERY AUTHORED OFFSET. `WorldRotation` has just been multiplied by
// vrRot, which carries the VMYaw = 90 model correction -- and a quarter turn in yaw maps the
// viewmodel's Right onto the CAMERA's Forward:
//
// Forward (1,0,0) -> (0,1,0) = Left Right (0,-1,0) -> (1,0,0) = Forward
//
// So `Pos.x` -- the offset editor's X slider, and slot 0 of AimAnimData / ViewModelOffset /
// RunAnimData -- is DEPTH, negative being toward the eye. `Pos.y` is sideways. The line
// says x * Right and means x * forward, which is exactly backwards from how it reads.
WorldRotation *= vrRot;
// Position has to be set after rotation!
WorldPosition += vrPos.z * WorldRotation.Up + vrPos.y * WorldRotation.Forward + vrPos.x * WorldRotation.Right;
// ── THE KICK, NOW THAT THE GUN IS WHERE IT BELONGS ──────────────────────────
//
// ⚠️ ROTATE-ABOUT-A-POINT, DONE ON THE OBJECT RATHER THAN ON THE FRAME. Turning the model
// in place means the pivot must not move, so the origin is swung around it by exactly the
// amount the orientation turned. `delta` is that turn expressed in WORLD space, which is
// what `new * old.Inverse` gives — `recoilTilt` itself lives in the viewmodel's local
// frame, and that frame carries the VMYaw = 90 correction, so using it directly on a world
// vector would rotate about the wrong axes entirely.
//
// ⚠️ THE BOUNDS ARE THE MODEL'S OWN, so "the centre" follows whatever is in your hands
// rather than a constant that would suit one gun and miss 489 others. They lag the pose by
// a frame, which at these angles is invisible.
//
// ⚠️ AND IT APPLIES TO THE OLD RANDOM-DIRECTION PATH TOO, not only the follow-the-kick
// one: both arrive as `_visualRecoil`, so `nz_recoil_tilt 0` gets the fixed pivot as well.
// The bug was never about which direction the kick pointed.
if ( hasTilt )
{
var pivot = WorldPosition;
var blend = NZombies.GlobalHandling.VisualPivot;
if ( blend != 0f && ViewModelRenderer.IsValid() )
{
// ⛔ WRITTEN OUT RATHER THAN `Vector3.Lerp`, SO IT CAN EXTRAPOLATE. Lerp clamps its
// fraction to 0..1, so every value past 1 would have silently landed on the model's
// centre — a slider that moves and does nothing, which is the exact failure this
// panel keeps being asked to fix. Past 1 the pivot walks out beyond the centre
// toward the muzzle, which is where a gun pivoting in two hands actually turns:
// far enough out and the STOCK swings rather than the barrel.
pivot += (ViewModelRenderer.Bounds.Center - pivot) * blend;
}
var before = WorldRotation;
WorldRotation *= recoilTilt;
var delta = WorldRotation * before.Inverse;
WorldPosition = pivot + delta * (WorldPosition - pivot);
}
// ⚠️ THE MW BASE'S GUN MOTION goes on last, about the eye, and does nothing while that look is off.
ApplyMwLook();
// ⛔ COMPOSED IN THE SAME ORDER AS THE REAL POSE ABOVE — rotation first, then position in the
// ROTATED frame. Composing it any other way makes the difference between the two include an
// error that grows with the pose, not just the sway.
var freeRot = Camera.WorldRotation * finalRotNoSway;
var freePos = Camera.WorldPosition
+ finalVectorPosNoSway.z * freeRot.Up
+ finalVectorPosNoSway.y * freeRot.Forward
+ finalVectorPosNoSway.x * freeRot.Right;
SwayFreeTransform = new Transform( freePos, freeRot );
if ( finalWeaponFOV != cachedFOV )
{
cachedFOV = finalWeaponFOV;
cachedVerticalFOV = Screen.CreateVerticalFieldOfView( finalWeaponFOV );
}
Camera.FieldOfView = cachedVerticalFOV;
// Initialize the target vectors for this frame
targetVectorPos = Vector3.Zero;
targetVectorRot = Vector3.Zero;
targetWeaponFOV = Weapon.ViewModelFOV;
// Editor mode
if ( EditorMode )
{
targetVectorRot += MathUtil.ToVector3( EditorOffset.Angle );
targetVectorPos += EditorOffset.Pos;
targetWeaponFOV = EditorFOV;
return;
}
// ⛔ WHOLE-VIEWMODEL YAW CORRECTION — live-tunable with nz_vm_yaw.
//
// User's observation, which no source-side edit explained: *"it's as if
// it's rotated 90 degrees around the player… the positions we created are
// still all proportional to each other but to the RIGHT of the player, not
// the front."* Offsets proportionally intact but the whole frame turned is
// a rotation of the VIEWMODEL, not of a bone — a bone would distort the
// relationships between the poses, and these are preserved.
//
// ⚠️ Applied to targetVectorRot, in the same units and the same place the
// aim/run offsets are, so it composes with them instead of fighting them.
// Dial it with `nz_vm_yaw <deg>` and bake the value once it is right.
// ⚠️ POSITION here, rotation LOWER DOWN — see the note at ToRotation.
targetVectorPos += new Vector3( VMX, VMY, VMZ );
// ⛔ THE HIP OFFSET IS THE IDLE POSE, NOT A BASE ADDED TO EVERYTHING.
//
// This is the correction that actually mattered. The three offsets are
// each measured ABSOLUTELY in the offset editor — you drag the gun to
// where you want it and read the numbers off. SWB, however, ADDS
// AimAnimData and RunAnimData on top of whatever pose is current. For
// SWB's own weapons those are the same thing, because their base pose is
// zero (the model is authored in place). For a PORTED model the base is
// not zero, so adding hip underneath ADS gave hip+ads — a position nobody
// measured and nowhere near either value.
//
// So it applies only while the gun is at rest. Aiming and running each
// own the pose outright, exactly as they were dialled in.
//
// ⚠️ No snap on transition: targetVector is lerped toward, not assigned,
// so dropping this term blends out over the same frames the aim pose
// blends in.
// ⛔ MIRRORS THE CONDITIONS THAT ACTUALLY CLAIM THE POSE, NOT AN APPROXIMATION OF THEM.
//
// This read `Weapon.IsAiming` alone, while HandleIronAnimation applies the aim pose only
// when `IsAiming && !IsReloading && AimAnimData != Zero`. Reloading with the aim button
// held therefore satisfied NEITHER branch: hip stood down because the gun was aiming, aim
// stood down because the gun was reloading, and the viewmodel fell back to the raw model
// origin for the whole reload — so the offset dialled in the editor visibly stopped
// applying the moment a reload started.
//
// ⚠️ The AimAnimData check closes the same hole for a second case: a weapon with no ADS
// pose authored lost its hip pose the instant it aimed, for exactly the same reason.
bool posedElsewhere = AimOwnsPose
|| (Weapon.RunAnimData != AngPos.Zero
&& (Weapon.ShouldTuckVar || Weapon.IsRunning));
if ( !posedElsewhere )
{
targetVectorRot += MathUtil.ToVector3( Weapon.ViewModelOffset.Angle );
targetVectorPos += Weapon.ViewModelOffset.Pos;
}
// I'm sure there's something already that does this for me, but I spend an hour
// searching through the wiki and a bunch of other garbage and couldn't find anything...
// So I'm doing it manually. Problem solved.
var eyeRot = player.EyeAngles.ToRotation();
localVel = new Vector3( eyeRot.Right.Dot( player.Velocity ), eyeRot.Forward.Dot( player.Velocity ), player.Velocity.z );
HandleIdleAnimation();
HandleWalkAnimation();
HandleJumpAnimation();
// Tucking
if ( Weapon.RunAnimData != AngPos.Zero && Weapon.ShouldTuckVar )
{
var animationCompletion = 1f;
if ( !player.IsClimbingLadder )
animationCompletion = Math.Min( 1, ((Weapon.TuckRange - Weapon.TuckDist) / Weapon.TuckRange) + 0.5f );
targetVectorPos += Weapon.RunAnimData.Pos * animationCompletion;
targetVectorRot += MathUtil.ToVector3( Weapon.RunAnimData.Angle * animationCompletion );
return;
}
HandleSwayAnimation();
HandleIronAnimation();
HandleSprintAnimation();
HandleCustomizeAnimation();
}
protected virtual void HandleIdleAnimation()
{
// No swaying if aiming
if ( Weapon.IsAiming )
return;
// Perform a "breathing" animation
var breatheTime = RealTime.Now * 2.0f;
targetVectorPos -= new Vector3( MathF.Cos( breatheTime / 4.0f ) / 8.0f, 0.0f, -MathF.Cos( breatheTime / 4.0f ) / 32.0f );
targetVectorRot -= new Vector3( MathF.Cos( breatheTime / 5.0f ), MathF.Cos( breatheTime / 4.0f ), MathF.Cos( breatheTime / 7.0f ) );
// Crouching animation
if ( player.IsCrouching && player.IsOnGround )
targetVectorPos += new Vector3( -1.0f, -1.0f, 0.5f );
}
protected virtual void HandleWalkAnimation()
{
var breatheTime = RealTime.Now * 16.0f;
var walkSpeed = new Vector3( player.Velocity.x, player.Velocity.y, 0.0f ).Length;
var maxWalkSpeed = 200.0f;
var roll = 0.0f;
var yaw = 0.0f;
// Check if on the ground
if ( !player.IsOnGround )
return;
// ⚠️ AN MW BASE GUN'S SPRINT CLIP IS ITS RUN (`NZombies.MwRunBob`, 2026-10-02): this bob fought its own cadence
if ( NZombies.MwRunBob.Skip( Weapon ) )
return;
// Check if sprinting
if ( player.IsRunning )
{
breatheTime = RealTime.Now * 18.0f;
maxWalkSpeed = 100.0f;
}
// Check for sideways velocity to sway the gun slightly
if ( Weapon.IsAiming || localVel.x > 0.0f )
roll = -7.0f * (localVel.x / maxWalkSpeed);
else if ( localVel.x < 0.0f )
yaw = 3.0f * (localVel.x / maxWalkSpeed);
// Check if ADS & firing
if ( Weapon.IsAiming && Weapon.TimeSincePrimaryShoot < 0.1f )
{
targetVectorRot -= new Vector3( 0, 0, roll );
return;
}
// Perform walk cycle
targetVectorPos -= new Vector3( (-MathF.Cos( breatheTime / 2.0f ) / 5.0f) * walkSpeed / maxWalkSpeed - yaw / 4.0f, 0.0f, 0.0f );
targetVectorRot -= new Vector3( (Math.Clamp( MathF.Cos( breatheTime ), -0.3f, 0.3f ) * 2.0f) * walkSpeed / maxWalkSpeed, (-MathF.Cos( breatheTime / 2.0f ) * 1.2f) * walkSpeed / maxWalkSpeed - yaw * 1.5f, roll );
}
protected virtual void HandleSwayAnimation()
{
var swayspeed = 5;
// Fix the sway faster if we're ironsighting
if ( Weapon.IsAiming )
swayspeed = 20;
SwaySpeed = swayspeed;
// Lerp the eye position
lastEyeRot = Rotation.Lerp( lastEyeRot, player.Camera.WorldRotation, swayspeed * RealTime.SmoothDelta );
// Calculate the difference between our current eye angles and old (lerped) eye angles
var angDif = player.Camera.WorldRotation.Angles() - lastEyeRot.Angles();
angDif = new Angles( angDif.pitch, MathX.RadianToDegree( MathF.Atan2( MathF.Sin( MathX.DegreeToRadian( angDif.yaw ) ), MathF.Cos( MathX.DegreeToRadian( angDif.yaw ) ) ) ), 0 );
SwayLag = angDif;
// Perform sway
//
// ⚠️ Lifted into locals ONLY so they can be published — the arithmetic and the magic numbers
// are unchanged. 0.04 units and 0.2 degrees per degree of lag, capped at 1.5 units and 4
// degrees, are what decide how far the gun swings.
var posSway = new Vector3( Math.Clamp( angDif.yaw * 0.04f, -SwayPosLimit, SwayPosLimit ), 0.0f, Math.Clamp( angDif.pitch * 0.04f, -SwayPosLimit, SwayPosLimit ) );
var rotSway = new Vector3( Math.Clamp( angDif.pitch * 0.2f, -SwayRotLimit, SwayRotLimit ), Math.Clamp( angDif.yaw * 0.2f, -SwayRotLimit, SwayRotLimit ), 0.0f );
// ⚠️ PUBLISHED EVEN WHEN SUPPRESSED, and zeroed rather than skipped: SwayFreeTransform is
// computed by SUBTRACTING these from the target, so leaving a stale non-zero offset behind
// would make the ADS dot read a sway the gun is no longer doing.
if ( !Weapon.UseSway )
{
posSway = Vector3.Zero;
rotSway = Vector3.Zero;
}
targetVectorPos += posSway;
targetVectorRot += rotSway;
SwayPosOffset = posSway;
SwayRotOffset = rotSway;
}
/// <summary>
/// Does the aim pose own the viewmodel this frame.
/// </summary>
///
/// ⛔ ONE EXPRESSION, READ IN BOTH PLACES. `HandleIronAnimation` applies the aim pose and the
/// hip-offset branch stands down for it; when those two conditions drifted apart, aiming during
/// a reload satisfied NEITHER and the viewmodel dropped to the raw model origin for the whole
/// reload. They cannot drift if there is only one of them.
///
/// ⚠️ `AimHold` BYPASSES `IsReloading`, WHICH IS THE POINT. The sight editor has to hold the
/// weapon at the sights while the gun does whatever it does — and the Prisma recharges its clip
/// continuously, so "wait until it is not reloading" is not a state a person can tune in.
///
/// ⚠️ IT DOES NOT BYPASS `AimAnimData != Zero`. That is the weapon saying it has no aim pose at
/// all, and forcing one would hand the viewmodel a zero offset while ALSO standing the hip
/// offset down — the gun would jump to the model origin, which is the exact bug above.
public bool AimOwnsPose => Weapon.IsValid() && Weapon.AimAnimData != AngPos.Zero
&& (NZombies.SckPartsRig.AimHold || (Weapon.IsAiming && !Weapon.IsReloading));
protected virtual void HandleIronAnimation()
{
if ( AimOwnsPose )
{
var speedMod = 1f;
if ( aimTime == 0 )
{
aimTime = RealTime.Now;
}
var timeDiff = RealTime.Now - aimTime;
// Mod only while actively scoping
if ( Weapon.IsScoping || (!Weapon.IsScoping && timeDiff < 0.2f) )
{
speedMod = timeDiff * 10;
}
// ⛔ DEADSHOT'S ADS SPEED IS APPLIED HERE. Its multiplier had no reader
// outside WeaponStatsPanel, so a third of the perk was doing nothing.
//
// ⚠️ INSIDE THE AIMING BRANCH ONLY, never on the `animSpeed` reset that
// runs every frame. That reset is the rate for EVERY viewmodel pose — idle,
// run, crouch, reload — and scaling it there would speed the whole viewmodel
// up. Deadshot buys a faster aim-in, not a gun that animates at double time.
//
// ⚠️ AIM-IN ONLY, not aim-out: the `else` branch keeps its own rate. The
// perk is sold as "aim down sights faster" and lowering the gun quicker is
// not part of it.
//
// ⚠️ AND THE FOV RATE WITH IT. The pose and the zoom are one visual event
// to the player; speeding the model up while the FOV kept its own rate would
// have the gun arrive at the shoulder before the sight picture caught up,
// which looks like a bug rather than a faster aim.
//
// ⚠️ THE TECH NODES RIDE THE SAME LOCAL, rather than getting their own
// multiply further down. `adsSpeed` is what both the pose rate and the FOV
// rate below read, so folding them in here is what keeps the two moving
// together for a weapon that has a node, Deadshot, or both.
//
// ⛔ AND THEY COME THROUGH `AdsSpeedFactor`, NOT A LOCAL `Factor` CALL. That
// accessor is Quickdraw x Emplacement in one place, because this rate has a
// second home in PlayerCameraHandler and reading the nodes separately in the
// two files is what let Quickdraw ship half-wired. Emplacement's x0.25 would
// desync the gun pose from the world zoom by FOUR times.
//
// ⚠️ The magnitudes stay in WeaponTech.cs. `nz_tech` prints that catalogue,
// so a number typed at this call site would make the printed table a liar.
var adsSpeed = NZombies.PerkEffects.AimSpeedMultiplierFor( Weapon )
* NZombies.TechEffects.AdsSpeedFactor( Weapon );
animSpeed = 10 * Weapon.AnimSpeed * speedMod * adsSpeed;
targetVectorPos += Weapon.AimAnimData.Pos;
targetVectorRot += MathUtil.ToVector3( Weapon.AimAnimData.Angle );
if ( Weapon.AimInfo.ViewModelFOV > 0 )
targetWeaponFOV = Weapon.AimInfo.ViewModelFOV;
weaponFOVSpeed = Weapon.AimInfo.AimInFOVSpeed * adsSpeed;
}
else
{
aimTime = 0;
targetWeaponFOV = Weapon.ViewModelFOV;
}
}
protected virtual void HandleSprintAnimation()
{
if ( Weapon.IsRunning && Weapon.RunAnimData != AngPos.Zero && !Weapon.IsCustomizing )
{
targetVectorPos += Weapon.RunAnimData.Pos;
targetVectorRot += MathUtil.ToVector3( Weapon.RunAnimData.Angle );
}
}
protected virtual void HandleCustomizeAnimation()
{
if ( Weapon.IsCustomizing && Weapon.CustomizeAnimData != AngPos.Zero )
{
targetVectorPos += Weapon.CustomizeAnimData.Pos;
targetVectorRot += MathUtil.ToVector3( Weapon.CustomizeAnimData.Angle );
}
}
protected virtual void HandleJumpAnimation()
{
// If we're not on the ground, reset the landing animation time
if ( !player.IsOnGround )
landTime = RealTime.Now + 0.31f;
// Reset the timers once they elapse
if ( landTime < RealTime.Now && landTime != 0.0f )
{
landTime = 0.0f;
jumpTime = 0.0f;
}
// If we jumped, start the animation
if ( Input.Down( InputButtonHelper.Jump ) && jumpTime == 0.0f )
{
jumpTime = RealTime.Now + 0.31f;
landTime = 0.0f;
}
// If we're not ironsighting, do a fancy jump animation
if ( !Weapon.IsAiming )
{
if ( jumpTime > RealTime.Now )
{
// If we jumped, do a curve upwards
var f = 0.31f - (jumpTime - RealTime.Now);
var xx = MathUtil.BezierY( f, 0.0f, -4.0f, 0.0f );
var yy = 0.0f;
var zz = MathUtil.BezierY( f, 0.0f, -2.0f, -5.0f );
var pt = MathUtil.BezierY( f, 0.0f, -4.36f, 10.0f );
var yw = xx;
var rl = MathUtil.BezierY( f, 0.0f, -10.82f, -5.0f );
targetVectorPos += new Vector3( xx, yy, zz ) / 4.0f;
targetVectorRot += new Vector3( pt, yw, rl ) / 4.0f;
animSpeed = 20.0f;
}
else if ( !player.IsOnGround )
{
// Shaking while falling
var breatheTime = RealTime.Now * 30.0f;
targetVectorPos += new Vector3( MathF.Cos( breatheTime / 2.0f ) / 16.0f, 0.0f, -5.0f + (MathF.Sin( breatheTime / 3.0f ) / 16.0f) ) / 4.0f;
targetVectorRot += new Vector3( 10.0f - (MathF.Sin( breatheTime / 3.0f ) / 4.0f), MathF.Cos( breatheTime / 2.0f ) / 4.0f, -5.0f ) / 4.0f;
animSpeed = 20.0f;
}
else if ( landTime > RealTime.Now )
{
// If we landed, do a fancy curve downwards
var f = landTime - RealTime.Now;
var xx = MathUtil.BezierY( f, 0.0f, -4.0f, 0.0f );
var yy = 0.0f;
var zz = MathUtil.BezierY( f, 0.0f, -2.0f, -5.0f );
var pt = MathUtil.BezierY( f, 0.0f, -4.36f, 10.0f );
var yw = xx;
var rl = MathUtil.BezierY( f, 0.0f, -10.82f, -5.0f );
targetVectorPos += new Vector3( xx, yy, zz ) / 2.0f;
targetVectorRot += new Vector3( pt, yw, rl ) / 2.0f;
animSpeed = 20.0f;
}
}
else
targetVectorPos += new Vector3( 0.0f, 0.0f, Math.Clamp( localVel.z / 1000.0f, -1.0f, 1.0f ) );
}
}