Compare commits

...

2 Commits

Author SHA1 Message Date
indigo 7d53eb18d4 Fix USD camera tumble: lossless matrix write-back + gimbal-free orbit init
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 <noreply@anthropic.com>
2026-06-16 07:28:28 +08:00
indigo ee1b750f8c Fix USD camera orbit pivot — use scene bbox centroid instead of focus distance
When switching to a USD camera, the orbit pivot was set from
GfCamera::GetFocusDistance() which is a depth-of-field attribute
that defaults to 5 scene units regardless of scene scale.

InitCameraNavigation() now computes the scene bounding box centroid,
projects it onto the camera view ray to get a proper orbit distance,
and calls SetFocalPoint/SetDist to place the pivot correctly.
Added SetDist() setter on ViewportCamera to support this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 04:46:20 +08:00
3 changed files with 86 additions and 22 deletions
+23
View File
@@ -335,6 +335,29 @@ void ViewportCamera::SetUsdCamera(const pxr::SdfPath& cameraPath)
m_usdCameraPath = 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) void ViewportCamera::SwitchToFreeCamera(const pxr::GfCamera* lastGfCamera)
{ {
if (m_mode == CameraMode::Free) return; if (m_mode == CameraMode::Free) return;
+16
View File
@@ -113,6 +113,22 @@ public:
m_cameraTransformDirty = true; m_cameraTransformDirty = true;
} }
/// Set the orbit distance directly.
/// Used when initialising from a USD camera prim to override the camera's
/// authored focus distance (which is a DOF attribute, not an orbit radius).
void SetDist(double d) {
m_dist = d;
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. /// Returns the current near-clip distance.
double GetNearClip() const { double GetNearClip() const {
return static_cast<double>(m_camera.GetClippingRange().GetMin()); return static_cast<double>(m_camera.GetClippingRange().GetMin());
+47 -22
View File
@@ -120,6 +120,34 @@ void ViewportTile::InitCameraNavigation()
pxr::UsdGeomCamera usdCam(prim); pxr::UsdGeomCamera usdCam(prim);
pxr::GfCamera gfCam = usdCam.GetCamera(m_displayTime); pxr::GfCamera gfCam = usdCam.GetCamera(m_displayTime);
m_camera.SwitchToFreeCamera(&gfCam); m_camera.SwitchToFreeCamera(&gfCam);
// 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 };
pxr::UsdGeomBBoxCache bboxCache(m_displayTime, purposes, true);
pxr::GfBBox3d stageBBox =
bboxCache.ComputeWorldBound(m_stage->GetPseudoRoot());
if (!stageBBox.GetRange().IsEmpty()) {
pxr::GfVec3d sceneCenter = stageBBox.ComputeCentroid();
pxr::GfVec3d camEye = gfCam.GetFrustum().GetPosition();
pxr::GfVec3d viewDir = gfCam.GetFrustum().ComputeViewDirection();
// 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.InitOrbitFromEyeAndCenter(
camEye, camEye + dist * viewDir, dist);
}
}
m_isDrivingUsdCamPrim = true; m_isDrivingUsdCamPrim = true;
m_drivenUsdCamPath = camPath; m_drivenUsdCamPath = camPath;
} }
@@ -281,28 +309,25 @@ pxr::GfCamera ViewportTile::ResolveCamera()
if (m_isDrivingUsdCamPrim) { if (m_isDrivingUsdCamPrim) {
pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_drivenUsdCamPath); pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_drivenUsdCamPath);
if (prim && prim.IsA<pxr::UsdGeomCamera>()) { if (prim && prim.IsA<pxr::UsdGeomCamera>()) {
pxr::UsdGeomXformCommonAPI xformAPI(prim); // Author the full camera-to-world matrix as a single transform
{ // op rather than decomposing to XYZ-euler translate/rotate.
pxr::GfVec3d t; pxr::GfVec3f r, s, pv; // The euler round-trip (Decompose(X,Y,Z) -> rotateXYZ op) is
pxr::UsdGeomXformCommonAPI::RotationOrder ro; // lossy: the read-back orientation differs from what was
if (!xformAPI.GetXformVectors( // written, which made the view jump every time a drag finished
&t, &r, &s, &pv, &ro, m_displayTime)) // and the mode flipped back to reading the prim. A matrix op
pxr::UsdGeomXformable(prim).ClearXformOpOrder(); // round-trips exactly through UsdGeomCamera::GetCamera().
} pxr::UsdGeomXformable xformable(prim);
pxr::GfMatrix4d camToWorld = gfCamera.GetTransform(); bool resets = false;
pxr::GfVec3d translate = camToWorld.ExtractTranslation(); std::vector<pxr::UsdGeomXformOp> ops =
pxr::GfRotation rotation = camToWorld.ExtractRotation(); xformable.GetOrderedXformOps(&resets);
pxr::GfVec3d eulerDeg = rotation.Decompose( pxr::UsdGeomXformOp matrixOp;
pxr::GfVec3d::XAxis(), if (ops.size() == 1 &&
pxr::GfVec3d::YAxis(), ops[0].GetOpType() == pxr::UsdGeomXformOp::TypeTransform)
pxr::GfVec3d::ZAxis()); matrixOp = ops[0];
xformAPI.SetTranslate(translate, m_editTime); else
xformAPI.SetRotate( matrixOp = xformable.MakeMatrixXform();
pxr::GfVec3f(float(eulerDeg[0]), if (matrixOp)
float(eulerDeg[1]), matrixOp.Set(gfCamera.GetTransform(), m_editTime);
float(eulerDeg[2])),
pxr::UsdGeomXformCommonAPI::RotationOrderXYZ,
m_editTime);
} }
if (!m_isOrbiting && !m_isPanning && !m_isDollying) { if (!m_isOrbiting && !m_isPanning && !m_isDollying) {
m_isDrivingUsdCamPrim = false; m_isDrivingUsdCamPrim = false;