"Citizen body aim needs SetLookDirection, not the aim_body floats"
▸ SYMPTOM
A Citizen's upper body will not track its aim direction. The held weapon points the same way no matter where the target sits, above or below.
The write looks correct. Reading back aim_body_pitch returns the exact angle you set, and aim_body_weight reads 1.00. Every number checks out, and the pose still does not move.
▸ CAUSE
The Citizen animgraph drives body aim from SetLookDirection( "aim_body", dir, weight ), not from the aim_body_pitch / aim_body_yaw floats. Writing only the floats reads back perfectly and moves nothing.
CitizenAnimationHelper.AimAngle localises a rotation (Target.WorldRotation.Inverse * value) and writes exactly those two floats. Copying that method looks complete, because it is the visible part of the helper. It is not complete. The same helper also feeds SetLookDirection( "aim_head" | "aim_body" | "aim_eyes", dir, weight ) from its own OnUpdate / WithLook, and the graph consumes that call, not the floats.
The measured evidence came from one run against an NPC whose upper body would not track its shot direction:
- The aim solver produced pitch
-39.4for a target 500 units up and 600 units across. - The renderer read back
aim_body_pitch=-39.4andaim_body_weight=1.00. - The held gun swung
0.0degrees between a target well above and one well below, while the shot direction swung79.6degrees. - Adding the three
SetLookDirectioncalls moved the gun75.8degrees.
▸ FIX
Call SetLookDirection on the SkinnedModelRenderer for each aim channel you drive. Keep writing the floats as well, because they are the only part of this that reads back, so a test can assert a write on them.
// This is what the graph actually consumes for body aim:
renderer.SetLookDirection( "aim_eyes", dir, weight );
renderer.SetLookDirection( "aim_head", dir, weight );
renderer.SetLookDirection( "aim_body", dir, weight );
// Keep the floats too: they read back, so a test can assert the write on them.
renderer.Set( "aim_body_pitch", pitch );
renderer.Set( "aim_body_yaw", yaw );Testing it: assert the pose, not the readback
A Set / GetFloat pair on a SkinnedModelRenderer is a dictionary. It holds any name you hand it whether or not the graph declares that name. A readback assertion proves the write happened and proves nothing about the effect.
Pair the readback with a physical assertion that needs no art numbers:
- Stage the same character against two inputs at opposite extremes.
- Sample the held model's world forward direction in each case.
- Grade the angle between the two samples.
That test needs no knowledge of which way the model's forward points and no threshold chosen from taste. It separates 0.0 from 75.8.
Staging a real vertical aim angle in a flat arena
You can stage a vertical aim angle without building new geometry, but the lift has two traps:
- Lifting a navmesh-driven character needs its
NavMeshAgentdisabled for the leg. The agent owns the position and snaps the character back to the mesh. - Lifting a player-controlled character needs the position rewritten every frame, not once. Gravity drops it hundreds of units inside a measurement window.
Pin the position per frame rather than disabling the controller. A disabled component is invisible to Scene.GetAllComponents, so the next stage that looks for it gets null, returns, and times out silently. In a capture script that shows up as two identical screenshots saved under two different names.
▸ WHY IT WORKS
The aim_body_pitch / aim_body_yaw floats are inert inputs the body-aim path does not read. SetLookDirection writes the look channel the graph consumes, so the pose moves. The physical assertion grades the visible pose, so it catches a write that lands in the renderer's dictionary but never reaches the animation.
- Published