Improve first-time Looks menu face framing

Replace ineffective third-person zoom with FOV narrowing (40°) to magnify the character's face for easier facial-feature editing. Center the player on the fixed LooksMenu camera's optical axis for the first few polls to prevent off-center framing caused by the camera not being perfectly aimed at the player. Capture and restore FOV and player transforms on menu open/close. Add diagnostic logging to track camera position, player position, and menu camera behavior.
This commit is contained in:
2026-06-28 21:31:15 +12:00
parent 2f37feb5b7
commit 37d6afbd5a
4 changed files with 239 additions and 2 deletions
+3
View File
@@ -53,6 +53,9 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
- First-time character creation launch now uses `coc COVault109` and waits for the `COVault109` start cell instead of `SanctuaryExt`. - First-time character creation launch now uses `coc COVault109` and waits for the `COVault109` start cell instead of `SanctuaryExt`.
- First-time `COVault109` launch now starts `CO_Vault109StartQuest` once per first-time join flow so the quest can move the player to its preset spawn marker. - First-time `COVault109` launch now starts `CO_Vault109StartQuest` once per first-time join flow so the quest can move the player to its preset spawn marker.
- `CO_Vault109StartQuest` now opens `ShowRaceMenu(player, 1)` after stage-10 spawn completion, waits for menu mode to close, then opens `ShowSPECIALMenu` for name/S.P.E.C.I.A.L. setup. - `CO_Vault109StartQuest` now opens `ShowRaceMenu(player, 1)` after stage-10 spawn completion, waits for menu mode to close, then opens `ShowSPECIALMenu` for name/S.P.E.C.I.A.L. setup.
- First-time Looks menu now narrows the camera FOV while open (restored on close) to magnify the face for easier facial-feature editing, since LooksMenu uses its own fixed camera that ignores third-person zoom offsets.
- First-time Looks menu now centers the player on the fixed LooksMenu camera's optical axis for the first few polls (preserving distance and facing the camera), fixing the off-center face caused by the FOV zoom; the player's original transform is restored when the menu closes.
- Added temporary diagnostic logging to LooksMenu centering (camera position/heading, target vs actual player position per poll) to confirm whether `SetPosition` takes effect and whether the menu camera is fixed or follows the player.
## [0.0.1] - 2026-06-23 ## [0.0.1] - 2026-06-23
Binary file not shown.
+68
View File
@@ -6274,3 +6274,71 @@ Fixed the first-time character customization close path so the third-person came
### Next Steps ### Next Steps
- Run first-time join through Vault 109, close LooksMenu, then confirm camera offset and movement controls before and after SPECIAL closes. - Run first-time join through Vault 109, close LooksMenu, then confirm camera offset and movement controls before and after SPECIAL closes.
---
## 2026-06-28 - Looks menu face close-up via FOV
### Summary
Replaced the ineffective third-person zoom approach for first-time LooksMenu face framing with a narrow camera FOV that magnifies the face, then restores the original FOV when LooksMenu closes.
### Files Changed
- `plugin/src/F4TSessionLaunch.cpp`
- `changelog.md`
- `docs/dev-log.md`
### Details
- Discovery: the Fallout 4 LooksMenu uses its own fixed camera, not the third-person camera. Writing `ThirdPersonState` zoom/shoulder/height offsets has no visual effect because that state is not active during LooksMenu and the game re-updates the camera every frame. Confirmed against the `LooksmenuPlayerRotation` mod, which never moves the camera and instead repositions the actor in front of a fixed, frozen camera.
- Reverted the no-op zoom/height tweak (`kLooksMenuFaceCameraZoomOffset` back to `-90.0`, removed the height adjust) since it cannot change the LooksMenu view.
- Added FOV-based zoom: on first LooksMenu-open detection, capture current world/first-person FOV, then issue console `fov 40` to narrow the field of view and enlarge the face.
- Re-assert the narrow FOV during the existing camera-nudge polls so the menu cannot revert it.
- On LooksMenu close (and on reset/retry paths), restore the captured FOV via `fov <firstPerson> <world>`.
### Testing
- Not run in this session (Fallout 4/F4SE runtime not available in this environment).
- Expected manual check: start a first-time server join from the main menu, wait for LooksMenu to open in Vault 109, and confirm the face now fills much more of the screen (visible zoom-in) compared to before.
- Confirm that after closing LooksMenu the FOV returns to normal before SPECIAL opens, and that movement/gameplay FOV is unchanged afterwards.
- Check `CommonwealthOnline.log` for `Captured FOV before face-edit zoom` and `Restored FOV after face-edit zoom`.
### Known Issues
- The close-up amount is a fixed FOV (`kLooksMenuFaceCameraFOV = 40`); it is a telephoto zoom rather than a physical dolly-in, so perspective is slightly flattened.
- FOV is applied via the console `fov` command (affects both first- and third-person FOV while open); it is restored on close, but an abnormal exit before restore would leave a narrowed FOV until the next launch flow restores it.
### Next Steps
- If a stronger/weaker close-up is wanted, tune `kLooksMenuFaceCameraFOV`.
- If a true physical dolly-in is preferred, implement the actor-reposition approach used by `LooksmenuPlayerRotation` (capture fixed camera pos/angle, move the player closer in front of it, snapshot/restore player position).
---
## 2026-06-28 - Center player in LooksMenu
### Summary
The FOV zoom worked but magnified the LooksMenu camera's existing horizontal offset, leaving the face pushed to one side of the screen. Added player-centering so the character sits on the camera's optical axis.
### Files Changed
- `plugin/src/F4TSessionLaunch.cpp`
- `changelog.md`
- `docs/dev-log.md`
### Details
- Because the LooksMenu camera is fixed and not aimed exactly at the player, narrowing the FOV amplified the off-center framing.
- Adopted the actor-reposition technique from `LooksmenuPlayerRotation`: capture the fixed camera world position (`PlayerCamera::GetCameraPosition`) and yaw (`PlayerCamera::heading`) once, then place the player on the camera's forward axis.
- Centering preserves the player's straight-line distance to the camera (so the existing zoom level is unchanged) and faces the player back toward the camera for a front-on view.
- Centering runs only for the first `kLooksMenuCenterMaxPolls` (6) polls after LooksMenu opens (handles camera settle), then stops so the menu's own model rotation/controls work normally.
- Snapshots the player's pre-LooksMenu position and angle and restores them when the menu closes (and on reset/retry paths), so post-customization gameplay starts at the original spawn marker.
- Used `Actor::SetPosition`, `TESObjectREFR::SetAngleOnReference`, and `Update3DPosition` for the move; vertical (Z) position is left unchanged.
### Testing
- Not run in this session (Fallout 4/F4SE runtime not available in this environment).
- Expected manual check: start a first-time server join, wait for LooksMenu in Vault 109, and confirm the face is now centered (not pushed to the right) and facing the camera, with the same zoom level.
- Confirm the player can still rotate the model via the menu after the first ~1.5s of auto-centering.
- Confirm the player returns to the original spawn position after LooksMenu closes and before SPECIAL opens.
- Check `CommonwealthOnline.log` for `Captured player transform before LooksMenu centering` and `Restored player transform after LooksMenu centering`.
### Known Issues
- Centering relies on `GetCameraPosition(..., true)` returning the camera's world position and on `PlayerCamera::heading` being the camera yaw; if framing is wrong in-game, the yaw source/sign or distance may need adjustment.
- Vertical framing is not adjusted; if the face sits too high/low, a Z offset can be added.
- If the camera is still settling when captured on the first poll, centering may be slightly off; increasing `kLooksMenuCenterMaxPolls` or delaying the first capture would help.
### Next Steps
- Verify in-game and tune `kLooksMenuCenterMaxPolls` / add a vertical offset if needed.
+168 -2
View File
@@ -32,6 +32,15 @@ namespace
constexpr int kLooksMenuCameraNudgeMaxPolls = 20; constexpr int kLooksMenuCameraNudgeMaxPolls = 20;
constexpr float kPi = 3.14159265358979323846F; constexpr float kPi = 3.14159265358979323846F;
constexpr float kLooksMenuFaceCameraZoomOffset = -90.0F; constexpr float kLooksMenuFaceCameraZoomOffset = -90.0F;
// LooksMenu runs its own fixed camera, so ThirdPersonState zoom is ignored. A narrow
// world FOV is used instead to magnify the face for easier facial-feature editing.
constexpr float kLooksMenuFaceCameraFOV = 40.0F;
constexpr float kDefaultWorldFOV = 80.0F;
// LooksMenu camera is fixed and not aimed at the player, so the zoom pushes the face
// off-centre. Move the player onto the camera's optical axis for the first few polls to
// centre them, then stop so the menu's own model rotation/controls work normally.
constexpr int kLooksMenuCenterMaxPolls = 6;
constexpr float kLooksMenuCenterFallbackDistance = 150.0F;
struct ThirdPersonCameraSnapshot struct ThirdPersonCameraSnapshot
{ {
@@ -72,6 +81,15 @@ namespace
bool g_sawSpecialMenuOpen = false; bool g_sawSpecialMenuOpen = false;
bool g_looksMenuFallbackIssued = false; bool g_looksMenuFallbackIssued = false;
bool g_looksMenuCameraContextApplied = false; bool g_looksMenuCameraContextApplied = false;
bool g_looksMenuFovApplied = false;
float g_preLooksWorldFov = 0.0F;
float g_preLooksFirstPersonFov = 0.0F;
bool g_looksMenuCameraCaptured = false;
RE::NiPoint3 g_looksMenuCameraPos{};
float g_looksMenuCameraYaw = 0.0F;
int g_looksMenuCenterPolls = 0;
std::optional<RE::NiPoint3> g_preLooksPlayerPos;
std::optional<RE::NiPoint3> g_preLooksPlayerAngle;
std::optional<ThirdPersonCameraSnapshot> g_preLooksCameraSnapshot; std::optional<ThirdPersonCameraSnapshot> g_preLooksCameraSnapshot;
int g_spawnPollFrameCounter = 0; int g_spawnPollFrameCounter = 0;
int g_spawnWaitAttempts = 0; int g_spawnWaitAttempts = 0;
@@ -407,6 +425,131 @@ namespace
LogInfo("Prepared camera context before Looks menu."); LogInfo("Prepared camera context before Looks menu.");
} }
void ApplyLooksMenuFaceFOV()
{
auto* camera = RE::PlayerCamera::GetSingleton();
if (!camera) {
LogWarning("PlayerCamera unavailable while applying face-edit FOV.");
return;
}
if (!g_looksMenuFovApplied) {
g_preLooksWorldFov = camera->worldFOV > 1.0F ? camera->worldFOV : kDefaultWorldFOV;
g_preLooksFirstPersonFov = camera->firstPersonFOV > 1.0F ? camera->firstPersonFOV : kDefaultWorldFOV;
g_looksMenuFovApplied = true;
LogInfo(std::format(
"Captured FOV before face-edit zoom (world={:.1f}, firstPerson={:.1f}).",
g_preLooksWorldFov,
g_preLooksFirstPersonFov));
}
// Console 'fov <value>' sets both first-person and world FOV; a narrow value
// magnifies the LooksMenu face view because the menu camera distance is fixed.
RE::Console::ExecuteCommand(std::format("fov {:.0f}", kLooksMenuFaceCameraFOV).c_str());
}
void RestoreLooksMenuFaceFOV()
{
if (!g_looksMenuFovApplied) {
return;
}
const float firstPersonFov = g_preLooksFirstPersonFov > 1.0F ? g_preLooksFirstPersonFov : kDefaultWorldFOV;
const float worldFov = g_preLooksWorldFov > 1.0F ? g_preLooksWorldFov : kDefaultWorldFOV;
// 'fov <first-person> <world>' restores both planes to their pre-LooksMenu values.
RE::Console::ExecuteCommand(std::format("fov {:.0f} {:.0f}", firstPersonFov, worldFov).c_str());
g_looksMenuFovApplied = false;
g_preLooksWorldFov = 0.0F;
g_preLooksFirstPersonFov = 0.0F;
LogInfo("Restored FOV after face-edit zoom.");
}
void CenterPlayerForLooksMenu()
{
auto* camera = RE::PlayerCamera::GetSingleton();
auto* player = RE::PlayerCharacter::GetSingleton();
if (!camera || !player) {
return;
}
const RE::NiPoint3 playerPos = player->GetPosition();
if (!g_preLooksPlayerPos) {
g_preLooksPlayerPos = playerPos;
g_preLooksPlayerAngle = RE::NiPoint3{ player->data.angle };
LogInfo("Captured player transform before LooksMenu centering.");
}
// Read the camera each poll so we can detect (via logs) whether the LooksMenu camera
// is fixed in world or follows the player after we move them.
RE::NiPoint3 freshCameraPos{};
const bool gotCamera = camera->GetCameraPosition(freshCameraPos, true);
const float freshYaw = camera->heading;
if (!g_looksMenuCameraCaptured) {
g_looksMenuCameraPos = freshCameraPos;
g_looksMenuCameraYaw = freshYaw;
g_looksMenuCameraCaptured = true;
}
const float dx = playerPos.x - g_looksMenuCameraPos.x;
const float dy = playerPos.y - g_looksMenuCameraPos.y;
float distance = std::sqrt(dx * dx + dy * dy);
if (distance < 1.0F) {
distance = kLooksMenuCenterFallbackDistance;
}
// Place the player on the camera's forward axis (centres them horizontally) at the
// same distance (preserves the current zoom), facing back toward the camera.
const RE::NiPoint3 centeredPos{
g_looksMenuCameraPos.x + distance * std::sin(g_looksMenuCameraYaw),
g_looksMenuCameraPos.y + distance * std::cos(g_looksMenuCameraYaw),
playerPos.z
};
player->SetPosition(centeredPos, true);
player->SetAngleOnReference(RE::NiPoint3{ 0.0F, 0.0F, NormalizeRadians(g_looksMenuCameraYaw + kPi) });
player->Update3DPosition(true);
const RE::NiPoint3 afterPos = player->GetPosition();
LogInfo(std::format(
"Center poll {}: gotCam={} capCam=({:.1f},{:.1f},{:.1f}) freshCam=({:.1f},{:.1f},{:.1f}) yaw={:.3f}/{:.3f} dist={:.1f} before=({:.1f},{:.1f},{:.1f}) target=({:.1f},{:.1f},{:.1f}) after=({:.1f},{:.1f},{:.1f}).",
g_looksMenuCenterPolls,
gotCamera,
g_looksMenuCameraPos.x, g_looksMenuCameraPos.y, g_looksMenuCameraPos.z,
freshCameraPos.x, freshCameraPos.y, freshCameraPos.z,
g_looksMenuCameraYaw, freshYaw,
distance,
playerPos.x, playerPos.y, playerPos.z,
centeredPos.x, centeredPos.y, centeredPos.z,
afterPos.x, afterPos.y, afterPos.z));
}
void RestorePlayerAfterLooksMenu()
{
if (!g_preLooksPlayerPos) {
g_looksMenuCameraCaptured = false;
g_looksMenuCenterPolls = 0;
return;
}
auto* player = RE::PlayerCharacter::GetSingleton();
if (player) {
player->SetPosition(*g_preLooksPlayerPos, true);
if (g_preLooksPlayerAngle) {
player->SetAngleOnReference(*g_preLooksPlayerAngle);
}
player->Update3DPosition(true);
LogInfo("Restored player transform after LooksMenu centering.");
}
g_preLooksPlayerPos.reset();
g_preLooksPlayerAngle.reset();
g_looksMenuCameraCaptured = false;
g_looksMenuCenterPolls = 0;
}
void ApplyLooksMenuCameraContext() void ApplyLooksMenuCameraContext()
{ {
if (g_looksMenuCameraContextApplied) { if (g_looksMenuCameraContextApplied) {
@@ -417,20 +560,23 @@ namespace
CapturePreLooksCameraState(); CapturePreLooksCameraState();
ForcePlayerThirdPersonCamera(); ForcePlayerThirdPersonCamera();
RE::Console::ExecuteCommand("ForceThirdPerson"); RE::Console::ExecuteCommand("ForceThirdPerson");
ApplyLooksMenuFaceFOV();
g_looksMenuCameraContextApplied = true; g_looksMenuCameraContextApplied = true;
g_looksMenuCameraNudgePolls = 0; g_looksMenuCameraNudgePolls = 0;
LogInfo("Applied chargen camera context after Looks menu open."); LogInfo("Applied close face-edit camera context (narrow FOV) after Looks menu open.");
} }
void RestoreCharGenCameraContext() void RestoreCharGenCameraContext()
{ {
if (!g_looksMenuCameraContextApplied && !g_preLooksCameraSnapshot) { if (!g_looksMenuCameraContextApplied && !g_looksMenuFovApplied && !g_preLooksCameraSnapshot && !g_preLooksPlayerPos) {
return; return;
} }
if (g_looksMenuCameraContextApplied) { if (g_looksMenuCameraContextApplied) {
RE::Console::ExecuteCommand("SetInChargen 0"); RE::Console::ExecuteCommand("SetInChargen 0");
} }
RestoreLooksMenuFaceFOV();
RestorePlayerAfterLooksMenu();
RestorePreLooksCameraState(); RestorePreLooksCameraState();
g_looksMenuCameraContextApplied = false; g_looksMenuCameraContextApplied = false;
g_looksMenuCameraNudgePolls = 0; g_looksMenuCameraNudgePolls = 0;
@@ -449,6 +595,13 @@ namespace
g_sawSpecialMenuOpen = false; g_sawSpecialMenuOpen = false;
g_looksMenuFallbackIssued = false; g_looksMenuFallbackIssued = false;
g_looksMenuCameraContextApplied = false; g_looksMenuCameraContextApplied = false;
g_looksMenuFovApplied = false;
g_preLooksWorldFov = 0.0F;
g_preLooksFirstPersonFov = 0.0F;
g_looksMenuCameraCaptured = false;
g_looksMenuCenterPolls = 0;
g_preLooksPlayerPos.reset();
g_preLooksPlayerAngle.reset();
g_preLooksCameraSnapshot.reset(); g_preLooksCameraSnapshot.reset();
g_spawnPollFrameCounter = 0; g_spawnPollFrameCounter = 0;
g_spawnWaitAttempts = 0; g_spawnWaitAttempts = 0;
@@ -469,6 +622,13 @@ namespace
g_sawSpecialMenuOpen = false; g_sawSpecialMenuOpen = false;
g_looksMenuFallbackIssued = false; g_looksMenuFallbackIssued = false;
g_looksMenuCameraContextApplied = false; g_looksMenuCameraContextApplied = false;
g_looksMenuFovApplied = false;
g_preLooksWorldFov = 0.0F;
g_preLooksFirstPersonFov = 0.0F;
g_looksMenuCameraCaptured = false;
g_looksMenuCenterPolls = 0;
g_preLooksPlayerPos.reset();
g_preLooksPlayerAngle.reset();
g_preLooksCameraSnapshot.reset(); g_preLooksCameraSnapshot.reset();
g_spawnPollFrameCounter = 0; g_spawnPollFrameCounter = 0;
g_spawnWaitAttempts = 0; g_spawnWaitAttempts = 0;
@@ -669,6 +829,7 @@ namespace
g_looksMenuFallbackIssued = false; g_looksMenuFallbackIssued = false;
g_looksMenuOpenPolls = 0; g_looksMenuOpenPolls = 0;
g_looksMenuCameraNudgePolls = 0; g_looksMenuCameraNudgePolls = 0;
g_looksMenuCenterPolls = 0;
OpenLooksMenuPrimary(); OpenLooksMenuPrimary();
g_charGenPhase = CharGenPhase::WaitingForLooksMenuClose; g_charGenPhase = CharGenPhase::WaitingForLooksMenuClose;
return; return;
@@ -684,6 +845,11 @@ namespace
++g_looksMenuCameraNudgePolls; ++g_looksMenuCameraNudgePolls;
ForcePlayerThirdPersonCamera(); ForcePlayerThirdPersonCamera();
RE::Console::ExecuteCommand("ForceThirdPerson"); RE::Console::ExecuteCommand("ForceThirdPerson");
ApplyLooksMenuFaceFOV();
}
if (g_looksMenuCenterPolls < kLooksMenuCenterMaxPolls) {
++g_looksMenuCenterPolls;
CenterPlayerForLooksMenu();
} }
return; return;
} }