Floptle — The Editor (`floptle-editor`)
Floptle — The Editor (floptle-editor)
The app that ties everything together: dockable panels, a live wgpu viewport, and a Scene View where you build geometry by drawing it. See ADR-0004, the VSCode workflow ADR-0011, and the panels it hosts:
./particles-vfx.md,./shaders.md,./materials-and-textures.md,./asset-pipeline.md,./physics.md, plus../ARCHITECTURE.md§2.
floptle-editor is one of two binaries (ARCHITECTURE §1).
Built on egui + egui_dock (ADR-0004) — pure
Rust, immediate-mode, rendered through the same wgpu device as the game, so editor
chrome and the live viewport coexist. It dogfoods the engine: building the editor
pressure-tests it.
1. Shell & theme
A docking shell (egui_dock): every panel is a tab you can split, stack, float,
or hide. Layouts are customizable and persisted per project; ship a few
presets (Scene, Shading, VFX).
┌──────────────────────────────────────────────────────────────┐
│ ☰ File Edit Scene Build ▶ Play ⏹ Stop [layout ▼] │
├───────────┬──────────────────────────────────┬───────────────┤
│ Hierarchy │ Scene View (wgpu) │ Inspector │
│ ▸ World │ ┌──────────────────────────┐ │ Transform │
│ ▾ Floor │ │ live viewport │ │ MeshRenderer │
│ • Cube │ │ gizmos · grid · pick │ │ Collider ☑ │
│ • Light │ └──────────────────────────┘ │ Material ▸ │
├───────────┴──────────────────────────────────┴───────────────┤
│ Asset Browser │ Console / Profiler │ Particle Timeline ▸ │
└──────────────────────────────────────────────────────────────┘
Theme: dark, somewhat high-contrast, retro / pixel-art-inspired but
organized, readable, and clear (VISION §6, ADR-0004).
Crisp 1px borders, a tight pixel font option, saturated accent colors on a deep
neutral base. Highly customizable: a theme editor exposes palette, accents,
spacing, font, and corner radius as a theme.ron users can tweak and share.
2. Panels
Each is an egui_dock tab over the shared EditorState:
-
Scene View — the live wgpu viewport; place, position, and build geometry (§3). The centerpiece.
-
Game View — the active camera's shot, exactly as a build shows it. It draws through one of two paths depending on where it is: fullscreen (double-click the tab) it renders straight to the window surface, docked it renders into an offscreen target sized to its own panel and is blitted there.
Those two must not look different, and keeping them the same is a standing hazard rather than a solved problem — the two gathers have drifted five times. Four of those were geometry a docked panel never drew; the fifth was subtler, and worth knowing about because it is the shape the next one will take: the offscreen path ran no opaque depth prepass, so contact shadows,
surfaceGap(shoreline foam, soft particles), screen-space reflections and lamp shadows all read an empty depth texture, each took its "nothing to report" branch, and drew nothing. Nothing was missing from the picture — four features were simply absent from it.tests/offscreen_draws_the_same_world.rsnow covers passes as well as gathers.The panel takes its whole tab body, margin included.
egui_dockinsets every body byspacing.window_margin, which is right for a panel of widgets and wrong for a view: the Game tab is transparent so the 3D can show through, so the inset left a band of the EDITOR's render of the scene visible all the way round the game — a border that moved when you orbited, because that is what it was. The viewport rect is expanded to the full body rather than merely painted over it, so the picture is rendered at the size it is shown at and the pointer still maps to where it looks like it does. -
Hierarchy — the node tree (the Node facade over the ECS, ADR-0005); reparent by drag, multi-select, rename.
-
Inspector — a modular component stack (Unity-style). The selection shows only the components it actually has — its Type (geometry / camera / light / …, mutually exclusive), Transform, and any Material / Rigidbody / Collider / Scripts, each indented under its header with a … overflow menu to copy / paste / remove it (paste targets another component of the same kind). A ➕ Add Component button opens a searchable menu (auto-focused for typing) to add the rest or switch the Type. Make an Empty node and build it up from nothing; physics edits apply live in Play.
-
Asset Browser — project assets; import-on-drop, drag-to-use, reimport (
./asset-pipeline.md§6). -
Particle Timeline — the video-editor-style VFX authoring surface (
./particles-vfx.md). -
Shader Graph — node-graph view of the shader IR with an Open in VSCode button (
./shaders.md§6). -
Material Editor — assign shader, tweak params, drop textures, set tiling, live preview (
./materials-and-textures.md§6). -
Console / Profiler — log output + the lightweight in-engine frame profiler (per-pass GPU timestamps, the raymarch step heatmap from
./renderer.md§6). "Lightweight" is measured, not assumed.
struct EditorState {
project: Project, // open project (paths, settings)
scene: SceneHandle, // active scene (nodes ⇄ ECS)
selection: Vec<NodeId>,
gizmo: GizmoMode, // Translate | Rotate | Scale
tool: SceneTool, // Select | DrawShape(ShapeKind)
snap: SnapSettings,
layout: DockLayout, // egui_dock tree, persisted
play: PlayState, // Editing | Playing | Paused
}
3. The Scene View — build geometry in-scene
The developer's exact vision: interact, place, position, AND build geometry right in the scene — no round-trip to Blender for blockouts.
Create menu
Right-click in the viewport:
Create new ▸
├─ Node (empty node — add components in the Inspector)
└─ Shape ▸
├─ Cube
├─ Sphere
├─ Cylinder
├─ Capsule
├─ Wedge
└─ Stairs (property: number of steps)
The creation gesture — draw the base, pull the height
Shapes are made by drawing, not dialog-filling:
1) pick a Shape 2) DRAW the base on the ground 3) EXPAND UP for height
(e.g. Cube) (drag a footprint rectangle) (drag the mouse up)
┌───────────┐ ┌───────────┐
cursor ✦ │ footprint │ │ █████████ │ ← height
└───────────┘ │ █████████ │
ground plane ───────────────────────────────────────┴───────────┴──
The footprint + height feed the shape's parametric generator — pure math from
the chosen ShapeKind and the drawn bounds produces the mesh. A Sphere's drawn
rectangle sets its radius bounds; Stairs lays steps treads across the footprint
rising to the pulled height; a Wedge slopes from one drawn edge.
enum ShapeKind {
Cube,
Sphere,
Cylinder,
Capsule,
Wedge,
Stairs { steps: u32 },
}
struct ShapeDef {
kind: ShapeKind,
bounds: Aabb, // footprint (x,z) + pulled height (y)
// regenerated whenever kind/bounds/params change
}
struct ShapeComponent { // lives on the node; mesh + SDF derive from it
def: ShapeDef,
collidable: bool, // → SDF collider (floptle-physics)
material: AssetRef,
}
Editable after creation
A ShapeComponent keeps its ShapeDef — it is parametric forever. Select the
shape and the Inspector shows its params (dimensions, steps, etc.); change one
and the mesh regenerates live. No baking into dead triangles. (Drag the
generated mesh into Blender only if you want to hand-sculpt beyond parametrics.)
Easy per-shape setup
Right in the Inspector / on drop, set the things the developer wants to be trivial:
- Collidable or not — a checkbox. On → the shape's SDF is registered in the
collision world (
./physics.md); these primitives double as SDF colliders (analytic Cube/Sphere/Capsule/Wedge/Stairs distance functions), sharing the exact path the fractals use (ARCHITECTURE §9b). - Material — assign/drop a material (
./materials-and-textures.md). - Texture + tiling — drag a texture on; auto-tiling (Repeat for good UVs,
Triplanar for these procedural shapes) so tiling needs no shader edit
(
./materials-and-textures.md§3, §6).
4. Gizmos, selection, snapping
- Gizmos — move/rotate/scale handles on the selection;
W/E/Rswitch modes; drag a handle to transform, hold to constrain to an axis/plane. - Selection / picking — click to pick (GPU id-buffer or ray-vs-AABB/SDF),
box-select,
Hierarchyand viewport selection stay in sync. - Snapping — grid snap for translation, angle snap for rotation, and a vertex/
surface snap so drawn footprints land cleanly.
SnapSettingsis configurable; hold a modifier to toggle snap on the fly.
Multi-select editing (v0.49)
The Inspector shows the last node picked and edits it — and hands whatever it changed to the rest of the selection. Only what changed travels: set roughness on twelve crates and each keeps its own colour, texture and everything else.
It works by DIFFERENCE, because an immediate-mode panel leaves no other record of
what it touched: multi_edit::Snapshot::take clones the primary's components
before the panel draws and apply compares them afterwards, field by field. Every
struct's diff destructures exhaustively — no .. — so adding a field to
Material or RigidBody fails to compile there until someone decides whether it
should travel. Three things deliberately do not: a Terrain/MapMesh id (two
nodes on one field is data loss, not an edit), a scene singleton like the Skybox
or PostProcess node, and a camera's active flag or render-target name (both are
identities). Transform diffs per axis, or typing a height would align nothing.
Editor::selected_group(e) is the other half: every ✚ / ✖ component button and
the script-drop path loop over it, so "add a rigid body" with twelve selected
means twelve rigid bodies.
Undo walks through what you selected (v0.60)
Picking a node is an undo step of its own. Ctrl+Z steps back through selections as well as edits, in the order they happened, and undoing an edit hands you back the node you were editing rather than dropping the selection on the floor — which is what made undo feel like it had gone one step too far.
Three rules make that safe:
- A selection step is history, not an edit. It does not mark the scene unsaved and it does not clear the redo stack. Undo a move, click another node to check something, press Ctrl+Y: the move comes back. A click must never make an edit unrecoverable.
- A selection that moved because of an edit belongs to that edit. Deleting a node clears the selection; that is one step, not two, so undoing the delete restores the node and re-selects it.
- Refs, not entities. A step stores node indices in
query::<Matter>()order — the order the scene serializer writes — becauserestore()respawns the world and anEntitywould dangle.Editor::begin_history_frameis the one place a frame's selection diff becomes a step; a change that resolves to the same refs (a scene load swapping the world) mints nothing.
The save indicator is always on screen (v0.60)
At the right end of the menu bar, wherever you are docked: a quiet ✔ saved, an
amber ● unsaved the moment an edit lands, and a green glow when a save
completes. Clicking it saves. Right-aligned in its own layout so nothing else on
the bar ever moves, and it is a chip rather than a toast because "did that
save?" is a question people ask later, when the toast is long gone.
It reads one flag, and that flag is aggregated over everything a save writes
— the scene .ron, terrain fields, vertex and texture paint, map geometry, the
texture palette. A sidecar that failed to write keeps the scene dirty and raises
the failure toast, because a permanent widget claiming "saved" while an hour of
sculpting sits only in memory is worse than no widget at all.
The rig in the viewport (v0.49)
A selected rigged mesh draws its skeleton in the Scene view — sticks between the joints, a dot on each, the selected one ringed — and clicking a joint selects it, so the transform gizmo poses it straight into the open clip. Only the selected mesh's rig is drawn: every rig at once buries the picture in white sticks, and the one being posed would be the hardest to find.
Picking is viz::pick_joint over the projected joints within BONE_PICK_PX,
nearest-to-camera winning a contested click, and it runs before the node pick —
the rig is only on screen for a mesh already selected and is drawn over the
model, so a click that lands on a joint meant the joint. Selecting a bone clears
the node selection (they are mutually exclusive, the same swap the Hierarchy
makes), and the overlay keys on the selection plus the bone's own mesh so the
rig survives that clear. Under Rig bones in the gizmo filter menu.
5. Open in VSCode
Scripts (.lua) and textual shaders (.flsl) open externally
(ADR-0011): the editor shells out to
code <projectRoot> --goto <file>:<line>
so VSCode opens (or reuses) the project as the workspace root and focuses the file/line. Triggered from the Inspector's script field, the Asset Browser right-click, and the Shader Graph's Open in VSCode button. The "external editor command" is configurable for non-VSCode users. No embedded code editor — that's scope creep against "lightweight."
6. Project & play management
enum PlayState { Editing, Playing, Paused }
- Projects — open/create a project (
./asset-pipeline.md§4);project.ronholds settings + the entry scene. - Scenes — create, save (RON), switch, and define transitions between scenes; an in-editor scene list.
- Play / Stop — ▶ Play runs the game in-editor using the same
floptle-runtimelogic (frame loop, scripts, physics, vfx); ⏹ Stop restores the edit-time scene. Pause + step for debugging. The Scene View becomes the game viewport while playing.
The depth prepass and the two render paths (v0.53.1)
Everything that reads the opaque depth prepass — contact shadows, surfaceGap
(shoreline foam, soft particles), screen-space reflections and lamp shadows —
does nothing at all without it, and does it silently. That makes the prepass the
single most drift-prone thing in the editor's two render paths, and it has now
drifted three times:
- the bind lived inside the
if rm_drawarm, so every one of those features worked in a scene with terrain and silently did nothing in a scene made of meshes; - the offscreen path ran no prepass at all, so a docked Game panel showed a different game from the same game fullscreen;
- the window path bound it only when
depth_prepass_withreported having ALLOCATED a target — which is permanently false once a frame draws two views, because the size-keyed cache then finds both slots already there. From that frame on the window drew with whatever the docked Game panel had bound last: another camera's depth buffer at another resolution, and that panel's stored picture. Reflections landed wrong or vanished, and any resize made them briefly correct again.
And the condition had drifted separately: the window's list was missing contact shadows, so a mesh scene with reflections and lamp shadows both off ran no prepass in the window while the Game panel ran one.
Two functions now, both shared:
wants_prepass(...)— the one answer to "does this view need it". Adding a feature that reads the prepass means adding a parameter, which is a compile error at both call sites rather than a silent omission at one.prepass_and_bind(...)— runs it and binds it, in one call. Running is not binding; the two are not separable here, so they are not separable at the call site either.
Raster::depth_prepass_with returns nothing now. It used to answer "was the
target reallocated?", which reads like "does the bind need refreshing?" and is a
different question. tests/offscreen_draws_the_same_world.rs requires both
functions by name in the offscreen path.
Nothing may be hidden that cannot be un-hidden (v0.54.0)
The Hierarchy folds every parent on the first draw after a scene loads, so an
opened scene reads as a list of top-level things rather than as everything at
once. Whether a row gets a disclosure triangle was a different question:
is_folder && has_kids, where is_folder meant Matter::Empty.
The two disagreed, and the disagreement was silent and permanent. A Reflection Probe parented to a Plane, a light parented to a mesh — anything under a node that is not an Empty — was folded shut by the first rule and had no triangle to open it under the second. Its children left the panel for good: still in the scene, still saved, still loaded, simply unreachable. Adding another child added to the pile. It reads as "the children I added just vanished", which is not a sentence anybody connects to the word hierarchy.
row_expandable(has_kids, has_bones) is the fix and the invariant: a node with
children IS a folder, whatever else it also is. fold_all_parents is now a free
function next to it, and the test that guards them asserts the property rather
than the behaviour — for every row the fold hides, row_expandable must answer
true. A row that can hide children must be able to reveal them.
The icon changed too. A non-folder with children used to be drawn with ⏷, the
expanded triangle glyph, in the icon column — so a collapsed unreachable
subtree announced itself as already open, which is why the panel looked correct
while being wrong.
7. Out of scope
This section has narrowed over time (texture painting and the embedded IDE both
landed after it was written, and the map-building suite —
../map-tools.md — added real in-editor
blockout modeling via the ▦ Map tool: draw a shape by dragging out its base
then its height, vertex/edge/face editing with normal-aligned move/rotate/scale
gizmos, extrude/inset/bridge/subdivide, a knife, per-face materials, and vertex
- texture painting that survives geometry edits). What still holds:
- Character/prop modeling — sculpt, retopo, precise UV unwrapping, subdiv
surface work stay in Blender, imported via glTF
(
./asset-pipeline.md). Map meshes are for LEVELS. - Animation rigging / weight painting — authored in Blender; we import
skins and play clips (
./animation.md).
If Blender does it better AND it isn't core to building a level in-engine, it doesn't belong in the editor.