27 KiB
Editor Architecture
High-level layout of the in-process editor binary (crates/editor). For mission and phased work see roadmap.md and mission.md.
Crate boundaries
editor/ egui UI, PIE, BRP, scene I/O, editor-only cameras, hot-reload (dev)
└─ uses ──► game/ GamePlugin shell, re-exports
game_hot/ Hot-reloadable gameplay systems (cdylib; dev)
settings/ assets/project.ron, SimTuning, camera FX profile
shared/ Authoring components, hydration types
sim/ Components + resources (TypeIds stay static)
protocol/ Input intent (network-ready)
Native-only dependencies (rfd, BRP HTTP) stay in editor. settings, shared, sim, and protocol must remain usable from a future WASM client.
Plugin graph (startup order matters)
| Plugin | Role |
|---|---|
GamePlugin |
Shared runtime: rendering, player, sim schedule; standalone default-level bootstrap |
ProjectSettingsPlugin |
Load assets/project.ron, emit changes, sync SimTuning |
ProjectIoPlugin |
User prefs, workspace metadata, window title inputs |
EditorStatePlugin |
EditorMode (Edit/Play), PlayPossession, camera/input gating |
PhysicsPlacementPlugin |
Paused Edit physics plus isolated transactional gravity placement |
ViewportPlugin |
Grid, focus, bookmarks, snap settings |
EditorCameraPlugin |
Fly camera; FX applied via render_view + viewport_camera sync |
ActorIconsPlugin |
Selectable actor root billboard icons |
EditorVisualizersPlugin |
Collider/light/spawn/prefab/runtime visualizers and selectable proxies |
SceneIoPlugin |
Level New/Open/Save, recent paths |
CollaborationPlugin |
Exact authored-file guards, asynchronous Git status, optional ownership providers |
OperatorPlugin |
Active operator lifecycle/status for commands and modal tools |
SettingsUiPlugin |
egui Project Settings panel |
EditorUiPlugin |
Dock, themed egui shell, viewport overlays, menus, status bar |
BrpPlugin |
Bevy Remote Protocol HTTP (editor-only) |
HotReloadPlugin |
Dev-only: game_hot dylib watch + status bar (--features dev) |
Hot reload (dev)
Gameplay system bodies live in game_hot as a cdylib. Stable component/resource TypeIds remain in sim / protocol / shared / settings so ECS inserts stay valid after reload (see ADR 0004).
| Piece | Location |
|---|---|
| Hot system implementations | crates/game_hot/src/ |
| Schedule registration + hot wrappers | crates/game/src/lib.rs (hot-reload feature) |
| Dylib poll, notify watcher, Ctrl+Shift+R | crates/editor/src/hot_reload.rs |
| Status bar label | ui/status_bar.rs |
Workflow: run cargo watch … build -p game_hot in one terminal, cargo run -p editor --features dev in another. Logic-only edits apply without restarting the editor; component layout changes still need a full restart.
Settings split (maintainability)
| Concern | Location |
|---|---|
| Serializable project data | crates/settings — ProjectSettings, save/load |
| Path + dirty flag | ProjectSettingsIo resource |
| Panel open/close | ProjectSettingsPanel in settings_ui.rs |
| Live apply to sim/rendering | ProjectSettingsChanged message (draft edits commit on Apply) |
Do not add egui or editor-only state to the settings crate.
Scene Persistence And Recovery
scene/scene_io.rs owns authored scene extraction and user-facing save/load requests;
scene/recovery.rs owns transactional byte persistence and machine-local recovery generations.
The editor sets GameSceneBootstrap::defer_default_level, so SceneIoPlugin alone materializes
the startup document. A standalone GamePlugin instead attaches a schema-aware DynamicWorldRoot
for ProjectSettings.default_level; it never runs this automatic load inside the editor.
Serialization always rebuilds stripped hydration state before returning, including error paths.
Manual writes use same-directory temporary files plus sync/rename, while recovery snapshots live
under the user state directory and never clear scene dirty state. Newer recovery is discovered on
load and exposed through File menu restore/keep-copy/discard actions; it is never restored
automatically. Keeping a copy writes a separate transactional scene without changing the active
scene or retiring recovery.
The bounded in-session scene I/O event log feeds the Diagnostics window so a later status update
does not erase a save or recovery failure.
See ADR 0023.
project/collaboration.rs wraps user-authored publication with BLAKE3 baselines and a second
revision/read-only check immediately before the same-directory rename. Scene tabs, prefab source
Apply/history, staged Material documents, and Project Settings use this shared boundary. Its worker
observes path-scoped Git porcelain and optional ownership providers without blocking the frame loop
or changing repository state; conflict recovery is explicit Reload, metadata comparison, Save As,
or Cancel. Generated artifacts remain under their subsystem-owned regeneration policy. See
collaborative-file-safety.md and
ADR 0037.
project/shutdown.rs owns native close, File > Quit, project switching, and programmatic editor
exit. The editor launch configuration leaves native close requests open; the coordinator coalesces
them, retries the shared native-dialog broker when busy, and authorizes AppExit only after clean
state, successful Save All, or explicit Discard. Scene I/O owns the asynchronous per-tab save
transaction and reports completion back to the coordinator. Final authorization and clean-session
persistence run in an editor schedule inserted after Bevy's Last, so material drops, brush/gizmo
finalizers, and every other authoring system finish before the last dirty-state check. See
ADR 0042.
project/session.rs owns the independent versioned restart document under the user state
directory. It snapshots allowlisted paths, panel visibility, dock/hierarchy metadata, and camera
bookmarks, writes a running marker at startup, and records clean state only for a coordinator-
authorized AppExit. Abnormal sessions do not auto-open the prior scene; the explicit safe-resume
prompt is the only path back. See ADR 0024.
project/diagnostics_bundle.rs exports a separate, transactional support report from an explicit
metadata allowlist. It summarizes build/renderer identity, project paths, dirty state, aggregate
validation, the prior crash boundary, and the bounded Scene I/O log without serializing ECS data,
asset contents, environment values, host identity, credentials, or operator state.
The active project root is a startup boundary because AssetPlugin.file_path, project settings,
the asset catalog, and the initial level must agree before editor systems initialize.
project/launcher.rs validates and activates explicit roots before the app exists, filters
user-local recents, and scaffolds only missing/empty sandbox destinations. The graphical BS-JD-001
chooser builds on this contract. Do not reintroduce in-process project switching that changes only
settings metadata while leaving AssetServer on the old root; see
ADR 0025.
Camera and viewport model
See ADR 0014 for the unified viewport decision.
Three camera roles coexist:
- Editor fly camera (
EditorCamera) - active while editing or ejected from PIE. - Player camera - active while playing + possessed.
- egui overlay camera (
PrimaryEguiContext) - full-windowCamera2d; never viewport-cropped. Attaching egui to a cropped 3D camera causes NaN layout panics.
Unified viewport: one docked Viewport tab owns the primary offscreen HDR target (ViewportRenderTarget). Edit mode and Play-ejected bind the editor fly camera to that target; Play-possessed binds the player camera to the same target. Saved layouts with the old Game View tab are migrated to one Viewport tab.
WYSIWYG rendering: render_view.rs is the only editor system that mutates viewport post-FX. It calls game_hot::sync_viewport_camera_stack on the active camera (editor fly or possessed player), driven by ActiveCameraRenderProfile from project settings and volumes plus EffectiveRenderStack for requested/effective GI fallback. scene_view and play/session set HDR render targets and is_active only—they do not strip FX. Only one camera carries the stack at a time (mesh-view bind group limits). See ADR 0015 and ADR 0016.
The viewport uses render-to-texture: the active camera renders to an offscreen HDR target sized
to the dock panel (panel_physical_size()), then egui displays that texture. Cameras use
viewport = None on image targets so the scissor always matches the texture; panel size only drives
the image extent, not a sub-viewport on the image. Resizing mutates the existing Image asset so its
ID and Egui texture registration remain stable. The presentation layer owns at most one strong Egui
registration and removes it when the target is replaced or absent.
HDR invariant: Atmosphere, authored HDR, and effective Solari all require an Hdr
camera target; they must never render to the primary window swapchain
(Rgba8UnormSrgb). When the HDR offscreen target is missing, scene_view / play
deactivate cameras; render_view skips stack sync until a target exists.
Render-target changes and Solari/Forward fallback changes force a full camera-stack rebuild so
stale sky or post-FX pipelines cannot survive across texture-format transitions.
Viewport picking: LMB selection maps the egui panel UV through scene_view_ray and raycasts with MeshRayCast. Object pick runs in Last after transform_gizmo_bevy so gizmo drags take priority. GizmoOptions.viewport_rect is synced from the viewport panel rect so gizmo hit-testing matches render-to-texture layout. Actor root icon and visualizer proxy hits resolve to their source entity, so clicking an icon or light/spawn/collider marker selects the authored or runtime object, not the helper. Actor icon hits are ordered ahead of ordinary mesh hits when both are under the cursor. In Edit mode, selecting the runtime Player redirects to an authored PlayerSpawn (Player Start), creating one if needed.
Selection overlay: selection_outline.rs draws a shared unit-box shell per selected LevelObject (bounds from Primitive, physics collider, or mesh AABB), scaled to fit. Custom shader (assets/shaders/selection_outline.wgsl) uses rim-only fresnel with two cheap depth passes: Greater (soft cyan x-ray) and LessEqual (amber edge). Sync runs only when selection changes. Editor-only.
Clean game-view overlay: ViewportDisplayMode.clean_game_view hides editor chrome, grid, selection outlines, transform gizmos, actor root icons, visualizers, and selectable proxy meshes without changing camera ownership. G toggles this mode; Ctrl+G toggles the grid.
Actor root icons: actor_icons.rs registers the editor PNG icon set as internal binary image assets and spawns screen-stable, camera-facing editor-only billboards at LevelObject roots. A custom overlay material draws them unlit with always-pass depth so opaque meshes do not obstruct them. The billboards are RaytracingExcluded, hidden with clean game-view, filtered from hierarchy and scene serialization, and expose per-category viewport options for actors, meshes, lights, physics, gameplay, volumes, prefab/model anchors, warnings, and icon size.
Scene visualizers: visualizers.rs draws transient gizmo lines for authoring components without obvious meshes: colliders, lights, PlayerSpawn, prefab/model anchors, Project Sun, and runtime player/camera markers. Small editor-only proxy meshes make those markers selectable but are filtered from hierarchy and scene serialization. Player/start markers use the runtime capsule constants from sim::tuning.
World lighting: Project settings provide default ambient light and a runtime ProjectSun. A scene-authored directional LightDesc is treated as a scene sun override; when present, sync_project_sun_visibility hides/disables the project sun to avoid duplicate directional lighting.
Editor UI shell
The egui layer lives under crates/editor/src/ui/:
| Module | Role |
|---|---|
mod.rs |
EditorUiPlugin, UiState, dock host, tab titles |
theme.rs |
Unity-like dark pro palette + egui_dock chrome |
fonts.rs |
Phosphor icon font on PrimaryEguiContext |
widgets.rs |
Icon buttons, tool toggles, menu helpers |
asset_browser.rs |
Project/file browser with tree, breadcrumb, filters, list/grid modes, embedded model shelves, details/import settings |
menu.rs |
Top menu bar with shortcuts |
viewport_chrome.rs |
Unified viewport RTT + overlay toolbars |
actor_icons.rs |
Screen-stable actor root icons and selectable icon proxies |
visualizers.rs |
Scene visualizer settings, gizmo drawing, selectable proxies |
toolbar.rs |
Bottom dock icon toolbar |
status_bar.rs |
Fixed bottom status strip |
hierarchy.rs / asset_browser.rs / inspector.rs |
Panel bodies |
diagnostics.rs |
Detailed stats (Window → Diagnostics, Asset Browser footer) |
layout.rs |
Dock layout RON persistence in editor_prefs.ron |
The Viewport fills the tab (no inline help text). Gizmo/grid/play controls sit on a near-black semi-transparent overlay, and G hides editor-only overlays for a clean game view. The fixed dark status strip prioritizes SceneIo.status feedback and also shows scene path/dirty state, compact active-file collaboration status, mode, selection count, history, and the active operator. Long feedback is truncated responsively and available in a hover tooltip. Dock layout restores from ~/.config/bevy-fps/editor_prefs.ron on startup; View → Reset Layout restores defaults.
Follow-up (Phase 4b): inspector search and richer per-asset previews.
PIE state machine
stateDiagram-v2
[*] --> Editing
Editing --> Playing_Possessed: F5 enter play
Playing_Possessed --> Playing_Ejected: F8 eject
Playing_Ejected --> Playing_Possessed: F8 possess
Playing_Possessed --> Editing: F5 stop
Playing_Ejected --> Editing: F5 stop
| Resource | Editing | Playing possessed | Playing ejected |
|---|---|---|---|
SimEnabled |
false | true | true |
GameInputEnabled |
false | true | false |
Time<Physics> |
paused | running | running |
| Editor camera active | true | false | true |
| Player camera active | false | true | false |
On play enter/exit the editor snapshots player sim state only (transform, velocity, jump timers). Authored scene entities are not rolled back so in-play edits persist when stopping PIE.
Paused Play also pauses Time<Physics>; F7 advances that clock by exactly one 64 Hz sim tick.
Edit mode keeps the clock paused. viewport/physics_placement.rs may advance bounded 120 Hz
preview steps while selected bodies are temporarily dynamic and every other movable body is
temporarily static. Commit records only final local transforms through grouped history; cancel and
the Edit-to-Play boundary restore the complete runtime snapshot. See
physics-placement.md and ADR 0041.
Scene authoring flow
- User edits entities with
LevelObject+ reflectable components fromshared. EditorOnlyentities (cameras, helpers) are filtered from hierarchy and save; visualizer proxies can be picked but resolve back to source entities.- Player placement is stored as
PlayerSpawn; the runtimePlayeris never serialized. SceneIowrites native Bevy dynamic scenes underassets/levels/, routed throughscene::document::SceneDocument. The active tab is materialized in the ECS world; inactive tabs retain normalized authored snapshots, canonical clean checkpoints, and independent dirty/recovery state. Undo/redo reconciles stable authored content rather than transient ECS entity IDs.SceneCompositionresources reference validated project-relative subscenes by stable IDs, and runtime-onlyComposedSceneMemberownership prevents child actors from being flattened into the owner save. See multi-scene-composition.md, ADR 0026, and ADR 0042.- Hydration systems in
gamespawn meshes, colliders, lights, static mesh renderer parts, and dedicated skinned-model hierarchies at runtime. Level-object roots are initialized with Bevy visibility hierarchy components before generated children are attached, so authoringEditorVisibilityparticipates in normal visibility propagation.ActorIdandComponentInstanceIdare the persisted identities; raw BevyEntityIDs are runtime-only and must not become tool-facing document IDs. - Brush actors store authored convex geometry as
BrushDesc; hydration rebuilds generated mesh children from those faces and strips the children before save.
Model import (glTF + FBX)
- Import: Import Here and Import To... copy glTF/GLB/FBX into the chosen normal folder
beneath
assets/without type-directory routing. An FBX import parses every safe external texture reference, including siblingtextures/and.fbm/layouts, then stages and publishes the complete referenced bundle transactionally. Absolute non-sidecar paths, parent traversal, and dependencies that canonically escape the source folder are rejected before project files change; unreferenced sidecar contents are not copied. - Processing:
content_pipelineowns one scan/reconciliation path used by editor startup, manual refresh, the debounced watcher, validation, packaging, and the headless command. Browser selection changes never trigger registry scans. The processor generates normalized model manifests underassets/meshes/generated/. The manifests store stable part IDs, glTF/FBX mesh/material subasset labels, whether each part is skin-bound, source metadata, dependencies, and import settings. Model, texture, and audio registry records use byte length plus BLAKE3 source identity; model manifests use the same policy. Semantic RON comparison preserves exact existing bytes for an equivalent refresh, so checkout mtimes and formatting do not dirty project content. See ADR 0043. These artifacts are hidden from the Asset Browser catalog. - FBX dependencies: the local
bevy_ufbxresolver owns separator normalization, safe.fbm/rebasing, deduplication, and sandbox rejection. Static-mesh manifests record every declared external texture even when it is absent. Project validation reports one blocking finding for missing textures used by any per-slot Source selection, or one informational finding when every slot deliberately uses Project/Default. The runtime loader reads unique dependencies throughLoadContextand creates labeled images directly, so missing paths cannot fan out into repeated asset-server errors. See ADR 0044. - Browser subassets: model rows can expand into a content shelf backed by the generated
manifest. Unrigged mesh subassets place as independent
StaticMeshRendereractors. A skinned subasset places its owning source throughSkinnedMeshRenderer, retaining joints and inverse bind poses. Material subassets expose source defaults, and texture dependencies can be applied to selected actors. The thumbnail studio renders each generated model/subasset/material preview into its own render target before registering it in the cache so later thumbnails cannot overwrite earlier cache entries. Completed renders are asynchronously read back into typed, content-addressed 256 px PNG artifacts underassets/.thumbnails/v3/. Visual signatures include source/dependency bytes, resolved Material inputs, textures, shader files, and the studio version. Catalog refresh increments a validation epoch without discarding the last-good GPU registration; only a changed signature schedules replacement. Corrupt artifacts regenerate, publication is atomic, and retryable studio failures receive one bounded retry. FBX model and mesh previews use neutral direct geometry; source-material previews preflight external textures and retain one stable hover-visible failure reason instead of enqueueing a known-missing path. - Default placement: Renderable Asset (Auto) creates
ActorKind::StaticMesh + StaticMeshRendererfor unrigged, non-animated models andActorKind::SkinnedMesh + SkinnedMeshRendererwhen any part is skin-bound or the source contains animation. Static renderer slots reference importedEditorAssetRefvalues and exclude skinned parts. StaticSingleActormode stores all parts in one renderer;SourceHierarchycreates a root with child static mesh actors. Skinned placement always preserves the imported source hierarchy. - Collision: when collider generation is enabled, static placement adds
RigidBodyDescandColliderDesc::StaticMeshusing the same imported mesh refs. Skinned render surfaces do not receive automatic triangle colliders; author stable gameplay collision explicitly. - Scene-instance placement: asset details can switch placement to
SceneInstance; that keepsImportedModel + ModelReffor a generic source scene and hydrates toWorldAssetRoot. It is not the animation renderer. glTF usesGltfAssetLabel::Scene; FBX usesbevy_ufbx(path#SceneN).FbxPluginis registered inGamePlugin. - Limitations: Binary FBX only (not ASCII). Animated or skinned FBX remains blocked until its loader can construct the same hierarchy and clips as the glTF skinned path. See ADRs 0031 and 0033.
Undo / history
Structural edits (spawn, delete, transform, rename) go through EditorHistory command objects in history.rs.
User-facing actions route through the operator lifecycle in operators.rs: begin, preview,
commit, cancel, disabled reason, stable ID, and status text. Registered commands return typed
OperatorAction::Commit or OperatorAction::ContinuePreview, so immediate commands finish while
Draw Brush and CSG retain modal Preview ownership; validation failures terminate instead of leaving
stale status. Existing EditorHistory commands remain the undo payload. Group Selection is one
atomic command that remaps its transient group identity on every redo without reparenting unrelated
siblings. Lighting reset and Project Sun changes use one grouped light command. Asset placement,
assignment, exact material drops, brush tools, terrain strokes, physics placement, and the real
transform tracker all publish terminal lifecycle state. operators/test_harness.rs verifies semantic
projections, dirty-state preservation, stable IDs, helper cleanup, undo grouping, repeated undo/redo,
and automatic interruption rollback; see
operator-regression-testing.md.
PIE stop restores player simulation state only; authored LevelObject edits made during PIE remain in the scene.
Editor crate layout (ADR 0008)
crates/editor is a library + binary. EditorPluginGroup in lib.rs registers plugins in a fixed order. Domain modules:
| Directory | Responsibility |
|---|---|
scene/ |
Level I/O, schema plugin, viewport RTT setup |
viewport/ |
Camera, selection, gizmos, render views, panel settings |
play/ |
PIE session, editor mode, net editor profiles |
assets/ |
Catalog, asset DB, static mesh artifacts, prefab overrides |
project/ |
Project I/O, settings UI, authored-file safety, and observational collaboration status |
ext/ |
Command palette, BRP, game panel adapters |
history/ |
Undo commands + plugin |
ui/ |
egui dock shell |
Panel composition and debt budgets
ui/inspector.rs and ui/asset_browser/panel.rs are thin composition shells and are not homes for
new domain behavior. Inspector component cards register callbacks through
EditorComponentRegistry; the registry is the only visible built-in dispatcher. Shared Material
presentation lives under ui/materials/, while bounded thumbnail/footer/status presentation lives
in ui/asset_card.rs. Content transactions, material resolution, authored-document publication,
and derived-processing decisions remain outside egui rendering.
The actor Inspector owns one fixed identity header and one actor-keyed component scroll region. Its scrollbar is floating with stable width reservation, so vertical overflow never changes the width supplied to responsive component cards. Nested component UIs intersect their local bounds with the inherited clip and must not expand painting or input above the fixed actor header.
The enforceable budgets and frozen legacy ceilings live in .codex/architecture.toml;
scripts/codex/architecture_audit.py runs in selective verification and candidate CI. Both UI
shells satisfy the 500-nonblank-line limit. Any future exception requires a tracker, rationale,
maximum, extraction target, and expiry milestone.
See ADR 0048.
Shared scene schema lives in the Bevy-free crates/scene crate (stamp/migrate/validate on save,
load, and CI). game::schema_world_loader is the runtime adapter that validates and unwraps the
schema envelope before Bevy deserializes versioned prefab assets; both game and editor install it
through GamePlugin.
HDR / swapchain invariant
The unified viewport renders through an HDR offscreen target (render_target.rs). When the panel
target is missing (egui reflow), cameras must strip atmosphere/post-FX before targeting the
swapchain. User-authored HDR may be disabled, but atmosphere and effective Solari still force Hdr
on the active viewport camera because Bevy sky/Solari pipelines are not safe to carry across SDR
targets. render_view::sync_project_render_view runs in PostUpdate after RTT target assignment
and reapplies the project FX stack whenever the active panel target handle changes (startup or
exceptional replacement), not only on settings Apply. Ordinary resize preserves the handle and
updates the Image asset in place. Project Settings uses a draft buffer; Apply commits after
render targets resync.
The editor host and packaged game intentionally use different presentation priorities. The editor
uses AutoNoVsync, preferring Immediate or Mailbox where supported, because FIFO acquisition on
NVIDIA Wayland can block for about one second per frame throughout an interactive native-window
resize. Packaged game windows retain AutoVsync. This complements the stable offscreen-target
ownership rule; it does not replace it.
Bevy 0.19 removed the workspace's prior local bevy_render swapchain-timeout patch. The only active Bevy-adjacent patches are compatibility shims for ecosystem crates: transform-gizmo-bevy and bevy_ufbx under third_party/.
Extensibility (phase 6)
extensibility.rs provides EditorCommand, EditorPlugin, and a working command palette (Ctrl+P). Game crates register panel hooks via game::editor_ext::editor_panel_setups(); the editor binary wraps them without editing ui/mod.rs (ADR 0007, dogfood: FPS Demo panel, Ctrl+Shift+G).