UsdLayerManager/openspec/changes/multi-viewport-support/design.md

7.6 KiB
Raw Blame History

Context

The app currently has a single ViewportPanel class that owns one ViewportCamera, one UsdSceneRenderer, and draws a full-window render with toolbars + overlays. Selection propagates through OnPrimPicked / OnPrimsPickedRect callbacks to SceneHierarchyPanel and PropertyPanel. The UsdSceneRenderer owns a GlfDrawTarget FBO; the render loop calls m_renderer.Render() then renders overlays (axis, bbox, gizmo, manipulator toolbar) into the same FBO or directly to the screen via ImGui draw lists.

The ViewportCamera and UsdSceneRenderer classes are already designed as independent units — they can be instantiated per-viewport without modification. The key architectural challenge is sharing the selection state across N viewport tiles while ensuring only the focused tile renders the transform gizmo.

Goals / Non-Goals

Goals:

  • Support 1, 2 (horizontal/vertical split), and 4-quadrant viewport layouts
  • Each viewport tile has its own camera, renderer, and per-viewport settings (grid, AA, bbox mode, background color, render delegate)
  • Single shared selection: picking in any tile updates all tiles and propagates to panels
  • Transform manipulator (gizmo) renders only in the focused tile
  • Drag-based dividers to resize tiles within a layout
  • Backward-compatible Application API: m_viewportPanel stays as the entry point
  • Space-key maximize/restore: pressing Space over the hovered tile temporarily fills the entire viewport area; pressing Space again restores the previous layout (Maya-style)

Non-Goals:

  • Not adding tear-off/undockable viewport windows (ImGui docking handles that separately)
  • Not adding per-viewport layer visibility overrides (future feature)
  • Not supporting more than 4 tiles (can be extended later)
  • Not adding viewport sync (playblasting) or timeline scrubbing across tiles

Decisions

  1. Extract ViewportTile from ViewportPanel rather than subclassing.

    • The existing 1100-line ViewportPanel::Render() method contains everything: toolbar rendering, camera resolution, scene rendering, overlay drawing, context menu, and manipulator. Extracting a ViewportTile class that owns a camera + renderer + local per-viewport settings + the subset of overlay logic that is per-tile keeps the container clean.
    • ViewportPanel becomes the container: it owns std::vector<std::unique_ptr<ViewportTile>>, manages layout geometry, and delegates Render() calls to each tile.
    • Alternative considered: having ViewportPanel contain N inline structs. This was rejected because tiles need their own camera/resolution/overlay lifecycle, which calls for a proper class.
  2. Selection lives on the container (ViewportPanel), not on any tile.

    • Each tile still calls m_renderer.SetSelectedPaths() and m_renderer.AddSelected() so its UsdImagingGLEngine highlights the correct prims, but the authoritative m_selectedSdfPaths / m_selectedPrimPath vector lives on ViewportPanel.
    • When a tile receives a pick hit, it calls back into the container to update the shared selection, and the container broadcasts it to all tiles and to the OnPrimPicked/OnPrimsPickedRect callbacks.
    • Alternative considered: each tile owns its selection and syncs via signals. Rejected because it adds complexity for a single-authority selection model.
  3. Manipulator ownership moves to ViewportPanel (container), not per tile.

    • The TransformManipulator is a singleton — there should never be two gizmos active. The container owns it and only passes it to the focused tile for rendering.
    • The focused tile is the one that last received a ImGui::IsWindowHovered() click. Tile tracks m_isFocused set by container during HandleInput().
  4. Layout state stored as simple enum + divider positions, no external dependency.

    • Layout enum values: Single, HSplit (2 tiles side-by-side), VSplit (2 tiles top-bottom), Quad (4 tiles).
    • Divider positions stored as normalized floats (0.01.0). Dragging a divider updates the normalized position, then ViewportPanel::Render() recomputes tile rectangles.
    • No XML/JSON config for layout — state is ephemeral (could be persisted via future settings system).
    • Alternative considered: ImGui::Splitter() based approach from ImGui demos. We'll implement a lightweight version directly since we need precise control over tile rectangle subdivision.
  5. Render delegate can differ per tile.

    • Each tile's UsdSceneRenderer independently calls SetRendererPlugin(). This means each tile FBO can use e.g. Storm vs HdEmbree independently.
    • This is useful for comparing renderers side-by-side and is already supported by UsdSceneRenderer's per-instance design.
  6. Space-key maximize stored as a simple toggle on the container, not on individual tiles.

    • ViewportPanel stores m_maximizedTileIndex (-1 = not maximized) and m_layoutBeforeMaximize (the LayoutMode enum value before Space was pressed).
    • When Space is pressed (with tile hovered and m_maximizedTileIndex == -1): save m_layoutBeforeMaximize, set m_maximizedTileIndex to the hovered tile, and render only that tile filling the entire viewport area (no dividers, no layout menu).
    • When Space is pressed again (or Escape): restore m_maximizedTileIndex to -1 and re-render the saved layout.
    • Keyboard shortcuts (F, A, Q/W/E/R) continue to target the maximized tile as the de facto focused tile.
    • Alternative considered: pushing a separate layout mode Maximized. Rejected because it conflates layout state with a transient display mode; storing the pre-maximize layout explicitly is cleaner and avoids state machine complexity.
  7. Per-tile settings stored on ViewportTile, not on a settings struct singleton.

    • Grid/AA/bg-color/bbox-mode/render-delegate are all fields on ViewportTile. The compact toolbar in each tile reads/writes these directly.
    • For layout persistence (future), tiles could serialize their settings.

Risks / Trade-offs

Risk Mitigation
N renderers means N FBO draw targets, each at full viewport resolution. For a 4K monitor with Quad layout, each tile is ~960×540 → 4× FBO allocations at that size. GPU memory may spike. Cap layout to 4 tiles. Add a m_renderScale per tile (default 1.0) if perf issues arise. OpenUSD's UsdImagingGLEngine already has renderParams for resolution scaling.
Input routing: multiple tiles competing for Alt+LMB, Alt+RMB, scroll, etc. The container dispatches HandleInput() only to the hovered tile each frame. The focused tile's manipulator is the only one that can consume input.
Right-click context menu: which tile owns it? The context menu appears on the hovered tile. The popup (ViewportContextMenuN) is opened per-tile using unique IDs.
Divider drag area is thin — hard to click on high-DPI. Dividers are 6px wide with a 2px visual line in the centre. Cursor changes to resize cursor on hover.
Keyboard shortcuts (F=frame, A=frame all, Q/W/E/R=manipulator) need to target the focused tile. Shortcuts in the container delegate to the focused tile's camera/manipulator. If no tile is focused (e.g. user just clicked a panel outside the viewport), shortcuts are ignored.
Space key might conflict with ImGui docking or text input. Guard with !io.WantTextInput (same as existing F/A shortcuts). Space is not a default ImGui docking shortcut, so no conflict with docking.
User might accidentally press Space when no tile is hovered (e.g. focus is on a panel). Space is a no-op when no tile is hovered and viewport is not maximized. If already maximized, Space always restores regardless of hover state.