UsdLayerManager/openspec/changes/camera-wireframe-viewport/design.md

6.3 KiB

Context

The renderer (UsdSceneRenderer) already has a working pattern for GL line overlays drawn into the FBO: a shared GLSL program (#version 130, in vec3 position; uniform mat4 mvpMatrix; uniform vec4 color) is compiled once and used by both DrawAxis and DrawBoundingBoxes. The FBO can be re-bound at any time via BindDrawTarget()/UnbindDrawTarget(). The TransformManipulator already selects and edits any Xformable prim — no changes to it are needed.

UsdGeomCamera inherits from UsdGeomXformable. Every camera prim carries lens data (focalLength, horizontalAperture, verticalAperture, clippingRange) and an Xformable world transform. GfFrustum::ComputeCorners() returns the 8 world-space corners of a frustum given a GfCamera.

ViewportPanel::Render already has two FBO overlay call sites (DrawAxis, DrawBoundingBoxes) and two ImGui DrawList overlay call sites (gizmo, selection rect). The camera wireframe follows the FBO overlay pattern.

Goals / Non-Goals

Goals:

  • Frustum wireframe drawn in 3D world space inside the FBO for every UsdGeomCamera prim present in the current stage at time UsdTimeCode::Default().
  • Three visual states: inactive (muted grey), active/viewport-driving (cyan), selected (yellow/orange matching selection accent).
  • Click in the viewport selects a camera prim when the mouse is within a pick threshold of a projected frustum segment (tested before the geometry pick).
  • Selected camera integrates with the existing TransformManipulator (translate/rotate).

Non-Goals:

  • No near/far clip plane rectangle beyond a fixed display depth (far clip could be millions of units; showing a visualisation cap is acceptable).
  • No billboard label/text annotation.
  • No custom manipulator handles specific to camera FOV adjustment.
  • No frustum change when dragging the manipulator (camera shape updates next frame via normal USD dirty propagation).

Decisions

D1 — GL overlay into FBO, not ImGui DrawList

Decision: Draw camera wireframes using the existing GLSL line program and BindDrawTarget/UnbindDrawTarget, the same as DrawAxis and DrawBoundingBoxes.

Rationale: 3D GL lines drawn into the FBO participate in the depth buffer and are occluded by geometry correctly. ImGui screen-space lines would always appear on top of everything. The existing infrastructure (VAO, VBO, GLSL program, FBO helpers) already supports this pattern at zero additional complexity.


D2 — Frustum geometry: near-plane pyramid + body box

Decision: The camera wireframe consists of two parts:

  1. Frustum pyramid: the camera apex (position) connected by 4 lines to the 4 corners of a display near-plane quad. The near clip value from the UsdGeomCamera is used but clamped to a minimum of 0.01 to keep it visible.
  2. Body box: a small fixed-size world-space box (side ≈ frustumScale * 0.15) centred at the camera position, representing the camera body. This gives a tangible handle at the camera origin that is easy to click.
  3. Up arrow: a short line from the body box top face indicating the camera's up direction, matching the camera local Y axis.

The far frustum rectangle is not drawn — far clip distances are often enormous and would produce confusing off-screen lines.

Rationale: Matches Houdini/Blender's standard camera shape. The body box ensures a reliable click target even at distance.


D3 — Frustum scale proportional to camera distance

Decision: The overall wireframe scale is dist * 0.12 where dist is the current viewport camera's look-at distance (from ViewportCamera::GetDist()). This matches the screenFactor approach used by the TransformManipulator.

Rationale: A camera at distance 100 should not appear tiny compared to one at distance 5. Constant apparent size improves usability.


D4 — Camera picking via screen-space segment proximity

Decision: PickCameraAtPoint iterates all UsdGeomCamera prims, projects each wireframe segment to screen space, and returns the prim path of the closest camera whose minimum segment distance to the mouse is < kCameraPickRadius (10 pixels). If multiple cameras qualify, the nearest in screen space wins.

Rationale: UsdImagingGLEngine::TestIntersection only hits rendered hydra geometry — cameras have no rendered prims in Storm. Custom 2D proximity testing is the only option. 10 px matches the manipulator's pick radius for consistency.


D5 — Camera pick tested before geometry pick

Decision: In ViewportPanel::HandleInput, the single-click path calls PickCameraAtPoint first. If a camera path is returned, that prim is selected and the geometry pick is skipped. If no camera is hit, the existing PickObject path runs as before.

Rationale: Camera wireframes are thin and can overlap rendered geometry behind them. Giving camera wireframes priority matches Houdini/Maya behaviour where camera icons are always selectable.


D6 — Active camera path passed through from ViewportPanel

Decision: DrawCameraWireframes and PickCameraAtPoint receive m_selectedCameraPath from ViewportPanel (the path of whichever USD camera is currently active in the toolbar, or SdfPath() for Free Camera). This allows the cyan highlight without coupling UsdSceneRenderer to ViewportCamera.

Rationale: UsdSceneRenderer should not hold a reference to ViewportCamera. The path is a cheap SdfPath copy per frame.

Risks / Trade-offs

  • [Risk] Frustum scale is view-dependent — when the viewport camera is inside a USD camera's frustum and zoomed in close, the scale might get very large. → Mitigation: clamp frustumScale to a maximum of 50 world units.

  • [Risk] Performance — iterating all UsdGeomCamera prims every frame could be slow for large scenes. → Mitigation: cache the list of camera prim paths; invalidate on stage change via SetForceRefresh.

  • [Risk] Camera behind the viewerWorldToScreen returns false for behind-near-plane points; those line endpoints are simply skipped. → Acceptable; only the visible portion of the wireframe is drawn.

Migration Plan

Additive changes only — no existing API is removed or broken. DrawCameraWireframes and PickCameraAtPoint are new methods; their call sites in ViewportPanel are inside existing per-frame code blocks.