![]() |
vkmEngine 1.0.0
A C++ game engine · vkmengine.com
|
The editor is an ImGui-based shell registered as a System on SystemStage::Editor. It owns the panel set, the workspace layout, the camera controller wiring, the undo/redo stack, and the scene I/O controller. Everything mutating goes through an EditorContext aggregate, so panels do not reach into each other.
The scene renders straight into the window's backbuffer, inside the viewport's rect. The editor states that rect once a frame on HostChrome - the editor-to-engine channel FrameContext carries as ctx.chrome - and RenderSystem, VisibilitySystem and UISystem each read it back from there. The Viewport window has no background, so the frame shows through it, and the overlays are drawn on top.
HostChrome carries the other thing an authoring host knows and the engine does not: whether the host's own panels, rather than the scene, own the pointer and the keyboard this frame. InputMap::update reads it: while the panels hold the keyboard every key binding reads as up, and while they hold the pointer every mouse-button binding, the wheel and the pointer's movement read as untouched - so the fly camera stops, typing into a field does not walk the character, and the game UI takes no click. The pointer's position still travels, and UISystem asks the chrome directly for the one thing it answers, hover. A runtime writes neither, and the defaults - the whole window, nobody holding anything - are what a shipped game wants.
What the viewport looks through is the third thing, and it is a frame product rather than a chrome statement: the editor's CameraControllerSystem publishes ctx.hostView at the Input stage, and VisibilitySystem renders through it instead of the scene's active camera - see The editor's view.
The editor runs on Dear ImGui's docking branch. The root window holds the menu bar, one dockspace and the status bar under it; every panel is a window of its own that docks into the dockspace, tabs beside another, or floats: Hierarchy, Viewport, Inspector, Material, Assets, Animation and Errors (their names are in chrome/dock_layout.h, because a name is what the ini files a window's place under). Where they are and how big is ImGui's: a splitter drag resizes, a tab drag moves, and nothing of the editor's holds a panel size. Multi-viewport is off - a panel dragged out of the window floats inside it rather than becoming a second OS window.
The default layout is the one above. buildDefaultLayout (chrome/dock_layout.cpp) cuts it with DockBuilder the first time - when the ini holds no dockspace - and again on Window > Reset Layout, which also shows every panel. Each side is given a fixed size in design pixels (HIERARCHY_WIDTH, INSPECTOR_WIDTH, BOTTOM_HEIGHT) rather than a share of the window, because the first frame can come before the window manager has sized the window; a window too small for it squeezes the side, never the viewport, and the side grows back when the window does.
A panel does not take the focus when it appears (NoFocusOnAppearing): a dock node shows its focused window's tab, so a group shown again would otherwise open on whichever of its windows began last rather than on the tab it was left on.
The viewport's overlays draw in a child that is exactly the scene's rect, so they place themselves the same way whether or not a tab bar sits above it: the tool strip down the left edge, the view bar top-right, the playbar centred between them and the navigation axes bottom-right. The strips are one design (beginOverlayStrip, ui/editor_widgets.h) with fixed sizes, so each is placed from the others' in the same frame; a viewport too small to hold the axes clear of the strips draws none.
What is shown is the editor's; where it is, ImGui's. The panel toggles - Window > Hierarchy, Inspector and Assets, Ctrl+1 to Ctrl+3 - show and hide the groups the default layout docks together: the Hierarchy; the Inspector and the Material editor; Assets, Animation and Errors. They are persisted per project in editor_settings.json. The windows have no close box, so a toggle is the one way to hide one and the one way back.
The layout is persisted per person, in ImGui's ini. io.IniFilename is imgui.ini in ProjectPaths::userRoot(), beside editor_user.json, so the layout follows the person from one project to the next (io.md). What is not a layout keeps out of it: the root window, the toast, the hidden-editor hint and every popup and dialog are NoSavedSettings, and io.LogFilename is null, so ImGui writes no log into the working directory either.
The editor decides it once a frame, before the ImGui frame opens, and every reader of a press takes that one answer (InputOwnership, input/input_ownership.h, carried as EditorContext::input): the capture declared on HostChrome, the shortcuts, the transform gizmo, the picker and the navigation axes. It is decided from the hovers the last frame drew - the frame a click was aimed in. "On the viewport" is the Viewport window's scene rect being hovered with nothing of ImGui's above it. The owner is the first row that holds:
| When | Owner | What answers a press |
|---|---|---|
| The cursor is hidden and grabbed, by the fly camera or a running game | Captured | Nothing of the editor's: ImGui is told it has no mouse, because an unseen cursor wanders over panels it is not pointing at. The viewport hover is held from before the grab (nextViewportHover) |
| A gizmo drag is held | GizmoHandle | The drag, wherever it has gone |
| The pointer is off the viewport | Panel | A panel, a popup, a floating window |
| Over the tool strip, the view bar, the playbar or the navigation axes | Overlay | The overlay |
| Over a gizmo handle | GizmoHandle | The handle |
| Over a blocking element of the game's UI | GameUI | Selected, unless the game has the viewport - then the game's |
| Anywhere else on the viewport | Scene | The picker |
A key is the editor's own UI's before it is anybody's binding: while a field is typed into, a menu, popup or dialog is open, or Preferences waits for a key to rebind, no keybind answers it (InputOwnership::keysHeldByUI) and the fly camera hears none of it.
A play session's viewport is the game's. While a session runs and the pointer is on the viewport, or the game has grabbed the cursor, the keyboard and the mouse buttons belong to the game: no editor shortcut acts, so Ctrl+1 picks a weapon rather than hiding the Hierarchy. Three things stay live - the editor-toggle key, the transport keys (Play / Stop, Ctrl+P, and Pause / Resume, Ctrl+Shift+P, in PlaybackBar::processKeys) and the playbar. The game still sees a chord's keys on the frame it is pressed, since its input is read at the Input stage; a pause discards the presses latched for the next tick. With the pointer on a panel the keyboard is the editor's, and the engine is told so. The editor's view stands down for the session (CameraControllerSystem::setActive): the viewport renders through the game's camera, and framing, the navigation axes, the gizmo and click-picking go with it (InputOwnership::clickPicks).
An ejected session is the editor's again. Eject / Return (F8, as live as the transport keys, or the camera button on the playbar) renders the running game through the editor's viewpoint, which flies, picks and edits as in Edit mode, while the game hears nothing - the chrome declares the host holds both devices (InputOwnership::hostHoldsPointer), and the fly camera samples its own map against what the panels alone hold (panelsHoldPointer). A cursor the game grabbed is freed on the way out, freed again whenever the game grabs it while ejected, and given back as the game last set it on the way in. Stop ends an ejection with the session (PlaySnapshot). The caption says PLAY MODE, EJECTED.
Every directory under src/editor/ names a responsibility, and a file belongs to exactly one of them:
| Directory | Holds |
|---|---|
| (root) | The state and the seams every other directory reads: EditorSystem, EditorState, EditorContext, EditorSettings, and editor_actions - the scene mutations the panels invoke rather than write |
| command/ | Undo: the Command base, the CommandStack, the concrete commands, EditScope/editStep/pushEdit, CommandHost::pushStep - which records an applied step and marks the scene unsaved, the pair every finished edit owes - and the prefab-override bookkeeping a command has to keep. It reaches the editor through CommandHost - six methods to implement, not EditorState - so the machinery that decides whether an author's work survives an undo can be run, and tested, without a window |
| session/ | What outlives a frame but not the editor: the open scene (SceneIOController), the open project (ProjectController), the Play/Stop snapshot and the ejection that lives and dies with it (PlaySnapshot) and the material preview cache |
| panels/ | One file per panel, each drawing one region and owning only its own widget state |
| overlays/ | What is drawn over the viewport: the transform gizmo - its maths, hit tests and drag state as well as its drawing - the tool strip and view bar, the playback bar, the axis navigation gizmo, and the wire primitives they share |
| chrome/ | What is drawn around the panels: the menu bar, the status bar, the dockspace's default layout, and the four dialogs the menus ask for - Import Model, Place Prefab, New Project and Open Project - which draw at the menu bar's scope so a closing menu does not take them with it |
| input/ | The keybind table and the shortcut dispatch that reads it, who has the input each frame (InputOwnership), and the editor's view (CameraControllerSystem) with its fly bindings and the framing that moves it (ViewFraming) |
| ui/ | The widget vocabulary every panel is written in: the prop* rows, the dialog scaffold, the style and theme, the icons, the asset picker, the audition transport |
| Panel | File | Description |
|---|---|---|
| Hierarchy | panels/hierarchy_panel.cpp | Entity tree of every entity, a Transform or not - a UI element and a logic-only Script entity are rows like any other; drag a node onto another to reparent (cycle-safe); context-menu Unparent |
| Inspector | panels/inspector_panel.cpp | The Inspector window, docked right. Component editor (an Animation card is a compact summary; keyframes and easing are the Animation tab's); Camera "Set as Main"; Hierarchy Unparent; prefab-instance overrides |
| Animation | panels/animation_panel.cpp | The Animation window. A transport, a three-lane timeline whose keyframe dots drag, and a table per track, all posing the entity as the playhead moves |
| Render Settings | panels/render_settings_panel.cpp | Render quality tuning: RenderSettings (debug view / tonemap / grid / MSAA, texture filtering, GTAO, bloom, shadows, screen-space reflections, probes) the visibility pass's culling thresholds among them; opened from Window > Render Settings |
| Material Editor | panels/material_editor_panel.cpp | The Material window, tabbed beside the Inspector. One material under a live preview: a Base card, a grid of map tiles, and a card per secondary lobe the material actually uses |
| Asset Browser | panels/asset_browser_panel.cpp | The Assets window. One library for every asset kind: a kind rail, a uniform tile grid, and one verb slot per kind (import / create). Materials and meshes render thumbnails, a texture is its thumbnail, sounds audition from the tile |
| Project Settings | panels/project_settings_panel.cpp | Floating window over what project.json records: name, entry scene, tick rate, engine version, and the seats and port the game is served with, plus a read-only list of what it replicates; opened from File > Settings... (under the Project heading) |
| Preferences | panels/preferences_panel.cpp | Floating editor/app settings window (Edit > Preferences, Ctrl+,) |
| Errors | panels/errors_panel.cpp | The Errors window: recoverable engine failures, newest first, filling the window. A function, not a class, because it remembers nothing |
| Viewport Overlay | overlays/viewport_overlay.cpp | The axis navigation gizmo, bottom-right of the viewport (click an axis to snap the editor's view) |
| Gizmo Overlay | overlays/gizmo_overlay.cpp | The transform gizmo's drawing and drag, and the viewport's click-to-pick |
| Gizmo Drawing | overlays/gizmo_overlay_draw.cpp | Every draw*Gizmos body, plus the selection outline: lights, cameras, probes, volumes, decals, emitters, audio, colliders, joints, skeletons, bounds |
| Viewport Toolbar | overlays/viewport_toolbar.cpp | The tool strip (tool, space, snap) down the viewport's left edge, and the view bar (shading, camera, Frame All, Focus) in its top-right corner |
| Playback Bar | overlays/playback_bar.cpp | Top-centre Play / Pause / Step / Stop transport for the simulation; Pause and Step also hold the mixer's voices; frames and captions the viewport while a session runs |
| Start Screen | panels/start_screen.cpp | What the editor shows when no project is open: New, Open, the projects opened before and copies of the examples this engine ships - and no workspace at all. See The start screen |
The pieces of chrome are not panels - they belong to EditorSystem and sit around the panel layout rather than in it:
| Chrome | File | Description |
|---|---|---|
| Menu Bar | chrome/editor_menu_bar.cpp | File / Edit / View / Window / Entity / Help, inside the root window's menu-bar scope. Holds no command state: it reads and writes EditorState and forwards scene-file intents to the SceneIOController, whose Save-As and Load dialogs it also draws so they stay in that scope |
| Status Bar | chrome/editor_status_bar.cpp | Bottom edge, stateless: the dirty dot, the selection's parent breadcrumb and position, and the build banner. Drawn last inside the root window, under the dockspace |
| Dock Layout | chrome/dock_layout.cpp | The workspace windows' names and the default layout DockBuilder cuts the dockspace into, on a first launch and on Window > Reset Layout. Holds no state: the layout itself is ImGui's |
Scene-global settings are cards in the World inspector (pick the World row at the top of the hierarchy): Environment (IBL / skybox), Procedural Sky, Volumetric Fog, and Physics (gravity, solver iterations - Scene::physics(), read by PhysicsSystem each fixed step). They are scene data, so they sit beside the components rather than in a settings window. Render Settings is the exception: it is quality tuning rather than world content, so it has its own window.
Both edit the properties of what is selected - the entity's components, and the material it draws with - so they are the same job at two depths, and a material editor wants height beside the viewport rather than the short, wide bottom row.
There is one way in, EditorState::openMaterial(handle), used by the Inspector's Mesh card, the Asset Browser's tile and its New verb. It sets the target, shows the Inspector's group and raises a one-frame revealMaterial request, which focuses the window wherever it is docked, because every caller is drawn before the window that answers it. It has no close box: it is shown and hidden with the Inspector (Ctrl+2).
Maps are a tile grid in the Asset Browser's grammar (tileFace, tileStrip). The six core slots always have a tile; the packed and secondary ones earn theirs by being bound, and until then sit behind one + tile.
The tab follows the selection, as the Inspector beside it does. A material chosen by hand - the chooser, an Asset Browser tile, New or Duplicate - is pinned in EditorState::materialEditorTarget and outranks the selection until another entity carrying a material of its own is picked, so a material nothing uses yet can be worked on without the next viewport click throwing it away.
Duplicate, Rename, New and Load PBR Folder sit behind one button on the identity row, beside a chip counting the entities that draw with this material (clicking it selects them) - the blast radius of every slider under it, since materials are shared by handle. Duplicate and Load PBR Folder hand the new material to the selected entity only when it draws with the one the tab shows.
Preferences, Render Settings and Project Settings open through beginToolWindow (ui/editor_dialogs.h): floating, never docked.
A multiplier on top of the display's own content scale, kept apart from it because only the content scale changes when the window moves to another monitor; the editor asks for it every frame, so a monitor change and a preference edit take one path. The theme is re-applied on a change, because ImGui's own metrics are absolute pixels; everything the editor draws itself follows through EditorStyle::px(). It is persisted per person in editor_user.json, read once in EditorSystem::init before any project opens.
Operates on the selected entity. Without an Animation the editor is drawn disabled behind one centred Add Animation Component button; a new animation defaults to a 5 s length so the timeline is usable at once.
The world does not step in Edit mode, so whatever moves the playhead - the tab or the Inspector card - poses the entity through AnimationSystem::applyAnimation, the function the system samples with. Moving the playhead is session state, but outside play the pose it writes is the authored Transform the scene saves, so it is recorded as a Scrub Animation step that merges over the gesture. In play the pose is the session's and is not recorded.
All four are ordinary editComponentCard sections, so they undo, record prefab overrides and appear in Add Component like every other component.
The hierarchy names an entity carrying an Animator a Rig, ahead of Mesh.
Ordinary editComponentCard sections. Auditioning needs the editor's audio device, which the cards reach through the EditorContext every card takes.
An AudioSource entity is a Sound in the hierarchy (2D Sound when not spatial); an AudioListener one is a Listener.
"Will the other player see this thing" is a question an author asks while looking at the thing, so the answer sits under the entity's name rather than in a window of its own: the wire slot and which of its components replicate, or a warning that it is inside a prefab instance and so says nothing on the wire. Both ends build such a subtree from the same prefab file into the same slots - the root replicates and the hierarchy carries the rest, which is right for anything posed the same way on both ends and wrong for a ragdoll (networking.md).
Drawn only for a project that replicates something, and only for an entity carrying some of it. Most entities are not on the wire, and a line saying so on every selection is one an author reads past within a day.
The Light card on the scene's key light is the case: with World > Procedural Sky on, SkySystem writes that light's rotation, colour and intensity from the Environment every frame, so a drag on Colour or Intensity there is undone before the next frame draws and the value the scene saves is the sky's. Offered like any other light's, those fields would make a working widget look broken and an authored colour vanish into the file.
So the card says so - one line naming the three fields and where they are authored - and disables the two it does not own, the same shape the Procedural Sky card uses for the fields that depend on its own toggle. Which light the sky is driving comes from findKeyLight, the engine's own answer, so the card and the system cannot disagree.
A component that cannot work is the editor's worst failure to report, because the card goes on rendering in full in front of a viewport where nothing happens. Where the engine holds both halves of the diagnosis, the card says it, in EditorStyle::DANGER for "this does nothing at all" and EditorStyle::WARNING for "this is not what you think it is" - on the card, where the mistake is made.
Every slider is sliderFloat / sliderInt and every combo begins with beginCombo (or is comboList), all in ui/editor_widgets.h; nothing calls ImGui's directly, so a look changed there is changed everywhere.
propDrag / propSlider / propDragInt / propDrag3 pass ImGuiSliderFlags_ClampOnInput (PROP_CLAMP in ui/editor_widgets.h). A Drag/Slider clamps the mouse to its bounds, but Ctrl+click turns it into a text field ImGui leaves unbounded, so without the flag every bound in the inspector would be advisory on the one path that can type an arbitrary number. ClampOnInput rather than AlwaysClamp, because AlwaysClamp also clamps a lo == hi == 0 range, which is how a row with no limit spells "unbounded". The Camera card holds its clip planes CLIP_PLANE_SEPARATION apart: equal planes divide by zero in the projection.
pickAsset offers a (none) row above the list, because an empty slot is a state the editor hands you (Create > Audio Source, Create > Decal) and round-trips through the scene file. The script behavior field and the bone picker draw the same row.
Every editor mutation goes through a Command that holds the "before" and the "after" state. The caller has already applied the change when it pushes the step, so pushing records it rather than performing it; undo and redo replay one side or the other. The stack is bounded (default 200 entries) and is cleared on scene load (entity IDs and component topology are not comparable across a swap).
The commands are in command/editor_commands.h, each documented there. Which one an edit takes:
CommandStack::push calls Command::tryMerge against the top of the undo stack first; that is where drag coalescing happens - but only while the gesture is still open. EditorSystem calls CommandStack::endGesture at the end of every frame in which no mouse button is held and no ImGui item is active, which seals the top of the stack. A gesture is a press, a motion and a release, and it is one undo step; identity alone cannot tell the micro-edits inside one drag apart from two separate drags of the same field, and without the seal the second drag would be swallowed by the first. A CompositeCommand with the same label inside one gesture is absorbed step by step, so a per-frame composite is one step per drag.
Command::addresses(slot) is the other half: the steps that name an entity say so, and CommandStack::forget drops exactly those. It is the narrow half of clear(), for an operation that outlives part of the history rather than all of it - see Save as Prefab below.
The editor edits a project, and without one it says so rather than pretending otherwise. EditorSystem::init keeps the answer ProjectController::open gives it: on failure the editor draws the start screen - New, Open, the recent list and the examples - and no workspace at all, because every path a workspace would compose would resolve against the engine's own directory. The engineRoot() fallback in ProjectPaths::projectRoot is for the other hosts, for which "beside the executable" is the project; the editor asks that a project.json exist, so vkm_editor shipped beside a project still opens it with no argument.
ProjectController::open (src/editor/session/project_controller.h) holds the one sequence that roots the editor in a project, and re-roots it in place - no restart. Order matters, because each step composes paths or reads code the one before it put in place:
A path that names a file rather than a directory still works - findProjectRoot walks up to the owning project.json, so dropping in a scene opens its project.
Command-line vkm_editor <project> runs the same sequence, from EditorSystem::init. Steps 1 and 4 run only while a project is open (EditorState::projectOpen), not merely after startup: the start screen's first open would otherwise write editor_settings.json into the engine root. ProjectController::OpenKind says only whether a path that is not a project is reported: silently at startup, with a toast when somebody asked. A session that never opens a project saves only the per-user settings when it quits. app/editor/main.cpp therefore opens nothing itself. See io.md.
Choosing a project is separate from opening one. OpenProjectDialog (chrome/open_project_dialog.h) draws a path field, File > Open Recent and the start screen the recent projects, and each hands what it picks to EditorState::requestSceneAction - the same guard New Scene and Open Scene go through, because opening a project throws the current scene away too.
One panel over the dimmed sky, with New Project and Open in its header and two tabs:
The menu bar holds File, Edit and Help alone until a project is open.
Quit, New Scene, Open Scene and Open Project all replace or destroy the world, so all four go through one entry point:
A caller says what it wants and nothing else. It does not ask whether the scene is dirty, does not park the target in a field of its own, and does not perform the action - which is what stops the next destructive action added from being the one that forgets to ask.
EditorSystem answers it, in resolveSceneAction, once per frame before the ImGui frame opens. The request carries a stage:
Performing before the ImGui frame is the point: all four rebuild the scene, which must not happen with a window still on the ImGui stack. Nothing in the editor opens a project or replaces a scene from inside its own draw.
File > Exit goes through the same entry point rather than raising the window's close flag, which the frame loop reads before the guard could ask. The titlebar close is intercepted at the top of the Editor stage, withdrawn, and re-raised by performSceneAction once the scene is safe.
SceneIOController owns the New / Open / Save / Save-As flow. It drives the file-picker modals and hands off to SceneSerializer::save / load (IO and serialization). After a successful load it clears the command stack and restores the editor's viewpoint for that scene (The editor's view). It ends any play session the outgoing scene was in, through the endPlaySession New Scene and Open Project share: a snapshot that outlived its scene would let Stop restore that dead world over the scene just opened, under its name. Every scene it opens or saves goes onto the recent-scenes list, which editor_settings.json persists per project, relative to its root.
The values a session turns on - the scene document, the session's whole asset list, where each prefab instance's entities stood and the dirty flag - are one object, PlaySnapshot (session/play_snapshot.h), meaningful only together and only between one capture and one restore. The controller decides when: when to cook, what to tell the author, and what a restore does to the selection and the undo stack. A snapshot depends on nothing from the editor, which is why the play/stop round trip is covered without a window, by vkm_engine_tests play.
Play hands the world to the simulation, so what the ECS holds during a session is the simulation's copy. Every panel stays live, so the editor is explicit about what an edit made there comes to:
SceneIOController::stopPlaySession is the whole of Stop, because the transport is not its only caller: answering Save to the unsaved-changes prompt ends the session first. SceneIOController::isPlaying() is the state itself, and everything that must not run against the simulation's copy asks it by name - not the clock, which is paused in Edit mode as well.
An open is the editor's clean break: it drops the undo stack, the selection, the material previews and any preview through one of its cameras. The asset graph is replaced by the swap too, so an asset the outgoing scene never named - a sound imported and not yet assigned to a source - has no name in the new document to be recreated from, and goes with the session that imported it.
New Scene and Open Project answer this the same way, through beginSceneReplace: it swaps a fresh ResourceManager in (keeping the font slot, which is engine-owned and never written to a scene) and counts the strays into the same toast. They throw a whole world away, so the reasoning above applies to them at least as strongly - and left in place, the outgoing graph would leak into the seed scene the next New Scene builds: buildDefaultScene takes its cube and default material through addGeneratedMesh and generateDefaultMaterial, which reuse whatever the graph already holds as mesh:generator:cube and material:default - so the new scene's cube would wear the outgoing session's default material, edits and all.
That is deliberately the opposite of what Stop does. Stop promises to put one session back exactly as Play found it - the undo history included. The snapshot is written from these entities at these slot indices and read back through createEntityAt, so every step on the stack still names what it named before Play, and edit / Play to check / Stop / undo the bad edit is a loop that works. The one slot the scene document does not carry is where a prefab instance's own entities stood, because it stores an instance as a reference; PlaySnapshot records that beside it (Prefab::instanceSlotsOf) and the restore builds each instance back into those slots. Without it the instance would take whatever was free - the slot of an entity deleted before Play - and undoing that delete would fail while redoing it destroyed the instance's entity. A panel is live in play mode, so an edit made during one addresses the world about to be discarded. captureSnapshot therefore parks the authored history (CommandStack::park) and the session takes its steps on an empty one: Ctrl+Z inside a session undoes the session's own edits and stops there, never reaching an authored step against the simulation's copy. Stop discards the session's history, says so with a toast if it held anything, and puts the parked one back (unpark). The parked history is dropped instead, with its own toast, when an instance could not go back into its slots (PlaySnapshot::restoredInPlace): a prefab changed on disk during the session builds other entities. The selection comes back the same way, by slot and whole; an open, whose entities are other ones, matches only the active entity, by name.
Beneath both, the steps that destroy by slot - redoing a delete, undoing a create or a placement, swapping a rebuilt subtree - first check the slot still holds the entity they were made against (EntitySnapshot::describes: its Name and prefab uid), and a step that reclaims a slot checks it is free. Either mismatch skips the step with an error toast rather than acting on another entity.
The asset graph is kept, not rebuilt. An undo step holds the asset it is to put back, as a handle, and a handle is a slot index into one ResourceManager. An open swaps in a graph built from the file it opened, which is why an open drops the stack; a Stop that did the same would leave the surviving steps addressing a manager that no longer exists, and since a rebuilt graph restarts at the same indices and generations those steps would resolve - to whatever landed in the slot instead. So restoreSnapshot reads the snapshot into the graph it was captured from (AssetPolicy::Merge), and puts each asset's contents back in place first - loadAssets with LoadMode::Reload, over the saveAllAssets document captureSnapshot recorded beside the scene - so a material edited during the session reverts like everything else while its handle goes on naming it (see IO and serialization).
An open makes no such promise - it is leaving that world for another one - and carrying the strays forward would grow the graph by a scene's worth of assets per open and cook every one of them into the project library at the next save.
What the open does owe the author is the fact. The ones that went are counted into a toast ("N unused import(s) stayed with the previous scene") and named one per line in the log, so re-importing them needs nothing but the message. Only assets the outgoing scene did not name are counted, and only those the new graph does not already hold - two scenes sharing a sound are not a loss.
Materials and meshes get a rendered preview (below). A texture does not: the tile draws the GPU mirror the renderer already samples, and the GPU minifies it. What it costs is residency: GLView::sync reaches a texture only through a material something draws, so a texture nothing binds has no mirror. EditorRenderHooks therefore has two calls:
The grid asks the first, and spends TEXTURE_UPLOADS_PER_FRAME calls to the second per frame, so a rail of 4K maps fills in over a few frames instead of stalling one.
Known: an sRGB texture's thumbnail draws darker than the file. ImGui samples a GL_SRGB8_ALPHA8 mirror - which linearises - and writes the result to a framebuffer that is not sRGB-encoded. Linear maps read exactly as authored. The Material Editor's slot thumbnails take the same path; correcting it needs a per-image ImGui draw callback, since the material and mesh thumbnails come out of the composite pass already display-encoded.
The Material Editor and the Asset Browser render previews through the backend's preview path (EditorRenderHooks::renderPreview, backed by GLPreview), a minimal forward + composite render into a small offscreen target, kept apart from the frame's pass list. Results are cached per asset (handle + version) with a per-frame bake budget (MaterialPreviewSession). The Material Editor's live view skips the budget but not the version gate: it re-renders when anything it shows changes. Each kind has its own key space (previewKey), and key 0 is the Material Editor's live pane.
A tile's context menu assigns it to the selected entity - a material or mesh to its Mesh, a sound to its AudioSource, a skeleton or a clip to its Animator
The browser is drawn into the Assets window EditorSystem begins. The panel is a KINDS[] table of AssetKind descriptors, each made by a builder that names its fields (a field left out is null), and the body that draws the rail, the tiles and the menus names an asset type only for what one kind alone does: a material tile opens the Material tab, and a sound tile carries the audition transport. A descriptor carries a label, a glyph, an Accent:: colour, its primary verb, and function pointers: enumerate, describe, preview, assign, rename, delete, and the walk that proves a delete is safe. The only place a C++ asset type appears is KindOps<Asset>.
All six of AssetType's kinds are in the table. A slot is null where the kind has nothing to put there: thumb for sounds, skeletons and clips (the tile draws the kind's glyph); assign for textures, which go into one of a material's slots no entity can name; rename for skeletons only - a skinned mesh and a clip carry the rig's name as a string, so renaming a rig would silently unbind every one of them. used is never null: every kind can say whether a delete is safe.
FontAsset is not a kind. AssetType leaves it out (ASSET_TYPE<FontAsset> is Count) because the library does not hold it: it is baked once at startup, referenced by name, and has no importer or entity slot.
The browser stays on ResourceManager rather than AssetLibrary::namesOf, because thumbnails and assignment need handles: the library is what a saved name resolves against, the manager is what is loaded.
A rail row wears its own kind's hue, and the rail pairs the kinds that are about each other; the hues are EditorStyle::Accent entries named in KINDS[], never a status colour. The tile keeps the same strip, and how solid it is says whether anything in the project uses the asset - the same used walk the delete guard asks, so the strip claims exactly what the guard does.
What entities reference is not walked here at all. It is AssetSerializer::collectAssetRefs (io/asset/asset_serializer.h), the walk a scene save builds its assets block from: the handles of every component that names an asset (the R rows of VKM_SCENE_COMPONENTS), the AssetRef fields a behavior authors, and names a load left unresolved. So a component or a behavior field that names an asset is in use exactly when a save would name it - a clip only a behavior plays is not offered for deletion. The browser resolves the names back through findByName and marks each kind's slots.
The scene is not the whole project, though. Half of what holds a reference is an asset, not an entity, and a walk that missed those would offer a Delete that breaks something far from where it was pressed. So three kinds walk the ResourceManager too:
| Kind | Also referenced by |
|---|---|
| Textures | all eleven TextureHandle slots on every MaterialAsset, drawn or not |
| Skeletons | MeshAsset::skeleton and AnimationClipAsset::skeleton, resolved back from their name strings |
| Clips | Animator::fadeFrom - session state no save names, but a fading clip is still being sampled |
The texture slots expand from VKM_MATERIAL_MAPS (MATERIAL_TEXTURE_SLOTS in the panel), the one list the serializer's field table and the backend's binding table expand from too, so a map added there is a reference here without an edit.
A tile is a square face, a name and a one-line detail, drawn by tileFace and tileStrip (ui/editor_widgets.h), which the Material tab's map grid shares. The face is a thumbnail where the kind has one and the kind's glyph where it does not - the rule editor_icons.h states for viewport markers - and a kind waiting on its bake draws the glyph faintly, so "no picture" and "picture coming" differ.
The name is clipped to one line in the middle (elidedLine), because these names share their starts and differ at their ends, with the full name in the tooltip. The detail has a short form for the tile and a verbose one for hover; the short form carries the one fact that separates assets of that kind:
A mesh says 926 tris . skinned - a skinned mesh's bind-pose thumbnail can look like nothing, and the tile says why - and a material its roughness, or its render path when that is not Opaque, which a preview sphere cannot show.
| Kind | Tile | Hover |
|---|---|---|
| Textures | 2048x2048 (or decoding...) | channels, usage, levels and size, or what the cooked cache says |
| Skeletons | 24 bones | and the root bone's name |
| Clips | 2.00s . 57 ch, or 2.00s . no rig | channels, the rig, the markers |
| Sounds | 0.50s . mono | rate and size |
no rig is the diagnostic the Clips rail exists for. A clip names its skeleton by string, and SkeletalAnimationSystem throws out a clip whose name does not answer - so it animates nothing while looking like one that works. The tile reports the rig it resolved, and the tooltip names the one that is missing. A multi-channel sound's hover says that positioning wants mono, because the mixer routes each channel to the output channel it was authored for.
Hovering a tile draws a border in the kind's hue, over a thumbnail and a glyph alike, rather than the theme's button colour, which paints behind what fills it. The pointer, not a selection, is what an operation acts on: F2 renames the tile under the cursor, as the Hierarchy renames the row under its own. Delete is not bound beside it - deleteEntity owns that key, and EditorShortcuts::process reads it before any panel draws. The context menu names its target before it offers anything.
Rename goes through renameDialog (ui/editor_dialogs.h), shared with the Material tab. ResourceManager::rename keeps names unique per type by suffixing a taken one, and the editor toasts it; the undo command records the name assigned, so redo repeats what happened.
Delete asks first, rather than offering an undo, because an asset cannot come back: re-adding one takes a new slot, so every handle that named the old one - those on the undo stack included - stays dead. RenameAssetCommand guards isAlive for that case. Deleting the clip that is auditioning stops the voice first, since AudioSystem holds its samples by shared_ptr.
The first control in the toolbar is always the chosen kind's primary action - New for materials, Import... for the rest - a value in the descriptor table, not a branch in the toolbar; the formats are in its tooltip. Search narrows the rail's counts and the grid together, and Escape empties the box rather than ImGui's default of reverting it.
Materials create; meshes, skeletons and clips raise EditorState::requestModelImport, because all three come out of one model import; textures and sounds each run an AssetPicker the panel owns, so their popup ids stay unique.
A texture is imported as colour (TextureUsage::Color). Data and normal maps arrive with their model, or through the Material Editor slot, which knows the usage. The import refuses a file the project already holds: loadTexture names the asset by its path, and ResourceManager::add under a taken name replaces that asset in place, under every material using it. A Material tab map tile binding a file already imported reuses that asset instead, since a filled slot is what was asked for; a file wanted as another usage is a second texture.
The transport sits on the sound tile's face, the Inspector card's auditionTransport: Play on every tile, and on the tile that is sounding a Pause and a Stop, with the detail line become the position slider. The face is submitted with SetNextItemAllowOverlap(), or the face button would take every click on the square.
One voice serves the panel, so a Play replaces whatever was sounding, and the panel remembers which clip the voice came from as a full handle, so a recycled slot cannot hand another clip a running transport. An audition does not stop when the pointer leaves the panel. Pause, Stop and the slider are lit off the device, not off a remembered id, which outlives the voice it named.
While the Sounds row is showing, the toolbar names the two ways a clip goes unheard with nothing here wrong: no audio device, and an AudioListener at volume 0. Importing a file the project already holds is answered with a toast and nothing else.
A generated mesh carries a deterministic name - mesh:generator:cube, mesh:generator:sphere:32:16 - read back out of its source descriptor (generatorName), and addGeneratedMesh and generateDefaultMaterial reuse what the graph holds under that name, so three cubes share one mesh:generator:cube and one material:default rather than three copies a scene would save. A material meant to be its own is made by Duplicate or New, which copy the default rather than renaming it: renaming it would take material:default out from under everything that resolves that name.
The editor's view is its own, so a scene with no camera opens, renders and edits like any other. A session in one has nothing to render from, and VisibilitySystem's warning goes to a log the editor cannot show, so ViewportOverlay::drawNoCameraNotice puts "No active camera - the game has nothing to render from" in the viewport, with the routes back - and F8, which ejects to the editor's view. Entity > Create > Camera activates the camera it creates when the scene has no active one; otherwise it creates it inactive, so a new camera does not take the game's view.
Viewport-space handles for translate, rotate and scale, axis-constrained; a fourth mode, Select, draws no handles so a click always selects. transform_gizmo.cpp holds the whole gizmo: manipulate(), its maths, the visuals, the pick tests and the drag state machine. A drag pushes one step when it ends: the dragged entity's editStep<Transform>, or one CompositeCommand when the selection has several roots.
Handles project through the near-plane test the viewport wires use (overlays/wire_draw.h): a point behind that plane has no screen position, so its handle is dropped from the draw and the hit test. A click-to-pick and every drag cast the ray viewportRay builds - the pointer unprojected at the near and the far plane, starting at the near one, not at the camera, whose position an orthographic camera's parallel rays never pass through. The gizmo hovers and starts a drag only where the frame's input ownership lets it (Who has the pointer and the keyboard).
Snapping (the tool strip's toggle, or Ctrl held) moves a drag in whole steps from where it began, along the handle held (overlays/gizmo_snap.h): only what the handle moves is snapped, and a value off the step keeps its offset - rounding the values would drag the other axes onto the grid and round an import at scale 0.01 to zero. A scale stops at one step rather than reach zero.
Tool keybinds, live only while the cursor is not captured: Q Select, W Move, E Rotate, R Scale, X Local / World, rebindable in Preferences > Keybinds.
Everything with no mesh of its own draws an authoring gizmo in gizmo_overlay_draw.cpp, so it can be found and placed: lights, cameras, reflection probes and irradiance volumes, decals, particle emitters, audio sources and listeners. Lights, cameras and the audio pair mark themselves with a glyph on a disc at one size, drawn by drawEntityMarker (ui/editor_icons.h), which also states the radius the picker answers within. The View menu adds Show Colliders, Show Bounds and Show Skeletons (posed bones from FrameContext::poses, with an axis triad per bone on the selected rig), off by default because they draw for every matching entity.
A scene stores a prefab instance as a reference, a pose and its overrides, and skips the subtree underneath it - so anything done inside an instance that is not an override is not written at all. The editor either makes the gesture mean what it looks like, or refuses it where it happens:
EditorActions::saveAsPrefab writes the selected subtree to the project's prefabs/ and turns it into an instance of what it wrote, so the scene stores it as a reference from then on. Two consequences to know before reaching for it:
Everything above is what an author sees. What follows is how the editor does it, for whoever maintains that half.
The editor looks at a scene from a viewpoint of its own - a position and a yaw and pitch (EditorViewpoint), held by CameraControllerSystem - not from an entity, for three reasons:
It reaches the frame through the engine's one authoring seam for it, FrameContext::hostView (visibility.md): the controller publishes a HostView at the Input stage and VisibilitySystem renders through it in place of the scene's active camera. The picker, the transform gizmo, the wire overlays and the navigation axes all project through ctx.visibility's matrices, so they follow whatever the frame rendered through without asking the controller.
What the viewport shows, from the camera box on the view bar at the top-right of the viewport:
| Mode | The frame renders through | Flies |
|---|---|---|
| Edit, Editor (the default) | the editor's viewpoint | yes |
| Edit, a scene camera picked from the box | that camera, active or not: a read-only preview, labelled (main) on the one the game starts on | no |
| Play | the game's camera - nothing is published | no; the box reads Game |
| Play, ejected (F8) | the editor's viewpoint, or a previewed camera | yes |
A preview ends when its camera stops being a camera with a pose, when the scene is replaced, and when Frame Selected, Frame All or a navigation axis moves the viewpoint - each of which shows the viewpoint they moved. A previewed camera, like the one the game renders through, draws no frustum or marker and takes no gizmo (Visibility::cameraEntity names it), because a drag measured in a view would move that view. Set as Main Camera in the inspector and the hierarchy is a separate thing: it decides the game's eye, which the editor's view is not.
Where it starts. editor_settings.json keeps the viewpoint per scene, by its path relative to the project, so the entries move with it, under sceneViews (EditorState::sceneViews); the open scene's is written into it as the scene is left - a New Scene, an open, a project switch, the editor closing - and read back when that scene is opened again. A scene with no entry is framed from its active camera, so the first look at a scene is the one its author set up for the game, or from (0, 2, 6) facing the origin when it has none. A Stop leaves the viewpoint where it was. An entry for a scene no longer on disk is dropped on load, as the recent scenes are.
CameraControllerSystem is a System on SystemStage::Input, before every reader of a view: a viewport that resolved matrices from last frame's pose would lag the pointer by a frame on every drag.
The editor registers it and no other host does. vkm_editor's own main() adds it after setupEngineApp returns, which makes fly controls an authoring tool rather than a switch a shipped game could reach: holding the right button puts the window in CursorMode::Disabled, and a game could not decline it, because BehaviorContext carries services and no systems.
Its actions are in a map of its own. The fly bindings (CameraActions) live in an InputMap the controller owns, sampled each frame against what the editor's panels hold (setCapture), never in the game's. That is what lets an ejected session fly while the game, told the host holds both devices, hears nothing - one map cannot answer "held" to the fly camera and "up" to the game for the same key - and it is why a game's map holds no Camera/ actions to take its command slots.
| Input | Action |
|---|---|
| Right mouse button (hold) | Enable look mode (cursor hidden) |
| Mouse movement (in look mode) | Rotate the view (yaw/pitch) |
| W / A / S / D | Move forward / left / back / right |
| Q / E | Move up / down |
| Shift (hold) | Speed boost |
| Scroll wheel (in look mode) | Dolly forward / back |
Its speeds and sensitivities (CameraControllerSystem::Settings) are the Preferences window's Camera tab, held in Preferences::camera and handed to the controller every frame, so they persist with the person rather than the project. Its bindings are input actions, not editor keybinds.