Files
UsdLayerManager/src/core/ViewportCamera.cpp
T
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

408 lines
14 KiB
C++

#include "ViewportCamera.h"
#include <pxr/usd/usdGeom/metrics.h>
#include <pxr/usd/usdGeom/tokens.h>
#include <pxr/usd/usdGeom/camera.h>
#include <pxr/base/gf/rotation.h>
#include <pxr/base/gf/frustum.h>
#include <pxr/imaging/cameraUtil/conformWindow.h>
#include <cmath>
#include <algorithm>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
namespace UsdLayerManager {
// ---------------------------------------------------------------------------
// Helper: rotation matrix around `axis` by `angleDeg` degrees
// ---------------------------------------------------------------------------
static pxr::GfMatrix4d RotMatrix(const pxr::GfVec3d& axis, double angleDeg)
{
return pxr::GfMatrix4d(1.0).SetRotate(pxr::GfRotation(axis, angleDeg));
}
// ===========================================================================
// Construction
// ===========================================================================
ViewportCamera::ViewportCamera()
: m_cameraTransformDirty(true)
, m_rotTheta(0.0)
, m_rotPhi(0.0)
, m_rotPsi(0.0)
, m_center(0.0, 0.0, 0.0)
, m_dist(100.0)
, m_selSize(10.0)
, m_isZUp(false)
, m_YZUpMatrix(1.0)
, m_YZUpInvMatrix(1.0)
, m_hasClosestVisibleDist(false)
, m_closestVisibleDist(0.0)
, m_lastFramedDist(100.0)
, m_lastFramedClosestDist(0.0)
, m_overrideNear(-1.0)
, m_overrideFar(-1.0)
, m_mode(CameraMode::Free)
{
// Default: perspective camera, vertical FOV = 60°, square aspect ratio.
m_camera.SetPerspectiveFromAspectRatioAndFieldOfView(
1.0f, 60.0f, pxr::GfCamera::FOVVertical);
m_camera.SetFocusDistance(static_cast<float>(m_dist));
ResetClippingPlanes();
}
// ===========================================================================
// Stage
// ===========================================================================
void ViewportCamera::SetStage(pxr::UsdStageRefPtr stage)
{
m_stage = stage;
m_isZUp = stage &&
(pxr::UsdGeomGetStageUpAxis(stage) == pxr::UsdGeomTokens->z);
if (m_isZUp) {
// GfCamera.Y_UP_TO_Z_UP_MATRIX: rotate -90° around X axis
m_YZUpMatrix = pxr::GfMatrix4d(1.0).SetRotate(
pxr::GfRotation(pxr::GfVec3d::XAxis(), -90.0));
m_YZUpInvMatrix = m_YZUpMatrix.GetInverse();
} else {
m_YZUpMatrix = pxr::GfMatrix4d(1.0);
m_YZUpInvMatrix = pxr::GfMatrix4d(1.0);
}
m_cameraTransformDirty = true;
}
// ===========================================================================
// Private: rebuild camera transform from orbital parameters
// Mirrors FreeCamera._pushToCameraTransform()
// ===========================================================================
void ViewportCamera::PushToCameraTransform()
{
if (!m_cameraTransformDirty) return;
// camera-to-world transform (same as FreeCamera._pushToCameraTransform):
// T(dist*Z) * R(-psi,Z) * R(-phi,X) * R(-theta,Y) * YZUpInv * T(center)
pxr::GfMatrix4d xform =
pxr::GfMatrix4d(1.0).SetTranslate(pxr::GfVec3d::ZAxis() * m_dist)
* RotMatrix(pxr::GfVec3d::ZAxis(), -m_rotPsi)
* RotMatrix(pxr::GfVec3d::XAxis(), -m_rotPhi)
* RotMatrix(pxr::GfVec3d::YAxis(), -m_rotTheta)
* m_YZUpInvMatrix
* pxr::GfMatrix4d(1.0).SetTranslate(m_center);
m_camera.SetTransform(xform);
m_camera.SetFocusDistance(static_cast<float>(m_dist));
m_cameraTransformDirty = false;
}
// Mirrors FreeCamera._pullFromCameraTransform()
void ViewportCamera::PullFromCameraTransform()
{
pxr::GfFrustum frustum = m_camera.GetFrustum();
m_dist = static_cast<double>(m_camera.GetFocusDistance());
m_selSize = m_dist / 10.0;
m_center = frustum.GetPosition() + m_dist * frustum.ComputeViewDirection();
pxr::GfMatrix4d camTransform = m_camera.GetTransform() * m_YZUpMatrix;
camTransform.Orthonormalize();
pxr::GfRotation rotation = camTransform.ExtractRotation();
// Decompose: Y → theta, X → phi, Z → psi
pxr::GfVec3d angles = rotation.Decompose(
pxr::GfVec3d::YAxis(),
pxr::GfVec3d::XAxis(),
pxr::GfVec3d::ZAxis());
m_rotTheta = -angles[0];
m_rotPhi = -angles[1];
m_rotPsi = -angles[2];
m_cameraTransformDirty = true;
}
// ===========================================================================
// Free Camera Operations
// ===========================================================================
void ViewportCamera::Tumble(double dTheta, double dPhi)
{
m_rotTheta += dTheta;
m_rotPhi += dPhi;
m_cameraTransformDirty = true;
}
void ViewportCamera::AdjustDistance(double scaleFactor)
{
// Mirrors FreeCamera.AdjustDistance(): prevents getting stuck near dist≈0.
if (scaleFactor > 1.0 && m_dist < 2.0) {
double selBasedIncr = m_selSize / 25.0;
scaleFactor -= 1.0;
m_dist += std::min(selBasedIncr, scaleFactor);
} else {
m_dist *= scaleFactor;
}
m_dist = std::max(m_dist, 0.001); // never let dist reach zero
// Keep closest-visible-distance estimate in sync with new dist
if (m_hasClosestVisibleDist) {
if (m_dist > m_lastFramedDist) {
m_closestVisibleDist = m_lastFramedClosestDist;
} else {
m_closestVisibleDist = m_lastFramedClosestDist
- m_lastFramedDist
+ m_dist;
}
}
m_cameraTransformDirty = true;
}
void ViewportCamera::Truck(double deltaRight, double deltaUp)
{
PushToCameraTransform();
pxr::GfFrustum frustum = m_camera.GetFrustum();
pxr::GfVec3d camUp = frustum.ComputeUpVector();
pxr::GfVec3d camRight = pxr::GfCross(frustum.ComputeViewDirection(), camUp);
m_center += deltaRight * camRight + deltaUp * camUp;
m_cameraTransformDirty = true;
}
double ViewportCamera::ComputePixelsToWorldFactor(double viewportHeight)
{
PushToCameraTransform();
pxr::GfFrustum frustum = m_camera.GetFrustum();
double frustumHeight = frustum.GetWindow().GetSize()[1];
return frustumHeight * m_dist / std::max(viewportHeight, 1.0);
}
void ViewportCamera::FrameSelection(const pxr::GfBBox3d& selBBox, double frameFit)
{
m_hasClosestVisibleDist = false;
m_center = selBBox.ComputeCentroid();
pxr::GfRange3d selRange = selBBox.ComputeAlignedRange();
pxr::GfVec3d sz = selRange.GetSize();
m_selSize = std::max({ sz[0], sz[1], sz[2] });
// Distance calculation from FreeCamera.frameSelection()
double fovRad = GetFOV() * M_PI / 180.0;
double halfFovRad = std::max(fovRad * 0.5, 0.00872665); // at least ~0.5°
double lengthToFit = m_selSize * frameFit * 0.5;
m_dist = lengthToFit / std::tan(halfFovRad);
// Prevent camera from intersecting the bounding box
if (m_dist < kDefaultNear + m_selSize * 0.5) {
m_dist = kDefaultNear + lengthToFit;
}
m_cameraTransformDirty = true;
}
// ===========================================================================
// Clipping Planes
// ===========================================================================
std::pair<double,double> ViewportCamera::RangeOfBoxAlongRay(
const pxr::GfRay& camRay, const pxr::GfBBox3d& bbox) const
{
double maxDist = -1e38;
double minDist = 1e38;
const pxr::GfRange3d& boxRange = bbox.GetRange();
const pxr::GfMatrix4d& boxXform = bbox.GetMatrix();
for (int i = 0; i < 8; ++i) {
pxr::GfVec3d corner = boxXform.Transform(boxRange.GetCorner(i));
double t = 0.0;
camRay.FindClosestPoint(corner, &t);
maxDist = std::max(maxDist, t);
minDist = std::min(minDist, t);
}
minDist = (minDist < kDefaultNear) ? kDefaultNear : minDist * 0.99;
maxDist *= 1.01;
return { minDist, maxDist };
}
void ViewportCamera::SetClippingPlanes(const pxr::GfBBox3d& stageBBox)
{
double computedNear, computedFar;
if (stageBBox.GetRange().IsEmpty()) {
computedNear = kDefaultNear;
computedFar = kDefaultFar;
} else {
pxr::GfFrustum frustum = m_camera.GetFrustum();
pxr::GfVec3d camPos = frustum.GetPosition();
pxr::GfRay camRay(camPos, frustum.ComputeViewDirection());
auto boxRange = RangeOfBoxAlongRay(camRay, stageBBox);
computedNear = boxRange.first;
computedFar = boxRange.second;
double precisionNear = computedFar / kMaxGoodZResolution;
if (m_hasClosestVisibleDist) {
double halfClose = m_closestVisibleDist / 2.0;
if (m_closestVisibleDist < m_lastFramedClosestDist) {
halfClose = std::max({ precisionNear, halfClose, computedNear });
}
if (halfClose < computedNear) {
computedNear = halfClose;
} else if (precisionNear > computedNear) {
computedNear = std::min((precisionNear + halfClose) / 2.0, halfClose);
}
}
}
double nearVal = (m_overrideNear > 0.0) ? m_overrideNear : computedNear;
double farVal = (m_overrideFar > 0.0) ? m_overrideFar : computedFar;
farVal = std::max(nearVal + 1.0, farVal);
m_camera.SetClippingRange(pxr::GfRange1f(
static_cast<float>(nearVal), static_cast<float>(farVal)));
}
void ViewportCamera::ResetClippingPlanes()
{
double nearVal = (m_overrideNear > 0.0) ? m_overrideNear : kDefaultNear;
double farVal = (m_overrideFar > 0.0) ? m_overrideFar : kDefaultFar;
m_camera.SetClippingRange(pxr::GfRange1f(
static_cast<float>(nearVal), static_cast<float>(farVal)));
}
// ===========================================================================
// Camera Resolution
// ===========================================================================
pxr::GfCamera ViewportCamera::ComputeGfCamera(
const pxr::GfBBox3d& stageBBox, bool autoClip)
{
PushToCameraTransform();
if (autoClip) {
SetClippingPlanes(stageBBox);
} else {
ResetClippingPlanes();
}
return m_camera;
}
void ViewportCamera::SetClosestVisibleDistFromPoint(const pxr::GfVec3d& point)
{
PushToCameraTransform();
pxr::GfFrustum frustum = m_camera.GetFrustum();
pxr::GfVec3d camPos = frustum.GetPosition();
pxr::GfRay camRay(camPos, frustum.ComputeViewDirection());
double t = 0.0;
camRay.FindClosestPoint(point, &t);
m_closestVisibleDist = t;
m_hasClosestVisibleDist = true;
m_lastFramedDist = m_dist;
m_lastFramedClosestDist = m_closestVisibleDist;
}
// ===========================================================================
// Matrix Accessors
// ===========================================================================
pxr::GfMatrix4d ViewportCamera::GetViewMatrix()
{
PushToCameraTransform();
return m_camera.GetFrustum().ComputeViewMatrix();
}
pxr::GfMatrix4d ViewportCamera::GetProjectionMatrix()
{
PushToCameraTransform();
return m_camera.GetFrustum().ComputeProjectionMatrix();
}
// ===========================================================================
// Compatibility Accessors
// ===========================================================================
pxr::GfVec3d ViewportCamera::GetEye()
{
PushToCameraTransform();
return m_camera.GetFrustum().GetPosition();
}
// ===========================================================================
// Camera Mode
// ===========================================================================
void ViewportCamera::SetUsdCamera(const pxr::SdfPath& cameraPath)
{
m_mode = CameraMode::UsdCamera;
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;
if (lastGfCamera) {
// Initialize free-camera state from the last rendered GfCamera
// (mirrors FreeCamera.FromGfCamera)
m_camera = *lastGfCamera;
PullFromCameraTransform();
}
m_mode = CameraMode::Free;
m_usdCameraPath = pxr::SdfPath();
m_cameraTransformDirty = true;
}
// ===========================================================================
// Camera Settings
// ===========================================================================
double ViewportCamera::GetFOV() const
{
return static_cast<double>(
m_camera.GetFieldOfView(pxr::GfCamera::FOVVertical));
}
void ViewportCamera::SetFOV(double fov)
{
m_camera.SetPerspectiveFromAspectRatioAndFieldOfView(
m_camera.GetAspectRatio(),
static_cast<float>(fov),
pxr::GfCamera::FOVVertical);
}
double ViewportCamera::GetAspectRatio() const
{
return static_cast<double>(m_camera.GetAspectRatio());
}
void ViewportCamera::SetAspectRatio(double aspect)
{
m_camera.SetPerspectiveFromAspectRatioAndFieldOfView(
static_cast<float>(aspect),
m_camera.GetFieldOfView(pxr::GfCamera::FOVVertical),
pxr::GfCamera::FOVVertical);
}
} // namespace UsdLayerManager