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

71 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 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. |