From 4c6584d1ecc57e8a34e078935b03851b59d4f9eb Mon Sep 17 00:00:00 2001 From: Hadet Date: Thu, 28 May 2026 21:27:52 -0500 Subject: add Over-The-Shoulder mouse look alternative --- indra/newview/app_settings/settings.xml | 57 ++++++++++ indra/newview/llagent.cpp | 18 ++- indra/newview/llagentcamera.cpp | 76 ++++++++++++- indra/newview/llagentcamera.h | 9 +- indra/newview/llquickprefs.cpp | 23 ++++ indra/newview/llquickprefs.h | 5 + indra/newview/llviewermenu.cpp | 24 +++- .../skins/default/xui/en/floater_quick_prefs.xml | 126 ++++++++++++++++++++- .../default/xui/en/panel_preferences_move.xml | 11 ++ 9 files changed, 335 insertions(+), 14 deletions(-) diff --git a/indra/newview/app_settings/settings.xml b/indra/newview/app_settings/settings.xml index 481cafafd1..dbe10a528a 100644 --- a/indra/newview/app_settings/settings.xml +++ b/indra/newview/app_settings/settings.xml @@ -1365,6 +1365,63 @@ 1 + + OTSEnabled + + Comment + When true, M key enters OTS shoulder cam instead of first-person mouselook + Persist + 1 + Type + Boolean + Value + 0 + + OTSCameraDistance + + Comment + OTS camera distance behind avatar (meters) + Persist + 1 + Type + F32 + Value + 3.0 + + OTSCameraSide + + Comment + OTS camera side offset (negative = right of avatar) + Persist + 1 + Type + F32 + Value + -0.5 + + OTSCameraHeight + + Comment + OTS camera height above avatar root (meters) + Persist + 1 + Type + F32 + Value + 0.5 + + OTSFocusDistance + + Comment + OTS focus point distance in front of avatar (meters) + Persist + 1 + Type + F32 + Value + 10.0 + + CameraOffset Comment diff --git a/indra/newview/llagent.cpp b/indra/newview/llagent.cpp index 23647487b0..75a9ef58fc 100644 --- a/indra/newview/llagent.cpp +++ b/indra/newview/llagent.cpp @@ -2123,6 +2123,13 @@ std::ostream& operator<<(std::ostream &s, const LLAgent &agent) //----------------------------------------------------------------------------- bool LLAgent::needsRenderAvatar() { + // OTS mode: always render avatar — we are in third-person even though + // mouselook input is active. + if (gAgentCamera.cameraOTS()) + { + return mShowAvatar && mOutfitChosen; + } + if (gAgentCamera.cameraMouselook() && !LLVOAvatar::sVisibleInFirstPerson) { return false; @@ -2134,6 +2141,11 @@ bool LLAgent::needsRenderAvatar() // true if we need to render your own avatar's head. bool LLAgent::needsRenderHead() { + // OTS mode: always render head — avatar is fully visible. + if (gAgentCamera.cameraOTS()) + { + return mShowAvatar; + } return (LLVOAvatar::sVisibleInFirstPerson && LLPipeline::sReflectionRender) || (mShowAvatar && !gAgentCamera.cameraMouselook()); } @@ -2247,7 +2259,8 @@ void LLAgent::endAnimationUpdateUI() } // clean up UI from mode we're leaving - if (gAgentCamera.getLastCameraMode() == CAMERA_MODE_MOUSELOOK ) + if (gAgentCamera.getLastCameraMode() == CAMERA_MODE_MOUSELOOK + || gAgentCamera.getLastCameraMode() == CAMERA_MODE_OTS) { gToolBarView->setToolBarsVisible(true); // show mouse cursor @@ -2369,7 +2382,8 @@ void LLAgent::endAnimationUpdateUI() //--------------------------------------------------------------------- // Set up UI for mode we're entering //--------------------------------------------------------------------- - if (gAgentCamera.getCameraMode() == CAMERA_MODE_MOUSELOOK) + if (gAgentCamera.getCameraMode() == CAMERA_MODE_MOUSELOOK + || gAgentCamera.getCameraMode() == CAMERA_MODE_OTS) { // clean up UI // first show anything hidden by UI toggle diff --git a/indra/newview/llagentcamera.cpp b/indra/newview/llagentcamera.cpp index 369a6d3697..80dfeac2d2 100644 --- a/indra/newview/llagentcamera.cpp +++ b/indra/newview/llagentcamera.cpp @@ -1176,7 +1176,7 @@ void LLAgentCamera::updateLookAt(const S32 mouse_x, const S32 mouse_y) LLVector3 headLookAxis; LLCoordFrame frameCamera = *((LLCoordFrame*)LLViewerCamera::getInstance()); - if (cameraMouselook()) + if (cameraMouselook() || cameraOTS()) { lookAtType = LOOKAT_TARGET_MOUSELOOK; } @@ -1409,7 +1409,7 @@ void LLAgentCamera::updateCamera() gAgent.setShowAvatar(true); } - if (isAgentAvatarValid() && (mCameraMode != CAMERA_MODE_MOUSELOOK)) + if (isAgentAvatarValid() && mCameraMode != CAMERA_MODE_MOUSELOOK) { gAgentAvatarp->updateAttachmentVisibility(mCameraMode); } @@ -1497,7 +1497,8 @@ void LLAgentCamera::updateCamera() } gAgent.setLastPositionGlobal(global_pos); - if (LLVOAvatar::sVisibleInFirstPerson && isAgentAvatarValid() && !gAgentAvatarp->isSitting() && cameraMouselook()) + // Exclude OTS — shoulder camera position must not be overridden by head-tracking. + if (LLVOAvatar::sVisibleInFirstPerson && isAgentAvatarValid() && !gAgentAvatarp->isSitting() && cameraMouselook() && !cameraOTS()) { LLVector3 head_pos = gAgentAvatarp->mHeadp->getWorldPosition() + LLVector3(0.08f, 0.f, 0.05f) * gAgentAvatarp->mHeadp->getWorldRotation() + @@ -1598,6 +1599,19 @@ LLVector3d LLAgentCamera::calcFocusPositionTargetGlobal() mFocusTargetGlobal = gAgent.getPosGlobalFromAgent(mFollowCam.getSimulatedFocus()); return mFocusTargetGlobal; } + else if (mCameraMode == CAMERA_MODE_OTS) + { + // Focus in front of avatar at aim height + static LLCachedControl ots_focus_dist(gSavedSettings, "OTSFocusDistance", 10.0f); + static LLCachedControl ots_height(gSavedSettings, "OTSCameraHeight", 0.5f); + static LLCachedControl ots_side(gSavedSettings, "OTSCameraSide", -0.5f); + LLVector3 focus_local((F32)ots_focus_dist, (F32)ots_side * 0.3f, (F32)ots_height * 0.5f); + LLQuaternion agent_rot = gAgent.getFrameAgent().getQuaternion(); + LLVector3 focus_world = focus_local * agent_rot; + LLVector3d avatar_pos = gAgent.getPosGlobalFromAgent(getAvatarRootPosition()); + mFocusTargetGlobal = avatar_pos + LLVector3d(focus_world); + return mFocusTargetGlobal; + } else if (mCameraMode == CAMERA_MODE_MOUSELOOK) { LLVector3d at_axis(1.0, 0.0, 0.0); @@ -1776,6 +1790,18 @@ LLVector3d LLAgentCamera::calcCameraPositionTargetGlobal(bool *hit_limit) { camera_position_global = gAgent.getPosGlobalFromAgent(mFollowCam.getSimulatedPosition()); } + else if (mCameraMode == CAMERA_MODE_OTS) + { + // Shoulder offset camera — avatar-local space: X=forward, Y=left, Z=up + static LLCachedControl ots_dist(gSavedSettings, "OTSCameraDistance", 3.0f); + static LLCachedControl ots_side(gSavedSettings, "OTSCameraSide", -0.5f); + static LLCachedControl ots_height(gSavedSettings, "OTSCameraHeight", 0.5f); + LLVector3 local_offset(-(F32)ots_dist, (F32)ots_side, (F32)ots_height); + LLQuaternion agent_rot = gAgent.getFrameAgent().getQuaternion(); + LLVector3 world_offset = local_offset * agent_rot; + LLVector3d avatar_pos = gAgent.getPosGlobalFromAgent(getAvatarRootPosition()); + camera_position_global = avatar_pos + LLVector3d(world_offset); + } else if (mCameraMode == CAMERA_MODE_MOUSELOOK) { if (!isAgentAvatarValid() || gAgentAvatarp->mDrawable.isNull()) @@ -2243,6 +2269,50 @@ void LLAgentCamera::changeCameraToDefault() } +//----------------------------------------------------------------------------- +// changeCameraToOTS() +// Over-the-shoulder aim mode. +// Calls changeCameraToMouselook() to inherit ALL of its input setup +// (cursor hiding, mouse capture, control flags, focus management), +// then immediately overrides mCameraMode to CAMERA_MODE_OTS so that +// calcCameraPositionTargetGlobal places the camera at the shoulder offset +// instead of the avatar's eye position. +// Avatar rendering is handled explicitly in needsRenderAvatar() and needsRenderHead(). +//----------------------------------------------------------------------------- +void LLAgentCamera::changeCameraToOTS() +{ + if (mCameraMode != CAMERA_MODE_OTS) + { + // Inherit everything from mouselook: cursor lock, mouse capture, + // AGENT_CONTROL_MOUSELOOK flag, keyboard focus clear, etc. + changeCameraToMouselook(false); + + // Override mCameraMode to OTS so position/focus calculations + // use the shoulder offset instead of the eye position. + mCameraMode = CAMERA_MODE_OTS; + + // changeCameraToMouselook hid attachments via updateAttachmentVisibility + // with CAMERA_MODE_MOUSELOOK. Restore full visibility for OTS mode. + if (isAgentAvatarValid()) + { + gAgentAvatarp->updateAttachmentVisibility(CAMERA_MODE_THIRD_PERSON); + } + } +} + +//----------------------------------------------------------------------------- +// changeCameraFromOTS() +//----------------------------------------------------------------------------- +void LLAgentCamera::changeCameraFromOTS() +{ + if (mCameraMode == CAMERA_MODE_OTS) + { + // changeCameraToDefault handles clearing AGENT_CONTROL_MOUSELOOK, + // showing the cursor, and restoring the normal camera mode. + changeCameraToDefault(); + } +} + //----------------------------------------------------------------------------- // changeCameraToFollow() //----------------------------------------------------------------------------- diff --git a/indra/newview/llagentcamera.h b/indra/newview/llagentcamera.h index d277fd6158..ac1a36a6c2 100644 --- a/indra/newview/llagentcamera.h +++ b/indra/newview/llagentcamera.h @@ -43,7 +43,8 @@ enum ECameraMode CAMERA_MODE_THIRD_PERSON, CAMERA_MODE_MOUSELOOK, CAMERA_MODE_CUSTOMIZE_AVATAR, - CAMERA_MODE_FOLLOW + CAMERA_MODE_FOLLOW, + CAMERA_MODE_OTS // Over-the-shoulder: mouselook input + third-person camera }; /** Camera Presets for CAMERA_MODE_THIRD_PERSON */ @@ -93,10 +94,14 @@ public: void changeCameraToThirdPerson(bool animate = true); void changeCameraToCustomizeAvatar(); // Trigger transition animation void changeCameraToFollow(bool animate = true); // Ventrella + void changeCameraToOTS(); // Over-the-shoulder aim mode + void changeCameraFromOTS(); // Exit OTS back to third person bool cameraThirdPerson() const { return (mCameraMode == CAMERA_MODE_THIRD_PERSON && mLastCameraMode == CAMERA_MODE_THIRD_PERSON); } - bool cameraMouselook() const { return (mCameraMode == CAMERA_MODE_MOUSELOOK && mLastCameraMode == CAMERA_MODE_MOUSELOOK); } + // Also true for OTS — reuses mouselook input and UI behaviour; camera position handled separately. + bool cameraMouselook() const { return (mCameraMode == CAMERA_MODE_MOUSELOOK && mLastCameraMode == CAMERA_MODE_MOUSELOOK) || mCameraMode == CAMERA_MODE_OTS; } bool cameraCustomizeAvatar() const { return (mCameraMode == CAMERA_MODE_CUSTOMIZE_AVATAR /*&& !mCameraAnimating*/); } bool cameraFollow() const { return (mCameraMode == CAMERA_MODE_FOLLOW && mLastCameraMode == CAMERA_MODE_FOLLOW); } + bool cameraOTS() const { return mCameraMode == CAMERA_MODE_OTS; } ECameraMode getCameraMode() const { return mCameraMode; } ECameraMode getLastCameraMode() const { return mLastCameraMode; } void updateCamera(); // Call once per frame to update camera location/orientation diff --git a/indra/newview/llquickprefs.cpp b/indra/newview/llquickprefs.cpp index 800aa7abac..9e83857a15 100644 --- a/indra/newview/llquickprefs.cpp +++ b/indra/newview/llquickprefs.cpp @@ -26,6 +26,8 @@ #include "llquickprefs.h" #include "llagent.h" +#include "llagentcamera.h" +#include "llcheckboxctrl.h" #include "llsliderctrl.h" #include "lltextbox.h" #include "llviewercontrol.h" @@ -97,6 +99,14 @@ bool LLFloaterQuickPrefs::postBuild() } onRegionChanged(); // evaluate current region immediately + // OTS aim mode checkbox + mOTSEnabledCheck = getChild("ots_enabled"); + if (mOTSEnabledCheck) + { + mOTSEnabledCheck->setCommitCallback( + boost::bind(&LLFloaterQuickPrefs::onOTSEnabledChanged, this)); + } + return true; } @@ -203,3 +213,16 @@ void LLFloaterQuickPrefs::syncAvatarZOffsetFromPreferenceSetting() F32 value = gSavedPerAccountSettings.getF32("AvatarHoverOffsetZ"); mAvatarZOffsetSlider->setValue(value, false); // false = no commit signal } + +void LLFloaterQuickPrefs::onOTSEnabledChanged() +{ + if (mOTSEnabledCheck) + { + gSavedSettings.setBOOL("OTSEnabled", mOTSEnabledCheck->get()); + // If currently in OTS mode and checkbox was unchecked, exit OTS + if (!mOTSEnabledCheck->get() && gAgentCamera.cameraOTS()) + { + gAgentCamera.changeCameraFromOTS(); + } + } +} diff --git a/indra/newview/llquickprefs.h b/indra/newview/llquickprefs.h index 0ef56a4299..b90333831c 100644 --- a/indra/newview/llquickprefs.h +++ b/indra/newview/llquickprefs.h @@ -27,6 +27,7 @@ #include "llfloater.h" class LLSliderCtrl; +class LLCheckBoxCtrl; class LLTextBox; /** @@ -76,6 +77,10 @@ private: void syncAvatarZOffsetFromPreferenceSetting(); boost::signals2::connection mRegionChangedSlot; + + // OTS over-the-shoulder aim + LLCheckBoxCtrl* mOTSEnabledCheck { nullptr }; + void onOTSEnabledChanged(); }; #endif // LL_LLQUICKPREFS_H diff --git a/indra/newview/llviewermenu.cpp b/indra/newview/llviewermenu.cpp index 4632800e2d..833a3cde6f 100644 --- a/indra/newview/llviewermenu.cpp +++ b/indra/newview/llviewermenu.cpp @@ -4778,25 +4778,37 @@ class LLViewMouselook : public view_listener_t { bool handleEvent(const LLSD& userdata) { + // When OTS is enabled M toggles OTS mode (shoulder cam). + // When disabled M toggles normal first-person mouselook. + if (gSavedSettings.getBOOL("OTSEnabled")) + { + if (!gAgentCamera.cameraOTS()) + { + gAgentCamera.changeCameraToOTS(); + } + else + { + gAgentCamera.changeCameraFromOTS(); + } + return true; + } + if (!gAgentCamera.cameraMouselook()) { gAgentCamera.changeCameraToMouselook(); } else { - // NaCl: Right-click + scroll wheel zoom in mouselook (ported from Firestorm). - // If we were zoomed when the user toggles out of mouselook, restore the - // normal (pre-zoom) FOV before switching back to the default camera. + // NaCl: restore FOV on mouselook exit LLVector3 mlFovValues = gSavedSettings.getVector3("_NACL_MLFovValues"); F32 cameraAngle = gSavedSettings.getF32("CameraAngle"); if (mlFovValues.mV[VZ] > 0.0f) { - mlFovValues.mV[VY] = cameraAngle; // preserve last zoomed FOV + mlFovValues.mV[VY] = cameraAngle; mlFovValues.mV[VZ] = 0.0f; gSavedSettings.setVector3("_NACL_MLFovValues", mlFovValues); - gSavedSettings.setF32("CameraAngle", mlFovValues.mV[VX]); // restore normal FOV + gSavedSettings.setF32("CameraAngle", mlFovValues.mV[VX]); } - // NaCl End gAgentCamera.changeCameraToDefault(); } return true; diff --git a/indra/newview/skins/default/xui/en/floater_quick_prefs.xml b/indra/newview/skins/default/xui/en/floater_quick_prefs.xml index 8b20fda6f5..f5bdcf2156 100644 --- a/indra/newview/skins/default/xui/en/floater_quick_prefs.xml +++ b/indra/newview/skins/default/xui/en/floater_quick_prefs.xml @@ -9,7 +9,7 @@ can_minimize="true" can_close="true" can_resize="false" - height="96" + height="240" width="320" layout="topleft" name="quick_prefs" @@ -82,4 +82,128 @@ name="max_bandwidth" tool_tip="Network bandwidth in Kbps (50 – 3000)" /> + + + + Over-the-Shoulder Cam: + + + + + + Distance: + + + + + + Side offset: + + + + + + Height: + + + + diff --git a/indra/newview/skins/default/xui/en/panel_preferences_move.xml b/indra/newview/skins/default/xui/en/panel_preferences_move.xml index eee55bd7bc..d59591a7fe 100644 --- a/indra/newview/skins/default/xui/en/panel_preferences_move.xml +++ b/indra/newview/skins/default/xui/en/panel_preferences_move.xml @@ -161,6 +161,17 @@ name="first_person_avatar_visible" top_pad="5" width="256" /> +