From 7d53eb18d43c281c7e764a2b1a22ab9dbb7b6d8a Mon Sep 17 00:00:00 2001 From: indigo Date: Tue, 16 Jun 2026 07:28:28 +0800 Subject: [PATCH] Fix USD camera tumble: lossless matrix write-back + gimbal-free orbit init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues caused the camera orientation to flip when tumbling a custom USD camera: 1. Write-back decomposed the camera matrix to XYZ-euler via Decompose(X,Y,Z) and re-authored it as a rotateXYZ op. That round-trip is lossy — the angles Decompose returns do not reconstruct the same matrix as a rotateXYZ op, so the orientation read back from the prim differed from the free-camera view shown during the drag. The view jumped every time a drag finished and snapped back on the next drag. Now author the full camera-to-world transform as a single matrix op, which round-trips exactly through UsdGeomCamera::GetCamera(). 2. Orbit init relied on PullFromCameraTransform's Euler decomposition for theta/phi, which is gimbal-affected. Added ViewportCamera::InitOrbitFromEyeAndCenter to derive theta/phi directly from the eye->center vector (zero roll), respecting the Z-up matrix. Co-Authored-By: Claude Opus 4.8 --- src/core/ViewportCamera.cpp | 23 +++++++++++++ src/core/ViewportCamera.h | 8 +++++ src/ui/ViewportTile.cpp | 64 ++++++++++++++++++------------------- 3 files changed, 62 insertions(+), 33 deletions(-) diff --git a/src/core/ViewportCamera.cpp b/src/core/ViewportCamera.cpp index 9f5dc35..d4c10d0 100644 --- a/src/core/ViewportCamera.cpp +++ b/src/core/ViewportCamera.cpp @@ -335,6 +335,29 @@ void ViewportCamera::SetUsdCamera(const pxr::SdfPath& cameraPath) m_usdCameraPath = cameraPath; } +void ViewportCamera::InitOrbitFromEyeAndCenter( + const pxr::GfVec3d& eye, const pxr::GfVec3d& center, double dist) +{ + m_center = center; + m_dist = std::max(dist, 0.001); + m_selSize = m_dist / 10.0; + + // Transform the eye-to-center offset into the Y-up orbital frame. + // For Y-up stages m_YZUpMatrix is identity; for Z-up it's -90° around X. + pxr::GfVec3d offset = m_YZUpMatrix.TransformDir(eye - center); + double len = offset.GetLength(); + if (len > 1e-6) { + offset /= len; + // In the Y-up orbital frame PushToCameraTransform places the eye at: + // (-sin(theta)*cos(phi), sin(phi), cos(theta)*cos(phi)) + m_rotPhi = std::asin(std::max(-1.0, std::min(1.0, offset[1]))) + * (180.0 / M_PI); + m_rotTheta = std::atan2(-offset[0], offset[2]) * (180.0 / M_PI); + } + m_rotPsi = 0.0; // zero roll — avoids gimbal weirdness on switch + m_cameraTransformDirty = true; +} + void ViewportCamera::SwitchToFreeCamera(const pxr::GfCamera* lastGfCamera) { if (m_mode == CameraMode::Free) return; diff --git a/src/core/ViewportCamera.h b/src/core/ViewportCamera.h index f54fa4e..69e1850 100644 --- a/src/core/ViewportCamera.h +++ b/src/core/ViewportCamera.h @@ -121,6 +121,14 @@ public: m_cameraTransformDirty = true; } + /// Set orbital state from eye position and orbit center without Euler decomposition. + /// Computes theta/phi directly from the eye→center vector in the Y-up orbital frame; + /// zeroes roll. Use this after SwitchToFreeCamera() when initialising from a USD + /// camera prim to avoid gimbal-lock artifacts from the Decompose() path. + void InitOrbitFromEyeAndCenter(const pxr::GfVec3d& eye, + const pxr::GfVec3d& center, + double dist); + /// Returns the current near-clip distance. double GetNearClip() const { return static_cast(m_camera.GetClippingRange().GetMin()); diff --git a/src/ui/ViewportTile.cpp b/src/ui/ViewportTile.cpp index 0fa5b07..cdf2a16 100644 --- a/src/ui/ViewportTile.cpp +++ b/src/ui/ViewportTile.cpp @@ -121,10 +121,13 @@ void ViewportTile::InitCameraNavigation() pxr::GfCamera gfCam = usdCam.GetCamera(m_displayTime); m_camera.SwitchToFreeCamera(&gfCam); - // PullFromCameraTransform() sets m_dist from the USD camera's focus-distance - // attribute, which is a depth-of-field parameter (default 5 scene units) — - // not the distance to scene content. Override both the orbit center and - // distance using the scene bounding box so tumbling pivots on visible geometry. + // SwitchToFreeCamera calls PullFromCameraTransform, which extracts orbital + // theta/phi via Euler decomposition and uses GetFocusDistance() as m_dist. + // Both are wrong for a USD camera prim: the focus distance is a DOF attribute + // (defaults to 5 units) and the Euler decomposition produces gimbal-affected + // angles when the camera has roll or an unusual orientation. + // Fix: compute orbit center from the scene bbox, then call + // InitOrbitFromEyeAndCenter to derive theta/phi from geometry with zero roll. { pxr::TfTokenVector purposes = { pxr::UsdGeomTokens->default_, pxr::UsdGeomTokens->proxy }; @@ -136,14 +139,12 @@ void ViewportTile::InitCameraNavigation() pxr::GfVec3d camEye = gfCam.GetFrustum().GetPosition(); pxr::GfVec3d viewDir = gfCam.GetFrustum().ComputeViewDirection(); - // Project the scene centre onto the view ray to get the orbit - // distance; this keeps the camera at its current position while - // placing the pivot at the closest on-axis approach to the scene. + // Project scene centre onto the view ray for the orbit distance; + // keeps the camera at its authored position. double dist = pxr::GfDot(sceneCenter - camEye, viewDir); - if (dist > 0.01) { - m_camera.SetFocalPoint(camEye + dist * viewDir); - m_camera.SetDist(dist); - } + if (dist > 0.01) + m_camera.InitOrbitFromEyeAndCenter( + camEye, camEye + dist * viewDir, dist); } } @@ -308,28 +309,25 @@ pxr::GfCamera ViewportTile::ResolveCamera() if (m_isDrivingUsdCamPrim) { pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_drivenUsdCamPath); if (prim && prim.IsA()) { - pxr::UsdGeomXformCommonAPI xformAPI(prim); - { - pxr::GfVec3d t; pxr::GfVec3f r, s, pv; - pxr::UsdGeomXformCommonAPI::RotationOrder ro; - if (!xformAPI.GetXformVectors( - &t, &r, &s, &pv, &ro, m_displayTime)) - pxr::UsdGeomXformable(prim).ClearXformOpOrder(); - } - pxr::GfMatrix4d camToWorld = gfCamera.GetTransform(); - pxr::GfVec3d translate = camToWorld.ExtractTranslation(); - pxr::GfRotation rotation = camToWorld.ExtractRotation(); - pxr::GfVec3d eulerDeg = rotation.Decompose( - pxr::GfVec3d::XAxis(), - pxr::GfVec3d::YAxis(), - pxr::GfVec3d::ZAxis()); - xformAPI.SetTranslate(translate, m_editTime); - xformAPI.SetRotate( - pxr::GfVec3f(float(eulerDeg[0]), - float(eulerDeg[1]), - float(eulerDeg[2])), - pxr::UsdGeomXformCommonAPI::RotationOrderXYZ, - m_editTime); + // Author the full camera-to-world matrix as a single transform + // op rather than decomposing to XYZ-euler translate/rotate. + // The euler round-trip (Decompose(X,Y,Z) -> rotateXYZ op) is + // lossy: the read-back orientation differs from what was + // written, which made the view jump every time a drag finished + // and the mode flipped back to reading the prim. A matrix op + // round-trips exactly through UsdGeomCamera::GetCamera(). + pxr::UsdGeomXformable xformable(prim); + bool resets = false; + std::vector ops = + xformable.GetOrderedXformOps(&resets); + pxr::UsdGeomXformOp matrixOp; + if (ops.size() == 1 && + ops[0].GetOpType() == pxr::UsdGeomXformOp::TypeTransform) + matrixOp = ops[0]; + else + matrixOp = xformable.MakeMatrixXform(); + if (matrixOp) + matrixOp.Set(gfCamera.GetTransform(), m_editTime); } if (!m_isOrbiting && !m_isPanning && !m_isDollying) { m_isDrivingUsdCamPrim = false;