Reference · Updated 2552.09.10.16.15
Aim Down Sights (ADS)
Camera FOV zoom, tightened bullet spread and reduced look sensitivity while aiming.
Camera FOV zoom, tightened bullet spread and reduced look sensitivity while aiming.
One rule for every weapon: LT aims whatever is in your hands. This replaced the original design, where ADS was derived from "the sidearm is drawn" — that was really the pistol's implementation standing in for a feature, and it meant the pistol could not be hip-fired and no other weapon could be aimed at all. The sidearm draw has since been remapped to the left shoulder. Any weapon opts in by setting ADSFieldOfView > 0.
ADS narrows the world camera FOV; the FP viewmodel (arms + weapons) is rendered at a separate, fixed first-person FOV so it does not swell with the zoom — UE's native first-person rendering does the projection split (see "The viewmodel swell fix" below). An earlier manual viewmodel counter-scale was tried and abandoned first; that history is kept as a footgun record.
#Data
USLWeaponDataAsset::ADSFieldOfView (degrees, `SystemLink | ADS`). 0 = no ADS zoom, and it is also the switch |
|---|
that enables ADS at all for that weapon — TickADS reads bAimHeld && HeldData->ADSFieldOfView > 0.f, so a weapon left at 0 ignores the aim input entirely. Lower = more zoom. The base FOV is 95, not 90.
Live values (read off the saved assets 2026-08-25 — the 2026-08-17 set was stale in every row):
| Weapon | ADSFieldOfView | Zoom vs. base 95 | ADSFirstPersonFieldOfView |
|---|---|---|---|
DA_AssaultRifle | 80 | 1.30x | 80 |
DA_Shotgun | 91 | 1.07x | 70 |
DA_SL_Pistol | 50.9 | 2.29x | 60 |
ℹ The shotgun's 91 is barely a zoom at all, while its viewmodel FOV moves a full 25 degrees — ADS on the shotgun is almost entirely a viewmodel move. Confirmed intentional and tuned to taste 2026-08-25; do not "fix" it toward the other two.
USLWeaponDataAsset::ADSFirstPersonFieldOfView (degrees, same category) moves the viewmodel's own FOV on the same alpha. 0 = leave it alone (default) — the viewmodel holds its size while the world zooms, which is the behaviour that fixed the swell.
⚠ The direction is the opposite of what the number suggests. Against the resting 95:
below it the world zooms past a receding viewmodel; above it the viewmodel magnifies. A narrower FOV
normally magnifies, so this reads backwards — but both ends were tested on the AR (100 pushed the wrong way, 80
gave the wanted look) and DA_AssaultRifle ships 80. UE rescales first-person primitives to hold their
screen size, so it does not behave like a plain projection change. This doc and the header both claimed the
reverse until 2026-08-24.
Zoom is reported as a multiplier by ASLPlayerCharacter::GetADSZoomMultiplier() — the ratio of the half-angle tangents, not of the angles, so 95 → 70 is 1.56x (a naive 95/70 would understate it as 1.36x). Halo CE's AR prints 1.4x, which ADSFieldOfView = 76 would match exactly. See SystemLink.Character.ZoomMultiplier.
#Implementation
ASLPlayerCharacter::TickADS(DeltaTime) — local only (it's the FP camera):
- Captures the base FOV once in
BeginPlay(DefaultFieldOfView = Camera->FieldOfView).
- Aiming =
bAimHeldand the held weapon defines anADSFieldOfView > 0.bAimHeldis set by the ADS input
action on ASLPlayerController via SetAimHeld(); the held weapon comes from GetActiveFireWeaponData(), which already resolves the sidearm-vs-primary indirection, so it reads correctly either way.
ADSFOVAlpha(0 = base, 1 = ADS) advances toward the target withFMath::FInterpConstantToat rate
1 / Duration, where Duration = the weapon's ADSTransitionInDuration / ADSTransitionOutDuration.
ADSWeaponDataretains the weapon the current zoom belongs to, so releasing aim and then swapping or
holstering still eases back rather than snapping — there would otherwise be nothing in hand to read an out-duration from. It is cleared only once the zoom has fully returned to hip.
Camera->SetFieldOfView(Lerp(DefaultFieldOfView, ADSFieldOfView, ADSFOVAlpha)), and it leaves the camera alone
once settled back to base (so it doesn't fight anything else that drives FOV).
#The viewmodel swell fix — separate first-person FOV (shipped 2026-07-05)
Narrowing the camera FOV for ADS magnifies the FP viewmodel too, because the arms + weapons were rendered with the same camera FOV as the world. At 95→65 that's a ~1.7× blow-up of something centimetres from the camera, and because it magnifies about the screen centre it also shoves the off-centre gun down/off-frame and shears it — the "giant pistol / gripping air" look. It is not a socket bug; re-seating the socket won't touch it.
The fix uses UE 5.5+ native first-person rendering, which renders flagged primitives at their own FOV independent of the camera:
- Meshes (
ASLCharacterBaseconstructor):FPMesh,FPWeaponMesh,FPSidearmMeshset
FirstPersonPrimitiveType = EFirstPersonPrimitiveType::FirstPerson. The sidearm mesh missing this flag was the original bug — the pistol is the sidearm, so it was the one FP mesh still rendering at the world FOV and swelling.
- Camera (
ASLPlayerCharacterconstructor):bEnableFirstPersonFieldOfView = true,
FirstPersonFieldOfView = 95 (matched to the base FOV so the arms look identical at rest).
- Project (
Config/DefaultEngine.ini):r.FirstPerson.Enabled=True. This is a read-only render cvar — it
only reads at startup and gates shader permutations, so it can't be toggled at runtime; changing it needs an editor restart.
Result: ADS narrows the world FOV (the world zooms) while the viewmodel holds its size and position at the fixed FP FOV. Verified clean with the project's Substrate materials + Virtual Shadow Maps (the risk with an experimental render path — no artefacts on the FP meshes).
⚠ The old "FirstPersonFieldOfView is hardcoded to 95" limitation is gone (2026-08-21). The resting value
lives in ASLPlayerCharacter::FirstPersonBaseFieldOfView (still 95, still matched to the base FOV so the arms
look identical at rest), andTickADSnow lerps the viewmodel FOV per weapon viaADSFirstPersonFieldOfView.
If a runtime player FOV setting is added, drive FirstPersonBaseFieldOfView from the same source as the base FOV.
These live in C++ (single source of truth for every character). BP_SL_MasterChief's CDO may still carry
redundant-but-identical overrides from the spike — harmless; reset them to default post-rebuild if you want the
.uasset clean.
#Viewmodel counter-scale (superseded — tried and abandoned 2026-07-03)
This was the first attempt at the swell above, before the first-person-FOV fix — cancel the magnification by counter-scaling the viewmodel. Both implementations broke; kept as a footgun record. The camera FOV magnifies everything by tan(Default/2) / tan(Current/2), and the FP weapon sits centimeters from the camera, so it swells far more than the distant world. The plan was to scale the FP viewmodel by the inverse (ADSScaleCompensation = tan(Current/2) / tan(Default/2), ~0.64 at 95→65). Two implementations, both broke — diagnosed live via the bridge on the drawn pawn:
- Folded into the ARMS mesh scale (
ApplyFPWeaponObstructionScale): shrinking the arm rig toward its pivot
(which sits well below the camera) dragged the socket-attached pistol ~81 cm below the camera, out the bottom of frame → pistol invisible while aiming. It was rendering fine, just at the player's feet.
- Applied to the PISTOL mesh only (scale in place about its socket): the pistol held its size and stayed in
hand, but the arms — deliberately left out to avoid the translation above — ballooned with the FOV, so a giant hand wrapped a normal pistol and read as "gripping air."
Keeping arms + weapon proportional requires scaling them together, which reintroduces the translation — solvable only by scaling the whole rig about the camera. That's effectively what UE's first-person rendering does for free, which is why the counter-scale was dropped in favour of it (see "The viewmodel swell fix" above). ApplyFPWeaponObstructionScale is back to obstruction-only (BaseScale × ObstructionRatio).
TheADSScaleCompensationmember that fed this has been removed fromSLPlayerCharacter.h.
Separately (Option B, this session): the FP sidearm now has its own decoupled FP scale. It's a child of the arms and used to inherit the arms' scale — which is the equipped main weapon's FirstPersonMeshScale — so the pistol's size depended on which primary you held. ApplyFPWeaponObstructionScale now divides that main-weapon baseline back out, so sidearm world scale = DA_SL_<Sidearm>.FirstPersonMeshScale × obstruction. Tune the pistol size directly via DA_SL_Pistol.FirstPersonMeshScale (live data value).
#Superseded — "read the sidearm's OWN data"
This doc used to instruct reading GetSidearmWeapon()->WeaponData rather than GetActiveFireWeaponData(), because the active-fire indirection flips back to the primary the instant the pistol holsters and would lose the FOV + duration mid-zoom-out, snapping the FOV. The problem was real; the fix is now the ADSWeaponData retention above, which solves it for every weapon rather than just the sidearm. Read GetActiveFireWeaponData().
#Footgun hit during build — FOV vibrated + FP gun looked detached
First implementation advanced the alpha with a manual signed step: Dir = (Target > Alpha) ? +1 : -1; Alpha += Dir * dt/Duration. When Alpha reaches Target, Target > Alpha is false → Dir flips to −1 and pushes Alpha back → it oscillates ±step every frame. Because the FP weapon sits right against the camera, a FOV pumping ~25° per frame flung its apparent size/position around (blurry, "gun not in hand") while the distant world looked stable. Fix: FInterpConstantTo — advances at a constant rate and stops exactly at the target. (See Docs/Footguns.md.)
#Status
FOV zoom, spread and sensitivity are all built and in the tree (branch ads-assault-rifle-shotgun). The viewmodel fix was verified 2026-07-05 with Substrate + VSM, no FP artefacts. Tune a weapon's on-screen size via DA_SL_<Weapon>.FirstPersonMeshScale (see the counter-scale section's Option B note).
⚠ A full rebuild + editor restart is needed after any change to the first-person render path — the r.FirstPerson.Enabled cvar is read-only and only reads at startup.
PIE-tested 2026-08-25: the AR and pistol spread-during-fire both read correctly in game. All three reticles (AR, shotgun, pistol) are wired and confirmed working.
#Reticle fire bloom — who has it, and why the shotgun does not
Fire bloom is the reticle-only bloom that firing adds on top of the movement-derived spread (FSLWeaponSpreadSettings::FireBloomPerShot, opt-in per weapon, 0 = off). It is cosmetic: it does not widen the bullet cone, which is FSLWeaponFireMode above and tuned separately.
| Weapon | FireBloomPerShot | MaxFireBloom | Decay/sec | MaxSpread |
|---|---|---|---|---|
DA_AssaultRifle | 15 | 100 | 110 | 75 |
DA_SL_Pistol | 10 | 20 | 20 | 20 |
DA_Shotgun | 0 — off, deliberately | — | — | 50 |
⚠ The shotgun's 0 is a decision, not an omission. Its firing reticle reaction is driven by a custom
animation on the reticle Blueprint instead, so adding fire bloom here would double up on a reaction that
already exists. Confirmed 2026-08-25 — leave it at 0.
ℹ Fire bloom IS the AR's firing reticle reaction. A separate Anim_FireKick UMG animation was planned and
dropped 2026-08-25 — bloom achieves the look, and it avoids the retrigger trap that PlayAnimation hits at
600 RPM (it restarts from frame 0 every 0.100 s and reads as frozen). Do not re-add it. ⚠ USLReticle::OnWeaponFired()
is still live regardless — the Shotgun reticle binds it for its own animation.
The rule if you ever do tune one: FireBloomPerShot × shots-per-second MUST exceed FireBloomDecayPerSecond or nothing accumulates. Time to cap = Max ÷ (influx − decay); recovery = Max ÷ decay.
Spread reaches the reticle additively and unclamped — movement + FireBloom (SLPlayerPerceptionComponent.cpp) — so MaxSpread keeps meaning "as bad as moving gets" rather than becoming a shared ceiling. The AR's bloom ceiling (100) therefore exceeds its movement ceiling (75) on purpose: firing opens the reticle wider than sprinting does. Tuned and signed off 2026-08-25.
#Spread — ADSSpreadMultiplier
Per fire mode (FSLWeaponFireMode, not per weapon), so primary and secondary tune independently. 1.0 = no change (the default — ADS must never silently tighten a weapon whose designer never opted in), 0.0 = pinpoint.
USLGameplayAbility_Fire::CalculateConeAngle() is the whole of it:
ConeAngle = BulletConeAngleDegrees
if (MaxBulletConeAngleDegrees > ConeAngle && MaxBulletSpreadSpeed > 0)
ConeAngle = Lerp(ConeAngle, MaxBulletConeAngleDegrees, GroundSpeed / MaxBulletSpreadSpeed)
if (bAiming)
ConeAngle *= ADSSpreadMultiplier
The multiplier is applied AFTER the movement lerp, never before. Multiplying the base angle first would let aiming cancel the movement penalty outright, making a sprinting-and-aiming player as accurate as a stationary one. Covered by SystemLink.Weapons.ConeAngle (SLConeAngleTest.cpp), including the two cases that look like bugs when you hit them: a 1.0 multiplier must be a no-op, and a multiplier on a zero base cone is still zero — which is why setting ADSSpreadMultiplier on a pinpoint weapon appears to do nothing.
The resulting angle feeds FMath::VRandCone per pellet, so on a shotgun every pellet gets the tightened cone.
#Live values (verified in-editor 2026-08-17, primary fire mode)
| Weapon | Base cone | Max cone (moving) | ADSSpreadMultiplier | Aimed cone | Pellets |
|---|---|---|---|---|---|
DA_AssaultRifle | 2.0° | off | 0.5 | 1.0° | 1 |
DA_Shotgun | 5.0° | off | 0.8 | 4.0° | 8 |
DA_SL_Pistol | 0.5° | 1.5° @ 600 cm/s | 1.0 (none) | 0.5–1.5° | 1 |
- The AR is a flat cone in every state —
MaxBulletConeAngleDegreesis 0, so it has no movement spread.
Standing, sprinting and aimed differ only by the ADS multiplier.
- The shotgun is deliberately kept wide at 4.0°. Tighten it much further and it stops being a shotgun.
- The pistol is the only weapon with movement spread wired up, and has no ADS tightening — its accuracy
comes from standing still, not from aiming.
⚠ Local only, and NOT server-validated. The cone is random (VRandCone), so client and server could never
agree on a direction anyway — the client traces and the server validates the resulting hit. The server
therefore never reads ADSSpreadMultiplier, and a modified client could already fire perfectly straight. This
is pre-existing and not specific to ADS. If cone validation is ever added server-side, **aim state has to be
replicated first — it currently is not** (bAimHeld is local input state).
⚠ Spread snaps, zoom eases.IsAiming()returnsbAimHeldraw, notGetADSAlpha(), so the tightened cone
applies on the frame you press aim while the FOV is still ramping over ADSTransitionInDuration. Deliberate —
it is what Halo does — but if you ever want accuracy to track the zoom, that is the one line to change.
#Sensitivity
Done, and driven by the player profile, not the weapon: ASLPlayerController lerps 1.0 → ADSSensitivityMul by GetADSAlpha(), so aim slows smoothly with the FOV rather than stepping at the moment of press (the opposite choice to spread, above). Applies to both yaw and pitch, after the invert flags.
#Not built yet / future
- Weapon aim pose + reticle swap — needs a centered aim anim (Maya). The reticle also needs to know that ADS
does not display the weapon mesh in FP, so it can adjust.
- FP mesh hide while aiming — must go through
ApplyFirstPersonMeshVisibility(), the single owner of FP
visibility since BUG-030. A second writer means toggling view mode while aiming un-hides the weapon.
- Per-weapon sensitivity —
ADSSensitivityMulis currently one profile-wide value for every weapon.
- If a runtime FOV setting is added, re-cache
DefaultFieldOfViewwhen it changes so ADS respects it.
#Related
Docs/SidearmMode.md— the sidearm draw/holster ADS used to be derived from, now remapped to left shoulder.
Docs/WeaponFireAbility.md—CalculateConeAngle/ComputeFireDirectionand the pellet trace path.
Docs/Shotgun.md— pellet count and the wide-cone reasoning.
Docs/BugTracker.md→ BUG-030 — whyApplyFirstPersonMeshVisibility()must stay the single FP visibility owner.